fix:批量修复了一些问题

This commit is contained in:
shangfangjian
2026-09-19 22:51:35 +08:00
parent 835d7217d0
commit 8e6ad5394e
44 changed files with 4921 additions and 395 deletions
+487 -124
View File
@@ -1,5 +1,7 @@
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'
interface Match {
id: number
@@ -31,10 +33,14 @@ interface Prediction {
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
}
interface AgentReport {
@@ -182,6 +188,7 @@ function OutcomeLine({
export default function Matches() {
const [league, setLeague] = useState('E0')
const [status, setStatus] = useState('scheduled')
const [date, setDate] = useState('') // 日期筛选(空=全部),"today"=今日
const [matches, setMatches] = useState<Match[]>([])
const [nextCursor, setNextCursor] = useState<string | null>(null)
const [loadingMore, setLoadingMore] = useState(false)
@@ -191,6 +198,10 @@ export default function Matches() {
const [predictionFor, setPredictionFor] = useState<Match | null>(null)
const [error, setError] = useState<string | null>(null)
const [mode, setMode] = useState<'single' | 'multi'>('multi')
const [expandedId, setExpandedId] = useState<number | null>(null)
const [detailMap, setDetailMap] = useState<Record<number, MatchDetailOut>>({})
const [contextMap, setContextMap] = useState<Record<number, MatchContextOut>>({})
const [detailLoading, setDetailLoading] = useState<number | null>(null)
// 请求竞态防护:切换联赛/状态很快时,先发的慢请求可能后返回,
// 把旧结果覆盖到新筛选上。用递增序号只认最后一次请求的响应。
@@ -211,11 +222,12 @@ export default function Matches() {
const load = useCallback(async () => {
const seq = ++loadSeq.current
setLoading(true)
// 切换筛选时作废进行中的「加载更多」,避免其标志位卡住
setLoadingMore(false)
setError(null)
try {
const params = new URLSearchParams({ league, status, limit: '50' })
if (date === 'today') params.set('date', todayStr())
else if (date) params.set('date', date)
const res = await fetch(`/api/v1/matches?${params}`)
if (seq !== loadSeq.current) return // 已有更新的请求,丢弃本次结果
if (!res.ok) throw new Error(`HTTP ${res.status}`)
@@ -229,7 +241,7 @@ export default function Matches() {
} finally {
if (seq === loadSeq.current) setLoading(false)
}
}, [league, status])
}, [league, status, date])
// 加载下一页(游标分页)
const loadMore = async () => {
@@ -238,6 +250,8 @@ export default function Matches() {
setLoadingMore(true)
try {
const params = new URLSearchParams({ league, status, limit: '50', cursor: nextCursor })
if (date === 'today') params.set('date', todayStr())
else if (date) params.set('date', date)
const res = await fetch(`/api/v1/matches?${params}`)
if (seq !== loadSeq.current) return
if (!res.ok) throw new Error(`HTTP ${res.status}`)
@@ -255,7 +269,26 @@ export default function Matches() {
useEffect(() => { load() }, [load])
/** 今日日期 YYYY-MM-DD(用于「今日」快速筛选) */
function todayStr(): string {
return new Date().toISOString().slice(0, 10)
}
/** 按日期分组(YYYY-MM-DD → Match[]),保持时间序 */
function groupByDate(list: Match[]): Array<[string, Match[]]> {
const map = new Map<string, Match[]>()
for (const m of list) {
const key = (m.match_date || '').slice(0, 10)
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)
@@ -285,9 +318,7 @@ export default function Matches() {
setError(
e instanceof DOMException && e.name === 'AbortError'
? '预测超时(5 分钟),请稍后重试或改用单次模式'
: e instanceof Error
? e.message
: String(e),
: readablePredictError(e),
)
} finally {
clearTimeout(timer)
@@ -366,6 +397,27 @@ export default function Matches() {
/>
</span>
<span className="inline-flex items-center gap-2.5">
<span className="text-2xs text-ink-400"></span>
<span className="inline-flex items-center gap-1">
<button
onClick={() => setDate(date === 'today' ? '' : 'today')}
className={`btn btn-sm px-2 ${date === 'today' ? 'btn-solid' : ''}`}
title="只看今日"
></button>
<input
type="date"
value={date === 'today' ? todayStr() : date}
onChange={e => setDate(e.target.value)}
className="field px-1.5 py-1 text-xs"
aria-label="按日期筛选"
/>
{date && (
<button onClick={() => setDate('')} className="text-ink-400 hover:text-ink-900" aria-label="清除日期" title="清除">×</button>
)}
</span>
</span>
<span className="ml-auto inline-flex items-center gap-3">
<span className="tabular-nums"> {matches.length} </span>
<button onClick={load} disabled={loading} className="btn btn-sm">
@@ -401,7 +453,7 @@ export default function Matches() {
/>
)}
{/* ── 赛程栏:表格化,行间细线 ── */}
{/* ── 赛程栏:表格化,行间细线,按日期分组 ── */}
<section aria-label="赛程">
{loading && <SkeletonRows n={4} />}
@@ -412,79 +464,129 @@ export default function Matches() {
</div>
)}
{!loading && matches.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 active = predictionFor?.id === m.id
const finished = m.match_status === 'finished'
{!loading && groupByDate(matches).map(([dateKey, group]) => (
<div key={dateKey}>
{/* 日期分组头 */}
<div className="sticky top-0 z-10 border-y border-ink-200 bg-paper-100 px-2 py-1.5 text-2xs font-medium tracking-wide text-ink-500">
{formatDateHeader(dateKey)} <span className="ml-1 text-ink-300">· {group.length} </span>
</div>
{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]
return (
<div
key={m.id}
className={`border-b border-ink-200 px-1 py-3 transition-colors hover:bg-paper-100 ${
active ? 'bg-press-wash/50' : ''
}`}
>
<div className="flex flex-col gap-2 sm:grid sm:grid-cols-[88px_minmax(0,1fr)_64px_minmax(0,1fr)_56px_auto] sm:items-center sm:gap-x-3 sm:gap-y-0">
{/* 日期 + 状态:移动端同行,桌面端日期单独归列 */}
<div className="flex items-center justify-between sm:contents">
<span className="text-2xs tabular-nums text-ink-400">{fmtDate(m.match_date)}</span>
<span className={`text-2xs sm:hidden ${st.cls}`}>{st.label}</span>
</div>
// 展开时懒加载详情(只读,不触发 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)
}
}
}
{/* 对阵:移动端主队/比分/客队同一行,桌面端 sm:contents 拆回 grid 列 */}
<div className="flex items-center gap-2 sm:contents">
{/* 主队(右对齐) */}
<div className="flex min-w-0 flex-1 items-center justify-end gap-1.5">
<TeamSideTag side="home" />
<span className="truncate text-sm font-medium text-ink-900">{homeName}</span>
return (
<div key={m.id}>
{/* 行:可点击展开 */}
<div
className={`border-b border-ink-200 px-1 py-3 transition-colors hover:bg-paper-100 cursor-pointer ${
expanded ? 'bg-paper-100/60' : ''
}`}
onClick={toggleExpand}
role="button"
tabIndex={0}
onKeyDown={e => { if (e.key === 'Enter') toggleExpand() }}
aria-expanded={expanded}
>
{/* 小屏:日期+状态行;桌面:日期单独一列 */}
<div className="flex items-center justify-between sm:contents">
<span className="text-2xs tabular-nums text-ink-400">{fmtDate(m.match_date)}</span>
<span className={`text-2xs sm:hidden ${st.cls}`}>{st.label}</span>
</div>
{/* 比分 / VS */}
<div className="flex w-14 flex-shrink-0 flex-col items-center sm:w-auto">
{m.home_goals !== null && m.away_goals !== null ? (
<span className="font-serif text-base font-bold tabular-nums text-ink-900">
{m.home_goals}<span className="mx-0.5 font-normal text-ink-300">:</span>{m.away_goals}
</span>
) : (
<span className="text-2xs tracking-widest text-ink-400">VS</span>
)}
{m.home_xg !== null && m.away_xg !== null && (
<span className="text-2xs tabular-nums text-ink-400">
xG {m.home_xg.toFixed(1)}-{m.away_xg.toFixed(1)}
</span>
)}
{/* 对阵行:小屏主队(弹性)/比分/客队(弹性)三格;桌面 sm:contents 走 grid */}
<div className="flex items-center gap-2">
<span className="flex min-w-0 flex-1 items-center justify-end gap-1.5">
<TeamSideTag side="home" />
<span className="truncate text-sm font-medium text-ink-900">{homeName}</span>
</span>
<span className="flex w-16 flex-shrink-0 flex-col items-center">
{m.home_goals !== null && m.away_goals !== null ? (
<span className="font-serif text-base font-bold tabular-nums text-ink-900">
{m.home_goals}<span className="mx-0.5 font-normal text-ink-300">:</span>{m.away_goals}
</span>
) : (
<span className="text-2xs tracking-widest text-ink-400">VS</span>
)}
{m.home_xg !== null && m.away_xg != null && (
<span className="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 flex-1 items-center gap-1.5">
<TeamSideTag side="away" />
<span className="truncate text-sm font-medium text-ink-900">{awayName}</span>
</span>
</div>
{/* 客队(左对齐) */}
<div className="flex min-w-0 flex-1 items-center gap-1.5">
<TeamSideTag side="away" />
<span className="truncate text-sm font-medium text-ink-900">{awayName}</span>
</div>
</div>
{/* 状态列(桌面) */}
<span className={`hidden text-right text-2xs sm:block ${st.cls}`}>{st.label}</span>
{/* 预测按钮 */}
<div className="flex justify-end">
{/* 预测按钮:小屏独占一行(桌面端 sm:contents 下隐藏) */}
{!finished && (
<button
onClick={() => predict(m)}
disabled={busy}
className="btn btn-sm w-[76px]"
title={`${mode === 'multi' ? '多专家' : '单次'}模式预测这场`}
>
{busy ? (<><Spinner /> </>) : '预测'}
</button>
<div className="flex justify-end sm:hidden" onClick={e => e.stopPropagation()}>
<button
onClick={() => predict(m)}
disabled={busy}
className="btn min-h-[44px] px-4"
title={`${mode === 'multi' ? '多专家' : '单次'}模式预测这场`}
>
{busy ? (<><Spinner /> </>) : '预测'}
</button>
</div>
)}
{/* 桌面端按钮(小屏隐藏) */}
<span className={`hidden text-right text-2xs sm:block ${st.cls}`}>{st.label}</span>
<div className="hidden sm:flex sm:justify-end" onClick={e => e.stopPropagation()}>
{!finished && (
<button
onClick={() => predict(m)}
disabled={busy}
className="btn btn-sm w-[76px]"
title={`${mode === 'multi' ? '多专家' : '单次'}模式预测这场`}
>
{busy ? (<><Spinner /> </>) : '预测'}
</button>
)}
</div>
</div>{/* 关闭可点击行(clickable row) */}
{/* 展开详情面板(只读数据 + 预测按钮 + 历史预测 + 专家报告入口) */}
{expanded && (
<MatchDetailPanel
match={m} detail={detail} ctx={ctx}
loading={detailLoading === m.id}
mode={mode}
/>
)}
</div>
</div>
</div>
)
})}
)
})}
</div>
))}
{!loading && nextCursor && (
<div className="flex justify-center pt-4">
@@ -499,6 +601,55 @@ export default function Matches() {
)
}
/** 日期分组头显示:今日/明天/周几 · 年月日 */
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.toISOString().slice(0, 10)
const tmr = new Date(today)
tmr.setDate(tmr.getDate() + 1)
const tmrKey = tmr.toISOString().slice(0, 10)
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 (
<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({ mode }: { mode: 'single' | 'multi' }) {
@@ -573,8 +724,15 @@ function PredictProgress({ mode }: { mode: 'single' | 'multi' }) {
)}
<p className="mt-6 text-center text-2xs text-ink-400">
{mode === 'multi' ? '五路专家并行分析后终裁,约需 30-90 秒;关闭窗口即取消' : '单次调用,约需 5-20 秒;关闭窗口即取消'}
{mode === 'multi'
? '五路专家并行分析后终裁,约需 30-90 秒;多专家调用消耗较多 token,请按需使用。关闭窗口即取消'
: '单次调用,约需 5-20 秒;关闭窗口即取消'}
</p>
{mode === 'multi' && (
<p className="mt-1 text-center text-2xs text-ink-300">
提示:每分钟限 10 ,
</p>
)}
</div>
)
}
@@ -670,12 +828,14 @@ function PredictionPanel({
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 okReports = (prediction.agent_outputs ?? []).filter(r => r.status === 'ok')
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">
@@ -694,71 +854,113 @@ function PredictionPanel({
)}
<div className="space-y-7 px-4 py-6 sm:px-5">
{/* ── 预测比分:版面核心,大号宋体 ── */}
<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>
{/* ── 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>
)}
</div>
<p className="mt-1.5 whitespace-pre-wrap text-xs leading-relaxed text-ink-600">
{prediction.reasoning || '所有专家均无有效数据或调用失败,无法生成可靠比分。'}
</p>
</div>
)}
{/* ── 胜平负 ── */}
<div className="border-y border-ink-200 py-4">
<OutcomeLine pick={prediction.pred_1x2} confidence={prediction.subjective_confidence} />
</div>
{/* ── 元信息一行 ── */}
<p className="text-center text-2xs text-ink-500">
{mode === 'multi' ? `多专家模式 · ${okReports.length}/${prediction.agent_outputs?.length ?? 0} 路有效` : '单次模式'}
{prediction.prompt_version && ` · prompt ${prediction.prompt_version}`}
{prediction.latency_ms !== null && ` · 终裁耗时 ${(prediction.latency_ms / 1000).toFixed(1)}`}
</p>
{/* ── 专家意见 ── */}
{prediction.agent_outputs && prediction.agent_outputs.length > 0 && (
<section>
<div className="section-head flex flex-wrap items-baseline justify-between gap-1">
<span></span>
{prediction.agent_weights && (
<span className="font-sans text-2xs font-normal text-ink-500">
:{Object.entries(prediction.agent_weights)
.sort((a, b) => b[1] - a[1])
.map(([k, v]) => `${AGENT_LABELS[k] ?? k} ${Math.round(v * 100)}%`)
.join(' / ')}
</span>
{/* ── 主结论(仅 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>
{prediction.agent_outputs.map((r, i) => (
<AgentCard key={r.agent} report={r} no={CN_NUM[i] ?? String(i + 1)} />
))}
<div className="border-y border-ink-200 py-4">
<OutcomeLine pick={prediction.pred_1x2} confidence={prediction.subjective_confidence} />
</div>
</section>
</>
)}
{/* ── 终裁意见:引文式,红竖线 ── */}
{prediction.reasoning && (
{/* ── 成本信息(耗时 + token + 限流余量) ── */}
{!degraded && (
<PredictionCost prediction={prediction} />
)}
{/* ── 元信息 ── */}
<p className="text-center text-2xs text-ink-500">
{mode === 'multi' ? `多专家模式 · ${okReports.length}/${reports.length} 路有效` : '单次模式'}
{prediction.prompt_version && ` · prompt ${prediction.prompt_version}`}
</p>
{/* ── 终裁/降级说明意见 ── */}
{prediction.reasoning && degraded && (
<section>
<h4 className="section-head mb-3"></h4>
<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>
<p className="whitespace-pre-wrap font-serif text-sm leading-loose text-ink-700">{prediction.reasoning}</p>
</blockquote>
</section>
)}
{/* ── 专家意见(多模式):可折叠 + 状态摘要 + 权重条形图 ── */}
{mode === 'multi' && 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 && mode === 'multi' && (
<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>
)
@@ -862,3 +1064,164 @@ function AgentCard({ report: r, no }: { report: AgentReport; no: string }) {
</details>
)
}
/** 比赛详情面板:双方近况/H2H + 历史预测列表(只读) */
function MatchDetailPanel({
match, detail, ctx, loading, mode,
}: {
match: Match
detail: MatchDetailOut | undefined
ctx: MatchContextOut | undefined
loading: boolean
mode: 'single' | 'multi'
}) {
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">
{mode === 'multi' ? '多专家' : '单次'}
</span>
)}
</div>
{/* 双方近况 + 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} mode={mode} />
))}
</div>
) : (
<p className="py-3 text-center text-2xs text-ink-400"></p>
)}
</div>
</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 ? r.match_date.slice(5, 10) : '—'
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, mode }: { p: MatchRecentPrediction; mode: 'single' | 'multi' }) {
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 = mode === 'multi' && 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?.slice(0, 16).replace('T', ' ') ?? '—'}</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>
)
}
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)
}