import { useCallback, useEffect, useRef, useState } from 'react' 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 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 STATUS_META: Record = { finished: { label: '已完赛', cls: 'bg-ink-100 text-ink-600' }, scheduled: { label: '未开赛', cls: 'bg-brand-50 text-brand-700' }, live: { label: '进行中', cls: 'bg-emerald-50 text-emerald-700' }, } /** 1x2 → 中文标签 */ const OUTCOME_LABEL: Record = { '1': '主胜', X: '平局', '2': '客胜' } /** 队名取首字作视觉标记(替代队徽图片,避免额外资源与 404) */ function initial(name: string): string { return (name || '?').trim().charAt(0) } /** 依据队名生成一个稳定的色相,让不同球队有可区分的淡色底 */ function hueOf(name: string): number { let h = 0 for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) % 360 return h } function TeamMark({ name, size = 40 }: { name: string; size?: number }) { const h = hueOf(name) return ( ) } /** 置信度条:把 0~1 的数值可视化,比纯数字更易读 */ function Meter({ value, tone = 'brand' }: { value: number; tone?: 'brand' | 'emerald' | 'amber' }) { const pct = Math.max(0, Math.min(100, Math.round(value * 100))) const bar = tone === 'emerald' ? 'bg-emerald-500' : tone === 'amber' ? 'bg-amber-500' : 'bg-brand-500' 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-brand-500' : 'bg-amber-500'}`} style={ v >= 0 ? { left: '50%', width: `${half}%` } : { right: '50%', width: `${half}%` } } />
) } /** 骨架屏行,替代 emoji spinner */ function SkeletonRows({ n = 4 }: { n?: number }) { return ( <> {Array.from({ length: n }).map((_, i) => (
))} ) } 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 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]) // 加载下一页(游标分页)。后端已支持 cursor,前端此前未使用, // 导致 limit=50 之后的数据永远看不到。 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) try { const res = await fetch('/api/v1/predict', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ match_id: m.id, mode }), }) 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 Error ? e.message : String(e)) } finally { 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 return (
{/* ── 工具栏 ── */}
{matches.length}
{/* ── 错误提示 ── */} {error && (

请求失败

{error}

)} {/* ── 比赛列表 ── */}
{loading && } {!loading && matches.length === 0 && (

暂无比赛数据

请先通过采集接口导入 {leagueName} 的赛程

)} {!loading && matches.map(m => { const st = STATUS_META[m.match_status] ?? { label: m.match_status, cls: 'bg-ink-100 text-ink-600' } 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 return (
{/* 对阵 */}
{homeName}
{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} {m.league_code ?? '—'} · {fmtDate(m.match_date)}
{/* 操作:主功能提升为实心按钮 */}
) })} {!loading && nextCursor && (
)}
{/* ── 预测中:占位卡片(替代 emoji 提示条) ── */} {predictingId && !prediction && (
正在生成预测 {mode === 'multi' ? '· 5 个专家并行分析后由终裁汇总,约需 20-60 秒' : '· 单次调用,约需 5-15 秒'}
)} {/* ── 预测结果 ── */} {prediction && predictionFor && ( )}
) } function Spinner({ className = '' }: { className?: string }) { // 纯 SVG 转圈,替代 emoji ⏳(各平台渲染不一致) return ( ) } /** 胜平负概率条:LLM 只给主观置信度,这里把它按 1x2 结果做成单条高亮, * 并显式标注「主观」避免用户误当概率 */ function OutcomeCard({ pick, confidence, }: { pick: string | null confidence: number | null }) { const label = pick ? OUTCOME_LABEL[pick] ?? pick : '—' const tone = pick === '1' ? 'brand' : pick === 'X' ? 'amber' : 'emerald' const color = tone === 'brand' ? 'text-brand-700' : tone === 'amber' ? 'text-amber-700' : 'text-emerald-700' const bg = tone === 'brand' ? 'bg-brand-50' : tone === 'amber' ? 'bg-amber-50' : 'bg-emerald-50' const border = tone === 'brand' ? 'border-brand-100' : tone === 'amber' ? 'border-amber-100' : 'border-emerald-100' return (

胜平负

{label}

{confidence !== null && (

主观置信度 {Math.round(confidence * 100)}%

)}
) } function PredictionPanel({ prediction, match, mode, }: { prediction: Prediction match: Match mode: 'single' | 'multi' }) { 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 (
{/* 面板头 */}

LLM 预测结果

{prediction.mode === 'multi' && ( 多 Agent · {okReports.length}/{prediction.agent_outputs?.length ?? 0} 专家有效 )}
{prediction.provider} / {prediction.model} {prediction.latency_ms !== null && ` · ${(prediction.latency_ms / 1000).toFixed(1)}s`}
{/* ── 对阵 + 预测比分(核心) ── */}
{homeName}
{prediction.pred_home_goals ?? '-'} : {prediction.pred_away_goals ?? '-'}
预测比分
{awayName}
{/* ── 指标卡 ── */}

预测模式

{mode === 'multi' ? '多 Agent' : '单次'}

prompt {prediction.prompt_version ?? '—'}

模型耗时

{prediction.latency_ms !== null ? (prediction.latency_ms / 1000).toFixed(1) : '—'} s

含专家并行 + 终裁汇总

{/* ── 专家报告 ── */} {prediction.agent_outputs && prediction.agent_outputs.length > 0 && (

专家 Agent 报告

{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 => ( ))}
)} {/* ── 推理过程 ── */} {prediction.reasoning && (

终裁推理

{prediction.reasoning}

)} {/* ── 原始上下文 ── */}
查看喂给模型的完整数据切片
            {prediction.context}
          
) } const STATUS_BADGE: Record = { ok: { label: '正常', cls: 'bg-emerald-50 text-emerald-700 border-emerald-100' }, no_data: { label: '无数据', cls: 'bg-ink-100 text-ink-500 border-ink-200' }, error: { label: '调用失败', cls: 'bg-red-50 text-red-600 border-red-100' }, parse_error: { label: '解析失败', cls: 'bg-amber-50 text-amber-700 border-amber-100' }, } const SUFFICIENCY_LABEL: Record = { high: '充分', medium: '一般', low: '偏少', none: '无', } function AgentCard({ report: r }: { report: AgentReport }) { const badge = STATUS_BADGE[r.status] ?? { label: r.status, cls: 'bg-ink-100 text-ink-500 border-ink-200' } const inactive = r.status !== 'ok' return (
{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-brand-600' : r.home_edge < 0 ? 'text-amber-600' : '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}
)}
) }