/** * 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 = { h2h: '历史交锋', form: '近期状态', stats: '攻防数据', home_away: '主客因素', injuries: '阵容完整性', } const OUTCOME_LABEL: Record = { '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([]) const [predictions, setPredictions] = useState([]) const [matchId, setMatchId] = useState('') const [mode, setMode] = useState<'single' | 'multi'>('multi') const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [successMsg, setSuccessMsg] = useState(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() 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 (
{/* 新建预测 */}
{error && setError(null)} />} {successMsg && ( setSuccessMsg(null)} /> )}
{/* 结算 */} {unsettled.length === 0 ? (

没有待结算的预测记录。预测完成后可在此录入实际比分。

) : (
{settleTarget && (

预测:{settleTarget.pred_home_goals ?? '-'} : {settleTarget.pred_away_goals ?? '-'} ({OUTCOME_LABEL[settleTarget.pred_1x2 ?? ''] ?? '?'}) {fmtTime(settleTarget.created_at)}

)}
setHomeGoals(e.target.value)} className="field w-full" />
setAwayGoals(e.target.value)} className="field w-full" />
{settleMsg && ( setSettleMsg(null)} /> )} )}
{/* 最近预测 */} {predictions.length === 0 ? (

暂无预测记录,触发预测后将在此显示

) : (
{predictions.map(p => { const okAgents = (p.agent_outputs ?? []).filter(a => a.status === 'ok') return (
#{p.id} {nameOf(p.match_id)} {p.pred_home_goals ?? '-'}:{p.pred_away_goals ?? '-'} {OUTCOME_LABEL[p.pred_1x2 ?? ''] ?? '—'} {p.subjective_confidence !== null && p.subjective_confidence !== undefined && ` · ${Math.round(p.subjective_confidence * 100)}%`} {p.settled ? ( 已结算 ) : ( 未结算 )} {fmtTime(p.created_at)}

{p.mode === 'multi' ? `多专家 · ${okAgents.length}/${p.agent_outputs?.length ?? 0} 路有效` : '单次模式'} {p.model && {p.model}}

{p.reasoning && (

{p.reasoning}

)} {p.agent_outputs && p.agent_outputs.length > 0 && (
    {p.agent_outputs.map((a, i) => (
  • ))}
)} {p.settled && (

实际比分 {p.actual_home_goals ?? '-'} : {p.actual_away_goals ?? '-'}

)}
) })}
)}
) }