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

29 KiB
Raw Blame History

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.ts1047 行)git mvsrc/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 0error 级规则拦截新增)
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 true0 报错
首屏 JS 315.14 kB ~237.6 kB(−24.6%
index chunk 110.20 kB 31.80 kB

逐项落地

编号 问题 处理
P0-1 无 lint / 测试 / CI 新增 eslint.config.mjsscripts/verify-tokens.sh.gitlab-ci.ymlpackage.jsontypecheck/lint/test/verify:tokens
P0-2 公共页反向依赖 admin 新建 src/api/public.ts 承载 fetchStandings/fetchMatchDetail/fetchMatchContextdal.ts 改为再导出;Matches.tsx/Standings.tsx 改指 api/public
P0-3 伪造数据当真指标 avg_latency_ms: 2400nullUI 显示「—」+ 「未接入」);DashboardStats 三个无端点字段由空数组改 nullfetchSystemConfig 等死接口删除
P0-4 假进度条 PredictProgress 重写为不确定态进度条 + 已等待秒数;移除全部伪造的 pct 计算
P1-1 23 处 any 归零。新增 PredictionMatchRefIngestTriggerResultPredictionJobRefLLMPingResultSettleResultHealthProbe 等类型;打开 noUnusedLocals/noUnusedParameters
P1-2 无障碍缺口 aria-live 0→4、htmlFor 1→17、aria-describedby 0→1、aria-invalid 0→1、role= 8→18Alert 按语义输出 role="alert"/"status"
P1-3 App.tsx 布局重复 抽出 components/SiteLayout.tsx
P1-4 零代码分割 路由级 lazy + Suspense + manualChunksadmin 页面各自独立 chunk
P1-5 SEO 元信息缺失 index.html 补 title/description/theme-color/robots/4 个图标/OG/Twitter Card;新增 public/ 图标资源
P1-6 死代码 删除 loadMatchDetailBundle;抽出 admin/AdminIcon.tsx 替代 90 行内联 switchnav.tsicon 同步收窄为联合类型)
P2-1 Login.tsx 裸色值 改用 bg-paper-50 shadow-print
P2-2 index.css 硬编码色 引入 :root RGB 三元组变量;并加 verify-tokens.sh 纳入构建,防止 <alpha-value> 回归
P2-3 热路径无记忆化 Matches.tsxvisibleMatches/dateGroupsuseMemo
P2-4 滚动监听三处重复 抽出 lib/useScroll.tsuseWindowScrollY / 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 && {...}upstreamnull 时求值为 nullnull !== 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 行):

"scripts": {
  "dev": "vite",
  "build": "tsc && vite build",
  "preview": "vite preview"
}
  • scripts没有 test、没有 lint、没有 typecheck
  • devDependencies 只有 9 项:@types/react@types/react-dom@vitejs/plugin-reactautoprefixerpostcsstailwindcsstypescriptvite没有 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 Layeradmin/types.ts 是后台的数据契约——但它们现在是"公共页面 + 后台"共用的底层。后果很具体:

  • 想重构后台?Matches.tsxStandings.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 秒"数字。运营人员看到它,会据此判断系统性能。

这是本报告中最危险的一条。 其他问题影响体验,这一条影响决策

建议

  1. 把"暂无端点"的字段类型改为 null(而不是 [] / 假数字),强制调用方处理这个状态;
  2. 后端未接的指标,UI 上明确显示"未接入",而不是显示一个数字;
  3. avg_latency_ms: 2400 这一行立即删除或改为 null —— 即使 UI 暂时留白,也比给一个假数字好。

P0-4 PredictProgress 是一个与后端状态无关的假进度条

证据src/pages/matches/components/MatchPredictPanel.tsx 第 92128 行):

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 行还在配合放大这个问题:

"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:65useCommandPalette.tsx:115SettingRow.tsx:83)。

  • Login.tsxSettingRow.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. 复核 useCommandPaletteautoFocus 是否与 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 逐字相同,唯一区别是 Mastheadactive prop 和 footer 里的文案。

后果:改一次容器宽度或内边距,要改两处;将来加第三个页面就是第三处。而 AdminLayout.tsx 里的 NavLink 也已经出现了同样的 className 复制(见 P1-6)。这已经不是偶发,是模式。

建议:抽一个 <SiteLayout active="home" | "standings">{children}</SiteLayout>,把 main + footer 收进去。这是 20 行的改动,消除的是结构性重复。


P1-4 零代码分割:前后台打进同一个 chunk

证据

  • src/routes.tsxsrc/App.tsxlazy(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/* 整体 lazyStandings 单独 lazy。仅此一项通常能砍掉 40%+ 的首屏 JS。
  2. vite.config.tsmanualChunksreact / react-dom / react-router-dom 拆成 vendor chunk,利用长期缓存。
  3. 顺带把 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 变化(比如 errornull 变成字符串、leagueNavRef 触发的 canScrollRight 变化)都会触发整表重算 + 全部 DOM 重建。

全仓 grepMatches.tsxuseMemo / 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/ 已经提供了 ButtonSwitchErrorBanner。但 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 超时、AbortControllerApiError 类;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.jsontypecheck / test 脚本,让已有测试跑起来(P0-1
  2. 删除 dal.ts:318avg_latency_ms: 2400(P0-3)—— 一行改动,消除一个会误导决策的假数据
  3. App.tsxSiteLayoutP1-3
  4. Matches.tsxgroupByDateuseMemoP2-3

第二批(12 天) 5. 路由级 React.lazy + 每路由 ErrorBoundaryP1-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 补 htmlForP1-2

第三批(工程化,需排期) 9. 引入 ESLint + 自定义规则禁止裸 btn 类名(P0-1 10. dal.ts / api.ts / types.ts 提升到 src/api/P0-2 11. dal.ts 的 14 处 any 逐个补类型,并打开 noUnusedLocalsP1-1 12. PredictProgress 改为诚实的 indeterminate 态,或改由后端真实阶段驱动(P0-4)


审计人:前端架构审计 审计日期:2025 审计基线:a7e8b75 方法:全量文件只读遍历 + 磁盘实证复核,所有 file:line 引用均已在当前工作区验证