/**
* 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)}%
)}
{/* 主客对比表 */}
| {homeName} |
统计项 |
{awayName} |
{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 (
|
{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 }
}