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
+107 -294
View File
@@ -1,222 +1,140 @@
/**
* Admin 后台 - 回测管理页面
*
* 功能:
* - 配置回测参数(策略、联赛、日期范围、初始资金)
* - 触发回测任务
* - 查看回测结果(收益率、最大回撤、夏普比率等)
* - 交易记录明细
*/
import { useEffect, useState } from 'react'
import { triggerBacktest, fetchBacktestHistory, fetchLeagues } from '../dal'
import type { BacktestRequest, BacktestResult, BacktestTrade, League } from '../types'
import {
Card,
CardBody,
CardHeader,
Badge,
ProgressBar,
DataTable,
EmptyState,
} from '../components'
import { SectionHeader } from '../components'
const STRATEGIES = [
{ value: 'confidence_weighted', label: '置信度加权', desc: '按预测置信度调整仓位' },
{ value: 'kelly', label: 'Kelly 准则', desc: 'Kelly 公式优化投注比例' },
{ value: 'flat', label: '固定金额', desc: '每次固定金额投注' },
]
import { useState } from 'react'
import { triggerBacktest, fetchEvalSummary } from '../dal'
import type { BacktestRequest, EvalSummary } from '../types'
import { Card, CardBody, CardHeader, Badge } from '../components'
export default function BacktestPage() {
const [leagues, setLeagues] = useState<League[]>([])
const [tasks, setTasks] = useState<BacktestResult[]>([])
const [strategy, setStrategy] = useState('confidence_weighted')
const [selectedLeagues, setSelectedLeagues] = useState<string[]>(['E0'])
const [leagueId, setLeagueId] = useState('')
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [bankroll, setBankroll] = useState(1000)
const [limit, setLimit] = useState(20)
const [mode, setMode] = useState<'single' | 'multi'>('single')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [successMsg, setSuccessMsg] = useState<string | null>(null)
const [result, setResult] = useState<any>(null)
const [evalSummary, setEvalSummary] = useState<EvalSummary | null>(null)
useEffect(() => {
Promise.all([fetchLeagues(), fetchBacktestHistory()]).then(([lg, ts]) => {
setLeagues(lg)
setTasks(ts)
})
}, [])
async function handleSubmit(e: React.FormEvent) {
async function handleBacktest(e: React.FormEvent) {
e.preventDefault()
setError(null)
setSuccessMsg(null)
setLoading(true)
setError(null)
setResult(null)
try {
const body: BacktestRequest = {
strategy,
league_codes: selectedLeagues,
date_from: dateFrom || '2024-01-01',
date_to: dateTo || new Date().toISOString().split('T')[0],
initial_bankroll: bankroll,
const req: BacktestRequest = {
league_id: leagueId ? parseInt(leagueId) : undefined,
date_from: dateFrom || undefined,
date_to: dateTo || undefined,
limit,
mode,
}
const res = await triggerBacktest(body)
setSuccessMsg(`回测任务已启动: ${res.task_id}`)
// 刷新列表
const ts = await fetchBacktestHistory()
setTasks(ts)
const res = await triggerBacktest(req)
setResult(res)
} catch (err: unknown) {
setError(err instanceof Error ? err.message : '回测触发失败')
setError(err instanceof Error ? err.message : '回测失败')
} finally {
setLoading(false)
}
}
function toggleLeague(code: string) {
setSelectedLeagues(prev =>
prev.includes(code) ? prev.filter(c => c !== code) : [...prev, code],
)
async function loadEval() {
const summary = await fetchEvalSummary()
setEvalSummary(summary)
}
return (
<div className="space-y-6">
<SectionHeader
title="回测管理"
description="配置回测参数,验证预测策略在历史数据上的表现"
/>
<div>
<h2 className="text-xl font-semibold text-white"></h2>
<p className="mt-1 text-sm text-gray-400"></p>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader title="回测配置" />
<CardBody>
<form onSubmit={handleBacktest} className="space-y-4">
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-400"> ID ()</label>
<input type="number" value={leagueId} onChange={e => setLeagueId(e.target.value)}
placeholder="留空=全部" className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-white" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-400"></label>
<input type="date" value={dateFrom} onChange={e => setDateFrom(e.target.value)}
className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-white" />
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-400"></label>
<input type="date" value={dateTo} onChange={e => setDateTo(e.target.value)}
className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-white" />
</div>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-400"></label>
<input type="number" value={limit} onChange={e => setLimit(parseInt(e.target.value) || 20)}
className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-white" />
</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="single"> ()</option>
<option value="multi"> Agent ()</option>
</select>
</div>
{error && <div className="rounded bg-red-500/10 p-3 text-sm text-red-400">{error}</div>}
<button type="submit" disabled={loading}
className="w-full rounded bg-blue-600 py-2 text-white hover:bg-blue-700 disabled:opacity-50">
{loading ? '回测中...' : '开始回测'}
</button>
</form>
</CardBody>
</Card>
<div className="space-y-6">
{result && (
<Card>
<CardHeader title="回测结果" />
<CardBody>
<div className="grid grid-cols-2 gap-4">
<div className="rounded bg-gray-800 p-3 text-center">
<div className="text-2xl font-bold text-white">{result.scored}/{result.total}</div>
<div className="text-xs text-gray-400"></div>
</div>
<div className="rounded bg-gray-800 p-3 text-center">
<div className="text-2xl font-bold text-blue-400">
{result.accuracy_1x2?.toFixed(1) ?? '—'}%
</div>
<div className="text-xs text-gray-400">1X2 </div>
</div>
</div>
</CardBody>
</Card>
)}
<div className="grid gap-6 lg:grid-cols-5">
{/* ── 回测配置表单 ── */}
<div className="lg:col-span-2">
<Card>
<CardHeader title="回测配置" />
<CardHeader title="模型评估" action={<button onClick={loadEval} className="text-xs text-blue-400 hover:underline"></button>} />
<CardBody>
<form onSubmit={handleSubmit} className="space-y-4">
{/* 策略选择 */}
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-400"></label>
<div className="space-y-2">
{STRATEGIES.map(s => (
<label
key={s.value}
className={`flex cursor-pointer items-start gap-3 rounded-md border p-3 transition-colors ${
strategy === s.value
? 'border-blue-500/50 bg-blue-500/10'
: 'border-gray-700 hover:border-gray-600'
}`}
>
<input
type="radio"
name="strategy"
value={s.value}
checked={strategy === s.value}
onChange={e => setStrategy(e.target.value)}
className="mt-0.5 accent-blue-500"
/>
<div>
<div className="text-sm font-medium text-gray-200">{s.label}</div>
<div className="text-xs text-gray-500">{s.desc}</div>
</div>
</label>
))}
</div>
</div>
{/* 联赛多选 */}
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-400">
()
</label>
<div className="flex flex-wrap gap-2">
{leagues.map(l => (
<button
key={l.code}
type="button"
onClick={() => toggleLeague(l.code)}
className={`rounded-md border px-2.5 py-1 text-xs transition-colors ${
selectedLeagues.includes(l.code)
? 'border-blue-500/50 bg-blue-500/10 text-blue-400'
: 'border-gray-700 text-gray-500 hover:border-gray-600'
}`}
>
{l.name_zh ?? l.code}
</button>
))}
</div>
</div>
{/* 日期范围 */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-400"></label>
<input
type="date"
value={dateFrom}
onChange={e => setDateFrom(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"
/>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-400"></label>
<input
type="date"
value={dateTo}
onChange={e => setDateTo(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"
/>
</div>
</div>
{/* 初始资金 */}
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-400"></label>
<input
type="number"
value={bankroll}
onChange={e => setBankroll(Number(e.target.value))}
min={100}
step={100}
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"
/>
</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 || selectedLeagues.length === 0}
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-3">
<Card>
<CardHeader title="回测任务" />
<CardBody className="p-0">
{tasks.length === 0 ? (
<EmptyState text="暂无回测任务" />
) : (
<div className="divide-y divide-gray-800">
{tasks.map(task => (
<BacktestResultRow key={task.task_id} task={task} />
{!evalSummary ? (
<p className="text-gray-400">"刷新"</p>
) : evalSummary.summary?.length > 0 ? (
<div className="space-y-2">
{evalSummary.summary.map((s: any, i: number) => (
<div key={i} className="flex items-center justify-between rounded border border-gray-700 p-2">
<span className="text-sm text-gray-300">{s.provider}/{s.model}</span>
<Badge status="info">{s.accuracy?.toFixed(1)}% ({s.correct}/{s.total})</Badge>
</div>
))}
</div>
) : (
<p className="text-gray-400"></p>
)}
</CardBody>
</Card>
@@ -225,108 +143,3 @@ export default function BacktestPage() {
</div>
)
}
function BacktestResultRow({ task }: { task: BacktestResult }) {
const m = task.metrics
return (
<div className="px-5 py-4">
{/* 头部 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-gray-500">{task.task_id.slice(0, 8)}</span>
<Badge status={task.status}>{task.status}</Badge>
</div>
{m && (
<span
className={`text-sm font-semibold tabular-nums ${
m.profit_loss >= 0 ? 'text-emerald-400' : 'text-red-400'
}`}
>
{m.profit_loss >= 0 ? '+' : ''}
{m.profit_loss.toFixed(0)} ({(m.roi * 100).toFixed(1)}%)
</span>
)}
</div>
{/* 进度条 */}
{task.status === 'running' && (
<div className="mt-2">
<ProgressBar value={task.progress} />
</div>
)}
{/* 指标网格 */}
{m && (
<div className="mt-3 grid grid-cols-3 gap-3 lg:grid-cols-5">
<MiniStat label="交易数" value={m.total_trades} />
<MiniStat label="胜率" value={`${(m.win_rate * 100).toFixed(1)}%`} />
<MiniStat label="ROI" value={`${(m.roi * 100).toFixed(1)}%`} />
<MiniStat label="最大回撤" value={`${(m.max_drawdown * 100).toFixed(1)}%`} />
<MiniStat label="夏普比率" value={m.sharpe_ratio?.toFixed(2) ?? '—'} />
</div>
)}
{/* 错误 */}
{task.error_message && (
<p className="mt-2 text-xs text-red-400">{task.error_message}</p>
)}
{/* 交易明细 */}
{task.trades && task.trades.length > 0 && (
<div className="mt-3">
<details>
<summary className="cursor-pointer text-xs text-gray-500 hover:text-gray-300">
({task.trades.length} )
</summary>
<div className="mt-2">
<DataTable
columns={[
{
key: 'match',
label: '比赛',
render: (row: BacktestTrade) => `${row.home_team} vs ${row.away_team}`,
},
{ key: 'bet_type', label: '类型', width: '60px' },
{ key: 'stake', label: '投注', width: '70px' },
{ key: 'odds', label: '赔率', width: '60px' },
{
key: 'result',
label: '结果',
width: '60px',
render: (row: BacktestTrade) => <Badge status={row.result}>{row.result}</Badge>,
},
{
key: 'profit',
label: '盈亏',
width: '80px',
render: (row: BacktestTrade) => (
<span
className={
row.profit >= 0 ? 'text-emerald-400' : 'text-red-400'
}
>
{row.profit >= 0 ? '+' : ''}
{row.profit.toFixed(1)}
</span>
),
},
]}
data={task.trades}
rowKey={(row: BacktestTrade) => row.match_id}
/>
</div>
</details>
</div>
)}
</div>
)
}
function MiniStat({ label, value }: { label: string; value: string | number }) {
return (
<div className="rounded border border-gray-800 px-2 py-1.5">
<div className="text-[10px] text-gray-600">{label}</div>
<div className="text-xs font-medium text-gray-300 tabular-nums">{value}</div>
</div>
)
}