/**
* 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 明确告知辅助技术「进度未知」,避免屏幕阅读器
把一个无意义的动画读成百分比进度。
*/}
{/* 专家列表:并行真相 —— 同时只有一路「分析中」 */}
{agents.map((a, i) => {
const isActive = i === activeIdx
return (
-
{AGENT_LABELS[a] ?? a}
{isActive ? (
分析中…
) : (
等待中
)}
)
})}
五路专家并行分析后终裁,约需 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 ? (
) : prediction ? (
) : null}
)
}