Files
Profeto/frontend/src/pages/Matches.tsx
T
WorkBuddy 77d01450b1 feat(ui): 清爽专业风设计系统与界面重构
设计令牌(tailwind.config.js):
- brand 11 档蓝色阶、ink 11 档冷灰色阶、win/draw/loss 语义色
- 中文字体栈、tabular-nums 数字等宽、2xs 字号
- borderRadius card/pill、boxShadow card/raise/focus、fade-up/shimmer 动画

组件层(index.css):
- @layer base: 全局背景/字色/抗锯齿、统一焦点环、自定义滚动条,
  尊重 prefers-reduced-motion
- @layer components: .card .btn .field .pill .skeleton .segment

界面:
- App.tsx: SVG 队徽替代 emoji、sticky 毛玻璃头部、服务状态指示、页脚免责声明
- Matches.tsx: 比赛列表卡片化;新增 TeamMark(队名哈希色相队标)、
  EdgeBar(home_edge 双向可视化)、OutcomeCard、AgentCard(状态徽章+证据
  列表+无数据说明)、骨架屏;SVG Spinner 替代 emoji 转圈

可用性修复:
- 预测面板拆为独立的胜平负/置信度/模式/耗时卡片,胜平负与比分各有
  独立可视化条
- 移动端横向溢出:比赛行改为 flex-col -> sm:flex-row,元信息与操作按钮重排
- 无数据专家卡片补充说明文案,不再空白

保留原有契约:loadSeq/predictSeq 竞态防护、游标分页、全部类型定义。

验证:tsc --noEmit(strict)0 错误;vite build 成功;
360/390/414/768/1280px 五档实测 scrollWidth === viewport,零横向溢出。
2026-09-15 17:45:16 +08:00

