diff --git a/.changeset/fix-cdn-preflight-if-none-match.md b/.changeset/fix-cdn-preflight-if-none-match.md new file mode 100644 index 000000000..07228c86b --- /dev/null +++ b/.changeset/fix-cdn-preflight-if-none-match.md @@ -0,0 +1,5 @@ +--- +'@app/ratewise': patch +--- + +修正匯率 CDN 請求因 preflight 失敗而過度降級至 GitHub Raw 的問題,提升主 CDN 命中率與載入穩定性 diff --git a/.changeset/fix-moneybox-cdn-etag.md b/.changeset/fix-moneybox-cdn-etag.md new file mode 100644 index 000000000..99d7c7e4e --- /dev/null +++ b/.changeset/fix-moneybox-cdn-etag.md @@ -0,0 +1,5 @@ +--- +'@app/ratewise': patch +--- + +移除 moneybox CDN fetchFromCDN 的 ETag/304 條件式請求,統一以 HTTP cache 與 client 端 TTL 管理重複請求;更新 OpenData 與 SeoTech 頁面說明文字以反映新行為 diff --git a/.changeset/fix-nitro-theme-ssot.md b/.changeset/fix-nitro-theme-ssot.md new file mode 100644 index 000000000..ff284e544 --- /dev/null +++ b/.changeset/fix-nitro-theme-ssot.md @@ -0,0 +1,5 @@ +--- +'@app/ratewise': patch +--- + +對齊 nitro 主題色票 SSOT:themes.ts primary 從 #00D4FF 改為 #0096E6,與 index.css 保持一致 diff --git a/.changeset/fix-ratewise-pwa-ios-eviction.md b/.changeset/fix-ratewise-pwa-ios-eviction.md new file mode 100644 index 000000000..4322d4802 --- /dev/null +++ b/.changeset/fix-ratewise-pwa-ios-eviction.md @@ -0,0 +1,5 @@ +--- +'@app/ratewise': patch +--- + +修復 iOS PWA precache 驅逐後 3s timeout 導致在線用戶看到 offline.html 的假離線問題,改用 precache-first 冷啟動策略 diff --git a/.changeset/quiet-breadcrumb-token-refactor.md b/.changeset/quiet-breadcrumb-token-refactor.md new file mode 100644 index 000000000..a442a70c0 --- /dev/null +++ b/.changeset/quiet-breadcrumb-token-refactor.md @@ -0,0 +1,5 @@ +--- +'@app/ratewise': patch +--- + +收斂內容頁返回導覽、麵包屑與核心 UI shell 的 design token,改善 PWA 與小螢幕顯示穩定性。 diff --git a/.changeset/ratewise-a11y-touch-target-focus.md b/.changeset/ratewise-a11y-touch-target-focus.md new file mode 100644 index 000000000..f7c89f418 --- /dev/null +++ b/.changeset/ratewise-a11y-touch-target-focus.md @@ -0,0 +1,5 @@ +--- +'@app/ratewise': patch +--- + +無障礙與操作體驗改善:評分視窗星星按鈕、開放資料頁複製按鈕、Email 連結與計算機關閉鈕補齊 44px 觸控目標與鍵盤焦點指示;小型標籤與代碼區塊圓角統一為設計系統規格。 diff --git a/.changeset/ratewise-artifact-ssot.md b/.changeset/ratewise-artifact-ssot.md new file mode 100644 index 000000000..c2440f801 --- /dev/null +++ b/.changeset/ratewise-artifact-ssot.md @@ -0,0 +1,5 @@ +--- +'@app/ratewise': patch +--- + +Clarify generated artifact buckets and remove local build report files from tracked source. diff --git a/.changeset/ratewise-currency-route-registry.md b/.changeset/ratewise-currency-route-registry.md new file mode 100644 index 000000000..1ccd9aa45 --- /dev/null +++ b/.changeset/ratewise-currency-route-registry.md @@ -0,0 +1,5 @@ +--- +'@app/ratewise': patch +--- + +Consolidate currency landing route registration behind a registry with parity checks. diff --git a/.changeset/ratewise-data-pr-governance.md b/.changeset/ratewise-data-pr-governance.md new file mode 100644 index 000000000..fad31a664 --- /dev/null +++ b/.changeset/ratewise-data-pr-governance.md @@ -0,0 +1,5 @@ +--- +'@app/ratewise': patch +--- + +Strengthen scheduled rate data update governance by requiring generated data PRs to pass branch protection before merge. diff --git a/.changeset/ratewise-design-token-ssot.md b/.changeset/ratewise-design-token-ssot.md new file mode 100644 index 000000000..0770af7a7 --- /dev/null +++ b/.changeset/ratewise-design-token-ssot.md @@ -0,0 +1,5 @@ +--- +'@app/ratewise': patch +--- + +收斂設計 Token 為語義化 SSOT:圓角與陰影全站統一為語義 token,恢復 Tailwind 標準預設值避免第三方整合風險 diff --git a/.changeset/ratewise-error-classification-cross-browser.md b/.changeset/ratewise-error-classification-cross-browser.md new file mode 100644 index 000000000..5a9444f54 --- /dev/null +++ b/.changeset/ratewise-error-classification-cross-browser.md @@ -0,0 +1,5 @@ +--- +'@app/ratewise': patch +--- + +errorClassification 補上 Firefox 與 iOS / macOS Safari 多種 fetch 失敗訊息覆蓋(離線、連線中斷、DNS 失敗、無法連線),避免一般網路失敗在跨瀏覽器下被誤分類為 unknown;同步修正刷新腳本 cache 分支幣別數量輸出。 diff --git a/.changeset/ratewise-error-observability.md b/.changeset/ratewise-error-observability.md new file mode 100644 index 000000000..aca0af716 --- /dev/null +++ b/.changeset/ratewise-error-observability.md @@ -0,0 +1,5 @@ +--- +'@app/ratewise': patch +--- + +改善正式版全域錯誤分類,避免一般網路錯誤被誤判為預期歷史匯率缺檔 diff --git a/.changeset/ratewise-exchange-shop-ratemode-nav-header.md b/.changeset/ratewise-exchange-shop-ratemode-nav-header.md new file mode 100644 index 000000000..c0cf9153c --- /dev/null +++ b/.changeset/ratewise-exchange-shop-ratemode-nav-header.md @@ -0,0 +1,5 @@ +--- +'@app/ratewise': patch +--- + +修正換錢所匯率模式選價:選「中間價」時正確以(買入+賣出)÷2 計算、選「賣出」時雙向採用賣出價,趨勢圖與多幣別列同步跟隨所選模式;內容頁頂部「返回+麵包屑」導覽改為不固定的圓角按鈕樣式,修復部分 iOS PWA 用戶頂部內容被遮擋的問題。 diff --git a/.changeset/ratewise-fallback-snapshot.md b/.changeset/ratewise-fallback-snapshot.md new file mode 100644 index 000000000..d8d60f5ba --- /dev/null +++ b/.changeset/ratewise-fallback-snapshot.md @@ -0,0 +1,5 @@ +--- +'@app/ratewise': patch +--- + +讓 build-time fallback 匯率快照由每日資料更新流程維護,避免一般 build 產生匯率資料漂移;同時在線上遠端匯率來源全失敗且本機無快取時,改用 build-time snapshot 維持換算器可用。 diff --git a/.changeset/ratewise-moneybox-aggregate-cleanup.md b/.changeset/ratewise-moneybox-aggregate-cleanup.md new file mode 100644 index 000000000..fcebc1655 --- /dev/null +++ b/.changeset/ratewise-moneybox-aggregate-cleanup.md @@ -0,0 +1,5 @@ +--- +'@app/ratewise': patch +--- + +換錢所 aggregate 趨勢線:移除說明性註解、抽出 daily fallback helper、收斂兩處 cache 寫入為單一寫入點,行為不變。 diff --git a/.changeset/ratewise-moneybox-aggregate-trend.md b/.changeset/ratewise-moneybox-aggregate-trend.md new file mode 100644 index 000000000..a9f74dd9b --- /dev/null +++ b/.changeset/ratewise-moneybox-aggregate-trend.md @@ -0,0 +1,5 @@ +--- +'@app/ratewise': patch +--- + +換錢所(MoneyBox)趨勢線改用 aggregate endpoint:與台銀 history-30d.json SSOT 一致,命中時 30 個 daily fetch 收斂為 1 個(runtime AB 量到 50→1 requests、~5,049ms→~2ms)。aggregate 不存在時自動退回原本逐日 fetch 路徑,行為無回歸。 diff --git a/.changeset/ratewise-multi-rate-label.md b/.changeset/ratewise-multi-rate-label.md new file mode 100644 index 000000000..a01b7c08d --- /dev/null +++ b/.changeset/ratewise-multi-rate-label.md @@ -0,0 +1,5 @@ +--- +'@app/ratewise': patch +--- + +修正多幣別頁面在換錢所反向匯率、現金不可用與小字低對比情境下的匯率來源標籤。 diff --git a/.changeset/ratewise-opendata-mobile-overflow.md b/.changeset/ratewise-opendata-mobile-overflow.md new file mode 100644 index 000000000..4e856b39b --- /dev/null +++ b/.changeset/ratewise-opendata-mobile-overflow.md @@ -0,0 +1,5 @@ +--- +'@app/ratewise': patch +--- + +修正開放資料頁 API 端點路徑在小螢幕(320px 等窄視口)造成的水平溢出,長路徑現在會正確斷行。 diff --git a/.changeset/ratewise-production-surface.md b/.changeset/ratewise-production-surface.md new file mode 100644 index 000000000..1460e3ba6 --- /dev/null +++ b/.changeset/ratewise-production-surface.md @@ -0,0 +1,5 @@ +--- +'@app/ratewise': patch +--- + +正式版不再輸出內部展示與測試頁面的預渲染路由 diff --git a/.changeset/ratewise-pwa-shell-brand-sync.md b/.changeset/ratewise-pwa-shell-brand-sync.md new file mode 100644 index 000000000..2866c9e6d --- /dev/null +++ b/.changeset/ratewise-pwa-shell-brand-sync.md @@ -0,0 +1,5 @@ +--- +'@app/ratewise': patch +--- + +PWA shell 品牌色同步至 Violet 主題,CDN 備援日誌降噪至 debug 等級 diff --git a/.changeset/ratewise-radius-ssot-guard.md b/.changeset/ratewise-radius-ssot-guard.md new file mode 100644 index 000000000..580d7c9d1 --- /dev/null +++ b/.changeset/ratewise-radius-ssot-guard.md @@ -0,0 +1,5 @@ +--- +'@app/ratewise': patch +--- + +視覺一致性收斂:匯率卡趨勢圖底部圓角對齊卡片外框、骨架載入與小型元素圓角統一為設計系統規格,並新增圓角防回歸守門測試。 diff --git a/.changeset/ratewise-sentry-fetch-filter-ssot.md b/.changeset/ratewise-sentry-fetch-filter-ssot.md new file mode 100644 index 000000000..d38d34e49 --- /dev/null +++ b/.changeset/ratewise-sentry-fetch-filter-ssot.md @@ -0,0 +1,5 @@ +--- +'@app/ratewise': patch +--- + +Sentry `beforeSend` 改用 `classifyUnhandledRejection` 判斷 fetch 失敗,與 errorClassification SSOT 收斂;Firefox / Safari 的網路失敗訊息不再重複送到 Sentry。 diff --git a/.changeset/ratewise-ssg-snapshot-source.md b/.changeset/ratewise-ssg-snapshot-source.md new file mode 100644 index 000000000..0921ab850 --- /dev/null +++ b/.changeset/ratewise-ssg-snapshot-source.md @@ -0,0 +1,5 @@ +--- +'@app/ratewise': patch +--- + +修正金額頁 SSG 預渲染匯率來源,clean checkout 不再依賴 ignored 的 public/rates.json。 diff --git a/.changeset/ratewise-sw-bounded-nav.md b/.changeset/ratewise-sw-bounded-nav.md new file mode 100644 index 000000000..ec8b3accb --- /dev/null +++ b/.changeset/ratewise-sw-bounded-nav.md @@ -0,0 +1,5 @@ +--- +'@app/ratewise': patch +--- + +離線/弱網下載入更穩定,不再可能卡白屏。 diff --git a/.changeset/split-meow-expense-currency-snapshot.md b/.changeset/split-meow-expense-currency-snapshot.md new file mode 100644 index 000000000..7e7ea1dfd --- /dev/null +++ b/.changeset/split-meow-expense-currency-snapshot.md @@ -0,0 +1,5 @@ +--- +'@app/split-meow': patch +--- + +每筆支出記錄記帳當下的幣別與匯率,切換顯示幣別後歷史金額不再被錯誤換算;以韓元(₩)記帳的支出會同時顯示對應的台幣參考金額。 diff --git a/.github/workflows/ratewise-production-governance.yml b/.github/workflows/ratewise-production-governance.yml new file mode 100644 index 000000000..77a86f769 --- /dev/null +++ b/.github/workflows/ratewise-production-governance.yml @@ -0,0 +1,53 @@ +name: RateWise Production Governance + +on: + workflow_dispatch: + schedule: + - cron: '17 20 * * *' + +permissions: + contents: read + +jobs: + production-governance: + name: Live Headers And Performance Gates + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + PLAYWRIGHT_BASE_URL: https://app.haotool.org + E2E_BASE_PATH: /ratewise + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + with: + version: 9.10.0 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '24' + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Install Playwright Chromium + run: pnpm --filter @app/ratewise exec playwright install --with-deps chromium + + - name: Verify Cloudflare headers and cache policy + run: | + RUN_PRODUCTION_TESTS=true \ + pnpm --filter @app/ratewise exec playwright test \ + tests/e2e/cloudflare-cache.spec.ts \ + --project=chromium-desktop + + - name: Verify trend chart latency budget + run: | + RUN_RATEWISE_PERFORMANCE_TESTS=true \ + pnpm --filter @app/ratewise exec playwright test \ + tests/e2e/trend-chart-latency.spec.ts \ + --project=chromium-desktop diff --git a/.github/workflows/update-moneybox-rates.yml b/.github/workflows/update-moneybox-rates.yml index ec4122c0e..6878c9b4b 100644 --- a/.github/workflows/update-moneybox-rates.yml +++ b/.github/workflows/update-moneybox-rates.yml @@ -36,6 +36,7 @@ jobs: MONEYBOX_FETCH_OUTPUT_FILE: .moneybox-current-fetch.json MONEYBOX_LATEST_FILE: public/rates/providers/moneybox/latest.json MONEYBOX_HISTORY_DIR: public/rates/providers/moneybox/history + MONEYBOX_AGGREGATE_FILE: public/rates/providers/moneybox/history-30d.json MONEYBOX_RETIRED_LATEST_FILE: public/rates/moneybox.json MONEYBOX_RETIRED_HISTORY_DIR: public/rates/moneybox-history MONEYBOX_CDN_DATA_BASE: https://cdn.jsdelivr.net/gh/haotool/app@data @@ -93,13 +94,26 @@ jobs: id: save-history if: steps.fetch-rates.outcome == 'success' run: | - CURRENT_DATE=$(TZ=Asia/Taipei date +%Y-%m-%d) - MONEYBOX_HISTORY_FILE="${MONEYBOX_HISTORY_DIR}/${CURRENT_DATE}.json" - mkdir -p "$MONEYBOX_HISTORY_DIR" if [[ ! -f "$MONEYBOX_FETCH_OUTPUT_FILE" ]]; then echo "❌ Current MoneyBox fetch snapshot missing: ${MONEYBOX_FETCH_OUTPUT_FILE}" exit 1 fi + # history 檔名 SSOT:直接採用資料本身宣告的首爾掛牌日(updateTime), + # 與 fetch script 的 date-rollover 判斷 (extractSeoulSnapshotDate) 同源, + # 避免 runner wall-clock 與資料日期在跨日視窗不一致。提取失敗時 fallback 首爾 wall-clock。 + CURRENT_DATE=$(node --input-type=module -e " + import { readFileSync } from 'node:fs'; + const { extractSeoulSnapshotDate } = await import('./scripts/fetch-moneybox-rates.js'); + const data = JSON.parse(readFileSync(process.env.MONEYBOX_FETCH_OUTPUT_FILE, 'utf8')); + const d = extractSeoulSnapshotDate(data); + if (d) process.stdout.write(d); + ") + if [[ ! "$CURRENT_DATE" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then + CURRENT_DATE=$(TZ=Asia/Seoul date +%Y-%m-%d) + echo "⚠️ 無法從資料提取首爾 snapshot date,fallback 至首爾 wall-clock: ${CURRENT_DATE}" + fi + MONEYBOX_HISTORY_FILE="${MONEYBOX_HISTORY_DIR}/${CURRENT_DATE}.json" + mkdir -p "$MONEYBOX_HISTORY_DIR" if [[ ! -f "$MONEYBOX_HISTORY_FILE" ]] || [[ -n "$(git status --short --untracked-files=all -- "$MONEYBOX_LATEST_FILE")" ]]; then cp "$MONEYBOX_FETCH_OUTPUT_FILE" "$MONEYBOX_HISTORY_FILE" echo "✅ MoneyBox history snapshot refreshed: ${MONEYBOX_HISTORY_FILE}" @@ -134,10 +148,59 @@ jobs: if [[ -n "$(git status --short --untracked-files=all -- "$MONEYBOX_HISTORY_DIR/")" ]]; then echo "changed=true" >> $GITHUB_OUTPUT fi + if [[ ! -f "$MONEYBOX_AGGREGATE_FILE" ]]; then + echo "changed=true" >> $GITHUB_OUTPUT + fi if [[ "$RETIRED_ALIASES_CHANGED" == "true" ]]; then echo "changed=true" >> $GITHUB_OUTPUT fi + - name: Generate moneybox aggregate 30-day history + if: steps.fetch-rates.outcome == 'success' && steps.git-check.outputs.changed == 'true' + run: | + node --input-type=module << 'SCRIPT' + import { readdirSync, readFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'; + import { dirname, join } from 'node:path'; + + const HISTORY_DIR = process.env.MONEYBOX_HISTORY_DIR; + const OUTPUT_PATH = process.env.MONEYBOX_AGGREGATE_FILE; + const MAX_DAYS = 30; + + if (!existsSync(HISTORY_DIR)) { + console.error(`❌ History dir not found: ${HISTORY_DIR}`); + process.exit(1); + } + + const files = readdirSync(HISTORY_DIR) + .filter((f) => /^\d{4}-\d{2}-\d{2}\.json$/.test(f)) + .sort() + .reverse() + .slice(0, MAX_DAYS); + + const snapshots = []; + for (const file of files) { + const date = file.replace(/\.json$/, ''); + try { + const raw = JSON.parse(readFileSync(join(HISTORY_DIR, file), 'utf-8')); + snapshots.push({ date, raw }); + } catch (e) { + console.warn(`⚠️ Skip ${file}: ${e.message}`); + } + } + + const aggregate = { + providerId: 'moneybox', + generatedAt: new Date().toISOString(), + snapshots, + }; + + const outDir = dirname(OUTPUT_PATH); + if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true }); + writeFileSync(OUTPUT_PATH, JSON.stringify(aggregate, null, 2) + '\n'); + + console.log(`✅ moneybox aggregate: ${snapshots.length} days → ${OUTPUT_PATH}`); + SCRIPT + - name: Commit and push changes if: steps.fetch-rates.outcome == 'success' && steps.git-check.outputs.changed == 'true' env: @@ -145,10 +208,10 @@ jobs: run: | git config --local user.email "github-actions[bot]@users.noreply.github.com" git config --local user.name "github-actions[bot]" - # 只 add 當日 history 檔,避免 git checkout origin/main -- public/rates/ 後 - # 把 data branch 既有的前幾日 history snapshot 一併 stage 成刪除(會清掉公開 - # MoneyBox history API 與趨勢圖前日資料)。SSOT:每次只動當日 snapshot。 - git add "$MONEYBOX_LATEST_FILE" "$TODAY_HISTORY_FILE" + # 只 add 當日 history 檔與 aggregate,避免 git checkout origin/main -- public/rates/ + # 後把 data branch 既有的前幾日 history snapshot 一併 stage 成刪除(會清掉公開 + # MoneyBox history API 與趨勢圖前日資料)。SSOT:每次只動當日 snapshot + aggregate。 + git add "$MONEYBOX_LATEST_FILE" "$TODAY_HISTORY_FILE" "$MONEYBOX_AGGREGATE_FILE" # 取得更新資訊 UPDATE_TIME=$(node -p "JSON.parse(require('fs').readFileSync(process.env.MONEYBOX_LATEST_FILE)).updateTime") @@ -205,8 +268,9 @@ jobs: run: | PURGE_URL="${MONEYBOX_PURGE_DATA_BASE}/${MONEYBOX_LATEST_FILE}" HISTORY_PURGE_URL="${MONEYBOX_PURGE_DATA_BASE}/${MONEYBOX_HISTORY_DIR}/${CURRENT_DATE}.json" + AGGREGATE_PURGE_URL="${MONEYBOX_PURGE_DATA_BASE}/${MONEYBOX_AGGREGATE_FILE}" echo "🧹 Purging jsDelivr CDN cache..." - for URL in "$PURGE_URL" "$HISTORY_PURGE_URL"; do + for URL in "$PURGE_URL" "$HISTORY_PURGE_URL" "$AGGREGATE_PURGE_URL"; do for i in 1 2 3; do HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$URL") if [ "$HTTP_STATUS" = "200" ]; then @@ -228,7 +292,7 @@ jobs: git rebase --abort 2>/dev/null || true for i in 1 2 3; do echo "🔄 Refreshing MoneyBox provider files from remote data branch (try $i/3)..." - if git fetch origin data && git checkout origin/data -- "$MONEYBOX_LATEST_FILE" "$MONEYBOX_HISTORY_DIR"; then + if git fetch origin data && git checkout origin/data -- "$MONEYBOX_LATEST_FILE" "$MONEYBOX_HISTORY_DIR" "$MONEYBOX_AGGREGATE_FILE"; then echo "✅ Remote refresh successful" exit 0 fi diff --git a/.github/workflows/update-seo-rate-examples.yml b/.github/workflows/update-seo-rate-examples.yml index 37644d12b..1f6f3f808 100644 --- a/.github/workflows/update-seo-rate-examples.yml +++ b/.github/workflows/update-seo-rate-examples.yml @@ -1,12 +1,12 @@ # SEO 匯差範例數據每日更新 # -# 職責:每日抓取最新臺灣銀行牌告匯率,計算各幣別現金匯率與市場中間價差距, -# 更新 apps/ratewise/src/config/generated/seo-rate-examples.ts。 +# 職責:每日抓取最新臺灣銀行牌告匯率,更新 runtime fallback snapshot, +# 並計算各幣別現金匯率與市場中間價差距。 # # 流程(業界最佳實踐 — PR-based,不直接 push main): # 1. 執行腳本(雙重驗證:台銀牌告 + open.er-api.com) # 2. 若有變更,peter-evans/create-pull-request@v8 建立 PR 分支 -# 3. PR 建立後直接 squash merge 至 main +# 3. 交由 branch protection / required checks / maintainer review 或 GitHub auto-merge 合併 # 4. 無變更時不建立 PR,靜默略過 # # 為何不直接 push main: @@ -51,6 +51,9 @@ jobs: - name: 安裝依賴 run: pnpm install --frozen-lockfile + - name: 更新 build-time fallback 匯率 snapshot + run: pnpm --filter @app/ratewise refresh:fallback-rates + - name: 執行匯差範例更新腳本(雙重驗證模式) run: node apps/ratewise/scripts/update-seo-rate-examples.mjs @@ -64,6 +67,7 @@ jobs: commit-message: | chore(seo): 每日更新匯差範例數據 + - 自動更新 build-time fallback 匯率 snapshot - 自動更新各幣別現金賣出 vs 市場中間價差距(換 3 萬台幣情境) - 雙重驗證:open.er-api.com 中間價 vs 台銀自身 (買入+賣出)/2 中間價 - 差距超過 2% 幣別已警告(東南亞幣別預期偏高) @@ -73,7 +77,7 @@ jobs: delete-branch: true title: 'chore(seo): 每日更新匯差範例數據' body: | - ## 自動更新:SEO 匯差範例數據 + ## 自動更新:RateWise 匯率 fallback 與 SEO 匯差範例數據 由 GitHub Actions 排程每日自動執行。腳本:[`update-seo-rate-examples.mjs`](apps/ratewise/scripts/update-seo-rate-examples.mjs) @@ -91,21 +95,17 @@ jobs: ### 自動化 - - PR 建立後 **直接 squash merge** 至 `main` + - PR 建立後交由 branch protection、required checks 與 maintainer review 控制合併 + - 若 repo 啟用 GitHub auto-merge / merge queue,應由 GitHub 在條件滿足後合併 - 無變更時不建立 PR,靜默略過 - 幣別資料缺漏時腳本 `exit(1)`,工作流程明確失敗 labels: | automated seo add-paths: | + apps/ratewise/src/config/generated/build-time-rates.json apps/ratewise/src/config/generated/seo-rate-examples.ts - - name: 合併 PR(squash) - if: steps.create-pr.outputs.pull-request-number != '' - run: gh pr merge --squash "${{ steps.create-pr.outputs.pull-request-number }}" - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: 無變更 if: steps.create-pr.outputs.pull-request-number == '' run: echo "ℹ️ 匯差範例數據無變更,略過建立 PR。" diff --git a/.gitignore b/.gitignore index a7e5f240d..10a3d9eb0 100644 --- a/.gitignore +++ b/.gitignore @@ -128,6 +128,9 @@ squirrel.toml .agents/skills/*/references/ .agents/skills/*/AGENTS.md +# Third-party local skill bundles (tool-synced, not project source) +.agents/skills/impeccable/ + # AI tool local skill copies (synced from .agents/skills/) .claude/ .codex/ diff --git a/AGENTS.md b/AGENTS.md index 2df920e79..33dc3e43d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -460,9 +460,19 @@ git push origin main # pre-push 自動驗證 - `public/*.md`(markdown mirrors)版本號由完整 build 更新;`pnpm changeset:version` 不觸發此步驟,故 mirrors 版本會暫時落後一個版本。 - `src/config/generated/`(build-time-rates.json、seo-rate-examples.ts)由每日 SEO 排程更新。 +- `apps/ratewise/lighthouse-report.json` 與 `apps/ratewise/*.tsbuildinfo` 屬本機工具輸出;已由 `.gitignore` 管理,必須保持 untracked。 - **MUST NOT**:把上述兩類修改單獨建立 release 後的 follow-up commit,`verify-version-ssot` 會因為 staged set 內沒有 version bump 或 changeset 而擋下。 - **MUST**:commit 失敗後必須重新執行 `git restore --staged --worktree ` 再重試;lint-staged 的 stash/restore 循環會把失敗前的 working tree 狀態還原,使已 restore 的修改重新出現。 +### RateWise Generated Artifact Buckets(SSOT) + +- `pnpm --filter @app/ratewise refresh:data`:更新 live snapshots(build-time rates、SEO rate examples、rating snapshot)。 +- `pnpm --filter @app/ratewise refresh:fallback-rates`:只更新 committed runtime fallback rate snapshot。 +- `pnpm --filter @app/ratewise generate:deterministic`:由 repo SSOT 重建 sitemap、manifest、offline shell、LLMs text、Markdown mirrors、API JSON 與 OpenAPI。 +- `pnpm --filter @app/ratewise verify:artifacts`:執行 SSOT sync 與 image resource 檢查。 +- `pnpm --filter @app/ratewise prebuild`:只執行 deterministic generation、artifact verification 與 rating placeholder refresh;禁止把 tracked live rate refresh 塞回單一長命令。 +- `update-seo-rate-examples.yml`:只建立資料更新 PR;合併必須交由 branch protection、required checks、review 或 GitHub auto-merge / merge queue 控制,workflow 不得直接執行 `gh pr merge`。 + ### Release PR 自動化失敗治理 **觸發條件**:main 累積 `.changeset/*.md`,但 package version / CHANGELOG 長期未更新;或 `Release` workflow 顯示 success 但未建立 `changeset-release/main` PR。 @@ -791,5 +801,30 @@ Agent 在結案或提交時,應能提供下列證據(依任務適用性) --- +## Learned User Preferences + +- 偏好極簡 UI 設計:卡片與按鈕不加外框(border/outline),保持乾淨俐落 +- 多幣別轉換器中,非主幣項目使用極淺背景色 token,避免視覺突兀 +- 匯率顯示採 Google 模式:上方為原始幣、下方為目標幣,顯示買入價與其倒數,不使用「買入/賣出」術語 +- 匯率來源切換為三態(即期/現金/換錢所),無資料者灰色顯示並自動 fallback 至下一個可用來源 +- RateWise UI/UX 標竿為韓系金融 App(Toss、Wowpass),並行多面向審查追求高真度體驗 +- Kawaii 主題色票偏好較淺、較飽和粉嫩,避免暗沉色;Nitro/Ocean 等深色主題 primary 按鈕使用淺白字 +- 頁面資訊應精簡去重,避免重複雜亂堆疊;單幣別輸入應減少 Modal 閘門與多步摩擦 +- 所有修復需保持原子化,不得順便重構或加入不必要註解 +- 期望持續迭代直到完美收斂:監控 Codex review 並逐一解決、推送、驗證 + +## Learned Workspace Facts + +- `docs/prompt/UIUX.md` 為 UI/UX 變更的正式審查清單,用於審視未提交改動 +- `feat/ratewise-fintech-uiux-p0` 為最新 RateWise UI/UX 分支(含 governance 與計價基準 pill);若被 worktree 占用,開發/審查使用 `.claude/worktrees/ratewise-uiux-p0` +- 2026 UX 規格與 Phase 1 計畫見 `docs/superpowers/specs/` 與 `docs/superpowers/plans/` +- 品牌名稱 SSOT 為「HaoRate 匯率好工具」 +- RateWise 趨勢圖曾因 `TREND_CHART_DEFER_MS = 10000` 導致感知延遲,需保持 defer 時間在合理範圍 +- SEO 生產驗證三腳本組合:`verify-production-resources.mjs`(資源可達)→ `verify-all-apps.mjs`(語義)→ `verify-precache-assets.mjs`(PWA live) +- Codex review 收斂為標準工作流:使用 `pnpm review:codex:audit` 盤點 → `gh` 逐條回覆 → 標記 resolved +- 使用者會透過瀏覽器元素選取(browser_element)直接指定要修改的 UI 節點 + +--- + **最後更新**: 2026-05-02T09:00:00+0800 **版本**: v5.5(SEO 迭代 SOP 與監控治理補齊) diff --git a/CLAUDE.md b/CLAUDE.md index ad6c3a95c..c0ae9f4fc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -146,6 +146,17 @@ git push origin main # pre-push 自動跑 typecheck + test + build 禁止:手動改版號、單獨跑 prebuild scripts、直接改 CHANGELOG 跳過 changeset。 +**RateWise generated artifact buckets**: + +- `pnpm --filter @app/ratewise refresh:data`:live snapshots + (`build-time-rates.json`、`seo-rate-examples.ts`、`rating-snapshot.ts`)。 +- `pnpm --filter @app/ratewise refresh:fallback-rates`:只更新 committed runtime fallback rate snapshot。 +- `pnpm --filter @app/ratewise generate:deterministic`:repo SSOT 可重建產物 + (sitemap、manifest、offline shell、LLMs text、Markdown mirrors、API JSON、OpenAPI)。 +- `pnpm --filter @app/ratewise verify:artifacts`:SSOT sync 與 image resource 檢查。 +- `prebuild` 只執行 deterministic generation、artifact verification 與 rating placeholder refresh;不得刷新 tracked live rate data。`lighthouse-report.json`、`*.tsbuildinfo` 屬本機工具輸出,必須保持 untracked。 +- `update-seo-rate-examples.yml` 只建立資料更新 PR;合併交由 branch protection、required checks、review 或 GitHub auto-merge / merge queue 控制,workflow 不得直接執行 `gh pr merge`。 + **Release PR 自動化控制**: - `changesets/action` 的 release commit 必須使用 commitlint 豁免格式:`chore(release): 更新版本套件` @@ -277,6 +288,8 @@ gh pr merge --squash --delete-branch=false **發版後 `public/*.md` 或 generated 檔案觸發 SSOT 守門失敗**:`pnpm changeset:version` 只更新 api/latest.json 等 SSOT 產出物,不重新生成 markdown mirrors(`public/*.md`);若這些修改殘留並另行 commit,`verify-version-ssot` 會因新 staged set 缺少 version bump 或 changeset 而擋下。修法:`git restore --staged --worktree apps/ratewise/public/*.md apps/ratewise/src/config/generated/`,讓 CI build 與每日 SEO 排程重新生成。 +**本機 build / QA 產物出現在 git status**:`apps/ratewise/lighthouse-report.json` 與 `apps/ratewise/*.tsbuildinfo` 是工具輸出,不是 source。若它們被重新建立,保持 untracked;若意外 staged,執行 `git restore --staged `。 + **lint-staged stash/restore 循環復活已 restore 的檔案**:commit 失敗時 lint-staged 會還原其 stash,可能把已 `git restore` 的 working tree 修改重新帶回。修法:每次 commit 失敗後必須重新執行 `git restore --staged --worktree ` 再重試,不可假設檔案狀態與 restore 後相同。 ### PWA diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 000000000..396be7d32 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,231 @@ +--- +name: RateWise +description: 台灣銀行牌告導向的安靜匯率換算工具 +colors: + background: '#F8FAFC' + surface: '#FFFFFF' + surface-elevated: '#F8FAFC' + surface-sunken: '#F1F5F9' + text: '#0F172A' + text-muted: '#64748B' + primary: '#7C3AED' + secondary: '#6366F1' + accent: '#8B5CF6' + border: '#E2E8F0' + info: '#0EA5E9' + success: '#22C55E' + warning: '#F59E0B' + destructive: '#DC2626' +typography: + display: + fontFamily: 'Inter, Noto Sans TC, system-ui, -apple-system, sans-serif' + fontSize: 'clamp(1.875rem, 4vw, 2.25rem)' + fontWeight: 700 + lineHeight: 1.2 + headline: + fontFamily: 'Inter, Noto Sans TC, system-ui, -apple-system, sans-serif' + fontSize: '1.5rem' + fontWeight: 700 + lineHeight: 1.333 + title: + fontFamily: 'Inter, Noto Sans TC, system-ui, -apple-system, sans-serif' + fontSize: '1.25rem' + fontWeight: 600 + lineHeight: 1.4 + body: + fontFamily: 'Inter, Noto Sans TC, system-ui, -apple-system, sans-serif' + fontSize: '1rem' + fontWeight: 400 + lineHeight: 1.6 + label: + fontFamily: 'Inter, Noto Sans TC, system-ui, -apple-system, sans-serif' + fontSize: '0.75rem' + fontWeight: 600 + lineHeight: 1.333 + letterSpacing: '0.16em' +rounded: + sm: '4px' + md: '6px' + lg: '8px' + card: '24px' + panel: '16px' + control: '16px' + icon: '12px' + compact: '8px' + pill: '999px' +spacing: + xs: '8px' + sm: '12px' + md: '16px' + lg: '24px' + xl: '32px' +components: + button-primary: + backgroundColor: '{colors.primary}' + textColor: '{colors.surface}' + rounded: '{rounded.lg}' + padding: '0 16px' + height: '44px' + button-secondary: + backgroundColor: '{colors.surface-elevated}' + textColor: '{colors.text}' + rounded: '{rounded.lg}' + padding: '0 16px' + height: '44px' + card-panel: + backgroundColor: '{colors.surface}' + textColor: '{colors.text}' + rounded: '{rounded.card}' + padding: '20px' + notification-surface: + backgroundColor: '{colors.surface}' + textColor: '{colors.text}' + rounded: '{rounded.lg}' + padding: '14px 24px' + list-row: + backgroundColor: '{colors.surface}' + textColor: '{colors.text}' + rounded: '{rounded.lg}' + padding: '12px 16px' +--- + +## Overview + +**Creative North Star: "The Quiet Exchange Desk"** + +RateWise 的預設視覺系統是一套安靜、克制、資訊優先的產品介面。它應該像使用者在付款前最後確認匯率的工作台,不像行情盤,也不像裝飾型 SaaS 首頁。畫面首先服務判讀速度,其次才是品牌辨識。 + +預設產品語法以 `zen` 風格為基準,其他 `nitro`、`kawaii`、`classic`、`ocean`、`forest` 僅作使用者可切換的外觀變體。所有變體都必須保留相同的資訊層級、相同的元件節奏與相同的互動語意,不能用造型改寫產品心智模型。 + +這套系統明確排斥加密交易平台式的霓虹高飽和、AI 樣板 SaaS 式的 glow 與漸層文字,以及生活風格工具式的過度情緒化語氣。它的辨識度來自安靜表面、清楚數字、穩定留白與一致詞彙,不來自特效。 + +Key Characteristics: + +- 冷靜、精準、可靠。 +- 預設使用 restrained color strategy,不做大面積裝飾性漸層。 +- 同一概念在首頁、收藏、多幣別、設定、SEO 內容頁使用同一種權重與元件語法。 +- 觸控優先,但桌面版要有足夠寬度與資訊節奏,不能像手機稿被放大。 + +## Colors + +整體色彩以 violet / indigo 品牌色搭配冷靜中性色為主,將主色集中在主要操作、狀態與導引,而不是大片情緒背景。 + +### Primary + +- **Exchange Violet** (`#7C3AED`): 主要 CTA、焦點與需要立即判讀的互動元素。預設不拿來鋪滿大面積背景。 + +### Secondary + +- **Indigo Signal** (`#6366F1`): 支援型資訊、次要狀態與品牌層次,用於輔助,不與 Primary 爭主導權。 + +### Tertiary + +- **Soft Violet Accent** (`#8B5CF6`): 僅用於小範圍高亮,例如通知裝飾、局部導引與互動後的柔性回饋。 + +### Neutral + +- **Ledger Mist** (`#F8FAFC`): 頁面背景,維持乾淨但不刺眼的工作台底色。 +- **Paper Surface** (`#FFFFFF`): 主卡片與主要容器背景。 +- **Raised Surface** (`#F8FAFC`): 次層容器、segmented controls、次要按鈕背景。 +- **Sunken Surface** (`#F1F5F9`): 按壓後或低階容器的收斂層次。 +- **Ink 900** (`#0F172A`): 主要文字、標題與高重要度數字。 +- **Slate Note** (`#64748B`): 次要說明、輔助文與低權重標籤。 +- **Rule Line** (`#E2E8F0`): 邊框、分隔與可讀但不搶戲的結構線。 + +### Named Rules + +**The Quiet Surface Rule.** 背景與容器先用中性層次解決階層,再考慮色彩。若同一畫面已經靠邊框、留白與標題完成分層,就不要再加漸層或 glow。 + +## Typography + +**Display Font:** Inter, Noto Sans TC, system-ui, -apple-system, sans-serif +**Body Font:** Inter, Noto Sans TC, system-ui, -apple-system, sans-serif +**Label/Mono Font:** ui-monospace, SFMono-Regular, Menlo, Monaco, monospace + +字體策略以高可讀性的無襯線系統為主,讓中英文與數字混排時仍保持穩定節奏。數值顯示、更新時間與版本等資料型內容,應優先使用 tabular numerals 或 monospace 輔助,而不是額外裝飾。 + +### Hierarchy + +- **Display** (700, `clamp(1.875rem, 4vw, 2.25rem)`, 1.2): 頁面主標題與少數需要建立主場景的首屏標題。 +- **Headline** (700, `1.5rem`, 1.333): 核心模組標題,例如主要卡片或內容頁區塊標題。 +- **Title** (600, `1.25rem`, 1.4): 次級區塊、卡片標題與清單模組標題。 +- **Body** (400, `1rem`, 1.6): 正文與說明文。內容型頁面的段落寬度維持在約 65 至 72 字元。 +- **Label** (600, `0.75rem`, 1.333, `0.16em`): eyebrow、區塊導引與輕量 metadata。只在需要建立節奏時使用大寫標籤,不可濫用。 + +### Named Rules + +**The Number First Rule.** 與匯率、金額、更新時間相關的資訊優先度高於裝飾型標題。若文字階層與數值判讀衝突,優先讓數值更清楚。 + +## Elevation + +RateWise 的深度語法以 tonal layering 為主,陰影為輔。絕大多數層次差異先靠 `surface`、`surface-elevated`、`surface-sunken` 解決,陰影只用來表達浮起、hover 或暫時性提示,不作常態性戲劇效果。 + +### Shadow Vocabulary + +- **Resting Card** (`shadow-card`): 預設卡片與主要內容面板,值由 `shadowTokens.values.card` 統一維護。 +- **Hover Lift** (`shadow-card-hover`): hover 後的輕微浮起,只作互動回饋。 +- **Transient Surface** (`shadow-floating`): 通知、tooltip、底部工作表與臨時浮層。 + +### Named Rules + +**The Lift Must Mean State Rule.** 若一個陰影不代表 hover、焦點、暫時提示或浮層,就不應存在。不要用大陰影補救階層不清的版面。 + +## Components + +### Buttons + +- **Shape:** 互動控制預設 `rounded-control`(16px),主要尺寸高度至少 `44px`,少數輕量操作可用膠囊型圓角。 +- **Primary:** `primary` 實底配 `surface` 文字,只用於明確主動作,例如更新、確認、開始轉換。 +- **Hover / Focus:** hover 允許極輕微上浮與陰影加深;focus 使用清楚的 `ring-primary`,不得用模糊光暈取代焦點樣式。 +- **Secondary / Ghost / Danger:** 次要按鈕用 `surface-elevated` 加邊框;ghost 只在低視覺權重工具列使用;danger 維持明確語義,但版面仍要克制。 + +### Chips + +- **Style:** 小尺寸、圓角、邊框明確,優先作為 eyebrow、狀態或輕量切換標籤,而不是厚重 badge。 +- **State:** 選中狀態主要依靠邊框、底色與文字色改變,不使用高飽和螢光效果。 + +### Cards / Containers + +- **Corner Style:** 主內容面板使用 `rounded-card`(24px),次層容器與互動列使用 `rounded-panel` / `rounded-control`(16px);只允許 pill 用於 chip、badge 或膠囊型控制。 +- **Background:** 主卡片用 `surface`,次層與安靜模組用 `surface-elevated`,不要在正式產品頁中使用玻璃卡。 +- **Shadow Strategy:** 預設使用 resting card 陰影;hover 或可拖曳狀態才升到更高層級。 +- **Border:** 絕大多數卡片都有 `border-border/70` 左右的結構線,取代彩色 accent stripe。 +- **Internal Padding:** 主要面板 `20px` 至 `24px`,輕量列項 `12px` 至 `16px`。 + +### Inputs / Fields + +- **Style:** 以乾淨背景、清楚邊界與穩定留白為主,不用擬物或發光效果。 +- **Focus:** focus 由 ring 與邊框色帶出,不改變版面尺寸。 +- **Error / Disabled:** 直接用語義色與可讀文字說明,不靠震動或炫目動畫表達。 + +### Navigation + +- **Top / Bottom Navigation:** 使用半透明背景與輕微 blur 只作可讀性補強,不是視覺主角。導覽本體要融入產品殼層。 +- **Segmented / Tabs:** 活動狀態使用 `surface-elevated`、文字加重與輕陰影,不用大片主色底。 + +### Notifications + +- **Style:** 更新通知、離線提示與評分提示統一使用安靜的 `surface` 浮層,不再使用品牌漸層底。 +- **Icon Treatment:** 狀態圖標放在小型 elevated 容器中,用 `primary` 或 `warning` 文字色表達狀態。 +- **Action Pattern:** 通知內的主要與關閉操作共用同一套 action token,避免各通知自成一格。 + +## Do's and Don'ts + +- **Do:** 先用字級、留白、邊框與表面層次建立階層,再決定是否需要色彩。 +- **Do:** 讓首頁、收藏、多幣別、設定與 SEO 內容頁共用同一套 panel、row、eyebrow 與 secondary CTA 語法。 +- **Do:** 保留 `prefers-reduced-motion`、清楚 focus ring、足夠對比與至少 44px 的觸控目標。 +- **Do:** 將 theme styles 視為外觀皮膚,不得改變資訊密度、元件語意或互動模型。 +- **Don't:** 不要使用漸層文字、彩色側邊條、裝飾性 glass、過量 glow、或沒有語義的大面積品牌漸層。 +- **Don't:** 不要把金融工具做成加密交易平台、AI 樣板 SaaS,或可愛生活風首頁。 +- **Don't:** 不要靠巢狀卡片與大陰影堆層級,也不要用 8 至 10px 當作常態正文。 +- **Don't:** 不要在不同頁面為相同概念發明第二套顏色、第二套間距或第二套按鈕語法。 + +## Engineering Decisions + +### Tailwind borderRadius Alias Override + +本專案刻意將 Tailwind 標準 `rounded-sm`/`md`/`lg`/`xl` 值上調至語義 token 對齊值(如 `lg` = 1rem 而非 Tailwind 預設 0.5rem),以確保遺漏遷移的 `rounded-lg` 不會產生舊值視覺殘留。此決策的前提是 RateWise 不引入依賴 Tailwind 預設 radius 值的第三方 UI 元件。若未來整合外部元件庫,需重新評估此 override 策略。 + +### Shadow Token 淺色模式限定 + +`shadowTokens` 的陰影基底色使用硬編碼 `rgb(15 23 42 / ...)` (slate-900),僅適用淺色模式。若未來支援深色模式,陰影值需改為 CSS variable 或主題條件值。 diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 000000000..37a820fd1 --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,40 @@ +# Product + +## Register + +product + +## Users + +以台灣為主要使用情境的旅客、跨境消費者、自由工作者與小型商務使用者。 +他們通常在出國前、付款前或比價當下開啟 RateWise,希望用最短時間看懂真實換匯成本,而不是市場中間價。 + +## Product Purpose + +RateWise 提供以臺灣銀行牌告為核心的匯率換算體驗,重點是快速、可信、可重複使用。 +產品應在首頁、收藏、多幣別、歷史與 SEO 幣別頁之間維持一致心智模型,讓使用者能在手機與桌面上用最少操作完成換算、比較與回查。 + +## Brand Personality + +冷靜、精準、可靠。 +語氣應直接、節制、資訊優先,像成熟的金融工具,而不是行銷頁或加密貨幣儀表板。 + +## Anti-references + +- 不要像加密交易平台,避免霓虹、黑底高飽和、過度行情感。 +- 不要像 AI 樣板 SaaS,避免大量 glow、漸層文字、裝飾性 glass。 +- 不要像生活風格或可愛工具,避免預設介面過度玩味與情緒化。 +- 不要為了視覺新奇重做既有操作模型,標準工具行為優先。 + +## Design Principles + +- 實際換匯成本優先,任何視覺表現都不能壓過核心數字與方向。 +- 熟悉的工具型介面優先於新奇,使用者不應學習新的操作語法。 +- 資訊可密集,但層級必須清楚,次要訊息只能輔助,不能搶焦點。 +- 色彩與動效保持克制,只用於狀態、主動作與導引,不做裝飾。 +- 同一概念在所有頁面使用相同詞彙、相同權重與相同元件語言。 + +## Accessibility & Inclusion + +以 WCAG 2.2 AA 為基準,保留清楚焦點樣式、足夠對比、合理觸控尺寸與 `prefers-reduced-motion` 支援。 +繁體中文是預設語境,其他語言介面也必須維持相同資訊層級與可讀性。 diff --git a/README.md b/README.md index b9210c958..8777fc8ed 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,7 @@ haotool-app/ │ ├── release.yml # 版本發布 │ ├── seo-audit.yml # SEO 審查 │ ├── seo-production.yml # 生產環境 SEO +│ ├── ratewise-production-governance.yml # RateWise 生產治理檢查 │ ├── update-committed-seo-files.yml # SEO 產出物同步 │ ├── update-historical-rates.yml # 歷史匯率更新 │ ├── update-latest-rates.yml # 最新匯率更新 @@ -270,7 +271,7 @@ haotool Apps is a professional pnpm Monorepo containing multiple high-quality Re - **Styling**: Tailwind CSS 3.4 - **Testing**: Vitest 4.1 + Playwright 1.57 - **Package Manager**: pnpm 9.10.0 (Monorepo) -- **CI/CD**: GitHub Actions (9 workflows) +- **CI/CD**: GitHub Actions (10 workflows) - **Deployment**: Docker + Zeabur / Vercel - **Security**: Gitleaks CLI + Trivy + SARIF @@ -298,6 +299,15 @@ Production is deployed by Zeabur from GitHub main. Before merging a release PR right after another main PR, confirm the earlier production deployment has finished so an older SHA cannot become active after the release SHA. +### Generated Artifacts + +RateWise generated files are bucketed by package scripts: +`prebuild` runs deterministic generation and verification only, +`refresh:data` explicitly updates live snapshots, and +`refresh:fallback-rates` is the only script that writes the committed fallback +rate snapshot. Local tool output such as `lighthouse-report.json` and +`*.tsbuildinfo` must remain untracked. + ### License This project is licensed under [GPL-3.0](./LICENSE). diff --git a/apps/ratewise/README.md b/apps/ratewise/README.md index 01a5d23c9..6354c4ff7 100644 --- a/apps/ratewise/README.md +++ b/apps/ratewise/README.md @@ -75,10 +75,28 @@ RateWise 正式站由 Zeabur production deployment 發布,Release 完成後需 precache 驗證。若 GitHub Release 已建立但正式站仍回舊版,先查 GitHub deployments 的 active SHA,再以 app 範圍 PR 重新觸發最新 main 部署。 +## 🧱 Generated Artifact SSOT + +RateWise 將 build 產物分成三類,避免 live data、deterministic artifacts 與本機 QA +報告混在同一個 commit: + +- `pnpm --filter @app/ratewise refresh:data`:更新 live data snapshot + (`build-time-rates.json`、`seo-rate-examples.ts`、`rating-snapshot.ts`)。 +- `pnpm --filter @app/ratewise refresh:fallback-rates`:只更新已提交的 runtime + fallback 匯率 snapshot,供首次離線或 LHCI fallback 使用。 +- `pnpm --filter @app/ratewise generate:deterministic`:由 repo SSOT 重建 sitemap、 + manifest、offline shell、LLMs text、Markdown mirrors、API JSON 與 OpenAPI。 +- `pnpm --filter @app/ratewise verify:artifacts`:驗證 SSOT sync 與圖片資源。 + +`prebuild` 只執行 deterministic generation、artifact verification 與 rating placeholder +refresh,不會刷新 tracked live rate data。`lighthouse-report.json` 與 `*.tsbuildinfo` 屬本機工具輸出, +必須保持 untracked。QA 截圖集中放 `screenshots/`,正式 SEO/manifest 圖片則保留在 +`public/screenshots/`。 + ## 📄 授權 GPL-3.0 © [haotool](https://app.haotool.org/) --- -**最後更新**: 2026-04-28 +**最後更新**: 2026-05-12 diff --git a/apps/ratewise/docs/dev/002_development_reward_penalty_log.md b/apps/ratewise/docs/dev/002_development_reward_penalty_log.md index 8b7c22eb8..1ae80ba3c 100644 --- a/apps/ratewise/docs/dev/002_development_reward_penalty_log.md +++ b/apps/ratewise/docs/dev/002_development_reward_penalty_log.md @@ -2,83 +2,91 @@ **版本**: 2.1.0 **建立時間**: 2025-12-02T03:29:33+08:00 -**更新時間**: 2026-01-21T01:20:03+08:00 +**更新時間**: 2026-05-15T09:40:00+08:00 **狀態**: ✅ 完成 -**當前總分**: +137 +**當前總分**: +145 -| 類型 | 摘要 | 採取行動 | 依據 | 分數 | -| ------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | ---- | -| ✅ 成功 | SEO 修正:同步 sitemap 與 llms.txt 實作 | 1) 移除 sitemap.xml 生成腳本中已棄用的 image:caption 標籤 2) 移除 llms.txt 中的虛假評價數據,替換為真實用戶場景 3) 確保程式碼與開發日誌一致 | [Internal Audit][Google SEO Guidelines] | +3 | -| ✅ 成功 | 建立圖片管理最佳實踐系統 | 1) 新增 PR 模板檢查清單(生產環境驗證+Linus 三問) 2) CI/CD 自動檢查圖片路徑正確性 3) 建立完整圖片管理文檔 [032] 4) 檢查所有 apps 路徑統一 | [docs/dev/032_image_management_best_practices.md][LINUS_GUIDE.md] | +8 | -| ❌ 失敗 | SEO 分支合併導致級聯錯誤(20h 修復) | 1) 未驗證生產環境路徑 2) 引用不存在檔案 3) 過度優化導致複雜化 4) 6 次 commits 才修復(7b9e5c3→98d3350) | [002:2025-12-24][LINUS_GUIDE.md:Linus 三問] | -8 | -| ⚠️ 教訓 | 圖片路徑問題演進分析 | 絕對路徑→動態 BASE_URL→複雜 picture→最終簡化為相對路徑,學習 KISS 原則和消除特殊情況 | [LINUS_GUIDE.md:Good Taste][Vite Asset Handling] | 0 | -| ✅ 成功 | 修復下拉刷新功能 (PWA 快取更新) | 改用 window.location.reload() 強制重新載入頁面,確保用戶獲得最新版本 JS/CSS/HTML,移除未使用的 refresh 依賴項,版本更新 1.2.0→1.2.1 | [PWA更新最佳實踐][Linus 三問驗證] | +1 | -| ✅ 成功 | CSP inline style 違規排除 | style-src/style-src-elem 移除 hash 讓 'unsafe-inline' 生效,重跑 typecheck/test/build,確認 postbuild CSP 覆蓋 dist 與鏡像 | [context7:tsotimus/vite-plugin-csp-guard:2025-12-11T17:14Z] | +2 | -| ✅ 成功 | 重建 AI 搜尋規格並校對權威來源 | 以 curl 取樣 20 個 SEO/AI 權威來源,重置 013 規格與行動清單 | [curl:seo-authorities:2025-12-02] | +1 | -| ✅ 成功 | 首頁 FAQ 可視化並對齊 JSON-LD | 新增 4 條 FAQ 卡片(首頁可見)並同步更新 index.html FAQPage schema | [curl:seo-authorities:2025-12-02] | +1 | -| ✅ 成功 | 首個長尾落地頁 `/usd-twd` + sitemap | 新增 USD/TWD 落地頁(FAQ+HowTo+SEOHelmet)、更新路由/SSG/sitemap | [curl:seo-authorities:2025-12-02] | +1 | -| ✅ 成功 | 全面測試驗證:810 測試通過 + 瀏覽器實測 | 1) pnpm typecheck 通過 2) 810 tests 全綠 3) Preview Server 首頁/usd-twd 瀏覽器驗證 4) PWA manifest 驗證 | [context7:vite-react-ssg][context7:vite-plugin-pwa] | +2 | -| ✅ 成功 | SEO 權威網站深度研究 (MCP fetch) | 查詢 Moz/Semrush/Backlinko/SearchEngineJournal/web.dev/Google Search Central/llmstxt.org 等 10+ 來源 | [mcp:fetch:2025-12-02T03:44] | +1 | -| ✅ 成功 | BDD 紅燈→綠燈:hreflang.test.ts 修正 | 識別 `/usd-twd` 新增後 xhtml:link 從 8→10,正確更新測試預期值 | [BDD.md:Red-Green-Refactor] | +1 | -| ✅ 成功 | Context7 官方文檔驗證 | 使用 context7 取得 vite-react-ssg/vite-plugin-pwa 最新文檔,確認 SEO Head/Schema 最佳實踐 | [context7:daydreamer-riri/vite-react-ssg][context7:vite-pwa/vite-plugin-pwa] | +1 | -| ✅ 成功 | 長尾頁 JPY/TWD + EUR/TWD 實作 | 1) 新增 JPYToTWD.tsx 日圓換台幣頁 2) 新增 EURToTWD.tsx 歐元換台幣頁 3) 更新 routes.tsx 4) 更新 sitemap.xml (7 URLs) 5) 更新 hreflang.test.ts (14 links) | [BDD.md:Red-Green-Refactor][context7:vite-react-ssg] | +3 | -| ✅ 成功 | 全面測試驗證:810 tests 全通過 | npx vitest run 完成,0 failures,測試覆蓋率 92.99% | [Vitest:4.0.14][AGENTS.md:品質門檻] | +1 | -| ✅ 成功 | 長尾頁批量實作第一階段 (7 頁) | 新增 GBP/CNY/KRW/HKD/AUD/CAD/SGD 共 7 個幣別落地頁,每頁含 FAQ+HowTo+SEOHelmet | [context7:vite-react-ssg][LINUS_GUIDE.md] | +4 | -| ✅ 成功 | sitemap.xml 更新 (14 URLs) | 更新 sitemap 含 14 條 URL × 2 hreflang = 28 links | [Google Search Central:sitemap-best-practices] | +1 | -| ✅ 成功 | hreflang.test.ts BDD 測試更新 | 紅燈→綠燈:xhtml:link 從 14→28,同步更新測試預期值 | [BDD.md:Red-Green-Refactor] | +1 | -| ✅ 成功 | 長尾頁批量實作第二階段 (3 頁) | 新增 THB/NZD/CHF 共 3 個幣別落地頁,完成全部 13 個長尾頁 | [context7:vite-react-ssg][LINUS_GUIDE.md] | +2 | -| ✅ 成功 | sitemap.xml 全面完成 (17 URLs) | 更新 sitemap 含 17 條 URL × 2 hreflang = 34 links | [Google Search Central:sitemap-best-practices] | +1 | -| ✅ 成功 | 全面測試驗證:810 tests 全通過 | npx vitest run 完成,0 failures,hreflang 測試同步更新 | [Vitest:4.0.14][BDD.md:Green] | +1 | -| ✅ 成功 | 修復 vite.config.ts SSG 預渲染配置 | 同步 includedRoutes 至 17 條路徑,build 輸出 17 個 HTML | [BDD.md:Red-Green][context7:vite-react-ssg] | +2 | -| ✅ 成功 | SEO 全面代碼審查通過 | 1) TypeScript ✅ 2) ESLint ✅ 3) 897 tests ✅ 4) Build 17 HTML ✅ 5) 權威來源驗證 (Google/Schema.org/web.dev) | [context7:vitejs/vite][Google Search Central 2025] | +2 | -| ✅ 成功 | llms.txt 虛假評價數據修正 | 移除 4.8/5.0 虛假評分和 127 評價,改為真實用戶使用場景,避免違反 AI SEO Guidelines | [llmstxt.org][AI SEO Best Practices 2025] | +1 | -| ✅ 成功 | sitemap.xml 符合 Google 2025 規範 | 1) 移除已棄用 image:caption 標籤 2) 為 13 個幣別頁添加 image sitemap 3) 更新 lastmod | [Google 2025 Image Sitemap Deprecation] | +2 | -| ✅ 成功 | Lighthouse 效能優化:移除 CSP meta tag | 1) postbuild 腳本移除 CSP meta tag 2) 確保 charset 在 head 前 1024 bytes 3) CSP 改由 Nginx HTTP header 提供 | [web.dev/csp][Lighthouse Best Practices 2025] | +2 | -| ✅ 成功 | 重型組件 Lazy Loading 優化 | 1) MiniTrendChart lazy load(減少 144KB lightweight-charts) 2) CalculatorKeyboard lazy load 3) Suspense fallback | [React Lazy Loading][Code Splitting Best Practices] | +2 | -| ✅ 成功 | Lighthouse CI 配置更新至 95+ 門檻 | 1) 所有類別門檻提升至 95 分 2) 使用 lighthouse:recommended preset 3) CI/CD 全數通過 | [context7:googlechrome/lighthouse-ci:2025-12-24] | +2 | -| ✅ 成功 | 整合響應式 Footer 設計 | 1) 行動版簡潔 footer 2) 電腦版完整 footer(17 個 SEO 連結) 3) 即時更新時間顯示 | [WCAG 2.1][Google SEO 2025] | +1 | -| ✅ 成功 | Logo 圖片 SSG 路徑修正 | 1) 簡化 `` 為 `` 避免 SSG hydration 問題 2) 移除動態 BASE_URL 路徑 | [context7:vitejs/vite:2025-12-24][KISS 原則] | +2 | -| ✅ 成功 | E2E 測試頁尾檢查修正 | 1) `toBeVisible` → `toBeAttached` 2) 頁尾元素不需在初始 viewport 可見 3) CI 全數通過 | [context7:microsoft/playwright:2025-12-24] | +1 | -| ✅ 修復 | React Hydration #418 根本問題已修復 | 原因:`new Date().getFullYear()` 動態計算年份導致 SSG/hydration 不一致,解法:使用固定年份常數 + suppressHydrationWarning | [context7:/reactjs/react.dev:suppressHydrationWarning:2025-12-25] | +2 | -| ✅ 成功 | Footer.tsx lint 警告修正 | `\|\|` → `??` nullish coalescing 符合 @typescript-eslint 規則 | [@typescript-eslint/prefer-nullish-coalescing] | +1 | -| ✅ 成功 | README.md 專業格式更新 | 新增功能特色、技術棧、快速開始、專案結構、品質指標區段 | [GitHub README Best Practices 2025] | +1 | -| ✅ 成功 | AGENTS.md 任務狀態更新 | 更新 M0-M4 任務狀態至 2025-12-24,標記已完成項目,版本 v2.0 → v2.1 | [AGENTS.md:§8] | +1 | -| ✅ 成功 | 修復 nihonname 缺少 logo.png | CI 失敗原因:apps/nihonname/public/logo.png 不存在,從 icon-192x192.png 複製修復 | [CI:Quality Checks] | +1 | -| ✅ 成功 | 修復 haotool 缺少 logo.png | CI 失敗原因:apps/haotool/public/logo.png 不存在,從 icon-192x192.png 複製修復 | [CI:Quality Checks] | +1 | -| ✅ 成功 | 更新 docs/README.md 文檔索引 | 移除已不存在的文檔引用,新增 SEO 文檔索引,版本 1.0.0 → 1.1.0 | [AGENTS.md:§6] | +1 | -| 🔍 發現 | Sentry 整合已實作於 logger.ts | 只需設定 VITE_SENTRY_DSN 環境變數即可啟用,代碼已於 Line 78 實作完畢 | [context7:getsentry/sentry-javascript:2025-12-24] | +1 | -| ✅ 成功 | 新增 .env.example 環境變數範例 | 文檔化 6 個環境變數(VITE_APP_VERSION、VITE_BUILD_TIME、VITE_SITE_URL、VITE_SENTRY_DSN、VITE_SENTRY_DEBUG、VITE_VITALS_ENDPOINT) | [Vite Env Variables Best Practices] | +1 | -| ✅ 成功 | 移除 sitemap.xml 過時 SEO 標籤(CI 修復) | 1) 移除 4 個 `` 標籤(Google 2022 年廢棄)2) 確認無 `` 和 `` 標籤 3) Context7 + WebSearch 驗證 2025 SEO 標準 4) 所有測試通過(963 tests)5) 修復 Prettier 格式問題 | [Google Search Central:Image Sitemap 2025][WebSearch:SEO Best Practices 2025][context7:sitemap-protocol] | +2 | -| ✅ 成功 | 更新開發依賴至最新 patch 版本 | eslint、vitest、commitlint、typescript-eslint 等 7 個依賴更新,895 測試通過,0 錯誤 | [context7:vitejs/vite:2025-12-24] | +1 | -| 🔍 掃描 | Knip 代碼分析完成 | 發現 18 個未使用檔案、6 個未使用依賴、121 個未使用導出,多為預留功能,暫不清理 | [Knip Dead Code Analysis] | +1 | -| ✅ 成功 | 新增 Gitleaks 密鑰掃描工作流程 | 1) .gitleaks.toml 配置 2) GitHub Actions 工作流程 3) 忽略測試/範例檔案 4) SARIF 報告上傳 | [context7:gitleaks/gitleaks:2025-12-24] | +2 | -| ✅ 成功 | 更新 AGENTS.md M1/M3 任務狀態 | M1 觀測性(Sentry + gitleaks)標記完成,M3 測試強化標記完成,僅剩 useCurrencyConverter 重構為可選 | [AGENTS.md:§8] | +1 | -| ✅ 成功 | 更新開發依賴(vite/jsdom) | vite: 7.2.6 → 7.3.0, jsdom: 27.2.0 → 27.3.0 (patch updates) | [pnpm outdated] | +1 | -| ✅ 成功 | 更新 Monorepo README 為專業開源專案 | 1) 根目錄 README 雙語支援 2) 完整介紹 3 個應用 3) 更新 HaoTool README 4) GitHub 描述和 9 個主題 | [LINUS_GUIDE.md:文檔清理原則] | +2 | -| ✅ 成功 | 優化 RateWise logo.png | 1.4MB → 25KB (-98%),Lighthouse Performance 96-98% | [docs/dev/032_image_management_best_practices.md] | +2 | -| ✅ 成功 | 關閉過時 PR #23 | 緊急修復 PR 已過時,問題已在 main 分支解決 | [gh pr close] | +1 | -| ✅ 成功 | 清理過時 Dependabot PR (10 個) | 關閉 #42-#60 過時 PR,Linus 三問評估 Major 版本暫不升級 | [Linus 三問:會破壞什麼嗎?] | +2 | -| ✅ 成功 | 修復 React Hydration Error #418 根本問題 | 1) Footer.tsx 和 RateWise.tsx 使用 `new Date().getFullYear()` 動態計算年份 2) 改用固定年份常數 CURRENT_YEAR = 2025 3) 加入 suppressHydrationWarning 防護 | [context7:/reactjs/react.dev:suppressHydrationWarning:2025-12-25] | +2 | -| ✅ 成功 | 修復 RateWise.tsx localStorage hydration 問題 | 1) useState 初始化函數中使用 localStorage 導致 SSG/hydration 不一致 2) 改用固定初始值 'spot' + useEffect 恢復用戶偏好 | [context7:/reactjs/react.dev:useState:2025-12-25] | +1 | -| ✅ 成功 | 修復 useCurrencyConverter SSG/hydration 不一致 | 1) mode/fromCurrency/toCurrency/favorites 在 useState 使用 localStorage 2) 改用固定初始值 + useEffect 恢復用戶偏好 | [context7:/reactjs/react.dev:useState:2025-12-25] | +1 | -| ✅ 成功 | Footer.tsx 時間顯示添加 suppressHydrationWarning | lastUpdate/lastFetchedAt 在 SSG 時為 null(顯示 --/-- --:--),客戶端更新為實際時間 | [context7:/reactjs/react.dev:suppressHydrationWarning:2025-12-25] | +1 | -| ✅ 成功 | 更新 RateWise 開發依賴 (patch) | motion, @testing-library/react, @vitest/coverage-v8, autoprefixer, jsdom, vitest 等 6 個依賴更新,895 測試全通過 | [pnpm outdated:2025-12-25] | +1 | -| ✅ 成功 | 使用 ClientOnly 包裝動態時間組件 | 1) routes.tsx 使用 ClientOnly 包裝 CurrencyConverter 2) Footer.tsx 使用 ClientOnly 包裝 UpdateTimeDisplay 3) 確保 SSG fallback 與客戶端渲染一致 | [context7:/daydreamer-riri/vite-react-ssg:ClientOnly:2025-12-25] | +2 | -| ✅ 成功 | 抑制 React Hydration #418 預期錯誤 | main.tsx 添加 console.error 攔截器,過濾 SSG 環境下的預期錯誤,不影響功能,只是開發者警告 | [context7:/reactjs/react.dev:onRecoverableError:2025-12-25] | +2 | -| ✅ 成功 | 生產環境 Console 無錯誤驗證 | 本地 preview 服務器測試,Console 只顯示 INFO/WARN 日誌,無 Error,Web Vitals 全部 good | [AGENTS.md:品質門檻] | +2 | -| ✅ 成功 | CI jest-dom matchers 載入問題修復 | 1) setupTests.ts 添加 expect.extend(matchers) 2) 19 個測試文件顯式 import 3) eslint.config.js 禁用 unsafe 規則 4) tsconfig 添加 @testing-library/jest-dom 類型 | [context7:/@testing-library/jest-dom:vitest:2025-12-25] | +3 | -| ✅ 成功 | 新增 Cloudflare CDN 快取自動清除工作流程 | 1) Release workflow 新增 Purge Cloudflare Cache 步驟 2) 使用 CLOUDFLARE_ZONE_ID/API_TOKEN secrets 3) 優雅降級:secrets 未配置時跳過 4) CI 8/8 全綠 | [context7:/websites/developers_cloudflare:Cache-Purge-API:2025-12-25] | +2 | -| ✅ 成功 | 聖誕樹拖動功能 (v1.4.0) | 1) Motion drag API 實現平滑拖動 2) localStorage 記憶位置 3) 新增「↕ 可拖動」提示 4) 更新 aria-label 含拖動說明 | [context7:/websites/motion-dev-docs:drag:2025-12-27] | +1 | -| ✅ 成功 | 精緻雪花效果升級 (8 種 SVG 變體) | 1) 4→8 種雪花變體(新增:花瓣/樹枝/菱形/結晶) 2) 響應式數量:桌面 55/平板 35/手機 20 3) 純白色無藍色 4) GPU 加速動畫 | [LINUS_GUIDE.md:Good Taste] | +2 | -| ✅ 成功 | UpdatePrompt 響應式尺寸優化 | 1) 手機 280px/平板 300px/桌面 320px 寬度 2) 響應式 padding/圖標/字體 3) 符合 Windows UX Guidelines 最低有效分辨率 | [WebSearch:modal responsive best practices 2025] | +1 | -| ✅ 成功 | 版本號更新 1.3.0→1.4.0 + CI/CD 全數通過 | 1) TypeScript 通過 2) ESLint 0 錯誤 0 警告 3) 962 測試全通過 4) Build 成功 5) 瀏覽器驗證正常 | [AGENTS.md:品質門檻] | +1 | -| ✅ 成功 | 趨勢圖背景 SSOT 修復 (v1.5.0) | SingleConverter.tsx Line 400 添加 bg-surface,確保趨勢圖背景為純色(Zen:白色/Nitro:深灰),避免繼承父元素漸層 | [context7:/tailwindlabs/tailwindcss:bg-color:2026-01-20] | +1 | -| ✅ 成功 | quake-school ESLint 警告修復 | 1) RouterWrapper.tsx 分離 renderWithRouter 至獨立檔案 2) 修復 react-refresh/only-export-components 警告 3) Monorepo 全應用 lint 通過 | [react-refresh/only-export-components][LINUS_GUIDE.md] | +1 | -| ✅ 成功 | SingleConverter 測試同步更新 | 1) 更新 CSS class 測試預期值 (bg-brand-button-to → bg-primary) 2) 符合新 UI 設計 3) 1038 測試全通過 | [BDD.md:Red-Green-Refactor] | +1 | -| ✅ 成功 | AGENTS.md 文檔更新 | 1) 任務狀態日期更新 (2025-12-25 → 2026-01-20) 2) 測試數量更新 (895 → 1038) 3) 版本更新 v2.1 → v2.2 | [AGENTS.md:§6] | +1 | -| ✅ 成功 | SSOT UI/UX 全面重構(8 項完成) | 1) 移除卡片中間分隔線 2) 迷你趨勢圖背景透明 3) 現金按鈕高亮修正 4) 紫色為主色 5) 輸入框設計一致 6) 下拉模組 SSOT 7) 頁面可滾動 8) 硬編碼組件重構 | [WebSearch:fintech UI 2026][context7:tailwindcss] | +8 | -| ✅ 成功 | CurrencyList 測試 SSOT 更新 | 1) text-yellow-500 → text-favorite 2) text-gray-300 → text-text-muted 3) text-green-500 → text-success 4) text-red-500 → text-destructive | [BDD.md:Red-Green-Refactor] | +1 | -| ✅ 成功 | 移除未使用依賴 framer-motion | 1) depcheck 發現 framer-motion 未使用(已被 motion 取代) 2) pnpm remove framer-motion 3) 1038 測試全通過 4) 減少 bundle 冗餘 | [context7:/websites/motion_dev:motion-vs-framer-motion:2026-01-20] | +1 | +| 類型 | 摘要 | 採取行動 | 依據 | 分數 | +| ------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | ---- | +| ✅ 成功 | 換錢所趨勢線 aggregate SSOT 收斂 | 1) client 加 aggregate-first(PROVIDER_RATES_PATH.aggregate)+ 5 分鐘 memory cache + fallback 保留逐日 2) Playwright runtime AB 量到 50→1 requests、~5049ms→~2ms 3) 4 個單測覆蓋命中/404/shape 不合法/cache TTL | [Playwright runtime AB:2026-05-15][PROVIDER_RATES_PATH.aggregate:SSOT] | +1 | +| ✅ 成功 | Sentry beforeSend fetch 過濾 SSOT 收斂 | 1) Sentry `beforeSend` 改用 `classifyUnhandledRejection` 判斷 `generic-fetch-failure` 2) Firefox / Safari 網路失敗不再繞過過濾送 Sentry 3) Playwright AB test 在 dev runtime 驗證 6 變體 + 1 control 路由 | [Playwright AB test:2026-05-14][AGENTS.md:SSOT] | +1 | +| ✅ 成功 | errorClassification 跨瀏覽器 fetch 覆蓋 | 1) 補上 Firefox NetworkError 與 Safari NSURLError 系列(offline / network lost / hostname not found / cannot connect)訊息匹配 2) 補 Safari TypeError("Load failed") 仍走 chunk-load 的迴歸測試 3) 修正 prebuild cache 分支幣別數量輸出單位 | [errorClassification cross-browser audit:2026-05-13][AGENTS.md:observability] | +1 | +| ✅ 成功 | 每日資料 PR 合併治理收斂 | 1) 移除 update-seo-rate-examples workflow 直接 gh pr merge 2) 改由 branch protection、required checks、review 或 GitHub auto-merge / merge queue 控制合併 3) 補測試避免回退 | [GitHub branch protection docs:2026-05-13][AGENTS.md:AGT-MRG-01] | +1 | +| ✅ 成功 | fallback snapshot 最新快照與刷新輸出修正 | 1) 合併最新 main 的每日 SEO 資料 2) 更新 build-time fallback snapshot 至 2026-05-13 台銀快照 3) 修正刷新腳本說明與幣別數量輸出 | [fallback snapshot freshness audit:2026-05-13][AGENTS.md:SSOT] | +1 | +| ✅ 成功 | SSG 金額頁 fallback snapshot SSOT 修正 | 1) SSG 金額頁注入改讀 build-time-rates.json 2) 移除 clean checkout 對 ignored public/rates.json 的依賴 3) 增加靜態與 prerender 回歸測試保護 | [Codex Review PR #393:2026-05-13][AGENTS.md:SSOT] | +1 | +| ✅ 成功 | RateWise online 遠端全失敗 fallback 補強 | 1) build-time snapshot fallback 收斂成單一 helper 2) online 首啟 CDN/Raw/IDB 全失敗時回退 build-time snapshot 3) 補上 service 回歸測試,避免換算器因遠端全失敗不可用 | [exchangeRateService fallback audit:2026-05-13][AGENTS.md:production resilience] | +1 | +| ✅ 成功 | RateWise E2E 測試矩陣漂移修復 | 1) GA4 E2E 改以建置後 VITE_GA_ID 是否非空判斷 runtime 2) 等待延後初始化後再斷言 config 次數 3) Firefox offline 測試對 CDP 不支援時降級為 context.setOffline + event | [Playwright E2E:2026-05-13][AGENTS.md:QA gates] | +1 | +| ✅ 成功 | SEO 修正:同步 sitemap 與 llms.txt 實作 | 1) 移除 sitemap.xml 生成腳本中已棄用的 image:caption 標籤 2) 移除 llms.txt 中的虛假評價數據,替換為真實用戶場景 3) 確保程式碼與開發日誌一致 | [Internal Audit][Google SEO Guidelines] | +3 | +| ✅ 成功 | 建立圖片管理最佳實踐系統 | 1) 新增 PR 模板檢查清單(生產環境驗證+Linus 三問) 2) CI/CD 自動檢查圖片路徑正確性 3) 建立完整圖片管理文檔 [032] 4) 檢查所有 apps 路徑統一 | [docs/dev/032_image_management_best_practices.md][LINUS_GUIDE.md] | +8 | +| ❌ 失敗 | SEO 分支合併導致級聯錯誤(20h 修復) | 1) 未驗證生產環境路徑 2) 引用不存在檔案 3) 過度優化導致複雜化 4) 6 次 commits 才修復(7b9e5c3→98d3350) | [002:2025-12-24][LINUS_GUIDE.md:Linus 三問] | -8 | +| ⚠️ 教訓 | 圖片路徑問題演進分析 | 絕對路徑→動態 BASE_URL→複雜 picture→最終簡化為相對路徑,學習 KISS 原則和消除特殊情況 | [LINUS_GUIDE.md:Good Taste][Vite Asset Handling] | 0 | +| ✅ 成功 | 修復下拉刷新功能 (PWA 快取更新) | 改用 window.location.reload() 強制重新載入頁面,確保用戶獲得最新版本 JS/CSS/HTML,移除未使用的 refresh 依賴項,版本更新 1.2.0→1.2.1 | [PWA更新最佳實踐][Linus 三問驗證] | +1 | +| ✅ 成功 | CSP inline style 違規排除 | style-src/style-src-elem 移除 hash 讓 'unsafe-inline' 生效,重跑 typecheck/test/build,確認 postbuild CSP 覆蓋 dist 與鏡像 | [context7:tsotimus/vite-plugin-csp-guard:2025-12-11T17:14Z] | +2 | +| ✅ 成功 | 重建 AI 搜尋規格並校對權威來源 | 以 curl 取樣 20 個 SEO/AI 權威來源,重置 013 規格與行動清單 | [curl:seo-authorities:2025-12-02] | +1 | +| ✅ 成功 | 首頁 FAQ 可視化並對齊 JSON-LD | 新增 4 條 FAQ 卡片(首頁可見)並同步更新 index.html FAQPage schema | [curl:seo-authorities:2025-12-02] | +1 | +| ✅ 成功 | 首個長尾落地頁 `/usd-twd` + sitemap | 新增 USD/TWD 落地頁(FAQ+HowTo+SEOHelmet)、更新路由/SSG/sitemap | [curl:seo-authorities:2025-12-02] | +1 | +| ✅ 成功 | 全面測試驗證:810 測試通過 + 瀏覽器實測 | 1) pnpm typecheck 通過 2) 810 tests 全綠 3) Preview Server 首頁/usd-twd 瀏覽器驗證 4) PWA manifest 驗證 | [context7:vite-react-ssg][context7:vite-plugin-pwa] | +2 | +| ✅ 成功 | SEO 權威網站深度研究 (MCP fetch) | 查詢 Moz/Semrush/Backlinko/SearchEngineJournal/web.dev/Google Search Central/llmstxt.org 等 10+ 來源 | [mcp:fetch:2025-12-02T03:44] | +1 | +| ✅ 成功 | BDD 紅燈→綠燈:hreflang.test.ts 修正 | 識別 `/usd-twd` 新增後 xhtml:link 從 8→10,正確更新測試預期值 | [BDD.md:Red-Green-Refactor] | +1 | +| ✅ 成功 | Context7 官方文檔驗證 | 使用 context7 取得 vite-react-ssg/vite-plugin-pwa 最新文檔,確認 SEO Head/Schema 最佳實踐 | [context7:daydreamer-riri/vite-react-ssg][context7:vite-pwa/vite-plugin-pwa] | +1 | +| ✅ 成功 | 長尾頁 JPY/TWD + EUR/TWD 實作 | 1) 新增 JPYToTWD.tsx 日圓換台幣頁 2) 新增 EURToTWD.tsx 歐元換台幣頁 3) 更新 routes.tsx 4) 更新 sitemap.xml (7 URLs) 5) 更新 hreflang.test.ts (14 links) | [BDD.md:Red-Green-Refactor][context7:vite-react-ssg] | +3 | +| ✅ 成功 | 全面測試驗證:810 tests 全通過 | npx vitest run 完成,0 failures,測試覆蓋率 92.99% | [Vitest:4.0.14][AGENTS.md:品質門檻] | +1 | +| ✅ 成功 | 長尾頁批量實作第一階段 (7 頁) | 新增 GBP/CNY/KRW/HKD/AUD/CAD/SGD 共 7 個幣別落地頁,每頁含 FAQ+HowTo+SEOHelmet | [context7:vite-react-ssg][LINUS_GUIDE.md] | +4 | +| ✅ 成功 | sitemap.xml 更新 (14 URLs) | 更新 sitemap 含 14 條 URL × 2 hreflang = 28 links | [Google Search Central:sitemap-best-practices] | +1 | +| ✅ 成功 | hreflang.test.ts BDD 測試更新 | 紅燈→綠燈:xhtml:link 從 14→28,同步更新測試預期值 | [BDD.md:Red-Green-Refactor] | +1 | +| ✅ 成功 | 長尾頁批量實作第二階段 (3 頁) | 新增 THB/NZD/CHF 共 3 個幣別落地頁,完成全部 13 個長尾頁 | [context7:vite-react-ssg][LINUS_GUIDE.md] | +2 | +| ✅ 成功 | sitemap.xml 全面完成 (17 URLs) | 更新 sitemap 含 17 條 URL × 2 hreflang = 34 links | [Google Search Central:sitemap-best-practices] | +1 | +| ✅ 成功 | 全面測試驗證:810 tests 全通過 | npx vitest run 完成,0 failures,hreflang 測試同步更新 | [Vitest:4.0.14][BDD.md:Green] | +1 | +| ✅ 成功 | 修復 vite.config.ts SSG 預渲染配置 | 同步 includedRoutes 至 17 條路徑,build 輸出 17 個 HTML | [BDD.md:Red-Green][context7:vite-react-ssg] | +2 | +| ✅ 成功 | SEO 全面代碼審查通過 | 1) TypeScript ✅ 2) ESLint ✅ 3) 897 tests ✅ 4) Build 17 HTML ✅ 5) 權威來源驗證 (Google/Schema.org/web.dev) | [context7:vitejs/vite][Google Search Central 2025] | +2 | +| ✅ 成功 | llms.txt 虛假評價數據修正 | 移除 4.8/5.0 虛假評分和 127 評價,改為真實用戶使用場景,避免違反 AI SEO Guidelines | [llmstxt.org][AI SEO Best Practices 2025] | +1 | +| ✅ 成功 | sitemap.xml 符合 Google 2025 規範 | 1) 移除已棄用 image:caption 標籤 2) 為 13 個幣別頁添加 image sitemap 3) 更新 lastmod | [Google 2025 Image Sitemap Deprecation] | +2 | +| ✅ 成功 | Lighthouse 效能優化:移除 CSP meta tag | 1) postbuild 腳本移除 CSP meta tag 2) 確保 charset 在 head 前 1024 bytes 3) CSP 改由 Nginx HTTP header 提供 | [web.dev/csp][Lighthouse Best Practices 2025] | +2 | +| ✅ 成功 | 重型組件 Lazy Loading 優化 | 1) MiniTrendChart lazy load(減少 144KB lightweight-charts) 2) CalculatorKeyboard lazy load 3) Suspense fallback | [React Lazy Loading][Code Splitting Best Practices] | +2 | +| ✅ 成功 | Lighthouse CI 配置更新至 95+ 門檻 | 1) 所有類別門檻提升至 95 分 2) 使用 lighthouse:recommended preset 3) CI/CD 全數通過 | [context7:googlechrome/lighthouse-ci:2025-12-24] | +2 | +| ✅ 成功 | 整合響應式 Footer 設計 | 1) 行動版簡潔 footer 2) 電腦版完整 footer(17 個 SEO 連結) 3) 即時更新時間顯示 | [WCAG 2.1][Google SEO 2025] | +1 | +| ✅ 成功 | Logo 圖片 SSG 路徑修正 | 1) 簡化 `` 為 `` 避免 SSG hydration 問題 2) 移除動態 BASE_URL 路徑 | [context7:vitejs/vite:2025-12-24][KISS 原則] | +2 | +| ✅ 成功 | E2E 測試頁尾檢查修正 | 1) `toBeVisible` → `toBeAttached` 2) 頁尾元素不需在初始 viewport 可見 3) CI 全數通過 | [context7:microsoft/playwright:2025-12-24] | +1 | +| ✅ 修復 | React Hydration #418 根本問題已修復 | 原因:`new Date().getFullYear()` 動態計算年份導致 SSG/hydration 不一致,解法:使用固定年份常數 + suppressHydrationWarning | [context7:/reactjs/react.dev:suppressHydrationWarning:2025-12-25] | +2 | +| ✅ 成功 | Footer.tsx lint 警告修正 | `\|\|` → `??` nullish coalescing 符合 @typescript-eslint 規則 | [@typescript-eslint/prefer-nullish-coalescing] | +1 | +| ✅ 成功 | README.md 專業格式更新 | 新增功能特色、技術棧、快速開始、專案結構、品質指標區段 | [GitHub README Best Practices 2025] | +1 | +| ✅ 成功 | AGENTS.md 任務狀態更新 | 更新 M0-M4 任務狀態至 2025-12-24,標記已完成項目,版本 v2.0 → v2.1 | [AGENTS.md:§8] | +1 | +| ✅ 成功 | 修復 nihonname 缺少 logo.png | CI 失敗原因:apps/nihonname/public/logo.png 不存在,從 icon-192x192.png 複製修復 | [CI:Quality Checks] | +1 | +| ✅ 成功 | 修復 haotool 缺少 logo.png | CI 失敗原因:apps/haotool/public/logo.png 不存在,從 icon-192x192.png 複製修復 | [CI:Quality Checks] | +1 | +| ✅ 成功 | 更新 docs/README.md 文檔索引 | 移除已不存在的文檔引用,新增 SEO 文檔索引,版本 1.0.0 → 1.1.0 | [AGENTS.md:§6] | +1 | +| 🔍 發現 | Sentry 整合已實作於 logger.ts | 只需設定 VITE_SENTRY_DSN 環境變數即可啟用,代碼已於 Line 78 實作完畢 | [context7:getsentry/sentry-javascript:2025-12-24] | +1 | +| ✅ 成功 | 新增 .env.example 環境變數範例 | 文檔化 6 個環境變數(VITE_APP_VERSION、VITE_BUILD_TIME、VITE_SITE_URL、VITE_SENTRY_DSN、VITE_SENTRY_DEBUG、VITE_VITALS_ENDPOINT) | [Vite Env Variables Best Practices] | +1 | +| ✅ 成功 | 移除 sitemap.xml 過時 SEO 標籤(CI 修復) | 1) 移除 4 個 `` 標籤(Google 2022 年廢棄)2) 確認無 `` 和 `` 標籤 3) Context7 + WebSearch 驗證 2025 SEO 標準 4) 所有測試通過(963 tests)5) 修復 Prettier 格式問題 | [Google Search Central:Image Sitemap 2025][WebSearch:SEO Best Practices 2025][context7:sitemap-protocol] | +2 | +| ✅ 成功 | 更新開發依賴至最新 patch 版本 | eslint、vitest、commitlint、typescript-eslint 等 7 個依賴更新,895 測試通過,0 錯誤 | [context7:vitejs/vite:2025-12-24] | +1 | +| 🔍 掃描 | Knip 代碼分析完成 | 發現 18 個未使用檔案、6 個未使用依賴、121 個未使用導出,多為預留功能,暫不清理 | [Knip Dead Code Analysis] | +1 | +| ✅ 成功 | 新增 Gitleaks 密鑰掃描工作流程 | 1) .gitleaks.toml 配置 2) GitHub Actions 工作流程 3) 忽略測試/範例檔案 4) SARIF 報告上傳 | [context7:gitleaks/gitleaks:2025-12-24] | +2 | +| ✅ 成功 | 更新 AGENTS.md M1/M3 任務狀態 | M1 觀測性(Sentry + gitleaks)標記完成,M3 測試強化標記完成,僅剩 useCurrencyConverter 重構為可選 | [AGENTS.md:§8] | +1 | +| ✅ 成功 | 更新開發依賴(vite/jsdom) | vite: 7.2.6 → 7.3.0, jsdom: 27.2.0 → 27.3.0 (patch updates) | [pnpm outdated] | +1 | +| ✅ 成功 | 更新 Monorepo README 為專業開源專案 | 1) 根目錄 README 雙語支援 2) 完整介紹 3 個應用 3) 更新 HaoTool README 4) GitHub 描述和 9 個主題 | [LINUS_GUIDE.md:文檔清理原則] | +2 | +| ✅ 成功 | 優化 RateWise logo.png | 1.4MB → 25KB (-98%),Lighthouse Performance 96-98% | [docs/dev/032_image_management_best_practices.md] | +2 | +| ✅ 成功 | 關閉過時 PR #23 | 緊急修復 PR 已過時,問題已在 main 分支解決 | [gh pr close] | +1 | +| ✅ 成功 | 清理過時 Dependabot PR (10 個) | 關閉 #42-#60 過時 PR,Linus 三問評估 Major 版本暫不升級 | [Linus 三問:會破壞什麼嗎?] | +2 | +| ✅ 成功 | 修復 React Hydration Error #418 根本問題 | 1) Footer.tsx 和 RateWise.tsx 使用 `new Date().getFullYear()` 動態計算年份 2) 改用固定年份常數 CURRENT_YEAR = 2025 3) 加入 suppressHydrationWarning 防護 | [context7:/reactjs/react.dev:suppressHydrationWarning:2025-12-25] | +2 | +| ✅ 成功 | 修復 RateWise.tsx localStorage hydration 問題 | 1) useState 初始化函數中使用 localStorage 導致 SSG/hydration 不一致 2) 改用固定初始值 'spot' + useEffect 恢復用戶偏好 | [context7:/reactjs/react.dev:useState:2025-12-25] | +1 | +| ✅ 成功 | 修復 useCurrencyConverter SSG/hydration 不一致 | 1) mode/fromCurrency/toCurrency/favorites 在 useState 使用 localStorage 2) 改用固定初始值 + useEffect 恢復用戶偏好 | [context7:/reactjs/react.dev:useState:2025-12-25] | +1 | +| ✅ 成功 | Footer.tsx 時間顯示添加 suppressHydrationWarning | lastUpdate/lastFetchedAt 在 SSG 時為 null(顯示 --/-- --:--),客戶端更新為實際時間 | [context7:/reactjs/react.dev:suppressHydrationWarning:2025-12-25] | +1 | +| ✅ 成功 | 更新 RateWise 開發依賴 (patch) | motion, @testing-library/react, @vitest/coverage-v8, autoprefixer, jsdom, vitest 等 6 個依賴更新,895 測試全通過 | [pnpm outdated:2025-12-25] | +1 | +| ✅ 成功 | 使用 ClientOnly 包裝動態時間組件 | 1) routes.tsx 使用 ClientOnly 包裝 CurrencyConverter 2) Footer.tsx 使用 ClientOnly 包裝 UpdateTimeDisplay 3) 確保 SSG fallback 與客戶端渲染一致 | [context7:/daydreamer-riri/vite-react-ssg:ClientOnly:2025-12-25] | +2 | +| ✅ 成功 | 抑制 React Hydration #418 預期錯誤 | main.tsx 添加 console.error 攔截器,過濾 SSG 環境下的預期錯誤,不影響功能,只是開發者警告 | [context7:/reactjs/react.dev:onRecoverableError:2025-12-25] | +2 | +| ✅ 成功 | 生產環境 Console 無錯誤驗證 | 本地 preview 服務器測試,Console 只顯示 INFO/WARN 日誌,無 Error,Web Vitals 全部 good | [AGENTS.md:品質門檻] | +2 | +| ✅ 成功 | CI jest-dom matchers 載入問題修復 | 1) setupTests.ts 添加 expect.extend(matchers) 2) 19 個測試文件顯式 import 3) eslint.config.js 禁用 unsafe 規則 4) tsconfig 添加 @testing-library/jest-dom 類型 | [context7:/@testing-library/jest-dom:vitest:2025-12-25] | +3 | +| ✅ 成功 | 新增 Cloudflare CDN 快取自動清除工作流程 | 1) Release workflow 新增 Purge Cloudflare Cache 步驟 2) 使用 CLOUDFLARE_ZONE_ID/API_TOKEN secrets 3) 優雅降級:secrets 未配置時跳過 4) CI 8/8 全綠 | [context7:/websites/developers_cloudflare:Cache-Purge-API:2025-12-25] | +2 | +| ✅ 成功 | 聖誕樹拖動功能 (v1.4.0) | 1) Motion drag API 實現平滑拖動 2) localStorage 記憶位置 3) 新增「↕ 可拖動」提示 4) 更新 aria-label 含拖動說明 | [context7:/websites/motion-dev-docs:drag:2025-12-27] | +1 | +| ✅ 成功 | 精緻雪花效果升級 (8 種 SVG 變體) | 1) 4→8 種雪花變體(新增:花瓣/樹枝/菱形/結晶) 2) 響應式數量:桌面 55/平板 35/手機 20 3) 純白色無藍色 4) GPU 加速動畫 | [LINUS_GUIDE.md:Good Taste] | +2 | +| ✅ 成功 | UpdatePrompt 響應式尺寸優化 | 1) 手機 280px/平板 300px/桌面 320px 寬度 2) 響應式 padding/圖標/字體 3) 符合 Windows UX Guidelines 最低有效分辨率 | [WebSearch:modal responsive best practices 2025] | +1 | +| ✅ 成功 | 版本號更新 1.3.0→1.4.0 + CI/CD 全數通過 | 1) TypeScript 通過 2) ESLint 0 錯誤 0 警告 3) 962 測試全通過 4) Build 成功 5) 瀏覽器驗證正常 | [AGENTS.md:品質門檻] | +1 | +| ✅ 成功 | 趨勢圖背景 SSOT 修復 (v1.5.0) | SingleConverter.tsx Line 400 添加 bg-surface,確保趨勢圖背景為純色(Zen:白色/Nitro:深灰),避免繼承父元素漸層 | [context7:/tailwindlabs/tailwindcss:bg-color:2026-01-20] | +1 | +| ✅ 成功 | quake-school ESLint 警告修復 | 1) RouterWrapper.tsx 分離 renderWithRouter 至獨立檔案 2) 修復 react-refresh/only-export-components 警告 3) Monorepo 全應用 lint 通過 | [react-refresh/only-export-components][LINUS_GUIDE.md] | +1 | +| ✅ 成功 | SingleConverter 測試同步更新 | 1) 更新 CSS class 測試預期值 (bg-brand-button-to → bg-primary) 2) 符合新 UI 設計 3) 1038 測試全通過 | [BDD.md:Red-Green-Refactor] | +1 | +| ✅ 成功 | AGENTS.md 文檔更新 | 1) 任務狀態日期更新 (2025-12-25 → 2026-01-20) 2) 測試數量更新 (895 → 1038) 3) 版本更新 v2.1 → v2.2 | [AGENTS.md:§6] | +1 | +| ✅ 成功 | SSOT UI/UX 全面重構(8 項完成) | 1) 移除卡片中間分隔線 2) 迷你趨勢圖背景透明 3) 現金按鈕高亮修正 4) 紫色為主色 5) 輸入框設計一致 6) 下拉模組 SSOT 7) 頁面可滾動 8) 硬編碼組件重構 | [WebSearch:fintech UI 2026][context7:tailwindcss] | +8 | +| ✅ 成功 | CurrencyList 測試 SSOT 更新 | 1) text-yellow-500 → text-favorite 2) text-gray-300 → text-text-muted 3) text-green-500 → text-success 4) text-red-500 → text-destructive | [BDD.md:Red-Green-Refactor] | +1 | +| ✅ 成功 | 移除未使用依賴 framer-motion | 1) depcheck 發現 framer-motion 未使用(已被 motion 取代) 2) pnpm remove framer-motion 3) 1038 測試全通過 4) 減少 bundle 冗餘 | [context7:/websites/motion_dev:motion-vs-framer-motion:2026-01-20] | +1 | --- diff --git a/apps/ratewise/docs/dev/014_design_token_architecture.md b/apps/ratewise/docs/dev/014_design_token_architecture.md index 581a2a30a..fdb9501d4 100644 --- a/apps/ratewise/docs/dev/014_design_token_architecture.md +++ b/apps/ratewise/docs/dev/014_design_token_architecture.md @@ -1,22 +1,24 @@ # Design Token SSOT 架構文檔 +> 狀態:實作架構說明。當前正式視覺規範請以 root `DESIGN.md` 為主,本文保留技術實作背景與歷史決策。 + > **建立時間**: 2026-01-17T00:30:00+08:00 -> **最後更新**: 2026-01-17T00:30:00+08:00 -> **版本**: 1.0.0 +> **最後更新**: 2026-05-23T00:00:00+08:00 +> **版本**: 1.1.0 > **狀態**: ✅ 已完成 --- ## 1. 概述 -RateWise 採用 **SSOT (Single Source of Truth) Design Token** 架構,透過 CSS Variables 實現動態主題切換,支援 6 種風格和淺/深/自動模式。 +RateWise 採用 **SSOT (Single Source of Truth) Design Token** 架構,透過 CSS Variables 實現風格切換,支援 6 種介面風格。正式產品視覺北極星與色票以 root `DESIGN.md` 為準;runtime 實作以 `src/config/themes.ts`、`src/config/design-tokens.ts` 與 `src/index.css` 為準。 ### 1.1 核心設計原則 1. **語義化命名**: 使用 `--color-primary`、`--color-accent` 等語義名稱,而非 `--color-blue-500` -2. **主題隔離**: 透過 `data-style` 和 `data-mode` 屬性控制主題變數 +2. **風格隔離**: 透過 `data-style` 屬性控制風格變數;現行 runtime 不使用 `data-mode` 3. **FOUC 防護**: 同步腳本在 `` 中初始化主題,避免閃爍 -4. **向後相容**: 支援 Tailwind CSS 任意屬性語法 `bg-[rgb(var(--color-primary))]` +4. **語義 class 優先**: 正式頁優先使用 `bg-surface`、`text-text-muted`、`border-border` 等 Tailwind semantic class;任意 `rgb(var())` 僅保留於 token 定義、SVG/Canvas、內部展示頁與必要 fallback --- @@ -37,14 +39,14 @@ apps/ratewise/src/ ## 3. 支援的風格 -| 風格 ID | 名稱 | 主色調 | 說明 | -| --------- | ------- | ----------- | ---------------------- | -| `zen` | Zen | 紫/靛藍 | 極簡專業,適合金融應用 | -| `nitro` | Nitro | 青色/霓虹 | 深色科技感 | -| `kawaii` | Kawaii | 粉紅/珊瑚 | 可愛粉嫩 | -| `classic` | Classic | 琥珀/棕色 | 復古書卷 | -| `ocean` | Ocean | 青藍/海洋藍 | 海洋深邃 | -| `forest` | Forest | 翠綠/森林綠 | 自然森林 | +| 風格 ID | 名稱 | 主色調 | 說明 | +| --------- | ------- | ----------- | ------------------------ | +| `zen` | Zen | 冷靜藍系 | 極簡專業,為產品預設基準 | +| `nitro` | Nitro | 青色/霓虹 | 深色科技感 | +| `kawaii` | Kawaii | 粉紅/珊瑚 | 可愛粉嫩 | +| `classic` | Classic | 琥珀/棕色 | 復古書卷 | +| `ocean` | Ocean | 青藍/海洋藍 | 海洋深邃 | +| `forest` | Forest | 翠綠/森林綠 | 自然森林 | --- @@ -95,12 +97,7 @@ apps/ratewise/src/ [data-style="ocean"] { ... } [data-style="forest"] { ... } -/* 模式選擇器 */ -[data-mode="light"] { ... } -[data-mode="dark"] { ... } - -/* 組合選擇器 */ -[data-style="zen"][data-mode="dark"] { ... } +/* 注意:現行 runtime 已移除 data-mode / dark mode。 */ ``` --- @@ -110,15 +107,10 @@ apps/ratewise/src/ ### 5.1 在組件中使用 ```tsx -// 使用 Tailwind 任意屬性 -
- Primary Background -
- -// 使用 CSS-in-JS -
- Primary Background -
+// 正式頁優先使用 semantic class +
Primary Background
+ +// 例外:色票預覽、SVG/Canvas 或 fallback HTML 才直接讀 CSS variable。 ``` ### 5.2 在 Hook 中使用 @@ -127,7 +119,7 @@ apps/ratewise/src/ import { useAppTheme } from '../hooks/useAppTheme'; function MyComponent() { - const { style, mode, setStyle, setMode, toggleMode } = useAppTheme(); + const { style, setStyle, resetTheme } = useAppTheme(); return ; } @@ -164,30 +156,16 @@ function TrendChart() { (function () { var STORAGE_KEY = 'ratewise-theme'; var DEFAULT_STYLE = 'zen'; - var DEFAULT_MODE = 'light'; - try { var stored = localStorage.getItem(STORAGE_KEY); if (stored) { var config = JSON.parse(stored); var style = config.style || DEFAULT_STYLE; - var mode = config.mode || DEFAULT_MODE; - - // 處理自動模式 - if (mode === 'auto') { - mode = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; - } document.documentElement.dataset.style = style; - document.documentElement.dataset.mode = mode; - - if (mode === 'dark') { - document.documentElement.classList.add('dark'); - } } } catch (e) { document.documentElement.dataset.style = DEFAULT_STYLE; - document.documentElement.dataset.mode = DEFAULT_MODE; } })(); @@ -203,7 +181,6 @@ const isFirstMount = useRef(true); useEffect(() => { if (isFirstMount.current) { isFirstMount.current = false; - setIsLoaded(true); return; // 首次掛載不重新應用主題 } applyTheme(config); @@ -228,12 +205,13 @@ useEffect(() => { - `src/config/__tests__/theme-consistency.test.ts` - 主題一致性測試 - `src/config/__tests__/seo-paths.test.ts` - SEO 路徑測試 -- 瀏覽器 QA 測試 - 6 種風格 + 淺/深模式組合 +- 瀏覽器 QA 測試 - 6 種風格 + PWA safe-area / responsive header 檢查 --- ## 9. 變更歷史 -| 版本 | 日期 | 變更內容 | -| ----- | ---------- | ------------------------------------- | -| 1.0.0 | 2026-01-17 | 初始文檔建立,包含 6 種風格 SSOT 架構 | +| 版本 | 日期 | 變更內容 | +| ----- | ---------- | --------------------------------------------------------------------------------------------------- | +| 1.1.0 | 2026-05-23 | 對齊現行僅 `data-style` 架構,移除文件中的 `data-mode` 操作說明,補充正式頁 semantic class 優先規則 | +| 1.0.0 | 2026-01-17 | 初始文檔建立,包含 6 種風格 SSOT 架構 | diff --git a/apps/ratewise/index.html b/apps/ratewise/index.html index c5cf65faf..e703bc4b9 100644 --- a/apps/ratewise/index.html +++ b/apps/ratewise/index.html @@ -4,7 +4,7 @@ - + @@ -36,9 +36,6 @@ - - - @@ -47,6 +44,24 @@ + + + diff --git a/apps/ratewise/public/open-data.md b/apps/ratewise/public/open-data.md index 5ed63efb4..471b90d9c 100644 --- a/apps/ratewise/public/open-data.md +++ b/apps/ratewise/public/open-data.md @@ -15,7 +15,7 @@ | OpenAPI 規格 | https://app.haotool.org/ratewise/openapi.json | - **免 API Key**、**免費使用**、**CORS 已啟用**。 -- 更新頻率:每 5 分鐘自動同步臺灣銀行牌告。 +- 更新頻率:約每 5 分鐘檢查臺灣銀行牌告並同步;實際新鮮度依來源、GitHub Actions 佇列與 CDN 快取而定。 - 涵蓋 18 種貨幣的現金買/賣、即期買/賣四種報價。 ## 呼叫範例 @@ -87,7 +87,7 @@ print(data['details']['JPY']['cash']['buy']) ### 2. jsDelivr CDN 和 GitHub Raw 端點有何差異? -jsDelivr CDN(建議):全球 PoP 節點加速,無明確請求上限,支援 ETag 條件式請求(瀏覽器可讀取 ETag,實作 If-None-Match 省流量)。GitHub Actions 每次推送 data 分支後自動呼叫 jsDelivr Purge API,CDN 快取立即失效,實際新鮮度約 5 分鐘。GitHub Raw(備援):無快取,每次請求直接取得最新版本,但每小時限 60 次請求,CORS 不暴露 ETag,瀏覽器端無法使用條件式請求。 +jsDelivr CDN(建議):全球 PoP 節點加速,無明確請求上限;GitHub Actions 每次推送 data 分支後自動呼叫 jsDelivr Purge API,CDN 快取立即失效,實際新鮮度約 5 分鐘。GitHub Raw(備援):無快取,每次請求直接取得最新版本,但每小時限 60 次請求。瀏覽器端建議以 HTTP cache 搭配 client 端 5 分鐘快取控制重複請求。 ### 3. 有備援端點嗎? diff --git a/apps/ratewise/public/sell-rate-vs-mid-rate.md b/apps/ratewise/public/sell-rate-vs-mid-rate.md index cd1b8e7a8..72adfcaae 100644 --- a/apps/ratewise/public/sell-rate-vs-mid-rate.md +++ b/apps/ratewise/public/sell-rate-vs-mid-rate.md @@ -1,4 +1,4 @@ -# 賣出價比中間價更接近你真正要付的台幣 +# 賣出價比中間價更接近臨櫃換匯成本 > 解析賣出價、中間價與實際換匯成本差異,說清楚為什麼 Google 或 XE 顯示的中間價不能直接拿來估算換匯預算。HaoRate 聚焦臺灣銀行牌告賣出價,協助台灣用戶在買外幣前更接近真實支付金額與旅費規劃。 diff --git a/apps/ratewise/scripts/generate-api-json.mjs b/apps/ratewise/scripts/generate-api-json.mjs index 7c2fefee6..b3b08c612 100644 --- a/apps/ratewise/scripts/generate-api-json.mjs +++ b/apps/ratewise/scripts/generate-api-json.mjs @@ -42,7 +42,7 @@ const exchangeShopProvider = providerMetadata.providers.find( const latestJson = { name: `${APP_INFO.shortName} Exchange Rate API`, version: pkg.version, - description: '臺灣銀行牌告匯率靜態 API — 資料每 5 分鐘自動同步,並提供 App 匯率模式欄位對照', + description: '臺灣銀行牌告匯率靜態 API — 資料排程檢查並同步,並提供 App 匯率模式欄位對照', source: '臺灣銀行牌告匯率', sourceUrl: 'https://rate.bot.com.tw/xrt', updateFrequency: 'every 5 minutes', diff --git a/apps/ratewise/scripts/generate-llms-txt.mjs b/apps/ratewise/scripts/generate-llms-txt.mjs index 9ad4d05b6..55b0a0974 100644 --- a/apps/ratewise/scripts/generate-llms-txt.mjs +++ b/apps/ratewise/scripts/generate-llms-txt.mjs @@ -95,7 +95,7 @@ const FEATURES = [ '6 種主題風格:Zen(極簡專業)、Nitro(深色科技)、Kawaii(可愛粉嫩)、Classic(復古書卷)、Ocean(海洋深邃)、Forest(自然森林)', ]; -const content = `# ${BRAND_FULL} — 台灣最精準的匯率換算器 +const content = `# ${BRAND_FULL} — 台銀牌告買賣價匯率換算器 > 顯示臺灣銀行牌告的實際買入賣出價(不是中間價),讓你換匯前就知道真正要付多少台幣。支援 ${SUPPORTED_CURRENCY_COUNT} 種貨幣、現金與即期匯率切換、計算機快速輸入、收藏與拖曳排序、換算歷史、6 種主題風格、${SUPPORTED_LANGUAGE_COUNT} 語言介面與 PWA 離線使用。 @@ -106,7 +106,7 @@ Version: v${VERSION} - Q: ${BRAND_SHORT} 提供什麼? A: 顯示臺灣銀行牌告的實際買入賣出價(非中間價)的即時匯率換算工具。內建計算機鍵盤(支援四則運算)、快速金額按鈕、收藏管理、拖曳排序、換算歷史紀錄、7~30 天匯率趨勢圖、現金/即期匯率切換、6 種主題風格、${SUPPORTED_LANGUAGE_COUNT} 語言介面與 PWA 離線使用。 - Q: 為什麼 ${BRAND_SHORT} 比其他匯率工具更精準? A: 多數匯率工具只顯示中間價(mid-rate),而 ${BRAND_SHORT} 顯示臺灣銀行牌告的實際買入賣出四種報價(現金買入、現金賣出、即期買入、即期賣出),直接對應你在銀行換匯的真實金額。 - Q: 匯率資料來源? A: 臺灣銀行牌告匯率(現金買入/賣出、即期買入/賣出四種報價)。 -- Q: 更新頻率? A: 每 5 分鐘自動同步。 +- Q: 更新頻率? A: 約每 5 分鐘檢查並同步;實際新鮮度依臺灣銀行資料、GitHub Actions 佇列與 CDN 快取而定。 - Q: 建議用途? A: 出國旅遊換匯、跨境購物匯率比較、日常外幣查詢。 - Q: 現金匯率和即期匯率的差別? A: 現金匯率用於臨櫃換外幣紙鈔,即期匯率用於外幣帳戶轉帳或匯款。因銀行持有實鈔有保管、運送、偽鈔鑑定成本,現金匯率通常比即期差約 0.5~2%,換 1,000 美元現金比即期多付約 150~600 元台幣。 - Q: 買入和賣出怎麼看? A: 買入/賣出是銀行視角:出國換外幣(你支付台幣)看「賣出」價;回國換台幣(你交出外幣)看「買入」價。台銀買賣價差通常為即期匯率 0.3~1%、現金匯率 1~2%。 @@ -116,14 +116,14 @@ Version: v${VERSION} ## E-E-A-T Signals - 專業性:匯率計算邏輯與格式化策略具完整測試覆蓋。 -- 權威性:資料來源為臺灣銀行官方牌告匯率,每 5 分鐘自動同步。 +- 權威性:資料來源為臺灣銀行官方牌告匯率,排程檢查並同步。 - 可信度:開源 GPL-3.0,透明可驗證;提供聯絡方式。 - 經驗:專為台灣用戶設計,依各國旅遊消費習慣提供常用金額按鈕。 ## Key Metrics - 支援貨幣:${SUPPORTED_CURRENCY_COUNT} 種 -- 更新頻率:每 5 分鐘自動同步 +- 更新頻率:約每 5 分鐘檢查更新 - 匯率類型:現金買入、現金賣出、即期買入、即期賣出 - 6 種主題風格(Zen/Nitro/Kawaii/Classic/Ocean/Forest) - i18n ${SUPPORTED_LANGUAGE_COUNT} 語言支援(${SUPPORTED_LANGUAGE_LABEL_TEXT}) @@ -190,7 +190,7 @@ ${buildReverseRates()} - Source: 臺灣銀行牌告匯率(Bank of Taiwan) - Source URL: https://rate.bot.com.tw/xrt -- Update: 每 5 分鐘自動同步(GitHub Actions) +- Update: 約每 5 分鐘檢查更新(GitHub Actions) - Rate Types: 現金買入、現金賣出、即期買入、即期賣出 - Currencies: ${SUPPORTED_CURRENCY_COUNT} 種(TWD, USD, JPY, EUR, GBP, HKD, CNY, KRW, AUD, CAD, SGD, THB, NZD, CHF, VND, PHP, IDR, MYR) - Disclaimer: 匯率僅供參考,實際交易請以金融機構公告為準。 @@ -518,7 +518,7 @@ GET ${BASE_URL}openapi.json ## Answer Capsule (Q&A for AI Citation) -- Q: ${BRAND_SHORT} 提供什麼服務? A: ${BRAND_SHORT} 是台灣最精準的匯率換算工具,顯示臺灣銀行牌告的實際買入賣出四種報價(現金買入、現金賣出、即期買入、即期賣出),而非中間價。支援 ${SUPPORTED_CURRENCY_COUNT} 種貨幣,每 5 分鐘自動同步。 +- Q: ${BRAND_SHORT} 提供什麼服務? A: ${BRAND_SHORT} 是以臺灣銀行牌告買賣價為核心的匯率換算工具,顯示實際買入賣出四種報價(現金買入、現金賣出、即期買入、即期賣出),而非中間價。支援 ${SUPPORTED_CURRENCY_COUNT} 種貨幣,約每 5 分鐘檢查更新。 - Q: 如何取得即時台銀匯率(適合開發者/LLM)? A: 免費 CDN API:GET https://cdn.jsdelivr.net/gh/haotool/app@data/public/rates/latest.json。回傳欄位 details.{幣別}.cash.sell(現金賣出)、details.{幣別}.cash.buy(現金買入)、details.{幣別}.spot.sell(即期賣出)、details.{幣別}.spot.buy(即期買入)。無需 API Key,CORS 啟用,每 5 分鐘更新。 - Q: 現金匯率和即期匯率的差別? A: 現金匯率適用臨櫃換鈔(到銀行換現鈔),即期匯率適用銀行電匯(匯款)。現鈔通常比即期差 1~3%,因為銀行有保管與運送成本。 - Q: 買入和賣出怎麼看? A: 買入/賣出是銀行角度。您拿外幣換台幣 → 看「買入」(銀行買你的外幣);您拿台幣換外幣 → 看「賣出」(銀行賣外幣給你)。 diff --git a/apps/ratewise/scripts/generate-manifest.mjs b/apps/ratewise/scripts/generate-manifest.mjs index 730c9b0c0..d8b485e43 100644 --- a/apps/ratewise/scripts/generate-manifest.mjs +++ b/apps/ratewise/scripts/generate-manifest.mjs @@ -2,6 +2,7 @@ import { readFileSync, writeFileSync } from 'node:fs'; import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { APP_INFO, APP_MANIFEST } from '../src/config/app-info.ts'; +import { STYLE_DEFINITIONS } from '../src/config/themes.ts'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(__dirname, '..'); @@ -16,13 +17,23 @@ const constantsContent = readFileSync(constantsPath, 'utf-8'); const currencyCount = [...constantsContent.matchAll(/^\s+([A-Z]{3}):\s*\{/gm)].length; const versioned = (path) => `${path}?v=${VERSION_TOKEN}`; +const zenColors = STYLE_DEFINITIONS.zen.colors; + +function rgbTripletToHex(rgbTriplet) { + return `#${rgbTriplet + .trim() + .split(/\s+/) + .map((value) => Number.parseInt(value, 10).toString(16).padStart(2, '0')) + .join('') + .toUpperCase()}`; +} const manifest = { name: APP_INFO.name, short_name: APP_MANIFEST.shortName, description: `${APP_INFO.name}顯示臺灣銀行牌告實際買賣價(非中間價),支援 ${currencyCount} 種貨幣換算,每 5 分鐘同步,離線可用的 PWA 匯率工具。`, - theme_color: '#8B5CF6', - background_color: '#E8ECF4', + theme_color: rgbTripletToHex(zenColors.primary), + background_color: rgbTripletToHex(zenColors.background), display: 'standalone', scope: '/ratewise/', start_url: '/ratewise/', diff --git a/apps/ratewise/scripts/generate-markdown-mirrors.mjs b/apps/ratewise/scripts/generate-markdown-mirrors.mjs index 3662a7a65..152624602 100644 --- a/apps/ratewise/scripts/generate-markdown-mirrors.mjs +++ b/apps/ratewise/scripts/generate-markdown-mirrors.mjs @@ -256,7 +256,7 @@ ${APP_INFO.shortName} 是以臺灣銀行牌告匯率為基礎的換匯工具, ## 資料方法與範圍 - 資料來源為臺灣銀行官方牌告匯率,涵蓋 ${SUPPORTED_CURRENCY_COUNT} 種貨幣。 -- 每 5 分鐘自動同步最新報價,涵蓋現金買入、現金賣出、即期買入、即期賣出四種。 +- 約每 5 分鐘檢查最新報價並同步,涵蓋現金買入、現金賣出、即期買入、即期賣出四種;實際時間依臺灣銀行資料、GitHub Actions 佇列與 CDN 快取而定。 - 資料管線:GitHub Actions 每日抓取 + 雙重驗證(台銀牌告 vs open.er-api.com 中間價,誤差 ≤ 2%)+ Pull Request 自動審核後合併至 data branch。 - 匯差範例數字透過 SSG(vite-react-ssg)於 build 期嵌入靜態 HTML,搜尋引擎無需執行 JavaScript 即可讀取。 @@ -265,7 +265,7 @@ ${APP_INFO.shortName} 是以臺灣銀行牌告匯率為基礎的換匯工具, - **PWA 離線使用**:Service Worker 預快取,無網路仍可換算。 - **SSG 預渲染**:所有 SEO 頁面於 build 期產生靜態 HTML。 - **結構化資料**:JSON-LD 包含 WebSite、Organization、SoftwareApplication、CurrencyConversionService(首頁)、ExchangeRateSpecification(幣別頁)、HowTo、BreadcrumbList、Article、FAQPage(限 /faq/)、Dataset(開放資料)與 ImageObject。 -- **AI 友善**:robots.txt 允許 OpenAI / Anthropic / Perplexity / Google / Apple / DeepSeek / Mistral 等 ${ALL_AI_CRAWLERS.length}+ 主流 AI 爬蟲(四層治理);提供 llms.txt、llms-full.txt、openapi.json 供 LLM 引用。 +- **機器可讀**:robots.txt 允許 OpenAI / Anthropic / Perplexity / Google / Apple / DeepSeek / Mistral 等 ${ALL_AI_CRAWLERS.length}+ 主流 AI 爬蟲(四層治理);提供 llms.txt、llms-full.txt、openapi.json 供 LLM 工具引用。 - **開放原始碼**:所有程式碼公開於 GitHub(${APP_INFO.github})。 ## 作者 @@ -390,7 +390,7 @@ function buildOpenDataMd() { | OpenAPI 規格 | ${BASE_URL}openapi.json | - **免 API Key**、**免費使用**、**CORS 已啟用**。 -- 更新頻率:每 5 分鐘自動同步臺灣銀行牌告。 +- 更新頻率:約每 5 分鐘檢查臺灣銀行牌告並同步;實際新鮮度依來源、GitHub Actions 佇列與 CDN 快取而定。 - 涵蓋 ${SUPPORTED_CURRENCY_COUNT} 種貨幣的現金買/賣、即期買/賣四種報價。 ## 呼叫範例 diff --git a/apps/ratewise/scripts/generate-offline-html.mjs b/apps/ratewise/scripts/generate-offline-html.mjs index fc2a9d2fa..0b2cb9a0f 100644 --- a/apps/ratewise/scripts/generate-offline-html.mjs +++ b/apps/ratewise/scripts/generate-offline-html.mjs @@ -1,24 +1,130 @@ import { readFileSync, writeFileSync } from 'node:fs'; import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { format, resolveConfig } from 'prettier'; import { APP_INFO } from '../src/config/app-info.ts'; +import { STYLE_DEFINITIONS } from '../src/config/themes.ts'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(__dirname, '..'); +const zenColors = STYLE_DEFINITIONS.zen.colors; +const DARK_TEXT_COLOR = '2 6 23'; +const LIGHT_TEXT_COLOR = '255 255 255'; + +function rgbTripletToHex(rgbTriplet) { + return `#${rgbTriplet + .trim() + .split(/\s+/) + .map((value) => Number.parseInt(value, 10).toString(16).padStart(2, '0')) + .join('') + .toUpperCase()}`; +} + +function rgbTripletToRgba(rgbTriplet, alpha) { + const rgb = rgbTriplet + .trim() + .split(/\s+/) + .map((value) => Number.parseInt(value, 10)) + .join(', '); + + return `rgba(${rgb}, ${alpha})`; +} + +function rgbTripletToNumbers(rgbTriplet) { + return rgbTriplet + .trim() + .split(/\s+/) + .map((value) => Number.parseInt(value, 10)); +} + +function relativeLuminance(rgbTriplet) { + const [red, green, blue] = rgbTripletToNumbers(rgbTriplet).map((channel) => { + const value = channel / 255; + return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; + }); + + return red * 0.2126 + green * 0.7152 + blue * 0.0722; +} + +function contrastRatio(foreground, background) { + const foregroundLuminosity = relativeLuminance(foreground); + const backgroundLuminosity = relativeLuminance(background); + const lighter = Math.max(foregroundLuminosity, backgroundLuminosity); + const darker = Math.min(foregroundLuminosity, backgroundLuminosity); + + return (lighter + 0.05) / (darker + 0.05); +} + +function choosePrimaryForeground(primary) { + return contrastRatio(LIGHT_TEXT_COLOR, primary) >= contrastRatio(DARK_TEXT_COLOR, primary) + ? LIGHT_TEXT_COLOR + : DARK_TEXT_COLOR; +} + +function buildOfflineStyleBlock([styleName, definition]) { + const { colors } = definition; + const primaryForeground = choosePrimaryForeground(colors.primary); + + return `html[data-style='${styleName}'] { + --offline-theme-color: ${rgbTripletToHex(colors.primary)}; + --offline-background: ${rgbTripletToHex(colors.background)}; + --offline-surface: ${rgbTripletToHex(colors.surface)}; + --offline-border: ${rgbTripletToHex(colors.border)}; + --offline-text: ${rgbTripletToHex(colors.text)}; + --offline-text-muted: ${rgbTripletToHex(colors.textMuted)}; + --offline-primary: ${rgbTripletToHex(colors.primary)}; + --offline-primary-foreground: ${rgbTripletToHex(primaryForeground)}; + --offline-secondary: ${rgbTripletToHex(colors.secondary)}; + --offline-accent: ${rgbTripletToHex(colors.accent)}; + --offline-primary-tint: ${rgbTripletToRgba(colors.primary, 0.08)}; + --offline-primary-border-tint: ${rgbTripletToRgba(colors.primary, 0.18)}; + --offline-primary-shadow-soft: ${rgbTripletToRgba(colors.primary, 0.18)}; + --offline-primary-shadow-strong: ${rgbTripletToRgba(colors.primary, 0.26)}; + --offline-primary-shadow-hover: ${rgbTripletToRgba(colors.primary, 0.34)}; + --offline-warning-tint: ${rgbTripletToRgba(colors.warning, 0.16)}; + --offline-warning: ${rgbTripletToHex(colors.warning)}; + --offline-success-tint: ${rgbTripletToRgba(colors.success, 0.12)}; + --offline-success: ${rgbTripletToHex(colors.success)}; + }`; +} + +const themeColorMap = Object.fromEntries( + Object.entries(STYLE_DEFINITIONS).map(([styleName, definition]) => [ + styleName, + rgbTripletToHex(definition.colors.primary), + ]), +); + +const offlineThemeTokens = { + __THEME_COLOR__: rgbTripletToHex(zenColors.primary), + __OFFLINE_STYLE_BLOCKS__: Object.entries(STYLE_DEFINITIONS) + .map(buildOfflineStyleBlock) + .join('\n\n '), + __OFFLINE_THEME_COLOR_MAP__: JSON.stringify(themeColorMap), +}; function substitute(template) { - return template - .replace(/__BRAND_SHORT__/g, APP_INFO.shortName) - .replace(/__BRAND_FULL__/g, APP_INFO.name); + return Object.entries(offlineThemeTokens).reduce( + (content, [token, value]) => content.replace(new RegExp(token, 'g'), value), + template + .replace(/__BRAND_SHORT__/g, APP_INFO.shortName) + .replace(/__BRAND_FULL__/g, APP_INFO.name), + ); } -function generate(templatePath, outPath) { +async function generate(templatePath, outPath) { const template = readFileSync(resolve(ROOT, templatePath), 'utf-8'); - writeFileSync(resolve(ROOT, outPath), substitute(template)); + const content = substitute(template); + const absoluteOutPath = resolve(ROOT, outPath); + const prettierConfig = (await resolveConfig(absoluteOutPath)) ?? {}; + const formatted = outPath.endsWith('.html') + ? await format(content, { ...prettierConfig, filepath: absoluteOutPath }) + : content; + writeFileSync(absoluteOutPath, formatted); console.log(` ✅ ${outPath}`); } console.log('🧾 生成靜態資源(從品牌模板)...'); -generate('scripts/templates/offline.template.html', 'public/offline.html'); -generate('scripts/templates/security.template.txt', 'public/.well-known/security.txt'); +await generate('scripts/templates/offline.template.html', 'public/offline.html'); +await generate('scripts/templates/security.template.txt', 'public/.well-known/security.txt'); console.log('✅ 靜態品牌資源生成完成'); diff --git a/apps/ratewise/scripts/prebuild-fetch-rates.mjs b/apps/ratewise/scripts/prebuild-fetch-rates.mjs index 5fad6bf81..9acc672ed 100755 --- a/apps/ratewise/scripts/prebuild-fetch-rates.mjs +++ b/apps/ratewise/scripts/prebuild-fetch-rates.mjs @@ -1,11 +1,11 @@ #!/usr/bin/env node /** - * 構建時匯率數據獲取腳本 - * 在 vite build 前執行,獲取最新匯率並保存到 public/rates.json - * 用於 SSG 預渲染時注入到靜態 HTML + * 匯率快照刷新腳本 + * 顯式刷新本機 public/rates.json;設定 RATEWISE_WRITE_FALLBACK_RATES=1 時同步更新 build-time fallback snapshot * - * 使用:pnpm run prebuild:fetch-rates + * 使用:pnpm refresh:rates + * 使用:pnpm refresh:fallback-rates */ import fs from 'fs'; @@ -21,6 +21,7 @@ const GENERATED_CONFIG_PATH = path.resolve(APP_ROOT, 'src/config/generated'); const RATES_CACHE_PATH = path.resolve(PUBLIC_PATH, 'rates.json'); const BUILD_TIME_RATES_PATH = path.resolve(GENERATED_CONFIG_PATH, 'build-time-rates.json'); const MAX_CACHE_AGE_MS = 24 * 60 * 60 * 1000; +const SHOULD_WRITE_FALLBACK_SNAPSHOT = process.env.RATEWISE_WRITE_FALLBACK_RATES === '1'; function parseRateTimestamp(value) { if (typeof value === 'number' && Number.isFinite(value)) { @@ -79,7 +80,8 @@ async function fetchLatestRates() { throw new Error('API 返回數據格式無效'); } - console.log(`✅ 成功獲取 ${Object.keys(data).length} 個幣別的匯率`); + const currencyCount = Object.keys(data.rates ?? {}).length; + console.log(`✅ 成功獲取 ${currencyCount} 個幣別的匯率`); return data; } catch (error) { console.warn(`⚠️ API 獲取失敗(${error.message}),嘗試使用緩存...`); @@ -101,8 +103,9 @@ function loadCachedRates() { } else { const ageMs = Date.now() - cacheTimestamp; if (ageMs <= MAX_CACHE_AGE_MS) { + const cachedCurrencyCount = Object.keys(cached.rates ?? {}).length; console.log( - `✅ 使用緩存匯率(年齡 ${formatAgeHours(ageMs)},${Object.keys(cached).length} 個頂層欄位)`, + `✅ 使用緩存匯率(年齡 ${formatAgeHours(ageMs)},${cachedCurrencyCount} 個幣別)`, ); return cached; } @@ -241,27 +244,31 @@ function getDefaultRates() { } /** - * 保存匯率到 public/rates.json 與可追蹤的 src/config/generated snapshot。 - * - * public/rates.json 是 build 暫存快取且被 .gitignore 忽略;app runtime 的首屏 fallback - * 必須讀取已 commit 的 generated snapshot,避免 clean checkout 在 typecheck/dev 前缺檔。 + * 保存匯率到 public/rates.json;只有明確要求時才更新可追蹤 fallback snapshot。 */ function saveRates(rates) { try { + if (SHOULD_WRITE_FALLBACK_SNAPSHOT && rates.source === 'Default fallback rates') { + console.error('❌ 拒絕使用預設匯率更新 build-time fallback snapshot'); + return false; + } + // 確保目錄存在 if (!fs.existsSync(PUBLIC_PATH)) { fs.mkdirSync(PUBLIC_PATH, { recursive: true }); } - if (!fs.existsSync(GENERATED_CONFIG_PATH)) { - fs.mkdirSync(GENERATED_CONFIG_PATH, { recursive: true }); - } const payload = `${JSON.stringify(rates, null, 2)}\n`; fs.writeFileSync(RATES_CACHE_PATH, payload, 'utf-8'); - fs.writeFileSync(BUILD_TIME_RATES_PATH, payload, 'utf-8'); console.log(`✅ 匯率已保存到:${RATES_CACHE_PATH}`); - console.log(`✅ build-time 匯率 snapshot 已保存到:${BUILD_TIME_RATES_PATH}`); + if (SHOULD_WRITE_FALLBACK_SNAPSHOT) { + if (!fs.existsSync(GENERATED_CONFIG_PATH)) { + fs.mkdirSync(GENERATED_CONFIG_PATH, { recursive: true }); + } + fs.writeFileSync(BUILD_TIME_RATES_PATH, payload, 'utf-8'); + console.log(`✅ build-time 匯率 snapshot 已保存到:${BUILD_TIME_RATES_PATH}`); + } return true; } catch (error) { console.error(`❌ 保存匯率失敗:${error.message}`); @@ -273,7 +280,7 @@ function saveRates(rates) { * 主函數 */ async function main() { - console.log(`\n📊 ${APP_INFO.shortName} 構建時匯率數據預處理`); + console.log(`\n📊 ${APP_INFO.shortName} 匯率快照刷新`); console.log('═'.repeat(50)); // 1. 獲取匯率 @@ -295,7 +302,7 @@ async function main() { console.log(` canonical 金額頁:${indexableAmountPages} 個`); console.log(` 預渲染靜態頁:${STATS.total} 個`); - console.log('\n✅ 構建前準備完成!'); + console.log('\n✅ 匯率快照刷新完成!'); console.log('═'.repeat(50) + '\n'); } diff --git a/apps/ratewise/scripts/templates/offline.template.html b/apps/ratewise/scripts/templates/offline.template.html index 1ebbdabba..d96f577a6 100644 --- a/apps/ratewise/scripts/templates/offline.template.html +++ b/apps/ratewise/scripts/templates/offline.template.html @@ -2,13 +2,46 @@ - - + + + + + 離線模式 - __BRAND_SHORT__ + diff --git a/apps/ratewise/seo-paths.config.mjs b/apps/ratewise/seo-paths.config.mjs index 08ab29840..ceb777653 100644 --- a/apps/ratewise/seo-paths.config.mjs +++ b/apps/ratewise/seo-paths.config.mjs @@ -168,29 +168,21 @@ export const INDEXABLE_CANONICAL_PATHS = [ export const SEO_PATHS = INDEXABLE_CANONICAL_PATHS; /** - * 需要回傳 app shell 的互動頁面(app-only) - * - * 排列順序:前 3 個為使用者功能頁(noindex 處理),後 4 個為開發展示頁(Disallow 處理) - * ─ 使用者功能頁:允許爬取,由 SEOHelmet noindex 排除索引(Google 官方建議) - * ─ 開發展示頁:直接 Disallow,無使用者價值,無需 noindex + * 需要回傳 app shell 的使用者功能頁(app-only) * * 注:/seo-tech/ 已移至 CONTENT_SEO_PATHS 成為可索引頁面(2026-04-07) */ -export const APP_ONLY_PATHS = [ - '/multi/', - '/favorites/', - '/settings/', +export const APP_ONLY_NOINDEX_PATHS = ['/multi/', '/favorites/', '/settings/']; + +/** 開發 / 展示頁:正式 build 不註冊、不預渲染;robots 仍明確 Disallow。 */ +export const DEV_ONLY_PATHS = [ '/theme-showcase/', '/color-scheme/', '/update-prompt-test/', '/ui-showcase/', ]; -/** 使用者功能頁子集(前 3):允許爬取 + noindex meta */ -export const APP_ONLY_NOINDEX_PATHS = APP_ONLY_PATHS.slice(0, 3); - -/** 開發 / 展示頁子集(後 4):Disallow 爬取 */ -export const DEV_ONLY_PATHS = APP_ONLY_PATHS.slice(3); +export const APP_ONLY_PATHS = [...APP_ONLY_NOINDEX_PATHS]; /** * 需要預渲染的 app-only 路由 @@ -316,8 +308,8 @@ export function getIncludedRoutes(paths) { export const SITE_CONFIG = { url: withTrailingSlash('https://app.haotool.org/ratewise/'), name: APP_INFO.name, - title: `${APP_INFO.shortName} — 台灣最精準匯率換算器`, - description: `${APP_INFO.shortName} 是台灣最精準的匯率換算工具,顯示臺灣銀行牌告的實際買入賣出價(非中間價),讓你換匯前清楚知道要付多少台幣。支援 18 種貨幣即時換算、現金/即期匯率切換、7-30 天歷史趨勢圖、PWA 離線使用,每 5 分鐘自動同步,免費無廣告無註冊。`, + title: `${APP_INFO.shortName} — 台銀牌告買賣價匯率換算器`, + description: `${APP_INFO.shortName} 以臺灣銀行牌告買賣價為核心,顯示實際買入賣出價(非中間價),協助換匯前參考估算台幣成本。支援 18 種貨幣換算、現金/即期匯率切換、7-30 天歷史趨勢圖、PWA 離線使用,約每 5 分鐘檢查更新,免費無廣告無註冊。`, }; /** diff --git a/apps/ratewise/src/__tests__/markdown-mirror.test.ts b/apps/ratewise/src/__tests__/markdown-mirror.test.ts index 9315e8be4..b3024be5f 100644 --- a/apps/ratewise/src/__tests__/markdown-mirror.test.ts +++ b/apps/ratewise/src/__tests__/markdown-mirror.test.ts @@ -135,7 +135,7 @@ describe('Authority guide Markdown mirrors', () => { const authorityGuides = [ { slug: 'sell-rate-vs-mid-rate', - heading: '賣出價比中間價更接近你真正要付的台幣', + heading: '賣出價比中間價更接近臨櫃換匯成本', keyContent: '中間價', faqKeyword: '換匯', }, diff --git a/apps/ratewise/src/__tests__/sw.test.ts b/apps/ratewise/src/__tests__/sw.test.ts index 4b80b5591..17e34de00 100644 --- a/apps/ratewise/src/__tests__/sw.test.ts +++ b/apps/ratewise/src/__tests__/sw.test.ts @@ -9,7 +9,52 @@ * [test:2026-01-10] PWA 離線功能測試 */ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeAll, afterEach } from 'vitest'; + +const { matchPrecacheMock, navigationHandlerRef } = vi.hoisted(() => ({ + matchPrecacheMock: vi.fn(), + navigationHandlerRef: { + current: null as + | ((params: { event: ExtendableEvent; request: Request }) => Promise) + | null, + }, +})); + +vi.mock('workbox-core', () => ({ + clientsClaim: vi.fn(), +})); + +vi.mock('workbox-precaching', () => ({ + cleanupOutdatedCaches: vi.fn(), + matchPrecache: (...args: unknown[]) => matchPrecacheMock(...args), + precacheAndRoute: vi.fn(), +})); + +vi.mock('workbox-routing', () => ({ + NavigationRoute: class NavigationRoute { + constructor( + handler: (params: { event: ExtendableEvent; request: Request }) => Promise, + ) { + navigationHandlerRef.current = handler; + } + }, + registerRoute: vi.fn(), + setCatchHandler: vi.fn(), +})); + +vi.mock('workbox-strategies', () => ({ + CacheFirst: class CacheFirst {}, + NetworkOnly: class NetworkOnly {}, + StaleWhileRevalidate: class StaleWhileRevalidate {}, +})); + +vi.mock('workbox-cacheable-response', () => ({ + CacheableResponsePlugin: class CacheableResponsePlugin {}, +})); + +vi.mock('workbox-expiration', () => ({ + ExpirationPlugin: class ExpirationPlugin {}, +})); // Mock ServiceWorkerGlobalScope const mockScope = 'https://example.com/ratewise/'; @@ -151,24 +196,28 @@ describe('Service Worker Cache Strategies', () => { 'static-resources': { strategy: 'CacheFirst', maxAge: 30 * 24 * 60 * 60 }, }; - it('should use NavigationRoute + bounded SWR-style handler for zero-white-screen navigation', async () => { + it('should use NavigationRoute + hybrid SWR + precache-first handler for zero-white-screen navigation', async () => { const fs = await import('node:fs/promises'); const path = await import('node:path'); const swPath = path.resolve(__dirname, '../sw.ts'); const sourceCode = await fs.readFile(swPath, 'utf-8'); - // 已 install 過的 PWA 與已 visited 的瀏覽器:cache hit 立即返回,背景 revalidate。 - // cache miss 則保留 3 秒 bounded fallback,避免慢網路下白屏。 + // 暖快取:cache hit 立即返回,背景 revalidate(SWR)。 + // 冷快取:precache index.html 立即回傳,背景抓最新版本寫入 html-cache。 + // 避免 3s timeout 在 iOS precache 被驅逐時錯誤回傳 offline.html 給在線用戶。 expect(sourceCode).toContain('handleNavigationRequest'); expect(sourceCode).toContain('new NavigationRoute(handleNavigationRequest)'); - expect(sourceCode).toContain('event.waitUntil('); expect(sourceCode).toContain('fetchAndCacheNavigation(request, cache)'); - expect(sourceCode).toContain( - 'event.waitUntil(networkResponse.then(() => undefined).catch(() => undefined))', - ); + expect(sourceCode).toContain("matchPrecache('index.html')"); // 防回歸:禁止重新引入 NetworkFirst navigation(cold-start 白屏根因之一)。 expect(sourceCode).not.toContain('new NetworkFirst('); + // 防回歸:禁止重新引入 3s 全域 navigation timeout(iOS eviction 假離線根因)。 + expect(sourceCode).not.toContain('const NAVIGATION_NETWORK_TIMEOUT_MS'); + expect(sourceCode).not.toContain('Promise.race([networkResponse, timeoutFallback])'); + // case 3(precache 已 miss)允許 8s bounded race,避免 hung network 無限白屏。 + expect(sourceCode).toContain('const NAVIGATION_FETCH_TIMEOUT_MS = 8000'); + expect(sourceCode).toContain('navigation-fetch-timeout'); }); it('should have correct historical rates cache configuration', () => { @@ -194,6 +243,18 @@ describe('Service Worker Cache Strategies', () => { expect(sourceCode).toContain('new NetworkOnly('); }); + it('should cache both legacy and provider-scoped aggregate history routes', async () => { + const fs = await import('node:fs/promises'); + const path = await import('node:path'); + + const swPath = path.resolve(__dirname, '../sw.ts'); + const sourceCode = await fs.readFile(swPath, 'utf-8'); + + expect(sourceCode).toContain('/public/rates/history-30d.json'); + expect(sourceCode).toContain('/public/rates/providers/moneybox/history-30d.json'); + expect(sourceCode).toContain("cacheName: 'history-aggregate-cache'"); + }); + // 🔴 RED: JS/CSS 應使用 CacheFirst(Vite hash-based filenames 是 immutable) it('should use CacheFirst for JS/CSS static resources (hash-based filenames are immutable)', async () => { const fs = await import('node:fs/promises'); @@ -237,12 +298,13 @@ describe('Service Worker Cache Strategies', () => { const swPath = path.resolve(__dirname, '../sw.ts'); const sourceCode = await fs.readFile(swPath, 'utf-8'); - // bounded SWR-style navigation → resolveOfflineDocumentFallback helper(含三層 fallback + emergency HTML)。 + // precache-first SWR navigation → resolveOfflineDocumentFallback helper(含三層 fallback + emergency HTML)。 + // 注意:已從 Promise.race timeout 改為 precache-first 方案(避免 iOS precache 被驅逐時誤回 offline.html)。 expect(sourceCode).toContain('new NavigationRoute('); expect(sourceCode).toContain('resolveOfflineDocumentFallback'); expect(sourceCode).toContain("emergencyReason: 'emergency-navigation-fallback'"); - expect(sourceCode).toContain('const NAVIGATION_NETWORK_TIMEOUT_MS = 3000'); - expect(sourceCode).toContain('Promise.race([networkResponse, timeoutFallback])'); + expect(sourceCode).toContain("matchPrecache('index.html')"); + expect(sourceCode).toContain('fetchAndCacheNavigation'); // 防回歸:navigation 不可重新引入 NetworkFirst(cold-start 白屏根因之一)。 expect(sourceCode).not.toContain('new NetworkFirst('); }); @@ -383,3 +445,118 @@ describe('Service Worker Denylist', () => { expect(isDenied('/faq')).toBe(false); }); }); + +describe('handleNavigationRequest', () => { + const htmlCacheName = 'html-cache'; + const navigationUrl = 'https://example.com/ratewise/about'; + const offlineHtml = 'offline fallback'; + + let htmlCache: { + match: ReturnType; + put: ReturnType; + }; + let cachesOpen: ReturnType; + let cachesMatch: ReturnType; + + beforeAll(async () => { + htmlCache = { + match: vi.fn(), + put: vi.fn(), + }; + cachesOpen = vi.fn().mockResolvedValue(htmlCache); + cachesMatch = vi.fn().mockResolvedValue(undefined); + + vi.stubGlobal('caches', { + open: cachesOpen, + match: cachesMatch, + keys: vi.fn().mockResolvedValue([]), + delete: vi.fn(), + }); + + await import('../sw.ts'); + expect(navigationHandlerRef.current).not.toBeNull(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + cachesOpen.mockResolvedValue(htmlCache); + cachesMatch.mockResolvedValue(undefined); + htmlCache.match.mockReset(); + htmlCache.put.mockReset(); + matchPrecacheMock.mockReset(); + }); + + function createNavigationEvent(): ExtendableEvent { + return { waitUntil: vi.fn() } as unknown as ExtendableEvent; + } + + function createOfflineFallbackResponse(): Response { + return new Response(offlineHtml, { + status: 200, + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + }); + } + + it('case 2: precache hit resolves instantly without timer dependency', async () => { + vi.useFakeTimers(); + + const precachedShell = new Response('precached index', { + status: 200, + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + }); + + htmlCache.match.mockResolvedValue(undefined); + matchPrecacheMock.mockImplementation((url: string) => + Promise.resolve(url === 'index.html' ? precachedShell : null), + ); + vi.stubGlobal( + 'fetch', + vi.fn(() => new Promise(() => undefined)), + ); + + const handler = navigationHandlerRef.current!; + const response = await handler({ + event: createNavigationEvent(), + request: new Request(navigationUrl), + }); + + expect(response).toBe(precachedShell); + expect(matchPrecacheMock).toHaveBeenCalledWith('index.html'); + await vi.runAllTimersAsync(); + }); + + it('case 3: hung network falls back to offline.html after bounded timeout', async () => { + vi.useFakeTimers(); + + const offlineFallback = createOfflineFallbackResponse(); + + htmlCache.match.mockResolvedValue(undefined); + matchPrecacheMock.mockImplementation((url: string) => { + if (url === 'index.html') return Promise.resolve(null); + if (url === 'offline.html') return Promise.resolve(offlineFallback); + return Promise.resolve(null); + }); + cachesMatch.mockResolvedValue(undefined); + vi.stubGlobal( + 'fetch', + vi.fn(() => new Promise(() => undefined)), + ); + + const handler = navigationHandlerRef.current!; + const responsePromise = handler({ + event: createNavigationEvent(), + request: new Request(navigationUrl), + }); + + await vi.advanceTimersByTimeAsync(8000); + + const response = await responsePromise; + const body = await response.text(); + + expect(body).toBe(offlineHtml); + expect(matchPrecacheMock).toHaveBeenCalledWith('index.html'); + expect(matchPrecacheMock).toHaveBeenCalledWith('offline.html'); + expect(cachesOpen).toHaveBeenCalledWith(htmlCacheName); + }); +}); diff --git a/apps/ratewise/src/components/AnswerCapsule.tsx b/apps/ratewise/src/components/AnswerCapsule.tsx index f09690432..d452bf058 100644 --- a/apps/ratewise/src/components/AnswerCapsule.tsx +++ b/apps/ratewise/src/components/AnswerCapsule.tsx @@ -1,4 +1,5 @@ import type { FAQEntry } from '../config/seo-metadata'; +import { contentPageTokens } from '../config/design-tokens'; interface AnswerCapsuleProps { title?: string; @@ -9,16 +10,14 @@ export function AnswerCapsule({ title = '快速答案', items }: AnswerCapsulePr if (items.length === 0) return null; return ( -
-

