import { useCallback, useEffect, useRef, useState } from 'react' import TeamSideTag from '../components/TeamSideTag' interface Match { id: number league_code: string | null season: string | null home_team: string away_team: string home_team_zh: string | null away_team_zh: string | null match_date: string match_status: string home_goals: number | null away_goals: number | null match_stage: string | null home_xg: number | null away_xg: number | null } interface Prediction { prediction_id: number provider: string model: string prompt_version: string | null mode: string pred_home_goals: number | null pred_away_goals: number | null alt_pred_home_goals: number | null alt_pred_away_goals: number | null pred_1x2: string | null subjective_confidence: number | null reasoning: string | null agent_outputs: AgentReport[] | null agent_weights: Record | null context: string latency_ms: number | null } interface AgentReport { agent: string status: string data_sufficiency: string analysis: string home_edge: number | null subjective_confidence: number | null key_evidence: string[] exp_home_goals: number | null exp_away_goals: number | null probable_score: string | null model: string latency_ms: number | null } const AGENT_LABELS: Record = { h2h: '历史交锋分析专家', form: '近期状态分析专家', stats: '攻防数据分析专家', home_away: '主客因素分析专家', injuries: '阵容完整性分析专家', } const LEAGUES = [ { code: 'E0', name: '英超' }, { code: 'SP1', name: '西甲' }, { code: 'D1', name: '德甲' }, { code: 'I1', name: '意甲' }, { code: 'F1', name: '法甲' }, ] /** 汉字编号,给专家意见排版用 */ const CN_NUM = ['一', '二', '三', '四', '五', '六', '七', '八'] const STATUS_META: Record = { finished: { label: '已完赛', cls: 'text-ink-400' }, scheduled: { label: '未开赛', cls: 'text-ink-600' }, live: { label: '进行中', cls: 'text-press font-medium' }, } /** 1x2 → 中文标签 */ const OUTCOME_LABEL: Record = { '1': '主胜', X: '平局', '2': '客胜' } /** 置信度细线:0~1 数值的低调可视化 */ function Meter({ value }: { value: number }) { const pct = Math.max(0, Math.min(100, Math.round(value * 100))) return (
) } /** home_edge(-1~1,正=利主队)的可视化:以中线为原点的双向细条 */ function EdgeBar({ value }: { value: number }) { const v = Math.max(-1, Math.min(1, value)) const half = Math.abs(v) * 50 return (
= 0 ? 'bg-press' : 'bg-ink-600'}`} style={ v >= 0 ? { left: '50%', width: `${half}%` } : { right: '50%', width: `${half}%` } } />
) } /** 骨架占位行:低调脉动灰块 */ function SkeletonRows({ n = 4 }: { n?: number }) { return ( <> {Array.from({ length: n }).map((_, i) => (
))} ) } function Spinner({ className = '' }: { className?: string }) { return ( ) } /** 胜平负一行文字:选中的红字加方块标记,未选中的退灰 */ function OutcomeLine({ pick, confidence, }: { pick: string | null confidence: number | null }) { const options = ['1', 'X', '2'] as const return (
{options.map(o => { const on = pick === o return (
{on && {on && confidence !== null && ( 置信 {Math.round(confidence * 100)}% )}
) })}
{pick && confidence !== null && (

主观置信度,非统计概率

)}
) } export default function Matches() { const [league, setLeague] = useState('E0') const [status, setStatus] = useState('scheduled') const [matches, setMatches] = useState([]) const [nextCursor, setNextCursor] = useState(null) const [loadingMore, setLoadingMore] = useState(false) const [loading, setLoading] = useState(false) const [predictingId, setPredictingId] = useState(null) const [prediction, setPrediction] = useState(null) const [predictionFor, setPredictionFor] = useState(null) const [error, setError] = useState(null) const [mode, setMode] = useState<'single' | 'multi'>('multi') // 请求竞态防护:切换联赛/状态很快时,先发的慢请求可能后返回, // 把旧结果覆盖到新筛选上。用递增序号只认最后一次请求的响应。 const loadSeq = useRef(0) const predictSeq = useRef(0) // 预测请求控制器:关闭弹窗时中止 const predictAbort = useRef(null) function closePredict() { predictAbort.current?.abort() predictSeq.current++ // 令中止请求的 catch/then 全部失效,不再写入错误 setPredictingId(null) setPrediction(null) setPredictionFor(null) setError(null) } const load = useCallback(async () => { const seq = ++loadSeq.current setLoading(true) // 切换筛选时作废进行中的「加载更多」,避免其标志位卡住 setLoadingMore(false) setError(null) try { const params = new URLSearchParams({ league, status, limit: '50' }) const res = await fetch(`/api/v1/matches?${params}`) if (seq !== loadSeq.current) return // 已有更新的请求,丢弃本次结果 if (!res.ok) throw new Error(`HTTP ${res.status}`) const data = await res.json() if (seq !== loadSeq.current) return setMatches(data.items) setNextCursor(data.next_cursor ?? null) } catch (e) { if (seq !== loadSeq.current) return setError(e instanceof Error ? e.message : String(e)) } finally { if (seq === loadSeq.current) setLoading(false) } }, [league, status]) // 加载下一页(游标分页) const loadMore = async () => { if (!nextCursor || loadingMore) return const seq = loadSeq.current // 不做自增:切换筛选会自增,这里只跟随当前序列 setLoadingMore(true) try { const params = new URLSearchParams({ league, status, limit: '50', cursor: nextCursor }) const res = await fetch(`/api/v1/matches?${params}`) if (seq !== loadSeq.current) return if (!res.ok) throw new Error(`HTTP ${res.status}`) const data = await res.json() if (seq !== loadSeq.current) return setMatches(prev => [...prev, ...data.items]) setNextCursor(data.next_cursor ?? null) } catch (e) { if (seq !== loadSeq.current) return setError(e instanceof Error ? e.message : String(e)) } finally { if (seq === loadSeq.current) setLoadingMore(false) } } useEffect(() => { load() }, [load]) const predict = async (m: Match) => { const seq = ++predictSeq.current setPredictingId(m.id) setError(null) setPrediction(null) setPredictionFor(m) // LLM 多专家预测耗时可达数分钟,给足超时(与 nginx 代理 300s 对齐) const controller = new AbortController() predictAbort.current = controller const timer = setTimeout(() => controller.abort(), 300_000) try { const res = await fetch('/api/v1/predict', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ match_id: m.id, mode }), signal: controller.signal, }) if (seq !== predictSeq.current) return if (!res.ok) { const t = await res.text() throw new Error(`HTTP ${res.status}: ${t}`) } const data = await res.json() if (seq !== predictSeq.current) return setPrediction(data) } catch (e) { if (seq !== predictSeq.current) return setError( e instanceof DOMException && e.name === 'AbortError' ? '预测超时(5 分钟),请稍后重试或改用单次模式' : e instanceof Error ? e.message : String(e), ) } finally { clearTimeout(timer) if (seq === predictSeq.current) setPredictingId(null) } } const fmtDate = (s: string) => { const d = new Date(s) return d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) } const leagueName = LEAGUES.find(l => l.code === league)?.name ?? league /** 状态/模式一组的文字切换 */ const Switch = ({ value, onChange, items }: { value: string onChange: (v: string) => void items: { v: string; label: string; title?: string }[] }) => ( {items.map((it, i) => ( {i > 0 && } ))} ) return (
{/* ── 联赛版面切换 ── */} {/* ── 第二行:状态 / 模式 / 计数 / 刷新 ── */}
状态 模式 setMode(v as 'single' | 'multi')} items={[ { v: 'single', label: '单次', title: '单次调用,快但只有一个模型看全部数据' }, { v: 'multi', label: '多专家', title: '5 个专家并行分析后由终裁汇总,质量更高' }, ]} /> 共 {matches.length} 场
{/* ── 错误提示 ── */} {error && (

请求失败

{error}

)} {/* ── 预测弹窗:进行中可视化 / 结果面板 ── */} {predictionFor && ( )} {/* ── 赛程栏:表格化,行间细线 ── */}
{loading && } {!loading && matches.length === 0 && (

本版暂无赛程

请先通过采集接口导入 {leagueName} 的比赛数据

)} {!loading && matches.map(m => { const st = STATUS_META[m.match_status] ?? { label: m.match_status, cls: 'text-ink-400' } const homeName = m.home_team_zh || m.home_team const awayName = m.away_team_zh || m.away_team const busy = predictingId === m.id const active = predictionFor?.id === m.id const finished = m.match_status === 'finished' return (
{/* 日期 + 状态:移动端同行,桌面端日期单独归列 */}
{fmtDate(m.match_date)} {st.label}
{/* 对阵:移动端主队/比分/客队同一行,桌面端 sm:contents 拆回 grid 列 */}
{/* 主队(右对齐) */}
{homeName}
{/* 比分 / VS */}
{m.home_goals !== null && m.away_goals !== null ? ( {m.home_goals}:{m.away_goals} ) : ( VS )} {m.home_xg !== null && m.away_xg !== null && ( xG {m.home_xg.toFixed(1)}-{m.away_xg.toFixed(1)} )}
{/* 客队(左对齐) */}
{awayName}
{/* 状态列(桌面) */} {st.label} {/* 预测按钮 */}
{!finished && ( )}
) })} {!loading && nextCursor && (
)}
) } /** 预测过程阶段(按时长模拟;结果到达即跳到完成) */ function PredictProgress({ mode }: { mode: 'single' | 'multi' }) { const [elapsed, setElapsed] = useState(0) useEffect(() => { const t = setInterval(() => setElapsed(e => e + 0.5), 500) return () => clearInterval(t) }, []) // 阶段阈值(秒): 切片 → 专家(各路依次点亮) → 终裁 const SLICE_END = mode === 'multi' ? 3 : 3 const AGENT_START = 4 const AGENT_STEP = 8 // 每路专家约 8s 点亮一路 const AGG_START = mode === 'multi' ? AGENT_START + AGENT_STEP * 5 : SLICE_END + 1 const agents = ['form', 'stats', 'home_away', 'injuries', 'h2h'] const phase = elapsed < SLICE_END ? 'slice' : mode === 'single' ? 'model' : elapsed < AGG_START ? 'agents' : 'agg' const pct = Math.min(95, Math.round((elapsed / (mode === 'multi' ? 70 : 20)) * 100)) return (
{/* 阶段标题 */}
{phase === 'slice' && '正在组装比赛数据切片'} {phase === 'agents' && '五路专家并行分析中'} {phase === 'model' && '模型分析中'} {phase === 'agg' && '终裁专家汇总裁定中'} {elapsed.toFixed(0)}s
{/* 进度条:渐进式,不封顶到 100% */}
{/* 专家灯序(多专家模式) */} {mode === 'multi' && (
    {agents.map((a, i) => { const lit = elapsed >= AGENT_START + AGENT_STEP * (i + 1) const activeNow = !lit && elapsed >= AGENT_START + AGENT_STEP * i return (
  • {lit && ✓ 完成} {activeNow && 分析中…}
  • ) })}
)}

{mode === 'multi' ? '五路专家并行分析后终裁,约需 30-90 秒;关闭窗口即取消' : '单次调用,约需 5-20 秒;关闭窗口即取消'}

) } /** 预测弹窗:进行中显示过程可视化,完成后显示预测版,失败显示原因 */ function PredictModal({ match, mode, predicting, prediction, error, onClose, }: { match: Match mode: 'single' | 'multi' 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 useEffect(() => { const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() } document.addEventListener('keydown', h) return () => document.removeEventListener('keydown', h) }, [onClose]) return (
{ if (e.target === e.currentTarget) onClose() }} >
{/* 弹窗报头 */}

预测版 · {homeName} {awayName}

{/* 弹窗体 */} {predicting ? ( ) : error ? (

预测失败

{error}

) : prediction ? ( ) : null}
) } function PredictionPanel({ prediction, match, mode, embedded = false, }: { prediction: Prediction match: Match mode: 'single' | 'multi' /** 弹窗嵌入模式:弹窗已提供报头,这里省略自带版头 */ embedded?: boolean }) { const homeName = match.home_team_zh || match.home_team const awayName = match.away_team_zh || match.away_team const okReports = (prediction.agent_outputs ?? []).filter(r => r.status === 'ok') return (
{/* 版头(嵌入模式由弹窗报头承担) */} {!embedded && (

预测版 · {homeName} {awayName}

{prediction.provider} / {prediction.model} {prediction.latency_ms !== null && ` · ${(prediction.latency_ms / 1000).toFixed(1)}s`}
)}
{/* ── 预测比分:版面核心,大号宋体 ── */}

{prediction.pred_home_goals ?? '-'} : {prediction.pred_away_goals ?? '-'}

预测比分

{prediction.alt_pred_home_goals !== null && prediction.alt_pred_away_goals !== null && (

备选{' '} {prediction.alt_pred_home_goals}:{prediction.alt_pred_away_goals}

)}
{/* ── 胜平负 ── */}
{/* ── 元信息一行 ── */}

{mode === 'multi' ? `多专家模式 · ${okReports.length}/${prediction.agent_outputs?.length ?? 0} 路有效` : '单次模式'} {prediction.prompt_version && ` · prompt ${prediction.prompt_version}`} {prediction.latency_ms !== null && ` · 终裁耗时 ${(prediction.latency_ms / 1000).toFixed(1)} 秒`}

{/* ── 专家意见 ── */} {prediction.agent_outputs && prediction.agent_outputs.length > 0 && (
五路专家意见 {prediction.agent_weights && ( 终裁权重:{Object.entries(prediction.agent_weights) .sort((a, b) => b[1] - a[1]) .map(([k, v]) => `${AGENT_LABELS[k] ?? k} ${Math.round(v * 100)}%`) .join(' / ')} )}
{prediction.agent_outputs.map((r, i) => ( ))}
)} {/* ── 终裁意见:引文式,红竖线 ── */} {prediction.reasoning && (

终裁意见

{prediction.reasoning}

)}
) } const STATUS_BADGE: Record = { ok: { label: '正常', cls: 'text-ink-500' }, no_data: { label: '无数据', cls: 'text-ink-400' }, error: { label: '调用失败', cls: 'text-press' }, parse_error: { label: '解析失败', cls: 'text-press' }, } const SUFFICIENCY_LABEL: Record = { high: '充分', medium: '一般', low: '偏少', none: '无', } /** 单路专家意见:汉字编号 + 细线行 */ function AgentCard({ report: r, no }: { report: AgentReport; no: string }) { const badge = STATUS_BADGE[r.status] ?? { label: r.status, cls: 'text-ink-400' } const inactive = r.status !== 'ok' return (
{no} {AGENT_LABELS[r.agent] ?? r.agent} {badge.label} {r.status === 'ok' && r.subjective_confidence !== null && ( 信心 {Math.round(r.subjective_confidence * 100)}% )} {r.status === 'ok' && r.probable_score && ( {r.probable_score} )}
{/* 无数据 / 失败时给出明确说明,避免用户以为是空白 bug */} {inactive && (

{r.status === 'no_data' && '该维度没有可用数据,已跳过 LLM 分析以节省额度(不影响其他专家)。'} {r.status === 'error' && '该专家调用失败,本次结论未纳入其视角(fail-open 设计,不阻断整体预测)。'} {r.status === 'parse_error' && '模型输出未通过格式校验,该报告已丢弃。'}

)} {!inactive && r.home_edge !== null && (
主队优势 0 ? 'text-press' : r.home_edge < 0 ? 'text-ink-700' : 'text-ink-500'}`}> {r.home_edge > 0 ? '+' : ''}{r.home_edge.toFixed(2)}
利客队 利主队
)} {r.analysis && (

{r.analysis}

)} {r.key_evidence.length > 0 && (
    {r.key_evidence.map((e, i) => (
  • {e}
  • ))}
)} {r.exp_home_goals !== null && r.exp_away_goals !== null && (

进球期望 {r.exp_home_goals.toFixed(1)} - {r.exp_away_goals.toFixed(1)}

)} {!inactive && (

数据充分度 {SUFFICIENCY_LABEL[r.data_sufficiency] ?? r.data_sufficiency} | {r.model} {r.latency_ms !== null && {r.latency_ms}ms}

)}
) }