Day 28 我們把「個人財務儀表板」從零想清楚:寫了 User Story、用 MoSCoW 劃定範疇、畫了 Wireframe、設計了 Transaction/Category/Account/Budget 四個 Entity、整理出「依目的挑圖表」的對照表,也規劃好了
server/+public/的資料夾骨架與技術棧。今天要把這份藍圖真正變成一個「打得開、可以互動」的網頁:整合折線圖、環狀圖、長條圖、極座標圖四種圖表於同一個儀表板,加入時間區間切換、類別篩選器、關鍵字搜尋、CSV 資料匯出等互動功能,並用 CSS Grid/Flexbox 完成響應式版面配置。本篇的完整可執行專案放在examples/example01/,建議一邊閱讀此內容、一邊對照原始碼動手跑起來。
本日範例程式碼:examples
Day 28 規劃出來的資料夾結構,今天就依樣把檔案生出來(examples/example01/ 內可以看到完整內容):
examples/example01/
├── server/ # Mock API 後端
│ ├── package.json
│ ├── server.js
│ └── data/
│ ├── categories.json # 8 個收支類別(含配色)
│ ├── accounts.json # 4 個帳戶(含 1 個信用卡負債帳戶)
│ ├── transactions.json # 近 6 個月、共 70 筆交易明細
│ ├── budgets.json # 近 3 個月、5 個支出類別的預算上限
│ └── generate-mock-data.js # 產生上面四份 JSON 的小工具(非必要,方便日後調整資料量)
└── public/ # 前端頁面
├── index.html
├── css/
│ └── style.css # Grid/Flexbox 響應式版面
└── js/
├── main.js # 進入點:載入資料、初始化圖表、綁定互動事件
├── api.js # 封裝 fetch,統一取得四份資料
├── aggregate.js # 篩選與彙整邏輯(groupBy/reduce)
├── chartSetup.js # 統一註冊 Chart.js、設定全域預設值
├── exportCsv.js # CSV 匯出(PapaParse.unparse + Blob 下載)
└── charts/
├── trendChart.js # 收支趨勢折線圖
├── categoryChart.js # 支出分類環狀圖
├── budgetChart.js # 預算 vs 實際長條圖
└── allocationChart.js # 資產配置極座標圖
cd examples/example01/server
npm install
npm start
看到終端機印出 Day29 個人財務儀表板 API 已啟動:http://localhost:3000 之後,直接用瀏覽器打開 http://localhost:3000 即可——server.js 裡用 express.static 把 public/ 資料夾也一起served 出來,不需要另外啟動第二個伺服器或安裝任何前端建置工具。
延續 Day 28 的技術棧決定:前端純粹是 HTML/CSS/JavaScript,index.html 用 <script type="module" src="js/main.js"></script> 載入進入點,chartSetup.js 內則直接用 Day 2 教過的 ESM CDN 寫法匯入 Chart.js:
// chartSetup.js(節錄)
import { Chart, registerables } from 'https://cdn.jsdelivr.net/npm/chart.js@4.5.1/+esm';
Chart.register(...registerables);
因為 main.js、aggregate.js、charts/trendChart.js 這些檔案彼此之間都是用相對路徑(例如 import { Chart } from '../chartSetup.js')互相 import,而不是 import 'chart.js' 這種需要打包工具或 import map 才能解析的「裸模組(bare specifier)」寫法,所以瀏覽器原生的 ES Module 機制就能正確載入整條 import 鏈,完全不需要額外安裝 Vite 或 Webpack。這也是 Day 28 說「不需要額外安裝打包工具,就能使用 import 語法做模組化拆分」的實際體現。
server.js// server.js(節錄自 examples/example01/server/server.js)
const path = require('path');
const express = require('express');
const cors = require('cors');
const app = express();
const PORT = 3000;
app.use(cors());
const categories = require('./data/categories.json');
const accounts = require('./data/accounts.json');
const transactions = require('./data/transactions.json');
const budgets = require('./data/budgets.json');
app.get('/api/categories', (req, res) => res.json(categories));
app.get('/api/accounts', (req, res) => res.json(accounts));
app.get('/api/transactions', (req, res) => res.json(transactions));
app.get('/api/budgets', (req, res) => res.json(budgets));
// 提供前端靜態檔案(index.html、css、js)
app.use(express.static(path.join(__dirname, '..', 'public')));
app.listen(PORT, () => {
console.log(`Day29 個人財務儀表板 API 已啟動:http://localhost:${PORT}`);
});
這支後端刻意設計得非常單純:四支 GET API,各自回傳一份完整的 JSON 陣列,沒有任何 ?month=、?categoryId= 這種篩選用的 query string(跟 Day 16 教的 RESTful 篩選寫法不同)。這是有意的教學選擇:
aggregate.js(彙整邏輯)明確放在 public/js/ 前端這一側,代表「篩選、彙整」本來就該由前端負責,後端只需要老實回傳資料。server/data/generate-mock-data.js 是一支輔助腳本(執行 node generate-mock-data.js 就能重新產生四份 JSON),用來產生近 6 個月、共 70 筆交易的資料。資料裡刻意安排了幾個 Day 28 提醒過的「邊界情況(Edge Case)」,方便驗證圖表與彙整邏輯是否夠健壯:
| 邊界情況 | 安排方式 | 用意 |
|---|---|---|
| 單月收入特別高 | 12 月除了薪資,還多一筆「年終獎金」45,000 元 | 測試趨勢折線圖在單一月份出現異常高峰時,Y 軸自動縮放是否合理 |
| 單筆大額支出 | 12 月的娛樂類別安排一筆「跨年小旅行」8,800 元 | 測試分類佔比環狀圖會不會被單一大額支出「撐爆」某個扇形 |
| 某類別特定月份無資料 | 醫療類別只出現在 11 月、1 月、2 月 | 測試彙整邏輯在「某月某類別金額為 0」時,圖表該怎麼呈現,而不是直接報錯 |
| 類別缺少預算資料 | budgets.json 刻意不含「其他」類別的預算 |
測試預算長條圖遇到「這個類別根本沒設定預算」時的處理方式 |
| 負餘額帳戶 | 信用卡帳戶(acc-credit)餘額為 -8600 |
測試資產配置圖遇到「負債」而非「資產」時該怎麼處理 |
💡 如果你想練習不同的資料規模,可以直接修改
generate-mock-data.js裡的邏輯(例如把months陣列延長成 12 個月),重新執行一次腳本即可覆蓋四份 JSON 檔案,不需要手動一筆一筆修改資料。
index.html 與響應式版面index.html 依照 Day 28 的 Wireframe,由上到下分成四大區塊:
<div class="dashboard">
<header class="dashboard__header"> ... 標題 + 篩選器工具列 ... </header>
<p id="statusText">資料載入中...</p>
<section class="summary-cards"> ... 收入/支出/結餘/較前期 四張卡片 ... </section>
<section class="charts-grid">
<div class="chart-box chart-box--wide"><canvas id="trendChart"></canvas></div>
<div class="chart-box"><canvas id="categoryChart"></canvas></div>
<div class="chart-box chart-box--wide"><canvas id="budgetChart"></canvas></div>
<div class="chart-box"><canvas id="allocationChart"></canvas></div>
</section>
<section class="transactions"> ... 交易明細表格 ... </section>
</div>
每個 <canvas> 都被包在一個獨立的 .chart-container 裡(完整結構請見 examples/example01/public/index.html),這正是 Day 20 教過的「dedicated container」規則——canvas 不能跟其他元素共用同一個容器,也不能省略這層包裝直接把 canvas 放在版面裡。
/* style.css(節錄) */
.summary-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
}
.charts-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.chart-box--wide {
grid-column: span 2; /* 趨勢圖、預算比較圖跨兩欄,畫面比例比較適合折線圖/長條圖 */
}
.chart-box {
min-width: 0; /* Day20 提過的陷阱:沒有這行,圖表可能把 Grid 版面撐爆 */
}
.chart-container {
position: relative; /* dedicated container 規則:必須是 relative,且裡面只能放一個 canvas */
height: 280px; /* 明確的高度,搭配 maintainAspectRatio:false 讓圖表確實填滿 */
}
.summary-cards 用 repeat(auto-fit, minmax(200px, 1fr)):瀏覽器會自動計算「這一行最多能塞幾張卡片」,寬螢幕上四張卡片並排,螢幕變窄時自動換行,不需要手動寫 Media Query 控制卡片數量。.charts-grid 固定兩欄,寬圖表(chart-box--wide)用 grid-column: span 2 跨滿整行,這對應 Day 28 Wireframe 「趨勢圖/預算比較圖比較寬、環狀圖/極座標圖比較窄」的版面安排。.chart-container 明確給高度(而不是用 aspect-ratio 讓圖表自己算),是因為 Dashboard 這種版面通常希望「每張卡片高度整齊劃一」,所以四張圖表全部採用 Day 20 建議的 maintainAspectRatio: false 模式,讓圖表完全貼合容器高度。/* 平板:寬圖表不再跨欄,改成單欄堆疊 */
@media (max-width: 900px) {
.charts-grid { grid-template-columns: 1fr; }
.chart-box--wide { grid-column: span 1; }
}
/* 手機:篩選器、卡片全部改成單欄堆疊 */
@media (max-width: 600px) {
.toolbar { flex-direction: column; align-items: stretch; }
.summary-cards { grid-template-columns: 1fr 1fr; }
.chart-container { height: 220px; }
}
交易明細表格則不強制擠壓欄位,而是讓外層 .transactions__table-wrapper 設定 overflow-x: auto,手機瀏覽時改用橫向捲動閱讀表格,避免欄位被壓縮到無法辨識金額與備註內容。
aggregate.js這是今天最重要的一支模組,延續 Day 28「Aggregation」章節的設計理念——彙整邏輯完全不 import Chart.js,只單純把「原始交易明細」轉換成「畫圖需要的乾淨資料物件」,方便獨立閱讀、測試,也方便未來替換成別的圖表庫。
Mock Data 的日期是固定寫死的(2025-10 ~ 2026-03),如果直接拿系統的 new Date() 當作「現在」,資料會顯示「本月完全沒有交易」而顯得很奇怪。所以 getRangeMonths() 改成從資料本身找出最新出現的月份,再往前推算範圍:
export function getRangeMonths(transactions, range) {
const allMonths = getSortedMonths(transactions); // 由舊到新排序的月份清單
const rangeSize = range === 'month' ? 1 : range === '3m' ? 3 : 6;
const currentMonths = allMonths.slice(-rangeSize);
const currentStartIndex = Math.max(0, allMonths.length - rangeSize);
const previousMonths = allMonths.slice(Math.max(0, currentStartIndex - rangeSize), currentStartIndex);
return { currentMonths, previousMonths, allMonths };
}
這裡同時算出 previousMonths(往前推算「同樣長度的前一個區間」),是為了讓總覽卡片能算出「支出較前期成長了幾 %」——例如選「近 3 個月」時,currentMonths 是最近 3 個月,previousMonths 就是再往前的 3 個月,兩者互相比較。
filterByRange vs filterTransactions/** 只依照時間區間篩選(不篩類別、不篩關鍵字),供圖表/卡片使用 */
export function filterByRange(transactions, range) {
const { currentMonths } = getRangeMonths(transactions, range);
const monthSet = new Set(currentMonths);
return transactions.filter((tx) => monthSet.has(tx.date.slice(0, 7)));
}
/** 完整篩選:時間區間 + 類別 + 關鍵字,供「交易明細表格」與「CSV 匯出」使用 */
export function filterTransactions(transactions, { range, categoryId, keyword }) {
const byRange = filterByRange(transactions, range);
const kw = (keyword || '').trim().toLowerCase();
return byRange.filter((tx) => {
if (categoryId && categoryId !== 'all' && tx.categoryId !== categoryId) return false;
if (kw && !tx.note.toLowerCase().includes(kw)) return false;
return true;
});
}
刻意拆成兩層是有原因的:「支出分類環狀圖」「預算比較長條圖」只需要反映時間區間,如果使用者在下拉選單選了「只看餐飲類別」,這兩張圖表依然應該顯示「所有類別的整體佔比」,而不是被篩到只剩一種類別(不然環狀圖就失去「佔比比較」的意義了)。真正需要「類別 + 關鍵字」雙重篩選的,只有交易明細表格與CSV 匯出這兩個「看明細」的功能。這個設計決策也回應了 Day 28 的提醒:「先想清楚每張圖表要回答什麼問題,再決定資料要怎麼篩選」。
/** 收支趨勢折線圖:固定呈現資料中「全部月份」,作為儀表板的整體脈絡 */
export function aggregateMonthlyTrend(transactions) {
const months = getSortedMonths(transactions);
const grouped = transactions.reduce((acc, tx) => {
const month = tx.date.slice(0, 7);
if (!acc[month]) acc[month] = { income: 0, expense: 0 };
acc[month][tx.type] += tx.amount;
return acc;
}, {});
return {
labels: months,
incomeData: months.map((m) => grouped[m]?.income ?? 0),
expenseData: months.map((m) => grouped[m]?.expense ?? 0)
};
}
這張折線圖是唯一一個不隨時間區間篩選器改變的圖表——不管使用者選「本月」還是「近 3 個月」,趨勢圖永遠顯示全部 6 個月的走勢。這是刻意的版面設計:讓使用者無論怎麼篩選其他區塊,都能隨時對照「這段時間在長期趨勢中的位置」,是儀表板設計中常見的「一張全局圖 + 多張細節圖」手法。(如果你希望趨勢圖也跟著篩選器變動,動手練習第 1 題會請你試著修改看看,並體會這樣做在「只選本月」時會遇到什麼 UX 上的取捨。)
nullexport function aggregateBudgetComparison(transactions, budgets, categories, months) {
const monthSet = new Set(months);
const expenseCategories = categories.filter((c) => c.type === 'expense');
const actualByCategory = transactions
.filter((tx) => tx.type === 'expense' && monthSet.has(tx.date.slice(0, 7)))
.reduce((acc, tx) => { acc[tx.categoryId] = (acc[tx.categoryId] || 0) + tx.amount; return acc; }, {});
const budgetByCategory = budgets
.filter((b) => monthSet.has(b.month))
.reduce((acc, b) => { acc[b.categoryId] = (acc[b.categoryId] || 0) + b.limitAmount; return acc; }, {});
return {
labels: expenseCategories.map((c) => c.name),
categoryIds: expenseCategories.map((c) => c.id),
budgetData: expenseCategories.map((c) => (c.id in budgetByCategory ? budgetByCategory[c.id] : null)),
actualData: expenseCategories.map((c) => actualByCategory[c.id] ?? 0),
hasNoBudget: expenseCategories.map((c) => !(c.id in budgetByCategory))
};
}
留意 budgetData 用的是 c.id in budgetByCategory ? ... : null,而不是預設成 0——這是有意的區分:「預算是 0 元」跟「根本沒有設定預算」是兩件不一樣的事。「其他」類別因為 Mock Data 沒給預算,會被彙整成 null;Chart.js 的長條圖遇到 null 資料點時,會直接跳過不畫這根長條(形成一個空隙),而不是誤導使用者「這個類別的預算是 0 元、隨便花一點就超支」。
export function aggregateAccountAllocation(accounts) {
const assetAccounts = accounts.filter((a) => a.balance > 0);
const debtAccounts = accounts.filter((a) => a.balance < 0);
return {
labels: assetAccounts.map((a) => a.name),
data: assetAccounts.map((a) => a.balance),
totalDebt: debtAccounts.reduce((sum, a) => sum + Math.abs(a.balance), 0),
debtAccountNames: debtAccounts.map((a) => a.name)
};
}
信用卡帳戶的餘額是 -8600(代表卡費未繳、屬於負債),如果直接把負數塞進極座標圖,會產生「半徑是負的」這種沒有意義的圖形。這裡選擇把資產(balance > 0)跟負債(balance < 0)分開處理:極座標圖只呈現資產帳戶的相對大小,負債總額則另外用一行提示文字呈現(見 main.js 的 debtHint)。這是資料清洗(Data Cleaning)中常見的判斷——不是所有數字都適合直接畫進同一張圖表,负值、比例不同的量值,常常需要先分流處理。
trendChart.js)延續 Day 5(fill 區域圖、tension 曲線平滑)與 Day 6(ticks.callback 座標軸格式化):
datasets: [
{ label: '收入', data: trend.incomeData, borderColor: '#22c55e', backgroundColor: 'rgba(34,197,94,0.15)', tension: 0.3, fill: true },
{ label: '支出', data: trend.expenseData, borderColor: '#ef4444', backgroundColor: 'rgba(239,68,68,0.15)', tension: 0.3, fill: true }
],
options: {
scales: { y: { ticks: { callback: (value) => `${(value / 1000).toLocaleString()}k` } } }
}
ticks.callback 把 Y 軸的數字從 52000 轉換成更易讀的 52k,這是 Day 6 教過的座標軸格式化技巧在真實專案中的應用。
categoryChart.js):Day 19 圖表連動實戰options: {
cutout: '60%',
onClick: (event, activeElements, chart) => {
if (!activeElements.length) return;
const { index } = activeElements[0];
const categoryId = chart.$categoryIds[index];
onClickCategory(categoryId); // 通知 main.js:使用者點了哪個類別
}
}
這裡直接重用 Day 19 教過的 onClick(event, activeElements, chart) 三個參數:activeElements[0].index 就是使用者點到的扇形索引,再對照 chart.$categoryIds(建立圖表時掛在實體上的一份對照陣列)反查出對應的 categoryId。拿到 categoryId 之後,透過 onClickCategory 這個由 main.js 傳進來的回呼函式,就能觸發「篩選交易明細表格」的連動效果——這正是 Day 19「圖表連動(點擊 A 圖表更新 B 圖表)」的具體實作。
被選中的扇形還會透過 Chart.js 的 offset 屬性往外凸出,作為「目前篩選中」的視覺提示:
chart.data.datasets[0].offset = breakdown.categoryIds.map((id) =>
selectedCategoryId !== 'all' && id === selectedCategoryId ? 12 : 0
);
budgetChart.js):Day 24 Scriptable Options 實戰function actualColorScriptable(comparison) {
return (context) => {
const { dataIndex } = context;
const budget = comparison.budgetData[dataIndex];
const actual = comparison.actualData[dataIndex];
if (budget == null) return '#94a3b8'; // 沒有預算資料:中性灰色
return actual > budget ? '#ef4444' : '#3b82f6'; // 超支:紅色;未超支:藍色
};
}
// ...
datasets: [
{ label: '預算上限', data: comparison.budgetData, backgroundColor: '#cbd5e1' },
{ label: '實際支出', data: comparison.actualData, backgroundColor: actualColorScriptable(comparison) }
]
backgroundColor 這裡傳入的是一個函式而不是固定色碼——這就是 Day 24 教過的 Scriptable Options:Chart.js 每次繪製長條時都會呼叫這個函式,並傳入包含 dataIndex 的 context,讓我們可以「依照當下這根長條對應的實際數值,動態決定顏色」。比對 budget == null 的情況(也就是上一節提到的「其他」類別沒有預算資料),顏色會落回中性灰色,而不會誤判成「超支」或「未超支」。
allocationChart.js)延續 Day 9 學過的 Polar Area:每個扇形的角度是相等的,只有半徑隨數值變化,因此很適合單純比較「哪個帳戶餘額比較大」,而不強調彼此加總的佔比意義——這也是為什麼上一章要把信用卡負債排除在外:極座標圖不像環狀圖需要「總和等於 100%」,但同樣不適合處理負值。
export function createAllocationChart(canvas, allocation) {
return new Chart(canvas, {
type: 'polarArea',
data: { labels: allocation.labels, datasets: [{ data: allocation.data, backgroundColor: [...] }] },
options: { responsive: true, maintainAspectRatio: false }
});
}
四支圖表模組都遵守同一個介面慣例:
createXxxChart(canvas, data, ...)負責第一次建立圖表,updateXxxChart(chart, data, ...)負責之後的局部更新(只換data、呼叫chart.update(),不會destroy()重建整張圖),這跟 Day 13 教過的「動態更新圖表資料」原則一致:能用update()局部更新,就不要整張圖表重新new Chart()。
main.js:狀態管理與事件綁定const state = {
categories: [], accounts: [], transactions: [], budgets: [],
filters: { range: 'month', categoryId: 'all', keyword: '' }
};
const charts = { trend: null, category: null, budget: null, allocation: null };
state.categories/accounts/transactions/budgets 是從 API 拿到之後就不會再變動的原始資料;state.filters 則是會隨著使用者操作篩選器而改變的「目前篩選條件」。每次篩選條件改變,並不會重新呼叫 API,而是直接拿記憶體裡現成的原始資料,透過 aggregate.js 重新算一次——這也是為什麼 server.js 不需要提供篩選用的 query string:篩選運算全部發生在瀏覽器端,速度快、也不會增加後端負擔。
renderAll() 與局部更新function renderAll() {
renderSummaryCards();
renderTrendChart();
renderCategoryChart();
renderBudgetChart();
renderAllocationChart();
renderTransactionsTable();
}
每個 renderXxx() 函式都遵守相同的「先建立、後更新」判斷:
function renderCategoryChart() {
const rangeFiltered = filterByRange(state.transactions, state.filters.range);
const breakdown = aggregateCategoryBreakdown(rangeFiltered, state.categories);
if (!charts.category) {
charts.category = createCategoryChart(canvas, breakdown, handleCategoryChartClick);
} else {
updateCategoryChart(charts.category, breakdown, state.filters.categoryId);
}
}
第一次執行時 charts.category 是 null,所以呼叫 createCategoryChart() 建立圖表;之後每次篩選條件改變,charts.category 已經存在,就改呼叫 updateCategoryChart() 只更新資料,避免重複 new Chart() 造成畫面閃爍或記憶體浪費。
rangeSelect.addEventListener('change', (event) => {
state.filters.range = event.target.value;
// 時間區間會影響:卡片、分類佔比圖、預算比較圖、交易明細(趨勢圖維持全月份不變)
renderSummaryCards();
renderCategoryChart();
renderBudgetChart();
renderTransactionsTable();
});
categorySelect.addEventListener('change', (event) => {
state.filters.categoryId = event.target.value;
updateCategoryChart(charts.category, /* ... */, state.filters.categoryId);
renderTransactionsTable();
});
值得注意的細節:rangeSelect 改變時不會重新渲染 renderAllocationChart()(資產配置圖是帳戶餘額快照,跟交易的時間區間無關),也不會重新渲染 renderTrendChart()(上一章解釋過的設計決策)。這種「哪個篩選器改變,只重新渲染真正受影響的區塊」的寫法,能幫助之後維護程式碼的人快速看懂「這個篩選器到底會動到畫面上的哪些地方」。
function handleCategoryChartClick(categoryId) {
// 再次點擊同一個類別會取消篩選(切換回「全部類別」),是常見的下拉選單 UX 慣例
state.filters.categoryId = state.filters.categoryId === categoryId ? 'all' : categoryId;
categorySelect.value = state.filters.categoryId;
updateCategoryChart(charts.category, /* ... */, state.filters.categoryId);
renderTransactionsTable();
}
這個函式把「點擊環狀圖」跟「操作下拉選單」兩種互動方式收斂成同一組狀態——不管使用者是點圖表還是選下拉選單,state.filters.categoryId 永遠是唯一的真相來源(Single Source of Truth),畫面上的下拉選單也會同步更新選中值,兩種操作方式不會互相打架。
<select id="rangeSelect"> 提供「本月」「近 3 個月」「近 6 個月」三個選項,對應 aggregate.js 的 getRangeMonths()。切換後除了圖表跟著改變,「較前期」卡片的比較基準也會跟著調整(近 3 個月時,會跟「再前面 3 個月」比較,而不是單純跟上個月比較)。
let keywordTimer = null;
keywordInput.addEventListener('input', (event) => {
clearTimeout(keywordTimer);
const value = event.target.value;
keywordTimer = setTimeout(() => {
state.filters.keyword = value;
renderTransactionsTable();
}, 200);
});
如果每打一個字就立刻重新渲染表格,在交易筆數變多之後可能會感覺卡頓。這裡用 setTimeout + clearTimeout 實作一個簡易的 debounce(防抖):使用者停止輸入 200 毫秒後才真正觸發渲染,避免「每敲一個字就整張表格重繪一次」的效能浪費,這個技巧在 Day 20 提到的 resizeDelay(監控 resize 事件的 debounce)也是同樣的原理。
// exportCsv.js(節錄)
export function exportTransactionsToCsv(transactions, categories, accounts, fileName) {
const rows = transactions.map((tx) => ({
日期: tx.date,
收支類型: tx.type === 'income' ? '收入' : '支出',
類別: categories.find((c) => c.id === tx.categoryId)?.name ?? tx.categoryId,
帳戶: accounts.find((a) => a.id === tx.accountId)?.name ?? tx.accountId,
金額: tx.amount,
備註: tx.note
}));
const csvText = Papa.unparse(rows); // Day17 學過的 PapaParse,這裡反過來做「JSON -> CSV」
const blob = new Blob([`\ufeff${csvText}`], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
幾個實務細節:
Papa.parse() 把 CSV 轉成 JSON,這裡反過來用 Papa.unparse() 把 JSON 陣列轉回 CSV 文字,是同一個套件的一體兩面。date、type...)轉成中文欄位名稱(日期、收支類型...),是為了讓 CSV 開啟後的標題列對一般使用者更友善。\ufeff(UTF-8 BOM):這是為了解決 Excel 開啟 UTF-8 編碼的 CSV 檔案時,中文字常常變成亂碼的經典問題。URL.createObjectURL(blob) 產生一個暫時的下載連結,建立一個「看不見的」<a download> 標籤觸發點擊,下載完成後記得呼叫 URL.revokeObjectURL(url) 釋放記憶體。getFilteredTransactions() 的結果),而不是全部 70 筆原始資料——這是資料匯出功能的常見期待:使用者篩出「只看餐飲類別」之後按下匯出,應該只拿到餐飲類別的 CSV,而不是整份原始資料。http://localhost:3000,應該會依序看到:統計卡片(收入/支出/結餘/較前期)→ 四張圖表 → 交易明細表格,且 statusText 顯示「資料載入完成,共 70 筆交易紀錄。」transactions_month_1234567890.csv 的檔案,用 Excel 或文字編輯器打開,中文欄位與內容都應該正常顯示,不會出現亂碼。本篇範例已經用 Puppeteer 進行過自動化驗證:確認四張
<canvas>都成功建立、三種篩選器(時間區間/類別/關鍵字)都能正確改變交易筆數、以及圖表點擊確實會連動下拉選單與表格,執行過程沒有任何 JavaScript 錯誤(唯一的瀏覽器提示是找不到favicon.ico,這與網站圖示無關,不影響任何功能)。
main.js,沒有拆到 aggregate.js:一開始覺得「反正資料不多,直接在事件監聽器裡面 filter/reduce 也很快」,但這樣會讓 main.js 越寫越肥,且彙整邏輯無法被單獨測試。今天的架構把「算資料」跟「畫圖表」「綁事件」明確拆開,是為了 Day 30 的重構鋪路。destroy() 再 new Chart():這是新手很容易犯的錯誤,會造成畫面明顯閃爍,也違反 Day 13 教過的「能 update() 就不要重建」原則。今天的四支圖表模組都刻意設計成「建立一次、之後只 update()」。categorySelect 的篩選條件也套用到 aggregateCategoryBreakdown(),環狀圖選了「餐飲」之後就會只剩一個扇形,失去「佔比比較」的意義。務必先想清楚每張圖表存在的目的(呼應 Day 28 用 User Story 釐清「為什麼要做」的精神),再決定篩選條件該套用在哪裡。\ufeff,用 Windows 版 Excel 直接開啟 CSV 時,中文欄位名稱與內容很容易變成亂碼,這是新手第一次做「JSON 轉 CSV」功能時很容易忽略的小細節。budgetData 一律預設成 0 而不是 null,畫面上會誤導使用者「這個類別不能花任何錢」,而不是「這個類別根本還沒設定預算」,兩者代表的意義完全不同。min-width: 0:在 CSS Grid/Flexbox 版面下,容器預設的最小寬度是「內容自身的寬度」,如果圖表容器裡的文字或 canvas 比較寬,忘記設定 min-width: 0 就可能把整個 Grid 版面撐爆、跑版。明天(Day 30)是這個系列的最後一天:我們會回頭檢視今天做出來的儀表板,討論當交易筆數從 70 筆成長到幾千筆時可能遇到的效能瓶頸,學習 decimation(資料抽稀)外掛的用法,並針對今天略顯集中在 main.js 的渲染邏輯做進一步的程式碼重構與模組化,最後把 Chart.js 這 30 天學過的所有知識點做一次總複習。
unparse()
setTimeout 實作)