> 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/architecture/frontend.md).

# 前端

以下目录下的一切 `frontend/` 是一个使用以下技术栈的 Vue 3 单页应用： `<script setup>`，Pinia、在 HTML5 history 模式下的 vue-router、vue-i18n，以及基于复制进来的 shadcn-vue 基础组件之上的 Tailwind CSS v4。没有 TypeScript。

`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` | 只读的共享诊断报告，noindex  |
| `/:pathMatch(.*)*` | —                       | 重定向到 `/`           |

除……之外的所有内容 `Home` 采用懒加载导入，因此不会进入首页 bundle。 `scrollBehavior` 刻意不 **不** 在同一路径上仅 query 变化时滚动——打开和关闭工具抽屉就是这种情况。

高级工具还有第二个入口：在首页上， `?tool=<slug>` 会在底部抽屉中打开同一个组件。两个入口渲染的是同一个 `.vue` 文件；不同的只是外层包装。

## 工具注册表

`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` |
| `表情符号`                 | 卡片和抽屉标题栏上的图标                                |
| `titleKey` / `noteKey` | 用于标题和单行描述的 i18n 键                           |
| `组件`                   | 懒加载 `import()` 该工具的 `.vue` 文件               |
| `requiresOriginalSite` | 可选门控；省略则表示该工具是公开的                           |

有三个消费者依赖它，因此添加一个条目通常就是全部工作：

