iT邦幫忙

2026 iThome 鐵人賽

DAY 21
0
Build on Google AI

液態玻璃美學:結合 Gemini 與 Antigravity IDE,打造 120FPS、多視窗比例與網格順延推擠佈局的強大商務運算終端系列 第 21

Day 21:【高階商務計算機】多國出差錢花去哪了一目了然——跨國多幣別旅行開銷儀表板、多維進度條與 AI 智慧消費分類導入

  • 分享至 

  • xImage
  •  

昨天我們實作了 Excel 動態連動公式注入架構與原生圓餅圖導引設計,讓匯出的財務試算表具備如同活體資料庫般的自我演算能力。然而,當跨國商務差旅或海外自由行橫跨多個國家時,使用者在手機操作介面上常常面臨另一種混亂的記帳焦慮:錢包裡同時混雜著美金、日圓、韓元與歐元,每一筆消費在當下記錄時看似井然有序,但幾天累積下來,到底整趟行程總共折合台幣花了多少?哪一種外幣支出佔比最高?哪些分類又是主要的開銷重災區?更重要的是,若缺乏智慧模型的語意輔助,面對各類繁雜的跨國品項,使用者根本無法在旅途中精準辨別消費性質並即時控制預算。

為了讓跨國差旅的開銷結構隨時清晰可控,今天我們實作多幣別旅行開銷儀表板、多維動態進度條系統,並正式導入 AI 模型消費語意分類引擎。系統自動掃描歷史庫中排除純商務計算後的真實支出,依據即時匯率矩陣將多國外幣無縫錨定折算為新台幣總額,同時透過 AI 模型對商品名稱與店家類型進行精準語意辨別與自動歸類,在專屬的焦點視窗中,從幣別分佈與生活分類兩大核心維度進行百分比動態長條圖渲染;更支援一鍵生成排版工整的純文字旅行開銷總摘要,隨手即可發送至通訊群組或差旅報銷備忘錄。


專案正式上線體驗與開源倉庫

線上體驗 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 模型在底層協助理解店家屬性與消費性質,產出的統計分類往往雜亂無章;此外,當團隊需要口頭回報或在群組同步開銷時,也缺乏輕量直觀的結構化格式。系統必須具備 AI 語意分類能力、直覺的進度條圖形化呈現,並提供結構清晰的摘要報告生成機制。

二、架構設計:AI 語意分類、即時匯率錨定與雙軌進度條

為了解決跨國多幣別與模糊消費的統計盲區,我們構建了結合 AI 智慧語意辨別的全自動折算儀表板管線:

AI 模型消費語意分類與店家屬性推斷:
在資料歸檔與統計前置階段,系統正式介接 AI 智慧分類判定邏輯。模型依據店家名稱與品名特徵進行即時語意剖析,精確識別當下消費屬於即時用餐(如餐廳、食堂、咖啡廳自動歸納為食)、個人採購(如藥局、免稅店、百貨自動歸納為購物)或是交通與商務支出。即使面對複雜的品名,也能在後台被智慧映射至正確的生活開銷維度,為儀表板提供乾淨精準的分類基底。

全域新台幣即時匯率錨定管線:
統計核心在執行時,主動遍歷全體歷史資料並自動過濾排除非個人支出的純商務運算。針對每一筆不同幣別的原始開銷,系統首先檢核該筆交易是否已帶有歷史換算定值;若無,則動態調用當前匯率矩陣,以美元作為基準中介貨幣,精確計算出各幣別折合新台幣的當前價值並進行累加,最終在視窗頂端以高對比焦點數值呈現全旅程總開銷。

雙核心維度百分比聚合與進度條渲染:
為了讓開銷結構立體化,我們設計了雙軌並行的資料聚合引擎。第一軌依原始幣別分流,計算各國貨幣的原幣總和、折合台幣總和與筆數,並計算其佔全旅程總支出的百分比;第二軌依 AI 辨別後的生活分類(食、購物、行、其他)進行消費型態歸類。每一組資料項皆動態計算長度比例,透過非同步排程賦予漸層進度條即時展開的視覺動態;對於交易來源更預先預留了未來智慧定位擴充彈性。

格式化純文字旅行報告產生器:
除了介面上的視覺圖形呈現外,系統內建專屬的字串序列化產生器。當使用者點擊複製報告按鈕時,函式庫會擷取當前系統時間、總開銷數值與各幣別的國旗圖示、原幣金額及折合百分比,格式化組裝成帶有典雅符號邊框的純文字報告,直接寫入系統剪貼簿,實現極致輕巧的群組溝通與差旅備忘。

