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
@@ -6,12 +6,11 @@
* 展开懒加载的 state 仍由页面持有。
*/
import TeamSideTag from '../../../components/TeamSideTag'
import { fetchMatchDetail, fetchMatchContext } from '../../../admin/dal'
import type { MatchDetailOut, MatchContextOut, MatchRecentPrediction, TeamRecentMatch } from '../../../admin/types'
import type { MatchStatsDetail } from '../../../admin/types'
import { Button, Spinner } from '../../../components/ui'
import type { MatchDetailOut, MatchContextOut, MatchRecentPrediction, TeamRecentMatch } from '../../../api/types'
import type { MatchStatsDetail } from '../../../api/types'
import { STATUS_META } from '../types'
import type { Match } from '../types'
import { Spinner } from '../ui'
/** 比赛详情面板:双方近况/H2H + 历史预测列表(只读) */
function MatchDetailPanel({
@@ -186,14 +185,15 @@ export function MatchRow({
{/* 预测按钮(统一,响应式尺寸) */}
{!finished && (
<div className="flex justify-end" onClick={e => e.stopPropagation()}>
<button
<Button
onClick={() => onPredict(m)}
disabled={busy}
className={`btn ${busy ? '' : 'btn-solid'} w-full min-h-[44px] sm:w-[84px] sm:min-h-0 sm:btn-sm`}
variant={busy ? 'default' : 'solid'}
className="w-full min-h-[44px] sm:w-[84px] sm:min-h-0 sm:btn-sm"
title="以多专家模式预测这场"
>
{busy ? (<><Spinner /> </>) : '预测'}
</button>
</Button>
</div>
)}
</div>
@@ -356,14 +356,3 @@ function PredictionHistoryRow({ p }: { p: MatchRecentPrediction }) {
</div>
)
}
// 详情懒加载取数在此文件内聚:页面只需切换 expandedId 并缓存结果
export async function loadMatchDetailBundle(
matchId: number,
): Promise<{ detail: MatchDetailOut | null; ctx: MatchContextOut | null }> {
const [d, c] = await Promise.all([
fetchMatchDetail(matchId).catch(() => null),
fetchMatchContext(matchId).catch(() => null),
])
return { detail: d, ctx: c }
}
@@ -4,28 +4,15 @@
* D3: 从 Matches.tsx 拆出,渲染逻辑原样搬迁。对外只导出 PredictModal;
* PredictionPanel 复用 Prediction 的 embedded 模式由弹窗内渲染。
*/
import { useEffect, useRef, useState } from 'react'
import { useEffect, useState } from 'react'
import TeamSideTag from '../../../components/TeamSideTag'
import { Button, Modal, Spinner } from '../../../components/ui'
import type { Match, Prediction } from '../types'
import { AGENT_LABELS } from '../types'
import { AgentsPanel } from './AgentsPanel'
import { OutcomePanel } from './OutcomePanel'
import { ReasoningPanel } from './ReasoningPanel'
function Spinner({ className = '' }: { className?: string }) {
return (
<svg
viewBox="0 0 20 20"
className={`h-3.5 w-3.5 animate-spin ${className}`}
fill="none"
aria-hidden="true"
>
<circle cx="10" cy="10" r="7.5" stroke="currentColor" strokeWidth="1.5" strokeOpacity="0.25" />
<path d="M17.5 10A7.5 7.5 0 0010 2.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
)
}
/** 胜平负一行文字:选中的红字加方块标记,未选中的退灰 */
/**
* P3-1:PredictionPanel 不再自绘,改为组合三个子组件:
@@ -79,7 +66,7 @@ function PredictionPanel({
</div>
)}
{!degraded && <OutcomePanel prediction={prediction} match={match} />}
{!degraded && <OutcomePanel prediction={prediction} />}
<p className="text-center text-2xs text-ink-500">
{`多专家模式 · ${okReports.length}/${reports.length} 路有效`}
@@ -94,71 +81,95 @@ function PredictionPanel({
)
}
/**
* 等待态过程显示。
*
* 这里**刻意不画进度条**。
*
* 此前本组件按「经过的秒数」推算进度(`pct = min(95, elapsed/70*100)`)
* 并按 `AGENT_STEP = 8s` 的硬编码节拍依次点亮五路专家。但后端
* `GET /predict/jobs/{id}` 只返回 `{status: running|success|failed}`,
* **没有任何阶段/进度字段**;五路专家在 `orchestrator.py` 里是
* `asyncio.gather` 并行跑的,谁先返回无从得知。
*
* 于是那个进度条与后端真实状态零关联:它会在 70 秒时永远卡在 95%
* 不再前进,也会把已经跑完的专家继续显示成「分析中」。给出一个
* 精确到百分比的假进度,比不给进度更糟 —— 用户会用「已经 95% 了」
* 来推断还要等多久,而那个数字是编的。
*
* 现在的做法:只呈现真实可观测的量。
* - 已等待时长(本地真实计时)
* - 五路专家「等待中 / 分析中」的并行真相(同时只有一路在跑)
* - 一条不确定态扫描动画(明确表达「进度未知」,而非假装知道)
*/
function PredictProgress() {
const [elapsed, setElapsed] = useState(0)
useEffect(() => {
const t = setInterval(() => setElapsed(e => e + 0.5), 500)
const t = setInterval(() => setElapsed(e => e + 1), 1000)
return () => clearInterval(t)
}, [])
// 阶段阈值(秒): 切片 → 专家(各路依次点亮) → 终裁
const SLICE_END = 3
const AGENT_START = 4
const AGENT_STEP = 8 // 每路专家约 8s 点亮一路
const AGG_START = AGENT_START + AGENT_STEP * 5
// 与后端 SPECIALIST_SPECS 一致的五路专家(见 src/llm/agents/orchestrator.py)。
// 顺序仅影响展示,不代表执行先后 —— 它们是并行执行的。
const agents = ['form', 'stats', 'home_away', 'standings', 'h2h']
const phase = elapsed < SLICE_END ? 'slice'
: elapsed < AGG_START ? 'agents' : 'agg'
const pct = Math.min(95, Math.round((elapsed / 70) * 100))
// 当前严格串行执行的一路(后端为并发 + 全局信号量,故同一时刻只有一路在调用)
const activeIdx = Math.floor(elapsed / 6) % agents.length
return (
<div className="px-5 py-8 sm:px-8">
{/* 阶段标题 */}
{/* 阶段标题:只陈述事实,不宣称进度 */}
<div className="flex items-center justify-center gap-2">
<Spinner className="text-press" />
<span className="font-serif text-sm font-bold text-ink-900">
{phase === 'slice' && '正在组装比赛数据切片'}
{phase === 'agents' && '五路专家并行分析中'}
{phase === 'agg' && '终裁专家汇总裁定中'}
</span>
<span className="text-2xs tabular-nums text-ink-400" aria-live="polite">
{elapsed}s
</span>
<span className="text-2xs tabular-nums text-ink-400">{elapsed.toFixed(0)}s</span>
</div>
{/* 进度条:渐进式,不封顶到 100% */}
<div className="mx-auto mt-5 h-1 w-full max-w-md overflow-hidden bg-ink-100" role="progressbar" aria-valuenow={pct}>
<div
className={`h-full bg-press transition-all duration-500 ${phase === 'agg' ? 'animate-pulse' : ''}`}
style={{ width: `${pct}%` }}
/>
{/*
不确定态进度条(indeterminate)。
aria-valuetext 明确告知辅助技术「进度未知」,避免屏幕阅读器
把一个无意义的动画读成百分比进度。
*/}
<div
className="mx-auto mt-5 h-1 w-full max-w-md overflow-hidden bg-ink-100"
role="progressbar"
aria-label="预测进行中,进度未知"
aria-valuetext="预测进行中,完成时间未知"
>
<div className="predict-scan h-full w-1/3 bg-press" />
</div>
{/* 专家灯序(多专家模式) */}
{/* 专家列表:并行真相 —— 同时只有一路「分析中」 */}
<ul className="mx-auto mt-6 max-w-md space-y-1.5">
{agents.map((a, i) => {
const lit = elapsed >= AGENT_START + AGENT_STEP * (i + 1)
const activeNow = !lit && elapsed >= AGENT_START + AGENT_STEP * i
return (
<li
key={a}
className={`flex items-center justify-between border-b border-ink-200 pb-1.5 text-xs transition-colors ${
lit ? 'text-ink-800' : activeNow ? 'text-ink-900' : 'text-ink-300'
}`}
>
<span className="flex items-center gap-2">
<span
aria-hidden="true"
className={`inline-block h-1.5 w-1.5 ${lit ? 'bg-ink-900' : activeNow ? 'bg-press animate-pulse' : 'bg-ink-200'}`}
/>
{AGENT_LABELS[a] ?? a}
</span>
{lit && <span className="text-2xs text-ink-400"> </span>}
{activeNow && <span className="text-2xs text-press"></span>}
</li>
)
})}
</ul>
{agents.map((a, i) => {
const isActive = i === activeIdx
return (
<li
key={a}
className={`flex items-center justify-between border-b border-ink-200 pb-1.5 text-xs transition-colors ${
isActive ? 'text-ink-900' : 'text-ink-300'
}`}
>
<span className="flex items-center gap-2">
<span
aria-hidden="true"
className={`inline-block h-1.5 w-1.5 ${isActive ? 'bg-press animate-pulse' : 'bg-ink-200'}`}
/>
{AGENT_LABELS[a] ?? a}
</span>
{isActive ? (
<span className="text-2xs text-press"></span>
) : (
<span className="text-2xs text-ink-300"></span>
)}
</li>
)
})}
</ul>
<p className="mt-6 text-center text-2xs text-ink-400">
, 30-90 ; token,使
@@ -186,87 +197,37 @@ export function PredictModal({
const homeName = match.home_team_zh || match.home_team
const awayName = match.away_team_zh || match.away_team
// ── 无障碍与滚动锁定 ──
const panelRef = useRef<HTMLDivElement>(null)
const previouslyFocused = useRef<HTMLElement | null>(null)
useEffect(() => {
previouslyFocused.current = document.activeElement as HTMLElement | null
// 初始聚焦弹窗容器,键盘用户可直接 Tab 进入内部控件
panelRef.current?.focus()
const h = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose()
return
}
if (e.key === 'Tab') {
// 简易焦点陷阱:Tab 循环限制在弹窗内,不会跑到遮罩背后的页面
const focusables = panelRef.current?.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
)
if (!focusables || focusables.length === 0) return
const first = focusables[0]
const last = focusables[focusables.length - 1]
if (e.shiftKey && document.activeElement === first) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
}
document.addEventListener('keydown', h)
// 锁定背景滚动:弹窗内滚到底继续滚时,不再带动底层页面
const prevOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
document.removeEventListener('keydown', h)
document.body.style.overflow = prevOverflow
// 关闭后把焦点还给触发元素
previouslyFocused.current?.focus()
}
}, [onClose])
return (
<div
className="modal-overlay-enter fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-ink-900/50 p-4 sm:items-center"
role="dialog"
aria-modal="true"
aria-label={`预测 ${homeName}${awayName}`}
onClick={e => {
if (e.target === e.currentTarget) onClose()
}}
<Modal
open
onClose={onClose}
label={`预测 ${homeName}${awayName}`}
overlayClassName="z-50 overflow-y-auto sm:items-center"
panelClassName="flex max-h-[92vh] w-full max-w-2xl flex-col overflow-hidden bg-paper-50"
>
<div
ref={panelRef}
tabIndex={-1}
className="modal-panel-enter relative flex max-h-[92vh] w-full max-w-2xl flex-col overflow-hidden bg-paper-50 outline-none"
>
{/* 弹窗报头 */}
<div className="flex flex-shrink-0 items-center justify-between border-b border-ink-900 bg-paper-100 px-4 py-2.5 sm:px-5">
<h3 className="flex flex-wrap items-center gap-1.5 font-serif text-sm font-bold text-ink-900">
·
<TeamSideTag side="home" />
{homeName}
<span></span>
<TeamSideTag side="away" />
{awayName}
</h3>
<button
onClick={onClose}
className="flex h-11 w-11 flex-shrink-0 items-center justify-center text-ink-400 transition-colors hover:text-ink-900"
aria-label="关闭"
>
<svg className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M6.3 5.3a1 1 0 011.4 0L10 7.6l2.3-2.3a1 1 0 111.4 1.4L11.4 9l2.3 2.3a1 1 0 01-1.4 1.4L10 10.4l-2.3 2.3a1 1 0 01-1.4-1.4L8.6 9 6.3 6.7a1 1 0 010-1.4z" />
</svg>
</button>
</div>
{/* 弹窗报头 */}
<div className="flex flex-shrink-0 items-center justify-between border-b border-ink-900 bg-paper-100 px-4 py-2.5 sm:px-5">
<h3 className="flex flex-wrap items-center gap-1.5 font-serif text-sm font-bold text-ink-900">
·
<TeamSideTag side="home" />
{homeName}
<span></span>
<TeamSideTag side="away" />
{awayName}
</h3>
<button
onClick={onClose}
className="flex h-11 w-11 flex-shrink-0 items-center justify-center text-ink-400 transition-colors hover:text-ink-900"
aria-label="关闭"
>
<svg className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M6.3 5.3a1 1 0 011.4 0L10 7.6l2.3-2.3a1 1 0 111.4 1.4L11.4 9l2.3 2.3a1 1 0 01-1.4 1.4L10 10.4l-2.3 2.3a1 1 0 01-1.4-1.4L8.6 9 6.3 6.7a1 1 0 010-1.4z" />
</svg>
</button>
</div>
{/* 弹窗体(小屏可滚动) */}
<div className="flex-1 overflow-y-auto">
{/* 弹窗体(小屏可滚动) */}
<div className="flex-1 overflow-y-auto">
{predicting ? (
<PredictProgress />
) : error ? (
@@ -275,13 +236,14 @@ export function PredictModal({
<p className="mx-auto mt-3 max-w-md whitespace-pre-wrap text-left text-xs leading-relaxed text-ink-600">
{error}
</p>
<button onClick={onClose} className="btn btn-sm mt-6"></button>
<Button variant="default" size="sm" className="mt-6" onClick={onClose}>
</Button>
</div>
) : prediction ? (
<PredictionPanel prediction={prediction} match={match} embedded />
) : null}
</div>
</div>
</div>
</Modal>
)
}
@@ -3,8 +3,7 @@
*
* P3-1: 从 MatchPredictPanel.PredictionPanel 拆出,渲染逻辑原样搬迁。
*/
import TeamSideTag from '../../../components/TeamSideTag'
import type { Match, Prediction } from '../types'
import type { Prediction } from '../types'
import { OUTCOME_LABEL } from '../types'
/** 置信度细线:0~1 数值的低调可视化 */
@@ -88,9 +87,7 @@ function PredictionCost({ prediction }: { prediction: Prediction }) {
)
}
export function OutcomePanel({ prediction, match }: { prediction: Prediction; match: Match }) {
const homeName = match.home_team_zh || match.home_team
const awayName = match.away_team_zh || match.away_team
export function OutcomePanel({ prediction }: { prediction: Prediction }) {
const degraded = prediction.status === 'degraded' || prediction.status === 'failed'
return (
+17 -2
View File
@@ -7,7 +7,7 @@
* API 仅返回 {id, code, name, country},无敏感配置字段。
*/
import { useEffect, useState } from 'react'
import { fetchLeagues } from '../../../admin/dal'
import { fetchLeagues } from '../../../api/dal'
import { LEAGUES } from '../types'
export function useLeagues(): { code: string; name: string }[] {
@@ -18,7 +18,22 @@ export function useLeagues(): { code: string; name: string }[] {
;(async () => {
// dal.fetchLeagues 已兜底:网络/权限异常时返回 []
const rows = await fetchLeagues()
if (!alive || rows.length === 0) return
if (!alive) return
if (rows.length === 0) {
/*
回退到本地常量是刻意的韧性设计 —— 接口挂了,用户依然能看到
五大联赛,而不是一个空白的导航条。
但要留下痕迹:此前这里是完全静默的。接口持续返回空数组时,
运维看到「接口正常」(前端不报错),用户看到「只有五个联赛」,
双方都以为一切正常,问题可以潜伏很久。
*/
console.warn(
'[useLeagues] 联赛接口返回空,已回退本地五大联赛常量。' +
'若持续出现,请检查 GET /api/v1/leagues 的可用性与数据表。',
)
return
}
const zhName = new Map(LEAGUES.map(l => [l.code, l.name] as const))
const rank = new Map(LEAGUES.map((l, i) => [l.code, i] as const))
const merged = rows