今天目標
- 新增即時搜尋(關鍵字過濾)功能 — 支援模糊比對。
- 顯示任務統計:總數、已完成、未完成。
- 在 UI 搜尋時顯示「無符合結果」提示。
- 使用 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>
小重點說明
- 我把「以物件參照」方式做刪除 / 切換(
filter
與indexOf
),這樣搜尋結果畫面下的操作仍然可以正確對應原始tasks
陣列。
-
debounce
可以避免使用者快速輸入時頻繁操作 DOM,對效能友好。
- 每次
renderTasks
都會updateStats()
,因此統計永遠與畫面一致。
今日作業(挑戰)
- 把搜尋改成「標題 + 建立時間一起搜」,也就是關鍵字可以比對
task.text
與 task.time
。
- 加上「排序功能」:按建立時間或完成狀態排序(新到舊 / 舊到新 / 未完成優先)。
- 把刪除改成「先顯示確認 Modal(自製)」,讓 UX 更友好。