mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-10-14 13:06:51 +00:00
docs(audit): 全栈审计报告(7 份分报告 + 总报告)
由 7 名成员并行审计 + 队长线上实测复核产出,合计约 3,600 行: - backend/audit-backend.md 后端架构与代码质量(22 条) - backend/audit-security.md 认证/授权/敏感信息(23 条) - backend/audit-testing.md 测试与可验证性(含真实命令输出) - backend/audit-requirements.md 需求覆盖对照(多租户规格错位) - frontend/audit-frontend.md 前端与移动端工程(32 条) - frontend/audit-uxui.md 交互与视觉一致性 - docker/audit-deploy.md 容器化与部署链路 - AUDIT-REPORT.md 总报告:止血项、修复路线、待决策事项、对既有结论的修正 各报告均标注级别、文件:行号、影响、建议修法,并单列未验证项与抽样边界。
This commit is contained in:
@@ -0,0 +1,457 @@
|
||||
# FastapiAdmin 前端摸底审计报告(frontend/web + frontend/app)
|
||||
|
||||
- 审计对象:`frontend/web`(Vue3 + Vite + TS + Element-Plus)、`frontend/app`(uni-app 移动端,Vue3 + wot-ui + alova)
|
||||
- 审计方式:**只读**静态审计 + 可用的静态检查实跑;后端仅作契约比对(只读)
|
||||
- 审计时间:本轮
|
||||
- 本轮未改任何仓库文件(唯一新增文件即本报告)
|
||||
|
||||
---
|
||||
|
||||
## 0. 结论摘要
|
||||
|
||||
两端类型检查与已有单测**全部通过**,但"绿灯"并不代表健康:Web 端的生产构建开关实际上**从未生效**,移动端的 **token 静默续期接口契约与后端不符**、**头像上传路径不存在**,且移动端与 Web 端存在 **49 个同名重复类型定义**,已经开始漂移(见 §3)。
|
||||
|
||||
| 优先级 | 问题 | 位置 | 级别 |
|
||||
|---|---|---|---|
|
||||
| 1 | `isProduction = mode === "prod"` 恒为 false → 生产构建不做压缩/移除 console/剔除 devtools | `web/vite.config.ts:28` | **P0 高** |
|
||||
| 2 | 移动端刷新令牌请求体与后端契约不符(后端要 JSON 字符串,移动端发对象) | `app/src/api/module_system/auth.ts:34` | **P0 高** |
|
||||
| 3 | 移动端头像上传路径 `/file/upload` 不存在(应为 `/common/file/upload`) | `app/src/subPages/module_system/profile/index.vue:26` | **P0 高** |
|
||||
| 4 | 富文本编辑器默认上传地址为 `//file/upload`(协议相对 URL + 路径缺 `/common`) | `web/src/components/forms/fa-wang-editor/index.vue:83` | **P0 高** |
|
||||
| 5 | 移动端生产环境 WebSocket 指向 `ws://localhost:5180/ws` → AI 对话生产不可用 | `app/.env.production:23` | **P0 高** |
|
||||
| 6 | 所有页面 `import.meta.glob(..., {eager:true})` → 首屏加载全量页面 | `web/src/router/route-loader.ts:24` | P1 高 |
|
||||
| 7 | 移动端开发环境直连生产后端 | `app/.env.development:6` | P1 高 |
|
||||
| 8 | 登录页硬编码 3 个演示账号(密码均 `123456`)并支持一键填充 | `web/src/views/module_system/auth/login/index.vue:333` | P1 高 |
|
||||
| 9 | 动态路由"去重"守卫是死代码;匿名路由无法被清理 | `web/src/router/route-loader.ts:326,336` | P1 高 |
|
||||
| 10 | `web/src/types/auto-imports.d.ts` 被 gitignore 且是 type-check 的前提 → 全新 clone 无法通过 type-check(实测 1358 error) | `web/.gitignore:12` | P1 高 |
|
||||
| 11 | 守卫里的"路由修复"函数只打日志,不修复 | `web/src/router/guards.ts:207` | P2 中 |
|
||||
| 12 | 生产仪表盘直接展示 mock 数据(真实接口调用被注释掉) | `web/src/views/dashboard/home/index.vue:165` | P2 中 |
|
||||
| 13 | iframe 路由无 `sandbox`,可加载任意外部 URL | `web/src/router/routes.ts:170` | P2 中 |
|
||||
| 14 | TS `any` 187 处 / 65 文件,且 ESLint 显式关闭 `no-explicit-any` | `web/eslint.config.mjs:68` | P2 中 |
|
||||
| 15 | 移动端路由守卫只判断登录,无任何权限/角色门禁(后台已鉴权,纯 UX/暴露面问题) | `app/src/router/index.ts:30` | P2 中 |
|
||||
| 16 | 两端口径不一致:屏幕锁"加密密钥"随 `.env` 提交仓库 | `web/.env:25` | P2 中 |
|
||||
| 17 | 移动端 no type-check in build / 无任何测试 | `app/package.json:31` | P2 中 |
|
||||
| 18 | 应用商店合规:安卓权限申请过宽 + 微信 appid 未替换 + `urlCheck:false` | `app/manifest.config.ts:36,68,70` | P2 中 |
|
||||
| 19 | 下载工具另建 axios 实例,绕过全部拦截器 | `web/src/utils/download/index.ts:23` | P3 低 |
|
||||
| 20 | 4 个空目录、1 个死组件、`VITE_DROP_CONSOLE` 死配置、死导出函数 | 见 WEB-02 / WEB-14 | P3 低 |
|
||||
|
||||
> **状态更新(t9)**:表中 **1-5 条 P0 已修复**,逐条的文件:行号、依据与验证证据见 **§6 P0 修复记录**。其余条目仍未处理。
|
||||
|
||||
---
|
||||
|
||||
## 1. 环境与验证记录(可复现)
|
||||
|
||||
| 检查 | 命令 | 结果 |
|
||||
|---|---|---|
|
||||
| 工具链 | `pnpm -v` / `node -v` | pnpm 10.34.5 / node v24.17.0(均可用,`node_modules` 已存在) |
|
||||
| Web 类型检查 | `cd frontend/web && pnpm run type-check` | **exit 0,无输出**(`vue-tsc --noEmit`) |
|
||||
| App 类型检查 | `cd frontend/app && pnpm run type-check` | **exit 0,无输出** |
|
||||
| Web 单测 | `cd frontend/web && pnpm run test` | **exit 0**,2 个文件 / **10 个用例全通过**(1.59s) |
|
||||
| 生成文件缺失对照实验 | 拷贝 `src` 到 `/tmp`,删除被 gitignore 的 `auto-imports.d.ts`/`components.d.ts` 后跑 `vue-tsc` | **exit 2,1358 个错误**(1233 × TS2304 "Cannot find name");补回两文件后 **exit 0** |
|
||||
| 契约比对 | 读取 `backend/app/modules/system/auth/controller.py`、`backend/app/api/v1/routers.py`、`backend/app/modules/*/**/controller.py` | 见 §3(只读,未修改后端) |
|
||||
| axios 对象→FormData 行为 | 读取 `web/node_modules/axios/lib/defaults/index.js:47-110`(axios 1.16.1) | 确认 `Content-Type: multipart/form-data` + 对象体 → **axios 会转换为 FormData**,故 Web 登录**不是**缺陷(避免误报) |
|
||||
|
||||
未执行(本轮约束):`pnpm build`(两端)、`lint`/`stylelint`(默认带 `--fix`,会写文件)、任何 dev/preview server、依赖安装、后端相关命令;`frontend/app` 无测试可跑(仓库内 0 个测试文件)。
|
||||
|
||||
> 说明:两端 `tsconfig` 均为 `strict: true`(Web 另有 `noUncheckedIndexedAccess: true`),因此"类型检查通过"本身是有含金量的结论;但 Web 端类型检查依赖**未纳入版本控制的生成声明文件**(见 WEB-11),可复现性存疑。
|
||||
|
||||
---
|
||||
|
||||
## 2. 问题清单
|
||||
|
||||
级别定义:**P0 高** = 生产功能不可用/构建产物异常;**P1 高** = 明显缺陷或可复现的工程风险;**P2 中** = 显著质量/安全问题;**P3 低** = 清理项。
|
||||
|
||||
### 2.1 Web 构建与配置
|
||||
|
||||
#### WEB-01【P0 高】生产构建开关恒不生效:不压缩、不删 console、打包 devtools
|
||||
- 文件:`frontend/web/vite.config.ts:28`(`const isProduction = mode === "prod";`),消费点 `:66`(`minify`)、`:220-238`(gzip/brotli)、`:240`(`vueDevTools()`)
|
||||
- 现象:`isProduction` 只在 `mode === "prod"` 时为真。但 `package.json:12` 的 `build` 是 `vue-tsc --noEmit && vite build`(mode 默认 `production`),`package.json:13` 的 `build:prod` 是 `vite build --mode production`。仓库中**不存在** `.env.prod` 或任何 `--mode prod` 的脚本 → `isProduction` 恒为 `false`。
|
||||
- 影响:
|
||||
1. `minify: false` → 生产 JS 完全不压缩(体积成倍);
|
||||
2. `terserOptions.compress.drop_console/drop_debugger` 不生效 → 源码里 175 处 `console.*` 全部进入生产包;
|
||||
3. gzip/brotli 预压缩产物不生成(依赖 nginx `gzip_static` 的部署方式失效);
|
||||
4. `vite-plugin-vue-devtools` 被编进生产包(作者本意只在非生产启用),既是体积问题也是信息暴露面。
|
||||
- 建议修法:把判据改为 `const isProduction = mode === "production" || mode === "prod";`(或 `import.meta.env.PROD`),并补一个 `--mode prod` 的 `.env.prod`;同时补 CI 断言:构建后校验 `dist/js` 存在已压缩文件且产物内无 `console.log`。
|
||||
|
||||
#### WEB-02【P3 低】`VITE_DROP_CONSOLE` 是死配置
|
||||
- 文件:`frontend/web/.env.development:17`、`.env.production:13`、`src/env.d.ts:48`(声明);全仓无任何读取处(`grep VITE_DROP_CONSOLE` 仅命中声明)
|
||||
- 现象:文档描述"是否删除控制台输出",实际由 `vite.config.ts` 的 `terserOptions` 控制,且因 WEB-01 也未生效。
|
||||
- 影响:配置项误导后续维护者,会以为 console 已被剥离。
|
||||
- 建议修法:删除该变量;或改为在 `vite.config.ts` 中真正读取 `env.VITE_DROP_CONSOLE` 驱动 `drop_console`。
|
||||
|
||||
#### WEB-03【P3 低】`chunkSizeWarningLimit: 4000` 掩盖分包问题
|
||||
- 文件:`frontend/web/vite.config.ts:65`
|
||||
- 现象:把 Rollup 体积告警阈值抬到 4000KB,而不是解决单 chunk 过大。
|
||||
- 影响:体积回归不会被构建日志提示(配合 WEB-06 的 eager glob,风险叠加)。
|
||||
- 建议修法:回落到默认 500KB,改用真实分包(见 WEB-06)消化告警。
|
||||
|
||||
#### WEB-04【P3 低】`serve:prod` 无代理配置,preview 下 `/api/v1` 必然 404
|
||||
- 文件:`frontend/web/package.json:15`(`vite preview --mode production`)、`vite.config.ts:35-46`(只有 `server.proxy`,没有 `preview.proxy`)
|
||||
- 现象:生产 `VITE_APP_BASE_API=/api/v1` 是相对路径(`web/.env.production`),依赖同源反向代理;而 `vite preview` 不读 `server.proxy`。
|
||||
- 影响:本地验收生产产物时接口全 404,容易被误判为"接口挂了"。
|
||||
- 建议修法:补 `preview.proxy` 与 `server.proxy` 同一份配置(抽常量复用),或在 README 明确 preview 需配合 nginx/docker 代理。
|
||||
|
||||
### 2.2 Web 路由与权限
|
||||
|
||||
#### WEB-05【P1 高】动态路由"去重"守卫是死代码,重复注册无法拦截
|
||||
- 文件:`frontend/web/src/router/route-loader.ts:326`(`if (this.router.hasRoute(registrationName(menu, index).trim())) return;`)、`:376-379`(`registrationName` 定义)、`:334`(实际以 `routeRecord.name` 注册)
|
||||
- 现象:`registrationName()` 返回 `Dyn_${index}_${name}`,但注册时用的名字来自 `RouteTransformer`(`route.name || firstSegment`)。全仓 `registrationName` 只有"定义 + 这次判断"两处引用 → `hasRoute()` **永远为 false**,去重逻辑失效。注释"去重:已注册过该 path 第一段则跳过"与实现不符(`firstSegment` 变量只用于壳层判断,未参与去重)。
|
||||
- 影响:`guards.ts:157` 与 `refresh.ts:46` 每次都会 `new RouteRegistry(router)`,实例级 `registered` 标志无法跨实例生效;配合 WEB-06(匿名路由未被清理,见下)在"菜单刷新/重新登录"路径上可能重复 `addRoute`,出现同名路由告警、路由被后注册项覆盖、工作标签栏指向失效路由。
|
||||
- 建议修法:`register()` 内改用 `router.hasRoute(String(routeRecord.name))` 做真实判断(或按 `path` 首段集合判重);把 `RouteRegistry` 做成单例或复用 `menuStore` 中保存的清理函数。
|
||||
|
||||
#### WEB-06【P1 高】匿名动态路由不会被清理,菜单刷新后残留
|
||||
- 文件:`frontend/web/src/router/route-loader.ts:336-341`(清理闭包 `if (name && this.router.hasRoute(name))`)、`frontend/web/src/router/MenuProcessor.ts:50`(`const name = item.route_name || undefined;`)
|
||||
- 现象:后端菜单节点可以不配 `route_name` → 转换出的路由 `name === undefined` → 清理闭包直接跳过,`removeRoute` 永不执行,而路由确实被 `addRoute` 了。
|
||||
- 影响:`refreshMenuAndRoutes()`(`refresh.ts:39`)先删后注册,未删掉的路由留下"幽灵页面":菜单里已不可见,但直接输入 URL 仍能打开(权限收紧后尤其危险)。
|
||||
- 建议修法:注册时保证每条动态路由都有确定性 name(如 `dyn_${index}_${path}`)并以此 name 记录清理函数;或在清理阶段按 `path` 前缀遍历 `router.getRoutes()` 移除。
|
||||
|
||||
#### WEB-07【P2 中】壳层路径白名单在两处不一致
|
||||
- 文件:`frontend/web/src/router/guards.ts:249`(`SHELL_SEGMENTS = new Set(["home","dashboard","fastlink"])`) vs `frontend/web/src/router/route-loader.ts:371`(`["home","profile","changelog","dashboard"]`)
|
||||
- 现象:路由注册器认为 `profile`/`changelog` 是壳层(跳过注册),权限校验器认为 `fastlink` 是壳层(无条件放行)。两份手工维护的清单已经不同步。
|
||||
- 影响:若后端菜单出现一级 `profile`,会出现"路由不注册 + 权限校验也不认"→ 用户被静默重定向到首页;`fastlink` 整段(含 `fachat` AI 聊天壳)对所有登录用户无条件放行。
|
||||
- 建议修法:抽出单一常量(例如 `router/shell-segments.ts`)供两处 import;壳层放行改为基于实际已注册的静态路由表判断,而非硬编码字符串集合。
|
||||
|
||||
#### WEB-08【P2 中】守卫中的"异常恢复"是空实现
|
||||
- 文件:`frontend/web/src/router/guards.ts:207-213`(`repairDynamicRoutesIfMenuEmpty` 只 `console.warn`)
|
||||
- 现象:函数名与调用意图(`:140`)都是"菜单空了但路由还在 → 修复",函数体只有一行 warn,不做任何反注册。
|
||||
- 影响:`RouteRegistry.unregister()` / `markAsRegistered()`(`route-loader.ts:347,365`)均无调用点,实际恢复路径依赖 `routeInitFailed` 之类的间接状态,问题被掩盖而非修复。
|
||||
- 建议修法:实现真正的修复(`resetDynamicRoutesSync()` 已存在,可直接调用)或删除该函数与调用点,避免"看起来有兜底"。
|
||||
|
||||
#### WEB-09【P2 中】iframe 路由无 `sandbox`,且 URL 来自菜单配置
|
||||
- 文件:`frontend/web/src/router/routes.ts:151-179`(`IframeView`),关键行 `:170-177`(`h("iframe", { src: iframeUrl.value, ... })`)
|
||||
- 现象:`src` 来自 `IframeRouteManager` / `route.meta.link`(后端菜单可配置任意 URL),渲染时未设置 `sandbox`、`referrerpolicy` 或白名单校验。
|
||||
- 影响:管理员/被篡改的菜单可让登录用户的内嵌 iframe 承载任意第三方页面,该页面与主站同窗口上下文(可发起顶层导航、读 referrer、诱导钓鱼);虽非同源脚本执行,仍属可被利用的信任边界缺失。
|
||||
- 建议修法:加 `sandbox="allow-scripts allow-same-origin allow-forms allow-popups"`(按需最小化)、`referrerpolicy="no-referrer"`,并对 `link` 做协议白名单(仅 http/https)校验。
|
||||
|
||||
#### WEB-10【P3 低】成功提示的过滤用子串匹配 `login`
|
||||
- 文件:`frontend/web/src/utils/http/index.ts:251-252`(`!response.config.url?.includes("login")`)
|
||||
- 现象:用 `includes("login")` 判断"登录/登出接口",但 `/system/log/login/list`(登录日志)也包含 `login`。
|
||||
- 影响:对登录日志的分页/导出等非 GET 请求,成功提示被静默吞掉,表现为"点了没反应"。
|
||||
- 建议修法:改为精确路径匹配(`url.endsWith("/auth/login")` / `/auth/logout`)或白名单数组。
|
||||
|
||||
### 2.3 Web 类型、规模与死代码
|
||||
|
||||
#### WEB-11【P1 高】类型检查依赖被 gitignore 的生成声明文件 → 全新 clone 无法通过 type-check
|
||||
- 文件:`frontend/web/.gitignore:12-13`(忽略 `src/types/auto-imports.d.ts`、`src/types/components.d.ts`)、生成方 `vite.config.ts:190,205`
|
||||
- 现象:`src/types/auto-imports.d.ts`、`src/types/components.d.ts` 未被 git 跟踪(`git ls-files` 无输出),仅由 `unplugin-auto-import` / `unplugin-vue-components` 在 **vite 进程**中生成。而 `type-check` = `vue-tsc --noEmit`,不会触发生成。
|
||||
- 实测证据:把 `src` 拷到 `/tmp/fa-audit-web`(软链 node_modules)后删掉这两个文件 → `vue-tsc --noEmit` **exit 2,1358 个错误(1233 个 TS2304)**;补回文件 → **exit 0**。本地之所以绿,是因为工作区里残留着上次构建生成的产物。
|
||||
- 影响:CI/新同事按 `pnpm type-check` 或 `pnpm build` 的顺序执行时,若未先跑过 vite,类型门禁直接失败(且报错信息是几百条"Cannot find name 'ref'",极难定位真因)。对照:`frontend/app/src/auto-imports.d.ts`、`components.d.ts` 是**已提交**的,两端策略不一致。
|
||||
- 建议修法:二选一并写进文档——(a) 把这两个 `.d.ts` 从 `.gitignore` 移出并提交(与 app 端一致);或 (b) 增加 `"type-check": "vite build --mode development --emptyOutDir=false ... && vue-tsc --noEmit"` 之类的"先生成后检查"脚本(或 `unplugin-auto-import` 的独立生成命令),并在 CI 中固定顺序。
|
||||
|
||||
#### WEB-12【P2 中】`any` 泛滥且被 lint 规则放行
|
||||
- 文件:`frontend/web/eslint.config.mjs:68`(`"@typescript-eslint/no-explicit-any": "off"`)
|
||||
- 现象:`grep` 统计 Web `src` 下 `: any` / `as any` / `<any>` / `any[]` 共 **187 处,分布在 65 个文件**(App 端仅 14 处)。典型:`route-loader.ts:35,44,56`(`load(): any`、`(mod as any).default`)、`vite.config.ts:141`(`assetInfo: any`)、`utils/download/index.ts:10,15`。
|
||||
- 影响:路由/组件加载这条最核心的链路完全无类型保护;`any` 会把 `undefined` 传下去(如 WEB-05 的 name 问题),且 `strict` 的价值被局部抵消。
|
||||
- 建议修法:规则改为 `"error"` 或至少 `"warn"`,用 `unknown` + 收窄替代;`ComponentLoader.load()` 返回 `Component`,`assetFileNames` 用 `PreRenderedAsset`。
|
||||
|
||||
#### WEB-13【P2 中】超大文件与单文件多职责
|
||||
- 文件(行数):
|
||||
- `web/src/views/fastlink/tutorial/index.vue` 1881
|
||||
- `web/src/views/module_task/storage/browse/index.vue` 1746
|
||||
- `web/src/views/module_system/menu/index.vue` 1319
|
||||
- `web/src/views/module_task/cronjob/job/index.vue` 1250
|
||||
- `web/src/views/module_generator/gencode/index.vue` 1224
|
||||
- `web/src/layouts/fa-work-tab/index.vue` 1083、`web/src/hooks/core/useTable.ts` 819、`web/src/hooks/core/useChart.ts` 773、`web/src/store/modules/worktab.store.ts` 670、`web/src/utils/table/index.ts` 606
|
||||
- 现象:Web `src` 非声明文件合计约 **80,022 行**(App 端约 9,259 行,最大单文件 716 行)。上述页面把表单、表格列、按钮权限、Dialg、样式、i18n 文案全部塞在一个 SFC 里。
|
||||
- 影响:改动冲突率高、无法按需懒加载、单测无法覆盖(整文件依赖构建期自动导入,见 `route-invariants.spec.ts` 的注释——测试只能靠 `vi.mock` 打桩)。
|
||||
- 建议修法:以 `fa-table` / `fa-form` 已有的组合式 API 为基线,把超大页面拆成 `modules/` 子组件 + 列定义文件 + `useXxxPage()`;`tutorial/index.vue` 是纯文档页,建议直接改为数据驱动(`manualSections.ts` 已是数据,模板可进一步收敛)。
|
||||
|
||||
#### WEB-14【P3 低】死代码与空目录
|
||||
- 死组件:`web/src/components/charts/fa-echarts/index.vue`(89 行)——除自身及生成的 `types/components.d.ts:139` 外无任何引用(组件卡片的图表走 `fa-line-chart` 等)。
|
||||
- 死导出:`web/src/utils/http/index.ts:140`(`showError`)、`:147`(`showSuccess`)、`:29`(`ExtendedRequestConfig`)、`:49`(`ErrorLogData`) 在 `utils/http` 之外 0 引用。
|
||||
- 空目录:`web/src/utils/oauth/`、`web/src/utils/socket/`、`web/src/layouts/fa-chat-window/`、`web/src/views/module_system/chat/`。
|
||||
- 过期 lint 忽略项:`web/eslint.config.mjs:30` 忽略 `src/utils/console.ts`,该文件不存在。
|
||||
- 建议修法:删除上述文件/目录/忽略项;对公共包(`utils/http`)保留的 API 需有调用点,否则一并删除。
|
||||
|
||||
### 2.4 Web 安全与数据真实性
|
||||
|
||||
#### WEB-15【P1 高】登录页硬编码 3 个演示账号(密码均 `123456`)且可一键回填
|
||||
- 文件:`frontend/web/src/views/module_system/auth/login/index.vue:333-355`(`accounts`:`super/admin/user`,`password: "123456"`)、`:474-479`(`setupAccount()` 把用户名+密码写进 `loginForm`)
|
||||
- 现象:登录页内置演示账号下拉/切换,选中即填充账号密码(`demoAccountKey` 默认 `"super"`)。
|
||||
- 影响:若这些账号在部署环境中真实存在(该项目的种子数据通常包含它们),等价于把后台最高权限账号密码印在登录页上;即便部署方改了密码,仍暴露了默认口令策略与用户名枚举。
|
||||
- 建议修法:改为构建期变量(`VITE_DEMO_ACCOUNTS`)+ 仅在 `import.meta.env.DEV` 或显式开关下渲染;生产构建彻底移除,并加一条构建期校验(产物中不得出现 `123456`)。
|
||||
|
||||
#### WEB-16【P2 中】屏幕锁"加密密钥"随 `.env` 提交仓库,锁形同虚设
|
||||
- 文件:`frontend/web/.env:25`(`VITE_LOCK_ENCRYPT_KEY = s3cur3k3y4adpro`,该文件**已被 git 跟踪**)、`frontend/web/.gitignore` 未忽略该文件、消费点 `frontend/web/src/layouts/fa-screen-lock/index.vue:168,363,393`(`CryptoJS.AES.decrypt(storedPassword, ENCRYPT_KEY)`)
|
||||
- 现象:锁屏密码用硬编码在仓库里的对称密钥(AES 口令模式)加密后存本地;`crypto-js` 的声明是 `declare module "crypto-js"`(`env.d.ts:77`)——**完全没有类型**。
|
||||
- 影响:任何拿到仓库的人都能解密锁屏口令;锁屏从"安全控制"退化为"防误触 UI"。同时 `.env` 里出现密钥会诱导把真正的密钥也放进来。
|
||||
- 建议修法:明确锁屏是 UI 层面的防误触,移除"AES 加密"叙事(改存不可逆的本地校验值,如 `hash(password+salt)`);若确需加密,密钥改为用户输入派生而非仓库常量;同时把 `crypto-js` 补上 `@types/crypto-js` 或删除依赖(当前仅此一处使用)。
|
||||
|
||||
#### WEB-17【P2 中】生产仪表盘展示 mock 数据
|
||||
- 文件:`frontend/web/src/views/dashboard/home/index.vue:165`(`import { getDashboardMock, ... } from "@/mock/dashboard"`)、`:176-177`(`healthList = ref(mock.health)`、`timelineData = ref(mock.timeline)`)、`:182-185`(`onMounted` 内真实接口 `DashboardAPI.getStats()` 被注释掉)
|
||||
- 现象:首页的时间线/健康列表硬编码为 mock 初值,真实统计接口调用被注释;`mock/dashboard.ts` 因此被真实打进产物(`api/module_monitor/dashboard.ts:3` 也从 mock 引类型)。
|
||||
- 影响:**线上展示编造数据**(用户会当成真实运维数据),并让 mock 模块无法被 tree-shake。
|
||||
- 建议修法:删除 `mock` 依赖,未接后端时改为骨架屏/空态;`HealthItem` 等类型迁到 `api/module_monitor/dashboard.ts`;若必须保留演示数据,用 `import.meta.env.DEV` 包住并动态 import。
|
||||
|
||||
### 2.5 移动端(frontend/app)
|
||||
|
||||
#### APP-01【P0 高】刷新令牌请求体与后端契约不符 → 静默续期必然失败
|
||||
- 文件:`frontend/app/src/api/module_system/auth.ts:34-39`(`refreshToken(body: RefreshToekenBody)`,发 `{ refresh_token }` 对象),调用点 `frontend/app/src/http/adapters/alova.ts:81-83`
|
||||
- 后端事实:`backend/app/modules/system/auth/controller.py:57` —— `payload: Annotated[str, Body(description="刷新token参数")]`,即 **body 必须是 JSON 字符串**(Web 端实现正确:`web/src/api/module_system/auth.ts:25-31` 直接发 `data: refreshToken`)。
|
||||
- 现象:移动端发 `{"refresh_token":"..."}`,FastAPI 对 `str` 的 body 解析会返回 422(`Input should be a valid string`)。
|
||||
- 影响:**access token 过期后无法续期**,`handleUnauthorized`(`alova.ts:60-102`)每次都会走到 `catch` → 清凭据 + 跳登录页。用户表现为"用一会儿被强制登出",而 Web 端无此问题。同时 `RefreshToekenBody` 是多端漂移的产物(Web 端没有这个类型)。
|
||||
- 建议修法:改为 `http.Post(url, JSON.stringify(refreshToken), { meta: {...} })`(与 `logout(token)` 的写法一致,见 `auth.ts:58-60` 的注释——同一后端约定),删除 `RefreshToekenBody`,并补一条契约测试。
|
||||
- 复现建议:`curl -s -X POST .../api/v1/system/auth/token/refresh -H 'Content-Type: application/json' -d '{"refresh_token":"x"}'` → 422;`-d '"x"'` → 进入业务逻辑。**(本轮未发请求,仅静态比对;如需 100% 确认请由后端同学执行上述 curl)**
|
||||
|
||||
#### APP-02【P0 高】头像上传路径不存在(缺 `/common` 前缀)
|
||||
- 文件(**活代码**):`frontend/app/src/subPages/module_system/profile/index.vue:26`
|
||||
```ts
|
||||
const uploadAvatarAction = `${import.meta.env.VITE_API_BASE_URL || ''}${import.meta.env.VITE_APP_BASE_API || ''}/file/upload?upload_type=avatar`
|
||||
```
|
||||
该地址交给 `wd-upload` 使用(模板 `:147`),成功后回调 `handleAvatarSuccess`(`:82-97`)。
|
||||
- 文件(**同缺陷的死代码**):`frontend/app/src/api/module_system/user.ts:27-29`(`http.Post('/file/upload?upload_type=avatar', ...)`)——全仓无调用点(`grep uploadCurrentUserAvatar` 仅命中定义本身),与 Web 端同名方法(`web/src/api/module_system/user.ts:15-19`,被 `views/fastlink/current/profile.vue:489,630` 使用)形成"有实现但没人用"的分叉。
|
||||
- 后端事实:`backend/app/modules/common/file/controller.py:15`(`prefix="/file"`)挂在 `backend/app/api/v1/routers.py:63` 的 `"/common"` 域下 → 真实路径 `/api/v1/common/file/upload`;Web 端写法正确(`web/src/api/module_system/user.ts:17`:`/common/file/upload?upload_type=avatar`)。
|
||||
- 现象:真实请求变成 `https://service.fastapiadmin.com/api/v1/file/upload?upload_type=avatar`(dev/prod 的 `VITE_API_BASE_URL` 都是该域名),后端无此路由。
|
||||
- 影响:移动端个人中心**更换头像必然失败**(404),失败提示是通用文案(`profile.uploadFailed`),用户无法自查原因。
|
||||
- 附带问题(同一处代码):该页**绕过了统一 `http` 层**——自己拼 URL(`:26`)、自己拼 `Authorization` 头(`:27`)、自己在回调里 `JSON.parse`(`:83-84`)。因此上传请求不享受 401 静默续期(`http/adapters/alova.ts:60-102`)与统一错误提示;且 `login_type`/`VITE_APP_BASE_API` 这类拼接散落在页面里,与 API 模块重复(对照 DUP-03 的 Web 端同类问题)。
|
||||
- 建议修法:
|
||||
1. 路径改为 `/common/file/upload?upload_type=avatar`;
|
||||
2. 上传能力收敛进 `api/module_system/user.ts`,页面只调用它;`wd-upload` 若要传 action,可从同一常量派生;
|
||||
3. 把"上传端点"登记进两端共享常量表(与 APP-01 一并治理),杜绝再次手抄。
|
||||
|
||||
#### APP-03【P0 高】生产环境 WebSocket 指向 localhost 且非加密
|
||||
- 文件:`frontend/app/.env.production:23`(`VITE_APP_WS_ENDPOINT= ws://localhost:5180/ws`)、消费点 `frontend/app/src/composables/useAiChat.ts:18-25`
|
||||
- 现象:`buildChatWsUrl()` 拼出 `ws://localhost:5180/ws/api/v1/ai/chat/ws?token=...`(`wsBase + apiPrefix + path`)。该地址只在"同时在本机 5180 起了一个 dev server"时才有意义。
|
||||
- 影响:生产/真机上的 AI 流式对话必然连不上(小程序端还会因 `ws://` 非加密域名被拒/被平台拦截);同一文件 `:24` 的注释还写着"不配置默认关闭 WebSocket",与"生产给了个 localhost 值"自相矛盾。
|
||||
- 建议修法:生产改为 `wss://<后端域名>`(或留空走 `VITE_API_BASE_URL` 的 `http→ws` 推导分支),并删除 `/ws` 这类路径残渣;在 `manifest`/CI 增加"生产 env 不得含 localhost"的校验。
|
||||
|
||||
#### APP-04【P1 高】开发环境直连生产后端
|
||||
- 文件:`frontend/app/.env.development:5-6`(第 5 行是注释掉的 `http://localhost:8001`,第 6 行 `VITE_API_BASE_URL=https://service.fastapiadmin.com`),该文件**已被 git 跟踪**(`git ls-files frontend/app/.env.development`)
|
||||
- 现象:`pnpm dev` 默认把所有请求打到线上 `service.fastapiadmin.com`。对比 Web 端 `frontend/web/.env.development`(**未被跟踪**)指向 `http://127.0.0.1:8001` —— 两端策略相反。
|
||||
- 影响:本地调试/联调会直接读写生产库(新建工单、改公告、删数据都是真实操作);本地登录会占用线上会话(同账号互踢);新人 clone 后第一次 `pnpm dev` 就踩坑。
|
||||
- 建议修法:`.env.development` 默认指向本地,线上地址放进不跟踪的 `.env.development.local`;与 Web 端对齐"env 文件不入库、只提交 `.example`"的策略(Web 已有 `.env.*.example` 范式)。
|
||||
|
||||
#### APP-05【P1 高】`wot-ui` 路由守卫只判断登录,无任何权限门禁
|
||||
- 文件:`frontend/app/src/router/index.ts:30-47`(只判断 `isAuthPage` 与 `userStore.isLoggedIn()`);`frontend/app/src/constants/storage.constant.ts:28`(`ROLE_ROOT = 'ADMIN'` 定义后**无任何使用点**)
|
||||
- 现象:登录后任何用户都能进入 `subPages/module_ai/ai-models/index.vue`(AI 模型配置,可增删改含 `api_key` 的配置)、工单管理、公告管理等页面;页面本身也不做 `is_superuser`/`roles` 判断(全仓仅 `profile/index.vue:65` 把 roles 当展示字段)。
|
||||
- 影响:**不构成越权**(已核对后端:`backend/app/modules/ai/chat/controller.py` 的 `/model` 各写接口均有 `Security(AuthPermission(["module_ai:chat:update"]))`),但是:(a) 普通用户会看到并进入只为管理员准备的界面,操作后拿到 403 提示,体验混乱;(b) 前端完全失去"能力可见性"这一层,菜单/入口无法按角色裁剪;(c) `ROLE_ROOT` 这类死常量会误导后续开发以为已有角色体系。
|
||||
- 建议修法:登录后拉取一次权限集合(后端已有 `AuthPermission` 的权限码体系),在守卫/页面入口按权限码过滤路由与入口按钮;或复现 Web 端的"后端菜单驱动"思路(移动端用白名单路由表 + `roles` 过滤)。至少先把 `ROLE_ROOT` 删除或落地使用。
|
||||
|
||||
#### APP-06【P2 中】移动端无类型检查门禁、无任何测试
|
||||
- 文件:`frontend/app/package.json:31`(`"build": "uni build"`,不跑 `vue-tsc`)、`:50`(`type-check` 脚本存在但无人调用)、仓库内 0 个 `*.spec.ts`/`*.test.ts`
|
||||
- 现象:App 端 `type-check` 是可用的(本轮实测 exit 0),但 `build`/CI 不调用;上传 `auto-imports.d.ts`、`components.d.ts`(已提交)说明生成文件是入库的,检查成本已很低。
|
||||
- 影响:类型错误不会阻断构建;核心逻辑(alova 401 队列、token 存储、路由守卫)零回归保护——APP-01/APP-02 这类契约破损正是因为缺测试才漏到线上。
|
||||
- 建议修法:`build` 改成 `vue-tsc --noEmit && uni build`;为 `http/adapters/alova.ts` 的 401 队列、`useAiChat`、`storage.ts` 补最小单测(vitest 已在 Web 端跑通,配置可复用)。
|
||||
|
||||
#### APP-07【P2 中】应用商店合规与产品化问题
|
||||
- 文件:`frontend/app/manifest.config.ts:13`(`name: 'wot-starter'`)、`:14`(`appid: '__UNI__1208592'`,模板自带)、`:36-52`(安卓权限列表)、`:68`(`appid: 'wxf306aa91619d2e1c'`,第 67 行 TODO 明确写着"替换为实际的微信小程序 appid")、`:70`(`setting.urlCheck: false`)
|
||||
- 现象:
|
||||
1. 应用名/DCloud appid 仍是 starter 模板值(页面标题却已是 FastapiAdmin)→ 名字与包标识不一致;
|
||||
2. 申请了 `READ_LOGS`、`READ_PHONE_STATE`、`GET_ACCOUNTS`、`WRITE_SETTINGS`、`MOUNT_UNMOUNT_FILESYSTEMS` 等与业务无关的高敏权限;
|
||||
3. 真实的微信小程序 appid 被提交进仓库,且"待替换"的注释长期未清理;
|
||||
4. `urlCheck: false` 关闭了微信开发者工具的域名校验(仅影响开发者工具,但常被误当配置带进发包流程)。
|
||||
- 影响:应用商店(尤其国内安卓渠道/App Store)上架会被权限最小化审核驳回或要求补隐私说明;模板残留直接影响品牌与合规材料;appid 泄露本身风险低(非密钥),但 TODO 长期挂着说明发布流程无检查。
|
||||
- 建议修法:替换 `name`/`appid`/`versionName`;按实际功能裁剪安卓权限(相机/相册/网络/震动通常足够);appid 改为构建期注入(或至少移除 TODO 并确认是公开值);`urlCheck` 收回到仅调试配置。
|
||||
|
||||
#### APP-08【P3 低】token 走 URL query(可缓解,但需明确)
|
||||
- 文件:`frontend/app/src/composables/useAiChat.ts:24-25`(`.../ai/chat/ws?token=${encodeURIComponent(token)}`)
|
||||
- 现象:为兼容小程序 WebSocket 无法自定义子协议,把 access token 放在 query。
|
||||
- 影响:token 会进入网关/反代访问日志、`Referer` 类日志与错误上报;与 Web 端做法不同(Web 用 `Sec-WebSocket-Protocol`,见 `web/src/views/module_ai/chat/index.vue:99-104`,注释明确写着"避免出现在 URL 与服务端 access log 中")。
|
||||
- 建议修法:服务端支持"连接后首帧鉴权"(先 `{action:"auth",token}` 再收发业务帧)或签发一次性短时效 ws ticket,取代长时效 token 入 query;至少在文档中标注该风险并缩短 token 有效期。
|
||||
|
||||
#### APP-09【P3 低】存储读取把 falsy 值当作"不存在"
|
||||
- 文件:`frontend/app/src/utils/storage.ts:13-17`(`const value = uni.getStorageSync(key); if (!value) return defaultValue as T`)
|
||||
- 现象:`""`、`0`、`false` 被一律视为未设置;且 `JSON.parse` 失败时返回原始字符串(`:21-24`),造成同一 key 可能返回两种类型。`get()` 泛型 `T` 也因此不成立。
|
||||
- 影响:布尔/数字型配置(如 `THEME_KEY`、`WATERMARK_KEY`)无法存 `false`/`0`;类型欺骗会让调用方以为拿到的是 T。
|
||||
- 建议修法:用 `uni.getStorageInfoSync().keys.includes(key)` 或 `value === undefined || value === ''` 判存在;解析失败时记录告警并返回 `defaultValue`。
|
||||
|
||||
#### APP-10【P3 低】生产包保留逐次导航日志
|
||||
- 文件:`frontend/app/src/router/index.ts:50-54`(`afterEach` 中 `console.log("📍 页面切换: ...")`)、`frontend/app/eslint.config.mjs:7`(`'no-console': 'off'`)
|
||||
- 现象:每次页面切换都打日志,且 lint 允许 console。
|
||||
- 影响:小程序/App 生产环境日志噪音(微信小程序还有日志体积与性能开销)。
|
||||
- 建议修法:日志收敛到 `utils/console` 并受 `VITE_APP_ENV` 控制;lint 改回 `no-console: warn` 并对 `console.error/warn` 放行。
|
||||
|
||||
#### APP-11【P3 低】响应类型定义过于宽松 + `msg`/`message` 双字段兼容
|
||||
- 文件:`frontend/app/src/http/types.ts:19-27`(`IResponse` 同时有 `msg`、`message?`、`[key: string]: any`)、`:33`(`PageParams` 也是 `[key:string]: any`)、`frontend/app/src/http/adapters/alova.ts:18-20`(`getErrorMessage` 依次尝试 `msg/message/error`)
|
||||
- 现象:后端统一响应只是 `code/data/msg`(见 `backend/app/common/response.py` 的 `ResponseSchema`),移动端却按"兼容三套字段"实现。
|
||||
- 影响:契约被"宽容"掩盖——真正的字段改名不会报错而会静默降级为通用提示(APP-01/APP-02 的失败提示就被吞成"请求错误"),排查成本高。
|
||||
- 建议修法:`IResponse<T>` 收敛为 `{ code: number; data: T; msg: string }`,删除索引签名与 `message?`;错误提示保留 `msg` 单一路径。
|
||||
|
||||
### 2.6 两端重复与可复用性
|
||||
|
||||
#### DUP-01【P2 中】49 个同名类型在两端各有一份副本,且已发生漂移
|
||||
- 证据:抽取两端 `api/`+`types/` 的 `interface/type` 名做交集 → **49 个重名**,含 `LoginFormData`、`LoginResult`、`MenuTable`、`UserInfo`、`roleSelectorType`、`positionSelectorType`、`deptTreeType`、`NoticeForm`、`TicketForm`、`BaseFormType`、`ApiResponse`、`CaptchaInfo`、`PageResult`、`DashboardStats` 等(Web 侧 222 个类型 / App 侧 84 个)。
|
||||
- 现象:App 端每个 api 文件顶部注释都写着"与 web 端 module_system/xxx.ts 对齐(完整字段定义)"——即**靠人工同步**。
|
||||
- 影响:漂移已经真实发生:`refresh_token` 请求体(APP-01)、上传路径(APP-02);此外 App 端命名也不同(`LoginResult` vs Web 的 `JWTOut`/`LoginResult` 别名、`RefreshToekenBody` 拼写错误 `Toeken`)。
|
||||
- 建议修法:在后端 OpenAPI 已存在的前提下,用 `openapi-ts-request`(App 依赖里已有 `@alova/wormhole`,`package.json` 也有 `alova-gen` 脚本)为两端生成共享类型/schema 包;或在 `frontend/shared/` 建一个 workspace 包存放纯类型,两端 `dependencies` 引用。
|
||||
|
||||
#### DUP-02【P2 中】401 静默续期逻辑在两端各写一遍,实现不一致
|
||||
- 文件:`frontend/web/src/utils/http/index.ts:157-182,307-358`(axios 版:`isRefreshing` + `pendingRequests` 重放) vs `frontend/app/src/http/adapters/alova.ts:31-102`(alova 版:同样语义但用 `Method.send()` 重放)
|
||||
- 现象:两段"防并发刷新 + 队列重放"逻辑语义相同、代码不同,且只有 Web 版正确(APP-01)。错误映射也是两套:Web `getErrorMessage`(`utils/http/index.ts:97-111`,i18n key)vs App `getErrorMessage`(`adapters/alova.ts:18-20`,中文字面量 `ShowMessage`)。
|
||||
- 影响:修一处漏一处(本次审计已坐实);错误文案在中英文/两端之间不一致。
|
||||
- 建议修法:把这套状态机抽成不依赖具体 HTTP 客户端的纯函数模块(注入 `sendRefresh()` 与 `replay(method)`),两端各自适配;错误码→文案映射收敛为一份 key 表。
|
||||
|
||||
#### DUP-03【P3 低】Web 内部两套 HTTP 通道
|
||||
- 文件:`frontend/web/src/utils/http/index.ts:186`(统一 `request` 实例) vs `frontend/web/src/utils/download/index.ts:23`(另建 `axios.post` 直连)
|
||||
- 现象:下载工具自己拼 `baseURL + "/common/file/download"`、自己加 `Authorization`、自己弹 `ElMessage`。
|
||||
- 影响:**绕过响应拦截器** → 401 不会触发续期(下载接口失效时表现为"下载失败"而非重新登录)、错误码不会归一、`Content-Type` 头不一致;同时 `let downloadLoadingInstance: any`(`:10`)又是一处 `any`。
|
||||
- 建议修法:改用统一 `request`(`responseType: "blob"`,拦截器已支持 `blob` 直通与 blob 错误体解析,见 `utils/http/index.ts:235-237,288-305`),只保留 `saveAs` 部分。
|
||||
|
||||
#### DUP-04【P2 中】两端各有一套"菜单/路由"模型,App 端完全没有权限菜单
|
||||
- 文件:Web `frontend/web/src/router/MenuProcessor.ts:36-95`(后端菜单 → `AppRouteRecord`,含目录/按钮过滤、`keep_alive`、`hidden` 映射) vs App `frontend/app/src/router/index.ts:6-25`(仅把 `virtual:uni-pages` 的静态页面列表铺平)
|
||||
- 现象:同一份后端菜单数据(`MenuTable`)只有 Web 端消费;App 端页面清单是编译期静态生成的(`pages.json`),与后端权限体系无关。
|
||||
- 影响:新增/下线功能需要改代码发版,无法像 Web 端那样按租户/角色动态下发;两端"同一功能不同入口"的维护成本持续上升。
|
||||
- 建议修法:明确 App 端的产品边界(若本就是固定几屏,保留静态页面即可,但应删掉 App 端无用的 `MenuTable`/`permission`/`route_name` 字段与死常量 `ROLE_ROOT`,减少误导);若要与后端菜单对齐,则复用 Web 端的菜单→页面的映射思路,只保留"权限码 → 页面白名单"这一薄层。
|
||||
|
||||
#### DUP-05【P3 低】Web 单测耦合后端种子数据
|
||||
- 文件:`frontend/web/src/__tests__/route-invariants.spec.ts:113-117`(从 `process.cwd()` 向上找 `backend/sql/sys_menu.json` 并断言其内容)
|
||||
- 现象:前端用例读取后端 SQL 种子文件,路径靠三个候选硬猜。
|
||||
- 影响:前端独立仓库/CI(不带 backend 目录)时该用例失败;菜单数据迁移目录即红。当前工作区能过(本轮 10/10 通过)。
|
||||
- 建议修法:把需要的菜单夹具快照进前端 `src/__tests__/fixtures/`,或把该用例移到后端/集成层。
|
||||
|
||||
---
|
||||
|
||||
## 3. 移动端与 Web 端的接口契约一致性(专项结论)
|
||||
|
||||
**结论:整体高度对齐,但存在 2 处已破损的硬契约 + 3 处口径不一致。**
|
||||
|
||||
### 3.1 已核对一致的端点(抽样)
|
||||
| 功能 | Web 路径 | App 路径 | 后端路由 | 结论 |
|
||||
|---|---|---|---|---|
|
||||
| 登录 | `/system/auth/login`(multipart/form-data) | `/system/auth/login`(x-www-form-urlencoded) | `POST /system/auth/login`,`CustomOAuth2PasswordRequestForm`(`backend/app/core/security.py:55-92`,全 `Form`) | ✅ 一致(两种 content-type 都是 Form,合法) |
|
||||
| 登出 | `/system/auth/logout`,body 为 JWT 字符串 | 同(`JSON.stringify(token)`) | `payload: Annotated[str, Body()]`(`auth/controller.py:85`) | ✅ 一致 |
|
||||
| 当前用户信息 | `/system/user/current/info` | 同 | `GET /user/current/info` | ✅ |
|
||||
| 修改密码 | `PUT /system/user/password/change` | 同 | ✅ | ✅ |
|
||||
| 工单/公告/AI 会话 | `/system/ticket/*`、`/system/notice/*`、`/ai/chat/*` | 同 | 一致(逐条比对 `*_BASE_URL` 拼接结果) | ✅ |
|
||||
| 监控 | `/monitor/online/stats`、`/monitor/server/info` | 同 | ✅ | ✅ |
|
||||
|
||||
### 3.2 已破损的契约(详见 §2.5)
|
||||
1. **刷新令牌请求体**:后端要 JSON 字符串,Web 正确、**App 错误**(`app/src/api/module_system/auth.ts:34` → APP-01)。
|
||||
2. **文件上传路径**:后端真实路径为 `/api/v1/common/file/upload`(`backend/app/api/v1/routers.py:63` + `common/file/controller.py:15`),Web 正确、**App 错误**(两处:活代码 `app/src/subPages/module_system/profile/index.vue:26`、死代码 `app/src/api/module_system/user.ts:27` → APP-02)。
|
||||
|
||||
### 3.3 口径不一致(不构成缺陷,但需统一)
|
||||
| 项 | Web | App | 建议 |
|
||||
|---|---|---|---|
|
||||
| 运行时基址 | 编译期相对 `/api/v1` + dev 代理(`vite.config.ts:40`) | `VITE_API_BASE_URL + VITE_APP_BASE_API` 拼接(`app/src/http/adapters/alova.ts:108`) | 统一"相对 + 代理"或统一"绝对域名",便于同一套 CORS/证书策略 |
|
||||
| token 传递(WS) | `Sec-WebSocket-Protocol`(`web/.../module_ai/chat/index.vue:99-104`) | URL query `?token=`(`app/src/composables/useAiChat.ts:25`) | 服务端支持首帧鉴权(APP-08),两端统一 |
|
||||
| 登录表单类型 | `multipart/form-data` | `application/x-www-form-urlencoded` | 都可用;建议统一为 urlencoded(语义更贴 Form,也避免 axios 隐式转 FormData) |
|
||||
| 响应错误字段 | `msg`(i18n key 映射,`utils/http/index.ts:97-111`) | `msg/message/error` 三重兼容(`adapters/alova.ts:18-20`) | App 收敛为 `msg`(APP-11) |
|
||||
| WS 端点变量 | `VITE_APP_WS_ENDPOINT`(dev `ws://localhost:8001`) | `VITE_APP_WS_ENDPOINT`(**prod 是 `ws://localhost:5180/ws`**) | 修 APP-03,并统一变量语义为"基址,不含业务路径" |
|
||||
|
||||
---
|
||||
|
||||
## 4. 按优先级排序的修复清单
|
||||
|
||||
### 第一批:先修(生产功能/产物已受影响,工作量小)
|
||||
1. **WEB-01** 修正 `isProduction` 判据(1 行),并补 `.env.prod` 或改为 `import.meta.env.PROD`;重新构建后核对产物已压缩、无 `console.log`、无 devtools。→ 影响所有生产部署。
|
||||
2. **APP-01** 刷新令牌改为发 JSON 字符串(与 `logout` 同一后端约定),删掉 `RefreshToekenBody`。→ 影响移动端登录态。
|
||||
3. **APP-02** 头像上传路径补 `/common`(改 `profile/index.vue:26`,并把上传收敛进 `api/module_system/user.ts` 以免再分叉)。→ 影响移动端个人中心。
|
||||
4. **WEB-09(富文本上传)** `web/src/components/forms/fa-wang-editor/index.vue:83`:默认 `uploadServer` 改为 `${VITE_APP_BASE_API}/common/file/upload`。→ 影响公告/工单/版本管理的富文本插图。
|
||||
5. **APP-03** 生产 WS 端点改为 `wss://` 真实域名;顺带清理 `ws://localhost:5180/ws`。→ 影响移动端 AI 对话。
|
||||
6. **APP-04** `.env.development` 改回本地地址,线上地址挪到不跟踪的 `.local`;与 Web 对齐 env 入库策略。→ 防止误操作生产数据。
|
||||
|
||||
### 第二批:紧接着修(工程防线与安全)
|
||||
7. **WEB-11** 决定生成文件入库或补"先生成后检查"脚本,让 `pnpm type-check` 在新 clone/CI 上可复现。→ 影响 CI 可信度。
|
||||
8. **WEB-15** 演示账号移出生产包(`import.meta.env.DEV` 或构建期变量 + 构建期校验)。→ 影响默认口令风险。
|
||||
9. **WEB-06 / WEB-05** 动态路由命名与清理闭环:为每条动态路由生成确定性 name 并据此清理;修正失效的去重判断。→ 影响菜单刷新/权限收紧后的可达性。
|
||||
10. **WEB-16** 锁屏改为不可逆本地校验(并补 `crypto-js` 类型或移除依赖);`.env` 不再承载"密钥"。→ 影响安全性叙事与依赖质量。
|
||||
11. **WEB-17** 首页移除 mock 依赖,改为骨架/空态。→ 影响线上数据可信度。
|
||||
12. **APP-05** 移动端加权限可见性门禁(或明确边界并删除死常量 `ROLE_ROOT`)。→ 影响管理员功能暴露面与体验。
|
||||
13. **APP-06** `build` 加 `vue-tsc --noEmit`;为 401 队列/token 存储补最小单测。→ 防止 APP-01/02 类问题复发。
|
||||
|
||||
### 第三批:规模化治理(有明确收益,需排期)
|
||||
14. **DUP-01 / DUP-02** 抽共享类型包(或 OpenAPI 生成)+ 抽出"401 续期状态机"公共实现。→ 根治两端漂移。
|
||||
15. **WEB-12** `no-explicit-any` 恢复为 warn/error,分批把 187 处 `any` 收敛(优先 `router/route-loader.ts`)。→ 恢复 `strict` 价值。
|
||||
16. **WEB-13** 拆分 5 个 <1000 行的超大页面(先从 `fastlink/tutorial`、`module_task/storage/browse` 入手)。→ 提升可维护性与可测试性。
|
||||
17. **WEB-06(性能)** 解决首屏全量加载:改为 `import.meta.glob` 默认(懒加载)+ 按目录分 chunk,同时把 `chunkSizeWarningLimit` 回落到默认值并观察真实分包收益。→ 影响首屏体积与加载时间。
|
||||
18. **APP-07** 应用元数据/权限裁剪与发版检查清单。→ 影响上架合规。
|
||||
|
||||
### 第四批:清理(低成本,随迭代顺手做)
|
||||
19. **WEB-10** 成功提示的 `includes("login")` 改精确匹配。
|
||||
20. **WEB-14** 删死组件 `fa-echarts`、死导出(`showError`/`showSuccess`/`ExtendedRequestConfig`/`ErrorLogData`)、4 个空目录、ESLint 过期忽略项。
|
||||
21. **WEB-02 / WEB-03 / WEB-04** `VITE_DROP_CONSOLE` 死配置、`chunkSizeWarningLimit`、`preview.proxy`。
|
||||
22. **DUP-03** 下载工具改用统一 `request` 实例。
|
||||
23. **DUP-05** 前端单测脱离后端 SQL 种子文件。
|
||||
24. **APP-09 / APP-10 / APP-11** 存储判空、生产日志、响应类型收敛。
|
||||
|
||||
---
|
||||
|
||||
## 5. 不确定项与本轮未覆盖
|
||||
|
||||
1. **运行时行为未实测**:本轮未启动任何服务、未发 HTTP 请求。APP-01/APP-02 的契约结论来自"前端请求形态 vs 后端 `Body(str)`/路由注册表"的静态比对(后端代码位置已给出),真实性极高但建议由后端同学用一条 curl 复核。
|
||||
2. **`pnpm-lock.yaml` 与 `package-lock.json` 并存**:`frontend/web` 同时存在 `package-lock.json`(444KB,5 月)与 `pnpm-lock.yaml`(318KB,7 月),`packageManager` 声明 pnpm,npm 锁文件是历史残留,可能导致安装产物不一致。未深入验证,列为待确认项。
|
||||
3. **依赖版本前瞻性**:Web `package.json` 声明 `typescript ^6.0.3`、`vite ^7.1.5`、`eslint ^10.3.0`、`vue-router ^5.0.7`、`pinia ^3.0.4`;App 声明 `@dcloudio/*` 为 `3.0.0-4080520251106001` 这类日期快照版本。本地 `node_modules` 已满足检查,但版本组合的长期可升级性未评估(超出本轮范围)。
|
||||
4. **样式与 i18n 质量未审计**:`styles/`(9 目录)、`locales/` 的完整性与 `stylelint` 实际告警数未跑(`stylelint` 默认带 `--fix`,本轮为避免改动文件未执行)。
|
||||
5. **未评估 accessibility、浏览器兼容(`target: es2024` 与 `browserslist` 中 Chrome>=84 的矛盾)**:`vite.config.ts:63` 构建目标 `es2024`,而 `package.json` 的 `browserslist` 写着 `Chrome >= 84`——两者不一致会让构建产物直接使用新语法而不降级,在旧浏览器上白屏。**列为疑似问题,建议下一轮验证**(本轮未做产物分析)。
|
||||
6. **App 端未跑的检查**:`uni build`(各平台)、`eslint`(`@uni-helper/eslint-config` 实际告警数)、`uni_modules/mp-html` 第三方组件安全性。
|
||||
|
||||
---
|
||||
|
||||
## 6. P0 修复记录(t9,已实施)
|
||||
|
||||
本轮对 §0 表中 5 条 P0 做了实现修复。**行号为本节修复后的当前行号**;`依据` 一栏说明"为什么这样改"的证据来源;`验证` 一栏是实际跑过的命令与结果。
|
||||
|
||||
### 修复 1 — WEB-01 生产构建开关恒不生效
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| 文件:行号(改后) | `frontend/web/vite.config.ts:37`(`isProduction`),连带生效:`:75`(`minify`)、`:76-90`(`terserOptions`)、`:233`(gzip/brotli)、`:253`(剔除 `vueDevTools`) |
|
||||
| 改法 | `const isProduction = mode === "prod"` → `mode === "production" \|\| mode === "prod"`,并补注释说明脚本实际传的 mode |
|
||||
| 附带修复(同一 terserOptions 块) | `frontend/web/vite.config.ts:85-89`:`format.comments: true` → `comments: "some"`。原写法是"**保留**全部注释",与旁边的注释文字"删除注释"意图相反;它把第三方与自身源码的 JSDoc(含示例里的 `console.log(...)`)原样带进产物。改为 terser 默认语义后仅保留 `@license/@preserve/!` 版权注释(MIT 等许可证要求保留版权声明,故不能一律删光) |
|
||||
| 依据 | `frontend/web/package.json:12` `build` = `vue-tsc --noEmit && vite build`(mode 默认 `production`)、`:13` `build:prod` = `vite build --mode production`;全仓无 `--mode prod` 脚本、无 `.env.prod` → 旧判据恒 false。`format.comments` 语义见 terser `format.comments`(`true`=保留全部,`"some"`=只保留版权类注释) |
|
||||
| 验证 | `pnpm run build:prod` → exit 0;产物最大 app chunk `dist/js/index.D0MzHYyR.js` 仅 **2 行 / 665,571 字节**(证明已压缩);`dist/js` 内 `.gz` 35 个、`.br` 35 个(预压缩生效);`grep -rl 'vite-plugin-vue-devtools\|__VUE_DEVTOOLS_GLOBAL_HOOK__' dist/` = 0 个文件(devtools 已剔除)|
|
||||
|
||||
### 修复 2 — APP-01 刷新令牌请求体与后端契约不符
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| 文件:行号(改后) | `frontend/app/src/api/module_system/auth.ts:42-48`(`refreshToken(refreshToken: string)`,body 改为 `JSON.stringify(refreshToken)`);调用点同步改为 `frontend/app/src/http/adapters/alova.ts:82`;删除无用类型 `RefreshToekenBody` |
|
||||
| 依据 | 后端 `backend/app/modules/system/auth/controller.py:57`:`payload: Annotated[str, Body(...)]` → body 必须是 JSON **字符串**。同文件 `logout`(`auth.ts:58-60` 一带)已是"后端要字符串 → 前端 `JSON.stringify`"的既定写法;Web 端 `frontend/web/src/api/module_system/auth.ts:25-31` 直接发字符串(axios 对字符串走 `stringifySafely` → `JSON.stringify`),是可用参照 |
|
||||
| 验证 | 用真实 axios(`web/node_modules/axios`)跑出 Web 端 body,与 App 修复后的 body 逐字节比较:**完全相同**,均为 `"eyJ…"`;修复前的对象形态为 `{"refresh_token":"eyJ…"}`,`JSON.parse` 得 Object → `Body(str)` 必然 422 |
|
||||
|
||||
### 修复 3 — APP-02 头像上传路径缺 `/common`,且绕过统一 http 层
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| 文件:行号(改后) | `frontend/app/src/api/module_system/user.ts:37-39`(路径改为 `/common/file/upload?upload_type=avatar`,返回类型改为 `UniApp.UploadFileSuccessCallbackResult`);`frontend/app/src/subPages/module_system/profile/index.vue:36-58`(新增 `uploadAvatar: UploadMethod`),模板接线 `:179` `:upload-method="uploadAvatar"`(原 `:action` / `:header` 已删除);`:2` 引入 `UploadMethod` 类型 |
|
||||
| 改法 | 用 `wd-upload` 的 `upload-method` 钩子把上传交给 `UserAPI.uploadCurrentUserAvatar`(即统一 http 层,alova 适配器 `requestType: 'upload'`)。页面不再自拼 `VITE_API_BASE_URL + VITE_APP_BASE_API`、不再手工设 `Authorization`;请求头由 `http/adapters/alova.ts` 的 `beforeRequest` 注入 |
|
||||
| 依据 | 后端 `backend/app/modules/common/file/controller.py:18-31`:路由挂载在 `/common` 下(`backend/app/api/v1/routers.py:63`),`upload_type` 是 Query、文件字段名 `file`,并要求 `module_common:file:upload` 权限 → 必须带令牌;`@alova/adapter-uniapp` 对 `requestType: 'upload'` 会把 `filePath`/`name` 作为文件字段、其余键作为 formData,并把 `header` 交给 `uni.uploadFile`(已读 `node_modules/@alova/adapter-uniapp/dist/alova-adapter-uniapp.esm.js` 确认) |
|
||||
| 验证 | `pnpm run build:h5` → exit 0;产物中命中 `FO.Post("/common/file/upload?upload_type=avatar"`(`http.Post` 经压缩后的名字),且 `grep -E "['\"\`]/file/upload" dist/build/h5` **无任何输出**(不存在裸 `/file/upload`);`eslint` 对改动文件 0 error |
|
||||
|
||||
### 修复 4 — APP-03 生产 WebSocket 指向 localhost
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| 文件:行号(改后) | `frontend/app/.env.production:25`:`VITE_APP_WS_ENDPOINT=ws://localhost:5180/ws` → `wss://service.fastapiadmin.com`(并把"只填基址、路径由代码拼接"写进注释) |
|
||||
| 依据 | 消费点 `frontend/app/src/composables/useAiChat.ts:18-25` 的拼接是 `{VITE_APP_WS_ENDPOINT}{VITE_APP_BASE_API}/ai/chat/ws`,因此变量只能是**基址**,旧值多带了 `/ws` 又指向 localhost;后端 WS 路由确认在 `backend/app/modules/ai/chat/controller.py:194`(`@ChatRouter.websocket("/ws")` + `/ai` + `/chat` → `/api/v1/ai/chat/ws`)。站点为 HTTPS,`ws://` 会被浏览器/小程序拦截,故用 `wss://` |
|
||||
| 验证 | `pnpm run build:h5` 产物中 `wss://service.fastapiadmin.com` 命中 1 个文件(`assets/subPages-module_ai-chat-index.*.js`);`grep -rl "localhost:5180" dist/` = **0** 个文件 |
|
||||
|
||||
### 修复 5 — APP-04 开发环境直连生产后端
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| 文件:行号(改后) | `frontend/app/.env.development:7`:`https://service.fastapiadmin.com` → `http://127.0.0.1:8001`;同文件 `:26` `VITE_APP_WS_ENDPOINT` 由 `wss://service.fastapiadmin.com` 改为 `ws://127.0.0.1:8001`(同类缺陷:dev 构建连生产 WS) |
|
||||
| 依据 | 与 `frontend/web/.env.development` 的 `VITE_API_BASE_URL = http://127.0.0.1:8001` 保持同一策略(Web 端该文件不入库、只提交 `.example`,本条使 App 的开发指向与之一致)。`frontend/app/.env.development` 本身**已被 git 跟踪**,之前默认把本地调试打到线上库 |
|
||||
| 验证 | `pnpm run build:h5` 产物中 `localhost:8001` / `127.0.0.1:8001` 命中 0(生产构建读 `.env.production`,符合预期);`type-check` exit 0。**未做运行时联调**(本轮未起服务),需本地起后端后确认 127.0.0.1:8001 可达 |
|
||||
| 注意 | 本次只改地址、未写入任何密钥;`.env.production` 的 `VITE_API_BASE_URL` 保持生产域名不变 |
|
||||
|
||||
### 本轮修复的验证命令汇总
|
||||
|
||||
| 命令 | 结果 |
|
||||
|---|---|
|
||||
| `cd frontend/web && pnpm run type-check` | exit 0,无输出 |
|
||||
| `cd frontend/app && pnpm run type-check` | exit 0,无输出 |
|
||||
| `cd frontend/web && pnpm run build:prod` | exit 0,30s 级完成 |
|
||||
| `cd frontend/app && pnpm run build:h5` | exit 0(仅 Sass `@import` 弃用告警与 unocss 图标告警,均为既有问题) |
|
||||
| `cd frontend/web && grep -rc "console\." dist/js \| awk -F: '{s+=$2} END {print "console 出现次数:", s+0}'` | **5**(非 0,逐条分类见下) |
|
||||
| `cd frontend/app && node_modules/.bin/eslint <4 个改动文件>` | exit 0 |
|
||||
| `cd frontend/web && node_modules/.bin/eslint vite.config.ts` | exit 0 |
|
||||
|
||||
**关于 `console 出现次数: 5`(未达到 0 的原因,逐条核查)**:这 5 处**没有一处是可执行的 console 调用**:
|
||||
|
||||
| # | 产物 | 形态 | 说明 |
|
||||
|---|---|---|---|
|
||||
| 1 | `index.D0MzHYyR.js` | **字符串字面量** | 教程页展示给用户的示例代码块 `console.log('编辑器内容:', html)`(`views/fastlink/tutorial/index.vue` 的文档内容) |
|
||||
| 2 | `wangeditor.*.js` | **英文提示字符串** | `… output logs to console. Ignoring \`debug: true\` …`,第三方库的告警文案 |
|
||||
| 3 | `echarts.*.js` | **非调用引用** | `"undefined"!=typeof console&&console.warn&&console.log;` —— 值被丢弃的特性检测表达式 |
|
||||
| 4 | `async-validator.*.js` | **非调用引用** | `catch(p){console.error,f.suppressValidatorError\|\|setTimeout(…)}` —— `console.error` 作裸引用参与逗号表达式 |
|
||||
| 5 | `exceljs.*.js` | **非调用引用** | `"undefined"==typeof console\|\|console.error` 与 `"object"==typeof console&&console.warn` |
|
||||
|
||||
terser 的 `drop_console` 只删除 **调用语句**,不会修改第三方库里"把 `console.x` 当值用"的守卫表达式;若强行清零需开启 `compress.unsafe`(会改变其他压缩语义,风险大于收益)或改动第三方/用户可见文案,**故未做**。
|
||||
|
||||
**第一方源码 console 已被彻底剥离**(决定性证据:以下 9 个只出现在 `console.*` 参数里的特征串在产物中全部为 0 个文件):
|
||||
`路由已注册`、`[路由配置]`、`[MenuProcessor]`、`[IframeRouteManager]`、`[HTTP Error]`、`[路由守卫]`、`[Download]`、`网络请求失败`、`接口请求失败` — 覆盖 `router/`、`utils/http`、`utils/download`、`stores` 等模块。
|
||||
|
||||
### 修复过程中新发现(本轮**未**改,建议后续项)
|
||||
|
||||
- `frontend/app/src/http/adapters/alova.ts:115-119`:默认请求头写的是 `ContentType: ...`(**缺连字符**,不是 `Content-Type`),因此所有 App 请求实际没有显式 Content-Type,靠平台默认值(`application/json`)工作。修正它会波及全部请求,尤其可能覆盖 `uni.uploadFile` 自动生成的 multipart 边界从而**打破刚修好的上传**,故本轮不动,建议单独一轮处理并回归上传/登录/JSON 三类请求。
|
||||
|
||||
@@ -0,0 +1,590 @@
|
||||
# FastapiAdmin 前端 UX/UI 审计报告(Web + 移动端)
|
||||
|
||||
- 审计轮次:摸底审计第 1 轮(**只读,未修改任何仓库文件**,本报告为唯一新增文件)
|
||||
- 审计对象:`frontend/web`(Vue3 + Vite + TS + Element Plus 2.14.3 + Tailwind v4 + Pinia)、`frontend/app`(uni-app + wot-ui + UnoCSS presetWot)、`frontend/docs`(VitePress)
|
||||
- 需求基准:`REQUIREMENTS.md`
|
||||
- 审计方式:静态代码证据(grep / AST 级脚本统计 / 依赖包类型定义核对 / 后端模型注释交叉验证)
|
||||
- 未执行:未启动 dev server、未安装依赖、未截图、未上真机。所有需运行期或设备才能确认的项均归入第 8 节「需人工/真机验证清单」,**不计为缺陷**。
|
||||
|
||||
---
|
||||
|
||||
## 1. 结论摘要
|
||||
|
||||
| 维度 | Web | 移动端 | 结论 |
|
||||
|---|---|---|---|
|
||||
| 状态设计完整性 | 加载/骨架/空态/二次确认**齐全**;**错误态与重试入口系统性缺失** | 骨架/空态/下拉刷新/二次确认齐全;错误态缺失,且失败被误渲染为「不存在」 | 两端共同短板是**错误态** |
|
||||
| 交互一致性 | 分页+搜索+批量+导入导出 | 触底加载+搜索+筛选 Tab,无批量/导出 | 差异多为平台合理差异,但**确认机制与状态色语义**真不一致 |
|
||||
| 可访问性 | 76 处可点击元素无键盘可达性;表单无 label;图标按钮无替代文本 | 0 个 aria 属性(原生端可访问性依赖系统 TalkBack/VoiceOver) | Web 侧问题可静态证明 |
|
||||
| 设计体系一致性 | **236 处硬编码色值散落在 50 个业务文件**,绕过动态主题令牌 | 81 处 / 11 文件(其中 44 处集中在 3 个登录类页面);共享组件基本走令牌 | Web 偏离显著重于移动端 |
|
||||
| 文案与术语一致性 | **101 个 views 文件中 85 个完全不含 i18n**;1063 处硬编码中文 | 14 个页面 **100% 走 i18n** | 两端 i18n 纪律差距最大 |
|
||||
|
||||
**最高优先级 4 项(P1)**:P1-1 通知状态枚举两端语义冲突、P1-2 Web i18n 近乎未落地、P1-3 列表页无错误态/重试、P1-4 移动端把接口失败渲染成「不存在」。
|
||||
|
||||
**先要说清的口径修正**:任务背景描述「Web 端 … + uno.config.ts」与事实不符。`uno.config.ts` 只存在于 `frontend/app/uno.config.ts`(UnoCSS + `@wot-ui/unocss-preset`);Web 端使用的是 **Tailwind v4**(`frontend/web/src/styles/tailwind.css` 的 `@theme` 块,`package.json` 依赖 `@tailwindcss/vite` + `tailwindcss@^4.3.0`,无 unocss 依赖)。因此「设计令牌」在两端是**两套独立体系**,Web 侧令牌载体是 `@theme` + `:root`/`.dark` CSS 变量,移动端是 `presetWot` + `theme.json` + `uni.scss`。
|
||||
|
||||
---
|
||||
|
||||
## 2. 可验证问题清单
|
||||
|
||||
级别定义:**P1 高**(用户可见的错误信息 / 无法完成的交互 / 数据语义错误);**P2 中**(一致性或可访问性缺陷,影响部分用户或造成理解成本);**P3 低**(维护性与体验打磨)。
|
||||
|
||||
### 2.1 P1 — 高
|
||||
|
||||
#### P1-1 通知公告 `status` 枚举在 Web 端与后端/移动端语义冲突(数据被错误呈现)
|
||||
|
||||
- **现象**:同一后端字段 `status`,三处定义不一致:
|
||||
|
||||
| 来源 | 取值语义 | 证据 |
|
||||
|---|---|---|
|
||||
| 后端模型(权威) | `0:草稿 1:已发布 2:已归档` | `backend/app/modules/system/notice/model.py:16` |
|
||||
| 移动端 | `0:草稿 1:已发布 2:已归档` | `frontend/app/src/subPages/module_system/notices/index.vue:25-27`、`:31` |
|
||||
| **Web** | `0:启用 1:停用`(**缺 2**) | `frontend/web/src/views/module_system/notice/index.vue:188-191`、`:254`、`:403-404` |
|
||||
|
||||
- **影响**:
|
||||
1. 状态为 `2`(已归档)的公告,在 Web 列表里落到 `resolveTagText` 的兜底分支,渲染为**灰色标签 + 原始数字「2」**(`frontend/web/src/components/display/fa-descriptions/index.vue:64-72`);搜索栏的「状态」下拉也只有 0/1 两项(`:226`),**无法筛选已归档公告**。
|
||||
2. 状态 `0/1` 的语义在 Web 被标成「启用/停用」而非「草稿/已发布」,审核流程判断会被误导。
|
||||
- **建议**:以 `backend/app/modules/system/notice/model.py` 的注释为单一真值,Web 端 `STATUS_OPTIONS`/列 map/详情 map(`:188-191`、`:254`、`:403-404`)补 `2:已归档` 并改为 `0:草稿 1:已发布 2:已归档`;两端共用一份枚举常量。**这是本轮唯一发现的数据语义级错误,建议优先核对是否已有线上影响。**
|
||||
|
||||
#### P1-2 Web 端 i18n 近乎未落地(切英文后业务界面仍为中文)
|
||||
|
||||
- **现象**:
|
||||
- `frontend/web/src/views/**/*.vue` 共 **101** 个文件,其中 **85 个(84%)完全不含 `$t(` / `useI18n`**。
|
||||
- `<template>` 内硬编码中文字符共 **1063 处**,分布在 **87 个 .vue 文件**(统计口径见第 9 节;已剔除 HTML 注释)。TOP 文件:`views/fastlink/tutorial/index.vue`(1114 字符)、`views/module_task/storage/browse/index.vue`(480)、`views/module_generator/gencode/components/FaGenBasicStep.vue`(324)、`components/others/fa-cron/index.vue`(215)。
|
||||
- 高频硬编码位置类型:文本节点 638、`label` 201、`placeholder` 89、`title` 56、`description` 22、`content` 22。
|
||||
- **对照事实(说明这是纪律问题而非能力问题)**:移动端 14 个页面 **0 个**缺 i18n;模板内硬编码中文仅 `frontend/app/src/components/PrivacyPopup.vue` 1 个文件 4 字符(且为微信小程序隐私协议默认值,属合理豁免)。两端 locale 键的 zh/en **完全对齐**(Web 509/509,移动端 331/331,无缺失键),Web 的 `locales/index.ts` 也完整可用。
|
||||
- **影响**:Web 端语言切换器形同虚设;英文用户在所有业务页(用户/角色/菜单/字典/工单/存储/定时任务/代码生成…)看到中文。
|
||||
- **建议**:分批治理,优先共享组件与系统模块。共享组件是放大器,先修 `frontend/web/src/components/tables/fa-table/index.vue:230`、`fa-table-header-left/index.vue`、`hooks/core/useConfirm.ts`、`hooks/core/useChart.ts:495`、`modal/fa-export-dialog/index.vue`、`modal/fa-import-dialog/index.vue`、`others/fa-cron/index.vue`。
|
||||
|
||||
#### P1-3 列表页系统性缺失「错误态 + 重试入口」,失败被降级为「暂无数据」
|
||||
|
||||
- **现象(Web)**:`hooks/core/useTable.ts` 已维护 `loadingState: "idle"|"loading"|"success"|"error"` 与 `error` ref(`:203-208`、`:463-468`),但 **16 个使用 `useTable` 的页面无一读取 `error`**(`grep -rn "error," --include="*.vue" views/` 无命中)。全仓仅 1 处渲染持久错误条:`views/module_ai/chat/components/FaChatMessages.vue:12`。页面普遍写成:
|
||||
|
||||
```ts
|
||||
} catch {
|
||||
// ignore ← frontend/web/src/views/module_system/ticket/index.vue:516-518
|
||||
}
|
||||
```
|
||||
|
||||
由于 `utils/http/index.ts` 只在拦截器里弹一次 `ElMessage.error`,用户看到的是「一个会消失的 toast + 一张写着『暂无数据』的空表」,**无法区分「本来没数据」与「加载失败」**,也没有重试按钮。
|
||||
- **典型受害页面**:
|
||||
- `frontend/web/src/views/module_monitor/server/index.vue:214-221`:`catch { /* 静默忽略错误 */ }`,且 `server` 保持全 0 初值 → 接口失败时页面显示 **CPU 0% / 内存 0% / 磁盘表空**,看起来像「服务器空闲」,**无任何异常指示、无重试**。
|
||||
- `frontend/web/src/views/module_system/params/index.vue:433-438`:`onMounted` 内 `await configStore.getConfig()`,无加载态、无失败分支;加载期间 `fieldsMap` 为空 → 左侧 Tab 全空白,用户以为页面坏了。
|
||||
- `frontend/web/src/views/module_system/ticket/index.vue:877-880`:评论加载失败仅 `// ignore`,评论区显示为空,用户以为「没有评论」。
|
||||
- `frontend/web/src/views/module_ai/chat/components/FaChatInput.vue:176-178`:模型列表加载 `catch { /* 静默失败 */ }`,模型下拉静默变空。
|
||||
- **现象(移动端)**:`frontend/app/src/composables/useListPage.ts:31,57` 同样暴露了 `error`,但 `subPages/module_system/tickets/index.vue:82-90` 与 `notices/index.vue:33-36` 都只写了 `onError: () => toast.error(t('common.loadFailed'))`,未消费 `error` 做 UI。
|
||||
- **建议**:为 `FaTable` 增加 `error`/`@retry` 两个 prop(放在 `frontend/web/src/components/tables/fa-table/index.vue`,一次修复覆盖 23 个消费页),`FaTable` 内 `v-if="error"` 渲染 `ElResult` + 重试按钮,`empty` 槽位仅在 `!error` 时启用;移动端在 `SkeletonPage` 旁增加 `ErrorState` 组件并在 `useListPage.onError` 中置位。
|
||||
|
||||
#### P1-4 移动端把「接口失败」渲染为「不存在 / 空资料」
|
||||
|
||||
- **现象**:
|
||||
- `frontend/app/src/subPages/module_system/ticket-detail/index.vue:38-46` 加载失败 → `ticket` 仍为 `null`;模板 `:244` 的 `wd-empty v-else :tip="t('ticketDetail.notFound')"` 命中的是**「工单不存在或已删除」**。网络抖动会让用户以为工单被删了。
|
||||
- 同文件 `:55-60`:`loadComments` 失败只 `console.error`,**连 toast 都没有**(`finally` 也不复位任何可见状态)。
|
||||
- `frontend/app/src/subPages/module_system/profile/index.vue:38-45`:失败后 `userProfile` 为 `null`,而模板 `:141` 的骨架条件 `v-if="loading && !userProfile"` 不再成立 → 直接进入 `v-else`,渲染**满屏 `-` 占位**(`deptText`/`roleText` 兜底 `'-'`,`:29-33`),无错误提示、无重试。
|
||||
- `frontend/app/src/pages/index/index.vue:270-286`:`Promise.allSettled` + `catch { /* silent */ }`,失败时统计卡显示 `-`,无错误态与重试。
|
||||
- **建议**:`ticket-detail` 增加 `loadError` 状态区分「失败」与「不存在」;`profile` 增加错误分支(骨架条件改为 `v-if="loading"`,`v-else-if="loadError"` 渲染错误态);`index` 增加错误态与重试。
|
||||
|
||||
### 2.2 P2 — 中
|
||||
|
||||
#### P2-1 Web `module_monitor/cache` 页面完全没有加载反馈(`:loading` 是无效属性)
|
||||
|
||||
- **现象**:`frontend/web/src/views/module_monitor/cache/index.vue:116` 与 `:179` 写作 `<ElTable :loading="loading">` / `:loading="subLoading"`。经核对 Element Plus 2.14.3 类型定义,`TableProps` 共 49 个 prop,**不含 `loading`**(`node_modules/element-plus/es/components/table/src/table/defaults.d.ts`;该文件中的 `loading?: boolean` 属于 `TreeNode` 接口,是树形懒加载字段,与表格加载态无关)。渲染层 `table.mjs` 也不读取 `loading`。
|
||||
- **佐证**:`loading`/`subLoading` 两个 ref 被完整维护(`:321,328,346,354,371,385,409,417`)却无任何消费者,属明显的「以为设了加载态」。
|
||||
- **影响**:该页两个表格在请求期间**无任何反馈**,用户会重复点击「清理全部缓存」(危险操作)。
|
||||
- **建议**:改为 `v-loading="loading"`,或整体换用 `FaTable`。
|
||||
|
||||
#### P2-2 Web 键盘可达性:76 处可点击元素既无 `tabindex` 也无 `role`
|
||||
|
||||
- **现象**:全仓 `@click` 挂载在 `<div>/<span>/<li>/<td>` 上、且同一标签内无 `tabindex` / `role=` / `@key*` 的共 **76 处**(`layouts/` 31、`views/` 24、`components/` 21)。而全仓实现正确模式(`role="button"` + `tabindex="0"` + `@keydown.enter`)的仅 **3 处**:`components/tables/fa-table-header/index.vue:189-196`(已封装成 `useToolButtonAttrs`)、`views/module_system/user/components/FaDeptTree.vue:21-25`、`views/module_ai/chat/components/FaWelcomeScreen.vue:14-21`。
|
||||
- **确定性样例(顶级控件,非菜单项,鼠标之外无可达路径)**:
|
||||
- `frontend/web/src/components/base/fa-back-to-top/index.vue:11-17` — 返回顶部按钮(`<div @click>`)。
|
||||
- `frontend/web/src/layouts/fa-settings-panel/widgets/FaSettingHeader.vue:4-8` — 设置面板关闭按钮。
|
||||
- `frontend/web/src/layouts/fa-header-bar/index.vue:73-79` — 顶栏全局搜索触发器。
|
||||
- `frontend/web/src/layouts/fa-header-bar/index.vue:21` — Logo 回首页。
|
||||
- `frontend/web/src/components/cards/fa-image-card/index.vue:3` — 整卡可点击。
|
||||
- `frontend/web/src/components/forms/fa-search-bar/index.vue:143` — 展开/收起筛选开关。
|
||||
- `frontend/web/src/components/tables/fa-table-select/index.vue:11`、`components/others/fa-icon-select/index.vue:6` — 弹层触发器。
|
||||
- `frontend/web/src/components/banners/fa-basic-banner/index.vue:39`、`components/banners/fa-card-banner/index.vue:13,24` — Banner 按钮。
|
||||
- **对比正向样例**:`frontend/web/src/views/dashboard/home/modules/quick-links.vue:33-40` 用原生 `<button type="button">` + `:aria-label` 实现卡片删除,是本仓最规范写法。
|
||||
- **建议**:把 `fa-table-header/index.vue:189-196` 的 `useToolButtonAttrs` 提升为 `hooks/core/useClickable.ts` 并全仓替换;或对纯装饰容器改用原生 `<button>` / `ElButton link`。
|
||||
|
||||
#### P2-3 Web 登录/注册/忘记密码表单没有 label(输入框无可访问名)
|
||||
|
||||
- **现象**:`ElFormItem` 只写了 `prop`,**没有 `label` 也没有 `#label` 插槽**,可访问名只能依赖 `placeholder`(输入后即消失,且不是 label 的合法替代):
|
||||
- `frontend/web/src/views/module_system/auth/login/components/forms/FaLoginAccountForm.vue:31`(username)、`:45`(password)
|
||||
- `frontend/web/src/views/module_system/auth/login/components/panels/FaLoginRegisterPanel.vue:12,24,37,55`
|
||||
- `frontend/web/src/views/module_system/auth/login/components/panels/FaLoginForgetPanel.vue:12`
|
||||
- 同类还有 `views/module_generator/gencode/components/FaGenBasicStep.vue:107,128,139,157,200,219`、`views/module_system/role/components/FaPermissonDrawer.vue:35`、`components/others/fa-comment-widget/index.vue:4,12,21`、`components/modal/fa-import-dialog/index.vue:21`。
|
||||
- **全仓统计**:`ElFormItem` 共 70 处,**27 处无 `label`**。
|
||||
- **对照**:移动端统一使用 `wd-form-item :label="t(...)"`(如 `frontend/app/src/subPages/module_system/tickets/index.vue:259-279`),label 覆盖完整——**这是移动端做得比 Web 好的一处**。
|
||||
- **建议**:登录/注册/忘记密码这类「极简设计」场景用 `label` 配 `label-position="top"` + `sr-only` 类,或至少补 `:aria-label`;`ElTooltip` 的 `content`(`:44`)不能替代表单 label。
|
||||
|
||||
#### P2-4 Web 图标按钮缺可访问名(含同一区域内不一致)
|
||||
|
||||
- **现象**:`ElButton` 内容仅为图标、且自身无 `aria-label`/`title` 的共 **12 处**(其中若干被 `el-tooltip` 包裹,见下方说明):
|
||||
- `frontend/web/src/views/module_ai/chat/components/FaChatInput.vue:99-107` — **发送**按钮无任何名称;而紧邻的 `:88-95` 停止按钮写了 `title="停止生成"`。**同一行内的不自洽是最硬的证据。**
|
||||
- `frontend/web/src/views/module_ai/chat/components/FaAiModelConfigPanel.vue:125`(显示/隐藏 API Key)、`:132`(复制 Key)— 无 tooltip、无 aria-label。
|
||||
- `frontend/web/src/views/module_ai/chat/components/FaMessageItem.vue:16-23` — 复制消息按钮。
|
||||
- `frontend/web/src/components/actions/fa-copy-button/index.vue:3` — 通用复制组件(被复用,影响面放大)。
|
||||
- `frontend/web/src/views/module_task/storage/workflow/components/FaStorageNodePanel.vue:7`、`FaEdgeConfigPanel.vue:7` — 面板关闭按钮。
|
||||
- `frontend/web/src/components/others/fa-ai-assistant/index.vue:5` — 悬浮球。
|
||||
- **说明(避免误判)**:`views/module_task/storage/browse/index.vue:41,46,113`、`FaAiModelConfigPanel.vue:92` 等同类按钮**已被 `el-tooltip` 包裹**,EP 会为触发器生成 `aria-describedby`;但「描述」不等于「名称」,屏幕阅读器读到的是空按钮 + 描述。是否可接受需人工验证(见第 8 节)。
|
||||
- **建议**:统一补 `aria-label`;`fa-copy-button` 加 `ariaLabel` prop 并默认取 `t('common.copy')`。
|
||||
|
||||
#### P2-5 移动端错误文案未国际化(英文语言下全是中文)
|
||||
|
||||
- **现象**:`frontend/app/src/http/tools/enum.ts:53-80` 的 `ShowMessage()` 对 400/401/403/404/408/500/501/502/503… 全部返回**硬编码中文**(`'请求错误(400)'`、`'未授权,请重新登录(401)'`…);`frontend/app/src/http/adapters/alova.ts:19`(`` `HTTP请求错误[${statusCode}]` ``)、`:142`、`:165`、`:176`(`'网络错误,请检查您的网络连接'`)、`:178`(`'请求超时,请重试'`)、`:180`、`:189` 同样硬编码。
|
||||
- **影响**:移动端虽然页面文案 100% i18n,但**所有网络/服务端错误的提示语仍是中文**——这是最容易被漏掉的一层(它在 `.ts` 里,不在 `<template>` 里,常规 i18n 检查扫不到)。
|
||||
- **Web 侧同类**:`frontend/web/src/utils/http/index.ts:273`(`"服务器连接失败,请检查后端服务是否正常运行"`)、`:277`(`"网络连接错误,请检查您的网络设置"`)为硬编码中文;其余分支已正确使用 `$t("httpMsg.*")`,且 `httpMsg` 键在 zh/en 中均存在。
|
||||
- **建议**:两端 HTTP 层的错误文案统一改走 i18n(`httpMsg.status.400` 等),移动端补 10 个 `httpMsg` 键。
|
||||
|
||||
#### P2-6 移动端危险操作确认机制不统一(原生 `uni.showModal` vs wot `wd-dialog`)
|
||||
|
||||
- **现象**:同一类「确认后执行破坏性操作」,两种实现并存:
|
||||
- 原生 `uni.showModal`(不可主题化;H5 下退化为浏览器原生 dialog,样式与 App 内其他弹窗割裂):`frontend/app/src/subPages/module_system/tickets/index.vue:154`、`subPages/module_ai/chat/index.vue:107`、`subPages/module_ai/ai-models/index.vue:106,123`、`composables/useSubscribeMessage.ts:116`、`components/GlobalMessage.vue:13`。
|
||||
- wot `wd-dialog`(经 `useGlobalDialog`,与设计体系一致):`frontend/app/src/subPages/setting/index.vue:93`、`pages/mine/index.vue:38`。
|
||||
- **建议**:统一收敛到 `globalDialog.confirm`,`useSubscribeMessage.ts` 的系统级订阅弹窗可保留原生。
|
||||
|
||||
#### P2-7 空态无法区分「筛选无结果」与「本来为空」
|
||||
|
||||
- **现象**:
|
||||
- Web:`FaTable`(`frontend/web/src/components/tables/fa-table/index.vue:230`)、`FaCardList`(`components/cards/fa-card-list/index.vue:51`)、`FaCardGrid`(`components/cards/fa-card-grid/index.vue:141`)、图表(`hooks/core/useChart.ts:495`)的空态文案**固定为「暂无数据」**,无「筛选无结果」变体。除 `views/module_system/ticket/index.vue:53` 传了 `empty-text="暂无工单"` 外,无页面覆盖。
|
||||
- Web locale(509 键)中**不存在任何通用空态键**(`empty`/`noData` 类仅命中 `notice.empty`、`common.delNoData`、`httpMsg.notFound`)。移动端有 **10 个**专用空态键并已区分两种语义(`common.noMore`、`tickets.empty`/`tickets.emptyWithFilter`、`notices.empty`/`emptyWithFilter`、`chat.empty`、`aiModels.empty`、`ticketDetail.emptyComments`、`ticketDetail.notFound`、`work.emptyTip`),实现见 `frontend/app/src/subPages/module_system/tickets/index.vue:215`。
|
||||
- **建议**:Web 补 `table.empty` / `table.emptyFiltered`,`FaTable` 接收 `hasFilter` 或 `filtered` 标记后切换文案。
|
||||
|
||||
#### P2-8 同一后端枚举的**状态色**两端不一致;「启用/停用」词形也不一致
|
||||
|
||||
- **现象(颜色,标签文字一致、颜色不一致)**:工单 `status`,Web 用 `statusTagType`(`frontend/web/src/views/module_system/ticket/index.vue:608-613`):`0→warning(橙)`、`1→info(灰)`、`2→success(绿)`、`3→info(灰)`;移动端用 `StatusBadge`(`frontend/app/src/components/StatusBadge.vue:33-36`):`0 pending→draft(灰)`、`1 processing→primary(蓝)`、`2 completed→enabled(绿)`、`3 closed→disabled(灰)`。即**「处理中」在 Web 是灰、在移动端是蓝;「待处理」在 Web 是橙、在移动端是灰**。
|
||||
- **现象(词形)**:Web 系统模块统一用「**启用/停用**」(`views/module_system/user/index.vue:457-458`、`role/index.vue:318-319`、`position/index.vue:224-225` 等,共 73 处字面量),移动端 `common.status.disabled` = 「**禁用**」(`frontend/app/src/locales/langs/zh.json`)。
|
||||
- **现象(术语,同一实体三种叫法)**:同一条公告,Web 业务页叫「**公告通知**」(`views/module_system/notice/index.vue:275-278`、`:304`、`:203`),Web 顶栏通知区叫「**通知**」(locale `notice.title`),移动端叫「**通知公告**」(locale `notices.title`、`pages.json:152`)。
|
||||
- **建议**:以「后端 comment 文字」为唯一词表来源,抽 `constants/status.ts` 供两端共用;状态色按语义(进行中=主色蓝、待处理=警示橙、完成=成功绿、终止=中性灰)统一。
|
||||
|
||||
#### P2-9 移动端主题令牌存在四份互相冲突的定义
|
||||
|
||||
- **现象**:同一份「主题色 → 背景色」值被声明了 4 次,且暗色值互相矛盾:
|
||||
|
||||
| 位置 | 内容 | 证据 |
|
||||
|---|---|---|
|
||||
| SCSS 变量层 | `--drop-base-a/-c` 等 6 主题色 | `frontend/app/src/styles/theme.scss:16-21,45-49,65-69,85-89,105-109,125-129` |
|
||||
| Pinia store | `THEME_NAV_BG`(同 6 值) | `frontend/app/src/store/themeStore.ts:13-20` |
|
||||
| 根组件 | `THEME_PAGE_BG`(同 6 值) | `frontend/app/src/App.ku.vue:9-14` |
|
||||
| 原生主题文件 | `theme.json`(仅 blue + dark) | `frontend/app/src/theme.json`(经 `manifest.config.ts:74,93` 的 `themeLocation` 生效) |
|
||||
|
||||
逐值核对:`themeStore.THEME_NAV_BG` 的 6 个值与 `theme.scss --drop-base-a` **完全一致**;`App.ku.THEME_PAGE_BG` 的 6 个值与 `--drop-base-c` **完全一致**。
|
||||
- **冲突点**:`theme.json` 的 `dark.bgColor = #000`、`dark.navBgColor = #000000`,而运行时实际使用的是 `#272B3B`(`store/themeStore.ts:76`、`App.ku.vue:23`;该值与 wot 令牌 `--wot-coolgrey-9: #272B3BFF` 一致,见 `node_modules/@wot-ui/ui/styles/theme/base/color.scss:160`、`theme/dark.scss:60`)。
|
||||
- **影响**:深色模式下原生窗口背景(由 `theme.json` 决定)与 App 页面背景(`#272B3B`)不是同一个颜色 → 冷启动/下拉回弹时可能出现可见色差或闪烁。(静态可证「值不一致」;**视觉表现需真机确认**,见第 8 节。)
|
||||
- **建议**:`theme.scss` 作为唯一真值源,`themeStore`/`App.ku.vue` 改读 CSS 变量或从同一常量模块导入;`theme.json` 的 dark 值改为与 `--wot-filled-content` 对齐。
|
||||
|
||||
#### P2-10 Web 组件与视图大量硬编码 Element Plus 默认调色板,绕过动态主题色
|
||||
|
||||
- **现象**:`#409eff`(EP 默认主色,非项目 `--theme-color`/`$fa-primary:#4080ff`)出现 6 次;EP 默认语义色 `#909399`(16)、`#e6a23c`(5)、`#f56c6c`(1)、`#c0c4cc`(4)、`#67c23a`(1)、`#606266`、`#f5f7fa` 等贯穿业务代码。项目明明提供了 `--el-text-color-*`/`--el-color-*`/`--fa-gray-*` 令牌(`frontend/web/src/styles/tailwind.css:215-232`)。
|
||||
- **暗色模式受害样例**(这些浅色专用灰**不会**随 `.dark` 切换,属可静态证明的对比度风险):`frontend/web/src/views/module_task/storage/browse/index.vue:88,213,233,260,275,284,289,348,433,444,471,476,492,514,1685`;`views/module_generator/gencode/components/FaGenPreviewStep.vue:16,22`;`views/module_task/storage/workflow/index.vue:99`。
|
||||
- **完整分布统计见第 6 节。**
|
||||
|
||||
#### P2-11 Web 字号/圆角绕过令牌(含不受用户设置影响的固定圆角)
|
||||
|
||||
- **现象**:
|
||||
- Tailwind `@theme` 已定义字号 5 档 `--font-size-xs..xl` = 11/12/13/15/18px(`frontend/web/src/styles/tailwind.css:56-62`),但仍有 **33 处任意值 class**:`text-[13px]`×15、`text-[10px]`×7、`text-[14px]`×6、`text-[11px]`×6、`text-[19px]`×3、`text-[16px]`×2、`text-[18px]`、`text-[12px]`;`<style>` 内另有 `font-size: 12px`×32、`14px`×30、`13px`×25、`16px`×13、`11px`×7。
|
||||
- 项目提供了 `rounded-custom-xs/sm`(`tailwind.css:66-72`)与 `--custom-radius`(用户在设置面板可调,影响 20+ 处 calc 表达式),但仍有 **17 处 `border-radius: 6px`、14 处 `8px`、13 处 `4px`** 固定值 → **用户调圆角时这些元素不变**。样例:`components/skeleton/fa-dashboard-skeleton/index.vue:229`、`layouts/fa-work-tab/index.vue:909,970`、`layouts/fa-menus/fa-sidebar-menu/index.vue:633`、`views/module_ai/chat/components/FaMessageItem.vue:138,150,201`、`views/module_task/storage/workflow/components/FaWorkflowDesignDrawer.vue:901`。
|
||||
- 间距硬编码(`<style>` 内 `padding/margin/gap: Npx`,共 231 处):`8px`×61、`12px`×31、`16px`×28、`4px`×19、`6px`×18、`10px`×17。
|
||||
- **建议**:`@theme` 补 `--font-size-md:14px` 等实际在用档位;`rounded-6/8` 等封装为走 `--custom-radius` 的工具类;对 `text-[Npx]` 用 stylelint 自定义规则(`declaration-property-value-disallowed-list` 已有 stylelint 基建,`frontend/web/package.json` 的 `lint:stylelint`)拦截新增。
|
||||
|
||||
#### P2-12 文档站 zh/en 术语与正文范围不对齐
|
||||
|
||||
- **现象**:VitePress 侧边栏同一份文档,中文标签为「**移动端开发指南**」,英文为「**Miniprogram Development Guide**」(`frontend/docs/.vitepress/config.mts` 的 `zhGuideSidebar` / `enGuideSidebar`);正文页标题同样中英不等(`frontend/docs/src/guide/miniprogram.md:4` = 「FastApp 移动端开发指南」 vs `frontend/docs/src/en/guide/miniprogram.md:4` = 「Miniprogram Development Guide」),而正文同时覆盖 **H5 / 微信·支付宝小程序 / iOS·Android**。英文标签把 App 端排除了。
|
||||
- **正向事实**:docs 中英文件数 1:1(`guide/` 与 `en/guide/` 均为 9 个 .md),无缺页。
|
||||
- **建议**:英文标签改为 "Mobile Development Guide",与 App 端 `README` 中 "mobile application" 的用词对齐。
|
||||
|
||||
### 2.3 P3 — 低
|
||||
|
||||
| ID | 现象 | 文件:行号 | 建议 |
|
||||
|---|---|---|---|
|
||||
| P3-1 | `useConfirm` 的确认框标题/按钮文案硬编码中文,绕过 locales | `frontend/web/src/hooks/core/useConfirm.ts:8,17,30,38` | 改走 `t()`;`common.confirm/cancel/tips` 键已存在 |
|
||||
| P3-2 | 破坏性操作确认框标题词表不统一(「警告」「提示」「批量删除」「危险!」「确认清空」「同步差异预览」),且部分走 `t()` 部分硬编码 | `useConfirm.ts:9,21,30`、`views/module_task/storage/browse/index.vue:1322`、`views/module_monitor/cache/index.vue:391`、`views/module_system/ticket/index.vue:799`、`views/module_ai/chat/index.vue:328` | 统一 `useConfirm` 出口与标题键(`type` 已统一为 `warning`,无需改) |
|
||||
| P3-3 | 空态「暂无数据」硬编码在 10 个位置(含 3 个共享组件),无法随语言切换 | `components/tables/fa-table/index.vue:230`、`components/cards/fa-card-list/index.vue:51`、`fa-card-grid/index.vue:141`、`hooks/core/useChart.ts:495`、`views/module_monitor/cache/index.vue:118,185`、`views/module_monitor/server/index.vue:163`、`views/module_generator/gencode/components/FaGenColumnsStep.vue:87`、`FaImportDbTableDialog.vue:49`、`views/module_system/user/components/FaDeptTree.vue:47`、`views/module_task/cronjob/job/index.vue:151` | 随 P2-7 一并治理 |
|
||||
| P3-4 | `fa-table-v2` 为死代码:53 行、无任何消费方(`grep -rn "FaTableV2\|fa-table-v2"` 在组件目录外 0 命中),且未实现 loading/empty | `frontend/web/src/components/tables/fa-table-v2/index.vue` | 删除或补齐状态并与 `FaTable` 收敛 |
|
||||
| P3-5 | 移动端 `GlobalLoading.vue`、`GlobalMessage.vue` 实际未用于任何页面(仅其自身 composable 引用,`grep` `useGlobalLoading(`/`useGlobalMessage(` 无业务命中) | `frontend/app/src/components/GlobalLoading.vue`、`GlobalMessage.vue` | 确认后删除,或接入使用 |
|
||||
| P3-6 | 死样式:`.el-dropdown-selfdefine` 是 Element Plus 遗留类,EP 2.14.3 的 JS 在 `node_modules/element-plus/es/**` 中**不产出**该 class(仅 theme-chalk CSS 残留),本条覆盖规则永不生效 | `frontend/web/src/styles/element-plus/_overrides.scss:273-275` | 删除;焦点可见性依赖 EP 自身实现(`:465-469` 已有 `.el-tooltip__trigger:focus-visible` 正例) |
|
||||
| P3-7 | 移动端工单页把表单提交 loading 与列表 loading 复用同一个 ref | `frontend/app/src/subPages/module_system/tickets/index.vue:135,151`(配合 `:212`、`:245`、`:285`) | 用独立 `submitting` ref(同仓 `ai-models/index.vue:226` 已是正确写法) |
|
||||
| P3-8 | 移动端 `chat` 页内联样式 12 处硬编码 rpx/px,与同仓其它页面的 UnoCSS/wot 令牌写法不一致 | `frontend/app/src/subPages/module_ai/chat/index.vue:207,218,247,252,275,276,283` 等 | 抽成 UnoCSS class 或 `styles/` 下的具名类 |
|
||||
| P3-9 | 移动端页面导航标题中文常量与 i18n 双真值源:`pages.json` 17 处 + `definePage` 13 处为中文常量,靠 `useI18nNavTitle` 在 `onShow` 覆盖 | `frontend/app/src/pages.json:23,34,45,54,63,72,80,88,102,113,123,134,143,152,161,170`;`composables/useI18nNavTitle.ts` | 覆盖率已 100%(14/14 有标题的页面均已接入),仅存在首屏闪中文的可能 → 见第 8 节 |
|
||||
| P3-10 | Web `dashboard/analysis`、`dashboard/workplace` 两个首页级页面**零**加载/骨架/错误处理(101 个 views 文件中仅这两处完全没有 `loading`/`Skeleton`/`ElEmpty`) | `frontend/web/src/views/dashboard/analysis/index.vue`、`views/dashboard/workplace/index.vue` | 复用 `FaDashboardSkeleton`(`views/dashboard/home/index.vue:3` 已有正例) |
|
||||
| P3-11 | 系统模块「启用/停用」字面量在同一页面重复 3~4 次(`columns` 的 tag map、详情 map、搜索项、编辑项各写一遍),共 73 处 | `views/module_system/user/index.vue:395,457,491,623`;`role/index.vue:318,390,514,584`;`position/index.vue:224,295,371,473`;`notice/index.vue:189,254,403` | 抽 `constants/status.ts` 共享 map(同时解决 P2-8) |
|
||||
| P3-12 | Web `<img>` 无 `alt` 5 处(24 个图片标签中 19 个有 alt) | `components/cards/fa-image-card/index.vue:6`、`actions/fa-upload/index.vue:47`、`layouts/fa-settings-panel/widgets/FaThemeSettings.vue:11`、`FaMenuStyleSettings.vue:17`、`FaMenuLayoutSettings.vue:12` | 先判定是否纯装饰;装饰图加 `alt=""`,其余补描述性 alt |
|
||||
| P3-13 | 移动端 5 个 `<image>` 均无 `alt`(H5 构建下会渲染为无替代文本的 `<img>`) | `frontend/app/src/pages/login/index.vue:248,328,336,344,352` | 品牌 logo 加 `alt`;第三方图标可 `alt=""`(装饰) |
|
||||
|
||||
---
|
||||
|
||||
## 3. 状态设计完整性(逐页抽样)
|
||||
|
||||
图例:✅ 有 / ❌ 缺 / ➖ 不适用(非数据页)。「空态」对 Web 指 `FaTable`/`ElEmpty`(组件内建也算 ✅)。
|
||||
|
||||
### 3.1 Web 抽样(23 个页面使用 `FaTable`,16 个使用 `useTable`)
|
||||
|
||||
| 页面 | 加载/骨架 | 空态 | **错误态** | **重试** | 危险操作二次确认 | 关键行号 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `views/module_system/user/index.vue` | ✅ | ✅ | ❌ | ❌ | ✅ `confirmDelete`/`confirmBatchDelete` | `:554,915-922` |
|
||||
| `views/module_system/role/index.vue` | ✅ | ✅ | ❌ | ❌ | ✅ | `:363,657` |
|
||||
| `views/module_system/menu/index.vue` | ✅ | ✅ | ❌ | ❌ | ✅ | `:825,1281` |
|
||||
| `views/module_system/dept/index.vue` | ✅ | ✅ | ❌ | ❌ | ✅ | `:272,526` |
|
||||
| `views/module_system/dict/index.vue` (+`DictDataPanel`) | ✅ | ✅ | ❌ | ❌ | ✅ | `:440`、`DictDataPanel.vue:630` |
|
||||
| `views/module_system/notice/index.vue` | ✅ | ✅ | ❌ | ❌ | ✅ | `:501,556` |
|
||||
| `views/module_system/position/index.vue` | ✅ | ✅ | ❌ | ❌ | ✅ | `:514,528` |
|
||||
| `views/module_system/log/index.vue` | ✅ | ✅ | ❌ | ❌ | ✅ | `:415,459,669,713` |
|
||||
| `views/module_system/version/index.vue` | ✅ | ✅ | ❌ | ❌ | ✅ | `:490` |
|
||||
| `views/module_system/ticket/index.vue` | ✅ | ✅ `暂无工单` | ❌ | ❌ | ✅ | 列表 `:516-518`;评论 `:877-880` ❌ |
|
||||
| `views/module_example/demo/index.vue` | ✅ | ✅ | ❌ | ❌ | ✅ | `:636,649` |
|
||||
| `views/module_ai/memory/index.vue` | ✅ | ✅ | ❌ | ❌ | ✅ | `:118,222` |
|
||||
| `views/module_monitor/online/index.vue` | ✅ | ✅ | ❌ | ❌ | ✅ `强退所有` | `:123,256` |
|
||||
| **`views/module_monitor/cache/index.vue`** | **❌ 无效 prop** | ✅ | ❌ | ⚠️ 仅 toast | ✅ | `:116,179`(见 P2-1) |
|
||||
| **`views/module_monitor/server/index.vue`** | **❌** | ✅ 磁盘表 | **❌ 静默** | ❌ | ➖ | `:214-221` |
|
||||
| **`views/module_system/params/index.vue`** | **❌** | ➖ | **❌** | ❌ | ➖(保存无确认,但有「已修改」标记) | `:433-438` |
|
||||
| `views/module_task/cronjob/job/index.vue` | ✅ | ✅ | ❌ | ❌ | ✅ 含「关闭调度器」 | `:744,1005,1017,1085` |
|
||||
| `views/module_task/cronjob/node/index.vue` | ✅ | ✅ | ❌ | ❌ | ✅ | `:382,491` |
|
||||
| `views/module_task/storage/node/index.vue` | ✅ | ✅ | ❌ | ❌ | ✅ | `:445,962` |
|
||||
| `views/module_task/storage/transfer/index.vue` | ✅ | ✅ | ⚠️ 空态文案含「加载失败」 | ❌ | ✅ | `:851,861`(取消/删除均有确认) |
|
||||
| `views/module_task/storage/browse/index.vue` | ✅ | ✅ 区分「加载失败」/「该目录下没有内容」 | ⚠️ 文案级 | ❌ | ✅ | `:254`、`confirmAction:1320-1331`、`deleteRow:1333`、`batchDelete:1349` |
|
||||
| `views/module_task/storage/workflow/index.vue` | ✅ | ✅ | ❌ | ❌ | ✅ | `:401,784`(含删除节点/连线确认) |
|
||||
| `views/module_generator/gencode/index.vue` | ✅ | ✅ | ✅ **全仓唯一** | ✅ 保留了重试路径 | ✅ | `:667-668` |
|
||||
| `views/dashboard/analysis/index.vue` / `workplace/index.vue` | ❌ | 仅图表内建空态 | ❌ | ❌ | ➖ | 见 P3-10 |
|
||||
|
||||
**Web 状态设计小结**:加载态 21/23 ✅、空态 22/23 ✅、**错误态 1/23 ✅**、**重试 1/23 ✅**、危险操作确认 100% ✅(含批量/关闭调度器/清理全部缓存/强退所有/删除工作流节点与连线)。
|
||||
**Web 的「二次确认」是全仓质量最高的一环**,`hooks/core/useConfirm.ts` + `ElPopconfirm` 双轨覆盖到位;短板完全集中在错误态与重试。
|
||||
|
||||
### 3.2 移动端抽样
|
||||
|
||||
| 页面 | 骨架 | 空态 | **错误态** | **重试** | 二次确认 | 下拉刷新 | 触底加载 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `subPages/module_system/tickets/index.vue` | ✅ `:212` | ✅ 区分筛选 `:215` | ❌ 仅 toast `:89` | ❌ | ✅ `uni.showModal:154`(机制见 P2-6) | ✅ `:174` | ✅ `:170` |
|
||||
| `subPages/module_system/notices/index.vue` | ✅ `:138` | ✅ 区分筛选 `:141` | ❌ 仅 toast `:35` | ❌ | ✅ `:101` | ✅ | ✅ `:163` |
|
||||
| `subPages/module_system/ticket-detail/index.vue` | ✅ `:145` | ✅ `:220,244` | **❌ 失败→误显示「不存在」`:244`** | ❌ | ➖ | ✅ | 手动分页 `:237` |
|
||||
| `subPages/module_system/profile/index.vue` | ⚠️ 仅首次 `:141` | **❌ 失败→满屏 `-`** | ❌ `:38-45` | ❌ | ➖ | ✅ | ➖ |
|
||||
| `subPages/module_ai/ai-models/index.vue` | ✅ `:150` | ✅ `:153` | ❌ 仅 toast `:52` | ❌ | ✅ `uni.showModal:106,123` | ✅ | ➖(一次全量) |
|
||||
| `subPages/module_ai/chat/index.vue` | ✅ `:226` | ✅ `:234` | ❌ 仅 toast `:67,101` | ❌ | ✅ `:107` | ➖ | ✅ |
|
||||
| `pages/index/index.vue` | ✅ `:431` | ➖(`-` 占位) | ❌ 静默 `:270-286` | ❌ | ➖ | ✅ | ➖ |
|
||||
| `subPages/setting/index.vue` | ➖ | ➖ | ➖ | ➖ | ✅ `wd-dialog:93` | ➖ | ➖ |
|
||||
| `pages/mine/index.vue` | ✅ | ➖ | ➖ | ➖ | ✅ `:38`(退出登录) | ✅ | ➖ |
|
||||
| `pages/work/index.vue` | ➖ | ✅ `:127` | ➖ | ➖ | ➖ | ➖ | ➖ |
|
||||
| `pages/login/*`、`pages/login/register`、`pages/login/forget` | ➖ | ➖ | ✅ 滑块失败重置+toast `login/index.vue:141,147` | ⚠️ 需手动重滑 | ✅ 注册协议勾选 | ➖ | ➖ |
|
||||
|
||||
**移动端状态设计小结**:骨架/空态/下拉刷新/触底加载/表单校验覆盖良好,**错误态与重试同样是系统性缺口**,且比 Web 更严重的是**错误被渲染成「不存在」**(P1-4)。
|
||||
|
||||
### 3.3 表单校验提示(两端均 ✅)
|
||||
|
||||
- Web:`FaForm` + `rules`,例如 `views/module_system/user/index.vue:130`(`:rules="rules"`)配合 `:736`(`const rules = reactive({...})`);`ElForm` 上挂 `@keyup.enter` 提交(`FaLoginAccountForm.vue:11`)。
|
||||
- 移动端:`wd-form :schema` / `wd-form-item` + 手动校验,例如 `subPages/module_system/tickets/index.vue:131-134`(标题必填 toast)、`pages/mine/index.vue:98-102`(两次密码一致性)。
|
||||
- 差异:Web 依赖 `trigger: "blur"` 的字段级即时提示;移动端多为**提交时**集中校验(如工单标题)。属平台习惯差异,**不算缺陷**,但同一表单两端提示时机不同,需在验收标准中明确。
|
||||
|
||||
---
|
||||
|
||||
## 4. Web 与移动端交互一致性(同操作对比)
|
||||
|
||||
| 操作 | Web | 移动端 | 判定 |
|
||||
|---|---|---|---|
|
||||
| 列表分页 | 页码分页 `FaPagination`(`components/tables/fa-pagination/index.vue:5`),23 个页面复用 | 触底无限加载 `useListPage.loadNext()` + `onReachBottom`(`tickets/index.vue:170-173`) | **合理差异**(平台习惯),但两端都需保证「到底提示」:移动端有 `common.noMore`(`:246`),Web 无等价提示 |
|
||||
| 列表刷新 | `FaTableHeader` 刷新按钮(`refreshData`)+ 列显隐 | 下拉刷新 `enablePullDownRefresh` + `onPullDownRefresh` | **合理差异**;移动端未提供显式刷新按钮,Web 未提供下拉刷新(Web 有 `Ctrl+Enter` 搜索,见下) |
|
||||
| 搜索 | `FaSearchBar` + 展开/收起 + 重置 + **Ctrl/Cmd+Enter 快捷搜索**(`components/forms/fa-search-bar/index.vue:199-205`,提示文案 `table.searchBar.searchTooltip`) | `wd-search` + 筛选 Tab + 下拉类型筛选(`tickets/index.vue:188-209`) | 能力对齐,`@clear="onReset"` 已覆盖清空。**建议**:移动端 `onReset`(`:108-112`)重置了关键词与类型但**保留状态 Tab**,Web 的「重置查询」会清空全部筛选项(`table.searchBar.resetTooltip`=「清除所有筛选条件」)→ **语义不一致,建议统一** |
|
||||
| 批量操作 | ✅ 多选 + 批量删除(`useTableSelection` + `confirmBatchDelete`),11 个页面具备 | ❌ 仅左滑单条删除(`tickets/index.vue:125-129`) | **能力缺口**(移动端通常不需要,但需在需求文档中显式声明为「不支持」而非遗漏) |
|
||||
| 导入 / 导出 | ✅ `FaImportDialog` / `FaExportDialog` / `FaExcelExport` / `FaExcelImport`,按权限 `perm-export` 控制(`user/index.vue:52-53`) | ❌ 无 | **能力缺口**,建议在 `REQUIREMENTS.md` 中登记 |
|
||||
| 新增/编辑 | `FaDrawer` 抽屉 + `FaForm` + `:confirm-loading` 防重复提交(`user/index.vue:85-95`) | `wd-popup` 底部弹窗 + 手动 `submitting`/`loading`(注意 P3-7) | 形式差异合理;**移动端提交中的防重复覆盖不全** |
|
||||
| 危险操作确认 | `ElMessageBox.confirm`(统一 `type: warning`)+ `ElPopconfirm` | 混用 `uni.showModal` 与 `wd-dialog` | **不一致 → P2-6** |
|
||||
| 退出登录 | `ElMessageBox.confirm` + i18n(`layouts/fa-header-bar/widgets/FaUserMenu.vue:151`) | `globalDialog.confirm` + i18n(`pages/mine/index.vue:38-44`) | ✅ 一致(且都用 i18n) |
|
||||
| 工单状态展示 | `FaStatusTag` 颜色映射 | `StatusBadge` 颜色映射 | **颜色语义不一致 → P2-8** |
|
||||
| 状态切换(启用/停用) | `handleMoreClick("enable"/"disable")` → `status/batch` | 编辑弹窗内 `wd-radio-group` 单选 | 交互路径不同但结果一致;**词形不一致(停用 vs 禁用)→ P2-8** |
|
||||
| 空态文案 | 固定「暂无数据」,不区分筛选 | 区分 `empty` / `emptyWithFilter` | **不一致 → P2-7** |
|
||||
|
||||
---
|
||||
|
||||
## 5. 可访问性(静态证据 + 需人工项)
|
||||
|
||||
### 5.1 可用静态代码证明(计入缺陷)
|
||||
|
||||
| 项 | 证据 | 级别 |
|
||||
|---|---|---|
|
||||
| 无 label 的表单控件 | Web `ElFormItem` 70 处中 **27 处无 `label`**(第 2.3 节 P2-3);移动端 `wd-form-item` label 覆盖完整 | P2 |
|
||||
| 图标按钮无可访问名 | Web **12 处**(P2-4);移动端 `wd-icon` 均配可见文字或为装饰 | P2 |
|
||||
| 自定义可点击元素键盘不可达 | Web **76 处** `@click` 的 div/span/li/td 无 `tabindex`/`role=`/`@key*`,仅 3 处实现正确模式(P2-2) | P2 |
|
||||
| ARIA 使用量 | Web 全仓:`aria-label` ×7、`aria-hidden` ×5、**`aria-live` ×0、`aria-describedby` ×0、`aria-expanded` ×0**;移动端 `pages/subPages/layouts/components` 下 **aria-\* = 0** | P2 |
|
||||
| 图片替代文本 | Web 24 个图片标签中 5 个无 `alt`(P3-12);移动端 5 个 `<image>` 均无 `alt`(P3-13) | P3 |
|
||||
| 焦点可见性 | Web 有 10 个文件定义了 `:focus-visible`/`:focus` 样式(`styles/element-plus/_overrides.scss:470-474`、`layouts/fa-work-tab/index.vue:818-823`、`views/module_ai/chat/components/FaWelcomeScreen.vue:110-113` 等),并有 2 处 `outline: none` 但**均提供了替代视觉反馈**(`styles/pages/_login.scss:411` + `:419-422` 的 focus 边框;`fa-work-tab/index.vue:819` + `:820` 的 inset 阴影)→ **未发现「移除焦点但无替代」的缺陷** | 通过 |
|
||||
| 快捷键 | `Ctrl/Cmd+Enter` 提交搜索已实现并配提示文案 | 通过 |
|
||||
| 语义化结构 | `layouts/index.vue:15,20` 使用 `<aside aria-label>` / `<main aria-label>`;`layouts/fa-breadcrumb/index.vue:3` 用 `<nav aria-label>` | 通过 |
|
||||
|
||||
### 5.2 不能用静态代码判断(**不得计为缺陷**,验证步骤见第 8 节)
|
||||
|
||||
- 对比度(需渲染后取实际前景/背景,尤其暗色模式下 P2-10 的硬编码灰色)。
|
||||
- 屏幕阅读器实际朗读(`el-tooltip` 包裹的图标按钮是否可被读出名称;`ElMessage` toast 是否有 `aria-live` 通告——源码里没有,但 EP 内部实现需实测确认)。
|
||||
- 触摸目标尺寸(移动端,需真机量取;仅能确认存在 `size="small"` 与 `w-4 h-4` 等小尺寸类,如 `views/dashboard/home/modules/quick-links.vue:35` 的 16px 删除按钮)。
|
||||
- 键盘 Tab 顺序与焦点陷阱(`ElDialog`/`wd-popup` 内)。
|
||||
- 移动端 H5 构建下的键盘可达性与小程序端 `aria-role` 支持。
|
||||
|
||||
---
|
||||
|
||||
## 6. 设计令牌偏离统计
|
||||
|
||||
### 6.1 令牌体系现状(三套并存)
|
||||
|
||||
| 端 | 令牌载体 | 说明 |
|
||||
|---|---|---|
|
||||
| Web | ① `src/styles/tailwind.css` 的 `@theme`(字号 5 档 + `--color-primary/g-100..900` 等映射到 `--fa-*`);② 同文件 `:root` / `.dark` 的 `--fa-*`、`--default-*`、`--app-bg`(约 **45 个** CSS 变量);③ `src/styles/core/_fa-tokens.scss`(SCSS 侧 6 个功能色,文件头注释明确声明「整个项目只有这一处定义 success/warning/danger/error/info 的 hex 值」);④ `--custom-radius`(用户可调) | 设计意图清晰,但 `_fa-tokens.scss:4` 的「单一来源」约定**已被违反**(见 6.3) |
|
||||
| 移动端 | ① `presetWot({ baseTokens: true })`(`uno.config.ts:27-29`)产出的 `--wot-*`;② `theme.json`(uni 原生暗色);③ `uni.scss`(uni 内置 SCSS 变量);④ 本项目自建 `--drop-*`(`styles/theme.scss`);⑤ `composables/types/theme.ts` 的 `primaryShades` | 四份主题色相关定义重复(P2-9) |
|
||||
| 文档站 | VitePress 默认主题,无共享令牌 | — |
|
||||
|
||||
### 6.2 硬编码色值统计
|
||||
|
||||
**Web**(`frontend/web/src`,`regex #hex{3,8}`):
|
||||
|
||||
| 分区 | 出现次数 | 文件数 |
|
||||
|---|---|---|
|
||||
| `src/styles/**`(设计体系层,**合理**) | 64 | 8 |
|
||||
| `views/module_task` | 38 | 12 |
|
||||
| `views/dashboard` | 37 | 9 |
|
||||
| `components/`(charts 27 + 其它 38) | 65 | 17 |
|
||||
| `views/module_system` | 20 | 6 |
|
||||
| `config/` + `hooks/` + `router/` | 66 | 5 |
|
||||
| `views/fastlink` / `module_generator` / `layouts` | 10 | 4 |
|
||||
| **合计** | **300**(其中 `src/styles/**` 外 **236**) | 58 |
|
||||
|
||||
**`src/styles/**` 之外的 TOP 文件**:`config/index.ts` 31、`hooks/core/useChart.ts` 21、`views/module_task/storage/browse/index.vue` 19、`views/dashboard/home/modules/banner.vue` 13、`components/charts/fa-map-chart/index.vue` 13、`views/module_system/auth/login/components/backdrops/FaLoginLeftView.vue` 10、`views/module_task/storage/workflow/components/protocol.ts` 9、`config/modules/fastEnter.ts` 8、`components/forms/fa-drag-verify/index.vue` 8。
|
||||
|
||||
**高频值与分布**(Web 全仓):
|
||||
|
||||
| 值 | 次数 | 性质 | 样例 |
|
||||
|---|---|---|---|
|
||||
| `#fff`/`#ffffff` | 27+3 | 中性(多数合理) | `styles/**` |
|
||||
| `#909399` | 16 | **EP 默认 info 灰**(绕过 `--el-text-color-secondary`) | `views/module_task/storage/browse/index.vue:88,213,233,284,289,433,444,471,476,492,514`、`views/module_generator/gencode/components/FaGenPreviewStep.vue:16,22`、`views/module_task/storage/workflow/index.vue:99` |
|
||||
| `#333` | 8 | 浅色专用文本色(暗色下不可读) | `components/banners/fa-basic-banner/index.vue:156,173`、`components/charts/fa-map-chart/index.vue:188`、`views/dashboard/home/modules/banner.vue:62`、`views/dashboard/home/modules/it_banners.vue:53`、`components/forms/fa-drag-verify/index.vue:91` |
|
||||
| `#409eff` | 6 | **EP 默认主色**(应为 `--el-color-primary`/`--theme-color`) | `views/fastlink/pricing/index.vue:348,377,385`、`views/module_task/storage/workflow/components/FaEdgeConfigPanel.vue:73,147`、`views/module_task/storage/workflow/components/protocol.ts:7-9` |
|
||||
| `#e6a23c` | 5 | EP 默认 warning(应为 `--el-color-warning`) | `views/module_task/storage/browse/index.vue:221,260,503`、`protocol.ts:10-13`、`FaFileBrowserDialog.vue:58` |
|
||||
| `#c0c4cc` | 4 | EP 默认 disabled 文本 | `views/module_task/storage/browse/index.vue:275,1685`、`FaDynamicEdge.vue:58`、`FaWorkflowDesignDrawer.vue:654` |
|
||||
| `#67c23a` | 1 | EP 默认 success | `components/cards/fa-progress-card/index.vue:56` |
|
||||
| `#f56c6c` | 1 | EP 默认 danger | `views/module_system/auth/login/components/forms/FaLoginAccountForm.vue:81` |
|
||||
| `#606266` | 1 | EP 默认 regular 文本 | `views/module_task/storage/browse/index.vue:348` |
|
||||
| `#f5f7fa` | 1 | EP 默认填充色 | `components/cards/fa-image-card/index.vue:12` |
|
||||
|
||||
> **判定**:`_fa-tokens.scss:1-4` 声明「整个项目只有这一处定义功能性色 hex」,但上述 EP 默认语义色在**业务代码**中被反复硬编码,**共 51 处**(`#909399`16 + `#409eff`6 + `#e6a23c`5 + `#c0c4cc`4 + `#67c23a`1 + `#f56c6c`1 + `#606266`1 + `#f5f7fa`1 + `#67C23A`1 + 其它)。这直接导致:用户切换主题色 / 切换暗色模式时这些元素**不跟随**。
|
||||
|
||||
**移动端**(`frontend/app/src`,排除 `uni_modules`):
|
||||
|
||||
| 分区 | 出现次数 |
|
||||
|---|---|
|
||||
| 令牌源(`composables/types/theme.ts`、`App.ku.vue`、`styles/theme.scss`、`uni.scss`、`theme.json`) | 127 |
|
||||
| `pages/login/index.vue` | 26(其中 `:327,335,343,351` 为微信/QQ/Gitee/GitHub **第三方品牌色,属合理豁免**;其余为 `var(--wot-*, #fallback)` 形式的 fallback) |
|
||||
| `pages/index/index.vue` | 16(ECharts 系列色 + `#FFFFFF` 文本 + `isDark` 双色) |
|
||||
| `store/themeStore.ts` | 11(`THEME_NAV_BG` 与 theme.scss 重复,P2-9) |
|
||||
| `pages/login/register` / `forget/index.vue` | 9 / 9(同上,fallback 形式) |
|
||||
| `composables/useGlobalToast.ts` | 4(`#07c160`/`#fa5151`/`#10aeff`/`#ff9f0a`,微信语义色,**合理豁免**) |
|
||||
| 其余 5 个文件 | 各 1-2 |
|
||||
| **`src/styles/**` 之外的合计** | **81 / 11 文件** |
|
||||
|
||||
**结论**:移动端 81 处中约 **60 处**是 `var(--wot-primary-6, #1C64FD)` 这类「带 fallback 的引用」写法(引用令牌但硬编码兜底值——**严格说是重复真值源,但不是绕过令牌**),真正的绕过集中在 `pages/index/index.vue:173-174,237-238,247-248,366,437,454,471,587,681,688,712` 与 `subPages/module_system/tickets/index.vue:223`、`pages/login/index.vue:587` 等。**共享组件层(`components/SkeletonPage.vue`、`StatusBadge.vue`、`GlobalDialog.vue` 等)几乎全部使用 wot 令牌**,这是移动端明显优于 Web 的地方。
|
||||
|
||||
### 6.3 圆角 / 字号 / 间距偏离
|
||||
|
||||
| 类别 | Web 硬编码统计 | 令牌替代 |
|
||||
|---|---|---|
|
||||
| 圆角 | `6px`×17、`8px`×14、`4px`×13、`999px`×4、`12px`×3、`2px`×2 等(另 `50%`×18 属合理) | `rounded-custom-xs/sm`(`tailwind.css:66-72`)、`--custom-radius`、`--el-border-radius-base`(`_overrides.scss:34-37` 已映射) |
|
||||
| 字号 | `<style>` 内 `12px`×32、`14px`×30、`13px`×25、`16px`×13、`11px`×7…;任意值 class **33 处** | `--font-size-xs..3xl`(11/12/13/15/18/22/28) |
|
||||
| 间距 | `<style>` 内 `padding/margin/gap` 固定 px 共 **231 处**(`8px`×61 占比最高) | Tailwind 间距刻度(0.5/1/2/3/4…) |
|
||||
|
||||
> **注意**:14px 这个最高频字号在 `@theme` 中**没有对应档位**(`--font-size-base: 13px`、`--font-size-lg: 15px`),说明令牌刻度与实际设计稿存在缺口 → 建议补 `--font-size-md: 14px`。这条是「令牌不完整」而非「开发者不守规矩」,建议在整改时区分对待。
|
||||
|
||||
---
|
||||
|
||||
## 7. 中文/英文文案与术语一致性
|
||||
|
||||
### 7.1 正向结论(先说不该修的)
|
||||
|
||||
| 项 | 事实 |
|
||||
|---|---|
|
||||
| locale 键完整性 | Web `zh.json`/`en.json` 各 509 键,**键集合完全相同**(无缺失、无多余);移动端各 331 键,**完全相同** |
|
||||
| 命名空间 | Web 24 个顶层命名空间(`common`、`login`、`register`、`setting`、`menus`、`table`、`httpMsg`…);移动端 17 个(`common`、`login`、`register`、`setting`、`tickets`、`notices`…) |
|
||||
| 移动端页面 i18n 覆盖率 | 14/14 页面(100%),模板内硬编码中文仅 1 文件 4 字符(微信隐私协议,合理) |
|
||||
| 移动端空态文案 | 10 个专用键,且区分 `empty` / `emptyWithFilter` |
|
||||
| 文档站 | zh/en 文件 1:1(各 9 篇) |
|
||||
|
||||
### 7.2 不一致清单
|
||||
|
||||
| 项 | Web | 移动端 | 判定 |
|
||||
|---|---|---|---|
|
||||
| 顶层命名空间复用率 | 24 个 | 17 个 | **仅 4 个共享**(`common`/`login`/`register`/`setting`),**两个字面 shared 键的英文值 10 个里有 5 个措辞不同**:`login.noAccount`(`No account yet?` vs `Don't have an account?`)、`login.sliderSuccessText`(`Verification successful` vs `Verified`)、`login.sliderText`(`Please slide to verify` vs `Slide to verify`)、`register.title`(`Create account` vs `Create Account`)、`register.toLogin`(`To login` vs `Login`)→ 建议建共享词表 |
|
||||
| 「启用/停用」 | 停用(`common` 侧 73 处字面量) | 禁用(`common.status.disabled`) | 不一致 |
|
||||
| 同一实体「公告」 | 业务页「公告通知」/ 顶栏「通知」 | 「通知公告」 | 三选一 |
|
||||
| 状态词表 | 各页面各写一份(`user/role/position/notice` 共 73 处字面量) | 集中 `common.status.*`(14 个键) | Web 缺少集中词表 |
|
||||
| 错误提示语言 | 2 处硬编码中文(`utils/http/index.ts:273,277`),其余 `$t` | **全部硬编码中文**(`http/tools/enum.ts` + `http/adapters/alova.ts`,约 15 处) | 两端均有缺口,移动端更彻底 |
|
||||
| 文档站移动端术语 | 中文「移动端开发指南」 | 英文「Miniprogram Development Guide」(正文含 App/H5) | 不一致(P2-12) |
|
||||
|
||||
---
|
||||
|
||||
## 8. 需人工 / 真机验证清单
|
||||
|
||||
> 以下项**均不得当作已确认缺陷**;每项给出可直接执行的验证步骤。本轮未启服务、未上真机,故不下结论。
|
||||
|
||||
### 8.1 视觉与对比度
|
||||
|
||||
1. **暗色模式硬编码灰的可读性**(对应 P2-10)
|
||||
- 步骤:`cd frontend/web && pnpm dev` → 打开 `/module_task/storage/browse`、`/module_generator/gencode` → 右上角切「暗色模式」→ 用浏览器 DevTools 拾色器取 `text-[#909399]` 文字与其实际背景的对比度。
|
||||
- 判定:WCAG 2.1 AA 正文需 ≥ 4.5:1。
|
||||
2. **深色模式冷启动是否出现原生背景色差**(对应 P2-9)
|
||||
- 步骤:真机(Android/iOS)系统切深色 → 冷启动 App(先杀进程)→ 录屏并逐帧观察导航栏/页面顶部是否出现 `#000000` → `#272B3B` 的跳变;同时测下拉回弹区。
|
||||
3. **`theme.json` 是否真的被原生层应用**(对应 P2-9)
|
||||
- 步骤:构建小程序/App 后检查产物中 `app.json` 的 `themeLocation`,并在系统深色下观察窗口背景。若未生效,则 P2-9 应降级为「死配置」而非「色差」。
|
||||
4. **暗色模式下圆角/间距令牌切换是否生效**(对应 P2-11)
|
||||
- 步骤:Web 设置面板切换圆角档位,逐页观察 6px/8px 元素是否跟随。
|
||||
|
||||
### 8.2 屏幕阅读器与键盘
|
||||
|
||||
5. **`el-tooltip` 包裹的图标按钮是否可被读出名称**(对应 P2-4)
|
||||
- 步骤:VoiceOver(macOS Safari/Chrome)或 NVDA(Windows Chrome)逐 Tab 遍历 `/module_task/storage/browse` 的后退/前进/刷新按钮,听是否读出「后退/前进/刷新」。
|
||||
- 若读出 → P2-4 中的 tooltip 类可豁免;未读出 → 结论升级。
|
||||
6. **表单无 label 的实际影响**(对应 P2-3)
|
||||
- 步骤:在登录页 `/login`,用键盘 Tab 定位用户名/密码输入框,听 SR 是否朗读名称;再输入内容后确认 SR 是否仍能识别字段。
|
||||
7. **自定义可点击元素的 Tab 顺序**(对应 P2-2)
|
||||
- 步骤:在 `/dashboard/home` 全程 Tab,确认「返回顶部」「设置面板关闭」「顶栏搜索」是否可达。
|
||||
8. **弹窗焦点陷阱与 Esc 关闭**(ElDialog / wd-popup)
|
||||
- 步骤:打开 `FaDrawer` 与 `wd-popup`,Tab 是否循环在弹窗内、Esc 是否关闭、关闭后焦点是否回到触发按钮。
|
||||
9. **toast 是否有无障碍通告**(`ElMessage` / `wd-toast`)
|
||||
- 步骤:触发一次失败请求,SR 是否播报错误。源码层未见 `aria-live`,需实测(EP 内部可能实现)。
|
||||
|
||||
### 8.3 移动端真机
|
||||
|
||||
10. **触摸目标尺寸**(WCAG 2.5.8 建议 ≥ 24×24 CSS px,iOS HIG 建议 44×44)
|
||||
- 步骤:真机打开工单列表,长按/开发者模式量取左滑删除按钮、`wd-fab`、Tabbar 项、以及 `views/dashboard/home/modules/quick-links.vue` 同类的 16px 小按钮(Web 侧是 `w-4 h-4`)。
|
||||
11. **安全区与底部遮挡**(`env(safe-area-inset-bottom)`)
|
||||
- 步骤:全面屏机型(iPhone 15 / Android 手势条),检查工单页浮动按钮(`tickets/index.vue:252` `:gap="{ bottom: 40 }"`)与底部 tabbar 是否遮挡列表最后一条、`ticket-detail` 输入栏(`:253`)是否被 Home 条覆盖。
|
||||
12. **`wd-slide-verify` 在真机的初始化与重置**(`pages/login/index.vue:64-85` 已针对小程序布局时序做处理)
|
||||
- 步骤:微信小程序 + iOS/Android App + H5 三端各登录失败 2 次,确认滑块能出现、能重置、能通过。
|
||||
13. **导航栏标题首屏是否闪中文**(对应 P3-9)
|
||||
- 步骤:语言设为 English → 冷启动 → 录屏观察导航栏标题是否先显示中文再被 `useI18nNavTitle` 覆盖。
|
||||
14. **`uni.showModal` 在 H5 下的实际外观**(对应 P2-6)
|
||||
- 步骤:`pnpm dev:h5` 打开工单页左滑删除,观察是否弹浏览器原生 confirm(外观与 App 内其他对话框不一致)。
|
||||
15. **下拉刷新与触底加载的边界**
|
||||
- 步骤:弱网(DevTools/真机限速)+ 空列表下拉、最后一页触底,确认 `uni.stopPullDownRefresh()` 一定被调用(`useListPage.ts:66-79` 的 `finally` 已保证,但需实测不会卡住指示器)。
|
||||
|
||||
### 8.4 需求/契约层(需与需求方确认,非缺陷)
|
||||
|
||||
16. 移动端**不支持批量操作与导入导出**(`REQUIREMENTS.md` 是否登记为已知范围外?)。
|
||||
17. 移动端**搜索重置保留状态 Tab** 是否符合两端统一的预期语义(第 4 节)。
|
||||
18. `notice.status` 的正确语义(P1-1)请后端确认 `model.py:16` 注释即权威,并确认线上是否已有 `status=2` 数据。
|
||||
19. 移动端 `loginFormData.captcha = 'verified'` 占位值(`pages/login/index.vue:138`)是后端约定还是历史遗留(`// 占位值,后端只校验 captcha_key 状态`),需与后端确认后端是否确实忽略坐标校验。
|
||||
20. Web 端将语言切到 English 后,**业务菜单名来自后端数据库**(不是 locales),因此即使补齐前端 i18n,菜单仍是中文——需确认后端是否提供 i18n 字段(这决定 P1-2 的整改半径)。
|
||||
|
||||
---
|
||||
|
||||
## 9. 附录:统计口径与复现命令
|
||||
|
||||
所有统计均在仓库根目录 `/Users/tao/workspace/FastapiAdmin` 下执行,只读。
|
||||
|
||||
**模板内硬编码中文(第 1 节 / P1-2)**
|
||||
```bash
|
||||
# 口径:取 <template> 到 <script> 之间的片段,剔除 <!-- --> 注释后,
|
||||
# 统计 (a) 非 : 开头属性值含 CJK 的出现次数,(b) 纯文本节点含 CJK 的出现次数
|
||||
python3 - <<'PY'
|
||||
import re, os
|
||||
cjk = re.compile(r'[\u4e00-\u9fff]')
|
||||
files=tot=0; hits=0
|
||||
for root,dirs,fs in os.walk('frontend/web/src'):
|
||||
for f in fs:
|
||||
if not f.endswith('.vue'): continue
|
||||
s=open(os.path.join(root,f),encoding='utf-8').read()
|
||||
m=re.search(r'<template>(.*?)</template>\s*(?=<script)', s, re.S)
|
||||
if not m: continue
|
||||
tpl=re.sub(r'<!--.*?-->','',m.group(1),flags=re.S); files+=1; n=0
|
||||
for am in re.finditer(r'([a-zA-Z:@\-\[\]]+)\s*=\s*"([^"]*)"', tpl):
|
||||
if not am.group(1).startswith(':') and cjk.search(am.group(2)): n+=1
|
||||
for tm in re.finditer(r'>([^<>{}]*[\u4e00-\u9fff][^<>{}]*)<', tpl):
|
||||
if tm.group(1).strip(): n+=1
|
||||
if n: tot+=1; hits+=n
|
||||
print(f"files={files} files_with_cjk={tot} cjk_occurrences={hits}")
|
||||
PY
|
||||
# → files=215 files_with_cjk=87 cjk_occurrences=1063
|
||||
```
|
||||
|
||||
**i18n 覆盖率(P1-2)**
|
||||
```bash
|
||||
cd frontend/web/src && T=0; N=0
|
||||
for f in $(find views -name "*.vue"); do T=$((T+1)); grep -q '\$t(\|useI18n' "$f" || N=$((N+1)); done
|
||||
echo "views=$T no_i18n=$N" # → views=101 no_i18n=85
|
||||
cd ../../app/src && T=0; N=0
|
||||
for f in $(find pages subPages -name "*.vue"); do T=$((T+1)); grep -q '\$t(\|useI18n' "$f" || N=$((N+1)); done
|
||||
echo "pages=$T no_i18n=$N" # → pages=14 no_i18n=0
|
||||
```
|
||||
|
||||
**硬编码色值(第 6.2 节)**
|
||||
```bash
|
||||
# Web:全量
|
||||
grep -rhoiE "#[0-9a-f]{3,8}\b" --include="*.vue" --include="*.scss" --include="*.ts" --include="*.css" frontend/web/src | wc -l # → 300
|
||||
# Web:排除设计体系层
|
||||
grep -rniE "#[0-9a-f]{3,8}\b" --include="*.vue" --include="*.scss" --include="*.ts" frontend/web/src | grep -v "/styles/" | wc -l # → 236 / 50 文件
|
||||
# 移动端:排除 uni_modules 与令牌源
|
||||
grep -rniE "#[0-9a-f]{3,8}\b" --include="*.vue" --include="*.ts" --include="*.scss" frontend/app/src \
|
||||
| grep -vE "uni_modules|composables/types/theme.ts|App\.ku\.vue|uni\.scss" | wc -l # → 81 / 11 文件
|
||||
```
|
||||
|
||||
**键盘可达性(P2-2)**
|
||||
```bash
|
||||
# 口径:<div|span|li|td> 且同标签内含 @click,且不含 tabindex / role= / @key*
|
||||
python3 - <<'PY'
|
||||
import re, os
|
||||
n=0
|
||||
for root,dirs,fs in os.walk('frontend/web/src'):
|
||||
for f in fs:
|
||||
if not f.endswith('.vue'): continue
|
||||
s=open(os.path.join(root,f),encoding='utf-8').read()
|
||||
for m in re.finditer(r'<(div|span|li|td)\b([^>]*?)>', s, re.S):
|
||||
a=m.group(2)
|
||||
if ('@click' in a or 'v-on:click' in a) and not ('tabindex' in a or '@key' in a or 'role=' in a): n+=1
|
||||
print(n) # → 76
|
||||
PY
|
||||
grep -rn 'role="button"\|tabindex' --include="*.vue" frontend/web/src # → 正确模式仅 3 处
|
||||
```
|
||||
|
||||
**ElTable `loading` 无效属性(P2-1)**
|
||||
```bash
|
||||
grep -rn "loading" frontend/web/node_modules/element-plus/es/components/table/src/table/defaults.d.ts | head
|
||||
# loading 位于 interface TreeNode(:292),TableProps(49 个 prop)不含 loading
|
||||
grep -rn "loading" frontend/web/node_modules/element-plus/es/components/table/src/table.mjs # → 无命中
|
||||
```
|
||||
|
||||
**locale 键对齐(7.1)**
|
||||
```bash
|
||||
python3 -c "
|
||||
import json
|
||||
def flat(d,p=''):
|
||||
o={}
|
||||
for k,v in d.items():
|
||||
kk=f'{p}.{k}' if p else k
|
||||
o.update(flat(v,kk)) if isinstance(v,dict) else o.__setitem__(kk,v)
|
||||
return o
|
||||
for b in ['frontend/web/src/locales/langs','frontend/app/src/locales/langs']:
|
||||
en=set(flat(json.load(open(b+'/en.json',encoding='utf-8'))))
|
||||
zh=set(flat(json.load(open(b+'/zh.json',encoding='utf-8'))))
|
||||
print(b, len(en), len(zh), 'only-en', sorted(en-zh), 'only-zh', sorted(zh-en))
|
||||
"
|
||||
# → web 509/509 [] [];app 331/331 [] []
|
||||
```
|
||||
|
||||
**其他一次性核对命令**
|
||||
```bash
|
||||
grep -c "ElFormItem\|el-form-item" -r frontend/web/src --include="*.vue" # form-item 70 处,无 label 27 处
|
||||
grep -rn "useListPage" -A 12 frontend/app/src/pages frontend/app/src/subPages # error 未被任何页面消费
|
||||
grep -rn "void=ElMessageBox\|ElPopconfirm" frontend/web/src --include="*.vue" # 二次确认覆盖:11+ 文件
|
||||
sed -n '16p' backend/app/modules/system/notice/model.py # 状态(0:草稿 1:已发布 2:已归档)
|
||||
```
|
||||
|
||||
**本轮未执行的验证方式(重要)**:未运行 `pnpm dev`/`pnpm build`/`vitest`/`eslint`/`stylelint`,未截图,未上真机,未做页面性能与网络抓包。所有「未验证」结论已在正文与第 8 节标明。
|
||||
|
||||
---
|
||||
|
||||
## 10. 建议整改优先级(供 captain 汇总用)
|
||||
|
||||
| 顺序 | 动作 | 覆盖问题 | 预估成本 |
|
||||
|---|---|---|---|
|
||||
| 1 | 修正 Web 通知 `status` 枚举(+2 已归档,改 0 草稿/1 已发布) | P1-1 | 1 文件 / 3 处 |
|
||||
| 2 | `FaTable` 增加 `error`/`retry` prop,移动端增加 `ErrorState` 组件并消费 `error` | P1-3、P2-7 | 2 个共享组件 + 16/2 页接入 |
|
||||
| 3 | 移动端区分「加载失败」与「不存在/空」,修 3 个页面 | P1-4 | 3 文件 |
|
||||
| 4 | 共享组件 + HTTP 层文案先走 i18n(Web 8 个文件 / 移动端 2 个文件) | P1-2 的 80% 收益、P2-5、P3-1、P3-3 | 10 文件 |
|
||||
| 5 | `useClickable`/`aria-label` 批量补齐 + 修 `module_monitor/cache` 的 `v-loading` | P2-1、P2-2、P2-4 | 机械替换 |
|
||||
| 6 | 抽 `constants/status.ts` 统一状态词表与色板,两端共用 | P2-8、P3-11 | 新增 1 文件 + 6 文件 |
|
||||
| 7 | 移动端 `theme.scss` 收敛为唯一主题真值源,对齐 `theme.json` | P2-9、P2-10(App 侧) | 3 文件 |
|
||||
| 8 | Web EP 默认色 → 语义令牌;`--font-size-md` 补齐;stylelint 规则拦截新增硬编码 | P2-10、P2-11 | 渐进 |
|
||||
| 9 | 清理死代码与死样式 | P3-4、P3-5、P3-6 | 4 文件 |
|
||||
Reference in New Issue
Block a user