三、核心代碼精華:script.js

以下為導入 AI 分類推斷、多幣別台幣動態折算、維度資料聚合、進度條排版與純文字報告生成的關鍵實作:

// 導入 AI 語意分類規則庫:智慧辨別店家屬性與消費性質
function inferCategoryFromName(name, storeName) {
    const s = String(storeName || '').toLowerCase();
    const n = String(name || '').toLowerCase();

    // 1. 若店家為藥妝店/免稅店/百貨公司 -> 優先歸類為購物 (shopping)
    if (/(藥妝|藥局|松本清|大國|sundrug|matsukiyo|donki|唐吉訶德|免稅|duty free|百貨|美妝|藥房)/.test(s)) {
        return 'shopping';
    }

    // 2. 若店家為餐廳/食堂/速食/咖啡廳 -> 歸類為食 (food)
    if (/(松屋|吉野家|すき家|sukiya|一蘭|拉麵|麥當勞|肯德基|摩斯|星巴克|starbucks|壽司|居酒屋|cafe|coffee|鐵板燒|火鍋|便當|燒肉)/.test(s)) {
        return 'food';
    }

    // 3. 依據商品名稱特徵進行語意推斷
    if (/(藥|保健|維他命|面膜|保養|洗面|眼藥水|貼布|補品)/.test(n)) {
        return 'shopping';
    }
    if (/(便當|餐|飯|麵|咖啡|茶|奶|拿鐵|麵包|餅乾|水|飲料|肉|菜|蛋|湯|酒|漢堡|點心|零食)/.test(n)) {
        return 'food';
    }
    if (/(車票|機票|地鐵|捷運|jr|高鐵|計程車|taxi|加油|過路費)/.test(n)) {
        return 'transport';
    }
    return 'other';
}