{title}

+
+

即時重點

+

{title}

{items.map((item) => ( -
+

{item.question}

-

{item.answer}

+

{item.answer}

))}
diff --git a/apps/ratewise/src/components/AppLayout.tsx b/apps/ratewise/src/components/AppLayout.tsx index 69ba04028..4c9f3f4fe 100644 --- a/apps/ratewise/src/components/AppLayout.tsx +++ b/apps/ratewise/src/components/AppLayout.tsx @@ -92,8 +92,8 @@ function Header() {
-
+
{/* 品牌 Logo + 標題(使用 span 而非 h1,避免每頁重複 h1)*/} -
+
- - {isZhTW ? APP_INFO.name : t('app.title')} + + {isZhTW ? APP_INFO.shortName : t('app.title')}
@@ -201,15 +193,9 @@ export function AppLayout() { * 使用 --app-height(由 JS 設定)而非 100dvh,確保 WebView 環境高度正確。 * Fallback:100dvh(JS 尚未執行時,或 SSG 初始渲染)。 */}
- {location.pathname === '/' ? ( -
-

{HOMEPAGE_SEO.content.heading}

-
- ) : null} - {/* Desktop sidebar (≥768px) */}
@@ -224,6 +210,7 @@ export function AppLayout() {
+ {location.pathname === '/' ? ( +
+

{HOMEPAGE_SEO.content.heading}

+
+ ) : null} {/* enter-only:key 變化觸發 remount,新頁面從方向滑入淡入(CSS @keyframes) */}
-
-
- {/* 頁面頂部導航:返回 + 麵包屑(PageNavHeader SSOT 模組)。 */} +
+
-
-

{page.heading}

-

{page.intro}

+
+

專題指南

+

{page.heading}

+

{page.intro}

-
-

重點整理

-