今天目標
- 搜尋可以同時比對 task.text 與 task.time(模糊比對)。
- 新增排序選單:
- 新到舊(Newest)
- 舊到新(Oldest)
- 未完成優先(Undone first)
- 確保搜尋 + 排序能合併運作(即先過濾再排序)。
- 保持先前所有功能(新增 / 刪除 / 完成 / 匯出 / 清除 / 主題 / 統計)。
完整範例(單一 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.text
與task.time
(toLocaleString()
的字串),因此你可以搜尋像「10/12」或「2025/10/12」或任務字詞都能找到。
- 排序在「過濾結果」之後執行(先 filter 再 sort),確保搜尋與排序可以正確合併。
- 每個 task 用
id
(字串)做唯一識別,比較穩健,不會受陣列索引變動影響。
- 所有狀態(tasks、theme)依然儲存在
localStorage
,重新整理不會遺失。
- 我加入
debounce
在搜尋上,避免頻繁 DOM 重繪(提升效能)。
今日作業 & 延伸挑戰
- 把搜尋方式改成「支援多關鍵字以空白分隔(AND 或 OR 選擇)」。
- 加入「標籤(tag)」欄位,並讓搜尋可以依標籤篩選。
- 把任務排序選項擴充為更多組合(例如:完成率排序、字母排序)。