feat: 管理界面报刊风统一改造 — 移除独立暗色主题,预测/回测/监控/配置页增强

This commit is contained in:
shangfangjian
2026-09-18 10:42:14 +08:00
parent 91e406f5ee
commit cf0751d1ff
15 changed files with 1275 additions and 828 deletions
+192 -88
View File
@@ -1,16 +1,55 @@
/**
* Admin 后台 - 回测管理页面
* Admin 后台 - 回测管理页面(报刊风)
*
* 响应式布局: 移动端单列,桌面端双列
* 触摸友好: 按钮最小 44px 高度
* 功能:
* - 回测配置: 联赛(下拉)、日期范围、场数、模式
* - 结果汇总: 已评分数、1X2 准确率、比分 RMSE、平均置信度
* - 逐场明细: 实际比分 vs 预测比分,正误标记
* - 模型评估: 各模型历史准确率(/eval/summary)
*
* 注意: 回测会对每场完赛比赛各发起一次 LLM 预测,成本高,需管理员密钥。
*/
import { useState } from 'react'
import { triggerBacktest, fetchEvalSummary } from '../dal'
import type { BacktestRequest, EvalSummary } from '../types'
import { Card, CardBody, CardHeader, Badge, SectionHeader } from '../components'
import { useEffect, useState } from 'react'
import { triggerBacktest, fetchEvalSummary, fetchLeagues } from '../dal'
import type { BacktestRequest, EvalSummary, League } from '../types'
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
interface BacktestResultRow {
match_id: number
league_code?: string | null
home_team: string
away_team: string
match_date?: string | null
actual_score: string
actual_1x2?: string
pred_home?: number | null
pred_away?: number | null
pred_1x2?: string | null
subjective_confidence?: number | null
correct_1x2: boolean
}
interface BacktestResponse {
summary: {
total: number
scored: number
accuracy_1x2?: number
avg_score_rmse?: number
avg_subjective_confidence?: number
}
results: BacktestResultRow[]
}
const OUTCOME_LABEL: Record<string, string> = { '1': '主胜', X: '平局', '2': '客胜' }
function fmtDate(s?: string | null): string {
if (!s) return '—'
return s.slice(0, 10)
}
export default function BacktestPage() {
const [leagues, setLeagues] = useState<League[]>([])
const [leagueId, setLeagueId] = useState('')
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
@@ -18,8 +57,23 @@ export default function BacktestPage() {
const [mode, setMode] = useState<'single' | 'multi'>('single')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [result, setResult] = useState<any>(null)
const [result, setResult] = useState<BacktestResponse | null>(null)
const [evalSummary, setEvalSummary] = useState<EvalSummary | null>(null)
const [evalLoading, setEvalLoading] = useState(false)
useEffect(() => {
fetchLeagues().then(setLeagues)
loadEval()
}, [])
async function loadEval() {
setEvalLoading(true)
try {
setEvalSummary(await fetchEvalSummary())
} finally {
setEvalLoading(false)
}
}
async function handleBacktest(e: React.FormEvent) {
e.preventDefault()
@@ -34,8 +88,8 @@ export default function BacktestPage() {
limit,
mode,
}
const res = await triggerBacktest(req)
setResult(res)
const res = await triggerBacktest(req as BacktestRequest)
setResult(res as unknown as BacktestResponse)
} catch (err: unknown) {
setError(err instanceof Error ? err.message : '回测失败')
} finally {
@@ -43,16 +97,13 @@ export default function BacktestPage() {
}
}
async function loadEval() {
const summary = await fetchEvalSummary()
setEvalSummary(summary)
}
const summary = result?.summary
return (
<div className="space-y-6">
<SectionHeader
title="回测管理"
description="在历史数据上运行预测并评估准确率"
description="在历史数据上运行预测并评估准确率。逐场调用 LLM,成本高,建议先小场次试跑。"
/>
<div className="grid gap-6 lg:grid-cols-2">
@@ -62,138 +113,191 @@ export default function BacktestPage() {
<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"
<label className="mb-1.5 block text-xs text-ink-500"></label>
<select
value={leagueId}
onChange={e => setLeagueId(e.target.value)}
placeholder="留空=全部"
className="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2.5 text-white min-h-[44px]"
/>
className="field w-full"
>
<option value=""></option>
{leagues.map(l => (
<option key={l.id ?? l.code} value={String(l.id)}>
{l.name_zh || l.name}
</option>
))}
</select>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-400"></label>
<label className="mb-1.5 block text-xs text-ink-500"></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.5 text-white min-h-[44px]"
className="field w-full"
/>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-400"></label>
<label className="mb-1.5 block text-xs text-ink-500"></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.5 text-white min-h-[44px]"
className="field w-full"
/>
</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-md border border-gray-700 bg-gray-800 px-3 py-2.5 text-white min-h-[44px]"
/>
</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 'single' | 'multi')}
className="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2.5 text-white min-h-[44px]"
>
<option value="single"> ()</option>
<option value="multi"> Agent ()</option>
</select>
</div>
{error && (
<div className="rounded-md bg-red-500/10 p-3 text-sm text-red-400 border border-red-500/30">
{error}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1.5 block text-xs text-ink-500"></label>
<input
type="number"
min={1}
max={200}
value={limit}
onChange={e => setLimit(parseInt(e.target.value) || 20)}
className="field w-full"
/>
</div>
)}
<div>
<label className="mb-1.5 block text-xs text-ink-500"></label>
<select
value={mode}
onChange={e => setMode(e.target.value as 'single' | 'multi')}
className="field w-full"
>
<option value="single"> ()</option>
<option value="multi"> (,)</option>
</select>
</div>
</div>
<button
type="submit"
disabled={loading}
className="w-full rounded-md bg-blue-600 px-4 py-2.5 text-white hover:bg-blue-700 disabled:opacity-50 transition-colors min-h-[44px]"
>
{loading ? '回测中...' : '开始回测'}
{error && <Alert kind="error" title="回测失败" message={error} onClose={() => setError(null)} />}
<button type="submit" disabled={loading} className="btn btn-solid w-full">
{loading ? (<><Spinner /> ,</>) : '开始回测'}
</button>
</form>
</CardBody>
</Card>
{/* 结果区域 */}
{/* 结果汇总 */}
<div className="space-y-6">
{result && (
{summary && (
<Card>
<CardHeader title="回测结果" />
<CardBody>
<div className="grid grid-cols-2 gap-3">
<div className="rounded-lg bg-gray-800 p-4 text-center">
<div className="text-2xl font-bold text-white">{result.scored}/{result.total}</div>
<div className="text-xs text-gray-400 mt-1"></div>
</div>
<div className="rounded-lg bg-gray-800 p-4 text-center">
<div className="text-2xl font-bold text-blue-400">
{result.accuracy_1x2?.toFixed(1) ?? '—'}%
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div>
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">
{summary.scored}/{summary.total}
</div>
<div className="text-xs text-gray-400 mt-1">1X2 </div>
<div className="mt-1 text-2xs text-ink-400"> / </div>
</div>
<div>
<div className="font-serif text-2xl font-bold tabular-nums text-press">
{summary.accuracy_1x2 !== undefined && summary.accuracy_1x2 !== null
? `${summary.accuracy_1x2.toFixed(1)}%`
: '—'}
</div>
<div className="mt-1 text-2xs text-ink-400">1X2 </div>
</div>
<div>
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">
{summary.avg_score_rmse !== undefined && summary.avg_score_rmse !== null
? summary.avg_score_rmse.toFixed(2)
: '—'}
</div>
<div className="mt-1 text-2xs text-ink-400"> RMSE</div>
</div>
<div>
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">
{summary.avg_subjective_confidence !== undefined && summary.avg_subjective_confidence !== null
? `${Math.round(summary.avg_subjective_confidence * 100)}%`
: '—'}
</div>
<div className="mt-1 text-2xs text-ink-400"></div>
</div>
</div>
</CardBody>
</Card>
)}
{/* 模型评估 */}
<Card>
<CardHeader
title="模型评估"
description="已结算预测的准确率统计"
action={
<button
onClick={loadEval}
className="rounded px-2 py-1 text-xs text-blue-400 hover:bg-gray-800 min-h-[44px] min-w-[44px]"
>
<button onClick={loadEval} disabled={evalLoading} className="btn btn-sm">
{evalLoading ? (<><Spinner /> </>) : '刷新'}
</button>
}
/>
<CardBody>
{!evalSummary ? (
<div className="text-center py-8 text-sm text-gray-500">
<p>"刷新"</p>
</div>
) : evalSummary.summary?.length > 0 ? (
<div className="space-y-2">
{evalSummary.summary.map((s: any, i: number) => (
{evalSummary && evalSummary.summary?.length > 0 ? (
<div className="space-y-2.5">
{evalSummary.summary.map((s, i) => (
<div
key={i}
className="flex flex-col sm:flex-row sm:items-center justify-between rounded-lg border border-gray-800 p-3 gap-2"
className="flex flex-col gap-1 border-b border-ink-200 pb-2.5 last:border-b-0 last:pb-0 sm:flex-row sm:items-center sm:justify-between"
>
<span className="text-sm text-gray-300">{s.provider}/{s.model}</span>
<Badge status="info">
{s.accuracy?.toFixed(1)}% ({s.correct}/{s.total})
</Badge>
<span className="font-mono text-xs text-ink-700">{s.provider}/{s.model}</span>
<span className="text-2xs tabular-nums text-ink-500">
<span className="font-serif text-sm font-bold text-ink-900">
{s.accuracy_1x2 !== undefined && s.accuracy_1x2 !== null ? `${s.accuracy_1x2.toFixed(1)}%` : '—'}
</span>
<span className="ml-2">{s.total} </span>
{s.avg_score_rmse != null && <span className="ml-2">RMSE {s.avg_score_rmse.toFixed(2)}</span>}
</span>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-sm text-gray-500">
<p></p>
</div>
<p className="py-4 text-center text-xs text-ink-400">
,
</p>
)}
</CardBody>
</Card>
</div>
</div>
{/* 逐场明细 */}
{result && result.results?.length > 0 && (
<Card>
<CardHeader title="逐场明细" />
<CardBody className="px-0 sm:px-0">
<div>
{result.results.map(r => (
<div
key={r.match_id}
className="flex flex-col gap-1.5 border-b border-ink-200 px-4 py-3 last:border-b-0 sm:flex-row sm:items-center sm:gap-3 sm:px-5"
>
<span className="w-24 flex-shrink-0 text-2xs tabular-nums text-ink-400">
{fmtDate(r.match_date)}
</span>
<span className="min-w-0 flex-1 truncate text-sm text-ink-800">
{r.home_team} vs {r.away_team}
</span>
<span className="flex-shrink-0 text-xs tabular-nums text-ink-500">
<span className="font-serif font-bold text-ink-900">{r.actual_score}</span>
<span className="mx-2 text-ink-200">|</span>
<span className={`font-serif font-bold ${r.correct_1x2 ? 'text-ink-900' : 'text-ink-400'}`}>
{r.pred_home ?? '-'}:{r.pred_away ?? '-'}
</span>
</span>
<span className="flex-shrink-0 sm:w-16 sm:text-right">
{r.correct_1x2 ? <Badge status="success"></Badge> : <Badge status="loss"></Badge>}
</span>
</div>
))}
</div>
</CardBody>
</Card>
)}
</div>
)
}