Files
Profeto/frontend/src/pages/Matches.tsx
T
WorkBuddy b6a251407d fix(pipeline): 死信表真正接线 + 前端 HTTP 收敛与状态修正
- bzzoiro events/standings/stats 抓取失败写入 IngestFailure 死信(尽力而为,
  写入失败不影响主流程);顺带修复 standings 失败路径 league_r 缺 errors 键
  的 KeyError —— 该路径此前从未跑通,一旦失败会顶掉原始异常
- admin/api.ts 收敛为 lib/http.ts 薄门面,消除第二套 HTTP 实现;
  UNAUTHORIZED_EVENT 定义移至共享层,断开 lib→admin 反向依赖
- STATUS_META 死键 live 改为 in_play(对齐 normalize.py 口径),
  补 paused/postponed/cancelled/suspended;移除无人消费的
  DashboardStats.total_matches(items.length 近似,上限 100)
- 新增 tests/test_ingest_deadletter.py(6 例,变异验证判别力)
2026-09-21 18:36:43 +08:00

1392 lines
58 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'
import type { MatchStatsDetail } from '../admin/types'
import { http } from '../lib/http'
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
}
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
}
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
}
const AGENT_LABELS: Record<string, string> = {
h2h: '历史交锋分析专家',
form: '近期状态分析专家',
stats: '攻防数据分析专家',
home_away: '主客因素分析专家',
standings: '联赛排名分析专家',
}
const LEAGUES = [
{ code: 'E0', name: '英超' },
{ code: 'SP1', name: '西甲' },
{ code: 'D1', name: '德甲' },
{ code: 'I1', name: '意甲' },
{ code: 'F1', name: '法甲' },
]
/** 汉字编号,给专家意见排版用 */
const CN_NUM = ['一', '二', '三', '四', '五', '六', '七', '八']
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 → 中文标签 */
const OUTCOME_LABEL: Record<string, string> = { '1': '主胜', X: '平局', '2': '客胜' }
/** 置信度细线: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 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>
))}
</>
)
}
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>
)
}
export default function Matches() {
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 [showBackTop, setShowBackTop] = useState(false) // 回到顶部按钮显示态
// 监听滚动,超过 300px 显示回到顶部按钮
useEffect(() => {
const handleScroll = () => setShowBackTop(window.scrollY > 300)
window.addEventListener('scroll', handleScroll, { passive: true })
return () => window.removeEventListener('scroll', handleScroll)
}, [])
const scrollToTop = () => window.scrollTo({ top: 0, behavior: 'smooth' })
const [predictingId, setPredictingId] = useState<number | null>(null)
const [prediction, setPrediction] = useState<Prediction | null>(null)
const [predictionFor, setPredictionFor] = useState<Match | null>(null)
const [error, setError] = useState<string | null>(null)
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)
// 请求竞态防护:切换联赛/状态很快时,先发的慢请求可能后返回,
// 把旧结果覆盖到新筛选上。用递增序号只认最后一次请求的响应。
const loadSeq = useRef(0)
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)
setError(null)
}
const load = useCallback(async () => {
const seq = ++loadSeq.current
setLoading(true)
setLoadingMore(false)
setShowAllUpcoming(false) // 切换筛选重置为「未来 3 天」视图
setError(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
setError(e instanceof Error ? e.message : String(e))
} finally {
if (seq === loadSeq.current) setLoading(false)
}
}, [league, status])
// 加载下一页(游标分页)
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
setError(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])
/** 日期 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)
}
const todayKey = dateKey(new Date())
const windowEnd = addDays(new Date(), 3) // 不含
/** 比赛是否在未来 3 天内(用于默认视图过滤) */
function withinNext3Days(matchDate: string): boolean {
const key = toLocalDateKey(matchDate)
return key >= todayKey && key < windowEnd
}
/** UTC ISO → 本地日期 YYYY-MM-DD(用于分组) */
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),保持时间序 */
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()]
}
const predict = async (m: Match) => {
// 防连点:若该场比赛已在预测中,直接忽略
if (predictingId === m.id) return
const seq = ++predictSeq.current
setPredictingId(m.id)
setError(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
setError(
e instanceof DOMException && e.name === 'AbortError'
? '预测超时(5 分钟),请稍后重试'
: readablePredictError(e),
)
} finally {
clearTimeout(timer)
if (seq === predictSeq.current) setPredictingId(null)
}
}
const fmtDate = (s: string) => {
const d = new Date(s)
return d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
}
/** 只显示时间 HH:mm(日期由分组头承担) */
const fmtTime = (s: string) => {
const d = new Date(s)
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false })
}
const leagueName = LEAGUES.find(l => l.code === league)?.name ?? league
/** 未开赛默认仅展示未来 3 天;其余状态展示全部。showAllUpcoming=true 时展开全部。 */
const isScheduledView = status === 'scheduled'
const visibleMatches = (!isScheduledView || showAllUpcoming)
? matches
: matches.filter(m => withinNext3Days(m.match_date))
// 是否有被折叠的未开赛比赛(用于显示「展开」按钮)
const hasHiddenUpcoming = isScheduledView && !showAllUpcoming && matches.length > visibleMatches.length
/** 状态/模式一组的文字切换 */
const Switch = ({ value, onChange, items }: {
value: string
onChange: (v: string) => void
items: { v: string; label: string; title?: string }[]
}) => (
<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>
)
return (
<div className="space-y-5">
{/* ── 联赛版面切换 ── */}
<nav className="flex items-center gap-6 overflow-x-auto border-b border-ink-900" aria-label="联赛">
{LEAGUES.map(l => (
<button
key={l.code}
onClick={() => setLeague(l.code)}
className={`relative tab ${league === l.code ? 'tab-on' : ''} font-serif`}
>
{l.name}
</button>
))}
</nav>
{/* ── 第二行:状态 / 模式 / 日期 / 计数 / 刷新(小屏 flex-wrap) ── */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-ink-500 sm:gap-x-5">
<span className="inline-flex items-center gap-2">
<span className="text-2xs text-ink-400">状态</span>
<Switch
value={status}
onChange={setStatus}
items={[
{ v: 'scheduled', label: '未开赛' },
{ v: 'finished', label: '已完赛' },
{ v: '', label: '全部' },
]}
/>
</span>
<span className="ml-auto inline-flex items-center gap-3">
<span className="tabular-nums">
{isScheduledView && !showAllUpcoming && hasHiddenUpcoming
? `未来3天 ${visibleMatches.length} / 共 ${matches.length} 场`
: `共 ${visibleMatches.length} 场`}
</span>
<button onClick={load} disabled={loading} className="btn btn-sm">
{loading ? (<><Spinner /> 获取中</>) : '刷新'}
</button>
</span>
</div>
{/* ── 错误提示(统一 error-banner 样式) ── */}
{error && (
<div className="error-banner">
<div>
<p className="error-banner-title">请求失败</p>
<p className="error-banner-detail">{error}</p>
</div>
<button onClick={() => setError(null)} className="text-ink-400 transition-colors hover:text-ink-900 text-lg leading-none p-1" aria-label="关闭">×</button>
</div>
)}
{/* ── 预测弹窗:进行中可视化 / 结果面板 ── */}
{predictionFor && (
<PredictModal
match={predictionFor}
predicting={predictingId === predictionFor.id}
prediction={predictingId === predictionFor.id ? null : prediction}
error={predictingId === predictionFor.id ? null : error}
onClose={closePredict}
/>
)}
{/* ── 进行中比赛(顶部独立区块,仅未开赛视图展示) ── */}
{isScheduledView && liveMatches.length > 0 && (
<section aria-label="进行中" className="border border-ink-900 bg-paper-100">
<div className="flex items-center gap-2 border-b border-ink-900 px-3 py-2">
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-press" />
<span className="text-xs font-medium tracking-wide text-ink-700">进行中 · 实时比分</span>
<span className="text-2xs text-ink-400">{liveMatches.length} </span>
</div>
<div className="divide-y divide-ink-200">
{liveMatches.map(m => {
const homeName = m.home_team_zh || m.home_team
const awayName = m.away_team_zh || m.away_team
return (
<div key={m.id} className="flex items-center justify-between gap-3 px-3 py-2.5">
<div className="flex items-center gap-2 min-w-0">
<span className="w-12 shrink-0 text-center font-serif text-lg font-bold tabular-nums text-ink-900">
{m.home_goals ?? '-'}
</span>
<span className="min-w-0 truncate text-xs text-ink-700">{homeName}</span>
</div>
<span className="shrink-0 text-2xs text-ink-400">vs</span>
<div className="flex items-center gap-2 min-w-0">
<span className="min-w-0 truncate text-right text-xs text-ink-700">{awayName}</span>
<span className="w-12 shrink-0 text-center font-serif text-lg font-bold tabular-nums text-ink-900">
{m.away_goals ?? '-'}
</span>
</div>
</div>
)
})}
</div>
</section>
)}
{/* ── 赛程栏:表格化,行间细线,按日期分组 ── */}
<section aria-label="赛程">
{loading && <SkeletonRows n={4} />}
{!loading && visibleMatches.length === 0 && (
<div className="empty-state">
{matches.length > 0 ? (
<>
<p className="empty-state-title">未来 3 天暂无 {leagueName} 比赛</p>
<p className="empty-state-sub">已导入 {matches.length} 场未开赛,点击下方按钮查看</p>
</>
) : (
<>
<p className="empty-state-title">本版暂无赛程</p>
<p className="empty-state-sub">请先通过「数据采集」导入 {leagueName} 的比赛数据</p>
<a href="/admin/collection" className="empty-state-action">
前往数据采集 <span aria-hidden="true"></span>
</a>
</>
)}
</div>
)}
{!loading && groupByDate(visibleMatches).map(([dateKey, group]) => (
<div key={dateKey}>
{/* 日期分组头:sticky 但 z 低于弹窗 z-50 */}
<div className="sticky top-0 z-20 border-b border-ink-900 bg-paper-100 px-3 py-2 text-xs font-medium tracking-wide text-ink-600">
{formatDateHeader(dateKey)}
<span className="ml-2 text-2xs font-normal text-ink-400">{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]
// 展开时懒加载详情(只读,不触发 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)
}
}
}
return (
<div key={m.id}>
{/* 行:可点击展开 */}
<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={toggleExpand}
role="button"
tabIndex={0}
onKeyDown={e => { if (e.key === 'Enter') toggleExpand() }}
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={() => predict(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 === m.id}
/>
)}
</div>
)
})}
</div>
))}
{/* 单一「加载更多」按钮:3 天视图时先展开,展开后从服务器拉取下一页 */}
{!loading && (hasHiddenUpcoming || nextCursor) && (
<div className="flex justify-center pt-4">
{hasHiddenUpcoming ? (
<button
onClick={() => setShowAllUpcoming(true)}
className="btn btn-outline min-h-[44px] w-full max-w-xs sm:w-auto"
>
显示后续 {matches.length - visibleMatches.length} 场未开赛
</button>
) : (
<button onClick={loadMore} disabled={loadingMore} className="btn min-h-[44px] w-full max-w-xs sm:w-auto">
{loadingMore ? (<><Spinner /> 获取中</>) : '载入更多赛程'}
</button>
)}
</div>
)}
{/* 已展开全部但未开赛:提供「收起」回未来 3 天 */}
{!loading && isScheduledView && showAllUpcoming && matches.length > 0 && (
<div className="flex justify-center pt-2">
<button
onClick={() => setShowAllUpcoming(false)}
className="text-xs text-ink-400 hover:text-ink-700 transition-colors"
>
收起,仅显示未来 3
</button>
</div>
)}
</section>
{/* 回到顶部按钮 */}
<button
onClick={scrollToTop}
className={`fixed bottom-6 right-6 z-40 flex h-10 w-10 items-center justify-center rounded-full border border-ink-200 bg-paper-50 text-ink-600 shadow-lg transition-all duration-300 hover:border-ink-400 hover:text-ink-900 ${
showBackTop ? 'translate-y-0 opacity-100' : 'translate-y-4 opacity-0 pointer-events-none'
}`}
aria-label="回到顶部"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
</svg>
</button>
</div>
)
}
/** 日期分组头显示:今日/明天/周几 · 年月日 */
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}`
}
/** 预测成本展示:耗时 + 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>
)
}
/** 预测弹窗:进行中显示过程可视化,完成后显示预测版,失败显示原因 */
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>
)
}
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>
)
}
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>
)
}
/** 比赛详情面板:双方近况/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>
)
}
/** 比赛详细统计面板(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>
)
}
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)
}