昨天我們完成了即時記帳分類氣泡與歷史紀錄二分法體系,成功化解了算完帳當下記憶猶新卻懶得開記帳軟體的心理摩擦力,並讓純商務算式與個人生活開銷在底層資料流中權責分明。然而,當商務人士結束一整趟密集的跨國出差或採購行程時,下一個令人崩潰的現實難題隨即降臨:手機歷史清單裡累積了數十筆甚至上百筆各類幣別的開銷,回到公司要報帳核銷時,難道還得盯著手機螢幕,一筆一筆用手指在電腦鍵盤上敲進試算表嗎?
為了解決報帳手抄的低效與繁瑣,今天我們實作離線 SheetJS 試算表匯出引擎、多維度資料展平與行動端原生分享機制。系統直接在瀏覽器前端以二進位方式組裝出正規試算表檔案,自動剔除未經分類的純商務運算草稿,將多國貨幣開銷、外文品名與中文翻譯結構化展平;更進一步結合現代瀏覽器原生檔案分享介面,讓使用者在手機端點擊一鍵,就能將完整的試算表發送至通訊軟體或公司信箱。
線上體驗 Live Demo:https://410355-collab.github.io/Premium-Business-Calculator/
(強烈建議使用 iPhone Safari 或 Android 手機開啟體驗!)
GitHub 開源倉庫:https://github.com/410355-collab/Premium-Business-Calculator
歡迎提出建議與指導
(開發環境:Google Antigravity IDE + Google Gemini 協同開發)
在跨國商務與行動辦公的真實流程中,傳統計算機與各類簡易記帳軟體往往陷入三大困境:
第一個問題是資料受困於行動端,無法直接對接企業財務系統。許多計算機工具雖然提供歷史紀錄儲存,但最多僅能呈現為無格式的純文字列表。當出差歸來的商務人士需要將每一筆計程車費、公務餐費或樣品採購費用整理給會計部門時,只能開啟電腦試算表軟體,雙眼來回對照手機畫面,手動輸入日期、幣別、金額與品項。這不僅耗時費力,更極易在手動謄寫的過程中發生小數點位移或打錯數字的人為疏失。
第二個問題是仰賴後端伺服器生成的隱私與斷網隱憂。市面上許多宣稱支援匯出試算表的服務,本質上是將使用者的帳目與消費數據上傳至遠端雲端伺服器,由後端生成檔案後再傳回前端下載。這不僅在搭乘航班、高鐵隧道或無訊號的海外環境下徹底失效,更將敏感的商業採購單價與差旅軌跡暴露於第三方伺服器之中,存在嚴重的資安隱私風險。
第三個問題是行動端檔案下載的斷層體驗。在智慧型手機環境下,傳統的網頁檔案下載往往直接將檔案存入難以尋找的底層資料夾,使用者需要跳出瀏覽器、開啟檔案總管,再手動選取檔案分享至通訊軟體。要提供媲美原生應用的極致體驗,系統必須具備將二進位試算表直接對接行動作業系統原生分享面板的能力。
為了解決財務匯出的痛點,我們在前端建立了完全自給自足的試算表生成管線:
動態按需載入與零伺服器二進位封裝:
為了兼顧計算機首頁的極速載入效能,我們將重量級試算表解析核心獨立拆分。在日常計算時完全不消耗任何網路頻寬與記憶體;僅在使用者點擊匯出按鈕的瞬間,非同步動態載入本機預載的離線函式庫,並直接在客戶端記憶體中將純 JavaScript 物件陣列編譯為正規的微軟開放式 XML 試算表格式,全程百分之百離線執行,保障極致隱私與零伺服器依賴。
商務過濾與多層次收據結構展平:
在進行二進位結構組裝前,資料處理管線首先套用二分法篩選器,將所有標記為純商務驗算的草稿紀錄全面剔除,僅保留真正具有財務意義的生活與差旅支出。若紀錄中包含整張收據所細分的多項商品明細,管線會自動執行遞歸展平,將每一項商品拆分為獨立明細列,並智慧判定是否包含外幣與外語品項,動態注入外文原始品名與繁體中文翻譯欄位。
原生檔案分享與自動降級下載:
為了消滅手機端尋找下載檔案的繁瑣路徑,我們整合了現代瀏覽器原生系統檔案分享機制。系統將前端生成的二進位資料封裝為原生檔案物件,優先呼叫行動裝置系統層級的分享面板,讓使用者可直接點選常用的通訊軟體群組或郵件應用程式發送;若遇到不支援檔案分享的桌上型瀏覽器或特殊環境,系統則無縫降級至記憶體物件連結自動觸發下載。
以下為動態載入離線函式庫、過濾純商務計算、結構化資料展平與原生分享的關鍵實作:
// 動態懶載入離線 SheetJS 函式庫,兼顧首頁輕量與功能完整
function ensureXlsxLoaded() {
if (typeof XLSX !== 'undefined') return Promise.resolve();
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = './xlsx.full.min.js';
script.onload = resolve;
script.onerror = () => reject(new Error('XLSX load failed'));
document.head.appendChild(script);
});
}
// 輸出正規 Excel (.xlsx) 檔案核心函式
async function exportHistoryExcel() {
triggerVibration(false);
if (!calcHistory || calcHistory.length === 0) {
showToastMsg(I18N[currentLang].noHistory || "NO HISTORY");
return;
}
// 確保離線核心已載入
if (typeof XLSX === 'undefined') {
try {
await ensureXlsxLoaded();
} catch (err) {
showToastMsg("XLSX Library not loaded");
return;
}
}
// 核心過濾:自動排除純商務 (business) 計算草稿
const exportItems = calcHistory.filter(item => (item.category || 'business') !== 'business');
if (exportItems.length === 0) {
showToastMsg(I18N[currentLang].toastNoExcelItems || "NO EXPENSE RECORDS (BUSINESS EXCLUDED)");
return;
}
try {
const detailSheetName = I18N[currentLang].excelDetailSheet || "記帳明細";
// 1. 展平處理發票與明細(若為多商品收據,個別拆解為獨立明細列)
const flatDetailItems = [];
let detailIndex = 1;
exportItems.forEach(item => {
if (Array.isArray(item.items) && item.items.length > 0) {
item.items.forEach(sub => {
const subOrigPrice = Number(sub.origPrice || sub.price) || 0;
const subTwdAmount = sub.twdAmount !== undefined ? sub.twdAmount : Math.round(subOrigPrice * (sub.rateToTWD || item.rateToTWD || 1) * 100) / 100;
flatDetailItems.push({
idx: detailIndex++,
timestamp: item.timestamp,
category: sub.category || item.category || 'shopping',
formula: sub.name || '商品細項',
nameForeign: sub.nameForeign || sub.name || '',
result: subOrigPrice,
currencyFrom: sub.origCurrency || item.currencyFrom || currentFrom,
convertedResult: subTwdAmount,
currencyTo: item.currencyTo || currentTo,
isTaxExcluded: item.isTaxExcluded
});
});
} else {
const numResult = typeof item.result === 'number' ? item.result : parseFloat(String(item.result).replace(/,/g, '')) || 0;
const convResult = (item.convertedResult !== undefined && item.convertedResult !== '') ? parseFloat(item.convertedResult) : numResult;
flatDetailItems.push({
idx: detailIndex++,
timestamp: item.timestamp,
category: item.category || 'other',
formula: item.formula,
nameForeign: item.nameForeign || item.formula,
result: numResult,
currencyFrom: item.currencyFrom || currentFrom,
convertedResult: convResult,
currencyTo: item.currencyTo || currentTo,
isTaxExcluded: item.isTaxExcluded
});
}
});
// 判斷是否包含外語或外國貨幣發票
const hasForeignItems = flatDetailItems.some(item => {
const curr = item.currencyFrom;
return (curr && curr !== 'TWD') || (item.nameForeign && item.nameForeign !== item.formula);
});
// 建立符合財務規範之欄位標題
const headers = [
I18N[currentLang].excelColNo || "編號",
I18N[currentLang].excelColDateTime || "日期時間",
I18N[currentLang].excelColCategory || "分類",
I18N[currentLang].excelColFormula || "項目/品名"
];
if (hasForeignItems) {
headers.push(I18N[currentLang].excelColForeignName || "外文品名");
headers.push(I18N[currentLang].excelColTranslatedName || "中文翻譯");
}
headers.push(
I18N[currentLang].excelColResult || "計算結果",
I18N[currentLang].excelColCurrency || "原始幣別",
I18N[currentLang].excelColConverted || "換算金額",
I18N[currentLang].excelColTargetCurr || "目標幣別",
I18N[currentLang].excelColTax || "稅率狀態"
);
// 填入明細資料列
const rows = flatDetailItems.map((item) => {
const dt = item.timestamp ? new Date(item.timestamp).toLocaleString() : new Date().toLocaleString();
const catName = `${getCategoryEmoji(item.category || 'other')} ${getCategoryName(item.category || 'other')}`;
const numResult = item.result;
const convResult = item.convertedResult !== undefined ? item.convertedResult : '';
const taxMode = item.isTaxExcluded ? (I18N[currentLang].taxBadgeEx || "未稅") : (I18N[currentLang].taxBadgeIn || "含稅");
const isForeign = (item.currencyFrom && item.currencyFrom !== 'TWD') && (item.nameForeign && item.nameForeign !== item.formula);
const rowData = [item.idx, dt, catName, item.formula];
if (hasForeignItems) {
rowData.push(isForeign ? (item.nameForeign || item.formula) : "");
rowData.push(isForeign ? item.formula : "");
}
rowData.push(numResult, item.currencyFrom || currentFrom, convResult, item.currencyTo || currentTo, taxMode);
return rowData;
});
// 建立工作表與活頁簿
const ws = XLSX.utils.aoa_to_sheet([headers, ...rows]);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, detailSheetName);
// 設置自適應欄位寬度
ws['!cols'] = [
{ wch: 6 }, { wch: 20 }, { wch: 14 }, { wch: 26 },
...(hasForeignItems ? [{ wch: 26 }, { wch: 26 }] : []),
{ wch: 14 }, { wch: 10 }, { wch: 14 }, { wch: 10 }, { wch: 12 }
];
// 寫入二進位並嘗試原生系統分享
const fileName = `BusinessCalc_${new Date().toISOString().slice(0, 10)}.xlsx`;
const wbout = XLSX.write(wb, { bookType: 'xlsx', type: 'array' });
const blob = new Blob([wbout], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
let shared = false;
if (navigator.canShare && typeof File !== 'undefined') {
try {
const testFile = new File([blob], fileName, { type: blob.type });
if (navigator.canShare({ files: [testFile] })) {
await navigator.share({ files: [testFile], title: fileName });
showToastMsg(I18N[currentLang].toastExcelExported || "EXCEL EXPORTED");
shared = true;
}
} catch (e) {
shared = false;
}
}
// 不支援分享時自動觸發本機下載
if (!shared) {
downloadExcelBlob(blob, fileName);
}
} catch (err) {
showToastMsg("Export failed");
}
}
在將複雜的二進位試算表生成引擎搬進前端的過程中,我們針對行動裝置環境進行了細緻的工程調校:
首頁體積零負擔的按需載入:
傳統引入大型資料處理函式庫的做法,往往會導致首頁加載體積暴增數百千位元組,拖慢手機端開啟速度。我們將核心模組完全解耦,只有當使用者明確點擊匯出按鈕時,才透過非同步載入器動態注入腳本。這樣既確保了計算機首頁常態維持輕盈秒開,又能在需要專業輸出時提供完整的財務級功能。
精確的字元寬度自適應配置:
由程式碼直接產生的試算表,最常見的缺陷就是文字遭到欄位截斷或擠成一團。我們依據各欄位的實際資料特性,精準指派了自適應字元寬度。編號欄配置緊湊空間、日期時間預留二十字元保證完整顯示時分秒,而品名與外國發票翻譯則大幅拓寬至二十六字元,使主管與會計打開檔案的瞬間,呈現出如同人工細心編排的清爽專業感。
雙軌檔案傳遞容錯設計:
在不同的行動裝置與瀏覽器內核中,系統檔案分享介面的支援度各有不同。若使用者在分享彈窗跳出後主動點擊取消,系統不應將其判定為錯誤而重複觸發下載;但若瀏覽器根本不具備原生分享能力,系統則即刻建立虛擬錨點元素並調用瀏覽器底層下載協定,形成百分之百無死角的跨平台交付閉環。
今天我們成功實作了離線 SheetJS 試算表匯出引擎、多維度明細展平與行動端原生分享機制。徹底消除了差旅報帳的手抄摩擦力,讓計算機在完全不依賴雲端伺服器的前提下,於手機本機端秒級產出正規乾淨的財務試算表,並能隨手一鍵發送至會計信箱或通訊群組。
然而,當我們打開產出的試算表時,新的進階需求隨之而來:一份專業的財務報告如果只有密密麻麻的數字清單,主管依然很難一眼看出哪一類開銷佔比最高。難道在前端生成的試算表裡,就不能自帶會動的計算公式、甚至直接畫出互動式圓餅圖嗎?明天我們將揭開試算表動態連動公式與原生圓餅圖導引的奧秘!
匯出了一長串消費明細,如果每次修改其中一筆金額,各分類的加總與佔比都要手動重新計算,那該有多麻煩?在不安裝任何肥大外掛的情況下,純前端產出的檔案難道也能內建動態公式與圖表嗎?明天我們將為大家帶來更震撼的試算表進化,敬請期待!