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:
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user