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:
2026-09-22 23:45:06 +08:00
parent 2cfc9ccc0f
commit f71f29b4cb
42 changed files with 2553 additions and 1629 deletions
+45 -26
View File
@@ -11,10 +11,11 @@
*/
import { useEffect, useState, useCallback, useRef } from 'react'
import { triggerCollection, fetchLeagues, fetchIngestJob, fetchIngestJobs } from '../dal'
import type { IngestJob, League } from '../types'
import type { CollectionRequest } from '../types'
import { triggerCollection, fetchLeagues, fetchIngestJob, fetchIngestJobs } from '../../api/dal'
import type { IngestJob, League } from '../../api/types'
import type { CollectionRequest } from '../../api/types'
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
import { Button, Input, Select } from '../../components/ui'
// 图标用与全站一致的几何字符(Dashboard 工作流卡同款),不混用 emoji
const TASKS = [
@@ -74,6 +75,22 @@ export default function CollectionPage() {
useEffect(() => () => stopPolling(), [stopPolling])
// ── 已运行时长 ──
// 必须放在 state 里、由定时器推进,而不是在 render 里读 Date.now()。
// 后者是「渲染期间的副作用」:同一份 props/state 会渲染出不同结果,
// React 的并发特性(以及未来的编译器优化)都依赖渲染幂等。
// 采集任务在跑时每秒推进一次,既给出真实的时长,也不制造额外重渲染。
const [elapsedSec, setElapsedSec] = useState(0)
useEffect(() => {
if (!taskStartedAt) { setElapsedSec(0); return }
if (taskStatus !== 'running') return
const id = setInterval(() => {
setElapsedSec(Math.round((Date.now() - taskStartedAt) / 1000))
}, 1000)
return () => clearInterval(id)
}, [taskStartedAt, taskStatus])
// ── 最近任务历史(GET /admin/ingest/jobs,最新在前) ──
// 声明须在 startJobPolling 之前(其终态回调会刷新历史)
const [recentJobs, setRecentJobs] = useState<IngestJob[] | null>(null)
@@ -174,7 +191,7 @@ export default function CollectionPage() {
}
}
const elapsed = taskStartedAt ? Math.round((Date.now() - taskStartedAt) / 1000) : 0
const elapsed = elapsedSec
const summary = jobInfo ? jobSummary(jobInfo) : null
return (
@@ -214,51 +231,53 @@ export default function CollectionPage() {
{/* 联赛选择 */}
<div>
<label className="mb-1.5 block text-xs text-ink-500"></label>
<select
<label className="mb-1.5 block text-xs text-ink-500" htmlFor="col-league"></label>
<Select
id="col-league"
value={leagueCode}
onChange={e => setLeagueCode(e.target.value)}
className="field w-full"
className="w-full"
>
<option value=""></option>
{leagues.map(l => (
<option key={l.code} value={l.code}>{l.name_zh || l.name}</option>
))}
</select>
</Select>
</div>
{/* events/all 任务专用: 比赛状态 + 日期 */}
{isEventsTask && (
<>
<div>
<label className="mb-1.5 block text-xs text-ink-500"></label>
<select
<label className="mb-1.5 block text-xs text-ink-500" htmlFor="col-status"></label>
<Select
id="col-status"
value={ingestStatus}
onChange={e => setIngestStatus(e.target.value)}
className="field w-full"
className="w-full"
>
<option value="">( + )</option>
<option value="finished"></option>
<option value="scheduled"></option>
</select>
</Select>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="mb-1.5 block text-xs text-ink-500"></label>
<input
<label className="mb-1.5 block text-xs text-ink-500" htmlFor="col-date-from"></label>
<Input id="col-date-from"
type="date"
value={dateFrom}
onChange={e => setDateFrom(e.target.value)}
className="field w-full"
className="w-full"
/>
</div>
<div>
<label className="mb-1.5 block text-xs text-ink-500"></label>
<input
<label className="mb-1.5 block text-xs text-ink-500" htmlFor="col-date-to"></label>
<Input id="col-date-to"
type="date"
value={dateTo}
onChange={e => setDateTo(e.target.value)}
className="field w-full"
className="w-full"
/>
</div>
</div>
@@ -268,13 +287,13 @@ export default function CollectionPage() {
{/* standings 任务专用: 赛季 */}
{(task === 'standings') && (
<div>
<label className="mb-1.5 block text-xs text-ink-500">()</label>
<input
<label className="mb-1.5 block text-xs text-ink-500" htmlFor="col-season">()</label>
<Input id="col-season"
type="text"
value={season}
onChange={e => setSeason(e.target.value)}
placeholder="如 2026-2027"
className="field w-full"
className="w-full"
/>
</div>
)}
@@ -283,13 +302,13 @@ export default function CollectionPage() {
{(task === 'stats') && (
<div>
<label className="mb-1.5 block text-xs text-ink-500">(1-500)</label>
<input
<Input
type="number"
min={1}
max={500}
value={limit}
onChange={e => setLimit(parseInt(e.target.value) || 100)}
className="field w-full"
className="w-full"
/>
<p className="mt-1 text-2xs text-ink-400">
source_event_id (), 1.2 /
@@ -308,9 +327,9 @@ export default function CollectionPage() {
)}
{/* 提交按钮 */}
<button type="submit" disabled={loading} className="btn btn-solid w-full">
<Button variant="solid" className="w-full" type="submit" disabled={loading}>
{loading ? (<><Spinner /> </>) : '触发采集'}
</button>
</Button>
</form>
</CardBody>
</Card>
@@ -366,7 +385,7 @@ export default function CollectionPage() {
<CardHeader
title="最近任务"
description="后台采集任务执行历史(最新在前)"
action={<button onClick={loadRecentJobs} className="btn btn-sm"></button>}
action={<Button size="sm" onClick={loadRecentJobs}></Button>}
/>
<CardBody className="px-0">
{recentJobs === null ? (