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:
1 parent
327e282356
commit
f9c6c1d826
204 files changed
+14107
-9103
No files matched your search
@@ -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 检查清单」逐项对比目标工程输出优化任务。
|
||||
Reference in new issue
Block a user