debt(D3): 拆分 Matches.tsx(~1400 行)为 hooks + 组件 + 共享层
- 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 通过,渲染逻辑原样搬迁
This commit is contained in:
+80
-1192
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,369 @@
|
|||||||
|
/**
|
||||||
|
* 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 }
|
||||||
|
}
|
||||||
@@ -0,0 +1,527 @@
|
|||||||
|
/**
|
||||||
|
* MatchPredictPanel: 预测弹窗全套(过程可视化 / 结果版面 / 专家意见)。
|
||||||
|
*
|
||||||
|
* D3: 从 Matches.tsx 拆出,渲染逻辑原样搬迁。对外只导出 PredictModal;
|
||||||
|
* PredictionPanel 复用 Prediction 的 embedded 模式由弹窗内渲染。
|
||||||
|
*/
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import TeamSideTag from '../../../components/TeamSideTag'
|
||||||
|
import type { AgentReport, Match, Prediction } from '../types'
|
||||||
|
import { AGENT_LABELS, CN_NUM, OUTCOME_LABEL } from '../types'
|
||||||
|
|
||||||
|
/** 置信度细线:0~1 数值的低调可视化 */
|
||||||
|
function Meter({ value }: { value: number }) {
|
||||||
|
const pct = Math.max(0, Math.min(100, Math.round(value * 100)))
|
||||||
|
return (
|
||||||
|
<div className="h-px w-full bg-ink-200" role="presentation">
|
||||||
|
<div className="h-px bg-press transition-[width] duration-500" style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 (
|
||||||
|
<div className="relative h-px w-full bg-ink-200" role="presentation">
|
||||||
|
<span className="absolute left-1/2 top-1/2 h-2 w-px -translate-x-1/2 -translate-y-1/2 bg-ink-400" />
|
||||||
|
<span
|
||||||
|
className={`absolute top-0 h-px transition-all duration-500 ${v >= 0 ? 'bg-press' : 'bg-ink-600'}`}
|
||||||
|
style={
|
||||||
|
v >= 0
|
||||||
|
? { left: '50%', width: `${half}%` }
|
||||||
|
: { right: '50%', width: `${half}%` }
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Spinner({ className = '' }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
className={`h-3.5 w-3.5 animate-spin ${className}`}
|
||||||
|
fill="none"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle cx="10" cy="10" r="7.5" stroke="currentColor" strokeWidth="1.5" strokeOpacity="0.25" />
|
||||||
|
<path d="M17.5 10A7.5 7.5 0 0010 2.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 胜平负一行文字:选中的红字加方块标记,未选中的退灰 */
|
||||||
|
function OutcomeLine({
|
||||||
|
pick,
|
||||||
|
confidence,
|
||||||
|
}: {
|
||||||
|
pick: string | null
|
||||||
|
confidence: number | null
|
||||||
|
}) {
|
||||||
|
const options = ['1', 'X', '2'] as const
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-baseline justify-center gap-6 sm:gap-10">
|
||||||
|
{options.map(o => {
|
||||||
|
const on = pick === o
|
||||||
|
return (
|
||||||
|
<div key={o} className="flex flex-col items-center gap-1">
|
||||||
|
<span className={`flex items-center gap-1.5 text-sm ${on ? 'font-semibold text-press' : 'text-ink-400'}`}>
|
||||||
|
{on && <span className="inline-block h-2 w-2 bg-press" aria-hidden="true" />}
|
||||||
|
{OUTCOME_LABEL[o]}
|
||||||
|
</span>
|
||||||
|
{on && confidence !== null && (
|
||||||
|
<span className="text-2xs tabular-nums text-ink-500">
|
||||||
|
置信 {Math.round(confidence * 100)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{pick && confidence !== null && (
|
||||||
|
<div className="mx-auto mt-3 max-w-xs">
|
||||||
|
<Meter value={confidence} />
|
||||||
|
<p className="mt-1 text-center text-2xs text-ink-400">主观置信度,非统计概率</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 预测成本展示:耗时 + 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 (
|
||||||
|
<div className="border-t border-ink-200 pt-3 text-2xs text-ink-500">
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1">
|
||||||
|
{latency && (
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<span aria-hidden="true" className="opacity-60">⏱</span>耗时 {latency}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{tokens && (
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<span aria-hidden="true" className="opacity-60">Tok</span>prompt/completion: {tokens}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{prediction.rate_limit_remaining != null && prediction.rate_limit_remaining <= 3 && (
|
||||||
|
<span className="text-press" title="每分钟最多 10 次预测">
|
||||||
|
剩余配额: {prediction.rate_limit_remaining}/10(分钟)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 预测过程阶段(按时长模拟;结果到达即跳到完成) */
|
||||||
|
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 (
|
||||||
|
<div className="px-5 py-8 sm:px-8">
|
||||||
|
{/* 阶段标题 */}
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<Spinner className="text-press" />
|
||||||
|
<span className="font-serif text-sm font-bold text-ink-900">
|
||||||
|
{phase === 'slice' && '正在组装比赛数据切片'}
|
||||||
|
{phase === 'agents' && '五路专家并行分析中'}
|
||||||
|
{phase === 'agg' && '终裁专家汇总裁定中'}
|
||||||
|
</span>
|
||||||
|
<span className="text-2xs tabular-nums text-ink-400">{elapsed.toFixed(0)}s</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 进度条:渐进式,不封顶到 100% */}
|
||||||
|
<div className="mx-auto mt-5 h-1 w-full max-w-md overflow-hidden bg-ink-100" role="progressbar" aria-valuenow={pct}>
|
||||||
|
<div
|
||||||
|
className={`h-full bg-press transition-all duration-500 ${phase === 'agg' ? 'animate-pulse' : ''}`}
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 专家灯序(多专家模式) */}
|
||||||
|
<ul className="mx-auto mt-6 max-w-md space-y-1.5">
|
||||||
|
{agents.map((a, i) => {
|
||||||
|
const lit = elapsed >= AGENT_START + AGENT_STEP * (i + 1)
|
||||||
|
const activeNow = !lit && elapsed >= AGENT_START + AGENT_STEP * i
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={a}
|
||||||
|
className={`flex items-center justify-between border-b border-ink-200 pb-1.5 text-xs transition-colors ${
|
||||||
|
lit ? 'text-ink-800' : activeNow ? 'text-ink-900' : 'text-ink-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`inline-block h-1.5 w-1.5 ${lit ? 'bg-ink-900' : activeNow ? 'bg-press animate-pulse' : 'bg-ink-200'}`}
|
||||||
|
/>
|
||||||
|
{AGENT_LABELS[a] ?? a}
|
||||||
|
</span>
|
||||||
|
{lit && <span className="text-2xs text-ink-400">✓ 完成</span>}
|
||||||
|
{activeNow && <span className="text-2xs text-press">分析中…</span>}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p className="mt-6 text-center text-2xs text-ink-400">
|
||||||
|
五路专家并行分析后终裁,约需 30-90 秒;多专家调用消耗较多 token,请按需使用。关闭窗口即取消
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-center text-2xs text-ink-300">
|
||||||
|
提示:每分钟限 10 次预测,耗尽后需等待下一分钟。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_BADGE: Record<string, { label: string; cls: string }> = {
|
||||||
|
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<string, string> = {
|
||||||
|
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 (
|
||||||
|
<details className="group border-b border-ink-200">
|
||||||
|
<summary className="flex cursor-pointer list-none items-baseline gap-2.5 px-1 py-3">
|
||||||
|
<span className="font-serif text-sm text-ink-400">{no}</span>
|
||||||
|
<span className="text-sm font-medium text-ink-900">{AGENT_LABELS[r.agent] ?? r.agent}</span>
|
||||||
|
<span className={`text-2xs ${badge.cls}`}>{badge.label}</span>
|
||||||
|
|
||||||
|
<span className="ml-auto flex items-baseline gap-3 text-2xs tabular-nums text-ink-500">
|
||||||
|
{r.status === 'ok' && r.subjective_confidence !== null && (
|
||||||
|
<span>信心 {Math.round(r.subjective_confidence * 100)}%</span>
|
||||||
|
)}
|
||||||
|
{r.status === 'ok' && r.probable_score && (
|
||||||
|
<span className="font-serif font-bold text-ink-800">{r.probable_score}</span>
|
||||||
|
)}
|
||||||
|
<svg viewBox="0 0 20 20" className="h-3 w-3 self-center text-ink-300 transition-transform group-open:rotate-90" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M7.3 5.3a1 1 0 011.4 0l4 4a1 1 0 010 1.4l-4 4a1 1 0 01-1.4-1.4L10.6 10 7.3 6.7a1 1 0 010-1.4z" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div className="space-y-3 px-1 pb-4 pl-7">
|
||||||
|
{/* 无数据 / 失败时给出明确说明,避免用户以为是空白 bug */}
|
||||||
|
{inactive && (
|
||||||
|
<p className="text-xs leading-relaxed text-ink-500">
|
||||||
|
{r.status === 'no_data' && '该维度没有可用数据,已跳过 LLM 分析以节省额度(不影响其他专家)。'}
|
||||||
|
{r.status === 'error' && '该专家调用失败,本次结论未纳入其视角(fail-open 设计,不阻断整体预测)。'}
|
||||||
|
{r.status === 'parse_error' && '模型输出未通过格式校验,该报告已丢弃。'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!inactive && r.home_edge !== null && (
|
||||||
|
<div>
|
||||||
|
<div className="mb-1.5 flex items-baseline justify-between text-2xs">
|
||||||
|
<span className="text-ink-500">主队优势</span>
|
||||||
|
<span className={`font-semibold tabular-nums ${r.home_edge > 0 ? 'text-press' : r.home_edge < 0 ? 'text-ink-700' : 'text-ink-500'}`}>
|
||||||
|
{r.home_edge > 0 ? '+' : ''}{r.home_edge.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<EdgeBar value={r.home_edge} />
|
||||||
|
<div className="mt-1 flex justify-between text-2xs text-ink-400">
|
||||||
|
<span>利客队</span>
|
||||||
|
<span>利主队</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{r.analysis && (
|
||||||
|
<p className="font-serif text-sm leading-loose text-ink-700">{r.analysis}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{r.key_evidence.length > 0 && (
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{r.key_evidence.map((e, i) => (
|
||||||
|
<li key={i} className="flex gap-2 text-xs leading-relaxed text-ink-600">
|
||||||
|
<span className="flex-shrink-0 text-ink-300" aria-hidden="true">—</span>
|
||||||
|
<span>{e}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{r.exp_home_goals !== null && r.exp_away_goals !== null && (
|
||||||
|
<p className="text-xs text-ink-500">
|
||||||
|
进球期望 <span className="font-serif font-bold tabular-nums text-ink-900">{r.exp_home_goals.toFixed(1)} - {r.exp_away_goals.toFixed(1)}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!inactive && (
|
||||||
|
<p className="border-t border-ink-100 pt-2.5 text-2xs text-ink-400">
|
||||||
|
数据充分度 {SUFFICIENCY_LABEL[r.data_sufficiency] ?? r.data_sufficiency}
|
||||||
|
<span className="mx-2 text-ink-200">|</span>
|
||||||
|
<span className="font-mono">{r.model}</span>
|
||||||
|
{r.latency_ms !== null && <span className="ml-2 tabular-nums">{r.latency_ms}ms</span>}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<article className={embedded ? 'bg-paper-50' : 'border border-ink-900 bg-paper-50'}>
|
||||||
|
{!embedded && (
|
||||||
|
<div className="flex flex-wrap items-baseline justify-between gap-2 border-b border-ink-900 bg-paper-100 px-4 py-2.5 sm:px-5">
|
||||||
|
<h3 className="flex flex-wrap items-center gap-1.5 font-serif text-sm font-bold text-ink-900">
|
||||||
|
预测版 ·
|
||||||
|
<TeamSideTag side="home" />
|
||||||
|
{homeName}
|
||||||
|
<span>对</span>
|
||||||
|
<TeamSideTag side="away" />
|
||||||
|
{awayName}
|
||||||
|
</h3>
|
||||||
|
<span className="text-2xs tabular-nums text-ink-500">
|
||||||
|
{prediction.provider} / {prediction.model}
|
||||||
|
{prediction.latency_ms !== null && ` · ${(prediction.latency_ms / 1000).toFixed(1)}s`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-7 px-4 py-6 sm:px-5">
|
||||||
|
{/* ── degraded / failed 态:醒目警示 + 原因,不展示虚假比分 ── */}
|
||||||
|
{degraded && (
|
||||||
|
<div className="border-l-2 border-press bg-press-wash/40 px-4 py-3">
|
||||||
|
<p className="font-serif text-sm font-bold text-press-dark">
|
||||||
|
{prediction.status === 'failed' ? '预测失败' : '预测降级(degraded)'}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1.5 whitespace-pre-wrap text-xs leading-relaxed text-ink-600">
|
||||||
|
{prediction.reasoning || '所有专家均无有效数据或调用失败,无法生成可靠比分。'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── 主结论(仅 success 展示) ── */}
|
||||||
|
{!degraded && (
|
||||||
|
<>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="font-serif text-5xl font-bold tabular-nums leading-none text-ink-900 sm:text-6xl">
|
||||||
|
{prediction.pred_home_goals ?? '-'}
|
||||||
|
<span className="mx-3 font-normal text-ink-300">:</span>
|
||||||
|
{prediction.pred_away_goals ?? '-'}
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-2xs tracking-[0.5em] text-ink-400">预测比分</p>
|
||||||
|
{prediction.alt_pred_home_goals != null && prediction.alt_pred_away_goals != null && (
|
||||||
|
<p className="mt-2 text-2xs tabular-nums text-ink-400">
|
||||||
|
备选{' '}
|
||||||
|
<span className="font-serif text-sm font-bold tabular-nums text-ink-600">
|
||||||
|
{prediction.alt_pred_home_goals}<span className="mx-0.5 font-normal text-ink-300">:</span>{prediction.alt_pred_away_goals}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-y border-ink-200 py-4">
|
||||||
|
<OutcomeLine pick={prediction.pred_1x2} confidence={prediction.subjective_confidence} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── 成本信息(耗时 + token + 限流余量) ── */}
|
||||||
|
{!degraded && (
|
||||||
|
<PredictionCost prediction={prediction} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── 元信息 ── */}
|
||||||
|
<p className="text-center text-2xs text-ink-500">
|
||||||
|
`多专家模式 · ${okReports.length}/${reports.length} 路有效`
|
||||||
|
{prediction.prompt_version && ` · prompt ${prediction.prompt_version}`}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* ── 终裁/降级说明意见 ── */}
|
||||||
|
{prediction.reasoning && degraded && (
|
||||||
|
<section>
|
||||||
|
<h4 className="section-head mb-2">降级原因</h4>
|
||||||
|
<blockquote className="border-l-2 border-press pl-4">
|
||||||
|
<p className="whitespace-pre-wrap font-serif text-sm leading-loose text-ink-700">{prediction.reasoning}</p>
|
||||||
|
</blockquote>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── 专家意见(多模式):可折叠 + 状态摘要 + 权重条形图 ── */}
|
||||||
|
{reports.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<button
|
||||||
|
onClick={() => setExpertsOpen(o => !o)}
|
||||||
|
className="flex w-full items-center justify-between border-b border-ink-200 pb-2 text-left"
|
||||||
|
>
|
||||||
|
<span className="section-head mb-0">五路专家意见({okReports.length}/{reports.length} 路有效)</span>
|
||||||
|
<span className="text-2xs text-ink-400">{expertsOpen ? '收起' : '展开'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 权重条形图(仅 success 且有权重时显示) */}
|
||||||
|
{!degraded && prediction.agent_weights && Object.keys(prediction.agent_weights).length > 0 && (
|
||||||
|
<div className="mt-3 space-y-1.5">
|
||||||
|
<span className="text-2xs text-ink-500">终裁权重分布</span>
|
||||||
|
{Object.entries(prediction.agent_weights)
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.map(([k, v]) => (
|
||||||
|
<div key={k} className="grid grid-cols-[96px_minmax(0,1fr)_40px] items-center gap-2">
|
||||||
|
<span className="truncate text-2xs text-ink-500">{AGENT_LABELS[k] ?? k}</span>
|
||||||
|
<div className="h-1.5 bg-paper-100">
|
||||||
|
<div className="h-full bg-press" style={{ width: `${Math.round(v * 100)}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-right text-2xs tabular-nums text-ink-500">{Math.round(v * 100)}%</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{expertsOpen && (
|
||||||
|
<div className="mt-2">
|
||||||
|
{reports.map((r, i) => (
|
||||||
|
<AgentCard key={r.agent} report={r} no={CN_NUM[i] ?? String(i + 1)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── 终裁意见(success) ── */}
|
||||||
|
{prediction.reasoning && !degraded && (
|
||||||
|
<section>
|
||||||
|
<h4 className="section-head mb-3">终裁意见</h4>
|
||||||
|
<blockquote className="border-l-2 border-press pl-4">
|
||||||
|
<p className="whitespace-pre-wrap font-serif text-sm leading-loose text-ink-700">{prediction.reasoning}</p>
|
||||||
|
</blockquote>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 预测弹窗:进行中显示过程可视化,完成后显示预测版,失败显示原因 */
|
||||||
|
export 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 (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-ink-900/50 p-4 sm:items-center"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={`预测 ${homeName} 对 ${awayName}`}
|
||||||
|
onClick={e => {
|
||||||
|
if (e.target === e.currentTarget) onClose()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="relative flex max-h-[92vh] w-full max-w-2xl flex-col overflow-hidden bg-paper-50 shadow-2xl">
|
||||||
|
{/* 弹窗报头 */}
|
||||||
|
<div className="flex flex-shrink-0 items-center justify-between border-b border-ink-900 bg-paper-100 px-4 py-2.5 sm:px-5">
|
||||||
|
<h3 className="flex flex-wrap items-center gap-1.5 font-serif text-sm font-bold text-ink-900">
|
||||||
|
预测版 ·
|
||||||
|
<TeamSideTag side="home" />
|
||||||
|
{homeName}
|
||||||
|
<span>对</span>
|
||||||
|
<TeamSideTag side="away" />
|
||||||
|
{awayName}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex h-11 w-11 flex-shrink-0 items-center justify-center text-ink-400 transition-colors hover:text-ink-900"
|
||||||
|
aria-label="关闭"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M6.3 5.3a1 1 0 011.4 0L10 7.6l2.3-2.3a1 1 0 111.4 1.4L11.4 9l2.3 2.3a1 1 0 01-1.4 1.4L10 10.4l-2.3 2.3a1 1 0 01-1.4-1.4L8.6 9 6.3 6.7a1 1 0 010-1.4z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 弹窗体(小屏可滚动) */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{predicting ? (
|
||||||
|
<PredictProgress />
|
||||||
|
) : error ? (
|
||||||
|
<div className="px-5 py-10 text-center sm:px-8">
|
||||||
|
<p className="font-serif text-sm font-bold text-press">预测失败</p>
|
||||||
|
<p className="mx-auto mt-3 max-w-md whitespace-pre-wrap text-left text-xs leading-relaxed text-ink-600">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
<button onClick={onClose} className="btn btn-sm mt-6">关闭</button>
|
||||||
|
</div>
|
||||||
|
) : prediction ? (
|
||||||
|
<PredictionPanel prediction={prediction} match={match} embedded />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* useMatchPredict: 预测流程状态机(发起/进行中/结果/失败/关闭中止)。
|
||||||
|
*
|
||||||
|
* D3: 从 Matches.tsx 拆出。语义不变:
|
||||||
|
* - 连点防护:同一场比赛预测中再次点击直接忽略
|
||||||
|
* - 竞态防护:递增序号,过期响应丢弃
|
||||||
|
* - 关闭弹窗 = 中止在途请求 + 序号失效(catch/then 不再写入)
|
||||||
|
* - 5 分钟超时,与 nginx 代理 300s 对齐
|
||||||
|
*/
|
||||||
|
import { useRef, useState } from 'react'
|
||||||
|
import { http } from '../../../lib/http'
|
||||||
|
import type { Match, Prediction } from '../types'
|
||||||
|
|
||||||
|
/** 把后端/网络错误翻译成用户可读文案 */
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseMatchPredictOptions {
|
||||||
|
/** 共享 error state(拆分前列表与预测共用同一个 error,行为保持一致) */
|
||||||
|
onError: (msg: string | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMatchPredict({ onError }: UseMatchPredictOptions) {
|
||||||
|
const [predictingId, setPredictingId] = useState<number | null>(null)
|
||||||
|
const [prediction, setPrediction] = useState<Prediction | null>(null)
|
||||||
|
const [predictionFor, setPredictionFor] = useState<Match | null>(null)
|
||||||
|
const predictSeq = useRef(0)
|
||||||
|
// 预测请求控制器:关闭弹窗时中止
|
||||||
|
const predictAbort = useRef<AbortController | null>(null)
|
||||||
|
|
||||||
|
function closePredict() {
|
||||||
|
predictAbort.current?.abort()
|
||||||
|
predictSeq.current++ // 令中止请求的 catch/then 全部失效,不再写入错误
|
||||||
|
setPredictingId(null)
|
||||||
|
setPrediction(null)
|
||||||
|
setPredictionFor(null)
|
||||||
|
onError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const predict = async (m: Match) => {
|
||||||
|
// 防连点:若该场比赛已在预测中,直接忽略
|
||||||
|
if (predictingId === m.id) return
|
||||||
|
const seq = ++predictSeq.current
|
||||||
|
setPredictingId(m.id)
|
||||||
|
onError(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<Prediction>('/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
|
||||||
|
onError(
|
||||||
|
e instanceof DOMException && e.name === 'AbortError'
|
||||||
|
? '预测超时(5 分钟),请稍后重试'
|
||||||
|
: readablePredictError(e),
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer)
|
||||||
|
if (seq === predictSeq.current) setPredictingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
predictingId,
|
||||||
|
prediction,
|
||||||
|
predictionFor,
|
||||||
|
predict,
|
||||||
|
closePredict,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* useMatchesList: 赛程列表数据获取(筛选/游标分页/进行中比赛)。
|
||||||
|
*
|
||||||
|
* D3: 从 Matches.tsx 拆出。竞态防护语义不变 —— 递增序号只认最后一次请求;
|
||||||
|
* loadMore 不自增序号(切换筛选才自增,翻页跟随当前序列)。
|
||||||
|
*/
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { http } from '../../../lib/http'
|
||||||
|
import type { Match } from '../types'
|
||||||
|
|
||||||
|
interface UseMatchesListOptions {
|
||||||
|
/** 共享 error state(拆分前列表与预测共用同一个 error,行为保持一致) */
|
||||||
|
onError: (msg: string | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMatchesList({ onError }: UseMatchesListOptions) {
|
||||||
|
const [league, setLeague] = useState('E0')
|
||||||
|
const [status, setStatus] = useState('scheduled')
|
||||||
|
const [matches, setMatches] = useState<Match[]>([])
|
||||||
|
const [nextCursor, setNextCursor] = useState<string | null>(null)
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [showAllUpcoming, setShowAllUpcoming] = useState(false) // 默认仅展示未来 3 天;true 展开全部
|
||||||
|
const [liveMatches, setLiveMatches] = useState<Match[]>([]) // 进行中比赛(顶部独立区块)
|
||||||
|
|
||||||
|
// 请求竞态防护:切换联赛/状态很快时,先发的慢请求可能后返回,
|
||||||
|
// 把旧结果覆盖到新筛选上。用递增序号只认最后一次请求的响应。
|
||||||
|
const loadSeq = useRef(0)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const seq = ++loadSeq.current
|
||||||
|
setLoading(true)
|
||||||
|
setLoadingMore(false)
|
||||||
|
setShowAllUpcoming(false) // 切换筛选重置为「未来 3 天」视图
|
||||||
|
onError(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
|
||||||
|
onError(e instanceof Error ? e.message : String(e))
|
||||||
|
} finally {
|
||||||
|
if (seq === loadSeq.current) setLoading(false)
|
||||||
|
}
|
||||||
|
}, [league, status, onError])
|
||||||
|
|
||||||
|
// 加载下一页(游标分页)
|
||||||
|
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
|
||||||
|
onError(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])
|
||||||
|
|
||||||
|
return {
|
||||||
|
league, setLeague,
|
||||||
|
status, setStatus,
|
||||||
|
matches,
|
||||||
|
nextCursor,
|
||||||
|
loading,
|
||||||
|
loadingMore,
|
||||||
|
showAllUpcoming, setShowAllUpcoming,
|
||||||
|
liveMatches,
|
||||||
|
load,
|
||||||
|
loadMore,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* Matches 页面族共享类型与常量。
|
||||||
|
*
|
||||||
|
* D3(工程债): Matches.tsx 原本 ~1400 行,类型/常量/预测面板/详情面板/
|
||||||
|
* 列表逻辑全部内联。本文件是拆分后的共享层 —— 只放数据契约与纯常量,
|
||||||
|
* 不含 React 组件。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** /matches 列表项(公开接口) */
|
||||||
|
export 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /predict 响应 */
|
||||||
|
export 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<string, number> | null
|
||||||
|
context: string
|
||||||
|
latency_ms: number | null
|
||||||
|
prompt_tokens: number | null
|
||||||
|
completion_tokens: number | null
|
||||||
|
rate_limit_remaining: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 多专家单路报告 */
|
||||||
|
export 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
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AGENT_LABELS: Record<string, string> = {
|
||||||
|
h2h: '历史交锋分析专家',
|
||||||
|
form: '近期状态分析专家',
|
||||||
|
stats: '攻防数据分析专家',
|
||||||
|
home_away: '主客因素分析专家',
|
||||||
|
standings: '联赛排名分析专家',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LEAGUES = [
|
||||||
|
{ code: 'E0', name: '英超' },
|
||||||
|
{ code: 'SP1', name: '西甲' },
|
||||||
|
{ code: 'D1', name: '德甲' },
|
||||||
|
{ code: 'I1', name: '意甲' },
|
||||||
|
{ code: 'F1', name: '法甲' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 汉字编号,给专家意见排版用 */
|
||||||
|
export const CN_NUM = ['一', '二', '三', '四', '五', '六', '七', '八']
|
||||||
|
|
||||||
|
export const STATUS_META: Record<string, { label: string; cls: string }> = {
|
||||||
|
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 → 中文标签 */
|
||||||
|
export const OUTCOME_LABEL: Record<string, string> = { '1': '主胜', X: '平局', '2': '客胜' }
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* Matches 页面族共享的原子 UI 小件(无业务状态)。
|
||||||
|
*
|
||||||
|
* D3: 从 Matches.tsx 内联定义上移到模块级 —— Switch 原先定义在组件函数
|
||||||
|
* 体内(每次渲染重建组件对象),它没有内部 state,提升后渲染结果一致。
|
||||||
|
*/
|
||||||
|
import type { Match } from './types'
|
||||||
|
|
||||||
|
export function Spinner({ className = '' }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
className={`h-3.5 w-3.5 animate-spin ${className}`}
|
||||||
|
fill="none"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle cx="10" cy="10" r="7.5" stroke="currentColor" strokeWidth="1.5" strokeOpacity="0.25" />
|
||||||
|
<path d="M17.5 10A7.5 7.5 0 0010 2.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 骨架占位行:低调脉动灰块 */
|
||||||
|
export function SkeletonRows({ n = 4 }: { n?: number }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{Array.from({ length: n }).map((_, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-4 border-b border-ink-200 px-1 py-3.5">
|
||||||
|
<div className="skeleton h-3 w-16" />
|
||||||
|
<div className="skeleton h-3 flex-1" />
|
||||||
|
<div className="skeleton h-3 w-10" />
|
||||||
|
<div className="skeleton h-3 flex-1" />
|
||||||
|
<div className="skeleton h-3 w-16" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 状态/模式一组的文字切换 */
|
||||||
|
export function Switch({ value, onChange, items }: {
|
||||||
|
value: string
|
||||||
|
onChange: (v: string) => void
|
||||||
|
items: { v: string; label: string; title?: string }[]
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-2.5">
|
||||||
|
{items.map((it, i) => (
|
||||||
|
<span key={it.v} className="inline-flex items-center gap-2.5">
|
||||||
|
{i > 0 && <span className="text-ink-300" aria-hidden="true">/</span>}
|
||||||
|
<button
|
||||||
|
onClick={() => onChange(it.v)}
|
||||||
|
title={it.title}
|
||||||
|
className={`relative tab ${value === it.v ? 'tab-on' : ''} text-xs`}
|
||||||
|
>
|
||||||
|
{it.label}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 日期分组头显示:今日/明天/周几 · 年月日 */
|
||||||
|
export 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}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** UTC ISO → 本地日期 YYYY-MM-DD(用于分组) */
|
||||||
|
export 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),保持时间序 */
|
||||||
|
export function groupByDate(list: Match[]): Array<[string, Match[]]> {
|
||||||
|
const map = new Map<string, Match[]>()
|
||||||
|
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()]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 日期 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 比赛是否在未来 3 天内(用于默认视图过滤) */
|
||||||
|
export function withinNext3Days(matchDate: string): boolean {
|
||||||
|
const key = toLocalDateKey(matchDate)
|
||||||
|
return key >= dateKey(new Date()) && key < addDays(new Date(), 3)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user