* **`Advanced.vue`** 将数组映射到卡片网格（`卡片`），并根据 `configs.originalSite` (`enabledCards`）进行过滤，然后使用以下方式解析抽屉中的活动工具： `TOOL_BY_SLUG.get(route.query.tool)`。解析出的懒加载组件会缓存到一个 `Map` 中，因此重渲染不会重新挂载正在运行的工具。对卡片进行普通左键单击会调用 `router.push({ path: '/', query: { tool } })`；按住修饰键的点击和中键点击会交给卡片的 `<a href>` ，因此独立页面会在新标签页中打开。
* **`StandaloneTool.vue`** 解析 `TOOL_BY_SLUG.get(route.params.slug)`，并将其包装在 `defineAsyncComponent`中，通过以下方式为每个工具设置本地化标题、描述和规范 URL： `use-document-meta.js`，并重定向到 `/` ，如果 slug 未知则重定向。请注意，路由器本身只有这一条动态路由——是注册表在负责解析，而不是生成的路由表。
* **`Nav.vue`** 在导航中列出相同的工具，并应用相同的 `requiresOriginalSite` 过滤条件。

{% hint style="info" %}
`requiresOriginalSite: true` 在自托管实例中会隐藏某个工具，因为它需要私有的 IPCheck.ing API 以及已登录用户。该标志会根据以下内容进行判断： `store.configs.originalSite`，后端会根据请求的 referer 推导出该值——参见 [与 IPCheck.ing 绑定的功能](/developer/zh/configuration/features-tied-to-ipcheck-ing.md).
{% endhint %}

其他注册表与它一起位于 `data/`: `sections.js` （驱动导航、滚动跟踪和加载状态的首页分区 ID）， `ip-databases.js` （用户可在其间切换的地理定位来源）， `achievements.js` 和 `achievement-rules.js`, `changelog.json`，以及 `default-preferences.js`.

## Pinia 存储

`frontend/store.js` 定义了一个存储： `main`。它保存的是跨组件状态，而不是某个组件的细节：

* **会话 / 认证** — `用户`, `isSignedIn`, `isFireBaseSet`，以及登录、登出和认证监听器动作。
* **后端功能标志** — `configs`，由以下内容以 fire-and-forget 方式填充： `fetchConfigs()` 来自 `/api/configs`。组件以响应式方式读取它，因此首次渲染从不等待这次往返请求。到达后，这些标志还会推导每个地理定位来源的可用性；如果存储的来源偏好不再配置，则会迁移到最近可用的来源。
* **用户偏好** — `userPreferences`，从以下位置读取并写回： `localStorage`，并支持从旧键迁移。
* **页面状态** — `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` 获取器； `enabled` 仅由配置派生，绝不会因运行时故障而翻转。
* **成就** — `userAchievements` 以及一个单槽更新流水线（`triggerUpdateAchievements` / `achievementToUpdate`） `User.vue` 会监听并上报给后端。

不能在存储实例之间共享的状态对象由工厂函数生成（`createInitialIpDBs()`, `createMountingStatus()`，……），而不是模块级字面量。

## app-events 总线

`frontend/utils/app-events.js` 大约 30 行：一个 `Map` 将事件名映射到 `Set` 处理器的 `onAppEvent(event, handler)` ，会返回一个取消订阅函数，以及 `emitAppEvent(event, payload)`。发射是 fire-and-forget 的，且会捕获抛错的处理器，因此一个出问题的订阅者不会破坏发射器。它不导入 Vue 的任何内容，因此 utils 和普通模块也能使用它。

组件会发出领域事件 **无条件地** ——“速度测试完成了”“whois 查询已运行”——并且不关心谁在监听。两条流水线都通过这条总线运行，并且都只在以下位置初始化一次： `App.vue`.

```mermaid
flowchart TD
    C["组件（SpeedTest、Whois、IpInfos，……）"]
    BUS["utils/app-events.js"]
    AE["use-achievement-engine.js"]
    RC["use-report-collector.js"]
    SE["sentry-init.js"]
    ST["Pinia 存储槽 → User.vue → 后端"]
    SN["报告快照 → 分享对话框 + /r/:id"]

    C -->|"emitAppEvent('speedtest:finished', {...})"| BUS
    BUS --> AE --> ST
    BUS --> RC --> SN
    BUS --> SE
```

### 成就引擎

`data/achievement-rules.js` 将事件映射到成就 slug。每条规则都是 `{ event, slug, when? }`，其中 `when` 是对载荷的纯谓词：

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

`composables/use-achievement-engine.js` 为每条规则订阅一个监听器，并负责所有横切防护：当访客未登录时丢弃事件，跳过已经解锁的成就，计算 `when`，然后将该 slug 入队。存储一次只处理一个成就，因此同时解锁的情况（一次速度测试可能跨过三个阈值）会相隔 2 秒派发，并且每个成就在派发时都会重新检查，以防它在队列中时已经被解锁。

因此，添加一个成就意味着：在以下位置添加一条记录 `data/achievements.js`，在以下位置添加一条规则 `data/achievement-rules.js`；只有在没有合适的现有事件时，才需要新增一个领域事件。组件从不需要改动。

### 报告收集器

可分享的诊断报告也走同一条总线。每个“我的网络”测试都会发出 `<domain>:finished` ，携带其完整的结构化结果。 `composables/use-report-collector.js` 会将每个载荷通过其构建器运行于 `utils/report-builders.js`，为每个分区保留最新快照（最新的生效），并以只读方式暴露给分享对话框和 `/r/:id` 页面。 `common/report-schema.js`，后端就是用同一个模块来验证上传的。

{% hint style="warning" %}
构建器采用软失败：未知值会静默丢弃该字段。如果你更改某个测试结果的语义，请在同一次修改中更新其构建器白名单和 schema 枚举，否则该字段会在报告中悄悄消失，而不是报错。
{% endhint %}

## 启动序列

`frontend/main.js` 保持关键路径尽可能短。它会创建应用、Pinia、i18n 和路由器，然后只把首次渲染建立在三件并行进行的事情上：认证监听器（**仅仅** 适用于其 `auth-hint` 标志表明他们已登录）， `store.loadPreferences()`，以及 `loadActiveLocaleMessages()`。 `store.fetchConfigs()`、分析、旗帜图标集合（数百 KB）以及 Sentry。

它还注册了一个 `vite:preloadError` 监听器：在模块求值阶段，部署之后，从旧构建加载的页面会在懒加载一个已不存在的哈希 chunk 时失败，因此应用会自动重载一次（使用每标签页的时间戳锁来防止在真正原因是离线网络时陷入重载循环）。

## 按需加载的语言环境

`frontend/locales/i18n.js` 使用以下内容创建 i18n 实例： **空的** 消息以及一个动态导入的加载器映射（`en`, `zh`, `fr`, `ru`）。每次页面加载只会激活一个语言环境——切换语言会持久化选择并重启应用——因此 `loadActiveLocaleMessages()` 会加载当前语言环境以及作为回退的英语，并且 `main.js` 会在挂载前等待它完成。把这四种语言全部急切打包会多出约 44 KB 的 gzip 死数据。

同样的原则也适用于子包：位于以下位置的 security-checklist 数据集 `locales/security-checklist/` 由该工具按需加载，而不是走启动路径。

消息加载完成后， `updateMeta()` 会设置 `document.documentElement.lang` （其中 `zh` 声明为 `zh-CN`），页面标题，以及 keywords / description 元标签。每页覆盖项来自 `composables/use-document-meta.js`.

## 由环境变量门控的动态初始化

两个可选集成在构建时受门控，因此没有它们的自托管部署会随包一起发布 **不** 任何相关代码。

* **Sentry** ——受以下变量门控： `VITE_SENTRY_DSN_FRONTEND`。没有 DSN， `sentry-init.js` 就永远不会被导入，SDK 也不会进入 bundle。若有它，则 chunk 会在挂载后加载——如果预初始化错误触发，则会立即加载。一个小缓冲区会捕获初始化前的未捕获错误和拒绝，并在之后刷新它们。
* **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>auth-hint 如何选择启动路径</summary>

`utils/auth-hint.js` 存储一个标志，用于描述上一次会话是否已登录。启动时 `main.js` 会读取它：

* `'1'` ——加载 Firebase，并在首次渲染前等待认证监听器，因此第一次已认证请求就已经携带令牌。
* `'0'` ——立即挂载；直到点击登录之前，SDK 都不会加载。
* `null` （自该标志发布后的首次访问，或已清空存储）——立即挂载，然后在 3 秒后于后台探测认证，以便下一次启动走完全相同的路径。

</details>

应用代码绝不应导入 `@sentry/vue` ——直接导入；静态导入会把 SDK 又拖回主 bundle。显式信号则改走事件总线； `sentry-init.js` 订阅 `ip-source:exhausted` （整个来源链都失败的 IP 卡片），方式与成就引擎订阅自己的事件相同。配置位于 [错误监控](/developer/zh/configuration/error-monitoring.md).

## 辅助函数放置位置

| 需要                | 放在                             |
| ----------------- | ------------------------------ |
| Vue 响应式或生命周期      | `composables/` 作为 `useXxx`     |
| 不含 Vue 特定内容       | `utils/` (从不 `use-` 前缀)        |
| shadcn 支持（`cn()`) | `lib/`                         |
| 后端也需要的内容          | `common/`，通过桥接在其中重新导出 `utils/` |

这些文件的编写约定见 [编码规范](/developer/zh/development/coding-conventions.md)；测试范围见 [测试](/developer/zh/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/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.
