Files
Profeto/frontend/前端问题审计报告.md
T
shangfangjian f71f29b4cb 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处存量)
2026-09-22 23:45:06 +08:00

508 lines
29 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` 第 625 行):
```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` 第 92128 行):
```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 + PydanticOpenAPI 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、Monitoring10 个后台页面一个都不少。而这 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
**第二批(12 天)**
5. 路由级 `React.lazy` + 每路由 `ErrorBoundary`P1-4 + P2-7
6. `index.html` 补 description / favicon / OGP1-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 引用均已在当前工作区验证*