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处存量)
250 lines
9.7 KiB
TypeScript
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>
|
|
)
|
|
}
|