Files
Profeto/frontend/src/admin/pages/Predictions.tsx
T

352 lines
14 KiB
TypeScript

/**
* Admin 后台 - 预测管理页面(报刊风)
*
* 功能:
* - 触发预测(选择比赛 + 模式)
* - 结算:录入实际比分,写入评估(接 /eval/settle)
* - 预测记录列表:可展开查看终裁理由与专家摘要
*
* 响应式布局: 移动端单列,桌面端双列
*/
import { useCallback, useEffect, useMemo, useState } from 'react'
import { triggerPrediction, fetchPredictions, fetchMatches, settlePrediction } from '../dal'
import type { Match, Prediction } from '../types'
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
const AGENT_LABELS: Record<string, string> = {
h2h: '历史交锋',
form: '近期状态',
stats: '攻防数据',
home_away: '主客因素',
injuries: '阵容完整性',
}
const OUTCOME_LABEL: Record<string, string> = { '1': '主胜', X: '平局', '2': '客胜' }
function fmtTime(s?: string | null): string {
if (!s) return '—'
const d = new Date(s)
return isNaN(d.getTime())
? s
: d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
}
export default function PredictionsPage() {
const [matches, setMatches] = useState<Match[]>([])
const [predictions, setPredictions] = useState<Prediction[]>([])
const [matchId, setMatchId] = useState('')
const [mode, setMode] = useState<'single' | 'multi'>('multi')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [successMsg, setSuccessMsg] = useState<string | null>(null)
// 结算表单
const [settleId, setSettleId] = useState('')
const [homeGoals, setHomeGoals] = useState('')
const [awayGoals, setAwayGoals] = useState('')
const [settling, setSettling] = useState(false)
const [settleMsg, setSettleMsg] = useState<{ kind: 'error' | 'ok'; text: string } | null>(null)
const refreshPredictions = useCallback(async () => {
const list = await fetchPredictions(50)
setPredictions(list)
}, [])
useEffect(() => {
refreshPredictions()
fetchMatches({ limit: 100 }).then(d => setMatches(d.items))
}, [refreshPredictions])
/** match_id → 中文名对阵 */
const matchName = useMemo(() => {
const map = new Map<number, string>()
for (const m of matches) {
const home = m.home_team_zh || m.home_team
const away = m.away_team_zh || m.away_team
map.set(m.id, `${home} vs ${away}`)
}
return map
}, [matches])
const nameOf = (id: number) => matchName.get(id) ?? `比赛 #${id}`
async function handlePredict(e: React.FormEvent) {
e.preventDefault()
if (!matchId) return
setLoading(true)
setError(null)
setSuccessMsg(null)
try {
await triggerPrediction({ match_id: parseInt(matchId), mode })
setSuccessMsg('预测任务已完成,记录已更新')
await refreshPredictions()
} catch (err: unknown) {
setError(err instanceof Error ? err.message : '预测失败')
} finally {
setLoading(false)
}
}
const unsettled = predictions.filter(p => !p.settled)
async function handleSettle(e: React.FormEvent) {
e.preventDefault()
const pid = parseInt(settleId)
const hg = parseInt(homeGoals)
const ag = parseInt(awayGoals)
if (!pid || isNaN(hg) || isNaN(ag)) return
setSettling(true)
setSettleMsg(null)
try {
await settlePrediction(pid, hg, ag)
setSettleMsg({ kind: 'ok', text: '结算完成,准确率统计已更新' })
setSettleId('')
setHomeGoals('')
setAwayGoals('')
await refreshPredictions()
} catch (err: unknown) {
setSettleMsg({
kind: 'error',
text: err instanceof Error ? err.message : '结算失败',
})
} finally {
setSettling(false)
}
}
const settleTarget = predictions.find(p => p.id === parseInt(settleId))
return (
<div className="space-y-6">
<SectionHeader
title="预测管理"
description="触发 LLM 预测;赛后录入实际比分完成结算,供准确率统计使用。"
/>
<div className="grid gap-6 lg:grid-cols-2">
{/* 新建预测 */}
<Card>
<CardHeader title="新建预测" />
<CardBody>
<form onSubmit={handlePredict} className="space-y-4">
<div>
<label className="mb-1.5 block text-xs text-ink-500">比赛</label>
<select
value={matchId}
onChange={e => setMatchId(e.target.value)}
className="field w-full"
>
<option value="">选择比赛</option>
{matches.map(m => (
<option key={m.id} value={m.id}>
{(m.home_team_zh || m.home_team)} vs {(m.away_team_zh || m.away_team)}
({m.match_date?.slice(5, 10)})
</option>
))}
</select>
</div>
<div>
<label className="mb-1.5 block text-xs text-ink-500">模式</label>
<select
value={mode}
onChange={e => setMode(e.target.value as 'single' | 'multi')}
className="field w-full"
>
<option value="multi">多专家 (5 + 终裁,慢而稳)</option>
<option value="single">单次调用 ()</option>
</select>
</div>
{error && <Alert kind="error" title="预测失败" message={error} onClose={() => setError(null)} />}
{successMsg && (
<Alert kind="ok" title={successMsg} onClose={() => setSuccessMsg(null)} />
)}
<button
type="submit"
disabled={loading || !matchId}
className="btn btn-solid w-full"
>
{loading ? (<><Spinner /> 预测中,多专家模式约需 20-60 </>) : '触发预测'}
</button>
</form>
</CardBody>
</Card>
{/* 结算 */}
<Card>
<CardHeader
title="预测结算"
description="录入实际比分,系统据此统计 1X2 准确率与比分 RMSE"
/>
<CardBody>
{unsettled.length === 0 ? (
<p className="py-6 text-center text-xs text-ink-400">
没有待结算的预测记录。预测完成后可在此录入实际比分。
</p>
) : (
<form onSubmit={handleSettle} className="space-y-4">
<div>
<label className="mb-1.5 block text-xs text-ink-500">预测记录</label>
<select
value={settleId}
onChange={e => setSettleId(e.target.value)}
className="field w-full"
>
<option value="">选择待结算预测({unsettled.length} )</option>
{unsettled.map(p => (
<option key={p.id} value={p.id}>
#{p.id} {nameOf(p.match_id)} · 预测 {p.pred_home_goals ?? '-'}:{p.pred_away_goals ?? '-'}
</option>
))}
</select>
</div>
{settleTarget && (
<p className="border-l-2 border-ink-300 pl-3 text-2xs text-ink-500">
预测:{settleTarget.pred_home_goals ?? '-'} : {settleTarget.pred_away_goals ?? '-'}
({OUTCOME_LABEL[settleTarget.pred_1x2 ?? ''] ?? '?'})
<span className="ml-2">{fmtTime(settleTarget.created_at)}</span>
</p>
)}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1.5 block text-xs text-ink-500">主队实际进球</label>
<input
type="number"
min={0}
max={30}
value={homeGoals}
onChange={e => setHomeGoals(e.target.value)}
className="field w-full"
/>
</div>
<div>
<label className="mb-1.5 block text-xs text-ink-500">客队实际进球</label>
<input
type="number"
min={0}
max={30}
value={awayGoals}
onChange={e => setAwayGoals(e.target.value)}
className="field w-full"
/>
</div>
</div>
{settleMsg && (
<Alert
kind={settleMsg.kind}
title={settleMsg.kind === 'ok' ? '结算完成' : '结算失败'}
message={settleMsg.kind === 'error' ? settleMsg.text : undefined}
onClose={() => setSettleMsg(null)}
/>
)}
<button
type="submit"
disabled={settling || !settleId || homeGoals === '' || awayGoals === ''}
className="btn btn-solid w-full"
>
{settling ? (<><Spinner /> 结算中</>) : '提交结算'}
</button>
</form>
)}
</CardBody>
</Card>
</div>
{/* 最近预测 */}
<Card>
<CardHeader title="预测记录" description="点击行可展开终裁理由与专家摘要" />
<CardBody className="px-0 sm:px-0">
{predictions.length === 0 ? (
<p className="py-10 text-center text-xs text-ink-400">
暂无预测记录,触发预测后将在此显示
</p>
) : (
<div>
{predictions.map(p => {
const okAgents = (p.agent_outputs ?? []).filter(a => a.status === 'ok')
return (
<details key={p.id} className="group border-b border-ink-200 last:border-b-0">
<summary className="flex cursor-pointer list-none flex-wrap items-baseline gap-x-3 gap-y-1 px-4 py-3 transition-colors hover:bg-paper-100 sm:px-5">
<span className="text-2xs tabular-nums text-ink-400">#{p.id}</span>
<span className="text-sm font-medium text-ink-900">{nameOf(p.match_id)}</span>
<span className="font-serif text-sm font-bold tabular-nums text-ink-900">
{p.pred_home_goals ?? '-'}<span className="mx-0.5 font-normal text-ink-300">:</span>{p.pred_away_goals ?? '-'}
</span>
<span className="text-2xs text-ink-500">
{OUTCOME_LABEL[p.pred_1x2 ?? ''] ?? '—'}
{p.subjective_confidence !== null && p.subjective_confidence !== undefined &&
` · ${Math.round(p.subjective_confidence * 100)}%`}
</span>
<span className="ml-auto flex items-baseline gap-3">
{p.settled ? (
<Badge status="success">已结算</Badge>
) : (
<Badge status="pending">未结算</Badge>
)}
<span className="text-2xs tabular-nums text-ink-400">{fmtTime(p.created_at)}</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-4 pb-4 pl-8 sm:px-6 sm:pl-9">
<p className="text-2xs text-ink-500">
{p.mode === 'multi' ? `多专家 · ${okAgents.length}/${p.agent_outputs?.length ?? 0} 路有效` : '单次模式'}
{p.model && <span className="ml-2 font-mono">{p.model}</span>}
</p>
{p.reasoning && (
<blockquote className="border-l-2 border-press pl-4">
<p className="whitespace-pre-wrap font-serif text-sm leading-loose text-ink-700">
{p.reasoning}
</p>
</blockquote>
)}
{p.agent_outputs && p.agent_outputs.length > 0 && (
<ul className="space-y-1">
{p.agent_outputs.map((a, i) => (
<li key={i} className="flex items-baseline gap-2.5 text-xs">
<span className={`inline-block h-1.5 w-1.5 flex-shrink-0 self-center ${a.status === 'ok' ? 'bg-ink-900' : 'bg-ink-300'}`} aria-hidden="true" />
<span className="text-ink-800">{AGENT_LABELS[a.agent] ?? a.agent}</span>
{a.probable_score && (
<span className="font-serif font-bold tabular-nums text-ink-800">{a.probable_score}</span>
)}
<span className="text-2xs text-ink-400">
{a.status === 'ok' ? '' : a.status === 'no_data' ? '无数据' : '失败'}
</span>
</li>
))}
</ul>
)}
{p.settled && (
<p className="border-t border-ink-100 pt-2.5 text-2xs text-ink-500">
实际比分 {p.actual_home_goals ?? '-'} : {p.actual_away_goals ?? '-'}
</p>
)}
</div>
</details>
)
})}
</div>
)}
</CardBody>
</Card>
</div>
)
}