- crypto.py: API Key 加密/解密工具 - runtime_config.py: 运行时动态配置管理 - log_buffer.py: 内存日志缓冲区 - config.py: 新增加密配置项 - http_client.py: 增强重试和错误处理
865 lines
33 KiB
TypeScript
865 lines
33 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
import TeamSideTag from '../components/TeamSideTag'
|
|
|
|
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
|
|
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 CN_NUM = ['一', '二', '三', '四', '五', '六', '七', '八']
|
|
|
|
const STATUS_META: Record<string, { label: string; cls: string }> = {
|
|
finished: { label: '已完赛', cls: 'text-ink-400' },
|
|
scheduled: { label: '未开赛', cls: 'text-ink-600' },
|
|
live: { label: '进行中', cls: 'text-press font-medium' },
|
|
}
|
|
|
|
/** 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 [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 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)
|
|
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])
|
|
|
|
// 加载下一页(游标分页)
|
|
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)
|
|
// LLM 多专家预测耗时可达数分钟,给足超时(与 nginx 代理 300s 对齐)
|
|
const controller = new AbortController()
|
|
predictAbort.current = controller
|
|
const timer = setTimeout(() => controller.abort(), 300_000)
|
|
try {
|
|
const res = await fetch('/api/v1/predict', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ match_id: m.id, mode }),
|
|
signal: controller.signal,
|
|
})
|
|
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 DOMException && e.name === 'AbortError'
|
|
? '预测超时(5 分钟),请稍后重试或改用单次模式'
|
|
: e instanceof Error
|
|
? e.message
|
|
: String(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' })
|
|
}
|
|
|
|
const leagueName = LEAGUES.find(l => l.code === league)?.name ?? league
|
|
|
|
/** 状态/模式一组的文字切换 */
|
|
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>
|
|
|
|
{/* ── 第二行:状态 / 模式 / 计数 / 刷新 ── */}
|
|
<div className="flex flex-wrap items-center gap-x-5 gap-y-2 text-xs text-ink-500">
|
|
<span className="inline-flex items-center gap-2.5">
|
|
<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="inline-flex items-center gap-2.5">
|
|
<span className="text-2xs text-ink-400">模式</span>
|
|
<Switch
|
|
value={mode}
|
|
onChange={v => setMode(v as 'single' | 'multi')}
|
|
items={[
|
|
{ v: 'single', label: '单次', title: '单次调用,快但只有一个模型看全部数据' },
|
|
{ v: 'multi', label: '多专家', title: '5 个专家并行分析后由终裁汇总,质量更高' },
|
|
]}
|
|
/>
|
|
</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">
|
|
{loading ? (<><Spinner /> 获取中</>) : '刷新'}
|
|
</button>
|
|
</span>
|
|
</div>
|
|
|
|
{/* ── 错误提示 ── */}
|
|
{error && (
|
|
<div className="flex items-start justify-between gap-3 border border-press bg-press-wash px-4 py-3">
|
|
<div>
|
|
<p className="text-sm font-medium text-press">请求失败</p>
|
|
<p className="mt-0.5 text-xs text-ink-600">{error}</p>
|
|
</div>
|
|
<button onClick={() => setError(null)} className="text-ink-400 transition-colors hover:text-ink-900" 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>
|
|
)}
|
|
|
|
{/* ── 预测弹窗:进行中可视化 / 结果面板 ── */}
|
|
{predictionFor && (
|
|
<PredictModal
|
|
match={predictionFor}
|
|
mode={mode}
|
|
predicting={predictingId === predictionFor.id}
|
|
prediction={predictingId === predictionFor.id ? null : prediction}
|
|
error={predictingId === predictionFor.id ? null : error}
|
|
onClose={closePredict}
|
|
/>
|
|
)}
|
|
|
|
{/* ── 赛程栏:表格化,行间细线 ── */}
|
|
<section aria-label="赛程">
|
|
{loading && <SkeletonRows n={4} />}
|
|
|
|
{!loading && matches.length === 0 && (
|
|
<div className="border-y border-ink-200 py-14 text-center">
|
|
<p className="font-serif text-sm text-ink-600">本版暂无赛程</p>
|
|
<p className="mt-1.5 text-xs text-ink-400">请先通过采集接口导入 {leagueName} 的比赛数据</p>
|
|
</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'
|
|
|
|
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>
|
|
|
|
{/* 对阵:移动端主队/比分/客队同一行,桌面端 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>
|
|
</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>
|
|
)}
|
|
</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">
|
|
{!finished && (
|
|
<button
|
|
onClick={() => predict(m)}
|
|
disabled={busy}
|
|
className="btn btn-sm w-[76px]"
|
|
title={`以${mode === 'multi' ? '多专家' : '单次'}模式预测这场`}
|
|
>
|
|
{busy ? (<><Spinner /> 预测中</>) : '预测'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
|
|
{!loading && nextCursor && (
|
|
<div className="flex justify-center pt-4">
|
|
<button onClick={loadMore} disabled={loadingMore} className="btn btn-sm">
|
|
{loadingMore ? (<><Spinner /> 获取中</>) : '载入更多'}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
</div>
|
|
)
|
|
}
|
|
|
|
|
|
/** 预测过程阶段(按时长模拟;结果到达即跳到完成) */
|
|
function PredictProgress({ mode }: { mode: 'single' | 'multi' }) {
|
|
const [elapsed, setElapsed] = useState(0)
|
|
useEffect(() => {
|
|
const t = setInterval(() => setElapsed(e => e + 0.5), 500)
|
|
return () => clearInterval(t)
|
|
}, [])
|
|
|
|
// 阶段阈值(秒): 切片 → 专家(各路依次点亮) → 终裁
|
|
const SLICE_END = mode === 'multi' ? 3 : 3
|
|
const AGENT_START = 4
|
|
const AGENT_STEP = 8 // 每路专家约 8s 点亮一路
|
|
const AGG_START = mode === 'multi' ? AGENT_START + AGENT_STEP * 5 : SLICE_END + 1
|
|
const agents = ['form', 'stats', 'home_away', 'injuries', 'h2h']
|
|
|
|
const phase = elapsed < SLICE_END ? 'slice'
|
|
: mode === 'single'
|
|
? 'model'
|
|
: elapsed < AGG_START ? 'agents' : 'agg'
|
|
|
|
const pct = Math.min(95, Math.round((elapsed / (mode === 'multi' ? 70 : 20)) * 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 === 'model' && '模型分析中'}
|
|
{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' || phase === 'model' ? 'animate-pulse' : ''}`}
|
|
style={{ width: `${pct}%` }}
|
|
/>
|
|
</div>
|
|
|
|
{/* 专家灯序(多专家模式) */}
|
|
{mode === 'multi' && (
|
|
<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">
|
|
{mode === 'multi' ? '五路专家并行分析后终裁,约需 30-90 秒;关闭窗口即取消' : '单次调用,约需 5-20 秒;关闭窗口即取消'}
|
|
</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** 预测弹窗:进行中显示过程可视化,完成后显示预测版,失败显示原因 */
|
|
function PredictModal({
|
|
match,
|
|
mode,
|
|
predicting,
|
|
prediction,
|
|
error,
|
|
onClose,
|
|
}: {
|
|
match: Match
|
|
mode: 'single' | 'multi'
|
|
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 w-full max-w-2xl bg-paper-50 shadow-2xl">
|
|
{/* 弹窗报头 */}
|
|
<div className="flex 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-7 w-7 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>
|
|
|
|
{/* 弹窗体 */}
|
|
{predicting ? (
|
|
<PredictProgress mode={mode} />
|
|
) : 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} mode={mode} embedded />
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function PredictionPanel({
|
|
prediction,
|
|
match,
|
|
mode,
|
|
embedded = false,
|
|
}: {
|
|
prediction: Prediction
|
|
match: Match
|
|
mode: 'single' | 'multi'
|
|
/** 弹窗嵌入模式:弹窗已提供报头,这里省略自带版头 */
|
|
embedded?: boolean
|
|
}) {
|
|
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 (
|
|
<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">
|
|
{/* ── 预测比分:版面核心,大号宋体 ── */}
|
|
<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>
|
|
|
|
{/* ── 元信息一行 ── */}
|
|
<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>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
{prediction.agent_outputs.map((r, i) => (
|
|
<AgentCard key={r.agent} report={r} no={CN_NUM[i] ?? String(i + 1)} />
|
|
))}
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{/* ── 终裁意见:引文式,红竖线 ── */}
|
|
{prediction.reasoning && (
|
|
<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>
|
|
)
|
|
}
|