// 渲染多幣別旅行開銷儀表板核心函式
function renderTravelSummary() {
    if (!calcHistory || calcHistory.length === 0) return false;

    let totalTwdAll = 0;
    const byCurrency = {};
    const byCategory = { food: 0, shopping: 0, transport: 0, other: 0 };
    const categoryCounts = { food: 0, shopping: 0, transport: 0, other: 0 };

    const flagIcons = { TWD: "🇹🇼", USD: "🇺🇸", JPY: "🇯🇵", KRW: "🇰🇷", EUR: "🇪🇺", CNY: "🇨🇳", MYR: "🇲🇾", SGD: "🇸🇬", AUD: "🇦🇺", VND: "🇻🇳", THB: "🇹🇭" };
    const currSymbols = { TWD: "NT$", USD: "$", JPY: "¥", KRW: "₩", EUR: "€", CNY: "¥", MYR: "RM", SGD: "S$", AUD: "A$", VND: "₫", THB: "฿" };

    // 核心資料遍歷:自動排除純商務計算,並統一折算為 TWD 錨定金額
    calcHistory.forEach(item => {
        if (item.category === 'business') return;

        const origCurr = item.origCurrency || item.currencyFrom || 'TWD';
        const numOrigPrice = typeof item.origPrice === 'number' ? item.origPrice : (typeof item.result === 'number' ? item.result : parseFloat(String(item.result).replace(/,/g, '')) || 0);

        let twdVal = 0;
        if (item.twdAmount !== undefined && !isNaN(Number(item.twdAmount))) {
            twdVal = Number(item.twdAmount);
        } else if (origCurr === 'TWD') {
            twdVal = numOrigPrice;
        } else {
            const twdRate = rates['TWD'] || 32.5;
            const currRate = rates[origCurr] || 1;
            twdVal = Math.round((numOrigPrice * (twdRate / currRate)) * 100) / 100;
        }

        totalTwdAll += twdVal;

        // 1. 依幣別聚合累計
        if (!byCurrency[origCurr]) {
            byCurrency[origCurr] = { origTotal: 0, twdTotal: 0, count: 0 };
        }
        byCurrency[origCurr].origTotal += numOrigPrice;
        byCurrency[origCurr].twdTotal += twdVal;
        byCurrency[origCurr].count += 1;

        // 2. 依 AI 智慧推斷之分類聚合累計
        const cat = item.category || inferCategoryFromName(item.formula, item.location) || 'other';
        if (byCategory[cat] !== undefined) {
            byCategory[cat] += twdVal;
            categoryCounts[cat] += 1;
        } else {
            byCategory.other += twdVal;
            categoryCounts.other += 1;
        }
    });

    // 渲染總額 Hero 焦點區域
    const heroEl = document.getElementById('travel-hero-amount');
    if (heroEl) heroEl.textContent = `NT$ ${Math.round(totalTwdAll).toLocaleString()} TWD`;

    // 渲染多幣別分佈與動態長條進度條
    const currListEl = document.getElementById('travel-curr-list');
    if (currListEl) {
        let html = '';
        Object.keys(byCurrency).forEach(code => {
            const info = byCurrency[code];
            const pct = totalTwdAll > 0 ? ((info.twdTotal / totalTwdAll) * 100).toFixed(1) : '0.0';
            const flag = flagIcons[code] || '🌐';
            const sym = currSymbols[code] || '';
            html += `
                <div class="travel-item-row">
                    <div class="travel-item-left">
                        <span>${flag}</span>
                        <span>${code}</span>
                        <span class="travel-item-sub">(${info.count} 筆)</span>
                    </div>
                    <div class="travel-item-right">
                        <span class="travel-item-primary">${sym} ${info.origTotal.toLocaleString()} ${code}</span>
                        <span class="travel-item-sub">約 NT$ ${Math.round(info.twdTotal).toLocaleString()} TWD (${pct}%)</span>
                    </div>
                </div>
                <div class="travel-progress-bar"><div class="travel-progress-fill" style="width:${pct}%"></div></div>
            `;
        });
        currListEl.innerHTML = html || '<div class="travel-item-sub">尚無多幣別資料</div>';
    }

    // 渲染生活分類分佈與動態長條進度條
    const catListEl = document.getElementById('travel-cat-list');
    if (catListEl) {
        let html = '';
        const catNames = { food: '食 🍜', shopping: '購物 🛍️', transport: '行 🚗', other: '其他 🏷️' };
        Object.keys(byCategory).forEach(catKey => {
            const twdSum = byCategory[catKey];
            const count = categoryCounts[catKey];
            if (twdSum > 0 || count > 0) {
                const pct = totalTwdAll > 0 ? ((twdSum / totalTwdAll) * 100).toFixed(1) : '0.0';
                html += `
                    <div class="travel-item-row">
                        <div class="travel-item-left">
                            <span>${catNames[catKey]}</span>
                            <span class="travel-item-sub">(${count} 筆)</span>
                        </div>
                        <div class="travel-item-right">
                            <span class="travel-item-primary">NT$ ${Math.round(twdSum).toLocaleString()}</span>
                            <span class="travel-item-sub">${pct}%</span>
                        </div>
                    </div>
                    <div class="travel-progress-bar"><div class="travel-progress-fill" style="width:${pct}%"></div></div>
                `;
            }
        });
        catListEl.innerHTML = html || '<div class="travel-item-sub">尚無分類消費資料</div>';
    }

    return true;
}

// 格式化純文字旅行開銷總報告並複製至剪貼簿
function copyTravelReportText() {
    triggerVibration(false);
    if (!calcHistory || calcHistory.length === 0) return;

    let totalTwdAll = 0;
    const byCurrency = {};
    const currSymbols = { TWD: "NT$", USD: "$", JPY: "¥", KRW: "₩", EUR: "€", CNY: "¥", MYR: "RM", SGD: "S$", AUD: "A$", VND: "₫", THB: "฿" };

    calcHistory.forEach(item => {
        if (item.category === 'business') return;
        const origCurr = item.origCurrency || item.currencyFrom || 'TWD';
        const numOrigPrice = typeof item.origPrice === 'number' ? item.origPrice : (typeof item.result === 'number' ? item.result : parseFloat(String(item.result).replace(/,/g, '')) || 0);

        let twdVal = 0;
        if (item.twdAmount !== undefined && !isNaN(Number(item.twdAmount))) {
            twdVal = Number(item.twdAmount);
        } else if (origCurr === 'TWD') {
            twdVal = numOrigPrice;
        } else {
            const twdRate = rates['TWD'] || 32.5;
            const currRate = rates[origCurr] || 1;
            twdVal = Math.round((numOrigPrice * (twdRate / currRate)) * 100) / 100;
        }
        totalTwdAll += twdVal;

        if (!byCurrency[origCurr]) byCurrency[origCurr] = { origTotal: 0, twdTotal: 0, count: 0 };
        byCurrency[origCurr].origTotal += numOrigPrice;
        byCurrency[origCurr].twdTotal += twdVal;
        byCurrency[origCurr].count += 1;
    });

    const lines = [
        `✈️ 【旅行總支出報告 (已換算 TWD)】`,
        `📅 產生時間:${new Date().toLocaleString()}`,
        `💰 旅費總開銷:NT$ ${Math.round(totalTwdAll).toLocaleString()} TWD`,
        `────────────────────`,
        `🔱 多幣別消費統計:`
    ];

    Object.keys(byCurrency).forEach(code => {
        const info = byCurrency[code];
        const sym = currSymbols[code] || '';
        const pct = totalTwdAll > 0 ? ((info.twdTotal / totalTwdAll) * 100).toFixed(1) : '0.0';
        lines.push(`• ${code}: ${sym} ${info.origTotal.toLocaleString()} → 約 NT$ ${Math.round(info.twdTotal).toLocaleString()} TWD (${pct}%)`);
    });

    lines.push(`────────────────────`);
    lines.push(`📱 感謝使用 Premium 計算機 - 多幣別自動歸檔系統`);

    copyTextToClipboard(lines.join('\n'));
    showToastMsg(I18N[currentLang].toastReportCopied || "已複製旅行總支出報告!");
}

