# LicenseManager Platform - 前端开发指南

| 项目 | 内容 |
| --- | --- |
| 文档版本 | v1.0 |
| 文档类型 | 前端开发指南 (Frontend Development Guide) |
| 适用范围 | LicenseManager 管理控制台前端 (`web/admin/`) |
| 技术栈 | Vue 3 + TypeScript + Element Plus + Pinia + Vue Router + Vite |
| 关联文档 | [编码规范 v1.0](./LicenseManager-Platform-Coding-Standards-v1.0.md) · [API 设计规范 v1.0](./LicenseManager-Platform-API-Spec-v1.0.md) · [开发环境搭建 v1.0](./LicenseManager-Platform-Dev-Setup-Guide-v1.0.md) |
| 文档日期 | 2026-07-25 |

---

## 0. 修订历史

| 版本 | 日期 | 修订内容 |
| --- | --- | --- |
| v1.0 | 2026-07-25 | 初稿 |
| v1.1 | 2026-09-24 | 复核同步：§11.2 views 页面清单补 `UserProfile.vue`（个人资料页，随 `/v1/auth/me` 功能上线），总数 70 → 71 |
| v1.2 | 2026-10-08 | 商业化裁剪：移除/标注资金流相关内容（目录树与 store 说明中的订单/支付项、§6.5 OrderPayDialog、§3.3 admin-only 商业路由、§7.3 商业命名空间、§11.3 API 模块数与示例），移交外部销售系统 |

---

## 1. 概述

### 1.1 文档目的

本指南面向 LicenseManager 管理控制台前端开发者，描述项目的技术栈选型、目录结构、路由与状态管理约定、API 客户端封装机制、页面开发规范、国际化与主题切换实现，以及构建部署流程。旨在帮助新成员快速上手，并约束日常开发的代码组织方式。

### 1.2 技术栈

| 依赖 | 版本 | 用途 |
| --- | --- | --- |
| vue | ^3.4.0 | 视图框架（组合式 API + `<script setup>`） |
| typescript | ^5.4.0 | 静态类型系统（`strict: true`） |
| element-plus | ^2.7.0 | UI 组件库 |
| @element-plus/icons-vue | ^2.3.0 | Element Plus 图标 |
| pinia | ^2.1.0 | 状态管理 |
| vue-router | ^4.3.0 | 路由 |
| vite | ^5.2.0 | 构建工具与开发服务器 |
| vue-i18n | ^9.13.0 | 国际化 |
| axios | ^1.7.0 | HTTP 客户端 |
| echarts / vue-echarts | ^5.5.0 / ^7.0.0 | 图表 |
| @playwright/test | ^1.61.1 | 端到端测试（devDependency） |
| vue-tsc | ^2.0.0 | Vue 模板类型检查（devDependency） |

### 1.3 应用入口

应用在 `src/main.ts` 中完成装配，安装顺序为：`createPinia()` → `router` → `ElementPlus` → `i18n`，并全局注册 `VChart` 组件与全部 Element Plus 图标。

```ts
app.use(createPinia())
app.use(router)
app.use(ElementPlus)
app.use(i18n)
app.component('VChart', VChart)
```

ECharts 采用按需引入，在 `main.ts` 中通过 `use([...])` 注册 `CanvasRenderer`、`LineChart`、`BarChart`、`PieChart` 及 `Title/Tooltip/Legend/Grid/DataZoom/MarkLine/MarkPoint` 等组件。

---

## 2. 项目结构

### 2.1 目录树

```
web/admin/
├── package.json
├── tsconfig.json
├── tsconfig.node.json
├── vite.config.ts
├── index.html
└── src/
    ├── main.ts                  # 应用入口
    ├── App.vue                  # 根组件
    ├── env.d.ts                 # Vite 类型声明
    ├── api/                     # API 模块（60+ 文件）
    │   ├── client.ts            # 统一 axios 封装与类型化助手
    │   ├── auth.ts              # 登录接口
    │   ├── product.ts           # 产品 CRUD
    │   ├── license.ts           # 授权 CRUD
    │   └── ...                  # 其余领域模块
    ├── components/              # 通用组件（6 个，节选）
    │   ├── PageHeader.vue       # 页面标题栏
    │   ├── StatCard.vue         # 统计卡片
    │   ├── NotificationBell.vue # 通知铃铛
    │   ├── CommandPalette.vue   # 命令面板（Ctrl+K）
    │   └── EmptyState.vue       # 空状态占位（原 OrderPayDialog 订单支付对话框已随商业化裁剪移除）
    ├── directives/
    │   └── permission.ts        # v-permission 权限点指令
    ├── i18n/
    │   ├── index.ts             # createI18n 与 setLocale
    │   ├── zh-CN.json           # 简体中文文案
    │   └── en-US.json           # 英文文案
    ├── layouts/
    │   └── DefaultLayout.vue    # 侧边栏 + 顶栏 + 主内容区
    ├── router/
    │   └── index.ts             # 路由表与全局守卫
    ├── stores/                  # Pinia store
    │   ├── auth.ts              # 认证（token/email/角色）
    │   ├── theme.ts             # 主题（light/dark/auto）
    │   ├── product.ts           # 产品列表状态
    │   ├── license.ts           # 授权列表状态
    │   └── customer.ts          # 客户列表状态
    ├── styles/
    │   └── main.css             # 全局样式与 CSS 变量
    ├── utils/                   # 纯函数工具（6 个：clipboard / csv / enumLabel / format / onlineStatus / statusTag，节选）
    │   └── format.ts            # formatDate / formatPrice
    └── views/                   # 页面组件（70+ .vue 文件）
        ├── Login.vue
        ├── Dashboard.vue
        ├── ProductList.vue
        ├── ProductForm.vue
        ├── CustomerList.vue
        ├── LicenseList.vue
        ├── LicenseDetail.vue
        ├── LicenseWizard.vue
        └── ...
```

### 2.2 各目录职责

| 目录 | 职责 |
| --- | --- |
| `api/` | 按领域拆分的 API 调用模块，每个文件导出资源接口类型与 CRUD 函数，统一复用 `client.ts` 的类型化助手 |
| `components/` | 跨页面复用的展示型组件，不直接持有业务状态 |
| `i18n/` | vue-i18n 实例与中英文文案 JSON，键命名空间与菜单/页面一一对应 |
| `layouts/` | 布局壳组件，承载侧边栏菜单、顶栏工具区、主题/语言切换、通知与命令面板 |
| `router/` | 路由表定义（全部懒加载）与全局前置守卫 |
| `stores/` | Pinia store，认证/主题为全局 store，product/license/customer 为列表页 store（原 order 等商业 store 已随商业化裁剪移除） |
| `styles/` | 全局基础样式，依赖 Element Plus CSS 变量（`--el-*`）实现主题适配 |
| `utils/` | 纯函数工具，如时间格式化、金额格式化 |
| `views/` | 路由对应的页面组件，每个文件对应一个命名路由 |

### 2.3 路径别名

`tsconfig.json` 与 `vite.config.ts` 均配置 `@/*` 指向 `src/*`：

```ts
// tsconfig.json
"paths": { "@/*": ["src/*"] }

// vite.config.ts
resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } }
```

所有业务代码统一使用 `@/` 前缀引用模块，禁止使用相对路径跨级引用。

---

## 3. 路由设计

### 3.1 路由配置

路由定义于 `src/router/index.ts`，使用 `createWebHistory()` 模式。根路由 `/` 重定向至 `/dashboard`，其下挂载 `DefaultLayout.vue` 作为布局壳，所有业务页面作为其 `children`。

```ts
const routes: RouteRecordRaw[] = [
  {
    path: '/login',
    name: 'Login',
    component: () => import('@/views/Login.vue'),
    meta: { public: true },
  },
  {
    path: '/',
    component: () => import('@/layouts/DefaultLayout.vue'),
    redirect: '/dashboard',
    children: [
      { path: 'dashboard', name: 'Dashboard', component: () => import('@/views/Dashboard.vue'), meta: { title: 'Dashboard' } },
      { path: 'products', name: 'ProductList', component: () => import('@/views/ProductList.vue'), meta: { title: 'Products' } },
      // ... 其余 60+ 子路由
    ],
  },
  { path: '/:pathMatch(.*)*', redirect: '/dashboard' },
]
```

未匹配的路径统一兜底重定向到 `/dashboard`。

### 3.2 懒加载策略

所有路由组件均采用动态 `import()` 语法懒加载，Vite 会自动按路由拆分 chunk，构建产物中每个页面为独立 chunk：

```ts
component: () => import('@/views/ProductList.vue')
```

`vite.config.ts` 中将 `build.chunkSizeWarningLimit` 调整为 `1000`（kB），以容纳体积较大的页面 chunk 而不产生告警。

### 3.3 路由守卫

全局 `beforeEach` 守卫负责认证与基于角色的访问控制（RBAC）。角色通过 `RouteMeta.roles?: AppRole[]` 数组声明：

```ts
export type AppRole = 'admin' | 'operator' | 'support' | 'customer'

declare module 'vue-router' {
  interface RouteMeta {
    public?: boolean
    title?: string
    /** 允许访问的角色列表；未设置表示所有认证用户可访问 */
    roles?: AppRole[]
  }
}

router.beforeEach((to) => {
  const auth = useAuthStore()
  // 1. 非公开路由且未认证 → 跳登录
  if (!to.meta.public && !auth.isAuthenticated) {
    return { name: 'Login' }
  }
  // 2. 已认证访问登录页 → 跳仪表盘
  if (to.name === 'Login' && auth.isAuthenticated) {
    return { name: 'Dashboard' }
  }
  // 3. meta.roles 角色检查 → 不含任一允许角色则拒绝并提示
  if (to.meta.roles && to.meta.roles.length > 0 && !auth.hasAnyRole(to.meta.roles)) {
    // 使用全局 i18n 实例，避免 useI18n() 在 setup 外调用抛错导致重定向失败
    ElMessage.error(i18n.global.t('common.forbidden'))
    return { name: 'Dashboard' }
  }
})
```

| 守卫逻辑 | 触发条件 | 行为 |
| --- | --- | --- |
| 401 拦截 | `!to.meta.public && !auth.isAuthenticated` | 重定向 `Login` |
| 登录页已登录拦截 | `to.name === 'Login' && auth.isAuthenticated` | 重定向 `Dashboard` |
| 角色检查 | `to.meta.roles` 非空且 `!auth.hasAnyRole(to.meta.roles)` | `ElMessage.error(i18n.global.t('common.forbidden'))` 并重定向 `Dashboard` |

角色约定（`AppRole`）：`admin`（系统管理员，完全访问）、`operator`（运营，业务+商业化+财务+通信，无系统管理）、`support`（技术支持，客户+许可证+工单+知识库）、`customer`（终端客户，仅仪表盘+工单+知识库）。`auth.hasAnyRole` 对 `admin` 角色始终放行（超级角色直通）。

`meta.roles: ['admin']` 的 admin-only 路由包括：`users`、`roles`、`api-keys`、`tenants`、`system-keys`、`configs`、`deployment-license`、`sms-config`、`ai-config`，以及 `imports`、`sso-providers`、`integrations`、`system-status`、`telemetry` 等（原 `currencies`、`tax-rules`、`invoice-templates`、`psp-configs`、`billing-platforms`、`psp-migrations`、`payment-security`、`data-residency` 等商业路由已随商业化裁剪移除）。其余业务路由多配置为 `['admin', 'operator', 'support']`；`dashboard` 未设置 `roles`（所有认证用户可访问）。

### 3.4 命名路由约定

- 每个路由必须配置 `name`，使用大驼峰命名（如 `ProductList`、`LicenseDetail`、`LicenseWizard`）。
- 列表页命名为 `XxxList`，详情页命名为 `XxxDetail`，向导/表单页命名为 `XxxWizard` / `XxxForm`。
- 代码内跳转统一使用 `router.push({ name: 'Xxx' })` 或 `router.push({ name: 'Xxx', params: { id } })`，避免硬编码路径字符串。
- 详情/编辑类路由使用路径参数，如 `licenses/:id`（原 `orders/:id` 已随商业化裁剪移除）。

---

## 4. 状态管理

### 4.1 Pinia store 设计

Store 定义于 `src/stores/`，统一采用组合式（Setup Store）写法：

```ts
export const useXxxStore = defineStore('xxx', () => {
  const state = ref(...)
  const derived = computed(...)
  function action() { ... }
  return { state, derived, action }
})
```

| store | 文件 | 职责 |
| --- | --- | --- |
| `auth` | `stores/auth.ts` | token、email、JWT 解析、登录/登出、角色判断 |
| `theme` | `stores/theme.ts` | 主题模式（light/dark/auto）与系统主题监听 |
| `product` | `stores/product.ts` | 产品列表 `items/total/loading` 与 CRUD 动作 |
| `license` | `stores/license.ts` | 授权列表状态 |
| `customer` | `stores/customer.ts` | 客户列表状态 |

### 4.2 auth store

`src/stores/auth.ts` 是认证核心，承担以下职责：

| 成员 | 类型 | 说明 |
| --- | --- | --- |
| `token` | `ref<string>` | JWT，初值取自 `sessionStorage.admin_token` |
| `email` | `ref<string>` | 登录邮箱，初值取自 `sessionStorage.admin_email` |
| `permissions` | `ref<string[]>` | 权限点数组，初值取自 `sessionStorage.admin_permissions` |
| `userRole` | `computed<string\|null>` | 从 JWT payload 解析 `role` claim，支持 string 或 string[] |
| `tokenExpiresAt` | `computed<number\|null>` | 从 JWT payload 解析 `exp`（Unix 秒） |
| `isAuthenticated` | `computed<boolean>` | `token && email` 非空且（无 exp 或 exp 未过期） |
| `hasRole(role)` | `function` | `userRole === role` |
| `hasAnyRole(roles)` | `function` | 当前角色在角色列表中即 `true`；`admin` 角色始终返回 `true`（直通），用于路由守卫 `meta.roles` 判断 |
| `hasPermission(permission)` | `function` | 检查单个权限点；`admin` 直通；`permissions` 为空时返回 `true`（向后兼容旧 token / 未启用权限点的环境） |
| `hasAnyPermission(perms)` | `function` | 权限点列表任一命中即 `true`；空数组视为无要求返回 `true`；`admin` 直通 |
| `login(payload)` | `async function` | 调用 `apiLogin`，成功后写入 token/email/permissions 与 sessionStorage（permissions 优先从 JWT payload 的 `permissions` claim 解析，兼容登录响应 `user.permissions`） |
| `logout()` | `function` | 清空内存与 sessionStorage 中的 token/email/permissions |

**JWT 解析**：`parseJwtPayload` 函数不校验签名，仅 split 三段并 base64 解码 payload，用于读取 `role`、`exp` 与 `permissions` claim。

**sessionStorage 持久化**：选用 `sessionStorage` 而非 `localStorage`，使 token 在浏览器标签关闭后即失效，降低 XSS 持久化风险。Key 分别为 `admin_token`、`admin_email` 与 `admin_permissions`。

**登录流程**：

```ts
async function login(payload: LoginPayload) {
  const res = await apiLogin(payload)        // POST /v1/auth/login
  if (!res.token) throw new Error('Login failed: no token returned')
  token.value = res.token
  sessionStorage.setItem('admin_token', res.token)
  email.value = payload.email
  sessionStorage.setItem('admin_email', payload.email)
  // 优先从 JWT payload 解析 permissions claim；若登录响应直接包含 permissions 也兼容
  const payloadClaims = parseJwtPayload(res.token)
  permissions.value = normalizePermissions(payloadClaims?.permissions ?? res.user?.permissions ?? [])
  sessionStorage.setItem('admin_permissions', JSON.stringify(permissions.value))
  return res
}
```

### 4.3 主题 store

`src/stores/theme.ts` 提供三态主题切换：

| 成员 | 说明 |
| --- | --- |
| `mode` | `'light' \| 'dark' \| 'auto'`，初值取自 `localStorage.admin_theme`，默认 `light` |
| `systemDark` | 当前系统是否偏好暗色（监听 `prefers-color-scheme`） |
| `isDark` | `computed`：`auto` 模式跟随系统，否则按 mode |
| `setMode(next)` | 切换并写入 `localStorage` |
| `toggle()` | 按 `light → dark → auto → light` 循环 |

主题应用通过在 `<html>` 上增删 `.dark` class 实现，`watch(isDark, applyDark, { immediate: true })` 确保初始化即生效。Element Plus 暗色样式由 `element-plus/theme-chalk/dark/css-vars.css` 提供（在 `main.ts` 中导入）。

### 4.4 列表页 store 模式

以 `stores/product.ts` 为例，列表页 store 统一暴露 `items / total / loading` 与 `fetchAll / create / update / remove` 方法，封装对应 `api/*.ts` 调用：

```ts
export const useProductStore = defineStore('product', () => {
  const items = ref<Product[]>([])
  const total = ref(0)
  const loading = ref(false)

  async function fetchAll(params?: ListParams) {
    loading.value = true
    try {
      const res = await listProducts(params)
      items.value = res.items || []
      total.value = res.total ?? items.value.length
    } finally {
      loading.value = false
    }
  }
  async function create(payload: ProductPayload) { return createProduct(payload) }
  async function update(id: string, payload: Partial<ProductPayload>) { return updateProduct(id, payload) }
  async function remove(id: string) { return deleteProduct(id) }

  return { items, total, loading, fetchAll, create, update, remove }
})
```

页面组件通过 `useProductStore()` 获取状态与动作，避免在组件内重复维护分页与 loading 状态。

---

## 5. API 客户端封装

### 5.1 client.ts 统一封装

`src/api/client.ts` 创建唯一 axios 实例并配置双拦截器。

**实例配置**：

```ts
const client = axios.create({
  baseURL: '',                    // 走 Vite 代理，无需配置 baseURL
  timeout: 30000,
  headers: { 'Content-Type': 'application/json' },
})
```

`baseURL` 留空，所有请求以 `/v1/...` 开头，由 Vite dev server 代理转发到后端（见 §10.3）。

**键名双向转换**：后端 REST API 统一使用 camelCase，前端视图与 API 模块统一使用 snake_case，`client.ts` 集中做双向转换：

- 请求方向：body / query params 键名 snake_case → camelCase（保证模型绑定）；`FormData` / `Blob` / `ArrayBuffer` 跳过。
- 响应方向：payload 键名 camelCase → snake_case（保证视图字段可读）。
- 例外：`PRESERVE_SUBTREE_KEYS`（`metadata`、`feature_flags` / `featureFlags`、`variables`、`credentials`）下的子树为用户自定义键值对，不做转换。

**空值裁剪**：请求前 `pruneEmpty` 递归过滤值为 `undefined` / `null` / 空字符串的字段，避免空字符串触发后端 Guid/DateTime 解析失败。

**请求拦截器**：从 `sessionStorage.admin_token` 读取 token，注入 `Authorization: Bearer <token>` 头；随后对 body 与 params 执行 `pruneEmpty` + 键名转换。

**响应拦截器**统一处理业务约定响应体 `{ success, message, data, error }`：

| 场景 | 处理 |
| --- | --- |
| `body.success === false` | `ElMessage.error(message \|\| error)`，reject |
| `body.success === true` | 返回 `body.data`（无 data 时返回整个 body） |
| 非 `success` 字段响应体 | 原样返回 `body` |
| 响应 payload 为 JSON 数组 | 自动包装为 `{ items, total }`，与 `PageResult` 分页契约一致 |
| HTTP 429 | 按 `Retry-After` 头自动重试（缺省按 2s/4s/8s 指数退避），最多重试 3 次 |
| HTTP 422 | 静默 reject，交由表单组件内联展示校验错误 |
| HTTP 401 | 动态 import `stores/auth` 与 `router`，调用 `auth.logout()` 并跳转 `/login`，**不弹冗余错误** |
| HTTP 403 | `ElMessage.error(t('common.forbidden'))`（i18n 文案） |
| 其他网络/HTTP 错误 | 提取 `response.data.message/error`（含 RFC 7807 风格 `errors` 字段），`ElMessage.error(msg)` |

**循环依赖规避**：401 处理中通过 `Promise.all([import('@/stores/auth'), import('@/router')])` 动态导入，打破 `client.ts ← stores/auth ← api/auth ← client.ts` 的循环。

### 5.2 API 模块组织

`src/api/` 下每个领域对应一个 `.ts` 文件（共 60+ 模块），统一模式：

1. 从 `./client` 导入类型化助手与 `ListParams / PageResult` 类型。
2. 声明资源接口（如 `Product`）与载荷接口（如 `ProductPayload`）。
3. 导出 `listXxx / getXxx / createXxx / updateXxx / deleteXxx` 异步函数，URL 形如 `/v1/admin/products` 与 `/v1/admin/products/${id}`。

以 `src/api/product.ts` 为例：

```ts
import { get, post, patch, del, type ListParams, type PageResult } from './client'

export interface Product { id: string; name: string; sku?: string; ... }
export interface ProductPayload { name: string; sku?: string; ... }

export async function listProducts(params?: ListParams): Promise<PageResult<Product>> {
  return get<PageResult<Product>>('/v1/admin/products', { params })
}
export async function getProduct(id: string): Promise<Product> {
  return get<Product>(`/v1/admin/products/${id}`)
}
export async function createProduct(payload: ProductPayload): Promise<Product> {
  return post<Product>('/v1/admin/products', payload)
}
export async function updateProduct(id: string, payload: Partial<ProductPayload>): Promise<Product> {
  return patch<Product>(`/v1/admin/products/${id}`, payload)
}
export async function deleteProduct(id: string): Promise<void> {
  return del<void>(`/v1/admin/products/${id}`)
}
```

新增领域模块时复制此结构，文件名使用小驼峰单数形式（如 `entitlementSet.ts`、`webhookDelivery.ts`）。

### 5.3 类型化助手重载

`client.ts` 导出五个 HTTP 助手：`get / post / patch / put / del`。每个助手提供类型重载，支持显式传入响应类型 `T` 与请求体类型 `B`：

```ts
export function get<T = any>(url: string, config?: AxiosRequestConfig): Promise<T>
export function post<T = any, B = unknown>(
  url: string,
  data?: B,
  config?: AxiosRequestConfig
): Promise<T>
export function patch<T = any, B = unknown>(url: string, data?: B, config?: AxiosRequestConfig): Promise<T>
export function put<T = any, B = unknown>(url: string, data?: B, config?: AxiosRequestConfig): Promise<T>
export function del<T = any>(url: string, config?: AxiosRequestConfig): Promise<T>
```

设计要点：

- `T` 默认 `any`，向后兼容既有 60+ 未类型化模块；新代码应显式传入响应类型。
- `B` 默认 `unknown`，调用方可选传请求体类型以收紧载荷校验，例如 `post<MyResponse, MyBody>(url, body)`。
- 实现签名保持宽松（`as unknown as Promise<T>`），让历史未类型化调用仍能通过编译。
- `data?: B` 为可选参数，便于无载荷的 POST/PATCH 调用。

### 5.4 401 自动登出

401 拦截逻辑收敛在响应拦截器中，避免每个 API 模块重复处理：

```ts
if (status === 401) {
  Promise.all([
    import('@/stores/auth'),
    import('@/router'),
  ]).then(([{ useAuthStore }, { default: router }]) => {
    const auth = useAuthStore()
    auth.logout()                                       // 清空 token/email
    if (router.currentRoute.value.path !== '/login') {
      router.push('/login')                             // 仅在非登录页时跳转
    }
  })
  return Promise.reject(error)
}
```

由于 `auth.logout()` 会清空 `sessionStorage`，下一次请求拦截器不会再注入 `Authorization` 头，形成闭环。

### 5.5 公共类型

`client.ts` 导出三个公共类型供所有 API 模块复用：

| 类型 | 字段 |
| --- | --- |
| `ApiResponse<T>` | `success: boolean`、`message?`、`data?: T`、`error?` |
| `PageResult<T>` | `items: T[]`、`total?`、`page?`、`page_size?` |
| `ListParams` | `page?`、`page_size?`、`limit?`、`offset?`、`search?`、`status?`、`resource_type?`、`start_time?`、`end_time?` |

三类接口均带 `[key: string]: any` 索引签名，便于扩展自定义查询参数。

---

## 6. 页面开发规范

### 6.1 新页面模板

新增列表页应遵循 `ProductList.vue` 的结构：`PageHeader` + `toolbar` + `el-table` + `el-pagination` + `el-dialog`（编辑/详情）。骨架如下：

```vue
<template>
  <div class="page-container">
    <PageHeader :title="t('xxx.title')" :description="t('xxx.description')">
      <template #actions>
        <el-button type="primary" :icon="Plus" @click="openCreate">{{ t('xxx.new') }}</el-button>
      </template>
    </PageHeader>

    <div class="toolbar">
      <el-input v-model="search" :placeholder="t('xxx.searchPlaceholder')" clearable
        :prefix-icon="Search" @keyup.enter="loadData" @clear="loadData" />
      <el-button :icon="Search" @click="loadData">{{ t('common.search') }}</el-button>
    </div>

    <el-table v-loading="store.loading" :data="store.items" border stripe>
      <!-- 列定义 -->
      <el-table-column :label="t('common.actions')" width="180" fixed="right">
        <template #default="{ row }">
          <el-button link type="primary" :icon="View" @click="openDetail(row)">{{ t('common.view') }}</el-button>
          <el-button link type="primary" :icon="Edit" @click="openEdit(row)">{{ t('common.edit') }}</el-button>
          <el-button link type="danger" :icon="Delete" @click="handleDelete(row)">{{ t('common.delete') }}</el-button>
        </template>
      </el-table-column>
    </el-table>

    <div class="pagination-bar">
      <el-pagination
        v-model:current-page="page"
        v-model:page-size="pageSize"
        :total="store.total"
        :page-sizes="[10, 20, 50]"
        layout="total, sizes, prev, pager, next"
        @current-change="loadData"
        @size-change="loadData"
      />
    </div>

    <el-dialog v-model="dialogVisible" :title="editing ? t('xxx.edit') : t('xxx.new')" width="640px">
      <XxxForm ref="formRef" :item="editing" />
      <template #footer>
        <el-button @click="dialogVisible = false">{{ t('common.cancel') }}</el-button>
        <el-button type="primary" :loading="saving" @click="handleSave">{{ t('common.save') }}</el-button>
      </template>
    </el-dialog>
  </div>
</template>
```

### 6.2 组合式 API

所有 `.vue` 文件统一使用 `<script setup lang="ts">`，禁止使用 Options API。典型页面脚本结构：

```ts
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Search, View, Edit, Delete } from '@element-plus/icons-vue'
import PageHeader from '@/components/PageHeader.vue'
import XxxForm from './XxxForm.vue'
import { useXxxStore } from '@/stores/xxx'
import type { Xxx } from '@/api/xxx'
import { formatDate, formatPrice } from '@/utils/format'

const { t } = useI18n()
const store = useXxxStore()
const search = ref('')
const page = ref(1)
const pageSize = ref(20)
const dialogVisible = ref(false)
const editing = ref<Xxx | null>(null)
const saving = ref(false)

async function loadData() {
  await store.fetchAll({ page: page.value, page_size: pageSize.value, search: search.value })
}
async function handleSave() {
  const valid = await formRef.value?.validate()
  if (!valid) return
  const data = formRef.value!.getData()
  saving.value = true
  try {
    if (editing.value?.id) await store.update(editing.value.id, data)
    else await store.create(data)
    dialogVisible.value = false
    await loadData()
  } finally {
    saving.value = false
  }
}
async function handleDelete(row: Xxx) {
  await ElMessageBox.confirm(t('xxx.confirmDelete', { name: row.name }), t('common.confirm'), { type: 'warning' })
  await store.remove(row.id)
  ElMessage.success(t('xxx.deletedSuccess'))
  await loadData()
}
onMounted(loadData)
</script>
```

约定：

- 列表分页状态（`page / pageSize / search`）保留在组件本地，不进入 store；store 仅持有 `items / total / loading`。
- 表单组件通过 `ref` 暴露 `validate()` 与 `getData()` 方法，父组件在保存前调用。
- 删除操作统一走 `ElMessageBox.confirm` 二次确认。
- 成功/失败提示优先由 `client.ts` 拦截器统一弹窗；仅业务成功提示（如 `createdSuccess`）在组件内显式调用 `ElMessage.success`。

### 6.3 Element Plus 组件使用

| 场景 | 组件 |
| --- | --- |
| 页面标题 | `PageHeader`（封装 `<h2>` + description + actions 插槽） |
| 列表 | `el-table` + `el-table-column`，`border stripe`，操作列 `fixed="right"` |
| 分页 | `el-pagination`，`layout="total, sizes, prev, pager, next"`，`page-sizes=[10,20,50]` |
| 弹窗表单 | `el-dialog` + 自定义表单组件，footer 插槽放取消/保存按钮 |
| 详情展示 | `el-descriptions` + `el-descriptions-item`，`:column="1" border` |
| 状态标签 | `el-tag`，`:type="row.is_active === false ? 'info' : 'success'"` |
| 操作按钮 | `el-button link`，搭配 `View/Edit/Delete` 图标 |
| 表单校验 | `el-form` + `:rules`，`FormInstance.validate()` |
| 消息提示 | `ElMessage.success/error`、`ElMessageBox.confirm` |
| 加载 | `v-loading="store.loading"` |

Element Plus 图标在 `main.ts` 中已全局注册，模板中可直接使用 `<Plus />`、`<Search />` 等；如需作为 prop 传递（如 `:icon="Plus"`），仍需从 `@element-plus/icons-vue` 导入。

### 6.4 表单验证

登录页 `Login.vue` 示范了表单验证模式：

```ts
const rules: FormRules = {
  email: [
    { required: true, message: () => t('login.pleaseEnterEmail'), trigger: 'blur' },
    { type: 'email', message: () => t('login.invalidEmail'), trigger: 'blur' },
  ],
  password: [{ required: true, message: () => t('login.pleaseEnterPassword'), trigger: 'blur' }],
}

async function handleLogin() {
  const valid = await formRef.value?.validate().catch(() => false)
  if (!valid) return
  // ...
}
```

约定：

- `message` 使用函数形式 `() => t('xxx')`，确保切换语言后校验提示同步更新。
- 提交前调用 `formRef.value?.validate()`，失败时静默返回，不弹全局错误。
- 后端 422 校验错误由 `client.ts` 拦截器透传，表单组件应捕获并映射到对应 `form-item` 的 `error` 状态。

### 6.5 订单支付弹窗（OrderPayDialog）

> **本节所述组件已随商业化裁剪移除**：`OrderPayDialog.vue`、`OrderList.vue` 与 `src/api/payment.ts`（支付意图流转）均已删除，订单与支付流程移交外部销售系统。原实现要点不再适用，小节号保留。

---

## 7. i18n 国际化

### 7.1 t() 调用规范

**禁止在模板与脚本中硬编码中文或英文字符串**，所有面向用户的文案必须通过 `t('key')` 调用。每个组件脚本起始处解构：

```ts
const { t } = useI18n()
```

模板中直接使用 `{{ t('products.title') }}`、`:placeholder="t('products.searchPlaceholder')"`。

### 7.2 键一致要求

- `zh-CN.json` 与 `en-US.json` 必须保持键完全一致，新增键时同步提交两个文件。
- 缺失键会导致 vue-i18n 回退到 `fallbackLocale: 'en-US'`，若英文也缺失则渲染键本身。
- 两个文件均采用 UTF-8 无 BOM 编码，2 空格缩进。

### 7.3 命名空间组织

JSON 顶层按业务域划分命名空间：

| 命名空间 | 用途 |
| --- | --- |
| `common` | 通用动作词（search/cancel/save/delete/...）、状态（active/inactive）、字段名（name/email/id/created/...） |
| `menu` | 侧边栏菜单项与分组标题 |
| `theme` | 主题切换文案（light/dark/auto/toggle） |
| `language` | 语言切换标签 |
| `login` | 登录页文案 |
| `commandPalette` | 命令面板占位符等 |
| `products` | 产品域 |
| `licenses` | 授权域 |
| `customers` | 客户域 |
| ~~`orders`~~ | ~~订单域~~（已随商业化裁剪移除） |
| `subscriptions` | 订阅域（订阅续期语义保留） |
| ~~`payments`~~ | ~~支付域~~（已随商业化裁剪移除） |
| `...` | 其余业务域 |

