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处存量)
29 KiB
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 行):
"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。
这直接导致了什么:
src/lib/http.test.ts是全仓唯一测试,靠node --experimental-strip-types手工跑,没有挂进任何脚本。它测的是整个前端最关键的模块(HTTP 层、401 事件广播、超时),却从未被自动执行过。- 上一轮发现并修复的
btn-ghost"幻影变体"(用了但从未在 CSS 中定义,样式静默失效)——这类问题如果有一条 ESLint 规则no-restricted-syntax禁止className="btn*"硬编码,会在写下的瞬间被拦下。现在只能靠人肉 review。 - 字符串里的类名、
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):
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 秒"数字。运营人员看到它,会据此判断系统性能。
这是本报告中最危险的一条。 其他问题影响体验,这一条影响决策。
建议:
- 把"暂无端点"的字段类型改为
null(而不是[]/ 假数字),强制调用方处理这个状态; - 后端未接的指标,UI 上明确显示"未接入",而不是显示一个数字;
avg_latency_ms: 2400这一行立即删除或改为null—— 即使 UI 暂时留白,也比给一个假数字好。
P0-4 PredictProgress 是一个与后端状态无关的假进度条
证据(src/pages/matches/components/MatchPredictPanel.tsx 第 92–128 行):
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 秒截止时间。进度信息的真实来源是存在的,只是没被用上。
建议(按代价从低到高):
- 最低成本:承认它不是进度条。改成不确定态(indeterminate)动画 + 已耗时计数器。诚实的"已等待 42 秒"远胜虚假的"95%"。
- 中成本:后端
job_id轮询若返回已完成/进行中的阶段数,就用真实阶段数驱动点亮。 - 高成本:后端在 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 行还在配合放大这个问题:
"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>,暂不适用 |
具体后果:
aria-live为 0 ⇒ 所有异步状态变化屏幕阅读器都不知道。 预测任务在后台跑完、加载失败弹出错误横幅、列表加载出更多比赛——视觉用户看到了,屏幕阅读器用户什么都收不到。这是表单类应用最典型也最容易修复的 a11y 缺口。htmlFor只有 1 个,而<input>有 20 个。 输入框的可见文字提示和输入控件之间没有程序化关联,屏幕阅读器读出的是"编辑框"而不是"邮箱地址"。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 挂载时序。
建议(按收益排序):
- 错误横幅 / 加载完成提示加
role="status"+aria-live="polite";错误用role="alert"。 - 给 20 个 input 补
htmlFor+id配对(可先在Input基元里强制要求id)。 - 表单错误用
aria-describedby指向错误文本节点。 - 复核
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.js323 KB(单个文件),index-BP3DscKo.css38.7 KB。 vite.config.ts全文无任何build.rollupOptions/manualChunks配置。
后果:一个只想看预测首页的匿名用户,必须下载整个后台的全部代码——Dashboard、Collection、Logs、EvalPage、Backtest、Settings、Monitoring,10 个后台页面一个都不少。而这 10 个页面里绝大概率存在只有登录用户才会触发的逻辑。
建议:
- 路由级
React.lazy+<Suspense>:/admin/*整体 lazy,Standings单独 lazy。仅此一项通常能砍掉 40%+ 的首屏 JS。 vite.config.ts加manualChunks把react/react-dom/react-router-dom拆成vendorchunk,利用长期缓存。- 顺带把
ErrorBoundary移到路由级(现在只在App.tsx:60最外层一个),这样单个页面崩溃不会白屏整个应用。
P1-5 index.html 缺少全部 SEO 与元信息
证据(frontend/index.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):
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):
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):
{!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 通过,产物可正常生成 |
建议的处理顺序
第一批(半天,收益最高)
package.json加typecheck/test脚本,让已有测试跑起来(P0-1)- 删除
dal.ts:318的avg_latency_ms: 2400(P0-3)—— 一行改动,消除一个会误导决策的假数据 App.tsx抽SiteLayout(P1-3)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 引用均已在当前工作区验证