- pages/matches/types.ts: Match/Prediction/AgentReport + 常量(LEAGUES/STATUS_META/AGENT_LABELS 等) - pages/matches/ui.tsx: Spinner/SkeletonRows/Switch/日期分组工具(Switch 自组件体内提升到模块级) - pages/matches/hooks/useMatchesList.ts: 列表筛选/游标分页/进行中比赛,竞态防护语义不变 - pages/matches/hooks/useMatchPredict.ts: 预测状态机(连点/竞态/中止/超时)+ 可读错误文案 - pages/matches/components/MatchPredictPanel.tsx: 预测弹窗全套(过程可视化/结果/专家意见) - pages/matches/components/MatchDetailSection.tsx: MatchRow(原内联行 JSX 抽出)+ 详情面板 - Matches.tsx 组装页 279 行(< 300);error state 共享行为与拆分前一致 - 验证: tsc + vite build 通过,渲染逻辑原样搬迁
370 lines
16 KiB
TypeScript
370 lines
16 KiB
TypeScript
/**
|
||
* 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 (
|
||
<div className="border-b border-ink-200 bg-paper-100/50 px-3 py-4">
|
||
{loading && (
|
||
<div className="flex items-center gap-2 text-xs text-ink-500"><Spinner /> 加载详情中…</div>
|
||
)}
|
||
|
||
{!loading && !detail && !ctx && (
|
||
<p className="py-4 text-center text-xs text-ink-400">暂无详情数据</p>
|
||
)}
|
||
|
||
{!loading && (detail || ctx) && (
|
||
<div className="space-y-5">
|
||
{/* 比分区(终场/当前比分 + 状态 + 预测按钮) */}
|
||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||
<div className="text-center">
|
||
<p className="font-serif text-3xl font-bold tabular-nums leading-none text-ink-900">
|
||
{match.home_goals ?? '-'}{' '}<span className="text-ink-300">:</span>{' '}{match.away_goals ?? '-'}
|
||
</p>
|
||
<p className="mt-1 text-2xs text-ink-500">
|
||
{match.match_stage || ''} {match.match_status === 'finished' ? '· 已完赛' : match.match_status === 'scheduled' ? '· 未开赛' : `· ${match.match_status}`}
|
||
</p>
|
||
{match.home_xg != null && match.away_xg != null && (
|
||
<p className="text-2xs tabular-nums text-ink-400">xG {match.home_xg.toFixed(1)}–{match.away_xg.toFixed(1)}</p>
|
||
)}
|
||
</div>
|
||
{!finished && (
|
||
<span className="text-2xs text-ink-500">
|
||
点击行首「预测」按钮发起多专家分析
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* 比赛详细统计(bzzoiro /events/{id}/stats/) */}
|
||
{detail?.stats && (
|
||
<MatchStatsPanel stats={detail.stats} homeName={homeName} awayName={awayName} />
|
||
)}
|
||
|
||
{/* 双方近况 + H2H */}
|
||
{(ctx?.home_recent?.length || ctx?.away_recent?.length || ctx?.h2h?.length) ? (
|
||
<div className="grid gap-4 sm:grid-cols-3">
|
||
<RecentBlock title={`${homeName} 近况`} rows={ctx?.home_recent} side="home" />
|
||
<RecentBlock title={`${awayName} 近况`} rows={ctx?.away_recent} side="away" />
|
||
<RecentBlock title="历史交锋(H2H)" rows={ctx?.h2h} side="h2h" />
|
||
</div>
|
||
) : (
|
||
!loading && <p className="text-2xs text-ink-400">暂无近期对战数据</p>
|
||
)}
|
||
|
||
{/* 历史预测列表 */}
|
||
<div>
|
||
<h4 className="section-head mb-2">历史预测({detail?.recent_predictions?.length ?? 0})</h4>
|
||
{detail?.recent_predictions?.length ? (
|
||
<div className="space-y-2">
|
||
{detail.recent_predictions.map(p => (
|
||
<PredictionHistoryRow key={p.id} p={p} />
|
||
))}
|
||
</div>
|
||
) : (
|
||
<p className="py-3 text-center text-2xs text-ink-400">该场比赛暂无预测记录</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** 赛程表一行:可点击展开详情;展开时懒加载详情(只读,不触发 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 (
|
||
<div>
|
||
{/* 行:可点击展开 */}
|
||
<div
|
||
className={`border-b border-ink-200 px-4 py-5 transition-colors hover:bg-paper-100/70 cursor-pointer sm:px-1 sm:py-4 ${
|
||
expanded ? 'bg-paper-100/60' : ''
|
||
}`}
|
||
onClick={onToggle}
|
||
role="button"
|
||
tabIndex={0}
|
||
onKeyDown={e => { if (e.key === 'Enter') onToggle() }}
|
||
aria-expanded={expanded}
|
||
>
|
||
{/* 桌面 grid: 日期 | 主队 | 比分 | 客队 | 状态 | 按钮 */}
|
||
<div className="flex flex-col gap-3 sm:grid sm:grid-cols-[96px_minmax(0,1fr)_72px_minmax(0,1fr)_64px_88px] sm:items-center sm:gap-x-4 sm:gap-y-0">
|
||
{/* 日期 + 状态:小屏同行;桌面 date 单独一列 */}
|
||
<div className="flex items-center justify-between text-xs sm:contents">
|
||
<span className="tabular-nums text-ink-500 sm:text-xs">{fmtTime(m.match_date)}</span>
|
||
<span className={`sm:hidden ${st.cls}`}>{st.label}</span>
|
||
</div>
|
||
|
||
{/* 主队 + 比分 + 客队:移动端 grid 三列(严格居中),桌面端 grid 分列 */}
|
||
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-4 sm:contents">
|
||
{/* 主队(右对齐) */}
|
||
<span className="flex min-w-0 items-center justify-end gap-2">
|
||
<TeamSideTag side="home" />
|
||
<span className="truncate text-sm font-medium text-ink-900">{homeName}</span>
|
||
</span>
|
||
|
||
{/* 比分 / VS(严格居中) */}
|
||
<span className="flex flex-col items-center justify-center">
|
||
{m.home_goals !== null && m.away_goals !== null ? (
|
||
<span className="font-serif text-xl font-bold tabular-nums leading-none text-ink-900 sm:text-xl">
|
||
{m.home_goals}<span className="mx-1 font-normal text-ink-300">:</span>{m.away_goals}
|
||
</span>
|
||
) : (
|
||
<span className="text-sm tracking-[0.2em] text-ink-500">VS</span>
|
||
)}
|
||
{m.home_xg !== null && m.away_xg !== null && (
|
||
<span className="mt-0.5 text-2xs tabular-nums text-ink-400">
|
||
xG {m.home_xg.toFixed(1)}–{m.away_xg.toFixed(1)}
|
||
</span>
|
||
)}
|
||
</span>
|
||
|
||
{/* 客队(左对齐) */}
|
||
<span className="flex min-w-0 items-center gap-2">
|
||
<TeamSideTag side="away" />
|
||
<span className="truncate text-sm font-medium text-ink-900">{awayName}</span>
|
||
</span>
|
||
</div>
|
||
|
||
{/* 状态标签:小屏隐藏(已有);桌面用徽标样式 */}
|
||
<span className="hidden text-right sm:block">
|
||
<span className={`inline-block border px-1.5 py-0.5 text-2xs leading-tight ${st.cls} ${
|
||
m.match_status === 'finished'
|
||
? 'border-ink-200 text-ink-500'
|
||
: m.match_status === 'scheduled'
|
||
? 'border-ink-300 text-ink-600'
|
||
: 'border-press/30 text-press'
|
||
}`}>
|
||
{st.label}
|
||
</span>
|
||
</span>
|
||
|
||
{/* 预测按钮(统一,响应式尺寸) */}
|
||
{!finished && (
|
||
<div className="flex justify-end" onClick={e => e.stopPropagation()}>
|
||
<button
|
||
onClick={() => onPredict(m)}
|
||
disabled={busy}
|
||
className={`btn ${busy ? '' : 'btn-solid'} w-full min-h-[44px] sm:w-[84px] sm:min-h-0 sm:btn-sm`}
|
||
title="以多专家模式预测这场"
|
||
>
|
||
{busy ? (<><Spinner /> 预测中</>) : '预测'}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>{/* 关闭可点击行 */}
|
||
|
||
{/* 展开详情面板 */}
|
||
{expanded && (
|
||
<MatchDetailPanel
|
||
match={m} detail={detail} ctx={ctx}
|
||
loading={detailLoading}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** 行内时间展示:只显示 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 (
|
||
<div>
|
||
<h4 className="section-head mb-2">比赛统计</h4>
|
||
|
||
{/* 控球率横条 */}
|
||
{possHome != null && possAway != null && (
|
||
<div className="mb-3">
|
||
<div className="mb-1 flex justify-between text-2xs text-ink-500">
|
||
<span>{possHome.toFixed(0)}%</span>
|
||
<span className="text-ink-400">控球率</span>
|
||
<span>{possAway.toFixed(0)}%</span>
|
||
</div>
|
||
<div className="flex h-1.5 overflow-hidden rounded-full bg-ink-200">
|
||
<div className="bg-ink-700 transition-[width] duration-500" style={{ width: `${possHome}%` }} />
|
||
<div className="bg-ink-300 transition-[width] duration-500" style={{ width: `${possAway}%` }} />
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 主客对比表 */}
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-xs">
|
||
<thead>
|
||
<tr className="border-b border-ink-200 text-ink-400">
|
||
<th className="py-1.5 text-left font-medium">{homeName}</th>
|
||
<th className="py-1.5 text-center font-medium text-ink-500">统计项</th>
|
||
<th className="py-1.5 text-right font-medium">{awayName}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{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 (
|
||
<tr key={r.label} className="border-b border-ink-100">
|
||
<td className={`py-1.5 text-right tabular-nums ${winner === 'home' ? 'font-bold text-ink-900' : 'text-ink-500'}`}>
|
||
{r.home ?? '—'}
|
||
</td>
|
||
<td className="py-1.5 text-center text-ink-500">{r.label}</td>
|
||
<td className={`py-1.5 text-left tabular-nums ${winner === 'away' ? 'font-bold text-ink-900' : 'text-ink-500'}`}>
|
||
{r.away ?? '—'}
|
||
</td>
|
||
</tr>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** 近况/H2H 单区块 */
|
||
function RecentBlock({ title, rows, side }: { title: string; rows?: TeamRecentMatch[]; side: 'home' | 'away' | 'h2h' }) {
|
||
return (
|
||
<div>
|
||
<h5 className="mb-1.5 text-2xs font-medium text-ink-500">{title}</h5>
|
||
{rows && rows.length > 0 ? (
|
||
<ul className="space-y-1">
|
||
{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 (
|
||
<li key={i} className="flex items-center justify-between text-2xs tabular-nums text-ink-600">
|
||
<span className="text-ink-400">{date}</span>
|
||
<span className="truncate">{label}</span>
|
||
</li>
|
||
)
|
||
})}
|
||
</ul>
|
||
) : (
|
||
<p className="text-2xs text-ink-300">暂无</p>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** 历史预测单行(含专家报告入口) */
|
||
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 (
|
||
<div className="border-b border-ink-200 pb-2 last:border-b-0">
|
||
<div className="flex items-center justify-between text-xs">
|
||
<span className="tabular-nums text-ink-600">
|
||
{score} {p.pred_1x2 ? `(${p.pred_1x2})` : ''}
|
||
{alt && <span className="ml-1 text-ink-400">备选 {alt}</span>}
|
||
</span>
|
||
<span className="flex items-center gap-2">
|
||
{p.subjective_confidence != null && (
|
||
<span className="text-2xs tabular-nums text-ink-400">信心 {Math.round(p.subjective_confidence * 100)}%</span>
|
||
)}
|
||
<span className={`text-2xs ${badge.cls}`}>{badge.label}</span>
|
||
</span>
|
||
</div>
|
||
<div className="mt-0.5 flex items-center justify-between text-2xs text-ink-400">
|
||
<span className="truncate">{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 }) : '—'}</span>
|
||
{hasAgents && <span className="text-press">{p.agent_outputs!.length} 路专家报告</span>}
|
||
</div>
|
||
{p.reasoning && (
|
||
<p className="mt-1 line-clamp-2 font-serif text-2xs leading-relaxed text-ink-500">{p.reasoning}</p>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 详情懒加载取数在此文件内聚:页面只需切换 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 }
|
||
}
|