/** * MatchDetailSection: 赛程行(MatchRow)+ 展开详情面板(统计/近况/H2H/历史预测)。 * * D3: 从 Matches.tsx 拆出,渲染逻辑原样搬迁。MatchRow 原先是主组件内 * group.map 的内联 JSX,现接收回调(onToggle/onPredict)保持行为一致; * 展开懒加载的 state 仍由页面持有。 */ 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 { STATUS_META } from '../types' import type { Match } from '../types' import { Spinner } from '../ui' /** 比赛详情面板:双方近况/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 => ( ))}
) : (

该场比赛暂无预测记录

)}
)}
) } /** 赛程表一行:可点击展开详情;展开时懒加载详情(只读,不触发 LLM) */ export function MatchRow({ m, busy, expanded, detail, ctx, detailLoading, onToggle, onPredict, }: { m: Match busy: boolean expanded: boolean detail: MatchDetailOut | undefined ctx: MatchContextOut | undefined detailLoading: boolean onToggle: () => void onPredict: (m: Match) => void }) { 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 finished = m.match_status === 'finished' return (
{/* 行:可点击展开 */}
{ if (e.key === 'Enter') onToggle() }} 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 && ( )}
) } /** 行内时间展示:只显示 HH:mm(日期由分组头承担) */ function fmtTime(s: string): string { const d = new Date(s) return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false }) } /** 比赛详细统计面板(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}

)}
) } // 详情懒加载取数在此文件内聚:页面只需切换 expandedId 并缓存结果 export async function loadMatchDetailBundle( matchId: number, ): Promise<{ detail: MatchDetailOut | null; ctx: MatchContextOut | null }> { const [d, c] = await Promise.all([ fetchMatchDetail(matchId).catch(() => null), fetchMatchContext(matchId).catch(() => null), ]) return { detail: d, ctx: c } }