iT邦幫忙

2025 iThome 鐵人賽

DAY 13
0
Modern Web

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

Day 30:任務分類面板 + 標籤統計圖表(使用 Chart.js)

  • 分享至 

  • xImage
  •  

前言

恭喜完成 30 天系列!🎉
今天把整個 To-Do 專案收尾成一個更有洞察力的儀表板:標籤統計面板 + 圖表視覺化。透過圖表你可以快速看到各標籤任務數量分布,並可點擊圖表對任務做篩選(互動式)。我把完整可跑的單一 HTML 檔放在下面(含註解),你可以直接複製貼上執行。


功能重點

  • 讀取 localStorage 中的任務資料(格式沿用前面 Day29:tasks_v2)。
  • 計算每個標籤的任務數量(tag counts)。
  • 使用 Chart.js**畫出 長條圖(Bar chart)**顯示標籤統計。
  • 點選圖表欄位可把畫面任務範圍切換為該標籤(tag filter)。
  • 圖表下方顯示標籤清單(點擊也可篩選)。
  • 保留原有功能:新增 / 刪除 / 完成 / 搜尋 / 排序 / 匯出 / 主題。

完整程式(單檔,可直接執行)

<!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 專案升級成「可視化儀表板」,現在你可以:

  • 一眼看出哪些標籤最常出現(優先處理、或評估工作分布)
  • 透過圖表互動快速篩選任務(點圖或點標籤)
  • 保留所有 CRUD、搜尋、排序、匯出與主題功能

下一步的建議(你可以繼續做):

  • 把前端切成模組(index.html + app.js + chart.js)以利維護。
  • 用框架(Vue / React)重寫一次,練習組件化與 props/state 管理。
  • 把資料同步到後端(透過 API 存取),支援多裝置同步。
  • 加入更多圖表(如完成率圓餅圖、時間序列趨勢圖)。

回顧重點:

HTML & CSS 階段: 打好結構與版面基礎,理解語意化標籤與 RWD 精髓。

JavaScript 階段: 學會讓網頁動起來,掌握 DOM、事件與資料互動。

前端框架階段: 了解現代開發流程與組件化設計。

實戰與部署階段: 將作品上線、優化效能,學會開發者思維。


未來方向:

我會繼續精進前端框架(Vue / React)、UI/UX 設計,以及全端整合技術,目標成為能獨立開發專案的專業前端工程師。


最後的話:

這 30 天的挑戰讓我體會到「堅持」的力量。
感謝每一位在閱讀文章、留言鼓勵、一起學習的朋友。
前端的世界很大,這只是旅程的開始。

👋 謝謝大家陪我走完這 30 天,我們未來在更強的作品裡再見!


上一篇
Day 29:加入「標籤 (Tag)」與多關鍵字搜尋(AND / OR 模式)
系列文
Modern Web:從基礎、框架到前端學習30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言