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:
shangfangjian
2026-09-17 02:11:50 +08:00
parent d3284c48c3
commit 6680da7d61
9 changed files with 572 additions and 1642 deletions
+76 -244
View File
@@ -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
}