import { useCallback, useEffect, useRef, useState } from 'react' import TeamSideTag from '../components/TeamSideTag' import { fetchMatchDetail, fetchMatchContext } from '../admin/dal' import type { MatchDetailOut, MatchContextOut, MatchRecentPrediction, TeamRecentMatch } from '../admin/types' import type { MatchStatsDetail } from '../admin/types' import { http } from '../lib/http' 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 status: string agent_outputs: AgentReport[] | null agent_weights: Record | null context: string latency_ms: number | null prompt_tokens: number | null completion_tokens: number | null rate_limit_remaining: 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: '主客因素分析专家', standings: '联赛排名分析专家', } 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' }, // 键与 normalize.py 的 VALID_STATUS 对齐: 库里存的是 in_play(上游 live 被归一化),不存在 'live' 状态 in_play: { label: '进行中', cls: 'text-press font-medium' }, paused: { label: '暂停', cls: 'text-press font-medium' }, postponed: { label: '延期', cls: 'text-ink-400' }, cancelled: { label: '取消', cls: 'text-ink-400' }, suspended: { label: '中止', cls: 'text-ink-400' }, } /** 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 [showAllUpcoming, setShowAllUpcoming] = useState(false) // 默认仅展示未来 3 天;true 展开全部 const [liveMatches, setLiveMatches] = useState([]) // 进行中比赛(顶部独立区块) const [showBackTop, setShowBackTop] = useState(false) // 回到顶部按钮显示态 // 监听滚动,超过 300px 显示回到顶部按钮 useEffect(() => { const handleScroll = () => setShowBackTop(window.scrollY > 300) window.addEventListener('scroll', handleScroll, { passive: true }) return () => window.removeEventListener('scroll', handleScroll) }, []) const scrollToTop = () => window.scrollTo({ top: 0, behavior: 'smooth' }) const [predictingId, setPredictingId] = useState(null) const [prediction, setPrediction] = useState(null) const [predictionFor, setPredictionFor] = useState(null) const [error, setError] = useState(null) const [expandedId, setExpandedId] = useState(null) const [detailMap, setDetailMap] = useState>({}) const [contextMap, setContextMap] = useState>({}) const [detailLoading, setDetailLoading] = useState(null) // 请求竞态防护:切换联赛/状态很快时,先发的慢请求可能后返回, // 把旧结果覆盖到新筛选上。用递增序号只认最后一次请求的响应。 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) setShowAllUpcoming(false) // 切换筛选重置为「未来 3 天」视图 setError(null) try { const params = new URLSearchParams({ league, status, limit: '50' }) const data = await http.get<{ items: Match[]; next_cursor: string | null }>(`/matches?${params}`) 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 data = await http.get<{ items: Match[]; next_cursor: string | null }>(`/matches?${params}`) 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) } } // 加载进行中比赛(顶部独立区块) const loadLive = useCallback(async () => { try { const params = new URLSearchParams({ league, status: 'in_play', limit: '20' }) const data = await http.get<{ items: Match[] }>(`/matches?${params}`) setLiveMatches(data.items ?? []) } catch { /* ignore:进行中非核心功能 */ } }, [league]) useEffect(() => { load(); loadLive() }, [load, loadLive]) /** 日期 key 辅助:YYYY-MM-DD(本地时区) */ function dateKey(d: Date): string { return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` } /** 未来 3 天窗口:今天 00:00 → 第 3 天 00:00(即今天/明天/后天) */ function addDays(d: Date, n: number): string { const x = new Date(d) x.setFullYear(x.getFullYear(), x.getMonth(), x.getDate() + n) return dateKey(x) } const todayKey = dateKey(new Date()) const windowEnd = addDays(new Date(), 3) // 不含 /** 比赛是否在未来 3 天内(用于默认视图过滤) */ function withinNext3Days(matchDate: string): boolean { const key = toLocalDateKey(matchDate) return key >= todayKey && key < windowEnd } /** UTC ISO → 本地日期 YYYY-MM-DD(用于分组) */ function toLocalDateKey(iso: string): string { const d = new Date(iso) return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` } /** 按本地日期分组(非 UTC),保持时间序 */ function groupByDate(list: Match[]): Array<[string, Match[]]> { const map = new Map() for (const m of list) { const key = toLocalDateKey(m.match_date) const arr = map.get(key) if (arr) arr.push(m) else map.set(key, [m]) } return [...map.entries()] } const predict = async (m: Match) => { // 防连点:若该场比赛已在预测中,直接忽略 if (predictingId === m.id) return 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 data = await http.post('/predict', { match_id: m.id, mode: 'multi' }, { timeoutMs: 300_000, signal: controller.signal, }) if (seq !== predictSeq.current) return setPrediction(data) } catch (e) { if (seq !== predictSeq.current) return setError( e instanceof DOMException && e.name === 'AbortError' ? '预测超时(5 分钟),请稍后重试' : readablePredictError(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' }) } /** 只显示时间 HH:mm(日期由分组头承担) */ const fmtTime = (s: string) => { const d = new Date(s) return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false }) } const leagueName = LEAGUES.find(l => l.code === league)?.name ?? league /** 未开赛默认仅展示未来 3 天;其余状态展示全部。showAllUpcoming=true 时展开全部。 */ const isScheduledView = status === 'scheduled' const visibleMatches = (!isScheduledView || showAllUpcoming) ? matches : matches.filter(m => withinNext3Days(m.match_date)) // 是否有被折叠的未开赛比赛(用于显示「展开」按钮) const hasHiddenUpcoming = isScheduledView && !showAllUpcoming && matches.length > visibleMatches.length /** 状态/模式一组的文字切换 */ 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 (
{/* ── 联赛版面切换 ── */} {/* ── 第二行:状态 / 模式 / 日期 / 计数 / 刷新(小屏 flex-wrap) ── */}
状态 {isScheduledView && !showAllUpcoming && hasHiddenUpcoming ? `未来3天 ${visibleMatches.length} / 共 ${matches.length} 场` : `共 ${visibleMatches.length} 场`}
{/* ── 错误提示(统一 error-banner 样式) ── */} {error && (

请求失败

{error}

)} {/* ── 预测弹窗:进行中可视化 / 结果面板 ── */} {predictionFor && ( )} {/* ── 进行中比赛(顶部独立区块,仅未开赛视图展示) ── */} {isScheduledView && liveMatches.length > 0 && (
进行中 · 实时比分 {liveMatches.length} 场
{liveMatches.map(m => { const homeName = m.home_team_zh || m.home_team const awayName = m.away_team_zh || m.away_team return (
{m.home_goals ?? '-'} {homeName}
vs
{awayName} {m.away_goals ?? '-'}
) })}
)} {/* ── 赛程栏:表格化,行间细线,按日期分组 ── */}
{loading && } {!loading && visibleMatches.length === 0 && (
{matches.length > 0 ? ( <>

未来 3 天暂无 {leagueName} 比赛

已导入 {matches.length} 场未开赛,点击下方按钮查看

) : ( <>

本版暂无赛程

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

前往数据采集 )}
)} {!loading && groupByDate(visibleMatches).map(([dateKey, group]) => (
{/* 日期分组头:sticky 但 z 低于弹窗 z-50 */}
{formatDateHeader(dateKey)} {group.length} 场
{group.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 finished = m.match_status === 'finished' const expanded = expandedId === m.id const detail = detailMap[m.id] const ctx = contextMap[m.id] // 展开时懒加载详情(只读,不触发 LLM) async function toggleExpand() { if (expanded) { setExpandedId(null); return } setExpandedId(m.id) if (!detailMap[m.id] || !contextMap[m.id]) { setDetailLoading(m.id) try { const [d, c] = await Promise.all([ fetchMatchDetail(m.id).catch(() => null), fetchMatchContext(m.id).catch(() => null), ]) if (d) setDetailMap(prev => ({ ...prev, [m.id]: d })) if (c) setContextMap(prev => ({ ...prev, [m.id]: c })) } finally { setDetailLoading(null) } } } return (
{/* 行:可点击展开 */}
{ if (e.key === 'Enter') toggleExpand() }} aria-expanded={expanded} > {/* 桌面 grid: 日期 | 主队 | 比分 | 客队 | 状态 | 按钮 */}
{/* 日期 + 状态:小屏同行;桌面 date 单独一列 */}
{fmtTime(m.match_date)} {st.label}
{/* 主队 + 比分 + 客队:移动端 grid 三列(严格居中),桌面端 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 && (
e.stopPropagation()}>
)}
{/* 关闭可点击行 */} {/* 展开详情面板 */} {expanded && ( )}
) })}
))} {/* 单一「加载更多」按钮:3 天视图时先展开,展开后从服务器拉取下一页 */} {!loading && (hasHiddenUpcoming || nextCursor) && (
{hasHiddenUpcoming ? ( ) : ( )}
)} {/* 已展开全部但未开赛:提供「收起」回未来 3 天 */} {!loading && isScheduledView && showAllUpcoming && matches.length > 0 && (
)}
{/* 回到顶部按钮 */}
) } /** 日期分组头显示:今日/明天/周几 · 年月日 */ function formatDateHeader(dateKey: string): string { if (!dateKey) return '未开赛' const d = new Date(dateKey + 'T00:00:00') if (isNaN(d.getTime())) return dateKey const today = new Date() const todayKey = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}` const tmr = new Date(today) tmr.setDate(tmr.getDate() + 1) const tmrKey = `${tmr.getFullYear()}-${String(tmr.getMonth() + 1).padStart(2, '0')}-${String(tmr.getDate()).padStart(2, '0')}` const weekday = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][d.getDay()] if (dateKey === todayKey) return `今日 ${weekday}` if (dateKey === tmrKey) return `明日 ${weekday}` return `${d.getMonth() + 1}月${d.getDate()}日 ${weekday}` } /** 预测成本展示:耗时 + token + 限流余量 */ function PredictionCost({ prediction }: { prediction: Prediction }) { const latency = prediction.latency_ms != null ? `${(prediction.latency_ms / 1000).toFixed(1)}s` : null const tokens = prediction.prompt_tokens != null || prediction.completion_tokens != null ? `${prediction.prompt_tokens ?? '?'}/${prediction.completion_tokens ?? '?'}` : null if (!latency && !tokens && prediction.rate_limit_remaining == null) return null return (
{latency && ( 耗时 {latency} )} {tokens && ( prompt/completion: {tokens} )} {prediction.rate_limit_remaining != null && prediction.rate_limit_remaining <= 3 && ( 剩余配额: {prediction.rate_limit_remaining}/10(分钟) )}
) } /** 预测过程阶段(按时长模拟;结果到达即跳到完成) */ function PredictProgress() { const [elapsed, setElapsed] = useState(0) useEffect(() => { const t = setInterval(() => setElapsed(e => e + 0.5), 500) return () => clearInterval(t) }, []) // 阶段阈值(秒): 切片 → 专家(各路依次点亮) → 终裁 const SLICE_END = 3 const AGENT_START = 4 const AGENT_STEP = 8 // 每路专家约 8s 点亮一路 const AGG_START = AGENT_START + AGENT_STEP * 5 const agents = ['form', 'stats', 'home_away', 'standings', 'h2h'] const phase = elapsed < SLICE_END ? 'slice' : elapsed < AGG_START ? 'agents' : 'agg' const pct = Math.min(95, Math.round((elapsed / 70) * 100)) return (
{/* 阶段标题 */}
{phase === 'slice' && '正在组装比赛数据切片'} {phase === 'agents' && '五路专家并行分析中'} {phase === 'agg' && '终裁专家汇总裁定中'} {elapsed.toFixed(0)}s
{/* 进度条:渐进式,不封顶到 100% */}
{/* 专家灯序(多专家模式) */}
    {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 && 分析中…}
  • ) })}

五路专家并行分析后终裁,约需 30-90 秒;多专家调用消耗较多 token,请按需使用。关闭窗口即取消

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

) } /** 预测弹窗:进行中显示过程可视化,完成后显示预测版,失败显示原因 */ 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 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, embedded = false, }: { prediction: Prediction match: Match /** 弹窗嵌入模式:弹窗已提供报头,这里省略自带版头 */ embedded?: boolean }) { const homeName = match.home_team_zh || match.home_team const [expertsOpen, setExpertsOpen] = useState(false) 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 / failed 态:醒目警示 + 原因,不展示虚假比分 ── */} {degraded && (

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

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

)} {/* ── 主结论(仅 success 展示) ── */} {!degraded && ( <>

{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}

)}
)} {/* ── 成本信息(耗时 + token + 限流余量) ── */} {!degraded && ( )} {/* ── 元信息 ── */}

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

{/* ── 终裁/降级说明意见 ── */} {prediction.reasoning && degraded && (

降级原因

{prediction.reasoning}

)} {/* ── 专家意见(多模式):可折叠 + 状态摘要 + 权重条形图 ── */} {reports.length > 0 && (
{/* 权重条形图(仅 success 且有权重时显示) */} {!degraded && prediction.agent_weights && Object.keys(prediction.agent_weights).length > 0 && (
终裁权重分布 {Object.entries(prediction.agent_weights) .sort((a, b) => b[1] - a[1]) .map(([k, v]) => (
{AGENT_LABELS[k] ?? k}
{Math.round(v * 100)}%
))}
)} {expertsOpen && (
{reports.map((r, i) => ( ))}
)}
)} {/* ── 终裁意见(success) ── */} {prediction.reasoning && !degraded && (

终裁意见

{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}

)}
) } /** 比赛详情面板:双方近况/H2H + 历史预测列表(只读) */ function MatchDetailPanel({ match, detail, ctx, loading, }: { match: Match detail: MatchDetailOut | undefined ctx: MatchContextOut | undefined loading: boolean }) { const homeName = match.home_team_zh || match.home_team const awayName = match.away_team_zh || match.away_team const finished = match.match_status === 'finished' return (
{loading && (
加载详情中…
)} {!loading && !detail && !ctx && (

暂无详情数据

)} {!loading && (detail || ctx) && (
{/* 比分区(终场/当前比分 + 状态 + 预测按钮) */}

{match.home_goals ?? '-'}{' '}:{' '}{match.away_goals ?? '-'}

{match.match_stage || ''} {match.match_status === 'finished' ? '· 已完赛' : match.match_status === 'scheduled' ? '· 未开赛' : `· ${match.match_status}`}

{match.home_xg != null && match.away_xg != null && (

xG {match.home_xg.toFixed(1)}–{match.away_xg.toFixed(1)}

)}
{!finished && ( 点击行首「预测」按钮发起多专家分析 )}
{/* 比赛详细统计(bzzoiro /events/{id}/stats/) */} {detail?.stats && ( )} {/* 双方近况 + H2H */} {(ctx?.home_recent?.length || ctx?.away_recent?.length || ctx?.h2h?.length) ? (
) : ( !loading &&

暂无近期对战数据

)} {/* 历史预测列表 */}

历史预测({detail?.recent_predictions?.length ?? 0})

{detail?.recent_predictions?.length ? (
{detail.recent_predictions.map(p => ( ))}
) : (

该场比赛暂无预测记录

)}
)}
) } /** 比赛详细统计面板(bzzoiro /events/{id}/stats/) */ function MatchStatsPanel({ stats, homeName, awayName, }: { stats: MatchStatsDetail; homeName: string; awayName: string }) { const rows: Array<{ label: string; home: number | null; away: number | null; highlight?: 'high' | 'low' }> = [ { label: '预期进球(xG)', home: stats.home_xg, away: stats.away_xg }, { label: '射门', home: stats.home_shots, away: stats.away_shots }, { label: '射正', home: stats.home_shots_on_target, away: stats.away_shots_on_target }, { label: '角球', home: stats.home_corners, away: stats.away_corners }, { label: '犯规', home: stats.home_fouls, away: stats.away_fouls }, { label: '绝佳机会', home: stats.home_big_chances, away: stats.away_big_chances }, { label: '黄牌', home: stats.home_yellow_cards, away: stats.away_yellow_cards }, { label: '红牌', home: stats.home_red_cards, away: stats.away_red_cards }, ] const hasAny = rows.some(r => r.home != null || r.away != null) if (!hasAny) return null // 控球率用横条展示 const possHome = stats.home_possession const possAway = possHome != null ? Math.max(0, 100 - possHome) : null return (

比赛统计

{/* 控球率横条 */} {possHome != null && possAway != null && (
{possHome.toFixed(0)}% 控球率 {possAway.toFixed(0)}%
)} {/* 主客对比表 */}
{rows.filter(r => r.home != null || r.away != null).map(r => { const h = r.home ?? 0 const a = r.away ?? 0 const winner = h > a ? 'home' : h < a ? 'away' : 'tie' return ( ) })}
{homeName} 统计项 {awayName}
{r.home ?? '—'} {r.label} {r.away ?? '—'}
) } /** 近况/H2H 单区块 */ function RecentBlock({ title, rows, side }: { title: string; rows?: TeamRecentMatch[]; side: 'home' | 'away' | 'h2h' }) { return (
{title}
{rows && rows.length > 0 ? (
    {rows.map((r, i) => { const date = r.match_date ? new Date(r.match_date).toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' }) : '—' const score = (r.home_goals != null && r.away_goals != null) ? `${r.home_goals}-${r.away_goals}` : 'vs' const label = side === 'h2h' ? `${r.home_team ?? '?'} ${score} ${r.away_team ?? '?'}` : `${score}` return (
  • {date} {label}
  • ) })}
) : (

暂无

)}
) } /** 历史预测单行(含专家报告入口) */ function PredictionHistoryRow({ p }: { p: MatchRecentPrediction }) { const badge = p.status === 'degraded' ? { label: 'degraded', cls: 'text-press' } : p.settled ? { label: p.correct_1x2 === undefined ? '已结算' : p.correct_1x2 ? '命中' : '未中', cls: p.correct_1x2 ? 'text-ink-900' : 'text-ink-400' } : { label: p.status === 'success' ? '成功' : p.status, cls: 'text-ink-600' } const score = (p.pred_home_goals != null && p.pred_away_goals != null) ? `${p.pred_home_goals.toFixed(1)}-${p.pred_away_goals.toFixed(1)}` : '—' const alt = (p.alt_pred_home_goals != null && p.alt_pred_away_goals != null) ? `${p.alt_pred_home_goals.toFixed(1)}-${p.alt_pred_away_goals.toFixed(1)}` : null const hasAgents = p.agent_outputs && p.agent_outputs.length > 0 return (
{score} {p.pred_1x2 ? `(${p.pred_1x2})` : ''} {alt && 备选 {alt}} {p.subjective_confidence != null && ( 信心 {Math.round(p.subjective_confidence * 100)}% )} {badge.label}
{p.model} · {p.mode} · {p.created_at ? new Date(p.created_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }) : '—'} {hasAgents && {p.agent_outputs!.length} 路专家报告}
{p.reasoning && (

{p.reasoning}

)}
) } function readablePredictError(e: unknown): string { if (e instanceof Error) { const m = e.message if (/429/.test(m)) { // 429 来自后端限流(每分钟 10 次),非上游 LLM return '操作过于频繁:每分钟最多 10 次预测。为保护 LLM 额度,请稍后再试。' } if (/502/.test(m)) return 'LLM 服务暂时不可用(502),请稍后重试' if (/402|Payment Required|额度|余额/.test(m)) return 'LLM 额度不足(402),请检查 API Key 余额' if (/400|已完赛/.test(m)) return '该比赛已完赛,不再支持预测' if (/409|已结算/.test(m)) return '该预测已结算,不能重新预测' if (/timeout|超时|timed out/i.test(m)) return '请求超时,请稍后重试' return m } return String(e) }