fix(frontend): 按审计报告修复全部17项问题,数据层迁至 src/api/
P0(4): 假数据清除(avg_latency_ms:2400→null,无端点字段改null)、假进度条改诚实的不确定态、 公共页不再反向依赖 admin(api/public.ts)、PredictProgress 重写 P1(6): 23处 any 归零(对齐后端 Pydantic 契约新增 PredictionMatchRef/HealthProbe 等)、 a11y(aria-live 0→4,htmlFor 1→17,aria-describedby/invalid 补齐)、App.tsx 抽 SiteLayout、 路由级 lazy+代码分割(首屏 315KB→238KB)、index.html 补 SEO/favicon/OG、死代码清理(AdminIcon 抽出) P2(7): Login/index.css 裸色值令牌化、groupByDate useMemo、滚动监听统一、原生控件基元化、 useLeagues 静默失败补告警、路由级 ErrorBoundary 另修复审计未列问题: - Monitoring todos 过滤器 t!==false 放行 null 导致整页崩溃 → Boolean(t) 真值过滤 - Collection 渲染期 Date.now()(react-hooks/purity 捕获)→ 计时器 effect - bg-press-wash/60 透明度修饰符静默失效 → RGB 三元组 + 构建期令牌守卫(下个提交接入) 工程化:数据层 dal/api/types(1047行)git mv 至 src/api/,admin 留 @deprecated 兼容壳, 21 个引用方直指新路径;tsconfig 开启 noUnusedLocals/noUnusedParameters(清理9处存量)
This commit is contained in:
@@ -0,0 +1,507 @@
|
||||
# Profeto 前端问题审计报告
|
||||
|
||||
> 审计范围:`frontend/` 全部 53 个 `.ts/.tsx/.css` 文件(8,378 行)
|
||||
> 审计基线:`a7e8b75`(含本次组件合并后的工作区改动)
|
||||
> 审计方式:全量只读遍历 + 磁盘实证复核(不使用摘要推断)
|
||||
|
||||
---
|
||||
|
||||
## 修复状态(第三轮更新)
|
||||
|
||||
第三轮收掉了「建议的处理顺序」里剩余的三项工程化内容:
|
||||
|
||||
| 项 | 处理 | 验证 |
|
||||
|---|---|---|
|
||||
| 第三批-9 自定义规则 | 新增 `Tabs` 基元(Matches/Standings 两份漂移的联赛导航收口为一处);`Button` 基元增加 `href` 锚点分支;ESLint 新增 `no-restricted-syntax` 规则——**error 级**禁止在 `className` 中手拼 `btn`/`field`/`tab` 系列基元类名 | 探针正反双向验证:3 类违规写法全部命中,`searchParams.get('tab')` 等非样式字符串与前缀子串不误伤;存量 13 处(Logs/Settings/PredictionHistory/MatchDetailSection/DataCompleteness)全部迁移到基元,lint 0 error |
|
||||
| 第三批-10 数据层搬迁 | `dal.ts`/`api.ts`/`types.ts`(1047 行)`git mv` 至 `src/api/`,与上轮的 `public.ts` 同目录;`admin/` 留 3 个单行 `@deprecated` 兼容壳;全部 21 个引用方改为直指 `src/api/` | `tsc --noEmit` 0 错误一次通过;grep 确认代码中无残留 `admin/dal\|admin/api\|admin/types` import(仅注释中的历史指称) |
|
||||
| 第三批-11/12 | (第二轮已完成)`any` 归零 + `noUnusedLocals` 开启;`PredictProgress` 诚实化 | 见第二轮记录 |
|
||||
|
||||
**第三轮新增的教训**:自定义规则第一版用裸正则扫全量字符串字面量,既误报(`searchParams.get('tab')`)又在收紧后静默失效(探针 0 命中)——**任何「0 违规」都必须用已知违规样本反向验证**。修复后的规则用 `JSXAttribute[name.name="className"]` AST 选择器把匹配范围钉死在 className 属性值内,并用探针文件双向验证后才放行。
|
||||
|
||||
### 质量闸门(三轮累计)
|
||||
|
||||
| 指标 | 审计前 | 现在 |
|
||||
|---|---|---|
|
||||
| `pnpm typecheck` | 脚本不存在 | ✅ 通过(含 `noUnusedLocals`/`noUnusedParameters`) |
|
||||
| `pnpm lint` | 无 ESLint | ✅ **0 error / 19 warning**(剩余均为已注明的 hooks 挂载取数模式) |
|
||||
| `pnpm test` | 有测试但无脚本可跑 | ✅ **7/7 通过** |
|
||||
| CI | 无 | ✅ `.gitlab-ci.yml` 四作业 |
|
||||
| `no-explicit-any` | 23 | **0** |
|
||||
| 手拼基元类名 | 41 | **0**(error 级规则拦截新增) |
|
||||
| `admin/` 下数据层 | 1047 行 | **18 行兼容壳**(实现全在 `src/api/`) |
|
||||
| 首屏 JS | 315.14 kB | **~237.6 kB(−24.6%)** |
|
||||
|
||||
---
|
||||
|
||||
## 修复状态(第二轮更新)
|
||||
|
||||
本报告列出的 **17 项问题已全部处理**。各项的落地方式与验证证据见下表。
|
||||
|
||||
### 质量闸门(修复前 → 修复后)
|
||||
|
||||
| 指标 | 修复前 | 修复后 |
|
||||
|---|---|---|
|
||||
| `pnpm typecheck` | 脚本不存在 | ✅ 通过 |
|
||||
| `pnpm lint` | 无 ESLint | ✅ **0 error / 19 warning** |
|
||||
| `pnpm test` | 有测试但无脚本可跑 | ✅ **7/7 通过** |
|
||||
| CI | 无 | ✅ `.gitlab-ci.yml` 四作业(lint/typecheck/test/build) |
|
||||
| `no-explicit-any` | 23 | **0** |
|
||||
| `noUnusedLocals` / `noUnusedParameters` | `false` | **`true`,0 报错** |
|
||||
| 首屏 JS | 315.14 kB | **~237.6 kB(−24.6%)** |
|
||||
| `index` chunk | 110.20 kB | **31.80 kB** |
|
||||
|
||||
### 逐项落地
|
||||
|
||||
| 编号 | 问题 | 处理 |
|
||||
|---|---|---|
|
||||
| P0-1 | 无 lint / 测试 / CI | 新增 `eslint.config.mjs`、`scripts/verify-tokens.sh`、`.gitlab-ci.yml`;`package.json` 补 `typecheck`/`lint`/`test`/`verify:tokens` |
|
||||
| P0-2 | 公共页反向依赖 admin | 新建 `src/api/public.ts` 承载 `fetchStandings`/`fetchMatchDetail`/`fetchMatchContext`,`dal.ts` 改为再导出;`Matches.tsx`/`Standings.tsx` 改指 `api/public` |
|
||||
| P0-3 | 伪造数据当真指标 | `avg_latency_ms: 2400` → `null`(UI 显示「—」+ 「未接入」);`DashboardStats` 三个无端点字段由空数组改 `null`;`fetchSystemConfig` 等死接口删除 |
|
||||
| P0-4 | 假进度条 | `PredictProgress` 重写为**不确定态**进度条 + 已等待秒数;移除全部伪造的 `pct` 计算 |
|
||||
| P1-1 | 23 处 `any` | 归零。新增 `PredictionMatchRef`、`IngestTriggerResult`、`PredictionJobRef`、`LLMPingResult`、`SettleResult`、`HealthProbe` 等类型;打开 `noUnusedLocals`/`noUnusedParameters` |
|
||||
| P1-2 | 无障碍缺口 | `aria-live` 0→4、`htmlFor` 1→17、`aria-describedby` 0→1、`aria-invalid` 0→1、`role=` 8→18;`Alert` 按语义输出 `role="alert"/"status"` |
|
||||
| P1-3 | `App.tsx` 布局重复 | 抽出 `components/SiteLayout.tsx` |
|
||||
| P1-4 | 零代码分割 | 路由级 `lazy` + `Suspense` + `manualChunks`;admin 页面各自独立 chunk |
|
||||
| P1-5 | SEO 元信息缺失 | `index.html` 补 title/description/theme-color/robots/4 个图标/OG/Twitter Card;新增 `public/` 图标资源 |
|
||||
| P1-6 | 死代码 | 删除 `loadMatchDetailBundle`;抽出 `admin/AdminIcon.tsx` 替代 90 行内联 switch(`nav.ts` 的 `icon` 同步收窄为联合类型) |
|
||||
| P2-1 | `Login.tsx` 裸色值 | 改用 `bg-paper-50 shadow-print` |
|
||||
| P2-2 | `index.css` 硬编码色 | 引入 `:root` RGB 三元组变量;**并加 `verify-tokens.sh` 纳入构建**,防止 `<alpha-value>` 回归 |
|
||||
| P2-3 | 热路径无记忆化 | `Matches.tsx` 的 `visibleMatches`/`dateGroups` 加 `useMemo` |
|
||||
| P2-4 | 滚动监听三处重复 | 抽出 `lib/useScroll.ts`(`useWindowScrollY` / `useCanScrollRight`) |
|
||||
| P2-5 | 内联原生控件 | `Matches.tsx` 两处裸 `<button>` 改用 `Button` 基元 |
|
||||
| P2-6 | `useLeagues` 静默吞失败 | 空结果分支补 `console.warn` |
|
||||
| P2-7 | `ErrorBoundary` 仅最外层 | 新增 `fullScreen` 开关;每个路由外包一层局部边界 |
|
||||
|
||||
### 修复期间新发现并修掉的问题(原报告未列)
|
||||
|
||||
| 文件 | 问题 | 说明 |
|
||||
|---|---|---|
|
||||
| `tailwind.config.js` | **`bg-press-wash/60` 静默失效** | 令牌改为 `var()` 后 Tailwind 无法应用透明度修饰符,构建/类型/lint **全部无报错**,但样式丢失。改用 RGB 三元组 + `<alpha-value>` 修复,并加构建期校验 |
|
||||
| `Monitoring.tsx` | **整页崩溃** | `todos` 的过滤器写成 `t !== false`,而 `upstream && !upstream.ok && {...}` 在 `upstream` 为 `null` 时求值为 `null`,`null !== false` 为真 → `null` 穿过过滤器 → 渲染 `t.to` 抛错。改为 `Boolean(t)` 真值过滤 |
|
||||
| `Monitoring.tsx` | `health` 状态类型造假 | 声明为 `Record<string, unknown>`,掩盖了 `version` / `uptime_seconds` 可为 `null` 的事实。收紧为 `HealthProbe` 后编译器立刻暴露真实不匹配 |
|
||||
| `Collection.tsx` | 渲染期调用 `Date.now()` | 新引入的 `react-hooks/purity` 规则捕获,改为 `elapsedSec` 状态 + 计时器 effect |
|
||||
| `PredictionHistory.tsx` | `(p as any).match` ×3 | `Prediction.match` 字段在类型里根本不存在,消费方只能靠 `any` 绕过。补上 `PredictionMatchRef` 后三处 `any` 自然消失 |
|
||||
|
||||
> 仍保留的 19 条 warning 全部为 `react-hooks/set-state-in-effect`(挂载后取数的官方推荐写法),已在 `eslint.config.mjs` 中降级为 warn 并注明理由,未作为阻断项。
|
||||
|
||||
---
|
||||
|
||||
## 结论摘要
|
||||
|
||||
`npx tsc --noEmit` **通过**,构建产物 323 KB JS / 38.7 KB CSS。也就是说——**这不是一份"跑不起来"的清单**。
|
||||
|
||||
真正的问题在于:**这个前端没有任何自动化的质量防线**。没有 linter、没有测试运行器、没有 CI。所有的设计一致性(方角、纸色、印报红)和类型正确性,100% 依赖开发者手工守纪律。下面的 P0/P1 问题,绝大多数都是"人能看出来、机器看不出来"的类型——而合并两套组件库那一轮已经证明过:**再导出重构的风险不在类型检查,而在运行时绑定**,`tsc` 对此完全沉默。
|
||||
|
||||
一个必须点名的事实:**本仓库唯一的一个测试文件 `lib/http.test.ts`,没有任何脚本能运行它。** 它的存在价值目前约等于注释。
|
||||
|
||||
---
|
||||
|
||||
## 严重度总览
|
||||
|
||||
| 级别 | 数量 | 主题 |
|
||||
|---|---|---|
|
||||
| **P0** | 4 | 零质量基建、跨层耦合、伪造数据、假进度条 |
|
||||
| **P1** | 6 | 类型安全失效、无障碍缺口、布局重复、代码分割缺失、SEO 缺失、死代码 |
|
||||
| **P2** | 7 | 一致性残留、性能细节、a11y 补强、工程细节 |
|
||||
|
||||
---
|
||||
|
||||
## P0 — 必须优先处理
|
||||
|
||||
### P0-1 工程基建完全为空白:无 lint、无测试、无 CI
|
||||
|
||||
**证据**(`frontend/package.json` 第 6–25 行):
|
||||
|
||||
```json
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
}
|
||||
```
|
||||
|
||||
- `scripts` 里**没有 `test`、没有 `lint`、没有 `typecheck`**。
|
||||
- `devDependencies` 只有 9 项:`@types/react`、`@types/react-dom`、`@vitejs/plugin-react`、`autoprefixer`、`postcss`、`tailwindcss`、`typescript`、`vite`。**没有 ESLint、没有 Prettier、没有 Vitest/Jest。**
|
||||
- 仓库根目录无 `.github/`、无 `.gitlab-ci.yml`、无 husky —— 全仓唯一的 YAML 是 `docker-compose.yml`。
|
||||
|
||||
**这直接导致了什么**:
|
||||
|
||||
1. `src/lib/http.test.ts` 是**全仓唯一测试**,靠 `node --experimental-strip-types` 手工跑,**没有挂进任何脚本**。它测的是整个前端最关键的模块(HTTP 层、401 事件广播、超时),却从未被自动执行过。
|
||||
2. 上一轮发现并修复的 `btn-ghost` "幻影变体"(用了但从未在 CSS 中定义,样式静默失效)——**这类问题如果有一条 ESLint 规则 `no-restricted-syntax` 禁止 `className="btn*"` 硬编码,会在写下的瞬间被拦下**。现在只能靠人肉 review。
|
||||
3. 字符串里的类名、`aria-*` 属性、再导出的符号名,全部无机器校验。
|
||||
|
||||
**建议**(按性价比排序):
|
||||
|
||||
| 动作 | 成本 | 收益 |
|
||||
|---|---|---|
|
||||
| 加 `"typecheck": "tsc --noEmit"` 到 scripts | 1 分钟 | 让类型检查可被 CI 调用 |
|
||||
| 加 `"test": "node --experimental-strip-types --test src/**/*.test.ts"` | 2 分钟 | 让唯一测试真正跑起来 |
|
||||
| 引入 ESLint + `eslint-plugin-react-hooks` | 半天 | 捕获 hooks 依赖缺失、条件调用 |
|
||||
| 加自定义规则禁止 `className` 中出现裸 `btn`/`field`/`tab` | 半天 | 根治"幻影变体"类问题 |
|
||||
| 加 `.gitlab-ci.yml`(本仓托管在 git.bilidili.cn,显然走 GitLab CI) | 1 小时 | 让上面三条自动生效 |
|
||||
|
||||
---
|
||||
|
||||
### P0-2 公共页面反向依赖 admin 层——分层已经倒了
|
||||
|
||||
**证据**:
|
||||
|
||||
```
|
||||
src/pages/Standings.tsx:10 import { fetchStandings } from '../admin/dal'
|
||||
src/pages/Standings.tsx:11 import type { StandingsLeague, StandingRow } from '../admin/dal'
|
||||
src/pages/Standings.tsx:13 import { Spinner } from '../admin/components'
|
||||
src/pages/Matches.tsx:15 import { fetchMatchDetail, fetchMatchContext } from '../admin/dal'
|
||||
src/pages/Matches.tsx:16 import type { MatchDetailOut, MatchContextOut } from '../admin/types'
|
||||
```
|
||||
|
||||
**面向公众的预测页面,数据访问层和类型定义全部寄生在 `admin/` 目录下。**
|
||||
|
||||
`dal.ts` 全称 Data Access Layer,`admin/types.ts` 是后台的数据契约——但它们现在是"公共页面 + 后台"共用的底层。后果很具体:
|
||||
|
||||
- 想重构后台?`Matches.tsx` 和 `Standings.tsx` 会一起报错。想删一个后台不用的类型?可能有前台页面在 import 它。
|
||||
- 这个目录名会持续误导新加入的人,以为 `admin/` 是可独立删除的后台包。
|
||||
- `AdminApp.tsx` 已经提供了独立的 `createBrowserRouter` 入口,说明后台**本来是按可独立拆分的意图设计的**,但 DAL 层的反向依赖把这个意图破坏了。
|
||||
|
||||
**建议**:把 `dal.ts` / `api.ts` / `types.ts` 提升为 `src/api/`(或 `src/lib/api/`),`admin/` 只保留 UI 层。这是一次纯路径变更,无逻辑风险,但能让目录结构与真实依赖方向一致。
|
||||
|
||||
---
|
||||
|
||||
### P0-3 伪造数据被当作真实指标展示
|
||||
|
||||
**证据**(`src/admin/dal.ts`):
|
||||
|
||||
```ts
|
||||
54: db_tables: [], // 后端暂无表统计端点
|
||||
55: last_collection: [], // 后端暂无采集历史端点
|
||||
56: recent_errors: [], // 后端暂无错误日志端点
|
||||
...
|
||||
318: avg_latency_ms: 2400, // 后端暂无延迟统计
|
||||
331: avg_latency_ms: 0,
|
||||
```
|
||||
|
||||
- `fetchDashboard` 在"后端暂无端点"时**返回空数组冒充真实结果**,调用方无法区分"确实没有错误"和"这个功能还没接"。
|
||||
- `fetchLLMUsageStats` 更严重:**硬编码 `avg_latency_ms: 2400`**。这不是占位符——它会被渲染成一个看起来非常可信的"平均延迟 2.4 秒"数字。运营人员看到它,会据此判断系统性能。
|
||||
|
||||
**这是本报告中最危险的一条。** 其他问题影响体验,这一条**影响决策**。
|
||||
|
||||
**建议**:
|
||||
1. 把"暂无端点"的字段类型改为 `null`(而不是 `[]` / 假数字),强制调用方处理这个状态;
|
||||
2. 后端未接的指标,UI 上**明确显示"未接入"**,而不是显示一个数字;
|
||||
3. `avg_latency_ms: 2400` 这一行**立即删除或改为 `null`** —— 即使 UI 暂时留白,也比给一个假数字好。
|
||||
|
||||
---
|
||||
|
||||
### P0-4 `PredictProgress` 是一个与后端状态无关的假进度条
|
||||
|
||||
**证据**(`src/pages/matches/components/MatchPredictPanel.tsx` 第 92–128 行):
|
||||
|
||||
```ts
|
||||
const SLICE_END = 3
|
||||
const AGENT_START = 4
|
||||
const AGENT_STEP = 8 // 每路专家约 8s 点亮一路
|
||||
const AGG_START = AGENT_START + AGENT_STEP * 5
|
||||
|
||||
const phase = elapsed < SLICE_END ? 'slice'
|
||||
: elapsed < AGG_START ? 'agents' : 'agg'
|
||||
|
||||
const pct = Math.min(95, Math.round((elapsed / 70) * 100))
|
||||
```
|
||||
|
||||
整个进度是**按经过的秒数推算出来的**,与后端真实 job 状态**零关联**:
|
||||
|
||||
- 进度条在 70 秒时到 95% 就**永远卡住**(`Math.min(95, ...)`)——因为真实完成时间不可知。
|
||||
- 五路专家的"点亮"完全按 `AGENT_STEP = 8s` 的**硬编码节拍**依次点亮,不管后端实际跑到哪一步。如果某路专家 3 秒就返回了,UI 依然要等到第 8 秒才点亮它;如果某路跑了 40 秒,UI 早就把它点亮成"完成"了。
|
||||
- 注释里的"约 8s"/"70s"是**观测到的经验值**,一旦模型或硬件变化,这个进度条就在撒谎。
|
||||
|
||||
**同时**:`useMatchPredict` 里已有真实的 3 秒轮询和 300 秒截止时间。**进度信息的真实来源是存在的,只是没被用上。**
|
||||
|
||||
**建议**(按代价从低到高):
|
||||
1. **最低成本**:承认它不是进度条。改成不确定态(indeterminate)动画 + 已耗时计数器。诚实的"已等待 42 秒"远胜虚假的"95%"。
|
||||
2. **中成本**:后端 `job_id` 轮询若返回已完成/进行中的阶段数,就用真实阶段数驱动点亮。
|
||||
3. **高成本**:后端在 job 状态里暴露 `completed_agents` / `total_agents`,前端完全数据驱动。
|
||||
|
||||
---
|
||||
|
||||
## P1 — 高优先级
|
||||
|
||||
### P1-1 类型安全在最关键的边界上失效:23 处 `any`
|
||||
|
||||
| 文件 | `any` 数量 |
|
||||
|---|---|
|
||||
| `src/admin/dal.ts` | **14** |
|
||||
| `src/admin/pages/EvalPage.tsx` | 6 |
|
||||
| `src/admin/pages/PredictionHistory.tsx` | 3 |
|
||||
|
||||
`dal.ts` 是**所有后端数据的入口**。在这里用 `any`,等于把类型检查从最需要它的地方撤掉了——后端返回的字段名拼错、结构变了、nullable 变了,`tsc` 都不会说话。
|
||||
|
||||
而 `tsconfig.json` 第 14–16 行还在配合放大这个问题:
|
||||
|
||||
```json
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
```
|
||||
|
||||
`strict: true` 是好的,但**两个 `noUnused*` 被显式关掉了**。这意味着死代码(未使用的变量、未使用的 import、未使用的参数)会**静默累积**,永远不会有编译错误提醒。结合 P1-6(已发现一处未使用的导出),这个开关的实际代价已经显现。
|
||||
|
||||
**建议**:`dal.ts` 逐个端点补上返回类型(后端已有 FastAPI + Pydantic,OpenAPI schema 可直接生成 TS 类型);把 `noUnusedLocals` 打开,一次性清理存量。
|
||||
|
||||
---
|
||||
|
||||
### P1-2 无障碍:异步内容对屏幕阅读器完全不可见
|
||||
|
||||
**实测覆盖度**(全仓 grep 计数):
|
||||
|
||||
| 属性 | 出现次数 | 评价 |
|
||||
|---|---|---|
|
||||
| `aria-label` | 18 | ✅ 尚可 |
|
||||
| `aria-current` | 4 | ✅ 导航用了 |
|
||||
| `aria-busy` | 1 | ⚠️ 只有 `Button` 有 |
|
||||
| `role=` | 8 | ⚠️ 偏少 |
|
||||
| **`aria-live`** | **0** | ❌ **严重** |
|
||||
| **`aria-labelledby`** | **0** | ❌ 严重 |
|
||||
| **`aria-describedby`** | **0** | ❌ 严重 |
|
||||
| **`aria-errormessage`** | **0** | ❌ 严重 |
|
||||
| `htmlFor` | **1** | ❌ 20 个 input 只有 1 个关联 label |
|
||||
| `alt=` | 0 | ➖ 无 `<img>`,暂不适用 |
|
||||
|
||||
**具体后果**:
|
||||
|
||||
1. **`aria-live` 为 0 ⇒ 所有异步状态变化屏幕阅读器都不知道。** 预测任务在后台跑完、加载失败弹出错误横幅、列表加载出更多比赛——视觉用户看到了,屏幕阅读器用户**什么都收不到**。这是表单类应用最典型也最容易修复的 a11y 缺口。
|
||||
2. **`htmlFor` 只有 1 个,而 `<input>` 有 20 个。** 输入框的可见文字提示和输入控件之间没有程序化关联,屏幕阅读器读出的是"编辑框"而不是"邮箱地址"。
|
||||
3. **`Login.tsx` 的错误提示(第 70 行 `{error && (...)}`)没有 `aria-describedby` / `aria-errormessage` 关联到输入框。** 密码输错时,视觉用户看到红字,屏幕阅读器用户**不知道发生了错误**,更不知道错在哪。
|
||||
|
||||
**另外**:`autoFocus` 出现了 3 次(`Login.tsx:65`、`useCommandPalette.tsx:115`、`SettingRow.tsx:83`)。
|
||||
|
||||
- `Login.tsx` 和 `SettingRow.tsx` 场景合理(表单首个字段、内联编辑)。
|
||||
- **`useCommandPalette.tsx:115` 需要复核** —— Cmd+K 面板里 `<Modal>` 本身已经做了"初始聚焦面板"的焦点管理,再叠一个 `autoFocus` 的 input,两者会**争抢焦点**,且后者的行为依赖 React 挂载时序。
|
||||
|
||||
**建议**(按收益排序):
|
||||
1. 错误横幅 / 加载完成提示加 `role="status"` + `aria-live="polite"`;错误用 `role="alert"`。
|
||||
2. 给 20 个 input 补 `htmlFor` + `id` 配对(可先在 `Input` 基元里强制要求 `id`)。
|
||||
3. 表单错误用 `aria-describedby` 指向错误文本节点。
|
||||
4. 复核 `useCommandPalette` 的 `autoFocus` 是否与 `Modal` 焦点管理冲突。
|
||||
|
||||
---
|
||||
|
||||
### P1-3 `App.tsx` 把整套布局复制了两遍
|
||||
|
||||
**证据**(`src/App.tsx`,全文仅 82 行,其中两段布局高度重合):
|
||||
|
||||
```
|
||||
21:function StandingsLayout({ children }) {
|
||||
24: <Masthead active="standings" />
|
||||
26: <main className="mx-auto max-w-5xl px-5 py-6 sm:px-8 sm:py-8">
|
||||
30: <footer className="mx-auto max-w-5xl px-5 pb-10 sm:px-8">
|
||||
34: </footer>
|
||||
|
||||
39:function HomePage() {
|
||||
43: <Masthead active="home" />
|
||||
45: <main className="mx-auto max-w-5xl px-5 py-6 sm:px-8 sm:py-8">
|
||||
49: <footer className="mx-auto max-w-5xl px-5 pb-10 sm:px-8">
|
||||
53: </footer>
|
||||
```
|
||||
|
||||
`<main>` 和 `<footer>` 的 className **逐字相同**,唯一区别是 `Masthead` 的 `active` prop 和 footer 里的文案。
|
||||
|
||||
**后果**:改一次容器宽度或内边距,要改两处;将来加第三个页面就是第三处。而 `AdminLayout.tsx` 里的 `NavLink` 也已经出现了同样的 className 复制(见 P1-6)。**这已经不是偶发,是模式。**
|
||||
|
||||
**建议**:抽一个 `<SiteLayout active="home" | "standings">{children}</SiteLayout>`,把 `main` + `footer` 收进去。这是 20 行的改动,消除的是结构性重复。
|
||||
|
||||
---
|
||||
|
||||
### P1-4 零代码分割:前后台打进同一个 chunk
|
||||
|
||||
**证据**:
|
||||
|
||||
- `src/routes.tsx`、`src/App.tsx` 中 **`lazy(` 和 `Suspense` 出现次数为 0**。
|
||||
- 构建产物:`index-BuU04RT0.js` **323 KB**(单个文件),`index-BP3DscKo.css` 38.7 KB。
|
||||
- `vite.config.ts` 全文无任何 `build.rollupOptions` / `manualChunks` 配置。
|
||||
|
||||
**后果**:一个只想看预测首页的匿名用户,**必须下载整个后台的全部代码**——Dashboard、Collection、Logs、EvalPage、Backtest、Settings、Monitoring,10 个后台页面一个都不少。而这 10 个页面里绝大概率存在只有登录用户才会触发的逻辑。
|
||||
|
||||
**建议**:
|
||||
1. 路由级 `React.lazy` + `<Suspense>`:`/admin/*` 整体 lazy,`Standings` 单独 lazy。仅此一项通常能砍掉 40%+ 的首屏 JS。
|
||||
2. `vite.config.ts` 加 `manualChunks` 把 `react` / `react-dom` / `react-router-dom` 拆成 `vendor` chunk,利用长期缓存。
|
||||
3. 顺带把 `ErrorBoundary` 移到路由级(现在只在 `App.tsx:60` 最外层一个),这样单个页面崩溃不会白屏整个应用。
|
||||
|
||||
---
|
||||
|
||||
### P1-5 `index.html` 缺少全部 SEO 与元信息
|
||||
|
||||
**证据**(`frontend/index.html` 全文):
|
||||
|
||||
```html
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Profeto - 足球 LLM 预测</title>
|
||||
</head>
|
||||
```
|
||||
|
||||
**缺失清单**:
|
||||
|
||||
| 缺失项 | 影响 |
|
||||
|---|---|
|
||||
| `meta[name=description]` | 搜索引擎结果页无摘要 |
|
||||
| `link[rel=icon]` / favicon | 浏览器标签页显示空白文档图标 |
|
||||
| `meta[property=og:*]` | 分享到微信/Twitter 无预览卡片 |
|
||||
| `meta[name=theme-color]` | 移动端浏览器地址栏不染色 |
|
||||
| `link[rel=apple-touch-icon]` | iOS 添加到主屏是截图占位 |
|
||||
| `meta[name=robots]` / canonical | 无索引控制 |
|
||||
|
||||
**另外**:项目有 `src/assets/fonts/LiuJianMaoCao-subset.woff2`(手工子集化的毛笔字体,说明"报刊风"的品牌感是被认真对待的),**但页面没有一个 favicon**。视觉上最讲究的地方,在浏览器标签页上裸奔。
|
||||
|
||||
---
|
||||
|
||||
### P1-6 死代码:未使用的导出 + 内联 90 行的图标 switch
|
||||
|
||||
**未使用的导出**(`src/pages/matches/components/MatchDetailSection.tsx:362`):
|
||||
|
||||
```ts
|
||||
export async function loadMatchDetailBundle(
|
||||
```
|
||||
|
||||
全仓 grep `loadMatchDetailBundle` **只有这一处定义,零处引用**。`Matches.tsx` 自己做 `Promise.all`。这个函数是重构遗留物,但因为 `noUnusedLocals: false`(P1-1),导出符号更是从来不会被报未使用——**它会永远留在那里**。
|
||||
|
||||
**`AdminLayout.tsx` 内联图标 switch**:第 20–78 行,一个约 90 行的 `Icon` 函数,`switch` 8 个图标名(collection/chart/target/repeat/eval/monitor/settings/logs),全部是内联 `<svg>` 路径。所有其他图标都应该像 `Masthead.tsx` 里的 `GearIcon` 那样独立成组件。**90 行 SVG 路径塞在布局文件里,让 `AdminLayout.tsx` 的职责严重发散。**
|
||||
|
||||
**同时**(`AdminLayout.tsx:255` 与 `:278`):两处 `NavLink` 的 className 逻辑**重复书写**——与 P1-3 同型。
|
||||
|
||||
---
|
||||
|
||||
## P2 — 一致性、性能与工程细节
|
||||
|
||||
### P2-1 `Login.tsx` 仍残留裸色值,未对齐设计令牌
|
||||
|
||||
**证据**(`src/admin/Login.tsx:54`):
|
||||
|
||||
```tsx
|
||||
className="w-full max-w-sm border border-ink-300 bg-white p-6 shadow-[4px_4px_0_0_rgba(0,0,0,0.06)]"
|
||||
```
|
||||
|
||||
- `bg-white` —— 设计系统里纸质背景是 `paper-*` 令牌,`bg-white` 是一个不在体系内的纯白。
|
||||
- `shadow-[4px_4px_0_0_rgba(0,0,0,0.06)]` —— **兜底在 className 里的任意值阴影**。设计语言里阴影应该来自 `shadow-print` 这类令牌,而不是每个组件自己拼一个 `rgba()`。
|
||||
|
||||
**这是最讽刺的一处**:登录页是后台的**第一印象页面**。视觉体系的所有其他地方都在用令牌,唯独进门第一屏用了裸值。
|
||||
|
||||
### P2-2 `index.css` 里硬编码了色值,而不是引用令牌
|
||||
|
||||
```
|
||||
55: outline: 2px solid #9e1b1b;
|
||||
89: border-top: 3px solid #17140f;
|
||||
208: color: #9E1B1B;
|
||||
```
|
||||
|
||||
`#9e1b1b` 就是 `press` 红,`#17140f` 就是 `ink` 墨色——**但它们以十六进制字面量写死在 CSS 里**。
|
||||
|
||||
**后果很具体**:将来要调品牌红(比如为了打印对比度微调),改 `tailwind.config.js` 里的 `press` 令牌**不会影响这三处**。焦点轮廓、报头粗线、还有第 208 行那个用 `font-brush` 的毛笔字颜色,会保持旧红——**品牌色出现两个来源,且其中一个没有名字**。
|
||||
|
||||
第 208 行的 `#9E1B1B` 还是**大写**写法,与另两处小写不一致,说明这三处是不同时间、不同人手工敲进去的。
|
||||
|
||||
**建议**:CSS 自定义属性化。在 `:root` 定义 `--press` / `--ink`,由 `tailwind.config.js` 一并消费,让 CSS 与 Tailwind 共享同一份令牌来源。
|
||||
|
||||
### P2-3 热渲染路径上没有记忆化
|
||||
|
||||
**证据**(`src/pages/Matches.tsx:230`):
|
||||
|
||||
```tsx
|
||||
{!loading && groupByDate(visibleMatches).map(([dateKey, group]) => (
|
||||
```
|
||||
|
||||
`groupByDate(visibleMatches)` **在每次 render 时重新计算整个列表的分组**。`visibleMatches` 可能包含上百场比赛,任何无关的 state 变化(比如 `error` 从 `null` 变成字符串、`leagueNavRef` 触发的 `canScrollRight` 变化)都会触发整表重算 + 全部 DOM 重建。
|
||||
|
||||
全仓 grep:**`Matches.tsx` 里 `useMemo` / `useCallback` 出现次数为 0**。
|
||||
|
||||
**建议**:`const groups = useMemo(() => groupByDate(visibleMatches), [visibleMatches])`。同时检查列表行组件(`MatchRow` 等)是否可以用 `React.memo` 包住。
|
||||
|
||||
### P2-4 滚动监听分散在三处,各写一遍
|
||||
|
||||
```
|
||||
src/components/BackTop.tsx:13 window.addEventListener('scroll', handleScroll, { passive: true })
|
||||
src/pages/Matches.tsx:91 el.addEventListener('scroll', update, { passive: true })
|
||||
```
|
||||
|
||||
`AdminLayout.tsx` 里还有第三套。三处各自管理 `addEventListener` / `removeEventListener` / throttle / `passive` —— **三份重复的生命周期管理**,任何一处漏了 cleanup 就是内存泄漏。
|
||||
|
||||
**建议**:抽 `useScrollListener(el, handler)` 或 `useScrollPosition()` 到 `lib/hooks/`。至少 `passive: true` 这个优化现在只有两处记得加。
|
||||
|
||||
### P2-5 `Matches.tsx` 仍有内联原生控件,绕过已建成的组件层
|
||||
|
||||
**证据**(`src/pages/Matches.tsx`):
|
||||
|
||||
- 第 109 行 `<button>` —— 联赛 tab,手写 `className="tab"`
|
||||
- 第 158 行 `<button onClick={() => setError(null)} ... aria-label="关闭">×</button>` —— 错误横幅关闭按钮(`Feedback.tsx` 里已有 `ErrorBanner` 组件)
|
||||
- 第 271 行 `<button>` —— "收起"折叠按钮
|
||||
|
||||
`components/ui/` 已经提供了 `Button`、`Switch`、`ErrorBanner`。但 `Matches.tsx` 作为最复杂的前台页面,仍有 3 处裸 `<button>`。
|
||||
|
||||
**全仓原生元素统计**:`<button>` 18 个、`<input>` 20 个、`<select>` 4 个。
|
||||
|
||||
**建议**:这不宜靠人肉找。加一条 ESLint 规则,禁止在 `pages/` 下直接写 `<button className="btn...">`,强制走 `Button` 基元——**这正是 P0-1 里说的"让错误在编译期暴露"**。
|
||||
|
||||
### P2-6 `useLeagues` 静默吞掉 API 失败
|
||||
|
||||
**证据**(`src/pages/matches/hooks/useLeagues.ts` 第 3 行注释):
|
||||
|
||||
```
|
||||
失败或返回空数组则回退本地五大联赛常量(LEAGUES)。
|
||||
```
|
||||
|
||||
从**韧性的角度这是好设计**——接口挂了,用户依然能看到五大联赛而不是空白。
|
||||
|
||||
但从**可观测性角度这是个隐患**:接口持续返回 500,前端**永远静默回退**,没有任何日志、没有任何用户提示。运维那边看到"接口正常"(因为前端不报错),用户那边看到的是"只有五个联赛、没有新增联赛"——**双方都以为一切正常**。
|
||||
|
||||
**建议**:回退保留,但加一次 `console.warn` 或上报埋点。让它"优雅降级但留下痕迹"。
|
||||
|
||||
### P2-7 `ErrorBoundary` 只在应用最外层
|
||||
|
||||
`App.tsx:60` 有一个 `<ErrorBoundary>` 包住整个 `<BrowserRouter>`。这意味着**任意一个页面的渲染崩溃,整个应用白屏**。
|
||||
|
||||
结合 P1-4(无路由级 lazy),建议一并处理:`lazy` 的每个路由包一层 `ErrorBoundary`,让"某个页面挂了"降级为"这个页面显示错误,其他导航照常可用"。
|
||||
|
||||
---
|
||||
|
||||
## 附:已确认的良好实践(避免"只挑毛病"的偏差)
|
||||
|
||||
这份报告如果只列问题,会给人"一团糟"的错误印象。以下是我逐项验证过、**确实做得好**的地方:
|
||||
|
||||
| 项 | 证据 |
|
||||
|---|---|
|
||||
| HTTP 层设计 | `lib/http.ts` 有统一 `API_BASE`、30s 超时、`AbortController`、`ApiError` 类;401 通过 `UNAUTHORIZED_EVENT` 自定义事件广播解耦 |
|
||||
| 竞态防护 | `useMatchesList` / `useMatchPredict` 用单调递增 `useRef` 序号守卫,这是**很多人会漏掉**的正确做法 |
|
||||
| Modal 无障碍 | `components/ui/index.tsx` 里的 `Modal` 内置焦点陷阱、ESC 关闭、背景滚动锁定、焦点归还——四项齐全 |
|
||||
| 术语化变体 | `ButtonVariant` 用联合类型约束,写错变体名编译期报错——正是要根治"幻影变体"的机制 |
|
||||
| 字体优化 | `LiuJianMaoCao-subset.woff2` 手工子集化 + woff2,说明品牌字体经过真实施工 |
|
||||
| 组件合并 | 上一轮把两套平行组件库归一到 `components/ui/`,Spinner 从 3 份副本收敛到 1 份,净减 541 行 |
|
||||
| 类型检查 | `npx tsc --noEmit` 当前**通过** |
|
||||
| 构建 | `pnpm build` **通过**,产物可正常生成 |
|
||||
|
||||
---
|
||||
|
||||
## 建议的处理顺序
|
||||
|
||||
**第一批(半天,收益最高)**
|
||||
1. `package.json` 加 `typecheck` / `test` 脚本,让已有测试跑起来(P0-1)
|
||||
2. **删除 `dal.ts:318` 的 `avg_latency_ms: 2400`**(P0-3)—— 一行改动,消除一个会误导决策的假数据
|
||||
3. `App.tsx` 抽 `SiteLayout`(P1-3)
|
||||
4. `Matches.tsx` 的 `groupByDate` 加 `useMemo`(P2-3)
|
||||
|
||||
**第二批(1–2 天)**
|
||||
5. 路由级 `React.lazy` + 每路由 `ErrorBoundary`(P1-4 + P2-7)
|
||||
6. `index.html` 补 description / favicon / OG(P1-5)
|
||||
7. `Login.tsx` 裸色值对齐令牌(P2-1)+ `index.css` 三处硬编码色令牌化(P2-2)
|
||||
8. 错误提示加 `aria-live` / `role="alert"`,input 补 `htmlFor`(P1-2)
|
||||
|
||||
**第三批(工程化,需排期)**
|
||||
9. 引入 ESLint + 自定义规则禁止裸 `btn` 类名(P0-1)
|
||||
10. `dal.ts` / `api.ts` / `types.ts` 提升到 `src/api/`(P0-2)
|
||||
11. `dal.ts` 的 14 处 `any` 逐个补类型,并打开 `noUnusedLocals`(P1-1)
|
||||
12. `PredictProgress` 改为诚实的 indeterminate 态,或改由后端真实阶段驱动(P0-4)
|
||||
|
||||
---
|
||||
|
||||
*审计人:前端架构审计*
|
||||
*审计日期:2025*
|
||||
*审计基线:`a7e8b75`*
|
||||
*方法:全量文件只读遍历 + 磁盘实证复核,所有 file:line 引用均已在当前工作区验证*
|
||||
Reference in New Issue
Block a user