線上體驗 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 協同開發)
在實際差旅或出國採買時,使用者往往不會一張一張發票慢慢拍,而是累積了一整天的收據後,一口氣將多張照片批次上傳辨識。然而,傳統記帳工具在處理長收據或多張發票時,經常走入兩種極端:要麼將發票內的七八樣商品一股腦拆解成獨立筆數,瞬間將整頁歷史紀錄洗版到看不到前面的算式;要麼粗暴地只保留最終總額,日後想要回頭對帳時,根本看不出那筆三千日圓的消費究竟買了什麼。
為了提供真正符合商務直覺的高階體驗,我們在 ai-scan.js 與 script.js 模組中打造了整張發票智慧歸檔與手風琴展開架構。系統能自動依據店家名稱與交易日期將商品細項凝聚為單一發票卡片,並在計算機的歷史紀錄中提供零跳轉、即地展開的手風琴折疊面板,同時賦予使用者「整張歸檔」與「拆分單一商品」的雙軌選擇權。
當使用者在相機選單中一次選取三到五張發票照片時,底層的畫廊模組必須維持清晰可見的縮圖佇列。如果直接在畫面上堆疊大圖,瀏覽器記憶體很容易因高解析度渲染而卡頓。
我們在 ai-scan.js 中實作了批次快照佇列與非同步載入器。每一張選取的照片都會被即時縮放為輕量級預覽圖卡,呈現在橫向滾動的畫廊縮圖列中,並附帶即時刪除與旋轉控制。
// 多張圖片非同步上傳與佇列計數更新
function handleMultiImageFiles(files) {
if (!files || files.length === 0) return;
Array.from(files).forEach((file) => {
if (!file.type.startsWith('image/')) return;
const reader = new FileReader();
reader.onload = (e) => {
uploadedImagesQueue.push({
id: Date.now() + Math.random(),
dataUrl: e.target.result,
file: file
});
updateImageGalleryUI();
};
reader.readAsDataURL(file);
});
}
使用者可以在辨識前直觀確認每一張發票的拍攝清晰度,並能一鍵啟動全量辨識。系統會將所有發票影像依序送往解析引擎,並在記憶體中建立完整的分組快照。
當多張發票辨識完畢後,提取出的商品項目可能多達數十筆。若直接按陣列順序寫入歷史紀錄,不同店家的商品將會混雜在一起。為此,我們在 importReceiptToCalculator 函式中設計了店家與日期的複合聚合器。
// ai-scan.js 核心分組聚合演算法
function importReceiptToCalculator() {
if (scannedItems.length === 0) return;
const data = lastRenderedData || {};
const receiptGroups = {};
// 依「店家名稱 + 日期」雙維度聚合分組
scannedItems.forEach(item => {
const groupKey = `${item.store_name || data.store_name || '發票收據明細'}_${item.date || data.date || ''}`;
if (!receiptGroups[groupKey]) {
receiptGroups[groupKey] = {
storeName: item.store_name || data.store_name || '發票收據明細',
date: item.date || data.date || (data.dates && data.dates[0]) || null,
items: []
};
}
receiptGroups[groupKey].items.push(item);
});
// 依序處理每個分組卡片...
}
透過這種雙維度鍵值聚合法,即便使用者同時上傳了在全家便利商店、松本清藥妝與一蘭拉麵拍攝的照片,演算法也能在毫秒內將它們精準收攏為三張獨立的收據物件,杜絕品項穿插錯亂。
在建立分組卡片的過程中,演算法還會自動計算該張發票的主導分類標籤。若該張發票中購買了三項零食與一包面紙,系統會動態統計各類別品項數量,自動選取佔比最高的類別作為卡片主分類標籤,省去人工逐張設定的麻煩。
歸檔完成後,歷史紀錄面板不再是一眼望不到底的零碎清單,而是呈現出乾淨俐落的收據卡片。卡片主標題清楚標示店家名稱與總商品數,並在下方提供一個高質感的微光膠囊按鈕「👇 查看商品細項」。
// script.js 手風琴結構動態渲染
const hasSubItems = Array.isArray(item.items) && item.items.length > 0;
const subItemsHtml = hasSubItems ? `
<div class="h-sub-items-btn" data-toggle-index="${index}">
<span class="h-toggle-text">${(I18N[currentLang].toggleDetails || '👇 查看商品細項')}</span>
</div>
<div class="h-sub-items-wrap" id="h-sub-items-${index}">
<div class="h-sub-items-table">
${item.items.map(sub => {
const subForeign = sub.nameForeign && sub.nameForeign !== sub.name ? `<span class="h-sub-item-foreign">(${sub.nameForeign})</span>` : '';
const subPriceStr = sub.origCurrency && sub.origCurrency !== 'TWD'
? `${sub.origCurrency} ${sub.origPrice || sub.price}`
: `NT$ ${Math.round(sub.twdAmount || sub.price || 0)}`;
return `
<div class="h-sub-item-row">
<div class="h-sub-item-name">${sub.name}${subForeign}</div>
<div class="h-sub-item-qty">x${sub.count || 1}</div>
<div class="h-sub-item-price">${subPriceStr}</div>
</div>
`;
}).join('')}
</div>
</div>
` : '';
為了避免點擊展開時觸發歷史卡片本身的長按或回填算式事件,我們在事件監聽層嚴格加上了 e.stopPropagation() 事件冒泡阻斷。點擊展開時,CSS 會透過平滑過渡動畫展開高度,並即時切換文字提示為「👆 收起細項」。使用者能在完全不離開主畫面的情況下,清晰查閱每一筆商品的數量、原幣金額、中文翻譯與折算台幣結果。
考慮到不同使用者的記帳哲學截然不同,部分使用者希望報表越精簡越好,而另一部分使用者則習慣針對特定高價物品單獨追蹤。因此,我們在辨識完成的動作列中設計了清晰的雙軌按鈕。
// 雙軌匯入入口
elements.btnImportReceipt.addEventListener('click', () => {
// 🧾 整張明細歸檔:按發票分組,支援手風琴折疊
importReceiptToCalculator();
});
elements.btnImportAll.addEventListener('click', () => {
// 🛍️ 拆分單一商品:將每個品項各自拆成一筆獨立歷史紀錄
importSingleItemsToCalculator();
});
當點擊「🧾 整張明細歸檔」時,系統執行上述分組聚合邏輯,產出帶有 items 陣列的複合卡片;而點擊「🛍️ 拆分單一商品」時,系統則會將每一個品項依序送入 addScannedRecordToHistory,賦予其獨立的時間戳記與分類屬性。無論使用者偏好哪種記帳風格,都能在一鍵點擊中獲得最流暢的歸檔體驗。
發票辨識、匯率折算與手風琴細項都已就定位,但在單一瀏覽器上算得再精準,一旦面臨更換手機、無痕模式重啟,或是回到辦公室想要切換到大螢幕筆電查看帳目時,儲存在本機儲存空間的所有資料依然會面臨隨時蒸發的風險。
在明天的文章中,我們將跨越前端單機的最後一道防線,深入剖析如何透過現代化雲端身份驗證架構,將分散在本地端的十六項核心商務狀態與按鍵佈局,實現無感、即時且跨裝置的自動鏡像同步。