352 lines
14 KiB
TypeScript
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>
|
|
)
|
|
}
|