fix: 修复 Admin 后台多个 bug
- api.ts: 修复 URL 拼接 bug(缺少 API_BASE) 和 204 判断逻辑 - dal.ts: 修复 fetchDashboard 类型错乱,修正 API 端点 - types.ts: 对齐后端 Pydantic 模型 - 所有页面: Badge variant→status, CardHeader children→action - 移除不存在的后端端点调用
This commit is contained in:
@@ -1,89 +1,38 @@
|
||||
/**
|
||||
* Admin 后台 - 预测管理页面
|
||||
*
|
||||
* 功能:
|
||||
* - 触发手动预测(选择比赛或联赛)
|
||||
* - 查看预测历史记录
|
||||
* - 评估结算(回填实际结果)
|
||||
* - 评估统计摘要
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import {
|
||||
triggerPrediction,
|
||||
fetchPredictionHistory,
|
||||
triggerSettle,
|
||||
fetchEvalSummary,
|
||||
fetchLeagues,
|
||||
} from '../dal'
|
||||
import type { PredictRequest, PredictionHistoryItem, League, EvalSummary } from '../types'
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Badge,
|
||||
DataTable,
|
||||
EmptyState,
|
||||
} from '../components'
|
||||
import { SectionHeader } from '../components'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { triggerPrediction, fetchPredictions, fetchMatches } from '../dal'
|
||||
import type { Match, Prediction } from '../types'
|
||||
import { Card, CardBody, CardHeader, Badge } from '../components'
|
||||
|
||||
export default function PredictionsPage() {
|
||||
const [leagues, setLeagues] = useState<League[]>([])
|
||||
const [history, setHistory] = useState<PredictionHistoryItem[]>([])
|
||||
const [evalData, setEvalData] = useState<EvalSummary | null>(null)
|
||||
const [leagueCode, setLeagueCode] = useState('')
|
||||
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 [page, setPage] = useState(1)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [successMsg, setSuccessMsg] = useState<string | null>(null)
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
const [lg, hist, ev] = await Promise.all([
|
||||
fetchLeagues(),
|
||||
fetchPredictionHistory(page),
|
||||
fetchEvalSummary(),
|
||||
])
|
||||
setLeagues(lg)
|
||||
setHistory(hist.items)
|
||||
setEvalData(ev)
|
||||
}, [page])
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [loadData])
|
||||
fetchPredictions(20).then(setPredictions)
|
||||
fetchMatches({ status: 'scheduled', limit: 20 }).then(d => setMatches(d.items))
|
||||
}, [])
|
||||
|
||||
async function handlePredict(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!matchId) return
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setSuccessMsg(null)
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const body: PredictRequest = {
|
||||
league_code: leagueCode || undefined,
|
||||
mode,
|
||||
}
|
||||
await triggerPrediction(body)
|
||||
setSuccessMsg(`预测任务已启动 (${mode === 'multi' ? '五路专家模式' : '单一模型模式'})`)
|
||||
loadData()
|
||||
await triggerPrediction({ match_id: parseInt(matchId), mode })
|
||||
setSuccessMsg('预测任务已提交')
|
||||
fetchPredictions(20).then(setPredictions)
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : '预测触发失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSettle() {
|
||||
setError(null)
|
||||
setSuccessMsg(null)
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await triggerSettle()
|
||||
setSuccessMsg(`结算完成: 成功 ${res.settled_count}, 失败 ${res.failed_count}`)
|
||||
loadData()
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : '结算失败')
|
||||
setError(err instanceof Error ? err.message : '预测失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -91,188 +40,71 @@ export default function PredictionsPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SectionHeader
|
||||
title="预测管理"
|
||||
description="触发 LLM 预测任务,管理预测历史和评估结算"
|
||||
/>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">预测管理</h2>
|
||||
<p className="mt-1 text-sm text-gray-400">触发 LLM 足球预测</p>
|
||||
</div>
|
||||
|
||||
{/* ── 评估摘要 ── */}
|
||||
{evalData && (
|
||||
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<EvalStat label="已结算总数" value={evalData.total_settled} />
|
||||
<EvalStat label="1x2 准确率" value={`${(evalData.accuracy_1x2 * 100).toFixed(1)}%`} />
|
||||
<EvalStat label="MAE 进球" value={evalData.mae_goals.toFixed(2)} />
|
||||
<EvalStat label="校准度" value={`${(evalData.calibration * 100).toFixed(1)}%`} />
|
||||
</div>
|
||||
)}
|
||||
<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 font-medium text-gray-400">比赛</label>
|
||||
<select value={matchId} onChange={e => setMatchId(e.target.value)}
|
||||
className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-white">
|
||||
<option value="">选择比赛</option>
|
||||
{matches.map(m => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.home_team} vs {m.away_team} ({m.match_date?.slice(0, 10)})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{/* ── 预测触发表单 ── */}
|
||||
<div className="lg:col-span-1">
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="触发预测"
|
||||
action={
|
||||
<button
|
||||
onClick={handleSettle}
|
||||
disabled={loading}
|
||||
className="rounded-md border border-gray-700 px-3 py-1 text-xs text-gray-400 transition-colors hover:border-emerald-500/50 hover:text-emerald-400 disabled:opacity-50"
|
||||
>
|
||||
结算
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<CardBody>
|
||||
<form onSubmit={handlePredict} className="space-y-4">
|
||||
{/* 联赛选择 */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">联赛</label>
|
||||
<select
|
||||
value={leagueCode}
|
||||
onChange={e => setLeagueCode(e.target.value)}
|
||||
className="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value="">全部联赛</option>
|
||||
{leagues.map(l => (
|
||||
<option key={l.code} value={l.code}>
|
||||
{l.name_zh ?? l.name} ({l.code})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">模式</label>
|
||||
<select value={mode} onChange={e => setMode(e.target.value as any)}
|
||||
className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-white">
|
||||
<option value="multi">多 Agent (5 专家 + 终裁)</option>
|
||||
<option value="single">单次调用</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 预测模式 */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">模式</label>
|
||||
<div className="flex gap-2">
|
||||
{(['multi', 'single'] as const).map(m => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => setMode(m)}
|
||||
className={`flex-1 rounded-md border px-3 py-2 text-xs transition-colors ${
|
||||
mode === m
|
||||
? 'border-blue-500/50 bg-blue-500/10 text-blue-400'
|
||||
: 'border-gray-700 text-gray-500 hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
{m === 'multi' ? '五路专家' : '单一模型'}
|
||||
</button>
|
||||
))}
|
||||
{error && <div className="rounded bg-red-500/10 p-3 text-sm text-red-400">{error}</div>}
|
||||
{successMsg && <div className="rounded bg-green-500/10 p-3 text-sm text-green-400">{successMsg}</div>}
|
||||
|
||||
<button type="submit" disabled={loading || !matchId}
|
||||
className="w-full rounded bg-blue-600 py-2 text-white hover:bg-blue-700 disabled:opacity-50">
|
||||
{loading ? '预测中...' : '触发预测'}
|
||||
</button>
|
||||
</form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="最近预测" />
|
||||
<CardBody>
|
||||
{predictions.length === 0 ? (
|
||||
<p className="text-gray-400">暂无预测记录</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{predictions.slice(0, 10).map(p => (
|
||||
<div key={p.id} className="flex items-center justify-between rounded border border-gray-700 p-2">
|
||||
<span className="text-sm text-gray-300">
|
||||
Match #{p.match_id} · {p.model}
|
||||
</span>
|
||||
<Badge status={p.settled ? 'success' : 'warning'}>
|
||||
{p.pred_1x2 || '?'} · {p.subjective_confidence ? `${(p.subjective_confidence * 100).toFixed(0)}%` : '—'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{successMsg && (
|
||||
<div className="rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-xs text-emerald-400">
|
||||
{successMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{loading ? '处理中...' : '启动预测'}
|
||||
</button>
|
||||
</form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* ── 预测历史 ── */}
|
||||
<div className="lg:col-span-2">
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="预测历史"
|
||||
action={
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page <= 1}
|
||||
className="rounded border border-gray-700 px-2 py-0.5 text-xs text-gray-400 hover:border-gray-600 disabled:opacity-30"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<span className="px-2 text-xs text-gray-500">{page}</span>
|
||||
<button
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
className="rounded border border-gray-700 px-2 py-0.5 text-xs text-gray-400 hover:border-gray-600"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<CardBody className="p-0">
|
||||
<DataTable
|
||||
columns={[
|
||||
{
|
||||
key: 'home_team',
|
||||
label: '主队',
|
||||
render: (row: PredictionHistoryItem) => row.home_team ?? '—',
|
||||
},
|
||||
{
|
||||
key: 'away_team',
|
||||
label: '客队',
|
||||
render: (row: PredictionHistoryItem) => row.away_team ?? '—',
|
||||
},
|
||||
{
|
||||
key: 'pred',
|
||||
label: '预测',
|
||||
width: '80px',
|
||||
render: (row: PredictionHistoryItem) =>
|
||||
row.pred_home_goals !== null && row.pred_away_goals !== null
|
||||
? `${row.pred_home_goals} - ${row.pred_away_goals}`
|
||||
: '—',
|
||||
},
|
||||
{
|
||||
key: 'pred_1x2',
|
||||
label: '1X2',
|
||||
width: '60px',
|
||||
render: (row: PredictionHistoryItem) => (row.pred_1x2 ? label1x2(row.pred_1x2) : '—'),
|
||||
},
|
||||
{
|
||||
key: 'confidence',
|
||||
label: '置信',
|
||||
width: '70px',
|
||||
render: (row: PredictionHistoryItem) =>
|
||||
row.confidence !== null && row.confidence !== undefined
|
||||
? `${Math.round(row.confidence * 100)}%`
|
||||
: '—',
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: '状态',
|
||||
width: '80px',
|
||||
render: (row: PredictionHistoryItem) => <Badge status={row.status}>{row.status}</Badge>,
|
||||
},
|
||||
]}
|
||||
data={history}
|
||||
rowKey={(row: PredictionHistoryItem) => row.id}
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EvalStat({ label, value }: { label: string; value: string | number }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-800 bg-gray-900 px-4 py-3">
|
||||
<div className="text-xs text-gray-500">{label}</div>
|
||||
<div className="mt-1 text-lg font-semibold text-gray-100 tabular-nums">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function label1x2(v: string): string {
|
||||
return { '1': '主胜', X: '平局', '2': '客胜' }[v] ?? v
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user