四、深度調校剖析:AI 語意防誤判與視覺比例防崩

在 AI 模型語意輔助與多幣別長條圖介面渲染的實踐中,我們融入了嚴謹的防誤判與排版邏輯:

AI 語意邊界防禦與免稅購物校正:
在海外消費中,最常見的誤判是去藥妝店購買伴手禮時,因品名包含「軟糖」或「巧克力」而被粗暴歸納為飲食。我們的模型規則建立了店家優先權重:若交易來源為藥妝店、免稅店或綜合百貨,哪怕品項包含食品,系統一律強制修正為購物類別。這確保了個人出國採購伴手禮的費用不會扭曲日常真實伙食開銷的統計比例。

除以零防禦與百分比浮點修約:
當歷史清單中僅有單筆開銷或總額為零時,若直接計算各項目的百分比,極易觸發非數值或無窮大的計算錯誤。系統在所有比例運算中均加入了總額大於零的條件防護,並將百分比統一修約至小數點後第一位。這確保了進度條寬度在零至一百之間嚴格受控,即使在極端邊界數值下,長條圖依然能精準貼合容器。

原幣對齊與台幣換算雙重呈現:
在商務核銷的真實核對中,使用者既需要知道當地貨幣的原始面額(如日本發票上的原幣含稅金額),又需要清楚折算後的本幣價值。儀表板採用了主次分明的雙列文字排版:主標題以清晰字體展示原幣符號與原始總額,副標題則以低飽和度色彩標註折合台幣金額與佔比百分比,兼顧了單據核驗與總帳宏觀審視。

五、總結

今天我們成功實作了跨國多幣別旅行開銷儀表板、多維度動態進度條系統,並正式引入 AI 模型消費語意辨別引擎。透過即時匯率矩陣自動錨定與智慧分類輔助,消除了跨國差旅中多種外幣混雜與外文發票記帳不清的雙重焦慮;結合幣別與生活分類的圖形化展示,讓使用者在旅途中隨時掌握財務全貌,並能一鍵產出結構優雅的摘要報告。

然而,手動在計算機上輸入數字、切換幣別與標記分類,哪怕有了 AI 語意輔助,依然需要人工敲擊鍵盤。當我們站在便利商店櫃檯前或採購完畢接過一大張印滿日文、英文或雙方塊條碼的實體發票時,有沒有一種終極可能:連數字都不用按,直接拿起手機對著發票拍一張照片,所有品名、數量、金額與幣別就能在兩秒鐘內自動辨識、翻譯並歸檔入帳?明天開始,我們將正式進入全系列最震撼的終極大魔王——AI 視覺發票掃描與多國收據辨識中心!

明日預告

出國採購拿到厚厚一疊發票,一張一張手動敲鍵盤記帳實在太累人。難道不能直接拿起相機對著發票拍一張照,系統就自動幫我們把所有品項、外幣與數量讀取出來嗎?在行動端開相機又會遇到哪些權限攔截與記憶體發燙的技術難題?明天我們將揭開首頁相機入口、視訊串流安全釋的神秘面紗,敬請期待!


上一篇
Day 20:【高階商務計算機】讓靜態表格自己說話——Excel 動態連動公式與自製原生圓餅圖導引
系列文
液態玻璃美學:結合 Gemini 與 Antigravity IDE,打造 120FPS、多視窗比例與網格順延推擠佈局的強大商務運算終端21
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言