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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user