Files
Profeto/frontend/src/pages/matches/components/MatchPredictPanel.tsx
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

250 lines
9.7 KiB
TypeScript

/**
* MatchPredictPanel: 预测弹窗全套(过程可视化 / 结果版面 / 专家意见)。
*
* D3: 从 Matches.tsx 拆出,渲染逻辑原样搬迁。对外只导出 PredictModal;
* PredictionPanel 复用 Prediction 的 embedded 模式由弹窗内渲染。
*/
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'
/** 胜平负一行文字:选中的红字加方块标记,未选中的退灰 */
/**
* P3-1:PredictionPanel 不再自绘,改为组合三个子组件:
* OutcomePanel(比分/胜平负/成本) / AgentsPanel(专家意见) / ReasoningPanel(终裁/降级)。
* 渲染输出与拆分前完全一致(仅降级警示 + 报头 + 元信息仍在此处)。
*/
function PredictionPanel({
prediction,
match,
embedded = false,
}: {
prediction: Prediction
match: Match
/** 弹窗嵌入模式:弹窗已提供报头,这里省略自带版头 */
embedded?: boolean
}) {
const homeName = match.home_team_zh || match.home_team
const awayName = match.away_team_zh || match.away_team
const degraded = prediction.status === 'degraded' || prediction.status === 'failed'
const reports = prediction.agent_outputs ?? []
const okReports = reports.filter(r => r.status === 'ok')
return (
<article className={embedded ? 'bg-paper-50' : 'border border-ink-900 bg-paper-50'}>
{!embedded && (
<div className="flex flex-wrap items-baseline justify-between gap-2 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>
<span className="text-2xs tabular-nums text-ink-500">
{prediction.provider} / {prediction.model}
{prediction.latency_ms !== null && ` · ${(prediction.latency_ms / 1000).toFixed(1)}s`}
</span>
</div>
)}
<div className="space-y-7 px-4 py-6 sm:px-5">
{degraded && (
<div className="border-l-2 border-press bg-press-wash/40 px-4 py-3">
<p className="font-serif text-sm font-bold text-press-dark">
{prediction.status === 'failed' ? '预测失败' : '预测降级(degraded)'}
</p>
<p className="mt-1.5 whitespace-pre-wrap text-xs leading-relaxed text-ink-600">
{prediction.reasoning || '所有专家均无有效数据或调用失败,无法生成可靠比分。'}
</p>
</div>
)}
{!degraded && <OutcomePanel prediction={prediction} />}
<p className="text-center text-2xs text-ink-500">
{`多专家模式 · ${okReports.length}/${reports.length} 路有效`}
{prediction.prompt_version && ` · prompt ${prediction.prompt_version}`}
</p>
<AgentsPanel prediction={prediction} />
<ReasoningPanel prediction={prediction} />
</div>
</article>
)
}
/**
* 等待态过程显示。
*
* 这里**刻意不画进度条**。
*
* 此前本组件按「经过的秒数」推算进度(`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 + 1), 1000)
return () => clearInterval(t)
}, [])
// 与后端 SPECIALIST_SPECS 一致的五路专家(见 src/llm/agents/orchestrator.py)。
// 顺序仅影响展示,不代表执行先后 —— 它们是并行执行的。
const agents = ['form', 'stats', 'home_away', 'standings', 'h2h']
// 当前严格串行执行的一路(后端为并发 + 全局信号量,故同一时刻只有一路在调用)
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">
五路专家分析中
</span>
<span className="text-2xs tabular-nums text-ink-400" aria-live="polite">
已等待 {elapsed}s
</span>
</div>
{/*
不确定态进度条(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 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,请按需使用。
关闭窗口将停止进度显示,后台任务可能仍在执行并消耗额度。
</p>
<p className="mt-1 text-center text-2xs text-ink-300">
提示:每分钟限 10 次预测,耗尽后需等待下一分钟。
</p>
</div>
)
}
export function PredictModal({
match,
predicting,
prediction,
error,
onClose,
}: {
match: Match
predicting: boolean
prediction: Prediction | null
error: string | null
onClose: () => void
}) {
const homeName = match.home_team_zh || match.home_team
const awayName = match.away_team_zh || match.away_team
return (
<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 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">
{predicting ? (
<PredictProgress />
) : error ? (
<div className="px-5 py-10 text-center sm:px-8">
<p className="font-serif text-sm font-bold text-press">预测失败</p>
<p className="mx-auto mt-3 max-w-md whitespace-pre-wrap text-left text-xs leading-relaxed text-ink-600">
{error}
</p>
<Button variant="default" size="sm" className="mt-6" onClick={onClose}>
关闭
</Button>
</div>
) : prediction ? (
<PredictionPanel prediction={prediction} match={match} embedded />
) : null}
</div>
</Modal>
)
}