iT邦幫忙

2025 iThome 鐵人賽

DAY 13
0
Modern Web

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

Day 28:搜尋同時比對文字與建立時間 + 排序(New→Old / Old→New / 未完成優先)

  • 分享至 

  • xImage
  •  

今天目標

  1. 搜尋可以同時比對 task.text 與 task.time(模糊比對)。
  2. 新增排序選單:
    • 新到舊(Newest)
    • 舊到新(Oldest)
    • 未完成優先(Undone first)
  3. 確保搜尋 + 排序能合併運作(即先過濾再排序)。
  4. 保持先前所有功能(新增 / 刪除 / 完成 / 匯出 / 清除 / 主題 / 統計)。

完整範例(單一 HTML 檔,可直接貼到瀏覽器執行)

<!DOCTYPE html>
<html lang="zh-Hant">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width,initial-scale=1" />
  <title>Day28 - 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; width:220px; border-radius:8px; border:1px solid #ccc;}
    select{padding:8px 10px; border-radius:8px;}
    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:100%; 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;}
    .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%; } select{ width:30%; } }
  </style>
</head>
<body>
  <h2>📝 To-Do List(Day28)</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="搜尋:可比對任務文字或建立時間..." />
    <select id="sortSelect" title="排序方式">
      <option value="newest">排序:新到舊</option>
      <option value="oldest">排序:舊到新</option>
      <option value="undone">排序:未完成優先</option>
    </select>
    <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>
    /********************************************************
     * Day28 完整功能:
     * - 新增 / 刪除 / 完成 / 建立時間
     * - localStorage 持久化
     * - 搜尋同時比對 task.text 與 task.time(模糊比對)
     * - 排序(newest / oldest / undone-first)
     * - 匯出 JSON、清除全部、主題切換
     * - debounce 搜尋
     ********************************************************/

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

    // ---------- 初始資料與設定 ----------
    // tasks 格式: { id: string, text: string, done: boolean, time: ISOstring }
    // 使用 id 可避免物件相等判斷的錯誤(更穩健)
    let tasks = JSON.parse(localStorage.getItem('tasks')) || [];
    let theme = localStorage.getItem('theme') || 'light';
    document.body.classList.toggle('dark', theme === 'dark');

    // 儲存/讀取常用函式
    function saveTasks() {
      localStorage.setItem('tasks', JSON.stringify(tasks));
    }
    function generateId() {
      return 'id_' + Math.random().toString(36).slice(2, 9);
    }

    // ---------- 搜尋/排序/過濾邏輯 ----------
    // 搜尋比對:同時比對 text 跟 time(time 使用 toLocaleString)
    function matchesKeyword(task, keyword) {
      if (!keyword) return true;
      const k = keyword.trim().toLowerCase();
      const text = (task.text || '').toLowerCase();
      const timeStr = (new Date(task.time)).toLocaleString().toLowerCase();
      return text.includes(k) || timeStr.includes(k);
    }

    // 排序函式:回傳新的陣列(不改變原 tasks 陣列順序)
    function sortTasksArray(arr, mode) {
      const copy = arr.slice(); // 淺拷貝
      if (mode === 'newest') {
        copy.sort((a, b) => new Date(b.time) - new Date(a.time)); // 新到舊
      } else if (mode === 'oldest') {
        copy.sort((a, b) => new Date(a.time) - new Date(b.time)); // 舊到新
      } else if (mode === 'undone') {
        // 未完成優先:未完成排前面;同狀態則新到舊
        copy.sort((a, b) => {
          if (a.done === b.done) return new Date(b.time) - new Date(a.time);
          return (a.done ? 1 : -1); // done=true 放後面
        });
      }
      return copy;
    }

    // 取得「先過濾(搜尋)再排序」的結果
    function getFilteredAndSortedTasks(keyword, sortMode) {
      const filtered = tasks.filter(t => matchesKeyword(t, keyword));
      const sorted = sortTasksArray(filtered, sortMode);
      return sorted;
    }

    // ---------- 渲染與統計 ----------
    function renderTasksList(displayTasks) {
      taskList.innerHTML = '';
      if (displayTasks.length === 0) {
        noResultEl.style.display = 'block';
      } else {
        noResultEl.style.display = 'none';
        displayTasks.forEach(task => {
          const li = document.createElement('li');

          // top row: 文字 + 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;
          // 點擊切換完成(使用 task.id 定位)
          span.addEventListener('click', () => toggleDone(task.id));

          const actions = document.createElement('div');
          actions.className = 'actions';
          const del = document.createElement('span');
          del.textContent = '✖';
          del.title = '刪除';
          del.addEventListener('click', () => deleteTask(task.id));
          actions.appendChild(del);

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

          // time row(使用 toLocaleString 顯示給使用者)
          const timeDiv = document.createElement('div');
          timeDiv.className = 'time';
          timeDiv.textContent = `建立時間:${new Date(task.time).toLocaleString()}`;

          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}`;
    }

    // ---------- CRUD 操作(修改 tasks 並儲存) ----------
    function addTask(text) {
      const id = generateId();
      const time = new Date().toISOString(); // 儲存為 ISO 格式(標準)
      tasks.push({ id, text, done: false, time });
      saveTasks();
    }

    function deleteTask(id) {
      tasks = tasks.filter(t => t.id !== id);
      saveTasks();
      refreshRender(); // 重新過濾+排序後 render
    }

    function toggleDone(id) {
      const idx = tasks.findIndex(t => t.id === id);
      if (idx === -1) return;
      tasks[idx].done = !tasks[idx].done;
      saveTasks();
      refreshRender();
    }

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

    // ---------- 搜尋 + 排序 的結合渲染 ----------
    function refreshRender() {
      const keyword = searchInput.value;
      const sortMode = sortSelect.value;
      const display = getFilteredAndSortedTasks(keyword, sortMode);
      renderTasksList(display);
    }

    // ---------- debounce(減少頻繁操作) ----------
    function debounce(fn, delay = 250) {
      let timer = null;
      return function(...args) {
        if (timer) clearTimeout(timer);
        timer = setTimeout(() => {
          fn.apply(this, args);
        }, delay);
      };
    }

    // ---------- 事件綁定 ----------
    addBtn.addEventListener('click', () => {
      const text = taskInput.value.trim();
      if (!text) return;
      addTask(text);
      taskInput.value = '';
      refreshRender(); // 使用者剛新增後也要依搜尋/排序狀態顯示
    });

    // 支援 Enter 鍵新增
    taskInput.addEventListener('keydown', (e) => {
      if (e.key === 'Enter') addBtn.click();
    });

    clearBtn.addEventListener('click', clearAll);

    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(() => {
      refreshRender();
    }, 200);
    searchInput.addEventListener('input', handleSearch);

    // 排序選單改變時立即重新 render
    sortSelect.addEventListener('change', () => {
      refreshRender();
    });

    // ---------- 初始 render(頁面載入時) ----------
    refreshRender();
  </script>
</body>
</html>

功能說明

  • 搜尋會把使用者輸入的關鍵字小寫化,比對task.texttask.timetoLocaleString()的字串),因此你可以搜尋像「10/12」或「2025/10/12」或任務字詞都能找到。
  • 排序在「過濾結果」之後執行(先 filter 再 sort),確保搜尋與排序可以正確合併。
  • 每個 task 用id(字串)做唯一識別,比較穩健,不會受陣列索引變動影響。
  • 所有狀態(tasks、theme)依然儲存在localStorage,重新整理不會遺失。
  • 我加入debounce在搜尋上,避免頻繁 DOM 重繪(提升效能)。

今日作業 & 延伸挑戰

  1. 把搜尋方式改成「支援多關鍵字以空白分隔(AND 或 OR 選擇)」。
  2. 加入「標籤(tag)」欄位,並讓搜尋可以依標籤篩選。
  3. 把任務排序選項擴充為更多組合(例如:完成率排序、字母排序)。

上一篇
Day 27:To-Do List 加入「搜尋」與「完成統計」
系列文
Modern Web:從基礎、框架到前端學習28
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言