在前面的文章中,我們實現了以 IndexedDB 做為本地儲存的 Local-First 架構。
但對於一個「不收集個人資料、資料存在使用者本地裝置」的工具來說,光是把資料存放在瀏覽器裡還不夠。萬一使用者更換電腦、清除瀏覽器快取,或是想把紀錄拿去做其他用途呢?
因此,我希望可以有資料匯出功能,讓使用者可以備份相關紀錄,所以接下來就要來加上 CSV 報表匯出 功能。
TaskTimer 的原始資料結構是有層級關係的:
主任務(Task)→ 子任務(Subtask) → 計時紀錄(Records)
但是,試算表軟體(如 Microsoft Excel、Google Sheets)讀取的 CSV 檔案是單層的橫列表格。
因此,我們需要進行 資料扁平化(Data Flattening) :將每一筆「計時紀錄」連同它所屬的主任務名稱與子任務名稱,拆解組合為獨立的一列資料。
在用原生 JavaScript 寫 CSV 匯出時,有三個地方需要特別注意:
\uFEFF):如果直接輸出 UTF-8 編碼的中文文字,Microsoft Excel 開啟時常會出現整頁 中文亂碼。在字串開頭加上 \uFEFF(Byte Order Mark)可以強制引導 Excel 以 UTF-8 正確解析中文字元。
使用者的「備註」或「任務名稱」中可能包含逗號 ,、雙引號 " 或換行符號 \n。若沒有處理,會導致 CSV 欄位錯位拆分。
規則是:只要欄位內含有特殊字元,就必須將整段內容用雙引號包起來,並把內部的 " 替換為 ""。
利用原生 Blob 物件將文字轉換為二進位檔案,並建立臨時的 <a download> 標籤觸發瀏覽器下載,下載完成後及時釋放記憶體(URL.revokeObjectURL)。
實作 CSV 匯出的核心邏輯可拆解為三個步驟:格式防錯、資料扁平化 與 觸發下載。
當欄位內容含有逗號 ,、雙引號 " 或換行符號 \n 時,必須用雙引號將內容包起來,並把內部的 " 替換為 "",避免試算表解析欄位錯亂:
function escapeCSVField(str) {
if (typeof str !== "string") return str;
if (str.includes(",") || str.includes('"') || str.includes("\n")) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
}
走訪 tasks 陣列,把主任務、子任務與計時紀錄展開組合成單一維度的橫列字串陣列:
// 1. 定義 CSV 標頭
const headers = ["主任務名稱", "子任務名稱", "子任務狀態", "計時時間區間", "專注時間(分鐘)", "備註"];
const csvRows = [headers.join(",")];
// 2. 展開三層嵌套結構
tasks.forEach((task) => {
task.subtasks?.forEach((sub) => {
// 遍歷每一筆計時紀錄並轉為 CSV 行
sub.records?.forEach((rec) => {
csvRows.push([
escapeCSVField(task.title),
escapeCSVField(sub.title),
sub.status === "completed" ? "已完成" : "進行中",
escapeCSVField(rec.timeRange || "-"),
rec.durationMinutes || 0,
escapeCSVField(rec.note || "")
].join(","));
});
});
});
在字串最前方加上 \uFEFF 標頭防範 Excel 中文亂碼,最後利用原生 Blob 與動態 <a> 標籤觸發下載:
// 3. 組合內容字串,加入 \uFEFF 防範 Excel 中文亂碼
const csvString = "\uFEFF" + csvRows.join("\n");
// 4. 建立 Blob 物件與動態下載連結
const blob = new Blob([csvString], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const downloadLink = document.createElement("a");
downloadLink.href = url;
downloadLink.setAttribute("download", `TaskTimer_Backup_${new Date().toISOString().split("T")[0]}.csv`);
document.body.appendChild(downloadLink);
// 5. 觸發下載並釋放記憶體
downloadLink.click();
document.body.removeChild(downloadLink);
URL.revokeObjectURL(url);
點擊頁面上的 「下載 CSV 檔案」 按鈕後,瀏覽器會自動下載檔名如 TaskTimer_Backup_2026-09-27.csv 的檔案。
開啟後,可以獲得結構完整、中文呈現正常的統計報表:
