feat: opsx-loki 全面升级 — 前端重构/用户与LDAP/部署向导/日志查询

基于 WEB-UI-Development-Guide 标准的整体升级,并更名 LokiDeploy → opsx-loki。

前端(Gin+React+shadcn/ui):
- 按 02/07 章重构:页面域化拆分(无超 400 行文件)、PageContainer/ListQuery、
  反馈分级(成功静默/失败 toast/危险确认)、401 单飞刷新+队列重放
- 暗色模式(next-themes)+ 语义 Token(--primary 品牌色/success/warning/info)
- 部署向导(状态推导四步:环境→存储→Loki→采集,内嵌表单闭环)
- 日志查询页:LogQL 标签构建器/拖拽直方图/日志上下文/Live Tail/查询历史
- 按用户+标签策略的查询授权(服务端强制合并策略)

后端(Go+Gin+JSON DB):
- 简单用户管理(仅 admin,无 RBAC)+ TokenVersion 会话吊销 + 登录 IP 限流
- LDAP 接入:配置(AES 加密存储/hasPassword 脱敏)/测试连接/批量导入/登录
- 存储配置测试连接(SigV4 零依赖)+ 桶不存在自动创建
- Loki 三模式部署修复:SSH 密钥认证、3.x 配置模板、ruler 对象存储嵌套块、
  凭证解密、孤儿 deploying 状态恢复、Grafana 部署(可选默认关)+面板导入
- DaemonSet 采集纯 YAML(去 helm 依赖)+ DNS 自愈回退 ClusterIP + 集群名标签
- 日志查询代理:cluster-svc 经 kube-apiserver proxy/url 直连 + cursor 翻页
- 统一 log 命名空间;部署就绪探活;Grafana 访问地址自动记录

修复:run.sh 增量编译检测、Sanitized 脱敏补全、LokiDeployment Update 白名单、
健康检查 CreatedAt 零值等 10+ 存量 bug。

数据兼容:DB 沿用旧 lokideploy.json(新名优先);localStorage 一次性迁移;
HKDF 加密前缀保留(已加密凭证不受影响)。
This commit is contained in:
logan committed 2026-10-24 13:20:12 +08:00
1 parent 327e282356
commit f9c6c1d826
204 files changed
+14107 -9103

No files matched your search

+169
View File
@@ -0,0 +1,169 @@
# 打造高质量 Web 管理端前端 — 开发标准
> 基于 vue-vben-admin(v5,★★★★★ Vue 标准标杆)、ant-design-pro(v6,★★★★★ React 标准标杆)、
> soybean-admin(v2.3,★★★★☆ 强烈推荐)、vue-pure-admin(v7,★★★★☆ 推荐)四大纯前端标杆提炼,
> gin-vue-admin(★★☆☆☆ 仅作全栈功能对照)。
>
> 生成时间:2026-10-22 | 修订:2026-10-22 定位为通用开发标准(去项目化)
---
## 📖 文档定位(先读这一段)
本文件是一份**前端开发标准**(Standard),不是教程:
1. **用途**:放入新项目仓库(如 `docs/`,并被 AGENTS.md / CLAUDE.md 引用),供 **AI 编程助手与开发者**阅读——AI 在编码、Review、重构时以此为基准**对比并优化**目标代码;与本文冲突的既有代码即为待改进项。
2. **范围**:Vue 3 与 React 的管理端 / 中后台 Web 前端(新项目优先);后端为 Gin / FastAPI 等任意 REST 服务均可——本标准只约定前端结构与 HTTP 契约,不绑定后端框架。
3. **效力**:条款分「必须 / 禁止」「推荐 / 避免」「可选」三级(见规范用语约定)。
4. **证据**:所有规范条目均标注出处(五项目的具体文件路径),不是凭空偏好。
5. **用法**:每章末尾有「AI 检查清单」——对比目标工程时逐项核对,不合格项即为优化任务。
---
## 📚 文档目录
| 序号 | 文件名 | 主题 | 核心内容 |
| -- | ----------------- | ---- | -------------------------------------------- |
| 01 | `01-框架选型与技术栈.md` | 框架选型 | 默认技术栈、UI 库对比、TS 采用策略、何时允许偏离 |
| 02 | `02-项目架构与目录结构.md` | 项目架构 | 按功能域组织、页面 co-location、可裁剪分层、命名规范 |
| 03 | `03-请求层与数据流.md` | 通信架构 | 统一响应契约、拦截器链、refresh 单飞队列、401 三范式、状态分层 |
| 04 | `04-路由与权限控制.md` | 路由权限 | meta 规范、动态路由三模式、守卫链、RBAC 三粒度、菜单生成 |
| 05 | `05-布局系统与导航设计.md` | 布局导航 | 布局分层、侧栏/标签页/面包屑、Keep-Alive 链路、页面模板 |
| 06 | `06-主题系统与视觉设计.md` | 主题视觉 | 设计 Token、暗色模式、CSS 变量换肤、品牌定制、死 CSS 治理 |
| 07 | `07-组件设计与交互反馈.md` | 组件交互 | 组件分层与晋升、命令式弹层、表单/表格范式、反馈分级、巨型文件拆分 |
| 08 | `08-工程化与质量保障.md` | 工程实践 | Vite 配置、env 分层、lint 流水线、测试金字塔、性能、双端一致性、CI/CD |
---
## 🏆 核心结论速览
### 默认技术栈(新项目从这里开始)
| 端 | 默认栈 | 参照 |
| --------- | ----------------------------------------------------------------------------------------------------- | ---------------------------------------------- |
| **Vue 3** | Vue 3 + Vite + TypeScript(允许渐进)+ Element Plus + Pinia + Vue Router | 同栈实操对照 vue-pure-admin;架构对照 vben;规范写法对照 soybean |
| **React** | React + Vite + TypeScript strict + antd(重 CRUD 加 ProComponents)或 shadcn/ui + Zustand + TanStack Query | 工程与组件对照 ant-design-pro |
包管理器一律 **pnpm**(`preinstall: only-allow pnpm`)。偏离默认栈的条件与程序见 01 章 1.4。
### 单栈优先与双栈迁移策略
```
新项目 ──▶ 必须单栈(Vue 或 React 二选一)
迁移期(存量重构)──▶ 允许双栈并存,但必须遵守三条纪律:
1. 功能以主端为准,副端只做「对齐」同步,不抢先实现新功能
2. 契约先统一命名:token key / 主题 key / 权限码 / 响应格式,两端同名
(推荐命名空间:<app>.auth.token / <app>.theme.mode)
3. 不共享代码,只共享一份契约文档——不为双栈引入 monorepo
```
(monorepo 的适用性判断见 01 章 1.3;双端一致性细则见 08 章第五节)
### 参考项目定位(五项目三级)
**首选参考 ★★★★★ —— Vue 看 vben,React 看 adp**
| 项目 | 技术栈 | 定位 | 主要借鉴点 |
| ------------------ | -------------------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------ |
| **vue-vben-admin** | Vue 3 + TS monorepo(pnpm + Turbo) | **Vue 标准标杆** | RequestClient + 预设拦截器、refresh 单飞队列、HSL Token + Tailwind 桥接、Preferences 偏好系统、三层布局架构、Vite 配置工厂、VITE_GLOB 运行时配置 |
| **ant-design-pro** | Umi Max + React 19 + antd v6 + ProComponents | **React 标准标杆** | 响应契约与错误分级(ErrorShowType)、access 权限纯函数、页面 co-location 五件套、React Query 数据流、Biome + commitlint 工程底线、测试范式 |
**强烈推荐 / 推荐 ★★★★☆ —— Vue 侧第二、第三参考**
| 项目 | 技术栈 | 定位 | 主要借鉴点 |
| ------------------ | ----------------------------------------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| **soybean-admin** | Vue 3.5 + Naive UI + UnoCSS + Vite(轻 workspace) | 强烈推荐:**代码规范与架构清晰** | @elegant-router 文件路由(目录即路由、类型即生成物)、请求五钩子契约 + flat 返回、store「index + shared」纯函数分离、hasAuth 极简权限、主题三端同步、最严格的 Lint/Commit 体系 |
| **vue-pure-admin** | Vue 3.5 + Element Plus + Tailwind v4(单包) | 推荐:**干净轻量,有官方精简版 thin** | 页面内聚五件套(index + form + utils/hook)、PureHttp 196 行请求类、ReDialog 命令式弹窗、EP↔Tailwind 变量桥接、两级按钮权限(v-auth/v-perms)、中文注释密度极高 |
**仅作对照 ★★☆☆☆ —— 功能参考,不作前端标准**
| 项目 | 技术栈 | 定位 | 可借鉴的模式 |
| ----------------- | ------------------------------------------- | ----------------- | ------------------------------------------------------------------------------- |
| **gin-vue-admin** | Vue 3 + Element Plus + Pinia + Vite(Gin 全栈) | Gin 全栈闭环 / 后端对接示例 | 后端菜单→`import.meta.glob` 动态路由、`defineOptions name` 与 keep-alive 链路、loading 防抖计数器 |
> gva 的价值在于「Gin 后端 + Vue 前端」同仓配套的全栈闭环,适合做整体功能对照;
> 其前端工程化、代码规范整体弱于上面 4 个纯前端项目(拦截器 `return error` 未 reject、EP 全量引入、无测试)。
> **前端实现分歧时以 vben(架构)/ soybean(规范)/ pure(同栈 EP 实操)为准,gva 只当功能实现方式对照,前端写法不照搬。**
### 按维度查哪个项目
| 维度 | 优先看 | 说明 |
| ---------------- | --------------------- | ----------------------------------------------------------------------- |
| 目录结构与分层 | vben > soybean > pure | vben monorepo 分层最值得学;soybean 文件路由清晰;pure 传统但干净 |
| 请求封装 | vben / soybean / pure | 错误处理、Token 刷新、类型定义三家都成熟,见 03 章对照 |
| 权限系统 | vben > soybean > pure | vben 动态路由 + 细粒度最完整;soybean 静态/动态一键切换;pure 两级按钮权限 |
| Lint / Commit 规范 | vben / soybean | 配置最严格完整,直接抄它们的配置文件 |
| 组件二次封装 | vben / pure / adp | vben schema 驱动且多 UI;pure hook + 命令式弹窗实用;adp ProTable/ProForm 是 React 标杆 |
| TypeScript 类型组织 | vben / soybean / adp | 类型集中、接口返回类型完整;soybean 的全局 namespace + 键级类型最易迁移到 JS 工程 |
### 管理端前端十大黄金法则
```
┌────────────────────────────────────────────────────────────────┐
│ 01. 一切 IO 走统一请求层 —— 组件不直接碰 axios/fetch │
│ 02. 响应契约全局唯一 —— code/data/msg + 错误分级 │
│ 03. 401 只有一个入口 —— 单飞刷新 + 队列重放,不竞态 │
│ 04. meta 驱动一切 UI 行为 —— 菜单/缓存/权限/标题单源维护 │
│ 05. 权限三态 —— 不可见 / 可见但 403 / 放行 │
│ 06. 页面私有代码不出页面目录 —— 复用第三次才晋升公共组件 │
│ 07. 反馈分级、少弹框 —— 成功静默、失败必现、危险确认 │
│ 08. 换肤只改 CSS 变量 —— 不重编译、不引第二套样式系统 │
│ 09. 列表/表单/详情有模板 —— 新页面从模板起步,不自由发挥 │
│ 10. 双栈契约优先 —— 同产品双端实现时 key、权限码、 │
│ 响应格式两端同名 │
└────────────────────────────────────────────────────────────────┘
```
### 质量红线(违反即技术债)
- 单文件组件 ≤ **400 行**(视图)/ ≤ **300 行**(工具、请求层),超限必须拆分
- 样式必须有唯一入口引用链,**无入口的 CSS 一律删除**(存量工程常见 2000+ 行死 CSS 的考古层)
- 生产构建**零 console.log**,尤其禁止打印 token 片段
- 组件不绕过 API 层直接调用请求实例
- 文档与实现同 PR 更新,禁止「描述已删除组件」的漂移文档
### 规范用语约定(必须遵守 vs 推荐)
| 标记 | 含义 | 违反后果 |
| ----------- | ------------------------------------ | --------------------- |
| **必须 / 禁止** | 强制规范(MUST / MUST NOT) | Code review 拒绝,视同 bug |
| **推荐 / 避免** | 默认采纳(SHOULD / SHOULD NOT),允许有充分理由的例外 | 例外需在 PR 描述中说明原因 |
| **可选** | 按需采用(MAY) | 无 |
正文中加粗的「必须 / 禁止」条款为强制项;各章「AI 检查清单」是带优先级(P0/P1/P2)的对比执行队列。
### 文档维护与更新规则
1. 实现与文档不一致 = 文档 bug,同一 PR 修复(详见 08 章文档治理)
2. 参考项目大版本升级(vben / soybean / pure / adp)后,复核对应章节引用的证据文件路径
3. 新增规范先以「推荐」落地,被 ≥2 个模块实际验证后再升级为「必须」
4. 修订历史以 git commit 为准
---
## 🎯 适用场景
- 🏗️ **可裁剪底座 / 脚手架** — 壳 / 系统 / 示例三层,fork 即用
- 🖥️ **管理后台 / 中台** — 用户、RBAC、审计、系统设置等系统级页面
- 📊 **运维监控平台** — 仪表盘、指标图表、实时日志(WebSocket/SSE)
- ⏰ **任务调度控制台** — 定时任务 CRUD、执行历史、手动触发
- 🔄 **双栈迁移工程** — Vue→React 或反向迁移期间的对照参照
---
## 📐 与兄弟指南的关系
| 指南 | 领域 | 分界 |
| ----------------------- | -------------------- | ---------------------- |
| `GUI-Development-Guide` | Electron/Tauri 桌面端 | 窗口、标题栏、IPC、自动更新 |
| `CLI-Development-Guide` | Go 命令行 / TUI / Agent | Cobra、Bubble Tea、审批流 |
| **本指南** | **浏览器 Web 管理端** | **请求层、路由权限、布局主题、组件工程** |
三份指南共享同一套写作范式:每个专题按「原理 → 标杆代码 → 最佳实践 → AI 检查清单」四层展开。
---
> 💡 **阅读建议**:开发者先通读本文件,写新页面前读 05(页面模板)和 07(组件规范);搭新模块前读 02(架构)和 04(路由权限);排查线上问题读 03(请求层)和 08(工程化)。
> **AI 助手**:编码前读 02/04/05/07;Review 与重构时按各章末「AI 检查清单」逐项对比目标代码,不合格项即输出为优化建议。
> 查阅标杆实现时:**Vue 架构问题对照 vben、规范写法对照 soybean、同栈(Element Plus)实操对照 pure、React 问题对照 adp,gva 仅作功能对照。**
@@ -0,0 +1,217 @@
# 01 — 框架选型与技术栈
> 参考:vue-vben-admin(★★★★★ Vue 标准标杆)、ant-design-pro(★★★★★ React 标准标杆)、soybean-admin(★★★★☆ 强烈推荐)、vue-pure-admin(★★★★☆ 推荐);对照:gin-vue-admin(★★☆☆☆ Gin 全栈)
---
## 一、选型的第一性问题:单栈还是双栈
### 1.1 标杆项目的单栈现实
| 项目 | 框架 | 为什么不双栈 |
|------|------|--------------|
| ant-design-pro | React 19 | 生态深度绑定 Umi Max 运行时(约定式路由、运行时配置、插件体系) |
| gin-vue-admin | Vue 3 | Element Plus + 国内管理端生态,Go 后端配套脚手架默认 Vue(★★☆ 仅对照) |
| vue-vben-admin | Vue 3 | 用 monorepo + adapter 层支持 5 个 UI 库,但**框架始终只有 Vue** |
| soybean-admin | Vue 3 | 文件路由 + 生成物策略,规范优先(★★★★☆ 强烈推荐) |
| vue-pure-admin | Vue 3 | 单包直出、轻量实用,官方精简版 thin(★★★★☆ 推荐) |
> **结论**:标杆项目全部单栈。多 UI 库共存靠的是「适配器层」(vben 的 `effects/*` adapter),而不是多框架。**框架级双栈的成本(请求层、状态、权限、构建、测试全部 ×2)只有迁移期才值得付。**
>
> 参考优先级:**Vue 架构看 vben、规范写法看 soybean、同栈 EP 实操看 pure、React 看 adp**;gva 仅作 Gin 全栈视角补充,不作前端代码质量的首选参考。
### 1.2 双栈只在迁移期
```
┌─────────────────────────────────────┐
│ 后端 REST API(Gin / FastAPI ...) │
│ 单一契约:/api/* │
└───────────┬─────────────┬───────────┘
│ │
┌───────────▼────┐ ┌─────▼──────────────┐
│ 主端(功能源) │ │ 副端(迁移参照) │
│ 承接全部新功能 │ │ 只对齐、不抢先 │
│ 进入产物 │ │ 迁移完成即退役 │
└────────────────┘ └────────────────────┘
```
迁移期三条纪律(**必须**):
1. **功能以主端为准**:副端只做「对齐」同步,不抢先实现。
2. **契约以两端共同名为准**:token key、主题 key、模块开关键、权限码、响应格式——迁移开始前就统一命名(推荐命名空间 `<app>.auth.token` / `<app>.theme.mode`),而不是迁移后再改。
3. **不共享代码,只共享约定**:两端各自实现同一份「契约文档」(见 08 章),不引入 monorepo 这种为双栈兜底的重型方案。
### 1.3 何时值得上 monorepo(vben 模式)
vben 用 pnpm workspace + Turbo 支撑 5 个 UI 库 app + 30+ 框架包,代价是:
- `pnpm-workspace.yaml` 维护 300+ 依赖 catalog,`overrides` 强制统一版本
- 循环依赖检查(`check:circular`)、依赖合规检查(`check:dep`)成为必跑任务
- 构建配置收敛为内部包 `internal/vite-config`,app 配置才能薄到 15 行
**判断标准**:当「框架层代码」(布局引擎、请求客户端、权限系统)需要被 ≥3 个应用复用时,才值得付 monorepo 成本。普通单产品工程(1-2 个前端)——**不值得**,独立 Vite 工程 + 统一 Makefile 编排即可。soybean 的「轻 workspace」(单应用 + 8 个工具包)是中间形态,适合工具链需要沉淀的团队。
### 1.4 何时允许偏离默认栈
**必须**:新项目 / 新模块使用本章默认栈(见 00 章「默认技术栈」);存量代码**不因「不合推荐」而大规模重写**——渐进迁移:新文件执行新标准,旧文件随功能迭代就近升级。
**允许偏离**的场景(需在 PR / ADR 中说明理由):组件库确缺关键能力(虚拟表格、复杂甘特等);性能热点需要更轻方案;与后端生态强绑定(如脚手架生成的默认产物)。
**禁止**:同一工程内并存两套 UI 库、两套状态库、两种 CSS 方案——偏离只允许发生在「整工程」粒度。
---
## 二、UI 组件库选型
### 2.1 三种流派对照
| 维度 | Element Plus(本标准 Vue 主推;gva 补充) | Ant Design + ProComponents(adp) | shadcn/ui(React 品牌差异化) |
|------|--------------------------|-----------------------------------|-----------------------------|
| 形态 | 传统全量组件库 | 组件库 + 中后台增强层 | 源码复制型(Radix + Tailwind) |
| 中后台生产力 | 高(表格/表单/弹层齐全) | **极高**(ProTable/ProForm 声明式 CRUD) | 中(原子件全,业务件自建) |
| 主题定制 | CSS 变量 + SCSS 双通道 | Design Token(configProvider) | **完全可控**(Token 就是你的 CSS 变量) |
| 暗色模式 | `dark/css-vars.css` 一行接入 | `data-theme="dark"` + token | `.dark` class 天然支持 |
| TS 支持 | 一般(类型宽松) | 好 | 极好(TSX 源码进仓库) |
| 体积 | 全量较大,靠 unplugin 按需 | 较大 | 用多少复制多少 |
| 适合谁 | Vue 主栈、快速交付 | React 中后台重 CRUD | React、要品牌差异化 |
### 2.2 选型决策树
```
需要管理端全家桶、快速交付?
├─ Vue 栈 ──────────▶ Element Plus(+ unplugin 按需引入)
├─ React 栈、CRUD 为主 ▶ Ant Design + ProComponents
└─ React 栈、要品牌感 ─▶ shadcn/ui + Tailwind
双栈迁移期?
└─▶ 主端选全量组件库保效率(EP),
副端选 shadcn 保可读性 —— 常见组合
```
两个 ★★★★☆ 参考的选型启示:soybean-admin(Naive UI + UnoCSS)证明「hook 优先 + 组件薄壳」也能撑起完整中后台;vue-pure-admin 证明了 **Tailwind v4 `@theme` 把 `--el-*` 变量桥接为原子色 token** 是 EP 工程引入原子类的最短路径(详见 06 章)。
### 2.3 图标体系(易被忽视的选型)
- **glob 注册模式(gva,补充)**:`import.meta.glob` 自动收集 `assets/icons/**/*.svg` 注册为全局组件,菜单存 icon 字符串即可 `<component :is>` 渲染;插件图标加 `插件名-` 前缀防冲突。适合「菜单图标由后端配置下发」的场景。
- **路由声明模式(adp)**:路由 `icon: 'crown'` 字符串 + 图标映射表,与路由集中声明天然契合。
- **按需编译模式(pure/soybean)**:unplugin-icons 虚拟模块(`import Upload from '~icons/ri/upload-line'`)+ Iconify 离线/在线双模式。
- **通用规则**:**图标名是数据(存在菜单表里),渲染是前端映射**——不要把图标组件硬编码进菜单模板。
---
## 三、构建工具与运行时
### 3.1 Vite 是唯一现实选择(2026)
| 能力 | 说明 | 标杆用法 |
|------|------|----------|
| 按需引入 | `unplugin-auto-import` + `unplugin-vue-components` | EP 组件零 import(vben/soybean 系均如此) |
| `import.meta.glob` | 动态路由的组件映射基石 | gva/vben/pure 的 backend 菜单模式 |
| env 分层 | `.env.development` / `.env.production` + `import.meta.env` | 全部项目;vben 另分 `VITE_GLOB_*` 运行时可改(见 08 章) |
| proxy | dev 反代 `/api` 到后端端口,避免 CORS 配置漂移 | `'/api' → 后端端口`(含 `ws: true` 供 WebSocket) |
| 构建指纹 | `define` 注入版本/commit hash | adp:`__APP_VERSION__` + git rev-parse 兜底 |
**必须**按需引入,**禁止**全量 `app.use(组件库)` + 全量 CSS(gva 全量引入是反面教材,首屏体积大)。
### 3.2 路由库
- Vue:`vue-router@4`,`createWebHistory`(hash 模式是历史包袱,新工程用 history)
- React:`react-router@7`,用**路由配置数组 + `<Route>` 声明式**或 `createBrowserRouter`;**禁止**用 `Object.entries().map` + 三元链选页面渲染
### 3.3 HTTP 客户端
| 库 | 使用方 | 要点 |
|----|--------|------|
| axios | vben / soybean / pure / gva | 拦截器、取消(AbortController)生态成熟 |
| umi-request / axios | adp | 错误契约挂载在 request config 上(skipErrorHandler 等) |
| fetch | — | 零依赖,但拦截器/超时/取消需自建,**不推荐**作为管理端基础 |
> **必须**统一 axios:拦截器结构、错误对象形态在全员脑中保持一致,排障不用切换心智模型。
---
## 四、TypeScript 采用策略
### 4.1 三档策略
| 档位 | 适用 | 做法 | 标杆 |
|------|------|------|------|
| **纯 JS + JSDoc** | 快速交付的存量 Vue 主端 | 关键模块(request/permission/stores)写 JSDoc 类型注释 | gva |
| **渐进 TS** | 存量 JS 工程演进 | 新文件 `.ts`,老文件不动;`allowJs: true`;先建 `typings/` 写 `.d.ts` | soybean 路线 |
| **TS strict** | 新工程(**默认**) | `strict: true`,类型即文档 | adp、vben、soybean、pure |
**新项目必须 TS strict**;存量工程按上表渐进,但接口函数**不允许**「既无类型注释也无类型定义」。
### 4.2 类型放哪里(adp/soybean 范式)
- 全局契约类型集中声明:`declare namespace API { interface RuleListItem {...} }`,服务层函数签名 `request<API.RuleList>(...)` 收口
- 页面私有类型随页面 co-location(`data.d.ts` / `utils/types.ts`)
- 路由 meta 用 `vue-router.d.ts` 模块扩充强类型化(vben 30+ 字段全 JSDoc;soybean 17 字段同款)
- soybean 补充:全局类型按 namespace 组织(`typings/` 下 Api/App/Storage/Env 四区,storage 与 env 均有**键级类型**,i18n key 递归派生)——**JS 工程渐进 TS 的最低成本路径**:先建 `typings/` 写 `.d.ts`,不动业务代码
---
## 五、状态管理选型
### 5.1 先分类再选库
```
状态三类:
├─ 客户端全局状态(token、用户信息、菜单、UI 偏好)→ Pinia / Zustand
├─ 服务端状态(列表、详情、缓存失效) → TanStack Query / ProTable request / 自研 usePagedQuery
└─ URL 状态(筛选、分页、Tab) → 路由 query,永远不要复制进 store
```
### 5.2 三种落法
| 落法 | 出处 | 说明 |
|------|------|------|
| adp 三层 | initialState(useModel)+ React Query + ProTable request | 服务端状态交给 Query:`queryKey` 按资源命名,mutation 成功后 `invalidateQueries` |
| vben 分域 store | Pinia setup 风格,按域拆 modules(gva 同构,补充参考) | token/user/router/app/dictionary 各一,持久化手动分层 |
| pure StoreHook | `useXxxStoreHook()` 包装 store + pinia 实例 | 在 setup 外(拦截器、守卫、工具函数)也能安全取 store |
| soybean 模块化 | 每模块一目录 `index.ts`(状态编排)+ `shared.ts`(可单测纯函数) | 纯逻辑与响应式状态强制分离 |
**经验值**:列表页优先「组件内管理 + 请求取消」(AbortController + 版本号方案,见 03 章),只有跨页面共享的数据才进 store。**不要为了「规范」把每个列表都塞进全局 store。**
---
## 六、技术栈对照总表
| 维度 | ant-design-pro | vue-vben-admin | soybean-admin | vue-pure-admin | gin-vue-admin | 本标准推荐值 |
| ---- | -------------- | -------------- | ------------- | -------------- | ------------- | ------------------ |
| 框架 | React 19 | Vue 3 | Vue 3.5 | Vue 3.5 | Vue 3 | Vue 3 或 React(单栈) |
| 语言 | TS strict | TS strict | TS strict(namespace 组织) | TS(strict 关闭) | JS | 新项目 TS strict;存量渐进 |
| UI 库 | antd v6 + Pro | antd-vue/EP/naive 适配 | Naive UI + UnoCSS | Element Plus + TW v4 | Element Plus | Vue: EP;React: antd 或 shadcn |
| 构建 | Umi Max(utoopack) | Vite + Turbo monorepo | Vite(轻 workspace) | Vite 8/rolldown 单包 | Vite | Vite + pnpm |
| 状态 | useModel + RQ | Pinia + 持久化插件 | Pinia(index+shared) | Pinia(StoreHook 模式) | Pinia 分域 | Pinia(Vue)/ Zustand+RQ(React) |
| 请求 | umi-request + 错误契约 | RequestClient 类 | 五钩子工厂 + flat 返回 | PureHttp 类(196 行) | axios 单例 | axios 封装(见 03 章) |
| 样式 | Tailwind v4 + antd-style | HSL Token + TW v4 | ThemeSetting 三端同步 | EP↔TW v4 变量桥接 | SCSS + UnoCSS + EP 变量 | 语义 Token 单源(见 06 章) |
| 规范 | Biome + commitlint | oxlint + eslint + lefthook | soybeanjs/eslint-config + sa CLI | eslint + stylelint + husky | ESLint flat + Prettier | ESLint flat + Prettier + commitlint |
| 测试 | Vitest + Playwright | Vitest(内核组件全覆盖) | —(未见体系化) | —(未见体系化) | 无(短板) | Vitest + Playwright(见 08 章) |
---
## 七、AI 检查清单(对比目标工程逐项核对)
**P0(结构性的,立即改)**
1. 工程是否单栈?若双栈并存,是否有明确的「主端/副端 + 退役计划」与统一契约命名?
2. 是否存在同工程两套 UI 库 / 两套状态库 / 两种 CSS 方案?
3. 组件库是否按需引入(unplugin 或手动)?全量 `app.use()` 即不合格
4. 包管理器是否 pnpm?`preinstall: only-allow pnpm` 是否配置?
**P1(质量项,排期改)**
5. 新文件是否 TS / 存量是否补 JSDoc?接口函数是否存在「无类型也无注释」
6. 路由渲染是否声明式(禁止 entries+三元链)?
7. HTTP 客户端是否统一 axios?
8. 状态三分法是否被遵守(URL 状态不进 store、列表不进全局 store)?
**P2(演进项)**
9. 是否需要 `typings/` 目录承接全局契约类型(soybean 四区范式)?
10. 构建指纹(版本/commit 注入)是否配置?
---
> 下一篇:[02-项目架构与目录结构](02-项目架构与目录结构.md) —— 目录是架构的最后陈述。
@@ -0,0 +1,231 @@
# 02 — 项目架构与目录结构
> 参考:vue-vben-admin(三层包划分)、soybean-admin(文件路由 + store 纯函数分离)、ant-design-pro(页面 co-location)、vue-pure-admin(页面内聚五件套);补充:gin-vue-admin(分域 api/view)
---
## 一、组织目录的三种流派
### 1.1 流派对照
| 流派 | 代表 | 结构 | 优点 | 代价 |
|------|------|------|------|------|
| **按层分**(layer-first) | 早期中台 | `components/ views/ api/ stores/` 每层内平铺全部业务 | 上手简单 | 业务散在 5 个目录,删模块要全局找 |
| **按域分**(domain-first) | **本标准(Vue 单包)/ gva** | 层目录保留,但 `api/user/`、`views/user/`、`stores/` 域内聚合 | 删域=删目录,心智清晰 | 需要约定维持层内域目录一致 |
| **按功能分包**(feature-first) | adp 页面级、vben 包级 | 页面目录自带 service/mock/类型/样式;框架能力独立成包 | 高内聚,可整体搬移 | 需要晋升机制防止复制泛滥 |
**结论:中小工程用「按域分 + 页面 co-location」混合式**——全局按域(api/views 域目录对齐),页面私有代码留在页面目录内。
### 1.2 四标杆的目录精髓
```
vue-vben-admin(依赖方向三层,分层标杆)
apps/* ──▶ packages/effects/* ──▶ packages/@core/*
(app 是唯一允许直接依赖具体 UI 库的层;
@core 零 UI 依赖零业务;effects 通过 adapter 适配 UI 生态)
ant-design-pro(页面五件套 co-location)
src/pages/table-list/
├── index.tsx # 页面(ProTable 声明式列配置)
├── service.ts # 本页接口(手写层)
├── data.d.ts # 本页类型
├── _mock.ts # 本页 mock(config 显式 include)
└── components/
├── CreateForm.tsx # 新建弹窗(trigger 模式)
└── UpdateForm.tsx # 编辑弹窗(StepsForm 进 Modal)
soybean-admin(生成物 + store 纯函数分离,★★★★☆)
src/
├── router/elegant/ # 文件路由生成物(routes/transform/imports + RouteKey 类型 d.ts)
├── service/{api,request}/ # api=纯请求函数、request=实例拦截器,两层分离
├── store/modules/<mod>/ # 每模块 index.ts(状态编排) + shared.ts(可单测纯函数)
├── typings/{api,...} # 全局 namespace 类型(Api/App/Storage/Env)
└── views/_builtin/ # 403/404/500/login 内置页统一收纳,业务页平级
vue-pure-admin(页面内聚五件套,★★★★☆)
src/views/system/user/
├── index.vue # 纯模板(零逻辑,只解构 hook)
├── form/index.vue # 弹窗表单
├── tree.vue # 左树
└── utils/{hook.tsx,rule.ts,types.ts} # 逻辑/校验/类型全在本域
gin-vue-admin(补充:全局分域 + 指令/钩子独立)
src/
├── api/ # 一域一文件:user.js menu.js ...
├── pinia/modules/ # 一域一 store
├── view/superAdmin/ view/example/ # 按业务分组
├── directive/auth.js # v-auth 指令独立目录
├── hooks/responsive.js # 组合式函数独立目录
└── plugin/<name>/ # 业务插件 = 自带 view/api/assets 的小应用
```
---
## 二、标准目录结构(Vue 工程)
### 2.1 目标结构
```
src/
├── main.js # 入口:仅装配(EP按需、pinia、router、指令、全局组件)
├── App.vue
├── api/ # ★ 按功能域分目录,与 views 域目录同名
│ ├── auth/ # captcha.js mfa.js
│ ├── user/ # users.js roles.js permissions.js
│ ├── rbac/ # 域小可单文件,大了再拆
│ ├── system/ # menu.js modules.js logs.js ...
│ └── <domain>/ # 业务域
├── views/ # ★ 域目录与 api/ 一一对应
│ ├── user/ # Users.vue + components/XxxFormDialog.vue ...
│ ├── <domain>/
│ └── _builtin/ # 403/404/500/login 内置页统一收纳(soybean 范式)
├── stores/ # Pinia:一域一文件(auth.js menu.js site.js ...)
├── composables/ # useXxx(依赖业务上下文的钩子)
├── components/ # ★ 全局组件(需晋升,见第四章)
├── layout/ # 布局骨架(业务页只挂内容区,禁止重写布局)
├── router/ # index.js(含守卫) modules/(路由分文件)
├── config/ # theme.js modules.js(纯配置,无副作用导入)
├── typings/ # 全局契约类型(TS 渐进路线,soybean 四区)
├── utils/ # request.js permission.js(无状态工具)
└── styles/ # 全部文件必须有入口引用链
```
React 工程同构映射:`views→pages`、`composables→hooks`、`stores→store`(zustand slices)、`components→components`(含 barrel 出口)。
### 2.2 层内规则(必须)
1. **api 域目录与 views 域目录强制同名**(`api/cron/` ↔ `views/cron/`);**禁止**在 api 根级别放散文件
2. **store 文件名 = 导出名**(`stores/auth.js` → `useAuthStore`);单 store ≤300 行;推荐 soybean 的「index + shared」目录式(状态编排与纯函数分离)
3. **layout/ 是禁改区**:业务页通过页面容器组件的 header/slot 定制,不碰布局骨架(可裁剪底座的前提)
4. **components/ 只放「晋升组件」**(见第四章晋升机制),新页面私有组件一律 `views/<域>/components/`
### 2.3 可裁剪分层与模块开关(底座/脚手架推荐)
```
┌─────────────────────────────────────────────────────┐
│ 壳层 shell 登录/MFA/403/500 —— 不挂主布局 │
├─────────────────────────────────────────────────────┤
│ 系统层 system 用户/RBAC/审计/设置 —— 底座自带 │
├─────────────────────────────────────────────────────┤
│ 示例层 examples 演示模块 —— fork 后可整层删除 │
└─────────────────────────────────────────────────────┘
```
- 路由 `meta.layer: 'shell' | 'system' | 'examples'` 声明归属
- 运行时开关:**源真相在后端**(如 `GET/PUT /api/system/modules`),localStorage 仅作缓存——**禁止**把 localStorage 当源真相
- 构建期裁剪:环境变量(如 `VITE_ENABLE_EXAMPLES=false`)+ 路由中字面量比较,保证打包器 tree-shake 掉示例 chunk
- 示例层代码**不得 import 系统层**内聚单元,保证整层可摘除;示例域附删除指引文档(EXAMPLES.md 惯例)
**分层是目录结构之外的第二根轴**:每个新模块必须先回答「我在哪一层」。
### 2.4 命名规范与类型目录
| 对象 | 规范 | 示例 |
|------|------|------|
| 组件文件 | PascalCase | `UserFormDialog.vue` |
| 工具 / composable | camelCase,composable 带 use 前缀 | `request.js`、`usePagedQuery.js` |
| 目录 | kebab-case(views 域目录用业务域名) | `user-management/`、`cron/` |
| store 文件 | 文件名与导出同名 | `stores/auth.js` → `useAuthStore` |
| API 文件 | 资源名复数 | `api/user/users.js` |
| 常量 | UPPER_SNAKE_CASE | `SYSTEM_ORDER` |
类型目录(TS 渐进路线,soybean 范式):全局契约类型集中 `typings/`(Api / App / Storage / Env 四区 namespace,storage 与 env 均键级类型);页面私有类型随页面(`data.d.ts` / `utils/types.ts`);**禁止**同一类型定义在多个组件内重复出现。
---
## 三、页面 co-location(adp 五件套)
### 3.1 什么时候页面自带 service
```
全局 api/<域>/xxx.js # 多页面复用、域级语义(users.js 的 list/create/delete)
页面内 service.ts # 只服务本页的接口(dashboard 聚合、报表导出)
```
判定:**接口第二个消费者出现时**,从页面 `service.ts` 提升到 `api/<域>/`。反向不成立——不要预防性提升。
### 3.2 页面目录模板
```
views/<domain>/<Page>.vue # 页面骨架:容器 + 列表组件 + 子组件编排(≤400 行)
views/<domain>/components/ # 本页私有组件(表单弹窗、详情抽屉、单元格渲染器)
views/<domain>/composables/ # 本页私有逻辑(可选,超过 2 个页面组件共享时建)
views/<domain>/utils/ # 本页 hook/rule/types(pure 范式,可选)
views/<domain>/EXAMPLES.md # 仅示例层:删除指引
```
---
## 四、公共组件晋升机制
```
第 1 次使用:写在页面 components/ 里
第 2 次使用:复制一份,页面内注明来源 ← 允许的重复
第 3 次使用:晋升 src/components/,删除两份复制 ← 必须执行
```
晋升时同步做三件事:
1. **去业务化**:props 收口(`fetcher`/`permissions` 进来,具体 API 调用出去)
2. **注册出口**:Vue 端全局组件在 `main.js` 注册;React 端进 `components/index.ts` barrel(adp:按「布局组件 / 业务组件」分组导出)
3. **写使用说明**:组件头部注释 3 行(用途/必需 props/已知限制)
必备的两个全局页面组件(详见 05/07 章):**PageContainer**(页面容器:页头/面包屑/slot)与 **ListPage**(分页列表:fetcher + 筛选 slot + 表格透传 + 分页)。
---
## 五、架构守护:让目录约定不被腐蚀
| 守护手段 | 出处 | 可落地做法 |
|----------|------|------------|
| 循环依赖扫描 | vben `check:circular` | `npx madge --circular src/` 进 CI |
| 依赖方向检查 | vben `check:dep`(@core 禁依赖 UI 库) | 示例层禁 import 系统层:ESLint `no-restricted-imports` 按路径分组 |
| 死代码检测 | adp coverage 排除生成物 | CI 跑 `knip`/`depcheck`,styles 无入口文件直接报错 |
| 生成物隔离 | adp `services/ant-design-pro/`(OpenAPI 生成,禁手改) | 生成物独立目录 + lint ignore |
| 路由自动收集 | pure `router/modules/` + `import.meta.glob` | 新增路由文件零注册代码 |
---
## 六、最佳实践清单
✅ **要做**
1. api 域目录与 views 域目录同名;新域从脚手架起步(自动生成五处对照:菜单/路由/页面/API/权限码)
2. 页面私有组件不出页面目录;晋升走「三次法则」
3. 每个新模块先声明 `meta.layer`,示例层保持可整层摘除
4. store 按域拆分、文件名=导出名、单 store ≤300 行
5. 目录结构变化(新增域/晋升组件/删示例)在同一 PR 内更新架构文档
❌ **不要**
1. 不要在 api 根级别放散文件
2. 不要把页面私有组件直接丢进 `src/components/`
3. 不要让业务页 import layout 内部组件(只准用 slot 协议)
4. 不要建「future/」「temp/」「new/」这类无语义目录
5. 不要保留无入口引用的样式文件和空目录
---
## 七、AI 检查清单(对比目标工程逐项核对)
**P0**
1. api 与 views 域目录是否同名对齐?有无 api 根级散文件?
2. store 文件名与导出名是否一致?有无超 300 行的 store?
3. 有无空目录、无引用样式、描述已不存在目录的文档残留?
**P1**
4. 页面骨架是否 ≤400 行?超 400 行的视图列出拆分建议(参照 07 章)
5. 私有组件是否越界进全局 components/?全局组件是否都有注册出口与头注释?
6. 若为可裁剪底座:meta.layer 是否全覆盖?模块开关源真相是否在后端?
7. CI 是否有循环依赖与死代码检查?
**P2**
8. 是否值得引入路由文件自动收集(`router/modules/` + glob)或文件路由(soybean @elegant-router)?
9. `typings/` 全局类型目录是否建立(Api/App/Storage/Env 四区)?
---
> 下一篇:[03-请求层与数据流](03-请求层与数据流.md) —— 前端工程的血管系统。
@@ -0,0 +1,284 @@
# 03 — 请求层与数据流
> 参考:vue-vben-admin(RequestClient + refresh 队列)、soybean-admin(五钩子契约 + flat 返回)、vue-pure-admin(PureHttp 请求类)、ant-design-pro(ErrorShowType 契约);补充:gin-vue-admin(loading 防抖、401 事件总线)
---
## 一、统一响应契约
### 1.1 契约即协议
请求层的第一件事不是封装 axios,而是**和后端签一份响应契约**。两种主流格式:
```js
// 格式 A:Go Gin / 多数国内后端惯例(gva 等)
{ code: 0, data: {...}, msg: 'success' } // code === 0 成功
// 细分扩展:0 成功 / 200 成功 / 2 MFA 待验证 / 3 强制改密
// 格式 B:adp(含错误展示声明)
{ success: true, data: {...},
errorCode?: number, errorMessage?: string, showType?: ErrorShowType }
```
### 1.2 adp 的杀手锏:错误展示分级
```ts
// ant-design-pro: src/requestErrorConfig.ts
enum ErrorShowType {
SILENT = 0, // 静默:调用方自行处理
WARN_MESSAGE = 1, // 轻提示 message.warning
ERROR_MESSAGE = 2, // 错误提示 message.error
NOTIFICATION = 3, // 通知栏 notification
REDIRECT = 9, // 重定向到错误页
}
```
- 后端在响应里**声明这个错误该怎么展示**,前端按枚举分发——错误处理策略从「前端猜」变成「契约说」
- 前端保留逃生口:`request(url, { skipErrorHandler: true })` 单请求关闭全局处理
> **推荐**:在 `{code, data, msg}` 上加可选字段 `show_type`,前端默认 `ERROR_MESSAGE`,登录页/轮询接口传 `SILENT`。改动只在请求层一处。
---
## 二、请求层结构:从单例到客户端类
### 2.1 三档演进
```
档1 单例 + 双拦截器(gva,230 行单文件)
适合:单应用、单一鉴权方式
档2 工厂五钩子(soybean:RequestOption 契约 + createFlatRequest)
适合:错误契约复杂、想要扁平返回与错误码 env 化
档3 客户端类 + 预设拦截器(vben RequestClient / pure PureHttp)
适合:多实例(业务 client / 裸 client)、上传下载 SSE 一等公民
```
### 2.2 vben 的 RequestClient 精髓
```ts
// vben: packages/effects/request/src/request-client/request-client.ts
class RequestClient {
private instance: AxiosInstance
addRequestInterceptor / addResponseInterceptor // 拦截器不写死,外部组装
upload / download / postSSE // 传输形态一等公民
}
// 预设拦截器(preset-interceptors.ts)按需装配:
defaultResponseInterceptor({ codeField, dataField, successCode }) // 解包策略可配置
authenticateResponseInterceptor // 401 + refresh
errorMessageResponseInterceptor // status→文案
```
应用组装处(`apps/web-antd/src/api/request.ts`)导出两个实例:
- `requestClient`:`responseReturn: 'data'`,业务用(自动剥壳)
- `baseRequestClient`:`responseReturn: 'raw'`,刷新 token 等裸接口用(**刷新接口自己不能被自己的拦截器拦**)
### 2.3 soybean 的五钩子契约与 flat 返回(★★★★☆ 工厂化范式)
soybean 把请求协议下沉为 workspace 包 `packages/axios`,业务侧只实现五个钩子:
```ts
interface RequestOption<ResponseData, ApiData> {
transform: (res) => ApiData // 解包
onRequest: (config) => config // 注入 token
isBackendSuccess: (res) => boolean // 业务 code 判定
onBackendFail: (res, instance) => Promise<AxiosResponse | null> // 失败处理(可重发)
onError: (error) => void // 错误提示
}
// createRequest:失败抛异常(传统风格)
// createFlatRequest:返回 { data, error, response },业务层 if (!error) —— Rust 风格扁平返回
```
另两条可迁移细节:**错误码策略 env 化**(`VITE_SERVICE_LOGOUT_CODES` 直接登出 / `MODAL_LOGOUT_CODES` 模态登出 / `EXPIRED_TOKEN_CODES` 刷新重发,三级以配置区分);**错误消息栈去重**(errMsgStack 防并发轰炸)。pure 的 `PureHttp` 类(196 行单文件)则是轻量级范本:qs 序列化数组参数、请求前本地判断 expires **提前刷新**(而非 401 后补救)、per-request 回调可覆盖全局拦截器。
---
## 三、Token 与 401:单飞刷新是铁律
### 3.1 问题:并发 401 的竞态
```
请求A ──401──▶ 刷新token(旧1) ──▶ 重放A ✔
请求B ──401──▶ 刷新token(旧1) ──▶ 后端拒(旧1已消费)──▶ B 被踢出登录 ✘
```
### 3.2 正确姿势:单飞标志 + 等待队列(vben)
```ts
// vben: authenticateResponseInterceptor
if (client.isRefreshing) {
return new Promise((resolve) => client.refreshTokenQueue.push((token) => {
config.headers.Authorization = `Bearer ${token}`
resolve(client.instance.request(config)) // 拿新 token 后重放
}))
}
client.isRefreshing = true
try { const newToken = await doRefresh(...) } finally { client.isRefreshing = false }
config.__isRetryRequest = true // 防无限循环
```
pure 用同构的静态成员实现(`isRefreshing` + `requests[]` 队列,刷新成功 `requests.forEach(cb => cb(token))` 重放);soybean 用共享 Promise 单飞(`state.refreshTokenPromise`,1 秒后置 null)。**三家殊途同归:并发 N 个过期请求只触发一次刷新。**
pure 的补充思路:**提前刷新**——token 三元组(accessToken/expires/refreshToken)存 Cookie,请求前本地判断 `expires - now <= 0` 主动刷新,把过期处理从「响应后补救」变成「请求前预防」(白名单短路 `/refresh-token`、`/login` 防死循环)。
### 3.3 刷新失败后的三种落法
| 落法 | 出处 | 适用 |
|------|------|------|
| 全局错误对话框确认后清 token 跳登录(事件总线单例防重复弹) | gva `emitter.emit('show-error')` | 多请求并发过期时体验最好(只弹一个框) |
| 登录过期 Modal(原页面保留,重登后继续) | vben `loginExpiredMode: 'modal'` | 长表单场景,避免填一半全丢 |
| 防抖单次跳转(最简可靠) | 常见轻实现 | 跳转加防抖标志;错误救援页(500/403)只清不跳,防按钮失效 |
> 共同底线:**并发 N 个 401 只产生一次副作用**(一个弹窗/一次跳转);登录页与轮询接口静默(`SILENT`)。
---
## 四、全局 loading 防抖(补充模式:gva)
```js
// gva: utils/request.js(要点提炼)
let activeAxios = 0 // 并发计数器
let timer = null
const showLoading = (persist) => {
if (timer) clearTimeout(timer)
activeAxios++
if (persist) { ... return } // 长任务:立即显示且不自动关
timer = setTimeout(() => showFullScreenLoading(), 400) // 400ms 防闪烁
}
const closeLoading = () => {
activeAxios--
if (activeAxios <= 0) { clearTimeout(timer); hide(); } // 全部结束才隐藏
}
// 兜底:30s 强制关闭;beforeunload 重置;单请求 donNotShowLoading 关闭
```
三个数字都是经验值:**400ms**(快请求不闪 loading)、**30s**(loading 不可能永远转)、**计数器**(并发请求只显示一个 loading)。若引入全局 loading,按此方案。
---
## 五、取消与防错序
### 5.1 为什么列表页必须取消旧请求
```
用户输入 "a" → 请求1发出
用户补输入 "ab" → 请求2发出
请求2先回 → 渲染 "ab" 结果 ✔
请求1后回 → 覆盖渲染 "a" 结果 ✘ ← 错序覆盖(stale response)
```
### 5.2 双重防护(列表页标准实现)
```js
// composables/usePagedQuery.js —— 取消 + 版本号双保险
let abortController = null
let requestVersion = 0
const fetchList = async () => {
abortController?.abort() // ① AbortController 取消旧请求
const currentVersion = ++requestVersion // ② 版本号令牌
const res = await fetcher(params)
if (currentVersion !== requestVersion) return // 过期响应直接丢弃
tableData.value = res.results
}
```
这是列表页的**强制标准**:所有带筛选/分页的列表页必须走「AbortController + 版本号」双重防护,**禁止**页面手写裸请求循环。
注:vben v5 并未内置请求去重(取消能力委托 axios 原生 config,`axios.isCancel` 静默透传)——**不要假设大而全的框架替你解决了这个问题**。soybean 另有 `cancelAllRequest`(nanoid 请求 ID + AbortControllerMap)作页面级批量取消。
### 5.3 重试与缓存策略
- **重试**:仅幂等 GET 且网络类错误(timeout / network error)自动重试,1-2 次指数退避(soybean 内置 axios-retry;需要时引入 axios-retry,**禁止**手写循环重试)。**禁止**对 POST / PUT / DELETE 自动重试
- **缓存**:字典 / 枚举类走缓存型 store(gva `dictionary.js` 范式:缓存 key 生成 + 参数校验,避免重复请求);列表页交给组件态 + 取消,不做全局缓存;React 端用 React Query 的 `queryKey` 失效模型(adp 范式:mutation 成功 → `invalidateQueries`)
- **判定**:同一数据被两个页面重复请求同一接口 = 该进缓存层,或该提升到全局 store 了
---
## 六、状态分层:什么数据进 store
### 6.1 判定表
| 数据 | 归属 | 标杆范式 |
|------|------|----------|
| token / 用户信息 / 权限码 | 全局 store,**持久化 pick 白名单**(只挑必要字段) | vben `persist.pick`;pure Cookie 三元组 + localStorage 用户信息 |
| 服务端菜单 / 动态路由表 | store(内存),**不落盘**(重登重拉,防止旧菜单闪现) | vben access store;pure 可选缓存需显式配置 |
| 列表数据 / 详情 | **不进全局 store**:组件内 usePagedQuery / React Query | adp:`queryKey:[资源名]`,mutation 成功 `invalidateQueries` |
| 字典 / 短枚举 | 缓存型 store,带缓存 key 去重请求 | gva `dictionary.js`、soybean dict |
| 主题 / UI 偏好 | 独立于业务 store 的偏好系统 | vben PreferenceManager(见 06 章) |
| 筛选 / 分页 / Tab 状态 | **URL query**,永远不复制进 store | 通用 |
### 6.2 登录编排(vben 范式)
```ts
// store/auth.ts —— 登录是一个编排函数,不是散落的事件
async function authLogin(params) {
const { accessToken } = await loginApi(params)
setAccessToken(accessToken)
const [userInfo, accessCodes] = await Promise.all([fetchUserInfo(), getAccessCodesApi()])
router.push(homePath)
}
```
配套要求:**禁止**在生产日志打印 token 片段;`isLoggedIn` 以内存态 + token 存在性为准,不依赖 localStorage 残留字段。pure 的 `useXxxStoreHook()` 模式保证拦截器/守卫等 setup 外代码也能安全取 store。
---
## 七、双端一致性要求(迁移期)
同产品双栈并存时,请求层**必须**满足:
| 项 | 要求 |
|----|------|
| token key | 两端同名(推荐 `<app>.auth.token`;迁移期「读旧写新」一个版本) |
| refresh 机制 | 两端同构(单飞 + 队列重放),副端不允许退化成「401 直接跳登录」 |
| 响应契约 | 同一份 code/data/msg 约定与错误分级 |
| mock | **显式模式**(dev 开关/页面级声明),**禁止**运行时因网络错误静默回退假数据(adp:mock 是构建模式不是运行时兜底) |
---
## 八、最佳实践清单
✅ **要做**
1. 组件永远 import `api/<域>` 函数,不直接碰 axios 实例(**禁止** store 绕过 API 层裸调)
2. 每个请求函数带注释(用途/参数/返回)+ 类型(`request<Api.Xxx>` 或 JSDoc)
3. 刷新 token 的请求用裸 client,绕过业务拦截器
4. 401 副作用全局单例(一个弹窗/一次跳转),登录页与轮询接口静默
5. 列表请求一律可取消(AbortController)+ 版本号防错序
❌ **不要**
1. 不要在拦截器里 `return error` 而非 `Promise.reject(error)`(gva 现存 bug,调用方 catch 不到)
2. 不要在组件里 try/catch 后吞错不提示(错误要么展示要么显式静默)
3. 不要把 token 存两套 key
4. 不要在请求层 import 具体页面组件(保持依赖单向)
5. 不要对同一资源做无缓存的重复并发请求
---
## 九、AI 检查清单(对比目标工程逐项核对)
**P0**
1. 是否存在组件/store 绕过 API 层直接调 axios 实例的代码?
2. 401 刷新是否单飞(isRefreshing/队列/共享 Promise 任一实现)?并发过期是否只产生一次副作用?
3. 是否有「网络错误静默回退 mock」类逻辑在生产路径上?
4. 拦截器错误分支是否 `Promise.reject`?是否有 token 打印类调试日志残留?
**P1**
5. 列表页是否全部走「取消 + 版本号」方案?手写裸请求循环的页面列出迁移清单
6. 错误提示是否分级(showType / silent 逃生口)?登录页与轮询是否静默?
7. token/用户信息持久化是否 pick 白名单?菜单/路由是否落盘(不应落盘)?
8. 字典/枚举是否有缓存层?重复并发请求同一资源的情况列出
**P2**
9. 是否值得升级到档 2/3(五钩子工厂 / RequestClient 类)?判断标准:多实例需求或错误契约复杂度
10. 是否引入 axios-retry(幂等 GET)与错误码 env 化?
---
> 下一篇:[04-路由与权限控制](04-路由与权限控制.md) —— meta 是路由的灵魂。
@@ -0,0 +1,236 @@
# 04 — 路由与权限控制
> 参考:vue-vben-admin(RouteMeta 30+ 字段、三种 access 模式)、soybean-admin(文件路由 + static/dynamic 一键切换)、ant-design-pro(access 纯函数)、vue-pure-admin(两级按钮权限);补充:gin-vue-admin(动态路由链路)
---
## 一、路由组织:静态、动态与混合
### 1.1 三种模式
```
frontend 模式:前端静态全量路由 + 按角色过滤
优点 简单、无接口依赖 缺点 菜单改动要发版
backend 模式:后端下发菜单树 + import.meta.glob 组件映射(vben / pure / gva)
优点 运营可配菜单 缺点 链路长、需处理守卫竞态
mixed 模式:本地静态路由 + 按 name 合并后端菜单(vben mergeRoutesByName)
优点 开发体验与可配置性兼得 缺点 命名纪律要求高
```
soybean 的成熟做法:用 `.env` 的 `VITE_AUTH_ROUTE_MODE=static|dynamic` **一键切换**两种模式(静态按 `meta.roles` 过滤本地路由 / 动态接收后端下发 ElegantConstRoute),两态殊途同归后统一装配菜单——**模式切换是配置而非代码**,底座工程的成熟标志。
**推荐**(多数中后台):「**路由静态、菜单动态**」的轻混合——路由决定 chunk 划分与守卫行为,宜稳;菜单决定导航展示,宜活。需要运营完全可配时再上 backend 模式。
### 1.2 backend 模式的实现链路(补充:gva 全链路;vben generateAccessible 同类实现更工程化)
```
POST /menu/getMenu(菜单树,component 为字符串路径 'view/layout/index.vue')
→ import.meta.glob('../view/**/*.vue') 按路径字符串匹配懒加载组件(pure 同款)
→ 守卫 addRoute(asyncRouterFlag 防重复注册)
→ 多级菜单递归扁平化为「layout + 二级路由」(pure:keep-alive 只支持二级)
→ routeMap(name→route)支撑菜单/keep-alive/参数查询
```
三个易踩坑,标杆都给了答案:
1. **守卫竞态**:动态 addRoute 后当前导航拿不到新路由 → 注册后需 `return to.fullPath` 重新 resolve 一次(vben:`generateAccess` 后 re-resolve)
2. **防重复注册**:`asyncRouterFlag` / `isAccessChecked` 标志位(pure:刷新时 `wholeMenus.length === 0` 才重建)
3. **对象冻结**:`to.meta.matched = [...to.matched]` 浅拷贝进 meta 供面包屑使用(直接引用会被冻结)
soybean 的差异化:**文件路由自动化**(@elegant-router)——`src/views` 目录即路由,生成物含 `RouteKey`/`RouteMap` 字面量联合类型,`routerPush({ name: 'user-center' })` 全程类型安全。开发者不写路由文件,新增页面自动获得路由与菜单。
---
## 二、RouteMeta 规范:meta 驱动一切 UI 行为
### 2.1 vben 的启示
vben 给 RouteMeta 定义了 30+ 强类型字段(全 JSDoc 注释),菜单/标签页/面包屑/缓存/权限行为**全部由 meta 声明**,布局组件只消费 meta 不写死逻辑:
```ts
authority?: string[] // 允许的角色
ignoreAccess?: boolean // 免鉴权(如关于页)
menuVisibleWithForbidden?: boolean // 菜单可见但访问 403(三态权限关键)
hideInMenu / hideInTab / hideInBreadcrumb / hideChildrenInMenu
keepAlive?: boolean // 组件缓存
order?: number // 菜单排序
affixTab?: boolean // 标签页固定
iframeSrc / link / activePath / noBasicLayout
```
soybean 同款(17 字段强类型扩展 `declare module 'vue-router'`),pure 用 `CustomizeRouteMeta` + 逐字段中文注释。
### 2.2 标准 meta 集(必须的最小集)
```js
// 每条路由必须声明的能力面
{
path: '/users',
component: () => import('@/views/user/Users.vue'),
meta: {
title: '用户管理', // document.title 与面包屑用
layer: 'system', // shell | system | examples(可裁剪分层归属,底座工程必须)
icon: 'user', // 菜单图标(图标名是数据)
permissions: ['user.view'],// 命中任一即放行;'*'/admin/超管角色 短路
roles: ['admin'], // 可选,角色级收紧
keepAlive: false, // 需要缓存的页面必须同时写 defineOptions name
hideInMenu: false,
}
}
```
**纪律**:meta 是路由的**唯一**能力声明处,且**必须有类型契约**(`router.d.ts` 模块扩充)。**禁止**「路由里一个 permission、菜单表里又一个 permission」双源人工同步。
---
## 三、守卫链设计
### 3.1 标准守卫流水线
```
① 白名单直通(login/mfa/403/500 等 shell 路由)
② token 检查(无 token → login?redirect=encodeURIComponent(fullPath))
③ 已登录访问 login → 重定向首页
④ 权限检查(meta.permissions 任一命中 / meta.roles)
⑤ NProgress + document.title + 主区滚动复位
⑥ onError:chunk 加载失败 → reload 一次(防发版后旧 chunk 404)
```
第 ⑥ 条与 adp 的 `ErrorBoundary 识别 ChunkLoadError → retryCount 作 key 强制重挂载 lazy` 异曲同工(一个在路由层救,一个在组件层救)——发版后旧 hash chunk 404 是必现事故,**必须**有恢复机制。
### 3.2 防开放重定向(安全底线,必须)
```ts
// adp: src/pages/user/login —— redirect 只允许同源相对路径
function getSafeRedirectUrl(redirect?: string) {
if (!redirect || /^https?:\/\//i.test(redirect)) return '/'
return redirect.startsWith('/') ? redirect : `/${redirect}`
}
```
redirect 参数编码传输 + 回跳侧**协议黑名单校验**(禁止 `//evil.com`、`https://evil.com`)。
### 3.3 React 端对照
- `RequireAuth`(未登录跳转 + 403 渲染)结构正确即可
- 路由渲染**必须**声明式(配置数组 + `<Route>`),权限字段语义与 Vue 端 meta 同名对齐
---
## 四、权限三粒度
### 4.1 粒度模型
```
路由级(页面能否访问)
meta.permissions / meta.roles —— 守卫拦截,403 兜底页
菜单级(导航显不显示)
服务端菜单 + 前端模块开关兜底过滤
按钮级(操作能不能点)
指令 / 组件 / 函数 —— 至少支持一种,语义全站统一
```
### 4.2 权限三态(vben 的关键设计)
| 态 | 行为 | meta |
|----|------|------|
| 不可见 | 菜单过滤 + 路由拒绝 | 默认(authority 不命中) |
| 可见但禁止 | 菜单显示,点进去 403 | `menuVisibleWithForbidden: true` |
| 放行 | 全通过 | authority 命中 / `ignoreAccess` |
无权限但想让用户知道功能存在的运营场景,用态 2(前端只需 403 页 + 菜单过滤豁免,成本低)。
### 4.3 按钮级实现对照
| 实现 | 出处 | 语义 | 失败行为 |
|------|------|------|----------|
| 指令 `v-auth` | gva `directive/auth.js` | 角色串比对,`.not` 修饰符 | `el.remove()`(DOM 摘除) |
| 指令 `v-access:code` | vben | 权限码/角色双模式 | `el.remove()` |
| 函数 `hasAuth()` | soybean | 权限码,一个 hook 覆盖 90% 场景(不造指令/组件) | `v-if` 不渲染(纯函数可单测) |
| 双轨 `v-auth`/`v-perms` | pure | 路由 meta.auths(功能级)与登录 permissions(接口级)两级,`*:*:*` 超管通配 | `el.remove()` |
| 组件 `<AccessControl>` | vben | 包裹式 | 插槽不渲染(可配 fallback) |
**规范**:指令用于「无权就不存在」;需要「无权置灰 + tooltip 解释」时用组件包裹。**禁止**用 `v-if + 手写权限字符串比较` 绕开统一入口(权限字符串散落是审计灾难)。soybean 的取向值得参考:**权限判断优先做成可组合函数**(逻辑可单测、不依赖模板编译),指令只是函数的语法糖。
### 4.4 权限计算要纯函数化(adp)
```ts
// src/access.ts —— 权限是 initialState 的纯函数,可单测穷举
export default function access(initialState) {
const { currentUser } = initialState ?? {}
return {
canAdmin: currentUser && currentUser.access === 'admin',
canUser: (code: string) => currentUser?.permissions?.includes(code),
}
}
```
**禁止**在前端硬编码「角色→权限」映射表——后端 RBAC 是唯一源真相,前端硬编码映射必然脱节,属死代码。
---
## 五、菜单生成
### 5.1 两种数据流
```
A. 路由驱动(adp):config/routes.ts 集中声明 → ProLayout 自动生成菜单
name 字段即 i18n key(menu.xxx),菜单名与路由单源
B. 接口驱动(vben/pure/gva):GET /auth/menu → 前端自建树 → 递归渲染
后端可配(hidden/icon/order/外链),前端兜底过滤
```
### 5.2 菜单组件规范(必须)
- 前端按**分组规则**(如 workspace→business→system 三段)建树,`SYSTEM_ORDER` 类常量定序
- **模块开关兜底过滤**:菜单接口没过滤掉的,前端再筛一次(后端是源真相,前端是最后防线)
- **加载失败显示重试按钮**,不留空白侧栏(菜单是导航生命线)
- 高亮:外链/iframe 路由用 `meta.activePath` 指回正确菜单项
- 菜单数据不持久化(重登重拉,防旧菜单闪现)
---
## 六、最佳实践清单
✅ **要做**
1. 新路由必须写全 meta 最低三件:title / layer(底座工程)/ permissions
2. keepAlive 路由的页面组件**必须** `defineOptions({ name })`(keep-alive include 按组件名匹配;pure/gva 以 vite 插件扫描校验)
3. redirect 参数编码传输 + 回跳侧同源校验
4. 权限判断统一走 permission 工具层,语义双端同名
5. 路由 meta 有类型契约(`router.d.ts` 模块扩充)
❌ **不要**
1. 不要在前端硬编码角色→权限映射(后端 RBAC 是唯一源真相)
2. 不要用 v-if 手写权限字符串比较
3. 不要在守卫里做异步重活(拉菜单/用户信息放登录编排或首屏初始化,守卫只做检查)
4. 不要让示例层路由 import 系统层组件(破坏可裁剪性)
5. 不要权限配置文件双份并存(漂移源头)
---
## 七、AI 检查清单(对比目标工程逐项核对)
**P0**
1. 路由 meta 是否有类型契约?是否存在「路由权限 + 菜单权限」双源人工同步?
2. redirect 回跳是否有同源校验(防开放重定向)?
3. 是否存在前端硬编码「角色→权限」映射(应删)?
4. chunk 加载失败是否有恢复机制(onError reload / ErrorBoundary)?
**P1**
5. keepAlive 链路是否完整(meta.keepAlive + defineOptions name + include 匹配)?缺 name 的缓存页面列出
6. 按钮权限是否统一入口(指令/组件/函数三选一,禁止散落 v-if 比较字符串)?
7. 菜单失败是否有重试 UI?菜单是否被错误地持久化?
8. 路由渲染(React 端)是否声明式?
**P2**
9. 是否值得引入文件路由自动化(soybean @elegant-router / pure `router/modules/` glob 收集)?
10. 是否需要权限三态中的「可见但 403」(运营诉求)?
---
> 下一篇:[05-布局系统与导航设计](05-布局系统与导航设计.md) —— 布局是业务的舞台,不是业务本身。
@@ -0,0 +1,234 @@
# 05 — 布局系统与导航设计
> 参考:vue-vben-admin(三层布局架构)、soybean-admin(layouts/modules 按功能拆分)、ant-design-pro(ProLayout 运行时配置)、vue-pure-admin(布局三模式);补充:gin-vue-admin(侧栏模式/标签页/水印)
---
## 一、布局的分层架构
### 1.1 vben 三层模型(理解布局复杂度的标尺)
```
┌─ 内核层 @core/ui-kit/layout-ui ──────────────────────┐
│ headless 布局引擎:40 个 props + defineModel │
│ (布局模式/头部模式/侧栏宽度折叠悬停),不认识 store │
├─ 业务层 effects/layouts ─────────────────────────────┤
│ 把 preferences 逐项映射为内核 props; │
│ 组装 15 个可插拔部件(面包屑/主题切换/搜索/锁屏…) │
├─ App 层 layouts/basic.vue ───────────────────────────┤
│ 只用插槽填内容(#user-dropdown #notification …) │
└──────────────────────────────────────────────────────┘
```
soybean 的同构做法:布局引擎抽成 `packages/materials`(AdminLayout/PageTab/SimpleScrollbar),`src/layouts/base-layout/` 只是配置消费者,`layouts/modules/global-*` 按功能拆分(header/sider/tab/breadcrumb/search/footer/theme-drawer)。pure 提供 vertical/horizontal/mix 三种布局模式运行时可切。
**设计要义**:布局引擎与业务解耦(内核不依赖状态库),布局模式是**运行时偏好**而非构建期选择。
### 1.2 单文件 Layout 的取舍(中小工程)
300 行左右的单文件 Layout(el-container 侧栏 + 顶栏 + main)对单布局产品是**正确选择**——不要为了架构美引入三层。分层触发条件:
- 出现第 2 种布局模式(如「无侧栏全屏监控大屏」)
- 布局配置需要用户可调(折叠宽度/头部固定/水印)
- 布局代码超过 500 行开始挤压可读性
无论哪种形态:**业务页挂在内容区(页面容器组件协议),禁止重写布局层**。
---
## 二、布局骨架与尺寸规范
### 2.1 管理端标准骨架
```
┌──────────────────────────────────────────────────────┐
│ Topbar 48px │ 页标题 · 面包屑 │ 暗色切换 · 用户下拉 │
├──────────┬───────────────────────────────────────────┤
│ Sidebar │ │
│ 220px │ main │
│ (折叠64) │ <router-view> │
│ │ PageContainer │
│ 分组: │ │
│ 按层定序 │ 页头(标题/描述/操作按钮) │
│ │ 内容卡片(24px 内边距) │
└──────────┴───────────────────────────────────────────┘
```
### 2.2 尺寸规范表(推荐值)
```
侧栏宽度 220px(折叠 64px,<1200px 自动折叠)
顶栏高度 48-56px
页面内边距 24px(紧凑模式 16px)
卡片间距 16px
卡片圆角 8px(--el-border-radius-base 级)
按钮/输入高度 32px(default size)
图标 16px 主图标 / 12px 辅助
正文字号 14px;辅助 12px;页面标题 18-20px
移动端断点 <768px 侧栏变覆盖式抽屉 + backdrop
```
### 2.3 卡片构成规范(视觉一致性的最小单位)
页面 = 卡片的组合,卡片写法全站统一:
```
构成 背景 bg-card + 描边 border-base 或 阴影 shadow-1(全站二选一,禁止混用)
内边距 20-24px;卡片内区块间距 16px;卡片之间间距 16px
标题区 卡片标题 16px/600 + 右侧操作区(同一基线对齐)
页头 页面标题 18-20px/600 + 副标题 13px 次要色;页头与内容区间距 16px
指标卡 数字 24-28px/600 + tabular-nums + 环比箭头(涨跌语义色,方向全站统一)
```
---
## 三、侧栏与导航
### 3.1 侧栏必备能力清单
| 能力 | 实现要点 | 出处 |
|------|----------|------|
| 折叠 | 图标态切换;`<1200px` 自动折叠;移动端强制抽屉 | gva/pure |
| 高亮 | 路由激活态;外链/iframe 用 `meta.activePath` 指回 | vben `activePath` |
| 分组 | 前端按 layer 分组 + 常量定序,服务端菜单为准 | 常见底座实现 |
| 图标 | icon 名是数据(菜单表字段),前端映射渲染 | gva/pure/soybean |
| 失败重试 | 菜单接口失败显示重试入口,不留空白 | 通行要求 |
| 外链 | `window.open` 新窗;不注册路由 | gva `link` |
### 3.2 可选演进:标签页(tabs)
功能完整参照(gva 280 行 / pure multiTags / vben tabs-ui):sessionStorage 持久化、tab key = `name + query + params`(同路由不同参数多开)、右键菜单(关左/关右/关其他)、中键关闭、首页不可关、`meta.closeTab` 不留痕;vben 再加 affixTab 固定、maxNumOfOpenTab 上限、拖拽排序。
**引入判断**:运维/排障类平台(多工位切换频繁)收益大;配置型后台收益小。做了标签页**必须**解决 keep-alive 联动(否则切 tab 丢状态)。
---
## 四、Keep-Alive 链路(做对了很隐形,做错了很诡异)
```
路由 meta.keepAlive: true
→ 页面组件 defineOptions({ name: 'UserList' }) ← 强约定!
→ layout 中 <keep-alive :include="cachedNames">
→ 刷新:keepAlive 页销毁重建 toggle;非缓存页跳隐藏 Reload 路由(gva 双轨)
```
**为什么必须 defineOptions name**:keep-alive 的 include 匹配的是**组件名**而非路由名。script setup 组件默认匿名,不写 name 缓存必然失效。gva/pure 以 Vite 插件扫描生成「路径→组件名」映射做校验——**推荐**「组件 name 与路由 name 同名」并用 lint 守住(`vue/name` 规则)。
pure 的补充经验:**三级以上路由拍平成二级**再注册(keep-alive 只对二级稳定生效,社区已知问题)。
---
## 五、页面模板(新页面从这里复制起步)
### 5.1 列表页(最高频模板)
```
┌─────────────────────────────────────────────────┐
│ PageContainer #header │
│ 批量删除 · 导入 · [+ 新增] ← 按钮级权限 │
├─────────────────────────────────────────────────┤
│ ListPage │
│ ├ filters slot:关键字/状态/日期筛选 [查询][重置] │
│ ├ el-table(操作列 fixed right,状态列 tag 化) │
│ └ el-pagination(10/30/50/100) │
├─────────────────────────────────────────────────┤
│ 对话框子组件:XxxFormDialog / ResetDialog / … │
└─────────────────────────────────────────────────┘
```
范式要点:
1. 页面只做「编排」:容器 + 列表组件 + 子组件组合,本身 ≤400 行
2. 数据获取走列表组件的 fetcher(内嵌「取消 + 版本号」双重防护,见 03 章)
3. 表单弹窗独立成 `components/XxxFormDialog.vue`,通过 props/emit 通信
4. 操作按钮逐个挂权限控制
### 5.2 详情/编辑页
```
┌─────────────────────────────────────────────┐
│ ← 返回 编辑用户 [保存] │
├─────────────────────────────────────────────┤
│ ┌─ 基本信息 ────────────────────────────┐ │
│ │ 分组卡片:descriptions 只读态 │ │
│ └───────────────────────────────────────┘ │
│ ┌─ 高级设置 ────────────────────────────┐ │
│ │ 表单区:危险操作独立分区(红色警示卡) │ │
│ └───────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
```
### 5.3 仪表盘页
```
┌─────────────────────────────────────────────┐
│ 4-6 张指标卡(数字 + 环比箭头) │
├──────────────────┬──────────────────────────┤
│ 折线图(趋势) │ 饼图/柱状(分布) │
├──────────────────┴──────────────────────────┤
│ 最近活动列表 / 快捷入口 │
└─────────────────────────────────────────────┘
```
图表规范:ECharts **按需引入**(`echarts/core` + 按需 register,**禁止**全量 import echarts);图表区懒加载 + loading 占位;WebSocket 实时图必须处理「组件卸载断开连接 + 重连退避」;图表色板从主题变量读取(见 06 章)。
### 5.4 空态页
图标 + 一句话 + 引导动作(如「模块未启用 → 查看启用步骤」)是「功能未开启」的统一形态。
---
## 六、响应式策略
| 断点 | 行为 |
|------|------|
| ≥1200px | 完整布局 |
| 768-1200px | 侧栏自动折叠为图标态 |
| <768px | 侧栏变覆盖式抽屉 + backdrop;表格横向滚动;弹窗宽度 90% |
实现参照(gva `hooks/responsive.js`、soybean 断点写回 store):resize 监听 + **100ms debounce** 写入全局 device 状态(`desktop | mobile`),布局组件只读状态不各自监听。弹窗移动端宽度走全局配置统一控制。
---
## 七、最佳实践清单
✅ **要做**
1. 业务页挂内容区(页面容器协议),**禁止重写 layout/**
2. 页面标题来自 `route.meta.title`,不硬编码
3. 可访问性:折叠钮/菜单项带 aria-label
4. 布局状态(折叠)持久化,key 纳入全局命名空间(见 08 章双端一致)
5. 路由切换动画统一一种(fade 150ms),`transition` 包 `router-view`
❌ **不要**
1. 不要在业务页写 fixed 定位的伪头部(与 Topbar 层级打架)
2. 不要侧栏菜单硬编码(数据源走菜单 store)
3. 不要绕过页面容器自造页面容器(内边距/面包屑/页头规范会失控)
4. 不要在移动端保留 hover-only 交互(用点击展开兜底)
5. 不要保留空目录和过期布局文档
---
## 八、AI 检查清单(对比目标工程逐项核对)
**P0**
1. 业务页面是否有绕过页面容器、直接改写布局层的代码?
2. 页面标题是否单源(meta.title)?硬编码标题清单列出
**P1**
3. 超过 500 行的 Layout / 超过 400 行的视图页面列出,并给拆分建议(参照 07 章)
4. keepAlive 链路是否完整(meta + defineOptions name + include)?
5. 响应式三断点行为是否齐全?弹窗移动端宽度是否有适配?
6. ECharts 是否按需引入?WebSocket 是否有卸载断开与重连退避?
**P2**
7. 是否值得引入标签页(多工位场景)?若引入,keep-alive 联动方案确认
8. 是否值得布局引擎化(出现第二种布局模式时)
---
> 下一篇:[06-主题系统与视觉设计](06-主题系统与视觉设计.md) —— 换肤只改变量,不引第二套样式。
@@ -0,0 +1,316 @@
# 06 — 主题系统与视觉设计
> 参考:vue-vben-admin(HSL Token + Tailwind 桥接 + CSS layer + EP 适配器)、soybean-admin(单一 ThemeSetting 类型 + 三端同步)、vue-pure-admin(EP↔Tailwind v4 变量桥接)、ant-design-pro(antd token + data-theme);补充:gin-vue-admin(EP SCSS 定制三层法)
---
## 一、设计 Token:单一事实源
### 1.1 Token 分层模型
```
语义层(唯一手写处)
--background --foreground --primary --card --border --radius ...
↓ 派生(工具类桥接)
Tailwind 层 bg-background text-foreground(@theme 映射)
↓ 派生(适配器)
组件库层 --el-color-primary / antd token —— 从语义层变量计算得出
↓ 派生(算法)
色阶层 primary-50 … primary-950 / light-3 … light-9
```
**铁律:语义 Token 是唯一手写源,其余全部派生。** 任何地方再出现硬编码色值(`#409eff`、`rgb(64,158,255)`)都是违规。
### 1.2 vben 的 Token 定义(shadcn 范式)
```css
/* vben: packages/@core/base/design/src/design-tokens/default.css */
:root {
--background: 0 0% 100%; /* HSL 三元组,不带 hsl() 包装 */
--primary: 211 91% 39%;
--border: 214 32% 91%;
--radius: 0.5rem;
}
.dark { /* 整组覆盖,暗色即换一套值 */
--background: 224 71% 4%;
--primary: 210 98% 56%;
}
```
两个细节值千金:
1. **裸 HSL 值不带 `hsl()` 包装**——这样 `hsl(var(--primary) / 0.5)` 可直接拼透明度(soybean 同款:RGB 拆三段 `r g b` 供 `rgb(var(--x) / 0.5)`)
2. **暗色 = `.dark` class 整组覆盖**,而非媒体查询——用户偏好优先于系统
### 1.3 Tailwind v4 桥接(vben)
```css
/* internal/tailwind-config/src/theme.css */
@custom-variant dark (&:is(.dark *)); /* class 策略暗色 */
@theme inline {
--color-background: hsl(var(--background)); /* 语义 Token → 工具类 */
--color-primary: hsl(var(--primary));
}
@layer properties, theme, base, components, utilities; /* 级联层声明 */
```
**CSS layer 声明是组件库共存的关键**:把组件库样式压在低层,工具类天然可覆盖,避免 `!important` 军备竞赛。
### 1.4 pure 的 EP↔Tailwind 桥接与 soybean 的三端同步(★★★★☆)
**EP 工程引入原子类的最短路径(pure):**
```css
/* vue-pure-admin: src/style/tailwind.css —— Tailwind v4 CSS-first 写法 */
@custom-variant dark (&:is(.dark *)); /* class 策略暗色 */
@theme {
--color-bg_color: var(--el-bg-color); /* EP 变量 → 原子色 token */
--color-primary: var(--el-color-primary); /* 于是 bg-bg_color / text-primary 与组件库同色 */
}
```
soybean 则是「单一强类型配置对象」路线:`themeSettings`(`App.Theme.ThemeSetting` 一份 schema 即文档)驱动 **CSS 变量 + NaiveUI GlobalThemeOverrides + UnoCSS theme 三端同步**,且用 `BUILD_TIME` 作覆盖标志——**发版自动淘汰用户本地旧主题缓存**(防「新版本旧皮肤」)。两案共同点:换肤 = 改上游变量,组件库与原子类自动跟随。
---
## 二、暗色模式
### 2.1 实现要点(三标杆殊途同归)
| 环节 | 做法 | 反面 |
|------|------|------|
| 切换机制 | html 挂 class(vben/pure `.dark`)或 `data-theme="dark"`(adp) | `prefers-color-scheme` 媒体查询直连(用户无法覆盖系统) |
| 跟随系统 | watch `prefers-color-scheme` 作为 **auto 模式初值**,用户手动选择后以用户为准 | — |
| 持久化 | localStorage 单 key;**读取要在 app 挂载前**(防白闪,必要时内联脚本) | 挂载后再初始化(首屏闪白) |
| 组件库 | EP:引 `dark/css-vars.css` 一行;antd:token 切换 | 自写暗色样式覆盖组件库 |
三态标准:`light | dark | auto`(auto 跟随系统)。附加无障碍模式(可选):灰度 / 色弱滤镜(`html-grey` / `html-weakness`,一行 filter + class 切换;值班大屏场景有真实需求)。
**暗色的分层策略与浅色不同**:阴影在深底上几乎不可见——暗色模式靠**边框 + 表面亮度差**分层(卡片 = 页面底色提亮一档),不要只调阴影。
### 2.2 双端 key 一致(迁移期,必须)
同产品双栈并存的工程,暗色 key **必须**两端同名(推荐 `<app>.theme.mode`),切换机制统一(class + data-theme 双挂成本最低)。key 不一致 = 同浏览器两端口偏好不互通 + 迁移时暗色选择器要全量改。
---
## 三、组件库主题定制(Element Plus)
标杆做法(vben):语义 Token **派生**组件库变量——`useElementPlusDesignTokens` 监听主题变化,把语义 Token 实时映射为 `--el-color-primary` 全系(暗色下自动反转色阶方向)。**换肤不碰组件库,只改上游 Token。**
补充范式(gva 的 EP 专属三层法):
```
第 1 层 编译期:SCSS 定制色板
@forward 'element-plus/theme-chalk/src/common/var.scss'
with ($colors: ('primary': ('base': #409EFF), ...));
第 2 层 运行时:JS 生成 --el-color-primary 及 light-1..10 / dark-1..2 / -bg
色阶写入 documentElement —— 换主色不重新编译
(gva setBodyPrimaryColor:light/dark 两套明度算法)
第 3 层 暗色变量:element-plus/theme-chalk/dark/css-vars.css
+ html.dark { --el-bg-color: ... } 局部覆盖
```
pure 的 `setEpThemeColor()`(`useDataThemeChange.ts`)是第 2 层的更简实现:`documentElement.style.setProperty` + 循环生成 light-1~9/dark-1~2,且暗色下**色阶方向反转**。品牌主色运行时换肤(站点名/Logo/主色/favicon 联动)是底座「品牌可配」能力的核心——主色、圆角(`--el-border-radius-base`)、基础字号纳入同一变量体系。
---
## 四、视觉执行规范(写出好看代码的数值层)
> 本章前三节解决「主题怎么搭」,本节解决「页面怎么写才好看」。所有数值**必须**落为 CSS 变量后使用——AI/开发者写页面时只允许引用变量,不允许现场发明数值。
### 4.1 品牌可配项
| 配置项 | 生效点 | 存储 |
|--------|--------|------|
| 站点名称 | 侧栏 logo 文案 / document.title 后缀 | 后端配置 |
| Logo | 侧栏 + favicon | 后端配置 |
| 主色 | `--el-color-primary` 系列运行时混色 | 后端配置或用户偏好 |
| 暗色 | 用户偏好 | localStorage(统一命名空间) |
pure 的补充范式:**运行时平台配置**(`public/platform-config.json`,Title/Layout/Theme/DarkMode 等 30+ 项,启动拉取注入)——改配置不发版(详见 08 章 env 分层)。
### 4.2 基础 Token 全表(新工程第一天就建)
```css
:root {
/* —— 背景层次(浅色值示例;暗色整组覆盖,靠亮度差分层) —— */
--bg-page: #f5f6f8; /* 页面底 */
--bg-card: #ffffff; /* 卡片 / 弹层 */
--bg-hover: rgba(0, 0, 0, 0.04); /* 行/项 hover */
--bg-muted: #f5f7fa; /* 次级区块 / 代码块 */
/* —— 文字层次(四档灰,禁用两档灰打天下) —— */
--text-primary: rgba(0, 0, 0, 0.88); /* 正文 */
--text-secondary: rgba(0, 0, 0, 0.65); /* 次要说明、表头 */
--text-placeholder: rgba(0, 0, 0, 0.38); /* 占位、禁用标签 */
--text-disabled: rgba(0, 0, 0, 0.25);
--text-inverse: #ffffff; /* 深底上的文字 */
/* —— 边框(三级) —— */
--border-strong: rgba(0, 0, 0, 0.26); /* 输入框 */
--border-base: rgba(0, 0, 0, 0.15); /* 卡片描边 */
--border-light: rgba(0, 0, 0, 0.08); /* 分割线 */
/* —— 语义色(对齐组件库 success/warning/danger/info) —— */
/* —— 间距(4px 基数,全站只用这六档) —— */
--space-1: 4px; --space-2: 8px; --space-3: 12px;
--space-4: 16px; --space-6: 24px; --space-8: 32px;
/* —— 圆角(三档) —— */
--radius-sm: 4px; /* 标签、小按钮 */
--radius: 8px; /* 卡片、按钮、输入框(主值) */
--radius-lg: 12px; /* 弹窗、大图 */
/* —— 阴影(卡片最多 2 级;弹层用组件库默认) —— */
--shadow-1: 0 1px 2px rgba(0, 0, 0, 0.06);
--shadow-2: 0 4px 12px rgba(0, 0, 0, 0.08);
/* —— 层级(防 z-index 军备竞赛) —— */
--z-dropdown: 100; --z-drawer: 200;
--z-modal: 300; --z-message: 400;
}
```
项目可从组件库变量派生(EP:`--bg-card: var(--el-bg-color)`),重点是**层次齐全、命名统一**。卡片风格全站二选一:**描边(border-base)或阴影(shadow-1)**,不许混用。
### 4.3 字体与排版
| 用途 | 字号 / 行高 | 字重 |
|------|------------|------|
| 页面标题 | 18-20 / 28 | 600 |
| 卡片标题 | 16 / 24 | 600 |
| 正文 / 表格 | 14 / 22 | 400 |
| 次要说明 | 13 / 20 | 400 |
| 辅助 / 标签 | 12 / 20 | 400 |
规则:**正文最小 12px**(再小不可读);同一页面标题层级 ≤2 级;数字用 `font-variant-numeric: tabular-nums`;中英文与数字混排加空格(「共 12 条」);长文本省略必须带 title 提示;字体栈取系统栈 + 组件库默认,**禁止**引入额外 webfont 除非品牌需要。
### 4.4 图标规范
- **一套图标集**(线性或面性统一,**禁止**混搭两套风格;推荐 Iconify 单集或组件库图标)
- 尺寸四档:12(辅助)/ 16(按钮、菜单、表格行内 **主值**)/ 20(页头)/ 24(空态插图位)
- 图标与文字并排间距 6px;按钮内图标与文字同色同级
- 纯图标按钮**必须**带 tooltip 与 aria-label
### 4.5 过渡与微交互
| 场景 | 时长 / 缓动 |
|------|-------------|
| 颜色 / hover 变化 | 150ms ease |
| 展开 / 抽屉 / 弹层 | 200-300ms `cubic-bezier(0.4, 0, 0.2, 1)` |
| 页面切换 | fade 150ms |
规则:同类交互全站**同时长同缓动**;**禁止**超过 400ms 的阻塞动画;按钮提交必须有 loading 态(防重复触发);自定义交互组件四态齐全——**hover / active / focus-visible / disabled** 缺一不可(focus 可见性是可访问性底线)。
### 4.6 对齐与栅格
- 表格:数字列右对齐 + tabular-nums,文本列左对齐,**居中对齐只用于状态标签/操作列**(全列居中是典型反模式)
- 表单:标签右对齐或上对齐全站统一一档;输入宽度按内容语义定(手机号 11 位就别 100% 宽)
- 卡片间距统一 `--space-4`(16px);页面内边距 `--space-6`(24px)
---
## 五、好看反模式清单(出现即改)
1. 硬编码色值(`#333`、`rgba(0,0,0,.5)` 散落模板)——不走 Token
2. 一页混用多种圆角 / 多种阴影 / 非 4px 基数间距(8 和 10 混用)
3. 全列居中对齐的表格(数字该右对齐、文本该左对齐)
4. 图标混用两套风格(线性 + 面性、单色 + 彩色)
5. 一页多个 primary 按钮(主操作**只能有一个**,其余 default/text)
6. 只有两种灰的文字层次(该用四档文字 Token)
7. 弹窗套弹窗再套抽屉(层级超过两层改抽屉分流)
8. 满屏成功 toast(见 07 章反馈分级:成功静默)
9. 骨架屏与真实内容尺寸不符(loading 结束页面跳动)
10. 表格列被挤压换行(缺 min-width / 未开横向滚动)
11. 危险操作无确认且无 danger 语义色
12. 空页面白板(empty 必须带引导动作)
13. 阴影当装饰乱叠(卡片最多 shadow-2,层级感来自留白而非阴影堆叠)
14. 暗色模式直接套浅色阴影(暗色靠边框 + 亮度差分层)
---
## 六、死 CSS 治理
存量工程常见病灶:styles/ 下积累多代无入口引用的样式文件(曾有工程沉淀约 2000 行死 CSS,且「设计系统」存在 v1/v2 两代并存——换了两茬样式系统、旧的没删的考古层)。
### 6.1 治理方案(四步)
```
第 1 步 验死:对每个文件跑全仓引用检查(import/@import/url()),
确认零引用后直接删除(git 里有历史,不建备份目录)
第 2 步 收敛:保留三件套 = variables(语义 Token)+ 栅格 + 基础元素;
组件样式回归 scoped / 组件库
第 3 步 立规:CI 检查 styles/ 下每个 .css 必须被入口链引用,
新文件无引用直接 fail
第 4 步 单源:设计规范类文档只留一份(docs),代码里不留第二套实现
```
**规则**:CSS 文件必须有入口引用链,如同 JS 文件必须有 import——「无引用样式」=「无主代码」,删除是唯一正确处理。
---
## 七、样式写法优先级(adp 明文范式,直接抄用)
```
1. 组件库自带能力(EP 的 type/size/token props) ← 优先
2. 语义工具类 / Tailwind 原子类(布局、间距) ← 其次
3. scoped 样式 + CSS 变量(主题相关) ← 业务组件
4. 深度选择器 :deep()(覆盖组件库,须注释原因) ← 最后手段
5. 全局覆盖(唯一收口文件,如 element 覆盖表) ← 禁散落
```
gva 的补充范式:语义化页面类(`gva-search-box / gva-table-box / gva-btn-list`)。与「页面容器 + 列表组件」的组件路线二选一——**推荐组件路线**(类型提示与 props 约束更强),不要并行引入两套体系。
---
## 八、最佳实践清单
✅ **要做**
1. 新颜色/间距/圆角一律引用 Token 变量,组件里禁止现场发明数值
2. 透明度拼接用 `hsl(var(--x) / 0.5)` 裸值模式
3. 暗色切换在 app 挂载前完成(必要时 index.html 内联首帧脚本)
4. 图表颜色从主题变量读取(初始化注入当前主题色板,暗色切换重建实例)
5. 新全局覆盖样式集中一个文件并注释原因
❌ **不要**
1. 不要保留 v1/v2 并存的设计系统文件
2. 不要用 `!important` 解决覆盖问题(用 CSS layer 或提高选择器语义)
3. 不要在组件里写 `@media (prefers-color-scheme)` 直连暗色(绕过用户选择)
4. 不要硬编码暗色背景色值(读组件库 bg 变量)
5. 不要双端各存一套主题 key(迁移期统一命名)
---
## 九、AI 检查清单(对比目标工程逐项核对)
**P0(结构)**
1. styles/ 下是否有零入口引用的样式文件?列出并标记删除(统计行数)
2. 是否存在硬编码色值 / 非 Token 间距圆角出现在模板与 scoped 样式?抽样列出
3. 是否有多套并行样式系统 / 两代设计系统文件并存?
**P1(视觉执行)**
4. 基础 Token 全表(背景四档/文字四档/边框三级/间距六档/圆角三档)是否建立且被引用?
5. 排版抽检:正文 <12px?数字列未右对齐/未 tabular-nums?长文本省略无 title?
6. 图标抽检:是否混用两套风格?纯图标按钮是否缺 tooltip/aria-label?
7. 过渡抽检:同类交互时长是否统一?提交按钮是否都有 loading?四态是否齐全?
8. 对照「好看反模式清单」14 条逐条扫描,命中项列出
9. 暗色抽检:分层是否靠边框/亮度差?图表色板是否随主题重建?
**P2(体验升级)**
10. 对比度达标:正文 ≥ 4.5:1、大字 ≥ 3:1(WCAG AA)——用工具抽查文字层次 Token
11. Tailwind 是否与组件库变量打通(pure `@theme` 桥接)?
12. 是否需要 BUILD_TIME 发版覆盖用户旧主题缓存(soybean 方案)?
13. 灰度/色弱无障碍模式是否值得启用(值班场景)?
---
> 下一篇:[07-组件设计与交互反馈](07-组件设计与交互反馈.md) —— 组件是设计的词汇表。
@@ -0,0 +1,253 @@
# 07 — 组件设计与交互反馈
> 参考:vue-vben-admin(Api 类命令式组件)、soybean-admin(hook 优先 + 组件薄壳)、vue-pure-admin(页面内聚五件套 + ReDialog)、ant-design-pro(trigger 弹窗 + ProTable 声明式)
---
## 一、组件分层与词汇表
```
┌─ 全局组件 src/components/ ──────────────────────┐
│ PageContainer 页面容器(页头/面包屑/slot) │
│ ListPage 分页列表(fetcher+筛选slot+表格) │
│ 权限组件 / 图标选择 / 主题切换 / 动态菜单 │
├─ 域组件 views/<domain>/components/ ─────────────┤
│ XxxFormDialog 表单弹窗 │
│ XxxDetail 详情抽屉 │
│ 单元格渲染器(状态 tag、操作按钮组) │
├─ 页面骨架 views/<domain>/<Page>.vue ────────────┤
│ 只做编排:组合上面两层,≤400 行 │
└─────────────────────────────────────────────────┘
```
**页面骨架的行数上限是硬约束**(400 行)。pure 的同构落地是「页面内聚五件套」:`index.vue`(纯模板,只解构 hook)+ `form/index.vue`(弹窗表单)+ 左树/右表子组件 + `utils/{hook.tsx, rule.ts, types.ts}`(逻辑/校验/类型全在本域)——`index.vue` 零逻辑。
两种封装哲学(按团队选择,**不混用**):
| 哲学 | 代表 | 做法 |
|------|------|------|
| schema 驱动重封装 | vben | FormApi/ModalApi 类 + schema 数据生成表单/弹层 |
| hook 优先薄壳 | soybean | 逻辑进 UI 无关 hook(useTable/useFormRules),组件只是组件库薄壳 |
---
## 二、弹层设计:trigger 模式与命令式 Api
### 2.1 trigger 模式(adp,声明式首选)
```tsx
// UpdateForm.tsx —— 调用方零状态
<UpdateForm
trigger={<Button type="link">编辑</Button>} // 传入触发器
onFinished={async.value => { actionRef.current?.reload() }}
/>
// 内部 cloneElement(trigger, { onClick: onOpen }) 注入打开事件
// 表单本体 StepsForm + render 进 Modal(destroyOnHidden)
```
Vue 对应写法:dialog 组件接 `trigger` slot,或保持「页面持 visible + 子组件 emit close」的朴素方案(够用)。**关键是弹窗组件自包含表单逻辑**,页面只传初始数据和回调。
### 2.2 命令式 Api 类(vben,复杂弹层)
```ts
// useVbenModal 返回 [Modal, api];ModalApi 持有独立响应式 store
const [Modal, modalApi] = useVbenModal({ connectedComponent: MyModal })
modalApi.open({ payload: { userId: 1 } }) // 命令式打开并传参
modalApi.onConfirm(async () => { ... }) // 生命周期钩子
```
适用:全局单例弹层(登录过期框、错误确认框——gva 的 ErrorPreview 是此模式的事件总线版)。普通 CRUD 弹窗**不必上这套**,props/emit 足矣。
Vue 侧最易落地的命令式弹窗是 pure 的 **ReDialog**:模块级 `dialogStore` + `addDialog({ title, contentRenderer, beforeSure: done => {...} })`,`done()` 关闭——无 provide/inject、无工厂,App.vue 挂载一次全局可用。表单弹窗若要从 props/emit 升级命令式,ReDialog 是阻力最小路线。
---
## 三、表单范式:手写、配置驱动与 schema 驱动
| 范式 | 适用 | 代表 | 成本 |
|------|------|------|------|
| 手写 el-form + rules | 表单结构常变、交互复杂(联动/自定义校验) | 常见 Vue 工程 | 低门槛高纪律 |
| hook 优先(不封表单组件) | 逻辑可测、UI 薄壳 | soybean `useFormRules`(`satisfies` 约束规则表) | 中 |
| 表单 SFC + 弹窗 contentRenderer | 弹窗内容即组件 | pure `form/index.vue` | 低 |
| 配置驱动 columns | 「一列三态」:同一份列配置生成表格列+搜索项+表单项 | adp ProTable(`valueType/valueEnum/hideInForm/formItemRender`) | 学习成本中,CRUD 提速极高 |
| schema 驱动 FormApi | 表单完全由 schema 数据生成,动态增删字段 | vben useVbenForm(zod 校验、响应式 schema) | 高,适合平台型产品 |
| RHF + zod | React 端 | react-hook-form + zod(schema 即校验源) | 中 |
**表单通用规则(必须)**:
1. 提交按钮 loading 态(防双击重复提交)
2. 校验消息中文、具体(「请输入用户名」而非「username 不能为空」)
3. 编辑态回填后 reset 时机明确(打开时回填,关闭时重置)
4. 危险输入(密码)用 `show-password`;密钥类只写不读(placeholder 提示「留空则不修改」)
---
## 四、表格范式:列表组件是资产
### 4.1 标准列表组件协议(强制走它)
```
<ListPage
:fetcher="fetchUsers" ← 函数:params → {results, count}
v-model:loading ← 内部管理
#filters(筛选 slot:关键字/状态/日期)
#default(el-table 列定义)
/>
内部:「取消 + 版本号」双重防护(见 03 章)+ 分页三件套
```
pure 的三层表格范式同构,可作演进对照:`@pureadmin/table`(全局表格组件,列用 tsx cellRenderer)+ `RePureTableBar`(刷新/列设置拖拽排序/密度/全屏工具条,作用域插槽透传 columns)+ 业务 `useUser()` hook(form/dataList/pagination/CRUD 全收编)——`index.vue` 只剩模板解构,**页面零逻辑**。
### 4.2 表格规范
```
列设计 操作列 fixed right;状态列 tag 化;时间列统一格式化函数
宽表 超 6 列启用横向滚动而非挤压;关键列 min-width 而非 width
空数据 empty 占位 + 「新增第一条」引导(不是裸的「暂无数据」)
加载 表格 v-loading(不用全局遮罩)
批量 多选 + 批量操作按钮常驻 disabled(选中方可点,视觉自说明)
行操作 ≤3 个平铺;>3 个收进更多下拉
数字列 右对齐 + tabular-nums
密度 行高全站统一一档(default 48px 或 compact 40px,不混用)
图标 行内图标按钮 16px,与文字间距 6px,风格全站统一(见 06 章 4.4)
```
### 4.3 详情复用列配置(adp 思路)
ProTable 的 columns 同时喂给 `ProDescriptions`(详情抽屉)——**列定义是数据**,表格、搜索、详情三处消费同一份。Vue 端可低成本借鉴:列数组中带 `formatter/render` 的描述导出给详情组件。
---
## 五、交互反馈分级(全站强制)
### 5.1 反馈决策表
| 场景 | 反馈 | 理由 |
|------|------|------|
| 操作成功(删除/保存) | ❌ 不弹框——界面已更新 | 降噪 |
| 操作失败 | ✅ 错误提示(原因必带) | 用户要知情 |
| 危险操作(删除/批量/不可逆) | ✅ 确认框(对象名进文案) | 防误操作 |
| 恢复默认/静默保存 | ❌ 不弹框 | 降噪 |
| 按钮已禁用 | ❌ 不弹框,视觉已说明 | 降噪 |
| 长任务(>1s) | ✅ 按钮 loading;>3s 进度条 | 可感知 |
| 登录/MFA 失败 | ✅ 表单内联错误(不 toast) | 上下文相关 |
| 401 过期 | ✅ 单例弹窗/单次跳转 | 防并发轰炸 |
```js
// ❌ 冗余 —— 成功也弹
await deleteMenu(id); ElMessage.success('删除成功')
// ✅ 专业 —— 静默,界面即反馈
await deleteMenu(id); loadMenus()
```
### 5.2 三态完整性检查清单
每个数据展示组件必须显式处理三态,缺一即 bug:
```
加载态 skeleton(首屏)/ loading(刷新)
空态 empty + 引导动作
错误态 错误占位 + 重试按钮(不是 console.error 完事)
```
---
## 六、Composables / Hooks 规范
### 6.1 命名与分层
```
composables/hooks 依赖业务上下文(store/请求层):usePagedQuery、useTable
utils 纯函数无依赖:confirm、格式化函数
```
规则:**composable 内禁止直接操作 DOM**(返回响应式状态);一个 composable 只做一件事;返回值用对象而非数组(追加字段不破坏调用方)。
soybean 分层补充:框架无关的 hook(useTable 泛型四参)与 UI 适配 hook(useNaiveTable)分层——换 UI 库只动适配层。
### 6.2 props 与事件命名规范
- props 用名词(`fetcher`、`initialData`、`permissions`),事件用动词(`@close`、`@submit`、`@success`)
- 双向绑定统一 `v-model:xxx` 约定(如 `v-model:visible`),**禁止**拆成 `visible` + `visibleChange` 两个接口
- 布尔 props 必须显式声明默认值;透传型组件(ListPage)用 `attrs` 透传 UI 库 props,并在组件头注释列出可透传项
- **避免过度封装**:props + slots 能解决的扩展,不引入 render 函数 / schema;封装层数超过两层时先考虑是否该拆组件
---
## 七、巨型单文件拆分实战
### 7.1 典型场景:定时任务管理页(约 1400 行单文件)
```
views/cron/
├── Cron.vue # ~120 行:页面容器 + 子组件编排
├── components/
│ ├── TaskTable.vue # 任务列表(列定义 + 批量选择)
│ ├── TaskFormDialog.vue # 新建/编辑任务(cron 表达式编辑器独立)
│ ├── CronExprInput.vue # cron 表达式输入 + 下次执行时间预览
│ ├── ExecutionHistory.vue# 执行记录(独立 tab/抽屉)
│ └── ExecutionLogDrawer.vue # 日志查看(自动刷新控制)
└── composables/
└── useCronTasks.js # CRUD 编排(复用列表 hook)
```
### 7.2 典型场景:监控大屏页(约 900 行单文件)
```
views/monitor/
├── Monitor.vue # ~100 行:单机/集群切换 + 布局
├── components/
│ ├── MetricCard.vue # 指标卡(CPU/内存/磁盘/网络 通用)
│ ├── TrendChart.vue # 趋势图(echarts 按需引入收口在此)
│ └── HostSelector.vue # 主机选择
└── composables/
└── useMetricsStream.js # WebSocket 连接/重连退避/卸载断开
```
拆分判据:**一个文件出现 ≥3 个「功能岛」**(各自的数据/状态/UI 块)即拆;拆出的单元可在测试里独立渲染。组织法参照 pure 页面内聚模式(index 纯模板 + form/ 子组件 + utils/hook 收编逻辑)。
---
## 八、最佳实践清单
✅ **要做**
1. 新列表页一律「页面容器 + 列表组件」起步(模板见 05 章)
2. 表单弹窗独立组件,页面只传数据与回调
3. 每个数据组件三态齐全(加载/空/错误)
4. composable 返回对象、单职责、无直接 DOM 操作
5. 组件 props 加类型与默认值;emit 声明 events
❌ **不要**
1. 不要在页面里写第二个 `<el-table>`(复用列配置或拆子组件)
2. 不要跨层 emit 链超过两级(改用 provide/inject 或提升状态)
3. 不要在组件内散落成功类 toast(见反馈决策表)
4. 不要用 index 作 v-for key(列表刷新错位之源)
5. 不要复制粘贴超过两次的 UI 块不抽组件(三次法则)
---
## 九、AI 检查清单(对比目标工程逐项核对)
**P0**
1. 超过 400 行的视图文件全部列出,按第七节方案给出拆分目录树
2. 成功类 toast 违反反馈决策表的调用点列出(应删)
3. v-for 是否有用 index 作 key 的列表(存在动态增删即不合格)
**P1**
4. 列表页是否统一走列表组件协议?手写分页/裸请求的页面列出迁移清单
5. 表单弹窗自查三件套:提交 loading、校验消息中文、关闭重置
6. 数据组件三态是否齐全(抽检空态与错误态渲染)
7. 权限控制是否逐按钮挂载(对照权限码清单)
**P2**
8. 是否值得引入列配置「一列三态」复用(adp 思路)
9. 弹窗是否需要升级命令式(全局单例场景用 ReDialog/ModalApi)
---
> 下一篇:[08-工程化与质量保障](08-工程化与质量保障.md) —— 让规范自动执行。
@@ -0,0 +1,256 @@
# 08 — 工程化与质量保障
> 参考:vue-vben-admin(Vite 配置工厂 + lefthook 流水线)、soybean-admin(eslint-config 工厂 + sa CLI)、vue-pure-admin(build/ 拆分 + env 开关能力)、ant-design-pro(Biome + 测试范式 + CI 矩阵)
---
## 一、构建配置组织
### 1.1 配置组织范式
```
vben:配置即内部包
internal/vite-config/ 提供 defineConfig({ application | library }) 工厂,
app 的 vite.config.ts 只剩 15 行(proxy 声明)
pure:根目录 build/ 拆分
build/{plugins,cdn,compress,optimize,utils,info}.ts,vite.config.ts 只做组装
(env 用 wrapperEnv 类型化读取)
soybean:build/plugins/ 按域拆文件
{router,unplugin,html,unocss,devtools}.ts 一文件一插件域
```
单工程标准:**必须**把 vite 配置按职责拆文件(plugins/env/proxy/compress),主配置只做组装;双栈工程的两个 vite 配置**结构镜像**(插件列表、alias、产物命名、proxy 规则逐项对齐)。
### 1.2 env 分层(三层结构)
| 层 | 形态 | 语义 | 说明 |
|----|------|------|------|
| 构建期烧录 | `VITE_*` | 打进产物,改了要重新构建 | API 前缀、功能开关 |
| 运行时可改 | `VITE_GLOB_*`(vben) | 构建时抽成 `_app-config-{hash}.js` 挂 window | **改 API 地址无需重构建** |
| 运行时 JSON | `public/platform-config.json`(pure) | 构建产物旁的配置文件,启动拉取注入 | 免重构建、免 window 挂载 |
| 纯 dev | proxy 端口等 | 只在 dev server 用 | — |
env 类型契约:每个 `VITE_*` 变量在 `vite-env.d.ts` 有类型与注释(soybean:联合类型直接收窄如 `'static' | 'dynamic'`)。
### 1.3 dev server 要点(必须)
```
strictPort: true # 端口被占直接报错,不悄悄漂移
proxy '/api' → 后端端口
ws: true # 供 WebSocket(必须显式开)
changeOrigin: true
```
### 1.4 产物与指纹
- 构建注入版本:`define: { __APP_VERSION__, __COMMIT_HASH__ }`(adp 用 git rev-parse 兜底)——设置页「关于」直接展示,排障救命
- 手工分包保留(radix/icons/charts/motion 等);新大依赖(图表/编辑器)**必须**评估独立 chunk + 懒加载
- 可选:构建压缩 env 开关(pure `VITE_COMPRESSION = none|gzip|brotli|both`)、CDN 开关(`VITE_CDN`)、构建时间戳 banner
---
## 二、代码规范流水线
### 2.1 分层流水线(vben lefthook 范式)
```
pre-commit(仅 staged 文件):
快修复:oxlint --fix → oxfmt → eslint --fix → stylelint --fix
(stage_fixed 回填暂存区)
commit-msg:commitlint(conventional)
push/CI:慢检查全量 vue-tsc/tsc --noEmit + lint + madge 循环依赖
```
原则:**快的在本地 pre-commit 自动修,慢的在 CI 卡**。
soybean 的同构版本:`@soybeanjs/eslint-config-vue` 工厂(eslint.config.mjs 仅 14 行)+ `vp lint`(oxc 快扫)→ `eslint`(精修)双层;commit 由自研 `sa` CLI 交互生成并校验,scope 白名单单一数据源。pure 再补两块:stylelint(recess-order 属性排序)与 eslint-plugin-better-tailwindcss(校验 class 书写)。
### 2.2 必配清单(必须)
| 工具 | 要求 |
|------|------|
| ESLint(flat)+ Prettier | 双端同大版本、规则集可复制 |
| Stylelint | standard-scss + recommended-vue + 属性排序 |
| commitlint + husky + lint-staged | pre-commit 快修复、commit-msg 校验 |
| `preinstall: only-allow pnpm` | 锁包管理器 |
**Conventional Commits 类型集**(commitlint type-enum,统一):`feat / fix / docs / style / refactor / perf / test / chore / revert`;scope 用功能域(`user`、`rbac`、`cron`、`react`……)。
**any 管控**:TS strict 端**禁止裸 any**——`as any` 需同行注释原因与消除计划;JS 端以 JSDoc `@param/@returns` 过渡,**不允许**出现「既无类型注释也无类型定义」的接口函数。
### 2.3 项目级 lint 增强(把各章规则自动化)
```js
// eslint 配置片段(示意)
'no-console': ['warn', { allow: ['warn', 'error'] }] // 生产禁 console.log
'no-restricted-imports': // 示例层禁 import 系统层内聚单元
{ patterns: [{ group: ['@/views/system/*'], message: '示例层不得依赖系统层' }] }
vue/name: 'error' // 页面组件必须 defineOptions name(keep-alive 前置)
import/no-cycle: 'error' // 循环依赖
```
---
## 三、测试策略
### 3.1 管理端测试金字塔(务实版)
```
▲ E2E 冒烟(Playwright)
│ 登录→进列表→CRUD→登出 3-5 条主线,别贪多
├─ 组件/页面测试(Vitest + Testing Library)
│ 列表组件三态、表单弹窗校验与提交、菜单失败重试
├─ 组合函数测试(重点投入区,性价比最高)
│ usePagedQuery(取消/错序/分页边界)
│ permission 纯函数穷举
│ 请求层拦截器分支(401刷新/重放/静默)
└─ 纯函数单测
格式化、confirm、色阶算法
```
### 3.2 adp 测试范式(直接照抄)
```ts
// 重依赖 mock + 动态 import(vi.mock 提升到顶部,import 在 test 内)
vi.mock('@/services/user', () => ({ queryCurrentUser: vi.fn() }))
test('未登录访问受保护页重定向 login 并保留 redirect', async () => {
queryCurrentUser.mockRejectedValueOnce({ /* 401 */ })
const { getInitialState } = await import('@/app')
const redirect = await getInitialState()
expect(redirect).toContain('/user/login?redirect=')
})
```
关键点:**纯函数穷举测试**(permission/access 这类零成本高回报)、**拦截器分支测试**(请求层是事故高发区,mock adapter 驱动各分支)。
### 3.3 覆盖要求
- coverage 阈值必须配置(如 lines 70 / statements 70 / functions 60 / branches 70),排除生成代码
- E2E 至少覆盖:登录主线、一条 CRUD 主线、模块开关生效(若为底座)
- vben 是组件测试深度的标杆:连 Modal 的 keep-alive 场景都有单测
---
## 四、性能优化清单
| 手段 | 做法 | 出处 |
|------|------|------|
| 路由懒加载 | 全部页面 `() => import()`;示例层可构建期 tree-shake | 通行 |
| 首屏占位 | index.html 注入 loading HTML(#root 空时先渲染骨架) | adp |
| 路由预取 | 菜单 hover prefetch / vite `server.warmup` 预热核心目录 | adp/vben/pure |
| 图表懒加载 | Suspense/条件 import 包裹 ECharts 区块 | 通行 |
| chunk 失效恢复 | ChunkLoadError → 提示/自动 reload 一次(发版后旧 hash 404) | adp + 路由 onError |
| 请求防抖取消 | 列表页「取消 + 版本号」(见 03 章) | 本标准 |
| 大日期库治理 | moment→dayjs / 按需 locale | adp |
| WebSocket | 卸载断开 + 重连指数退避 + 心跳 | 监控类页面 |
| 长列表 | 虚拟滚动(日志类页面 >1000 行时) | 按需 |
**度量先行**:CI 加 `rollup-plugin-visualizer` 产物报告,chunk 超阈值(如 500KB)报警。
---
## 五、双端一致性规范(迁移期)
### 5.1 契约清单(迁移开始前统一,不等迁移完成)
| 项 | 统一值(推荐) | 说明 |
|----|----------------|------|
| token key | `<app>.auth.token` | 迁移期「读旧写新」一个版本 |
| 暗色 key | `<app>.theme.mode` | 同上 |
| 模块开关键 | `<app>.modules.<name>.enabled` | 常量两端同名,注释指向后端 API |
| 会话存储结构 | 字段名一致 | localStorage 布局对齐 |
| 响应契约 | 同一份 code/data/msg + 错误分级 | 见 03 章 |
| 权限语义 | 权限码清单同源(单一文档维护) | 见 04 章 |
| 路由路径 | 两端一致 | 互通书签 |
| ESLint / Prettier | 同大版本、同规则 | 配置可复制 |
**执行方式**:契约清单进 `docs/`(一页表),双端各引用;改任何一项 = 双端 PR 同步。副端纪律:不抢先实现主端没有的功能;mock 显式开关、生产禁用;每对齐一个域删除对应 mock(向真实收敛)。
---
## 六、CI/CD
### 6.1 标准流水线(adp 范式裁剪)
```
PR 触发:
job lint eslint + prettier --check + vue-tsc/tsc
job test vitest --coverage(阈值卡点)
job build vite build
job e2e 主分支 PR:playwright(webServer 自动拉起前后端)
main 合并:
构建产物部署(嵌入后端二进制 / 静态托管)
```
CI 增强项:madge 循环依赖检查、styles 入口引用检查、产物体积报告。**CI 与本地命令同源**(CI 里跑 make 目标,不另写一套脚本)。
---
## 七、文档与可观测性
### 7.1 文档治理(制度)
| 文档 | 责任 |
|------|------|
| 新模块指南 | 新模块五列表(菜单/路由/页面/API/权限码)同步 |
| 信息架构文档 | 分组/菜单行为描述与实现同步 |
| 删除性重构 | 过期段落归档,不留「描述已删除组件」的漂移文档 |
| CHANGELOG | 只描述现存功能 |
**规则**:实现与文档不一致 = 文档 bug,同一 PR 修复。
### 7.2 版本可观测
- 设置页「关于」展示 `__APP_VERSION__` / commit hash / 构建时间(构建指纹注入)
- 全局错误上报预留钩子(gva `core/error-handel.js` 式),先 console.error 收口,接 Sentry 留接口
- 可选:页面版本更新提示(pure 的 version-rocket:新版本发布后提醒用户刷新)
---
## 八、最佳实践清单
✅ **要做**
1. pre-commit 快修复 + CI 慢检查的分层流水线
2. 请求层与权限纯函数优先补测试
3. 双端 vite 配置结构镜像;契约清单一页表维护
4. CI 与本地同源(make 目标)
5. 每次构建带版本指纹,关于页可见
❌ **不要**
1. 不要双份 e2e/eslint 配置文件并存(配置漂移源头)
2. 不要 CI 跑本地跑不通的命令(或反之)
3. 不要 console.log 进生产(尤其 token 片段)
4. 不要在 mock 数据里放真实用户名/密钥样式的假数据
5. 不要为「以后可能用到」保留死配置、死样式、空目录
---
## 九、AI 检查清单(对比目标工程逐项核对)
**P0(安全与一致性)**
1. 生产路径上是否有 console.log(尤其 token 片段打印)?
2. 双端(若有)token/主题 key 是否同名?配置文件是否双份并存?
3. mock 是否显式开关(是否存在网络错误静默回退假数据)?
**P1(质量基建)**
4. lint 流水线是否分层(pre-commit 快修 + CI 慢查)?stylelint/commitlint 是否齐配?
5. 请求层与权限纯函数是否有测试?coverage 阈值是否配置?
6. CI 是否有循环依赖检查、产物体积报告?
7. 构建指纹是否注入并在关于页展示?
**P2(体验与治理)**
8. 文档漂移清单(描述已不存在实现的文档段落)列出并归档
9. 运行时配置(VITE_GLOB / platform-config.json)是否值得引入
10. 错误上报钩子是否预留
---
> 至此八章完毕。回到 [00-目录与概览](00-目录与概览.md) 复查十大黄金法则,或按各章「AI 检查清单」逐项对比目标工程输出优化任务。