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,332 @@
|
||||
/**
|
||||
* 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: '每次固定金额投注' },
|
||||
]
|
||||
|
||||
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 [dateFrom, setDateFrom] = useState('')
|
||||
const [dateTo, setDateTo] = useState('')
|
||||
const [bankroll, setBankroll] = useState(1000)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [successMsg, setSuccessMsg] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([fetchLeagues(), fetchBacktestHistory()]).then(([lg, ts]) => {
|
||||
setLeagues(lg)
|
||||
setTasks(ts)
|
||||
})
|
||||
}, [])
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setSuccessMsg(null)
|
||||
setLoading(true)
|
||||
|
||||
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 res = await triggerBacktest(body)
|
||||
setSuccessMsg(`回测任务已启动: ${res.task_id}`)
|
||||
// 刷新列表
|
||||
const ts = await fetchBacktestHistory()
|
||||
setTasks(ts)
|
||||
} catch (err: unknown) {
|
||||
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],
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SectionHeader
|
||||
title="回测管理"
|
||||
description="配置回测参数,验证预测策略在历史数据上的表现"
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-5">
|
||||
{/* ── 回测配置表单 ── */}
|
||||
<div className="lg:col-span-2">
|
||||
<Card>
|
||||
<CardHeader title="回测配置" />
|
||||
<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} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* Admin 后台 - 数据采集页面
|
||||
*
|
||||
* 功能:
|
||||
* - 选择数据源(bzzoiro / understat / injuries)
|
||||
* - 选择联赛、日期范围
|
||||
* - 触发采集任务
|
||||
* - 实时显示任务进度
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { triggerCollection, fetchCollectionTasks, fetchLeagues } from '../dal'
|
||||
import type { CollectionRequest, CollectionTask, League } from '../types'
|
||||
import { Card, CardBody, CardHeader, Badge, ProgressBar, EmptyState } from '../components'
|
||||
import { SectionHeader } from '../components'
|
||||
|
||||
const SOURCES = [
|
||||
{ value: 'bzzoiro', label: 'Bzzoiro', desc: '历史赛程与比分数据' },
|
||||
{ value: 'understat', label: 'Understat', desc: '进阶统计数据(xG/xA)' },
|
||||
{ value: 'injuries', label: 'Injuries', desc: '球员伤停信息' },
|
||||
] as const
|
||||
|
||||
export default function CollectionPage() {
|
||||
const [leagues, setLeagues] = useState<League[]>([])
|
||||
const [tasks, setTasks] = useState<CollectionTask[]>([])
|
||||
const [source, setSource] = useState<string>('bzzoiro')
|
||||
const [leagueCode, setLeagueCode] = useState<string>('')
|
||||
const [dateFrom, setDateFrom] = useState('')
|
||||
const [dateTo, setDateTo] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [successMsg, setSuccessMsg] = useState<string | null>(null)
|
||||
|
||||
// 加载联赛列表和任务历史
|
||||
const loadData = useCallback(async () => {
|
||||
const [lg, ts] = await Promise.all([fetchLeagues(), fetchCollectionTasks()])
|
||||
setLeagues(lg)
|
||||
setTasks(ts)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
// 自动刷新任务状态(每 5 秒)
|
||||
const timer = setInterval(loadData, 5000)
|
||||
return () => clearInterval(timer)
|
||||
}, [loadData])
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setSuccessMsg(null)
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const body: CollectionRequest = {
|
||||
source: source as CollectionRequest['source'],
|
||||
league_code: leagueCode || undefined,
|
||||
date_from: dateFrom || undefined,
|
||||
date_to: dateTo || undefined,
|
||||
}
|
||||
const res = await triggerCollection(body)
|
||||
setSuccessMsg(`任务已创建: ${res.message}`)
|
||||
loadData()
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : '采集触发失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SectionHeader
|
||||
title="数据采集"
|
||||
description="触发数据源采集任务,支持联赛筛选和日期范围"
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-5">
|
||||
{/* ── 采集配置表单 ── */}
|
||||
<div className="lg:col-span-2">
|
||||
<Card>
|
||||
<CardHeader title="新建采集任务" />
|
||||
<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">
|
||||
{SOURCES.map(s => (
|
||||
<label
|
||||
key={s.value}
|
||||
className={`flex cursor-pointer items-start gap-3 rounded-md border p-3 transition-colors ${
|
||||
source === s.value
|
||||
? 'border-blue-500/50 bg-blue-500/10'
|
||||
: 'border-gray-700 hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="source"
|
||||
value={s.value}
|
||||
checked={source === s.value}
|
||||
onChange={e => setSource(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>
|
||||
<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 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>
|
||||
|
||||
{/* 提示消息 */}
|
||||
{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-3">
|
||||
<Card>
|
||||
<CardHeader title="任务队列" />
|
||||
<CardBody className="p-0">
|
||||
{tasks.length === 0 ? (
|
||||
<EmptyState text="暂无采集任务" />
|
||||
) : (
|
||||
<div className="divide-y divide-gray-800">
|
||||
{tasks.map(task => (
|
||||
<TaskRow key={task.task_id} task={task} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TaskRow({ task }: { task: CollectionTask }) {
|
||||
return (
|
||||
<div className="px-5 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-200">{task.source}</span>
|
||||
<Badge status={task.status}>{task.status}</Badge>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">
|
||||
{task.processed}/{task.total}
|
||||
</span>
|
||||
</div>
|
||||
{task.status === 'running' && (
|
||||
<div className="mt-2">
|
||||
<ProgressBar value={task.progress} />
|
||||
</div>
|
||||
)}
|
||||
{task.error_message && (
|
||||
<p className="mt-1.5 text-xs text-red-400">{task.error_message}</p>
|
||||
)}
|
||||
{task.finished_at && (
|
||||
<p className="mt-1 text-xs text-gray-600">
|
||||
完成于 {new Date(task.finished_at).toLocaleString('zh-CN')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Admin 后台 - 配置管理页面
|
||||
*
|
||||
* 功能:
|
||||
* - 查看当前 API Key 配置(脱敏显示)
|
||||
* - 更新 LLM / 数据源密钥
|
||||
* - 配置分类管理(LLM / 数据源 / 系统)
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { fetchConfigs, updateConfig } from '../dal'
|
||||
import type { ConfigEntry } from '../types'
|
||||
import { Card, CardBody, CardHeader, Badge, EmptyState } from '../components'
|
||||
import { SectionHeader } from '../components'
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
llm: 'LLM 服务',
|
||||
datasource: '数据源',
|
||||
system: '系统配置',
|
||||
}
|
||||
|
||||
const CATEGORY_ORDER = ['llm', 'datasource', 'system']
|
||||
|
||||
export default function ConfigPage() {
|
||||
const [configs, setConfigs] = useState<ConfigEntry[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [editing, setEditing] = useState<string | null>(null)
|
||||
const [editValue, setEditValue] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [successMsg, setSuccessMsg] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfigs()
|
||||
.then(setConfigs)
|
||||
.catch(err => setError(err instanceof Error ? err.message : '加载配置失败'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
function startEdit(key: string, currentValue: string) {
|
||||
setEditing(key)
|
||||
setEditValue(currentValue.replace(/\*+$/, '')) // 去掉脱敏星号
|
||||
setError(null)
|
||||
setSuccessMsg(null)
|
||||
}
|
||||
|
||||
async function saveEdit(key: string) {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
setSuccessMsg(null)
|
||||
try {
|
||||
await updateConfig({ key, value: editValue })
|
||||
setSuccessMsg(`${key} 已更新`)
|
||||
setEditing(null)
|
||||
// 刷新列表
|
||||
const updated = await fetchConfigs()
|
||||
setConfigs(updated)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '保存失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 按分类分组
|
||||
const grouped = CATEGORY_ORDER.reduce(
|
||||
(acc, cat) => {
|
||||
acc[cat] = configs.filter(c => c.category === cat)
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, ConfigEntry[]>,
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SectionHeader
|
||||
title="配置管理"
|
||||
description="管理 API Key 和系统参数(存储在 .env 文件)"
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{successMsg && (
|
||||
<div className="rounded-md border border-emerald-500/30 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-400">
|
||||
{successMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<Card>
|
||||
<CardBody>
|
||||
<EmptyState text="加载配置中..." />
|
||||
</CardBody>
|
||||
</Card>
|
||||
) : configs.length === 0 ? (
|
||||
<Card>
|
||||
<CardBody>
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-sm text-gray-500">暂无可管理的配置项</p>
|
||||
<p className="mt-1 text-xs text-gray-600">
|
||||
后端暂未实现配置管理 API,配置需通过 .env 文件手动管理
|
||||
</p>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
) : (
|
||||
CATEGORY_ORDER.map(cat => {
|
||||
const items = grouped[cat]
|
||||
if (!items || items.length === 0) return null
|
||||
return (
|
||||
<Card key={cat}>
|
||||
<CardHeader title={CATEGORY_LABELS[cat]} />
|
||||
<CardBody className="p-0">
|
||||
<div className="divide-y divide-gray-800">
|
||||
{items.map(cfg => (
|
||||
<ConfigRow
|
||||
key={cfg.key}
|
||||
config={cfg}
|
||||
editing={editing === cfg.key}
|
||||
editValue={editValue}
|
||||
saving={saving}
|
||||
onEditValueChange={setEditValue}
|
||||
onStartEdit={() => startEdit(cfg.key, cfg.value)}
|
||||
onSave={() => saveEdit(cfg.key)}
|
||||
onCancel={() => setEditing(null)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)
|
||||
})
|
||||
)}
|
||||
|
||||
{/* 配置说明 */}
|
||||
<Card>
|
||||
<CardHeader title="配置说明" />
|
||||
<CardBody>
|
||||
<div className="space-y-3 text-xs text-gray-500">
|
||||
<p>
|
||||
<strong className="text-gray-400">LLM 服务:</strong> 用于五路专家预测的大语言模型 API Key
|
||||
(如 OpenAI、Anthropic)
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-gray-400">数据源:</strong> 数据采集服务的认证密钥
|
||||
(如 Understat、Bzzoiro)
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-gray-400">系统配置:</strong> 数据库连接、日志级别等运行时参数
|
||||
</p>
|
||||
<p className="rounded-md border border-yellow-500/20 bg-yellow-500/5 px-3 py-2 text-yellow-400/80">
|
||||
⚠️ 配置修改后需要重启服务才能生效
|
||||
</p>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ConfigRow({
|
||||
config,
|
||||
editing,
|
||||
editValue,
|
||||
saving,
|
||||
onEditValueChange,
|
||||
onStartEdit,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: {
|
||||
config: ConfigEntry
|
||||
editing: boolean
|
||||
editValue: string
|
||||
saving: boolean
|
||||
onEditValueChange: (v: string) => void
|
||||
onStartEdit: () => void
|
||||
onSave: () => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-4 px-5 py-3">
|
||||
{/* 键名 */}
|
||||
<div className="w-48 flex-shrink-0">
|
||||
<div className="font-mono text-xs text-gray-400">{config.key}</div>
|
||||
{config.description && (
|
||||
<div className="mt-0.5 text-[10px] text-gray-600">{config.description}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 值 */}
|
||||
<div className="flex-1">
|
||||
{editing ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editValue}
|
||||
onChange={e => onEditValueChange(e.target.value)}
|
||||
className="flex-1 rounded-md border border-gray-600 bg-gray-800 px-2 py-1 text-xs text-gray-200 focus:border-blue-500 focus:outline-none"
|
||||
autoFocus
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') onSave()
|
||||
if (e.key === 'Escape') onCancel()
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={saving}
|
||||
className="rounded bg-blue-600 px-2 py-1 text-xs text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="rounded border border-gray-700 px-2 py-1 text-xs text-gray-400 hover:border-gray-600"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="font-mono text-xs text-gray-300">{config.value}</code>
|
||||
{config.updated_at && (
|
||||
<span className="text-[10px] text-gray-600">
|
||||
更新于 {new Date(config.updated_at).toLocaleDateString('zh-CN')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作 */}
|
||||
{!editing && (
|
||||
<button
|
||||
onClick={onStartEdit}
|
||||
className="flex-shrink-0 rounded border border-gray-700 px-2 py-1 text-xs text-gray-400 hover:border-gray-600"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Admin 后台 - 仪表盘
|
||||
*
|
||||
* 系统概览:
|
||||
* - 数据库表行数统计
|
||||
* - 最近采集状态
|
||||
* - 预测统计
|
||||
* - 最近错误日志
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { fetchDashboard } from '../dal'
|
||||
import type { DashboardStats, TableStats, CollectionRecord, ErrorLog } from '../types'
|
||||
import { Card, CardBody, CardHeader, StatCard, DataTable, Badge, EmptyState } from '../components'
|
||||
|
||||
export default function Dashboard() {
|
||||
const [data, setData] = useState<DashboardStats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
setLoading(true)
|
||||
fetchDashboard()
|
||||
.then((stats: DashboardStats) => {
|
||||
if (active) setData(stats)
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (active) setError(err instanceof Error ? err.message : '加载失败')
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-lg border border-red-500/30 bg-red-500/10 p-6 text-center text-red-400">
|
||||
<p className="text-lg font-medium">加载仪表盘失败</p>
|
||||
<p className="mt-1 text-sm">{error}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* ── 统计卡片 ── */}
|
||||
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<StatCard
|
||||
label="数据库表数"
|
||||
value={loading ? '—' : data?.db_tables.length ?? 0}
|
||||
icon="◫"
|
||||
/>
|
||||
<StatCard
|
||||
label="总预测数"
|
||||
value={loading ? '—' : data?.prediction_stats.total_predictions ?? 0}
|
||||
icon="◆"
|
||||
/>
|
||||
<StatCard
|
||||
label="今日预测"
|
||||
value={loading ? '—' : data?.prediction_stats.today_predictions ?? 0}
|
||||
icon="◇"
|
||||
/>
|
||||
<StatCard
|
||||
label="平均延迟"
|
||||
value={loading ? '—' : `${data?.prediction_stats.avg_latency_ms ?? 0}ms`}
|
||||
icon="◷"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* ── 数据库表统计 ── */}
|
||||
<Card>
|
||||
<CardHeader title="数据库表状态" />
|
||||
<CardBody className="p-0">
|
||||
{loading ? (
|
||||
<EmptyState text="加载中..." />
|
||||
) : data && data.db_tables.length > 0 ? (
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'name', label: '表名' },
|
||||
{ key: 'row_count', label: '行数', width: '80px' },
|
||||
{
|
||||
key: 'last_updated',
|
||||
label: '最后更新',
|
||||
width: '180px',
|
||||
render: (row: TableStats) =>
|
||||
row.last_updated
|
||||
? new Date(row.last_updated).toLocaleString('zh-CN')
|
||||
: '—',
|
||||
},
|
||||
]}
|
||||
data={data.db_tables}
|
||||
rowKey={(row: TableStats) => row.name}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState text="暂无表统计信息" />
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* ── 最近采集记录 ── */}
|
||||
<Card>
|
||||
<CardHeader title="最近采集任务" />
|
||||
<CardBody className="p-0">
|
||||
{loading ? (
|
||||
<EmptyState text="加载中..." />
|
||||
) : data && data.last_collection.length > 0 ? (
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'source', label: '数据源' },
|
||||
{ key: 'league_code', label: '联赛', width: '80px' },
|
||||
{
|
||||
key: 'status',
|
||||
label: '状态',
|
||||
width: '90px',
|
||||
render: (row: CollectionRecord) => <Badge status={row.status}>{statusLabel(row.status)}</Badge>,
|
||||
},
|
||||
{
|
||||
key: 'finished_at',
|
||||
label: '完成时间',
|
||||
width: '160px',
|
||||
render: (row: CollectionRecord) =>
|
||||
row.finished_at
|
||||
? new Date(row.finished_at).toLocaleString('zh-CN')
|
||||
: '进行中',
|
||||
},
|
||||
]}
|
||||
data={data.last_collection}
|
||||
rowKey={(row: CollectionRecord) => `${row.source}-${row.started_at}`}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState text="暂无采集记录" />
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* ── 最近错误日志 ── */}
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader title="最近错误日志" />
|
||||
<CardBody className="p-0">
|
||||
{loading ? (
|
||||
<EmptyState text="加载中..." />
|
||||
) : data && data.recent_errors.length > 0 ? (
|
||||
<DataTable
|
||||
columns={[
|
||||
{
|
||||
key: 'timestamp',
|
||||
label: '时间',
|
||||
width: '180px',
|
||||
render: (row: ErrorLog) => new Date(row.timestamp).toLocaleString('zh-CN'),
|
||||
},
|
||||
{
|
||||
key: 'level',
|
||||
label: '级别',
|
||||
width: '80px',
|
||||
render: (row: ErrorLog) => <Badge status={row.level}>{row.level}</Badge>,
|
||||
},
|
||||
{ key: 'source', label: '来源', width: '120px' },
|
||||
{ key: 'message', label: '消息' },
|
||||
]}
|
||||
data={data.recent_errors}
|
||||
rowKey={(row: ErrorLog) => row.id}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState text="暂无错误日志 ✓" />
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
running: '运行中',
|
||||
success: '成功',
|
||||
failed: '失败',
|
||||
queued: '排队',
|
||||
completed: '已完成',
|
||||
}
|
||||
return map[status] ?? status
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* Admin 后台 - 监控面板
|
||||
*
|
||||
* 功能:
|
||||
* - 系统健康检查
|
||||
* - 采集错误日志
|
||||
* - 死信队列监控
|
||||
* - 实时状态刷新
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { fetchErrorLogs } from '../dal'
|
||||
import type { ErrorLog } from '../types'
|
||||
import { Card, CardBody, CardHeader, Badge, DataTable, EmptyState } from '../components'
|
||||
import { SectionHeader } from '../components'
|
||||
|
||||
interface HealthCheck {
|
||||
name: string
|
||||
status: 'pass' | 'fail' | 'warn'
|
||||
detail?: string
|
||||
}
|
||||
|
||||
interface HealthInfo {
|
||||
status: 'ok' | 'degraded' | 'error'
|
||||
version?: string
|
||||
uptime?: string
|
||||
checks: HealthCheck[]
|
||||
}
|
||||
|
||||
export default function MonitoringPage() {
|
||||
const [logs, setLogs] = useState<ErrorLog[]>([])
|
||||
const [health, setHealth] = useState<HealthInfo | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [autoRefresh, setAutoRefresh] = useState(true)
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const [logData, healthRes] = await Promise.all([
|
||||
fetchErrorLogs(100),
|
||||
fetch('/health').then(r => r.json()) as Promise<HealthInfo>,
|
||||
])
|
||||
setLogs(logData)
|
||||
setHealth(healthRes)
|
||||
} catch {
|
||||
// 静默处理,保持上次数据
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
if (!autoRefresh) return
|
||||
const timer = setInterval(loadData, 10_000)
|
||||
return () => clearInterval(timer)
|
||||
}, [loadData, autoRefresh])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SectionHeader
|
||||
title="监控面板"
|
||||
description="系统健康状态、错误日志和死信队列监控"
|
||||
/>
|
||||
|
||||
{/* ── 系统健康 ── */}
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader title="服务状态" />
|
||||
<CardBody>
|
||||
{health ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`inline-block h-3 w-3 rounded-full ${
|
||||
health.status === 'ok'
|
||||
? 'bg-emerald-500'
|
||||
: health.status === 'degraded'
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-red-500'
|
||||
}`}
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-200">
|
||||
{health.status === 'ok'
|
||||
? '运行正常'
|
||||
: health.status === 'degraded'
|
||||
? '部分降级'
|
||||
: '异常'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1 text-xs text-gray-500">
|
||||
<div>版本: {health.version ?? '—'}</div>
|
||||
<div>运行时间: {health.uptime ?? '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : loading ? (
|
||||
<EmptyState text="加载中..." />
|
||||
) : (
|
||||
<EmptyState text="无法获取健康状态" />
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 健康检查项 */}
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader title="健康检查项" />
|
||||
<CardBody>
|
||||
{health && health.checks && health.checks.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{health.checks.map((check: HealthCheck) => (
|
||||
<div
|
||||
key={check.name}
|
||||
className="flex items-center justify-between rounded-md border border-gray-800 px-3 py-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`inline-block h-2 w-2 rounded-full ${
|
||||
check.status === 'pass'
|
||||
? 'bg-emerald-500'
|
||||
: check.status === 'warn'
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-red-500'
|
||||
}`}
|
||||
/>
|
||||
<span className="text-sm text-gray-300">{check.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{check.detail && (
|
||||
<span className="text-xs text-gray-500">{check.detail}</span>
|
||||
)}
|
||||
<Badge status={check.status === 'pass' ? 'success' : check.status === 'warn' ? 'warning' : 'error'}>
|
||||
{check.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState text="暂无检查项数据" />
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* ── 错误日志 ── */}
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="错误日志"
|
||||
action={
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="flex items-center gap-1.5 text-xs text-gray-500">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoRefresh}
|
||||
onChange={e => setAutoRefresh(e.target.checked)}
|
||||
className="accent-blue-500"
|
||||
/>
|
||||
自动刷新
|
||||
</label>
|
||||
<button
|
||||
onClick={loadData}
|
||||
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: 'timestamp',
|
||||
label: '时间',
|
||||
width: '180px',
|
||||
render: (row: ErrorLog) => (
|
||||
<span className="font-mono text-xs">
|
||||
{new Date(row.timestamp).toLocaleString('zh-CN')}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'level',
|
||||
label: '级别',
|
||||
width: '80px',
|
||||
render: (row: ErrorLog) => <Badge status={row.level}>{row.level}</Badge>,
|
||||
},
|
||||
{ key: 'source', label: '来源', width: '120px' },
|
||||
{ key: 'message', label: '消息' },
|
||||
]}
|
||||
data={logs}
|
||||
rowKey={(row: ErrorLog) => row.id}
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* ── 死信队列 ── */}
|
||||
<Card>
|
||||
<CardHeader title="死信队列" />
|
||||
<CardBody>
|
||||
<DeadLetterQueue />
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DeadLetterQueue() {
|
||||
// 死信队列数据 - 实际应从后端获取
|
||||
interface DLQItem {
|
||||
id: string
|
||||
source: string
|
||||
error: string
|
||||
payload: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const [items] = useState<DLQItem[]>([])
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl text-gray-700" aria-hidden="true">
|
||||
✓
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-gray-500">死信队列为空</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'source', label: '来源', width: '120px' },
|
||||
{ key: 'error', label: '错误' },
|
||||
{
|
||||
key: 'created_at',
|
||||
label: '时间',
|
||||
width: '180px',
|
||||
render: (row: DLQItem) => new Date(row.created_at).toLocaleString('zh-CN'),
|
||||
},
|
||||
]}
|
||||
data={items}
|
||||
rowKey={(row: DLQItem) => row.id}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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