feat: 后台管理 Admin 仪表盘

新增完整的后台管理系统 (/admin):
- Dashboard: 系统概览、最近采集状态、预测统计
- Collection: 数据采集触发(bzzoiro/understat/injuries)
- Predictions: 预测历史查看、触发新预测
- Backtest: 回测配置与结果查看
- Monitoring: 系统健康、错误日志、死色队列
- Config: API Key 与数据源配置

技术栈: React Router + Tailwind 暗色主题 + TypeScript
文件: 11 个新文件, +21KB JS / +5KB CSS
This commit is contained in:
shangfangjian
2026-09-17 02:00:14 +08:00
parent 1219b4fd18
commit d3284c48c3
19 changed files with 2671 additions and 81 deletions
+278
View File
@@ -0,0 +1,278 @@
/**
* 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'
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 [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])
async function handlePredict(e: React.FormEvent) {
e.preventDefault()
setError(null)
setSuccessMsg(null)
setLoading(true)
try {
const body: PredictRequest = {
league_code: leagueCode || undefined,
mode,
}
await triggerPrediction(body)
setSuccessMsg(`预测任务已启动 (${mode === 'multi' ? '五路专家模式' : '单一模型模式'})`)
loadData()
} 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 : '结算失败')
} finally {
setLoading(false)
}
}
return (
<div className="space-y-6">
<SectionHeader
title="预测管理"
description="触发 LLM 预测任务,管理预测历史和评估结算"
/>
{/* ── 评估摘要 ── */}
{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-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>
<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>
))}
</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>
</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
}