753 lines
31 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react'
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
pred_1x2: string | null
subjective_confidence: number | null
reasoning: string | null
agent_outputs: AgentReport[] | null
agent_weights: Record<string, number> | null
context: string
latency_ms: 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: '主客因素',
injuries: '阵容完整性',
}
const LEAGUES = [
{ code: 'E0', name: '英超' },
{ code: 'SP1', name: '西甲' },
{ code: 'D1', name: '德甲' },
{ code: 'I1', name: '意甲' },
{ code: 'F1', name: '法甲' },
]
const STATUS_META: Record<string, { label: string; cls: string }> = {
finished: { label: '已完赛', cls: 'bg-ink-100 text-ink-600' },
scheduled: { label: '未开赛', cls: 'bg-brand-50 text-brand-700' },
live: { label: '进行中', cls: 'bg-emerald-50 text-emerald-700' },
}
/** 1x2 → 中文标签 */
const OUTCOME_LABEL: Record<string, string> = { '1': '主胜', X: '平局', '2': '客胜' }
/** 队名取首字作视觉标记(替代队徽图片,避免额外资源与 404) */
function initial(name: string): string {
return (name || '?').trim().charAt(0)
}
/** 依据队名生成一个稳定的色相,让不同球队有可区分的淡色底 */
function hueOf(name: string): number {
let h = 0
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) % 360
return h
}
function TeamMark({ name, size = 40 }: { name: string; size?: number }) {
const h = hueOf(name)
return (
<span
className="flex flex-shrink-0 items-center justify-center rounded-lg font-semibold"
style={{
width: size,
height: size,
fontSize: size * 0.4,
background: `hsl(${h} 70% 96%)`,
color: `hsl(${h} 55% 38%)`,
border: `1px solid hsl(${h} 60% 88%)`,
}}
aria-hidden="true"
>
{initial(name)}
</span>
)
}
/** 置信度条:把 0~1 的数值可视化,比纯数字更易读 */
function Meter({ value, tone = 'brand' }: { value: number; tone?: 'brand' | 'emerald' | 'amber' }) {
const pct = Math.max(0, Math.min(100, Math.round(value * 100)))
const bar =
tone === 'emerald' ? 'bg-emerald-500' : tone === 'amber' ? 'bg-amber-500' : 'bg-brand-500'
return (
<div className="h-1.5 w-full overflow-hidden rounded-pill bg-ink-200" role="presentation">
<div className={`h-full rounded-pill ${bar} 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-1.5 w-full overflow-hidden rounded-pill bg-ink-200" role="presentation">
<span className="absolute left-1/2 top-0 h-full w-px -translate-x-1/2 bg-ink-300" />
<span
className={`absolute top-0 h-full transition-all duration-500 ${v >= 0 ? 'bg-brand-500' : 'bg-amber-500'}`}
style={
v >= 0
? { left: '50%', width: `${half}%` }
: { right: '50%', width: `${half}%` }
}
/>
</div>
)
}
/** 骨架屏行,替代 emoji spinner */
function SkeletonRows({ n = 4 }: { n?: number }) {
return (
<>
{Array.from({ length: n }).map((_, i) => (
<div key={i} className="flex items-center gap-4 rounded-card border border-ink-200 bg-white p-4">
<div className="skeleton h-10 w-10 rounded-lg" />
<div className="flex-1 space-y-2">
<div className="skeleton h-3.5 w-40" />
<div className="skeleton h-3 w-24" />
</div>
<div className="skeleton h-8 w-20 rounded-lg" />
</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 [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 [mode, setMode] = useState<'single' | 'multi'>('multi')
// 请求竞态防护:切换联赛/状态很快时,先发的慢请求可能后返回,
// 把旧结果覆盖到新筛选上。用递增序号只认最后一次请求的响应。
const loadSeq = useRef(0)
const predictSeq = useRef(0)
const load = useCallback(async () => {
const seq = ++loadSeq.current
setLoading(true)
// 切换筛选时作废进行中的「加载更多」,避免其标志位卡住
setLoadingMore(false)
setError(null)
try {
const params = new URLSearchParams({ league, status, limit: '50' })
const res = await fetch(`/api/v1/matches?${params}`)
if (seq !== loadSeq.current) return // 已有更新的请求,丢弃本次结果
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
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])
// 加载下一页(游标分页)。后端已支持 cursor,前端此前未使用,
// 导致 limit=50 之后的数据永远看不到。
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 res = await fetch(`/api/v1/matches?${params}`)
if (seq !== loadSeq.current) return
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
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)
}
}
useEffect(() => { load() }, [load])
const predict = async (m: Match) => {
const seq = ++predictSeq.current
setPredictingId(m.id)
setError(null)
setPrediction(null)
setPredictionFor(m)
try {
const res = await fetch('/api/v1/predict', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ match_id: m.id, mode }),
})
if (seq !== predictSeq.current) return
if (!res.ok) {
const t = await res.text()
throw new Error(`HTTP ${res.status}: ${t}`)
}
const data = await res.json()
if (seq !== predictSeq.current) return
setPrediction(data)
} catch (e) {
if (seq !== predictSeq.current) return
setError(e instanceof Error ? e.message : String(e))
} finally {
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' })
}
const leagueName = LEAGUES.find(l => l.code === league)?.name ?? league
return (
<div className="space-y-4">
{/* ── 工具栏 ── */}
<div className="card flex flex-wrap items-center gap-3 p-3">
<div className="flex items-center gap-2">
<label className="text-2xs font-medium text-ink-500">联赛</label>
<select value={league} onChange={e => setLeague(e.target.value)} className="field w-28">
{LEAGUES.map(l => <option key={l.code} value={l.code}>{l.name}</option>)}
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-2xs font-medium text-ink-500">状态</label>
<select value={status} onChange={e => setStatus(e.target.value)} className="field w-28">
<option value="scheduled">未开赛</option>
<option value="finished">已完赛</option>
<option value="">全部</option>
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-2xs font-medium text-ink-500">预测模式</label>
<div className="inline-flex rounded-lg bg-ink-100 p-0.5">
<button
onClick={() => setMode('single')}
className={`segment ${mode === 'single' ? 'segment-on' : 'segment-off'}`}
title="单次调用,快但只有一个模型看全部数据"
>单次</button>
<button
onClick={() => setMode('multi')}
className={`segment ${mode === 'multi' ? 'segment-on' : 'segment-off'}`}
title="5 个专家并行分析后由终裁汇总,质量更高"
> Agent</button>
</div>
</div>
<div className="ml-auto flex items-center gap-3">
<span className="text-2xs text-ink-500">
<span className="font-semibold text-ink-700">{matches.length}</span>
</span>
<button onClick={load} disabled={loading} className="btn-ghost btn-sm">
{loading ? (
<>
<Spinner /> 加载中
</>
) : '刷新'}
</button>
</div>
</div>
{/* ── 错误提示 ── */}
{error && (
<div className="flex items-start justify-between gap-3 rounded-card border border-red-200 bg-red-50 px-4 py-3">
<div className="flex items-start gap-2.5">
<svg viewBox="0 0 20 20" className="mt-0.5 h-4 w-4 flex-shrink-0 text-red-500" fill="currentColor" aria-hidden="true">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9 8a1 1 0 012 0v4a1 1 0 11-2 0V8zm1-4a1 1 0 100 2 1 1 0 000-2z" clipRule="evenodd" />
</svg>
<div>
<p className="text-sm font-medium text-red-800">请求失败</p>
<p className="mt-0.5 text-xs text-red-600">{error}</p>
</div>
</div>
<button onClick={() => setError(null)} className="text-red-400 transition-colors hover:text-red-600" aria-label="关闭">
<svg viewBox="0 0 20 20" className="h-4 w-4" 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="space-y-2.5">
{loading && <SkeletonRows n={4} />}
{!loading && matches.length === 0 && (
<div className="card flex flex-col items-center justify-center gap-2 py-16">
<svg viewBox="0 0 24 24" className="h-10 w-10 text-ink-300" fill="none" stroke="currentColor" strokeWidth="1.4" aria-hidden="true">
<circle cx="12" cy="12" r="9" />
<path d="M12 7v5l3 2" strokeLinecap="round" />
</svg>
<p className="text-sm font-medium text-ink-600">暂无比赛数据</p>
<p className="text-xs text-ink-400">请先通过采集接口导入 {leagueName} 的赛程</p>
</div>
)}
{!loading && matches.map(m => {
const st = STATUS_META[m.match_status] ?? { label: m.match_status, cls: 'bg-ink-100 text-ink-600' }
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
return (
<div
key={m.id}
className={`group card p-4 transition-all duration-200 hover:shadow-raise ${
active ? 'ring-1 ring-brand-300' : ''
}`}
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
{/* 对阵 */}
<div className="flex min-w-0 flex-1 items-center gap-2 sm:gap-3">
<div className="flex min-w-0 flex-1 items-center justify-end gap-2 sm:gap-2.5">
<span className="truncate text-sm font-medium text-ink-800">{homeName}</span>
<TeamMark name={homeName} size={36} />
</div>
<div className="flex w-14 flex-shrink-0 flex-col items-center sm:w-16">
{m.home_goals !== null && m.away_goals !== null ? (
<span className="text-base font-semibold tabular-nums text-ink-900">
{m.home_goals}<span className="mx-0.5 text-ink-300">:</span>{m.away_goals}
</span>
) : (
<span className="text-xs font-medium text-ink-300">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>
)}
</div>
<div className="flex min-w-0 flex-1 items-center gap-2 sm:gap-2.5">
<TeamMark name={awayName} size={36} />
<span className="truncate text-sm font-medium text-ink-800">{awayName}</span>
</div>
</div>
<div className="flex items-center gap-3 sm:flex-shrink-0">
{/* 元信息:桌面端独立成列,移动端与按钮同排 */}
<div className="flex min-w-0 flex-1 flex-col items-start gap-1 sm:w-40 sm:flex-none">
<span className={`pill ${st.cls}`}>
<span className="h-1.5 w-1.5 rounded-full bg-current opacity-60" />
{st.label}
</span>
<span className="truncate text-2xs text-ink-400">
{m.league_code ?? '—'} · {fmtDate(m.match_date)}
</span>
</div>
{/* 操作:主功能提升为实心按钮 */}
<button
onClick={() => predict(m)}
disabled={busy}
className="btn-primary btn-sm w-[88px] flex-shrink-0"
title={`以${mode === 'multi' ? '多 Agent' : '单次'}模式预测这场`}
>
{busy ? (
<>
<Spinner /> 预测中
</>
) : (
<>
<svg viewBox="0 0 20 20" className="h-3.5 w-3.5" fill="currentColor" aria-hidden="true">
<path d="M10 2l1.9 5.1L17 9l-5.1 1.9L10 16l-1.9-5.1L3 9l5.1-1.9L10 2z" />
</svg>
预测
</>
)}
</button>
</div>
</div>
</div>
)
})}
{!loading && nextCursor && (
<div className="flex justify-center pt-2">
<button onClick={loadMore} disabled={loadingMore} className="btn-ghost">
{loadingMore ? (
<>
<Spinner /> 加载中
</>
) : '加载更多'}
</button>
</div>
)}
</div>
{/* ── 预测中:占位卡片(替代 emoji 提示条) ── */}
{predictingId && !prediction && (
<div className="card animate-fade-up overflow-hidden">
<div className="flex items-center gap-2 border-b border-ink-200 bg-ink-50 px-5 py-3">
<Spinner className="text-brand-600" />
<span className="text-sm font-medium text-ink-700">正在生成预测</span>
<span className="text-xs text-ink-400">
{mode === 'multi' ? '· 5 个专家并行分析后由终裁汇总,约需 20-60 秒' : '· 单次调用,约需 5-15 秒'}
</span>
</div>
<div className="space-y-4 p-5">
<div className="flex items-center justify-center gap-6">
<div className="skeleton h-3.5 w-24" />
<div className="skeleton h-10 w-20 rounded-lg" />
<div className="skeleton h-3.5 w-24" />
</div>
<div className="skeleton h-2 w-full rounded-pill" />
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="skeleton h-20 rounded-card" />
<div className="skeleton h-20 rounded-card" />
</div>
</div>
</div>
)}
{/* ── 预测结果 ── */}
{prediction && predictionFor && (
<PredictionPanel
prediction={prediction}
match={predictionFor}
mode={mode}
/>
)}
</div>
)
}
function Spinner({ className = '' }: { className?: string }) {
// 纯 SVG 转圈,替代 emoji ⏳(各平台渲染不一致)
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="2" strokeOpacity="0.25" />
<path d="M17.5 10A7.5 7.5 0 0010 2.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
)
}
/** 胜平负概率条:LLM 只给主观置信度,这里把它按 1x2 结果做成单条高亮,
* 并显式标注「主观」避免用户误当概率 */
function OutcomeCard({
pick,
confidence,
}: {
pick: string | null
confidence: number | null
}) {
const label = pick ? OUTCOME_LABEL[pick] ?? pick : '—'
const tone = pick === '1' ? 'brand' : pick === 'X' ? 'amber' : 'emerald'
const color =
tone === 'brand' ? 'text-brand-700' : tone === 'amber' ? 'text-amber-700' : 'text-emerald-700'
const bg = tone === 'brand' ? 'bg-brand-50' : tone === 'amber' ? 'bg-amber-50' : 'bg-emerald-50'
const border =
tone === 'brand' ? 'border-brand-100' : tone === 'amber' ? 'border-amber-100' : 'border-emerald-100'
return (
<div className={`rounded-card border ${border} ${bg} p-4`}>
<p className="text-2xs font-medium text-ink-500">胜平负</p>
<p className={`mt-1 text-2xl font-semibold ${color}`}>{label}</p>
{confidence !== null && (
<div className="mt-2.5 space-y-1.5">
<Meter value={confidence} tone={tone === 'brand' ? 'brand' : tone === 'amber' ? 'amber' : 'emerald'} />
<p className="text-2xs text-ink-400">主观置信度 {Math.round(confidence * 100)}%</p>
</div>
)}
</div>
)
}
function PredictionPanel({
prediction,
match,
mode,
}: {
prediction: Prediction
match: Match
mode: 'single' | 'multi'
}) {
const homeName = match.home_team_zh || match.home_team
const awayName = match.away_team_zh || match.away_team
const okReports = (prediction.agent_outputs ?? []).filter(r => r.status === 'ok')
return (
<div className="card animate-fade-up overflow-hidden">
{/* 面板头 */}
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-ink-200 bg-ink-50 px-5 py-3">
<div className="flex items-center gap-2">
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-brand-600">
<svg viewBox="0 0 20 20" className="h-3.5 w-3.5 text-white" fill="currentColor" aria-hidden="true">
<path d="M10 2l1.9 5.1L17 9l-5.1 1.9L10 16l-1.9-5.1L3 9l5.1-1.9L10 2z" />
</svg>
</span>
<h3 className="text-sm font-semibold text-ink-800">LLM 预测结果</h3>
{prediction.mode === 'multi' && (
<span className="pill bg-brand-50 text-brand-700">
Agent · {okReports.length}/{prediction.agent_outputs?.length ?? 0} 专家有效
</span>
)}
</div>
<span className="text-2xs text-ink-400">
{prediction.provider} / {prediction.model}
{prediction.latency_ms !== null && ` · ${(prediction.latency_ms / 1000).toFixed(1)}s`}
</span>
</div>
<div className="space-y-5 p-5">
{/* ── 对阵 + 预测比分(核心) ── */}
<div className="flex items-center justify-center gap-4 sm:gap-8">
<div className="flex min-w-0 flex-1 flex-col items-center gap-2 sm:flex-row sm:justify-end">
<span className="order-2 truncate text-sm font-medium text-ink-800 sm:order-1 sm:text-right">{homeName}</span>
<span className="order-1 sm:order-2"><TeamMark name={homeName} size={44} /></span>
</div>
<div className="flex flex-shrink-0 flex-col items-center">
<div className="flex items-center gap-1.5">
<span className="rounded-lg bg-ink-100 px-3 py-1.5 text-2xl font-semibold tabular-nums text-ink-900">
{prediction.pred_home_goals ?? '-'}
</span>
<span className="text-lg text-ink-300">:</span>
<span className="rounded-lg bg-ink-100 px-3 py-1.5 text-2xl font-semibold tabular-nums text-ink-900">
{prediction.pred_away_goals ?? '-'}
</span>
</div>
<span className="mt-1.5 text-2xs text-ink-400">预测比分</span>
</div>
<div className="flex min-w-0 flex-1 flex-col items-center gap-2 sm:flex-row">
<span className="order-1"><TeamMark name={awayName} size={44} /></span>
<span className="order-2 truncate text-sm font-medium text-ink-800 sm:text-left">{awayName}</span>
</div>
</div>
{/* ── 指标卡 ── */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<OutcomeCard pick={prediction.pred_1x2} confidence={prediction.subjective_confidence} />
<div className="rounded-card border border-ink-200 p-4">
<p className="text-2xs font-medium text-ink-500">预测模式</p>
<p className="mt-1 text-2xl font-semibold text-ink-800">{mode === 'multi' ? '多 Agent' : '单次'}</p>
<p className="mt-2.5 text-2xs text-ink-400">
prompt {prediction.prompt_version ?? '—'}
</p>
</div>
<div className="rounded-card border border-ink-200 p-4">
<p className="text-2xs font-medium text-ink-500">模型耗时</p>
<p className="mt-1 text-2xl font-semibold tabular-nums text-ink-800">
{prediction.latency_ms !== null ? (prediction.latency_ms / 1000).toFixed(1) : '—'}
<span className="ml-0.5 text-sm font-normal text-ink-400">s</span>
</p>
<p className="mt-2.5 text-2xs text-ink-400">含专家并行 + 终裁汇总</p>
</div>
</div>
{/* ── 专家报告 ── */}
{prediction.agent_outputs && prediction.agent_outputs.length > 0 && (
<div>
<div className="mb-2.5 flex items-center gap-2">
<h4 className="text-xs font-semibold text-ink-700">专家 Agent 报告</h4>
{prediction.agent_weights && (
<span className="text-2xs text-ink-400">
终裁权重:
{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>
)}
</div>
<div className="grid grid-cols-1 gap-2.5 lg:grid-cols-2">
{prediction.agent_outputs.map(r => (
<AgentCard key={r.agent} report={r} />
))}
</div>
</div>
)}
{/* ── 推理过程 ── */}
{prediction.reasoning && (
<div className="rounded-card border border-ink-200 bg-ink-50 p-4">
<p className="mb-2 text-2xs font-semibold uppercase tracking-wide text-ink-500">终裁推理</p>
<p className="whitespace-pre-wrap text-sm leading-relaxed text-ink-700">{prediction.reasoning}</p>
</div>
)}
{/* ── 原始上下文 ── */}
<details className="group">
<summary className="flex cursor-pointer list-none items-center gap-1.5 text-xs font-medium text-ink-500 transition-colors hover:text-ink-700">
<svg viewBox="0 0 20 20" className="h-3.5 w-3.5 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>
查看喂给模型的完整数据切片
</summary>
<pre className="mt-2 max-h-80 overflow-auto rounded-card border border-ink-700 bg-ink-900 p-4 font-mono text-2xs leading-relaxed text-ink-300">
{prediction.context}
</pre>
</details>
</div>
</div>
)
}
const STATUS_BADGE: Record<string, { label: string; cls: string }> = {
ok: { label: '正常', cls: 'bg-emerald-50 text-emerald-700 border-emerald-100' },
no_data: { label: '无数据', cls: 'bg-ink-100 text-ink-500 border-ink-200' },
error: { label: '调用失败', cls: 'bg-red-50 text-red-600 border-red-100' },
parse_error: { label: '解析失败', cls: 'bg-amber-50 text-amber-700 border-amber-100' },
}
const SUFFICIENCY_LABEL: Record<string, string> = {
high: '充分',
medium: '一般',
low: '偏少',
none: '无',
}
function AgentCard({ report: r }: { report: AgentReport }) {
const badge = STATUS_BADGE[r.status] ?? { label: r.status, cls: 'bg-ink-100 text-ink-500 border-ink-200' }
const inactive = r.status !== 'ok'
return (
<details className="group rounded-card border border-ink-200 bg-white transition-colors open:border-ink-300 hover:border-ink-300">
<summary className="flex cursor-pointer list-none items-center gap-2.5 px-3.5 py-3">
<svg viewBox="0 0 20 20" className="h-3.5 w-3.5 flex-shrink-0 text-ink-400 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 className="text-sm font-medium text-ink-800">{AGENT_LABELS[r.agent] ?? r.agent}</span>
<span className={`pill border ${badge.cls}`}>{badge.label}</span>
<span className="ml-auto flex items-center 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="rounded bg-ink-100 px-1.5 py-0.5 font-medium text-ink-600">{r.probable_score}</span>
)}
</span>
</summary>
<div className="space-y-3 border-t border-ink-100 px-3.5 pb-3.5 pt-3">
{/* 无数据 / 失败时给出明确说明,避免用户以为是空白 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-center justify-between text-2xs">
<span className="font-medium text-ink-500">主队优势</span>
<span className={`font-semibold tabular-nums ${r.home_edge > 0 ? 'text-brand-600' : r.home_edge < 0 ? 'text-amber-600' : '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="text-sm leading-relaxed 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="mt-1.5 h-1 w-1 flex-shrink-0 rounded-full bg-ink-300" />
<span>{e}</span>
</li>
))}
</ul>
)}
{r.exp_home_goals !== null && r.exp_away_goals !== null && (
<div className="flex items-center gap-2 rounded-lg bg-ink-50 px-3 py-2">
<span className="text-2xs font-medium text-ink-500">进球期望</span>
<span className="text-sm font-semibold tabular-nums text-ink-800">
{r.exp_home_goals.toFixed(1)} - {r.exp_away_goals.toFixed(1)}
</span>
</div>
)}
{!inactive && (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 border-t border-ink-100 pt-2.5 text-2xs text-ink-400">
<span>数据充分度 {SUFFICIENCY_LABEL[r.data_sufficiency] ?? r.data_sufficiency}</span>
<span className="font-mono">{r.model}</span>
{r.latency_ms !== null && <span className="tabular-nums">{r.latency_ms}ms</span>}
</div>
)}
</div>
</details>
)
}