iT邦幫忙

2025 iThome 鐵人賽

DAY 13
0
Modern Web

Modern Web:從基礎、框架到前端學習系列 第 29

Day 29:加入「標籤 (Tag)」與多關鍵字搜尋(AND / OR 模式)

  • 分享至 

  • xImage
  •  

今天目標

  1. 每個任務可以新增多個標籤(tags)
  2. 搜尋功能支援:
    • 同時搜尋「任務文字」+「建立時間」+「標籤名稱」
    • 使用多關鍵字搜尋(空白分隔)
    • 可切換搜尋模式:
      • AND 模式:所有關鍵字都要符合
      • OR 模式:符合任一關鍵字即可
  3. 與排序、新增、刪除、完成、主題切換、localStorage 保持相容。

今日重點概念

標籤系統

  • 每個任務可有多個 tag,例如:
{
  id: "id_abc123",
  text: "學 JavaScript",
  tags: ["前端", "學習"],
  done: false,
  time: "2025-10-12T15:30:00.000Z"
}
  • 用陣列儲存,可動態新增。

多關鍵字搜尋

  • 以空白分隔:例如搜尋「前端 學習」。
  • AND 模式 → 所有關鍵字都必須出現。
  • OR 模式 → 任意關鍵字出現即可。
  • 搜尋範圍包括:
    • 任務文字
    • 建立時間(本地格式字串)
    • 所有標籤文字

完整範例(單一 HTML 檔,可直接貼上執行)

