/** * 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 (
{!embedded && (

预测版 · {homeName} {awayName}

{prediction.provider} / {prediction.model} {prediction.latency_ms !== null && ` · ${(prediction.latency_ms / 1000).toFixed(1)}s`}
)}
{degraded && (

{prediction.status === 'failed' ? '预测失败' : '预测降级(degraded)'}

{prediction.reasoning || '所有专家均无有效数据或调用失败,无法生成可靠比分。'}

)} {!degraded && }

{`多专家模式 · ${okReports.length}/${reports.length} 路有效`} {prediction.prompt_version && ` · prompt ${prediction.prompt_version}`}

) } /** * 等待态过程显示。 * * 这里**刻意不画进度条**。 * * 此前本组件按「经过的秒数」推算进度(`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 (
{/* 阶段标题:只陈述事实,不宣称进度 */}
五路专家分析中 已等待 {elapsed}s
{/* 不确定态进度条(indeterminate)。 aria-valuetext 明确告知辅助技术「进度未知」,避免屏幕阅读器 把一个无意义的动画读成百分比进度。 */}
{/* 专家列表:并行真相 —— 同时只有一路「分析中」 */}

五路专家并行分析后终裁,约需 30-90 秒;多专家调用消耗较多 token,请按需使用。 关闭窗口将停止进度显示,后台任务可能仍在执行并消耗额度。

提示:每分钟限 10 次预测,耗尽后需等待下一分钟。

) } 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 ( {/* 弹窗报头 */}

预测版 · {homeName} {awayName}

{/* 弹窗体(小屏可滚动) */}
{predicting ? ( ) : error ? (

预测失败

{error}

) : prediction ? ( ) : null}
) }