今天目標
- 每個任務可以新增多個標籤(tags)。
- 搜尋功能支援:
- 同時搜尋「任務文字」+「建立時間」+「標籤名稱」
- 使用多關鍵字搜尋(空白分隔)
- 可切換搜尋模式:
-
AND 模式:所有關鍵字都要符合
-
OR 模式:符合任一關鍵字即可
- 與排序、新增、刪除、完成、主題切換、localStorage 保持相容。
今日重點概念
標籤系統
{
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 完全相容。
今日挑戰
- 讓使用者能直接「點擊標籤」→ 自動加入搜尋框。
- 為每個標籤加顏色標記(例如用隨機色或依標籤名稱固定色)。
- 在介面上新增「標籤統計」區塊,顯示各標籤任務數量。