恭喜完成 30 天系列!🎉
今天把整個 To-Do 專案收尾成一個更有洞察力的儀表板:標籤統計面板 + 圖表視覺化。透過圖表你可以快速看到各標籤任務數量分布,並可點擊圖表對任務做篩選(互動式)。我把完整可跑的單一 HTML 檔放在下面(含註解),你可以直接複製貼上執行。
tasks_v2
)。<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Day30 - To-Do 儀表板:標籤統計 & Chart.js</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:24px; transition:0.25s;}
body.dark{background:var(--bg-dark); color:var(--text-dark);}
.wrap{max-width:1000px; width:100%; display:grid; grid-template-columns: 1fr 360px; gap:20px;}
@media(max-width:920px){ .wrap{ grid-template-columns: 1fr; } }
header{display:flex; justify-content:space-between; align-items:center; width:100%; margin-bottom:12px;}
h1{margin:0; font-size:20px;}
.controls{display:flex; gap:8px; align-items:center; margin-bottom:8px; flex-wrap:wrap;}
input[type="text"]{padding:8px 10px; border-radius:8px; border:1px solid #ccc;}
select{padding:8px 10px; border-radius:8px;}
button{padding:8px 10px; 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 10px; border-radius:8px;}
.panel{background:var(--card-light); border-radius:8px; padding:12px; box-shadow:0 2px 6px rgba(0,0,0,0.05);}
body.dark .panel{background:var(--card-dark);}
ul{list-style:none; padding:0; margin:8px 0; max-height:540px; overflow:auto;}
li{padding:8px; border-bottom:1px solid #eee; display:flex; justify-content:space-between; align-items:center;}
.tag{display:inline-block; background:var(--accent); color:#fff; padding:3px 8px; border-radius:12px; font-size:0.85rem; margin-right:6px;}
.chartWrap{height:260px;}
.tagList{display:flex; flex-wrap:wrap; gap:8px; margin-top:8px;}
.tagChip{padding:6px 10px; border-radius:12px; background:#eee; cursor:pointer; font-size:0.9rem;}
body.dark .tagChip{background:#333; color:var(--text-dark);}
.stats{font-size:0.95rem; margin-top:8px;}
</style>
</head>
<body>
<header>
<h1>Day30 - 任務分類面板 & 標籤統計</h1>
<div>
<button id="exportBtn" class="export">匯出 JSON</button>
<button id="themeBtn" class="theme">切換主題</button>
</div>
</header>
<div class="wrap">
<!-- 主內容:任務列表與控制 -->
<main>
<div class="panel">
<div class="controls">
<input id="taskInput" placeholder="輸入任務..." />
<input id="tagInput" placeholder="標籤(逗號分隔)" />
<button id="addBtn" class="add">新增</button>
<button id="clearBtn" class="clear">清除全部</button>
</div>
<div style="display:flex; gap:8px; align-items:center; margin-bottom:8px;">
<input id="searchInput" placeholder="搜尋 文字/時間/標籤(支援多關鍵字)" style="flex:1" />
<select id="modeSelect" title="搜尋模式">
<option value="and">AND</option>
<option value="or">OR</option>
</select>
<select id="sortSelect" title="排序">
<option value="newest">新到舊</option>
<option value="oldest">舊到新</option>
<option value="undone">未完成優先</option>
</select>
</div>
<div class="stats" id="stats">總數:0 | 已完成:0 | 未完成:0</div>
<ul id="taskList" aria-live="polite"></ul>
</div>
</main>
<!-- 側邊面板:圖表 + 標籤清單 -->
<aside>
<div class="panel">
<h3 style="margin-top:0;">標籤統計</h3>
<div class="chartWrap">
<canvas id="tagChart" width="400" height="240"></canvas>
</div>
<div style="margin-top:12px;">
<strong>快速標籤:</strong>
<div id="tagChips" class="tagList" aria-label="標籤清單"></div>
</div>
</div>
</aside>
</div>
<!-- Chart.js CDN -->
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
/*************************************************
* Day30: Tag Statistics + Chart
* 支援互動:點擊 chart bar 或 tag chip 會過濾任務列表
*************************************************/
// ---------- 元素綁定 ----------
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 modeSelect = document.getElementById('modeSelect');
const sortSelect = document.getElementById('sortSelect');
const taskList = document.getElementById('taskList');
const statsEl = document.getElementById('stats');
const tagChartCanvas = document.getElementById('tagChart');
const tagChips = document.getElementById('tagChips');
// ---------- 資料與 localStorage ----------
// 使用與前面相同的儲存 key(若你用過前面的範例,建議一致)
const STORAGE_KEY = 'tasks_v2';
let tasks = JSON.parse(localStorage.getItem(STORAGE_KEY)) || [];
let theme = localStorage.getItem('theme') || 'light';
document.body.classList.toggle('dark', theme === 'dark');
// ---------- 小工具函式 ----------
function saveTasks() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks));
}
function generateId() {
return 'id_' + Math.random().toString(36).slice(2, 9);
}
// 將輸入的標籤字串 (逗號分隔) 轉成陣列(去除空白)
function parseTags(input) {
if (!input) return [];
return input.split(',').map(t => t.trim()).filter(Boolean);
}
// 計算 tag counts(回傳物件:{ tagName: count })
function computeTagCounts(tasksArr) {
const counts = {};
tasksArr.forEach(t => {
if (!Array.isArray(t.tags)) return;
t.tags.forEach(tag => {
const key = tag.trim();
if (!key) return;
counts[key] = (counts[key] || 0) + 1;
});
});
return counts;
}
// 取得所有 tag 名稱排序(依數量 desc)
function getSortedTags(counts) {
return Object.keys(counts).sort((a,b)=>counts[b]-counts[a]);
}
// ---------- Chart.js 初始化 ----------
let tagChart = null;
function initChart(labels = [], data = []) {
// 若已經存在 chart,先 destroy
if (tagChart) tagChart.destroy();
tagChart = new Chart(tagChartCanvas.getContext('2d'), {
type: 'bar',
data: {
labels,
datasets: [{
label: '任務數量',
data,
// 不指定顏色,Chart.js 會自動分配;你也可以提供陣列顏色
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: { mode: 'index' }
},
onClick: (evt, elements) => {
// 點擊 bar 時,取得被點擊的 index,然後用 label 過濾任務
if (elements && elements.length) {
const index = elements[0].index;
const label = tagChart.data.labels[index];
// 把標籤設入搜尋欄,並執行篩選
searchInput.value = label;
refreshRender();
}
},
scales: {
y: { beginAtZero: true, precision:0 }
}
}
});
}
// ---------- 渲染任務列表 ----------
function renderTasks(list) {
taskList.innerHTML = '';
if (list.length === 0) {
taskList.innerHTML = '<li style="text-align:center;color:gray;">沒有符合的任務</li>';
updateStats();
return;
}
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.style.cursor = 'pointer';
span.onclick = () => { toggleDone(t.id); };
const actions = document.createElement('div');
actions.innerHTML = `<span style="cursor:pointer;color:red" title="刪除">✖</span>`;
actions.querySelector('span').onclick = ()=>{ deleteTask(t.id); };
top.append(span, actions);
li.appendChild(top);
// tags
const tagRow = document.createElement('div');
tagRow.className = 'tags';
(t.tags||[]).forEach(tag=>{
const sp = document.createElement('span');
sp.className = 'tag';
sp.textContent = tag;
sp.style.marginRight = '6px';
sp.style.cursor = 'pointer';
// 點擊 tag chip 會直接把該 tag 放到搜尋欄並篩選
sp.onclick = ()=> { searchInput.value = tag; refreshRender(); };
tagRow.appendChild(sp);
});
li.appendChild(tagRow);
// time
const time = document.createElement('div');
time.className = 'time';
time.textContent = new Date(t.time).toLocaleString();
li.appendChild(time);
taskList.appendChild(li);
});
updateStats();
}
// ---------- 統計、搜尋、排序、過濾 ----------
function updateStats() {
const total = tasks.length;
const done = tasks.filter(t=>t.done).length;
statsEl.textContent = `總數:${total} | 已完成:${done} | 未完成:${total - done}`;
}
// matchesKeyword:同時比對 text / time / tags,多關鍵字支援 AND / OR
function matchesKeyword(task, keyword, mode='and') {
if (!keyword) return true;
const keys = keyword.toLowerCase().split(/\s+/).filter(Boolean);
// target 字串包含所有 searchable fields
const searchTarget = [
(task.text||'').toLowerCase(),
(new Date(task.time)).toLocaleString().toLowerCase(),
...(task.tags||[]).map(tag=>tag.toLowerCase())
].join(' ');
if (mode === 'and') return keys.every(k => searchTarget.includes(k));
return keys.some(k => searchTarget.includes(k));
}
// 排序:newest / oldest / undone
function sortTasks(arr, mode='newest') {
const copy = arr.slice();
if (mode==='newest') return copy.sort((a,b)=>new Date(b.time)-new Date(a.time));
if (mode==='oldest') return copy.sort((a,b)=>new Date(a.time)-new Date(b.time));
if (mode==='undone') return copy.sort((a,b)=> (a.done===b.done)? new Date(b.time)-new Date(a.time) : (a.done?1:-1));
return copy;
}
function getFilteredSortedTasks() {
const kw = searchInput.value.trim();
const mode = modeSelect.value;
const sortMode = sortSelect.value;
const filtered = tasks.filter(t => matchesKeyword(t, kw, mode));
return sortTasks(filtered, sortMode);
}
// ---------- CRUD 操作 ----------
function addTask(text, tags=[]) {
const id = generateId();
tasks.push({ id, text, tags, done:false, time: new Date().toISOString() });
saveTasks();
refreshRender();
}
function deleteTask(id) {
tasks = tasks.filter(t=>t.id !== id);
saveTasks();
refreshRender();
}
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 (!confirm('確定要清除全部任務嗎?')) return;
tasks = [];
saveTasks();
refreshRender();
}
// ---------- Chart 與 Tag chip render ----------
function refreshTagPanel() {
// 計算標籤數量
const counts = computeTagCounts(tasks);
const labels = getSortedTags(counts);
const data = labels.map(l => counts[l]);
// 初始化或更新圖表
initChart(labels, data);
// render tag chips
tagChips.innerHTML = '';
labels.forEach(label=>{
const chip = document.createElement('div');
chip.className = 'tagChip';
chip.textContent = `${label} (${counts[label]})`;
chip.onclick = ()=>{ searchInput.value = label; refreshRender(); };
tagChips.appendChild(chip);
});
}
// ---------- Chart helper: 初始化/更新 ----------
function initChart(labels=[], data=[]) {
if (tagChart) tagChart.destroy();
tagChart = new Chart(tagChartCanvas.getContext('2d'), {
type: 'bar',
data: {
labels,
datasets: [{ label:'任務數量', data }]
},
options: {
responsive:true, maintainAspectRatio:false,
plugins:{ legend:{display:false} },
onClick: (evt, elements) => {
if (!elements.length) return;
const idx = elements[0].index;
const label = tagChart.data.labels[idx];
// 點擊 bar:以該標籤搜尋並渲染
searchInput.value = label;
refreshRender();
}
}
});
}
// ---------- UI 事件綁定 ----------
addBtn.addEventListener('click', ()=>{
const text = taskInput.value.trim();
if (!text) return;
const tags = parseTags(tagInput.value);
addTask(text, tags);
taskInput.value = ''; tagInput.value = '';
});
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_v30.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);
});
// 搜尋與選項變動時重新 render
[searchInput, modeSelect, sortSelect].forEach(el => el.addEventListener('input', ()=> refreshRender()));
// 若任務資料變動也要更新圖表(在 refreshRender 中會呼叫)
// ---------- refreshRender(主流程:過濾->排序->渲染->更新圖表) ----------
function refreshRender() {
const display = getFilteredSortedTasks();
renderTasks(display);
refreshTagPanel();
}
// ---------- 初始 render ----------
refreshRender();
</script>
</body>
</html>
Day 30 把你的 To-Do 專案升級成「可視化儀表板」,現在你可以:
下一步的建議(你可以繼續做):
HTML & CSS 階段: 打好結構與版面基礎,理解語意化標籤與 RWD 精髓。
JavaScript 階段: 學會讓網頁動起來,掌握 DOM、事件與資料互動。
前端框架階段: 了解現代開發流程與組件化設計。
實戰與部署階段: 將作品上線、優化效能,學會開發者思維。
我會繼續精進前端框架(Vue / React)、UI/UX 設計,以及全端整合技術,目標成為能獨立開發專案的專業前端工程師。
這 30 天的挑戰讓我體會到「堅持」的力量。
感謝每一位在閱讀文章、留言鼓勵、一起學習的朋友。
前端的世界很大,這只是旅程的開始。
👋 謝謝大家陪我走完這 30 天,我們未來在更強的作品裡再見!