新增页面时，应在其所属命名空间下追加 `title / description / new / edit / searchPlaceholder / createdSuccess / updatedSuccess / deletedSuccess / confirmDelete` 等标准键。

### 7.4 语言切换实现

`src/i18n/index.ts` 创建 i18n 实例：

```ts
const i18n = createI18n({
  legacy: false,              // 必须为 false 以使用组合式 API
  globalInjection: true,      // 模板中可直接用 $t
  locale: getDefaultLocale(),
  fallbackLocale: 'en-US',
  messages: { 'zh-CN': zhCN, 'en-US': enUS },
})
```

| 函数 | 作用 |
| --- | --- |
| `getDefaultLocale()` | 优先读 `localStorage.admin_locale`，否则按 `navigator.language` 判断中英文 |
| `persistLocale(locale)` | 写入 `localStorage.admin_locale` |
| `setLocale(locale)` | 更新 `i18n.global.locale.value`、持久化、设置 `<html lang>` |

`DefaultLayout.vue` 顶栏语言下拉调用 `setLocale(cmd)` 完成切换。支持的语言枚举为：

```ts
export const SUPPORTED_LOCALES = [
  { value: 'zh-CN', label: '简体中文' },
  { value: 'en-US', label: 'English' },
]
```

初始化时同步设置 `<html lang>` 属性以利 SEO 与无障碍。

---

## 8. 主题切换

### 8.1 明暗主题

主题三态：`light` / `dark` / `auto`。

- `light`：固定浅色。
- `dark`：固定深色，在 `<html>` 添加 `.dark` class。
- `auto`：跟随系统 `prefers-color-scheme`，通过 `matchMedia` 监听变化实时切换。

`DefaultLayout.vue` 顶栏下拉提供三选项，图标随当前模式切换为 `Sunny / Moon / Monitor`。

### 8.2 CSS 变量

项目完全依赖 Element Plus 的 CSS 变量体系（`--el-bg-color`、`--el-text-color-primary`、`--el-border-color-light`、`--el-fill-color-light`、`--el-bg-color-page`、`--el-color-primary` 等）。`src/styles/main.css` 仅定义少量全局基础样式与布局类（`.page-container / .toolbar / .pagination-bar / .chart-container`），所有颜色均通过 `var(--el-*)` 引用，禁止硬编码色值。

暗色模式由 `element-plus/theme-chalk/dark/css-vars.css`（在 `main.ts` 导入）覆盖 `--el-*` 变量，配合 `<html class="dark">` 生效。组件内 `<style scoped>` 同样应使用 `var(--el-*)`，确保主题切换时自动适配。

---

## 9. 菜单结构

### 9.1 侧边栏分组

`DefaultLayout.vue` 侧边栏使用 `el-menu` + `el-sub-menu`，按业务域划分为 **8 个分组**，另含若干顶级菜单项：

| 分组 | i18n 键 | 子项 |
| --- | --- | --- |
| Products（产品） | `menu.productGroup` | products、features、license-templates、entitlement-sets、custom-fields、releases、plans |
| Licenses（授权） | `menu.licenseGroup` | licenses、devices、named-users、license-users、offline-activation、floating-pool、consumption、token-pools、batch-operations |
| Compliance（合规） | `menu.complianceGroup` | legal-documents、eula-use-types、dsr |
| System（系统） | `menu.systemGroup` | users、roles、api-keys、pats、login-security、tenants、system-keys、sso-providers、integrations、imports、system-status、telemetry |
| Communications（通信） | `menu.commsGroup` | webhooks、webhook-deliveries、notification-templates、webhook-events、incidents |
| Support（支持） | `menu.supportGroup` | tickets、knowledge-base、support-tiers |

顶级独立菜单项：`dashboard`、`customers`、`audit`、`reports`、`import-export`。

### 9.2 菜单 i18n 键

每个菜单项的标题通过 `t('menu.xxx')` 渲染，分组标题通过 `t('menu.xxxGroup')` 渲染。`zh-CN.json` 与 `en-US.json` 的 `menu` 命名空间必须覆盖上述全部键。

### 9.3 菜单状态

- `:default-active="activeMenu"`：根据当前路由计算高亮项，子路由（如 `/licenses/:id`）会手动映射到父菜单 `/licenses`。
- `router` 属性启用：点击菜单项按 `index` 跳转对应路径。
- `collapsed` 状态由顶栏折叠按钮控制，侧栏宽度在 `220px` 与 `64px` 间切换。

---

## 10. 构建与部署

### 10.1 开发服务器

```bash
npm run dev
```

启动 Vite dev server，默认端口 `5173`，监听所有请求中以 `/v1` 开头的路径并代理到 `http://localhost:8080`（后端 API）。`changeOrigin: true` 确保 Host 头重写为后端地址。

### 10.2 生产构建

```bash
npm run build
```

执行 `vue-tsc -b && vite build`：先用 `vue-tsc` 做模板类型检查与项目引用构建，再由 Vite 打包。产物输出到 `dist/`，`chunkSizeWarningLimit` 提升至 `1000` kB 以适应大型页面 chunk。

其他脚本：

| 脚本 | 作用 |
| --- | --- |
| `npm run preview` | 本地预览 `dist/` 产物 |
| `npm run test` | 运行 Playwright 端到端测试 |
| `npm run test:headed` | 有头模式运行测试 |
| `npm run test:ui` | Playwright UI 模式 |
| `npm run test:report` | 查看上次测试报告 |

### 10.3 Vite 代理配置

`vite.config.ts`：

```ts
server: {
  port: 5173,
  proxy: {
    '/v1': {
      target: 'http://localhost:8080',
      changeOrigin: true,
    },
  },
},
```

由于 `client.ts` 中 `baseURL` 为空，前端所有 API 请求都以 `/v1/...` 开头，开发期由 Vite 代理转发；生产环境则由反向代理（如 Nginx）统一将 `/v1` 路由到后端服务。

### 10.4 TypeScript 配置要点

`tsconfig.json` 关键项：

| 选项 | 值 | 说明 |
| --- | --- | --- |
| `target` | `ES2020` | 编译目标 |
| `module` | `ESNext` | 模块系统 |
| `moduleResolution` | `bundler` | 适配 Vite 的解析策略 |
| `strict` | `true` | 启用全部严格类型检查 |
| `noFallthroughCasesInSwitch` | `true` | switch 必须有 break/return |
| `resolveJsonModule` | `true` | 允许直接 import JSON（i18n 文案） |
| `isolatedModules` | `true` | 每个文件独立编译，禁止跨文件类型推断 |
| `noEmit` | `true` | 仅类型检查，产物由 Vite 输出 |
| `paths` | `@/* → src/*` | 路径别名 |

项目引用通过 `references: [{ "path": "./tsconfig.node.json" }]` 拆分 Node 端配置（如 `vite.config.ts`）。

---

## 11. 附录

### 11.1 常用命令速查表

| 命令 | 作用 | 执行位置 |
| --- | --- | --- |
| `npm install` | 安装依赖 | `web/admin/` |
| `npm run dev` | 启动开发服务器（:5173） | `web/admin/` |
| `npm run build` | 类型检查 + 生产构建 | `web/admin/` |
| `npm run preview` | 预览生产产物 | `web/admin/` |
| `npm run test` | 运行 Playwright 测试 | `web/admin/` |
| `npm run test:headed` | 有头模式测试 | `web/admin/` |
| `npm run test:ui` | Playwright UI 模式 | `web/admin/` |
| `npm run test:report` | 查看测试报告 | `web/admin/` |

### 11.2 页面清单（views/ 目录）

下表列出 `src/views/` 下全部 `.vue` 文件（共 71 个），按业务域分组：

| 业务域 | 页面文件 |
| --- | --- |
| 通用 | `Login.vue`、`Dashboard.vue`、`AuditLog.vue`、`Reports.vue`、`ImportExport.vue`、`UserProfile.vue` |
| 产品 | `ProductList.vue`、`ProductForm.vue`、`FeatureCatalog.vue`、`LicenseTemplate.vue`、`EntitlementSetList.vue`、`CustomFieldList.vue`、`ReleaseManagement.vue`、`PlanList.vue` |
| 客户 | `CustomerList.vue`、`CustomerForm.vue` |
| 授权 | `LicenseList.vue`、`LicenseDetail.vue`、`LicenseWizard.vue`、`DeviceList.vue`、`NamedUserList.vue`、`LicenseUserList.vue`、`OfflineActivation.vue`、`FloatingPool.vue`、`ConsumptionReport.vue`、`TokenPoolList.vue`、`BatchOperations.vue` |
| 合规 | `LegalDocuments.vue`、`EulaUseTypes.vue`、`DsrList.vue` |
| 系统 | `UserList.vue`、`RoleList.vue`、`ApiKeyList.vue`、`PersonalAccessTokens.vue`、`LoginSecurity.vue`、`TenantList.vue`、`SystemKeys.vue`、`SsoProviderList.vue`、`IntegrationList.vue`、`ImportList.vue`、`SystemStatus.vue`、`TelemetryConfig.vue` |
| 通信 | `WebhookEndpoints.vue`、`WebhookDeliveries.vue`、`NotificationTemplates.vue`、`WebhookEventCatalog.vue`、`IncidentList.vue` |
| 支持 | `TicketList.vue`、`KnowledgeBase.vue`、`SupportTiers.vue` |

### 11.3 API 模块清单（api/ 目录）

`src/api/` 共 43 个模块文件（不含 `client.ts`），与上述页面域一一对应。命名规则：小驼峰单数，如 `product.ts`、`licenseTemplate.ts`、`webhookDelivery.ts`、`entitlementSet.ts`（原 `order.ts`、`payment.ts`、`pspConfig.ts`、`billingPlatform.ts` 等商业模块已随商业化裁剪移除）。每个模块导出资源接口、载荷接口与 `list/get/create/update/delete` 五个函数。

### 11.4 关键文件索引

| 关注点 | 文件路径 |
| --- | --- |
| 应用入口 | `src/main.ts` |
| 根组件 | `src/App.vue` |
| 路由表与守卫 | `src/router/index.ts` |
| 认证 store | `src/stores/auth.ts` |
| 主题 store | `src/stores/theme.ts` |
| API 客户端 | `src/api/client.ts` |
| i18n 实例 | `src/i18n/index.ts` |
| 中文文案 | `src/i18n/zh-CN.json` |
| 英文文案 | `src/i18n/en-US.json` |
| 布局壳 | `src/layouts/DefaultLayout.vue` |
| 全局样式 | `src/styles/main.css` |
| Vite 配置 | `vite.config.ts` |
| TS 配置 | `tsconfig.json` |
