iT邦幫忙

2025 iThome 鐵人賽

DAY 13
0
Modern Web

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

Day 27:To-Do List 加入「搜尋」與「完成統計」

  • 分享至 

  • xImage
  •  

今天目標

  1. 新增即時搜尋(關鍵字過濾)功能 — 支援模糊比對。
  2. 顯示任務統計:總數、已完成、未完成。
  3. 在 UI 搜尋時顯示「無符合結果」提示。
  4. 使用 debounce 減少頻繁 DOM 操作(效能優化)。

完整範例(含前 26 天的功能:新增 / 刪除 / 完成 / 時間 / 清除全部 / 匯出 / 主題)

<!DOCTYPE html>
<html lang="zh-Hant">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width,initial-scale=1" />
  <title>Day27 - To-Do List:搜尋 + 統計</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; width:220px; 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; width:320px; justify-content:center;}
    .stats{margin-top:12px; font-size:0.95rem; opacity:0.9;}
    ul{list-style:none; padding:0; width:320px; 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;}
    .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%; } input[type="text"]{ width:60%; } }
  </style>
</head>
<body>
  <h2>📝 To-Do List(搜尋 + 統計)</h2>

  <div class="controls">
    <input id="taskInput" 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="搜尋任務(可模糊比對)..." />
    <span id="stats" class="stats">總數:0|已完成:0|未完成:0</span>
  </div>

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

  <script>
    /**************
     * 元素綁定
     **************/
    const taskInput = document.getElementById('taskInput');
    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 taskList = document.getElementById('taskList');
    const statsEl = document.getElementById('stats');
    const noResultEl = document.getElementById('noResult');

    /**************
     * 初始資料與設定
     **************/
    // tasks 格式: { text: string, done: boolean, time: string }
    let tasks = JSON.parse(localStorage.getItem('tasks')) || [];
    let theme = localStorage.getItem('theme') || 'light';
    document.body.classList.toggle('dark', theme === 'dark');

    /**************
     * 工具函式
     **************/
    // 更新 localStorage
    function updateStorage() {
      localStorage.setItem('tasks', JSON.stringify(tasks));
    }

    // 產生任務 DOM(只負責呈現傳入的 tasksToRender)
    function renderTasks(tasksToRender) {
      taskList.innerHTML = '';
      if (!tasksToRender.length) {
        noResultEl.style.display = 'block';
      } else {
        noResultEl.style.display = 'none';
        tasksToRender.forEach((task, index) => {
          const li = document.createElement('li');

          // top row: text + actions
          const topRow = document.createElement('div');
          topRow.className = 'top-row';

          const span = document.createElement('span');
          span.className = 'taskText' + (task.done ? ' done' : '');
          span.textContent = task.text;
          // 點擊文字切換完成狀態(注意:index 為整個 tasks 的 index,非 filtered index)
          span.addEventListener('click', () => toggleDoneByText(task));

          const actions = document.createElement('div');
          actions.className = 'actions';
          // 刪除按鈕(找到 tasks 中真實 index)
          const del = document.createElement('span');
          del.textContent = '✖';
          del.addEventListener('click', () => deleteTaskByText(task));
          actions.appendChild(del);

          topRow.appendChild(span);
          topRow.appendChild(actions);

          // time row
          const timeDiv = document.createElement('div');
          timeDiv.className = 'time';
          timeDiv.textContent = `建立時間:${task.time}`;

          li.appendChild(topRow);
          li.appendChild(timeDiv);
          taskList.appendChild(li);
        });
      }

      updateStats(); // 每次 render 都更新統計
    }

    // 更新統計(總數 / 已完成 / 未完成)
    function updateStats() {
      const total = tasks.length;
      const doneCount = tasks.filter(t => t.done).length;
      const undone = total - doneCount;
      statsEl.textContent = `總數:${total}|已完成:${doneCount}|未完成:${undone}`;
    }

    // 將搜尋關鍵字應用到 tasks,回傳篩選後陣列
    function filterTasksByKeyword(keyword) {
      const k = keyword.trim().toLowerCase();
      if (!k) return tasks.slice(); // 無關鍵字則回傳全部(淺拷貝)
      return tasks.filter(t => t.text.toLowerCase().includes(k));
    }

    // debounce:減少頻繁觸發(等待 delay 毫秒無新事件再執行)
    function debounce(fn, delay = 300) {
      let timer = null;
      return function(...args) {
        if (timer) clearTimeout(timer);
        timer = setTimeout(() => {
          fn.apply(this, args);
        }, delay);
      };
    }

    /**************
     * 動作函式:資料修改(會直接對 tasks 修改)
     * 注意:為了讓搜尋/刪除/切換在 filtered 狀態下一樣正確,我們以 `task` 物件本體為 key
     **************/
    // 新增任務
    addBtn.addEventListener('click', () => {
      const text = taskInput.value.trim();
      if (!text) return;
      const time = new Date().toLocaleString();
      tasks.push({ text, done: false, time });
      updateStorage();
      taskInput.value = '';
      // 直接 render 搜尋結果(若有搜尋字串,維持篩選)
      const filtered = filterTasksByKeyword(searchInput.value);
      renderTasks(filtered);
    });

    // 刪除任務(以物件比對)
    function deleteTaskByText(taskObj) {
      tasks = tasks.filter(t => t !== taskObj);
      updateStorage();
      const filtered = filterTasksByKeyword(searchInput.value);
      renderTasks(filtered);
    }

    // 切換完成(以物件比對)
    function toggleDoneByText(taskObj) {
      const idx = tasks.indexOf(taskObj);
      if (idx === -1) return;
      tasks[idx].done = !tasks[idx].done;
      updateStorage();
      const filtered = filterTasksByKeyword(searchInput.value);
      renderTasks(filtered);
    }

    // 清除全部
    clearBtn.addEventListener('click', () => {
      if (!tasks.length) return;
      if (confirm('確定要清除全部任務嗎?')) {
        tasks = [];
        updateStorage();
        renderTasks([]);
      }
    });

    // 匯出 JSON
    exportBtn.addEventListener('click', () => {
      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_list.json';
      a.click();
      URL.revokeObjectURL(url);
    });

    // 主題切換
    themeBtn.addEventListener('click', () => {
      document.body.classList.toggle('dark');
      theme = document.body.classList.contains('dark') ? 'dark' : 'light';
      localStorage.setItem('theme', theme);
    });

    /**************
     * 搜尋(使用 debounce)
     **************/
    const handleSearch = debounce(() => {
      const keyword = searchInput.value;
      const filtered = filterTasksByKeyword(keyword);
      renderTasks(filtered);
    }, 250);

    searchInput.addEventListener('input', handleSearch);

    /**************
     * 初始 render
     **************/
    renderTasks(tasks);
  </script>
</body>
</html>

小重點說明

  • 我把「以物件參照」方式做刪除 / 切換(filterindexOf),這樣搜尋結果畫面下的操作仍然可以正確對應原始tasks陣列。
  • debounce可以避免使用者快速輸入時頻繁操作 DOM,對效能友好。
  • 每次renderTasks都會updateStats(),因此統計永遠與畫面一致。

今日作業(挑戰)

  1. 把搜尋改成「標題 + 建立時間一起搜」,也就是關鍵字可以比對task.texttask.time
  2. 加上「排序功能」:按建立時間或完成狀態排序(新到舊 / 舊到新 / 未完成優先)。
  3. 把刪除改成「先顯示確認 Modal(自製)」,讓 UX 更友好。

上一篇
Day26:To-Do List 專業版(時間記錄 + 全部清除 + JSON 導出)
下一篇
Day 28:搜尋同時比對文字與建立時間 + 排序(New→Old / Old→New / 未完成優先)
系列文
Modern Web:從基礎、框架到前端學習28
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言