<!DOCTYPE html>
<html lang="zh-Hant">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width,initial-scale=1" />
  <title>Day29 - To-Do:標籤 + 多關鍵字搜尋</title>
  <style>
    :root{
      --bg-light:#f5f7fa; --bg-dark:#111;
      --text-light:#333; --text-dark:#f4f4f4;
      --card-light:#fff; --card-dark:#222;
      --primary:#4caf50; --danger:crimson; --accent:#2196f3;
    }
    body{background:var(--bg-light); color:var(--text-light); font-family:Arial, Helvetica, sans-serif;
      display:flex; flex-direction:column; align-items:center; padding:36px; transition:0.25s;}
    body.dark{background:var(--bg-dark); color:var(--text-dark);}
    h2{margin-bottom:12px;}
    .controls{display:flex; gap:8px; align-items:center; margin-bottom:12px; flex-wrap:wrap;}
    input[type="text"]{padding:8px 10px; border-radius:8px; border:1px solid #ccc;}
    button{padding:8px 12px; border-radius:8px; border:none; color:#fff; cursor:pointer;}
    .add{background:var(--primary);} .clear{background:var(--danger);} .export{background:var(--accent);}
    .theme{background:none; color:var(--accent); border:2px solid var(--accent); padding:7px 12px; border-radius:8px;}
    .searchRow{display:flex; gap:8px; align-items:center; margin-top:8px; flex-wrap:wrap; justify-content:center;}
    .stats{margin-top:12px; font-size:0.95rem; opacity:0.9;}
    ul{list-style:none; padding:0; width:360px; margin-top:14px;}
    li{background:var(--card-light); border:1px solid #ddd; border-radius:8px; padding:10px; margin-bottom:10px;
      display:flex; flex-direction:column; transition:0.2s;}
    body.dark li{background:var(--card-dark);}
    .top-row{display:flex; justify-content:space-between; align-items:center;}
    .taskText{cursor:pointer;}
    .done{ text-decoration:line-through; opacity:0.6; }
    .time{font-size:0.85rem; opacity:0.7; margin-top:6px;}
    .tags{margin-top:6px; display:flex; flex-wrap:wrap; gap:6px;}
    .tag{background:var(--accent); color:#fff; padding:2px 6px; border-radius:6px; font-size:0.8rem;}
    .actions span{cursor:pointer; color:red; margin-left:8px;}
    .no-result{padding:20px; text-align:center; color:gray;}
    @media(max-width:520px){ ul, .searchRow{ width:90%; } }
  </style>
</head>
<body>
  <h2>📝 To-Do List(Day29)</h2>

  <div class="controls">
    <input id="taskInput" type="text" placeholder="輸入代辦事項..." />
    <input id="tagInput" type="text" placeholder="輸入標籤(以逗號分隔)..." />
    <button id="addBtn" class="add">新增</button>
    <button id="clearBtn" class="clear">清除全部</button>
    <button id="exportBtn" class="export">匯出 JSON</button>
    <button id="themeBtn" class="theme">切換主題</button>
  </div>

  <div class="searchRow">
    <input id="searchInput" type="text" placeholder="搜尋任務 / 標籤 / 時間(可多關鍵字)..." />
    <select id="modeSelect">
      <option value="and">AND 模式</option>
      <option value="or">OR 模式</option>
    </select>
    <select id="sortSelect">
      <option value="newest">新到舊</option>
      <option value="oldest">舊到新</option>
      <option value="undone">未完成優先</option>
    </select>
  </div>

  <ul id="taskList"></ul>
  <div id="noResult" class="no-result" style="display:none;">沒有符合的任務</div>
  <div id="stats" class="stats"></div>

  <script>
    const taskInput = document.getElementById('taskInput');
    const tagInput = document.getElementById('tagInput');
    const addBtn = document.getElementById('addBtn');
    const clearBtn = document.getElementById('clearBtn');
    const exportBtn = document.getElementById('exportBtn');
    const themeBtn = document.getElementById('themeBtn');
    const searchInput = document.getElementById('searchInput');
    const sortSelect = document.getElementById('sortSelect');
    const modeSelect = document.getElementById('modeSelect');
    const taskList = document.getElementById('taskList');
    const statsEl = document.getElementById('stats');
    const noResultEl = document.getElementById('noResult');

    let tasks = JSON.parse(localStorage.getItem('tasks_v2')) || [];
    let theme = localStorage.getItem('theme') || 'light';
    document.body.classList.toggle('dark', theme === 'dark');

    const saveTasks = () => localStorage.setItem('tasks_v2', JSON.stringify(tasks));

    function addTask(text, tags = []) {
      const task = {
        id: 'id_' + Math.random().toString(36).slice(2, 9),
        text, tags, done: false, time: new Date().toISOString()
      };
      tasks.push(task);
      saveTasks();
      refreshRender();
    }

    function deleteTask(id) {
      tasks = tasks.filter(t => t.id !== id);
      saveTasks();
      refreshRender();
    }

    function toggleDone(id) {
      const task = tasks.find(t => t.id === id);
      if (task) task.done = !task.done;
      saveTasks();
      refreshRender();
    }

    function clearAll() {
      if (!confirm('確定要清除全部任務嗎?')) return;
      tasks = [];
      saveTasks();
      refreshRender();
    }

    function getFilteredTasks(keyword, mode) {
      if (!keyword) return tasks;
      const keys = keyword.toLowerCase().split(/\s+/).filter(Boolean);
      return tasks.filter(t => {
        const target = [
          t.text.toLowerCase(),
          new Date(t.time).toLocaleString().toLowerCase(),
          ...(t.tags.map(tag => tag.toLowerCase()))
        ].join(' ');
        if (mode === 'and') return keys.every(k => target.includes(k));
        else return keys.some(k => target.includes(k));
      });
    }

    function sortTasks(arr, mode) {
      const c = arr.slice();
      if (mode === 'newest') return c.sort((a,b)=>new Date(b.time)-new Date(a.time));
      if (mode === 'oldest') return c.sort((a,b)=>new Date(a.time)-new Date(b.time));
      if (mode === 'undone') return c.sort((a,b)=> (a.done===b.done)?new Date(b.time)-new Date(a.time):(a.done?1:-1));
      return c;
    }

    function renderTasks(list) {
      taskList.innerHTML = '';
      if (list.length === 0) {
        noResultEl.style.display = 'block';
        statsEl.textContent = '';
        return;
      }
      noResultEl.style.display = 'none';
      list.forEach(t => {
        const li = document.createElement('li');
        const top = document.createElement('div');
        top.className = 'top-row';
        const span = document.createElement('span');
        span.textContent = t.text;
        span.className = 'taskText' + (t.done ? ' done' : '');
        span.onclick = ()=>toggleDone(t.id);
        const del = document.createElement('span');
        del.textContent = '✖';
        del.className = 'actions';
        del.onclick = ()=>deleteTask(t.id);
        top.append(span, del);
        li.appendChild(top);

        const tagDiv = document.createElement('div');
        tagDiv.className = 'tags';
        t.tags.forEach(tag=>{
          const tagEl = document.createElement('span');
          tagEl.className='tag';
          tagEl.textContent=tag;
          tagDiv.appendChild(tagEl);
        });
        li.appendChild(tagDiv);

        const time = document.createElement('div');
        time.className = 'time';
        time.textContent = new Date(t.time).toLocaleString();
        li.appendChild(time);

        taskList.appendChild(li);
      });
      const total = tasks.length;
      const done = tasks.filter(t=>t.done).length;
      statsEl.textContent = `總數:${total}|已完成:${done}|未完成:${total - done}`;
    }

    function refreshRender() {
      const keyword = searchInput.value.trim();
      const mode = modeSelect.value;
      const sortMode = sortSelect.value;
      const filtered = getFilteredTasks(keyword, mode);
      const sorted = sortTasks(filtered, sortMode);
      renderTasks(sorted);
    }

    addBtn.onclick = ()=>{
      const text = taskInput.value.trim();
      const tags = tagInput.value.split(',').map(t=>t.trim()).filter(Boolean);
      if (!text) return;
      addTask(text, tags);
      taskInput.value = '';
      tagInput.value = '';
    };

    clearBtn.onclick = clearAll;

    exportBtn.onclick = ()=>{
      const blob = new Blob([JSON.stringify(tasks, null, 2)], {type:'application/json'});
      const url = URL.createObjectURL(blob);
      const a = document.createElement('a');
      a.href = url; a.download = 'todo_with_tags.json'; a.click();
      URL.revokeObjectURL(url);
    };

    themeBtn.onclick = ()=>{
      document.body.classList.toggle('dark');
      theme = document.body.classList.contains('dark') ? 'dark' : 'light';
      localStorage.setItem('theme', theme);
    };

    [searchInput, sortSelect, modeSelect].forEach(el=>el.addEventListener('input', refreshRender));

    refreshRender();
  </script>
</body>
</html>

功能重點

  • 可為每個任務加上多個標籤(用逗號分隔)。
  • 搜尋支援:
    • 多關鍵字(空白分隔)
    • 模式切換(AND / OR)
    • 搜尋任務文字、建立時間、標籤內容
  • 排序與主題、匯出、localStorage 完全相容。

今日挑戰

  1. 讓使用者能直接「點擊標籤」→ 自動加入搜尋框。
  2. 為每個標籤加顏色標記(例如用隨機色或依標籤名稱固定色)。
  3. 在介面上新增「標籤統計」區塊,顯示各標籤任務數量。

上一篇
Day 28:搜尋同時比對文字與建立時間 + 排序(New→Old / Old→New / 未完成優先)
下一篇
Day 30:任務分類面板 + 標籤統計圖表(使用 Chart.js)
系列文
Modern Web:從基礎、框架到前端學習30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言