> For the complete documentation index, see [llms.txt](https://docs.ipcheck.ing/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.ipcheck.ing/developer/zh-tw/architecture/frontend.md).

# 前端

Vue 3 單頁應用：工具註冊表、狀態、路由，以及應用程式事件與命令匯流排。

底下的一切 `frontend/` 是一個使用 `<script setup>`、Pinia、採用 HTML5 history 模式的 vue-router、vue-i18n，以及建立在複製進來的 shadcn-vue 基礎元件之上的 Tailwind CSS v4。

`App.vue` 是一個精簡外殼——tooltip 提供者、toast 主體、PWA 安裝提示、主題，以及 `<router-view>`。它也是事件驅動管線恰好初始化一次的地方。

## 路由

`frontend/router/index.js` 宣告了四條實際路由與一個萬用捕捉：

| 路徑                 | 元件                      | 備註                |
| ------------------ | ----------------------- | ----------------- |
| `/`                | `Home.vue`              | 採預先載入——預設的著陸頁     |
| `/tools/:slug`     | `StandaloneTool.vue`    | 單一工具的完整頁面，可分享且可索引 |
| `/privacy`         | `PrivacyPolicy.vue`     |                   |
| `/r/:id`           | `report/ReportPage.vue` | 唯讀的共享診斷報告，不索引     |
| `/:pathMatch(.*)*` | —                       | 重新導向至 `/`         |

除了 `首頁` 是以延遲方式匯入，因此會排除在首頁 bundle 之外。 `scrollBehavior` 刻意 **不** 在同一路徑上只有 query 改變時捲動——這正是開啟與關閉工具抽屜時會發生的事。

進階工具有第二個進入點：在首頁上， `?tool=<slug>` 會在底部抽屜中打開同一個元件。兩個進入點都會渲染相同的 `.vue` 檔案；只有外層包裝不同。

值得連結的工具會把其輸入放在 query 中，格式為 `?q=`，在兩個進入點皆然—— `/tools/ipcalculator?q=…` 以及 `/?tool=ipcalculator&q=…`。元件在掛載時讀取 `route.query.q` 並執行它，然後在每次執行後用 `router.replace` 把 query 寫回去，因此網址列總是描述目前可見的結果，同時不會因每次執行都多出一筆歷史紀錄。 `IpCalculator.vue` 是最值得參考的範例。

抽屜是 *唯一的* 工具入口，當其登錄項目設定 `noStandalone: true` — `/tools/<slug>` 會重新導向至 `/?tool=<slug>` ，而不是直接渲染。當工具讀取的是首頁所擁有的狀態時，就會獲得這個旗標：Persona Check 會交叉參照首頁測試的結果，而且若從獨立頁面執行，會離開首頁。

## 工具登錄表

`frontend/data/tools.js` 是進階工具的單一事實來源。它輸出一個有順序的陣列和一個查找對應表：

```js
export const ADVANCED_TOOLS = [
  { slug: 'whois', emoji: '📓', titleKey: 'whois.Title',
    noteKey: 'advancedtools.Whois',
    component: () => import('@/components/advanced-tools/Whois.vue') },
  // …
];

export const TOOL_BY_SLUG = new Map(ADVANCED_TOOLS.map((t) => [t.slug, t]));
```

條目欄位：

| 欄位                     | 意義                                                  |
| ---------------------- | --------------------------------------------------- |
| `slug`                 | 由兩者共用的穩定識別碼 `?tool=<slug>` 以及 `/tools/:slug`        |
| `emoji`                | 卡片上與抽屜標題列中的圖示                                       |
| `titleKey` / `noteKey` | 標題與單行說明的 i18n 鍵                                     |
| `component`            | 延遲 `import()` 該工具的 `.vue` 檔案                        |
| `requiresOriginalSite` | 可選閘門；省略表示該工具是公開的                                    |
| `noStandalone`         | 可選。 `true` 表示該工具只會以首頁抽屜的形式存在——沒有 `/tools/<slug>` 頁面 |

有三個消費端會從它衍生，因此新增一個條目通常就是全部工作：

* **`Advanced.vue`** 把陣列映射到卡片網格（`卡片`），再依據 `configs.originalSite` (`enabledCards`）進行過濾，並以 `TOOL_BY_SLUG.get(route.query.tool)`解析抽屜的目前工具。解析後的延遲元件會快取在一個 `Map` 中，因此重新渲染不會重新掛載正在執行中的工具。卡片上的一般左鍵點擊會呼叫 `router.push({ path: '/', query: { tool } })`；修飾鍵與中鍵點擊則會落到卡片的 `<a href>` ，讓獨立頁面在新分頁開啟。對於一個 `noStandalone` 工具若 `href` 是 `/?tool=<slug>` 相反地，且抽屜中的「在新分頁開啟」圖示會隱藏。
* **`StandaloneTool.vue`** 解析 `TOOL_BY_SLUG.get(route.params.slug)`，再將其包裝在 `defineAsyncComponent`中，並透過 `use-document-meta.js`為每個工具設定本地化的標題、說明與 canonical URL，並在未知的 slug 時重新導向至 `/` 。一個 `noStandalone` 工具在這裡會被視為未註冊，但仍保留其目的地：重新導向會攜帶 `?tool=<slug>` ，因此首頁會在抽屜中打開它。請注意，路由器本身只有那一條動態路由——是登錄表在做解析，不是生成出來的路由表。
* **`Nav.vue`** 會在導覽中列出相同的工具，並套用相同的 `requiresOriginalSite` 過濾條件。

{% hint style="info" %}
`requiresOriginalSite: true` 會在自架實例上隱藏某個工具，因為它需要私有的 IPCheck.ing API 與已登入使用者。這個旗標是根據 `store.configs.originalSite`來評估的，而後端是從請求的 referer 推導出來的——見 [與 IPCheck.ing 綁定的功能](/developer/zh-tw/configuration/features-tied-to-ipcheck-ing.md).
{% endhint %}

其他登錄表則與它並列放在 `data/`: `sections.js` （驅動導覽、捲動追蹤與載入狀態的首頁區段 ID）、 `ip-databases.js` （使用者可切換的地理定位來源）、 `achievements.js` 以及 `achievement-rules.js`, `connectivity-import-lists.js` （Connectivity 區段的預設清單成員、匯入對話框提供的精選套件，以及多清單模型的上限——每清單目標數、清單數量、清單名稱長度；每個成員都會在 `public/favicons/`下隨附一個已提交的圖示，由 `pnpm fetch-favicons`抓取，並且可能帶有 `siteUrl` 覆寫值，用於卡片的開啟網站連結）、 `persona-tables.js` （深入 Persona Check 在任何請求前都需要的兩張參考表——各國可能使用的語言，以及標記書寫系統的字體）、 `changelog.json`，以及 `default-preferences.js`.

Connectivity 資料檔維持宣告式：該區段由使用者管理清單背後的邏輯——啟動時清理已儲存模型、清單 CRUD 防護（預先建立的「Mine」清單不能刪除或重新命名，且至少保留一個成員），以及精選匯入規劃（在目標清單內以主機名稱去重，並針對其每清單上限採取全有或全無）——都只是 `utils/connectivity-lists.js`中的純函式，由 `tests/connectivity-lists.test.js`.

其中一個子目錄 `data/` 被刻意地 **不** 一個已提交的登錄表： `data/banners/` 是被 git 忽略的、部署時資料，用於每個區段的橫幅欄位（`components/widgets/InfoBanner.vue`），每個首頁區段都會在底部掛載它——參見 [區段橫幅](/developer/zh-tw/configuration/section-banners.md).

## 沒有後端的工具

瀏覽器資訊、安全檢查清單和 IP 計算器從不呼叫 `/api`。登錄表條目是完全相同的—— `tools.js` 並未說明某個工具是否有路由——因此真正區分它們的是邏輯所在之處：位於 `utils/` （或 `common/`，當後端共用它們時）下的純模組，各自帶有規格，而且只負責渲染的元件。

IP 計算器（`ipcalculator`）是最完整的範例，共分三層：

| 層級    | 檔案                                                              | 角色                                                                                                                                                                       |
| ----- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 算術    | `common/ip-math.js`                                             | 面向兩種家族的 BigInt 位址運算——嚴格解析器、RFC 5952 格式化、遮罩與計數、包含關係、分割／彙整／範圍轉 CIDR。零匯入；後端的 RDAP 啟動查找（`common/rdap.js`）使用相同的 `parseCidr` / `prefixContains`。作為 `utils/ip-math.js`.         |
| 計算器邏輯 | `utils/ip-calc.js`                                              | `classifyInput(raw)` 決定貼上了什麼；分析器推導出卡片顯示的一切； `calculate(raw)` 是元件呼叫的唯一入口。                                                                                                 |
| 介面    | `components/advanced-tools/IpCalculator.vue` + `ip-calculator/` | 外殼（輸入、範例膠囊，以及來自 `store.allIPs`, `?q=` 同步的訪客自身 IP）與每種形狀各一張結果卡： `Ipv4Result`, `Ipv6Result`, `RangeResult`，由 `CalcSection`, `ValueRow`, `PrefixBitmap` 以及 `SubnetSplitter`. |

底下兩層之間有兩個契約： **沒有任何東西會丟出例外** ——垃圾資料會得到 `null` 來自輔助函式，或 `{ kind: 'invalid', reason }` 來自分類器，而元件會將 `reason` 對應到 `ipcalculator.invalid.*` 字串——而且 **分析器輸出已可直接顯示**：位址是字串，計數則透過 `formatCount()`處理，因此沒有任何模板會碰到 BigInt（ `JSON.stringify` 也會對它卡住）。分類器保留原始的 `值` ，因為位元圖需要這個數字。

`classifyInput` 會以固定順序套用規則，而這個順序就是語法——先檢查範圍，再檢查清單，最後才是單一 token：

| `種類`                      | 符合                                                                                                                     |
| ------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `範圍`                      | `a-b` （或 `a - b`）；IPv4 的結尾可以只是單獨的最後一個八位元組（`10.0.0.1-254`）；若是反向配對，會交換並標記                                                |
| `cidr-list`               | 兩個或更多 token 以空白分隔， `,` 或 `;`，每個都是前綴或位址；無效 token 會被回報，但不會致命                                                             |
| `ipv4-cidr` / `ipv6-cidr` | `a.b.c.d/n`, `x::/n`，或帶有點分遮罩的 IPv4；主機位元會在 `位址`上保留，在 `網路`                                                               |
| `ipv6`                    | 任何帶有冒號且 `parseIPv6` 接受的內容；會先剝除 `%zone` 後綴與 `[…]` 方括號，保留 zone 以便顯示                                                      |
| `ipv4`                    | 嚴格的點分四元組（`表示法：'dotted'`），或 inet\_aton 形式—— `127.1`, `0177.0.0.1`, `0x7f.1`、一個裸八進位數字——會被標記為 `obfuscated: true` ，其 `表示法` |
| `hex`                     | `0x…` ，最多 32 個數字（≤ 8 → IPv4），或一個裸的 32 位十六進位字串                                                                          |
| `整數`                      | 十進位數字；依數值大小判定家族                                                                                                        |
| `無效`                      | 其他所有情況，會附上一個 `reason` 代碼（`empty`, `範圍`, `cidr-prefix`, `ipv6-syntax`, `hex-too-large`，……）                              |

嚴格解析（`01.2.3.4` 不是 IPv4）放在 `ip-math.js`；寬鬆的 inet\_aton 語法則放在 `ip-calc.js` 中，這是刻意設計的，並會標示為 obfuscated——重點是告訴訪客瀏覽器會如何讀取這個字串，而不是悄悄選定某種解讀。MAC 位址刻意不作為輸入：MAC Lookup 工具負責那些內容，而這裡只處理 IPv6 介面識別碼中的 EUI-64 → MAC 回復。

`ip-calc.js` 也會把 IANA 的特殊用途登錄表帶來，分成兩個表格， `IPV4_SPECIAL_BLOCKS` / `IPV6_SPECIAL_BLOCKS` ——一列列的 `{ cidr, id, label, rfc[], scope, global }`，按最長前綴解析（`lookupBlocks`). `label` 是登錄表本身的英文名稱，會原樣呈現；只有 `scope` 這個字會經過 `t()` (`ipcalculator.scope.*`）。IPv6 位址若不屬於任何一列，會被分類為 IETF 保留；IPv4 則是一般的全域單播。

外殼負責的一項互動細節： `PrefixBitmap` 會發出 `update:prefix` 在拖曳滑桿時的每一步，以及 `commit:prefix` 在放開時。Shell 會重新計算 `analysis.cidr` 即時，但會前進 `analysis.split` — 什麼 `SubnetSplitter` 讀取——只在提交時，因為子網清單在拖曳途中縮小會使頁面變短，瀏覽器會限制捲動，而滑塊把手會被從指標下方拉走。 `ValueRow` 基於同樣的原因，具有固定的最小高度。

規格： `tests/ip-math.test.js` （這也會將橋接固定到每個 `common` 匯出）以及 `tests/ip-calc.test.js`。請參見 [測試](/developer/zh-tw/development/testing.md).

## Pinia store

`frontend/store.js` 定義了一個 store， `main`。它保存的是跨元件狀態，而不是各元件的細節：

* **工作階段 / 驗證** — `使用者`, `isSignedIn`, `isFireBaseSet`，以及登入、登出和驗證監聽器動作。 `remoteUserInfo` 保存登入後的設定檔，來源為 `/api/getuserinfo`，包括位於以下 getter 後方的每個功能配額快照： `quotaExceeded` getter（透過 `markQuotaExhausted()` 在後端回覆 429 時將其固定）。這個 getter 只是建議性質——後端才是權威地執行配額——而且它的 `ipinfo` 旗標是 **僅供顯示**：該配額是按唯一 IP 計量，所以「已耗盡」仍允許重複查詢，且絕不能搶先攔截請求。
* **後端功能旗標** — `configs`，由以下項目以 fire-and-forget 方式填入： `fetchConfigs()` 來自 `/api/configs`。元件以響應式方式讀取，因此第一次渲染絕不會等待該往返請求。到達後，這些旗標也會推導出各地理定位來源的可用性；如果儲存的來源偏好已不再設定，便會遷移到最近可用的來源。
* **使用者偏好** — `userPreferences`，從單一有版本的 `localStorage` 鍵載入並回寫；較舊的鍵會被忽略，而不會遷移。唯一的結構化項目是 Connectivity 區段的 `connectivityLists` 多清單模型，另外還會經過 `sanitizeLists()` (`utils/connectivity-lists.js`）在每次載入時處理：垃圾項目會被移除，而遺失或損毀的模型會從舊版扁平目標鍵重建——這些鍵只在該遷移時讀取並保留以便回復，但之後不再寫回。
* **頁面狀態** — `mountingStatus` 以及 `loadingStatus` （每個區段一個旗標，來自 `data/sections.js`), `currentSection`, `isMobile`, `isDarkMode`, `openSheet`，toast 的 `alert` 插槽。
* **收集到的 IP** — `allIPs`，一個陣列，內容為 `{ ip, country, location, asn, org }` ，透過以下方式從多個元件合併： `updateAllIPs()`；後來的來源會補上較早來源留空的欄位。Globalping 選擇器與 IP 歷史記錄器會讀取它。
* **地理定位來源** — `ipDBs` （來自 `data/ip-databases.js`）以及 `activeSources` getter； `已啟用` 僅由設定推導，絕不會因執行時失敗而翻轉。
* **成就** — `userAchievements` 再加上一個單一槽位的更新管線（`triggerUpdateAchievements` / `achievementToUpdate`）其 `User.vue` 會監看並回報給後端。

不得在 store 實例之間共用的狀態物件是由工廠產生的（`createInitialIpDBs()`, `createMountingStatus()`，…），而不是模組層級的字面值。

## app-events 匯流排

`frontend/utils/app-events.js` 刻意做得很小：一個 `Map` 把事件名稱對應到一個 `Set` 處理器的 `onAppEvent(event, handler)` ，並回傳一個取消訂閱函式， `emitAppEvent(event, payload)`，以及一次性的 `waitForAppEvent(event, { timeoutMs })`，它會以事件的下一個 payload 解析（先訂閱，再觸發產生者）。發送採 fire-and-forget，且會捕捉拋錯的處理器，因此單一損壞的訂閱者不會破壞發送者。它不從 Vue 匯入任何東西，所以 utils 與純模組也能使用。

事件是狀態事實；當某件事需要 *讓* 執行一次測試並取回其結果時，那就是 [app-commands 匯流排](#the-app-commands-bus) 如下。

元件會發出領域事件 **無條件地** —「速度測試完成」、「whois 查詢已執行」—並且對誰在監聽毫不在意。三條管線搭乘這個匯流排，各自在 `App.vue`.

```mermaid
flowchart TD
    CB["呼叫端（快捷鍵、重新整理協調器、PersonaCheck、文件助手）"]
    CMD["utils/app-commands.js"]
    C["元件（SpeedTest、Whois、IpInfos、⋯）"]
    BUS["utils/app-events.js"]
    AE["use-achievement-engine.js"]
    RC["use-report-collector.js"]
    PC["use-persona-collector.js"]
    SE["sentry-init.js"]
    ST["Pinia store 欄位 → User.vue → 後端"]
    SN["報告快照 → 分享對話框 + /r/:id"]
    PO["Persona 快照 → PersonaCheck.vue"]

    CB -->|"dispatchAppCommand('webrtc:run', {...})"| CMD
    CMD -->|"每個命令一個擁有者"| C
    C -->|"emitAppEvent('speedtest:finished', {...})"| BUS
    BUS --> AE --> ST
    BUS --> RC --> SN
    BUS --> PC --> PO
    BUS --> SE
```

### 成就引擎

`data/achievement-rules.js` 將事件對應到成就 slug。每條規則都是 `{ event, slug, when? }`，其中 `when` 是對 payload 的純判定：

```js
{ event: 'speedtest:finished', slug: 'RapidPace', when: (p) => p.downloadSpeed >= 500 },
```

`composables/use-achievement-engine.js` 每條規則訂閱一個監聽器，並負責所有跨切關卡：當訪客未登入時丟棄事件，評估 `when`，略過已解鎖的成就，然後將 slug 排入佇列。由於啟動事件可能在帳戶的成就快照從後端載入之前就觸發，因此在快照到達前符合條件的規則會先暫存，待套用後再重新檢查——全為 false 的初始狀態絕不會被誤認為「尚未達成任何成就」。store 一次只保存一個成就，因此同時解鎖（一次速度測試可能跨越三個門檻）會每隔 2 秒派送一次，而且每次派送時都會重新檢查，以防在佇列期間已經解鎖。

因此，新增成就意味著：在 `data/achievements.js`中新增一筆，並在 `data/achievement-rules.js`中新增一條規則，且只有在沒有合適的現有事件時才新增新的領域事件。元件完全不需要動。

### 報告收集器

可分享的診斷報告也搭乘同一個匯流排。每個「我的網路」測試都會發出 `<domain>:finished` ，並帶上完整的結構化結果。 `composables/use-report-collector.js` 會將每個 payload 交給其 builder，位於 `utils/report-builders.js`，為每個區段保留最新的快照（最新者勝出），並以唯讀方式提供給分享對話框以及 `/r/:id` 頁面。區段形狀由以下檔案白名單化： `common/report-schema.js`，後端就是用同一個模組來驗證上傳。

{% hint style="warning" %}
Builder 會柔性失敗：未知值會靜默刪除該欄位。如果你變更某項測試的結果語意，務必在同一次變更中更新其 builder 白名單與 schema enum，否則該欄位會悄悄從報告中消失，而不是報錯。
{% endhint %}

某欄位必須列在 **五個** 地方中才能一路存活，而只有最後四個會明確失敗：

1. 其中 `emitAppEvent()` 元件中的呼叫——有些發送者會從自己的狀態中挑選欄位，而不是直接展開整個狀態。
2. 位於 `utils/report-builders.js`.
3. 位於 `common/report-schema.js`.
4. 其中 `/r/:id` 位於 `components/report/sections/`.
5. Markdown 表格位於 `utils/report-export.js`，其欄位是手動列出的。（JSON 下載什麼都不需要——它會原樣序列化收集到的快照。）

若漏掉步驟 1 或 5，所有東西仍會通過：測試是直接餵給 builders，因此永遠不會實際跑到發送器，而缺少 Markdown 欄位也不算錯誤。請在一次變更中把欄位加到所有地方。

命令處理器會以相同的 `<domain>:finished` payload 解析，因此欄位白名單也會影響文件助手的 `run_my_tests` 工具拿回什麼。

## app-commands 匯流排

`frontend/utils/app-commands.js` 是事件匯流排的命令式雙生體。事件表示「這件事發生了」——可有任意數量的訂閱者，沒有回傳值。命令表示「做這件事」——只有一個擁有者，而且 `dispatchAppCommand(name, payload, { timeoutMs })` 會回傳一個 promise，在工作完成時以擁有者的結果解析。和事件匯流排一樣，它不從 Vue 匯入任何東西。

API 介面：

| 函式                                                 | 角色                                  |
| -------------------------------------------------- | ----------------------------------- |
| `registerAppCommand(name, handler)`                | 認領一個命令；回傳一個取消註冊函式，只會移除自己的處理器        |
| `dispatchAppCommand(name, payload, { timeoutMs })` | 執行該命令；以處理器結果解析                      |
| `hasAppCommand(name)`                              | 該命令目前是否有擁有者                         |
| `waitForAppCommand(name, { timeoutMs })`           | 一旦命令有擁有者就解析——若已經有，則立即解析             |
| `appCommandError(code, message)`                   | 真正的 `Error` 攜帶可機器讀取的 `code`，用於結構化拒絕 |

拒絕會帶有 `error.code`，因此呼叫端可以以程式化方式分辨受閘控或格式錯誤的執行與真正失敗：

| 代碼    | 產生者     | 意義                        |
| ----- | ------- | ------------------------- |
| `不可用` | 匯流排     | 沒有為該命令註冊擁有者               |
| `逾時`  | 匯流排     | 處理器（或註冊等待）超過了 `timeoutMs` |
| `驗證`  | 依慣例，擁有者 | 需要登入                      |
| `配額`  | 依慣例，擁有者 | 已達使用上限                    |
| `輸入`  | 依慣例，擁有者 | Payload 缺失或無效             |

擁有者可以新增領域專屬代碼，但這五個在整個匯流排上都維持相同含義。

payload 合約刻意保持寬鬆：一個普通 JSON 物件，其形狀由命令擁有者在註冊處定義並文件化——沒有中央 payload 登錄。

元件透過以下方式註冊： `composables/use-app-command.js`，這是 Vue 綁定：註冊發生在 setup 時，因此只要擁有者存在，命令就可派送，而元件的作用域釋放會將其解除註冊。今天共註冊了五個命令：

| 命令                 | 擁有者                    | payload                        |
| ------------------ | ---------------------- | ------------------------------ |
| `ipinfo:refresh`   | `IpInfos.vue`          | `{ index }` 可選——一張卡片，或不帶它的整個網格 |
| `connectivity:run` | `ConnectivityTest.vue` | `{ trigger }`                  |
| `webrtc:run`       | `WebRtcTest.vue`       | `{ isRefresh }`                |
| `dnsleak:run`      | `DnsLeaksTest.vue`     | `{ isRefresh }`                |
| `speedtest:toggle` | `SpeedTest.vue`        | 無——執行／暫停／繼續切換                  |

呼叫端是解耦的觸發器：鍵盤捷徑（`composables/use-shortcuts.js`）、重新整理協調器（`composables/use-refresh-orchestrator.js`）、Persona Check 的「執行缺少的測試」修復，以及文件助手的 `run_my_tests` 工具。

{% hint style="warning" %}
**跨元件觸發都經由匯流排——絕不用 template ref。** Home 以前會收集其區段元件的 refs 並呼叫它們的方法；那種模式已經不見了。Template ref 只保留給 UI 外框使用（聚焦輸入框、量測元素），絕不是用來讓另一個元件做事。
{% endhint %}

命令擁有者位於 home 路由，因此不在 home 的呼叫端會先導覽過去，然後搭配 `waitForAppCommand` 與派送一起使用——擁有者會在其 setup 期間註冊，而等待則彌合了 `router.push('/')` 解析完成與區段掛載之間的空隙。Persona Check 與文件助手都遵循這個流程；未來若有一個抽屜式掛載的工具只在其 `?tool=` 路由開啟時才擁有某個命令，也會用同樣方式被呼叫。

處理器會藉由事件匯流排來以測試結果解析，而不是在測試程式碼中鋪設回傳路徑：它會以 `waitForAppEvent('<domain>:finished')`訂閱，啟動執行，並回傳該 promise——因此呼叫端收到的 payload 與報告收集器和成就引擎看到的完全一致。 `IpInfos.vue`的 `ipinfo:refresh` 處理器是可複製的模式。

## 啟動序列

`frontend/main.js` 讓關鍵路徑保持精簡。它建立 app、Pinia、i18n 與 router，然後只以三件事來限制第一次渲染，而且是平行執行：驗證監聽器（**唯一的** 針對其 `auth-hint` 旗標顯示其曾登入的訪客）， `store.loadPreferences()`，以及 `loadActiveLocaleMessages()`。其他所有事情都採 fire-and-forget 或延後到掛載後—— `store.fetchConfigs()`、分析、旗幟圖示集合（數百 KB）以及 Sentry。

它也會註冊一個 `vite:preloadError` 在模組評估時的監聽器：部署後，從舊版建置載入的頁面會無法 lazy-import 已不存在的雜湊 chunk，因此 app 會重新載入一次（使用每分頁的時間戳鎖，防止真正原因是離線網路時陷入重新載入迴圈）。

## 按需載入的語系

`frontend/locales/i18n.js` 會建立 i18n 實例，並帶有 **empty** messages 以及透過 glob 建立的載入器對應表 `locales/*.json` ，並保留任何 `common/locale-registry.js` 所宣告的內容——一個 pack 檔與一條 registry 行必須彼此一致，語言才算存在。每次頁面載入只會有一個 locale 處於作用中——切換語言會保存選擇並重新啟動 app——因此 `loadActiveLocaleMessages()` 會載入目前 locale 的整個 fallback 鏈（變體 → 基底 → `en`），而 `main.js` 會在掛載前等待它。當時只有四個 pack，將每個 pack 都急切打包會多出約 44 KB gzip 後的無用負擔，而自那以後 registry 又變大了。

同樣原則也適用於子 pack：位於 `locales/` 下的 security-checklist 與 privacy 資料集會由各自的視圖按需 glob 並載入，而不是從啟動路徑載入。

messages 載入後， `updateMeta()` 會設定 `document.documentElement.lang` 為 registry 的 `htmlLang` (`zh` 所宣告的 `zh-CN`）、頁面標題，以及 keywords / description 的 meta 標籤。每頁覆寫來自 `composables/use-document-meta.js`.

一個建置時的 Vite 外掛會以相同的選取順序鏡像處理——已儲存的偏好設定， `?hl=`, `navigator.language`, `en` ——在一段行內 `<head>` script 和 `modulepreload`在 HTML 仍在串流時完成這條鏈。詳情見 [i18n](/developer/zh-tw/development/i18n.md).

## 由環境變數門控的動態初始化

兩個可選整合會在建置時受門控，因此沒有它們的自架部署會出貨 **沒有** 任何相關程式碼。

* **Sentry** ——受 `VITE_SENTRY_DSN_FRONTEND`門控。沒有 DSN 時， `sentry-init.js` 絕不會被匯入，而且 SDK 不會出現在打包中。有了它之後，區塊會在掛載後載入——如果在初始化前發生錯誤，則會立即載入。小型緩衝區會在初始化前攔截未捕捉的錯誤與拒絕，之後再將它們刷新出去。
* **Firebase Auth** ——受 `VITE_FIREBASE_API_KEY` + `VITE_FIREBASE_AUTH_DOMAIN` + `VITE_FIREBASE_PROJECT_ID`. `firebase-init.js` 載入 `firebase/app` 以及 `firebase/auth` 在第一次 `loadFirebaseAuth()` 呼叫時——已登入的啟動、登入點擊，或背景探測——並將結果記住。從未登入過的訪客永遠不會下載 SDK。

<details>

<summary>驗證提示如何選擇啟動路徑</summary>

`utils/auth-hint.js` 會儲存一個旗標，描述上一個工作階段是否已登入。在啟動時 `main.js` 會讀取它：

* `'1'` ——載入 Firebase，並在第一次渲染前等待驗證監聽器，因此第一個已驗證請求就會帶上權杖。
* `'0'` ——立即掛載；在點擊登入之前，SDK 絕不會載入。
* `null` （自該旗標推出以來的首次造訪，或已清除儲存空間）——立即掛載，然後在 3 秒後於背景探測驗證，讓下次啟動走完全相同的路徑。

</details>

第三個功能則是透過環境變數受門控，而不使用動態匯入機制： **Earth Online**，導覽列中的即時訪客面板——分成兩個層級。 `utils/pulse-beacon.js` 推導出 `PULSE_BEACON_URL` 以及 `hasPulseBackend` 來自 `VITE_PULSE_BEACON_URL`，而社交那半邊則依據這個旗標： `widgets/Pulse.vue` 會隱藏其狀態撰寫器、最新動態和訪客地圖， `PrivacyPolicy.vue` 會省略其區塊，且 `App.vue`的每頁載入一次訪問信標會立即返回。面板本身更廣——其故障動態仰賴後端的 `/api/cfradar?view=outages` ——因此導覽項目和 <kbd>p</kbd> 在 `use-shortcuts.js` 在以下任一情況下顯示 `hasPulseBackend` 或執行時的 `cloudFlare` 旗標來自 `/api/configs` 已設為啟用。該元件是一般的靜態匯入，因此這是渲染門檻，而不是打包門檻；信標本身是 fire-and-forget、以 idle 排程，且會吞掉每個拒絕，因此缺少或損壞的服務絕不會影響到應用程式其餘部分。見 [與 IPCheck.ing 綁定的功能](/developer/zh-tw/configuration/features-tied-to-ipcheck-ing.md).

App 程式碼絕不能直接匯入 `@sentry/vue` ——靜態匯入會把 SDK 再次拖回主打包中。明確訊號改由 event bus 傳遞； `sentry-init.js` 會訂閱 `ip-source:exhausted` （一張整個來源鏈都失敗的 IP 卡片），就像成就引擎訂閱自身事件的方式一樣。設定放在 [錯誤監控](/developer/zh-tw/configuration/error-monitoring.md).

## 輔助函式放置位置

| 需要                | 放在                           |
| ----------------- | ---------------------------- |
| Vue 響應式或生命週期      | `composables/` 為 `useXxx`    |
| 沒有任何 Vue 專屬內容     | `utils/` （從不 `以 use-` 為前綴）   |
| shadcn 支援（`cn()`) | `lib/`                       |
| 後端也需要的東西          | `common/`，並透過其中的橋接在 `utils/` |

撰寫這些檔案的慣例見於 [編碼慣例](/developer/zh-tw/development/coding-conventions.md)；測試範圍見於 [測試](/developer/zh-tw/development/testing.md).


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.ipcheck.ing/developer/zh-tw/architecture/frontend.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
