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
+10 -20
View File
@@ -1,17 +1,10 @@
/** /**
* Admin 后台管理系统 - 统一 API 客户端 * Admin 后台管理系统 - 统一 API 客户端
*
* 封装 fetch 调用,提供:
* - 统一错误处理
* - 请求/响应日志
* - 超时控制
* - 类型安全的响应解析
*/ */
const API_BASE = '/api/v1' const API_BASE = '/api/v1'
const TIMEOUT_MS = 30_000 const TIMEOUT_MS = 30_000
/** 通用 API 错误类型 */
export class ApiError extends Error { export class ApiError extends Error {
constructor( constructor(
message: string, message: string,
@@ -23,12 +16,14 @@ export class ApiError extends Error {
} }
} }
/** 基础 fetch 封装,带超时和错误处理 */ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
async function request<T>( // 修复: 正确拼接 API_BASE
path: string, const url = path.startsWith('http')
options: RequestInit = {}, ? path
): Promise<T> { : path.startsWith('/')
const url = path.startsWith('http') ? path : `${path}` ? path // 已经是绝对路径(如 /health)
: `${API_BASE}${path}`
const controller = new AbortController() const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS) const timer = setTimeout(() => controller.abort(), TIMEOUT_MS)
@@ -58,8 +53,8 @@ async function request<T>(
) )
} }
// 204 No Content // 修复: 正确判断 204 No Content
if (res.status === 200 && res.headers.get('content-length') === '0') { if (res.status === 204) {
return undefined as T return undefined as T
} }
@@ -78,17 +73,12 @@ async function request<T>(
} }
} }
// ── 通用 CRUD 快捷方法 ──────────────────────────────────────────
export const api = { export const api = {
get: <T>(path: string) => request<T>(path), get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body?: unknown) => post: <T>(path: string, body?: unknown) =>
request<T>(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined }), request<T>(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined }),
put: <T>(path: string, body?: unknown) => put: <T>(path: string, body?: unknown) =>
request<T>(path, { method: 'PUT', body: body ? JSON.stringify(body) : undefined }), request<T>(path, { method: 'PUT', body: body ? JSON.stringify(body) : undefined }),
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }), delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
} }
+99 -116
View File
@@ -2,114 +2,94 @@
* Admin 后台 - 数据访问层 * Admin 后台 - 数据访问层
* *
* 封装所有 API 端点调用,返回类型安全的数据。 * 封装所有 API 端点调用,返回类型安全的数据。
* 页面组件直接调用这些函数,无需关心网络细节 * 所有端点对齐 FastAPI 后端实际实现
*/ */
import { api, API_BASE } from './api' import { api, API_BASE } from './api'
import type { import type {
DashboardStats, DashboardStats,
ConfigEntry,
ConfigUpdate,
CollectionRequest, CollectionRequest,
CollectionResponse,
CollectionTask,
PredictRequest,
PredictionHistoryItem,
SettleResponse,
EvalSummary,
BacktestRequest, BacktestRequest,
BacktestResult, BacktestSummary,
League, League,
Paginated, Match,
ErrorLog, Prediction,
EvalSummary,
} from './types' } from './types'
// ── 仪表盘 ────────────────────────────────────────────────────── // ── 仪表盘 ──────────────────────────────────────────────────────
/**
* 从多个端点聚合仪表盘数据。
* 后端暂无专用仪表盘端点,这里组合 health + 各列表端点。
*/
export async function fetchDashboard(): Promise<DashboardStats> { export async function fetchDashboard(): Promise<DashboardStats> {
// 后端可能没有专门的仪表盘聚合端点,这里用 health + 各端点组合 // 并行获取各端点数据
const health = await fetchHealth() const [leagues, matches, predictions, health] = await Promise.allSettled([
api.get<League[]>(`${API_BASE}/leagues`),
api.get<Match[]>(`${API_BASE}/matches?limit=1`),
api.get<Prediction[]>(`${API_BASE}/predictions?limit=1`),
api.get<{ status: string }>('/health'),
])
return { return {
db_tables: health.db_tables ?? [], leagues: leagues.status === 'fulfilled' ? leagues.value : [],
last_collection: health.last_collection ?? [], total_matches: matches.status === 'fulfilled' ? (matches.value as any)?.total ?? 0 : 0,
prediction_stats: health.prediction_stats ?? { total_predictions: predictions.status === 'fulfilled' ? (predictions.value as any)?.total ?? 0 : 0,
total_predictions: 0, health: health.status === 'fulfilled' ? (health.value as any).status : 'unknown',
today_predictions: 0, db_tables: [], // 后端暂无表统计端点
avg_latency_ms: 0, last_collection: [], // 后端暂无采集历史端点
success_rate: 0, recent_errors: [], // 后端暂无错误日志端点
},
recent_errors: health.recent_errors ?? [],
} }
} }
/** 后端 health 端点暂时返回基础信息,仪表盘从多端点拼装 */
async function fetchHealth() {
try {
const res = await api.get<{ status: string }>('/health')
return res as unknown as DashboardStats
} catch {
return {} as DashboardStats
}
}
// ── 配置管理 ────────────────────────────────────────────────────
export async function fetchConfigs(): Promise<ConfigEntry[]> {
try {
return await api.get<ConfigEntry[]>(`${API_BASE}/configs`)
} catch {
// 后端暂未实现配置端点,返回空列表
return []
}
}
export async function updateConfig(update: ConfigUpdate): Promise<void> {
await api.post(`${API_BASE}/configs`, update)
}
// ── 数据采集 ──────────────────────────────────────────────────── // ── 数据采集 ────────────────────────────────────────────────────
export async function triggerCollection(req: CollectionRequest): Promise<CollectionResponse> { export async function triggerCollection(req: CollectionRequest): Promise<any> {
const sourceMap: Record<string, string> = { const sourceMap: Record<string, { path: string; body: any }> = {
bzzoiro: `${API_BASE}/ingest/bzzoiro`, bzzoiro: {
understat: `${API_BASE}/ingest/understat`, path: `${API_BASE}/ingest/bzzoiro`,
injuries: `${API_BASE}/ingest/injuries`, body: {
} leagues: req.leagues,
return api.post<CollectionResponse>(sourceMap[req.source], req) date_from: req.date_from,
} date_to: req.date_to,
status: 'finished',
export async function fetchCollectionTasks(): Promise<CollectionTask[]> { },
try { },
return await api.get<CollectionTask[]>(`${API_BASE}/admin/tasks`) understat: {
} catch { path: `${API_BASE}/ingest/understat`,
return [] body: {
league: req.league,
season: req.season ? parseInt(req.season) : new Date().getFullYear(),
},
},
injuries: {
path: `${API_BASE}/ingest/injuries`,
body: {
date: req.date_from || new Date().toISOString().slice(0, 10),
},
},
} }
const cfg = sourceMap[req.source]
if (!cfg) throw new Error(`未知数据源: ${req.source}`)
return api.post(cfg.path, cfg.body)
} }
// ── 预测管理 ──────────────────────────────────────────────────── // ── 预测管理 ────────────────────────────────────────────────────
export async function triggerPrediction(req: PredictRequest): Promise<unknown> { export async function triggerPrediction(req: { match_id: number; mode?: string }): Promise<any> {
return api.post(`${API_BASE}/predict`, req) return api.post(`${API_BASE}/predict`, {
match_id: req.match_id,
mode: req.mode || 'multi',
})
} }
export async function fetchPredictionHistory( export async function fetchPredictions(limit = 50): Promise<any[]> {
page = 1, const res = await api.get<any>(`${API_BASE}/predictions?limit=${limit}`)
pageSize = 20, return Array.isArray(res) ? res : (res as any)?.items ?? []
): Promise<Paginated<PredictionHistoryItem>> {
try {
return await api.get<Paginated<PredictionHistoryItem>>(
`${API_BASE}/predictions?page=${page}&page_size=${pageSize}`,
)
} catch {
return { items: [], total: 0, page: 1, page_size: pageSize, has_next: false }
}
} }
// ── 评估 & 结算 ───────────────────────────────────────────────── // ── 评估 & 回测 ─────────────────────────────────────────────────
export async function triggerSettle(): Promise<SettleResponse> {
return api.post<SettleResponse>(`${API_BASE}/eval/settle`)
}
export async function fetchEvalSummary(): Promise<EvalSummary | null> { export async function fetchEvalSummary(): Promise<EvalSummary | null> {
try { try {
@@ -119,26 +99,8 @@ export async function fetchEvalSummary(): Promise<EvalSummary | null> {
} }
} }
// ── 回测管理 ──────────────────────────────────────────────────── export async function triggerBacktest(req: BacktestRequest): Promise<BacktestSummary> {
return api.post<BacktestSummary>(`${API_BASE}/backtest`, req)
export async function triggerBacktest(req: BacktestRequest): Promise<BacktestResult> {
return api.post<BacktestResult>(`${API_BASE}/backtest`, req)
}
export async function fetchBacktestResult(taskId: string): Promise<BacktestResult | null> {
try {
return await api.get<BacktestResult>(`${API_BASE}/backtest/${taskId}`)
} catch {
return null
}
}
export async function fetchBacktestHistory(): Promise<BacktestResult[]> {
try {
return await api.get<BacktestResult[]>(`${API_BASE}/backtest`)
} catch {
return []
}
} }
// ── 辅助数据 ──────────────────────────────────────────────────── // ── 辅助数据 ────────────────────────────────────────────────────
@@ -146,23 +108,44 @@ export async function fetchBacktestHistory(): Promise<BacktestResult[]> {
export async function fetchLeagues(): Promise<League[]> { export async function fetchLeagues(): Promise<League[]> {
try { try {
return await api.get<League[]>(`${API_BASE}/leagues`) return await api.get<League[]>(`${API_BASE}/leagues`)
} catch {
return [
{ code: 'E0', name: 'Premier League', name_zh: '英超', country: 'England' },
{ code: 'SP1', name: 'La Liga', name_zh: '西甲', country: 'Spain' },
{ code: 'D1', name: 'Bundesliga', name_zh: '德甲', country: 'Germany' },
{ code: 'I1', name: 'Serie A', name_zh: '意甲', country: 'Italy' },
{ code: 'F1', name: 'Ligue 1', name_zh: '法甲', country: 'France' },
]
}
}
// ── 监控 & 错误日志 ─────────────────────────────────────────────
export async function fetchErrorLogs(limit = 50): Promise<ErrorLog[]> {
try {
return await api.get<ErrorLog[]>(`${API_BASE}/admin/logs?limit=${limit}`)
} catch { } catch {
return [] return []
} }
} }
export async function fetchMatches(params: {
league?: string
status?: string
limit?: number
cursor?: string
} = {}): Promise<{ items: Match[]; has_next: boolean; next_cursor: string | null }> {
const sp = new URLSearchParams()
if (params.league) sp.set('league', params.league)
if (params.status) sp.set('status', params.status)
if (params.limit) sp.set('limit', String(params.limit))
if (params.cursor) sp.set('cursor', params.cursor)
try {
return await api.get<any>(`${API_BASE}/matches?${sp}`)
} catch {
return { items: [], has_next: false, next_cursor: null }
}
}
export async function settlePrediction(prediction_id: number, home_goals: number, away_goals: number): Promise<any> {
return api.post(`${API_BASE}/eval/settle`, {
prediction_id,
home_goals,
away_goals,
})
}
// ── 健康检查 ────────────────────────────────────────────────────
export async function fetchHealth(): Promise<any> {
try {
return await api.get<any>('/health')
} catch {
return { status: 'unknown' }
}
}
+87 -274
View File
@@ -1,222 +1,140 @@
/** /**
* Admin 后台 - 回测管理页面 * Admin 后台 - 回测管理页面
*
* 功能:
* - 配置回测参数(策略、联赛、日期范围、初始资金)
* - 触发回测任务
* - 查看回测结果(收益率、最大回撤、夏普比率等)
* - 交易记录明细
*/ */
import { useEffect, useState } from 'react' import { useState } from 'react'
import { triggerBacktest, fetchBacktestHistory, fetchLeagues } from '../dal' import { triggerBacktest, fetchEvalSummary } from '../dal'
import type { BacktestRequest, BacktestResult, BacktestTrade, League } from '../types' import type { BacktestRequest, EvalSummary } from '../types'
import { import { Card, CardBody, CardHeader, Badge } from '../components'
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() { export default function BacktestPage() {
const [leagues, setLeagues] = useState<League[]>([]) const [leagueId, setLeagueId] = useState('')
const [tasks, setTasks] = useState<BacktestResult[]>([])
const [strategy, setStrategy] = useState('confidence_weighted')
const [selectedLeagues, setSelectedLeagues] = useState<string[]>(['E0'])
const [dateFrom, setDateFrom] = useState('') const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = 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 [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) 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(() => { async function handleBacktest(e: React.FormEvent) {
Promise.all([fetchLeagues(), fetchBacktestHistory()]).then(([lg, ts]) => {
setLeagues(lg)
setTasks(ts)
})
}, [])
async function handleSubmit(e: React.FormEvent) {
e.preventDefault() e.preventDefault()
setError(null)
setSuccessMsg(null)
setLoading(true) setLoading(true)
setError(null)
setResult(null)
try { try {
const body: BacktestRequest = { const req: BacktestRequest = {
strategy, league_id: leagueId ? parseInt(leagueId) : undefined,
league_codes: selectedLeagues, date_from: dateFrom || undefined,
date_from: dateFrom || '2024-01-01', date_to: dateTo || undefined,
date_to: dateTo || new Date().toISOString().split('T')[0], limit,
initial_bankroll: bankroll, mode,
} }
const res = await triggerBacktest(body) const res = await triggerBacktest(req)
setSuccessMsg(`回测任务已启动: ${res.task_id}`) setResult(res)
// 刷新列表
const ts = await fetchBacktestHistory()
setTasks(ts)
} catch (err: unknown) { } catch (err: unknown) {
setError(err instanceof Error ? err.message : '回测触发失败') setError(err instanceof Error ? err.message : '回测失败')
} finally { } finally {
setLoading(false) setLoading(false)
} }
} }
function toggleLeague(code: string) { async function loadEval() {
setSelectedLeagues(prev => const summary = await fetchEvalSummary()
prev.includes(code) ? prev.filter(c => c !== code) : [...prev, code], setEvalSummary(summary)
)
} }
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<SectionHeader <div>
title="回测管理" <h2 className="text-xl font-semibold text-white"></h2>
description="配置回测参数,验证预测策略在历史数据上的表现" <p className="mt-1 text-sm text-gray-400"></p>
/> </div>
<div className="grid gap-6 lg:grid-cols-5"> <div className="grid gap-6 lg:grid-cols-2">
{/* ── 回测配置表单 ── */}
<div className="lg:col-span-2">
<Card> <Card>
<CardHeader title="回测配置" /> <CardHeader title="回测配置" />
<CardBody> <CardBody>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleBacktest} className="space-y-4">
{/* 策略选择 */}
<div> <div>
<label className="mb-1.5 block text-xs font-medium text-gray-400"></label> <label className="mb-1.5 block text-xs font-medium text-gray-400"> ID ()</label>
<div className="space-y-2"> <input type="number" value={leagueId} onChange={e => setLeagueId(e.target.value)}
{STRATEGIES.map(s => ( placeholder="留空=全部" className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-white" />
<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> </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 className="grid grid-cols-2 gap-3">
<div> <div>
<label className="mb-1.5 block text-xs font-medium text-gray-400"></label> <label className="mb-1.5 block text-xs font-medium text-gray-400"></label>
<input <input type="date" value={dateFrom} onChange={e => setDateFrom(e.target.value)}
type="date" className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-white" />
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>
<div> <div>
<label className="mb-1.5 block text-xs font-medium text-gray-400"></label> <label className="mb-1.5 block text-xs font-medium text-gray-400"></label>
<input <input type="date" value={dateTo} onChange={e => setDateTo(e.target.value)}
type="date" className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-white" />
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> </div>
{/* 初始资金 */}
<div> <div>
<label className="mb-1.5 block text-xs font-medium text-gray-400"></label> <label className="mb-1.5 block text-xs font-medium text-gray-400"></label>
<input <input type="number" value={limit} onChange={e => setLimit(parseInt(e.target.value) || 20)}
type="number" className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-white" />
value={bankroll} </div>
onChange={e => setBankroll(Number(e.target.value))} <div>
min={100} <label className="mb-1.5 block text-xs font-medium text-gray-400"></label>
step={100} <select value={mode} onChange={e => setMode(e.target.value as any)}
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" 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> </div>
{error && ( {error && <div className="rounded bg-red-500/10 p-3 text-sm text-red-400">{error}</div>}
<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 <button type="submit" disabled={loading}
type="submit" className="w-full rounded bg-blue-600 py-2 text-white hover:bg-blue-700 disabled:opacity-50">
disabled={loading || selectedLeagues.length === 0} {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> </button>
</form> </form>
</CardBody> </CardBody>
</Card> </Card>
</div>
{/* ── 回测任务与结果 ── */} <div className="space-y-6">
<div className="lg:col-span-3"> {result && (
<Card> <Card>
<CardHeader title="回测任务" /> <CardHeader title="回测结果" />
<CardBody className="p-0"> <CardBody>
{tasks.length === 0 ? ( <div className="grid grid-cols-2 gap-4">
<EmptyState text="暂无回测任务" /> <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="divide-y divide-gray-800"> <div className="text-xs text-gray-400"></div>
{tasks.map(task => ( </div>
<BacktestResultRow key={task.task_id} task={task} /> <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>
)}
<Card>
<CardHeader title="模型评估" action={<button onClick={loadEval} className="text-xs text-blue-400 hover:underline"></button>} />
<CardBody>
{!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> </div>
) : (
<p className="text-gray-400"></p>
)} )}
</CardBody> </CardBody>
</Card> </Card>
@@ -225,108 +143,3 @@ export default function BacktestPage() {
</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>
)
}
+66 -163
View File
@@ -1,49 +1,35 @@
/** /**
* Admin 后台 - 数据采集页面 * Admin 后台 - 数据采集页面
*
* 功能:
* - 选择数据源(bzzoiro / understat / injuries)
* - 选择联赛、日期范围
* - 触发采集任务
* - 实时显示任务进度
*/ */
import { useEffect, useState, useCallback } from 'react' import { useEffect, useState, useCallback } from 'react'
import { triggerCollection, fetchCollectionTasks, fetchLeagues } from '../dal' import { triggerCollection, fetchLeagues } from '../dal'
import type { CollectionRequest, CollectionTask, League } from '../types' import type { CollectionRequest, League } from '../types'
import { Card, CardBody, CardHeader, Badge, ProgressBar, EmptyState } from '../components' import { Card, CardBody, CardHeader, Badge } from '../components'
import { SectionHeader } from '../components'
const SOURCES = [ const SOURCES = [
{ value: 'bzzoiro', label: 'Bzzoiro', desc: '历史赛程与比分数据' }, { value: 'bzzoiro', label: 'Bzzoiro', desc: '历史赛程与比分' },
{ value: 'understat', label: 'Understat', desc: '进阶统计数据(xG/xA)' }, { value: 'understat', label: 'Understat', desc: 'xG 进阶数据' },
{ value: 'injuries', label: 'Injuries', desc: '球员伤停信息' }, { value: 'injuries', label: 'Injuries', desc: '球员伤停' },
] as const ] as const
export default function CollectionPage() { export default function CollectionPage() {
const [leagues, setLeagues] = useState<League[]>([]) const [leagues, setLeagues] = useState<League[]>([])
const [tasks, setTasks] = useState<CollectionTask[]>([])
const [source, setSource] = useState<string>('bzzoiro') const [source, setSource] = useState<string>('bzzoiro')
const [leagueCode, setLeagueCode] = useState<string>('') const [leagueCode, setLeagueCode] = useState('')
const [dateFrom, setDateFrom] = useState('') const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('') const [dateTo, setDateTo] = useState('')
const [season, setSeason] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [successMsg, setSuccessMsg] = useState<string | null>(null) const [successMsg, setSuccessMsg] = useState<string | null>(null)
// 加载联赛列表和任务历史 const loadLeagues = useCallback(async () => {
const loadData = useCallback(async () => { const lg = await fetchLeagues()
const [lg, ts] = await Promise.all([fetchLeagues(), fetchCollectionTasks()])
setLeagues(lg) setLeagues(lg)
setTasks(ts)
}, []) }, [])
useEffect(() => { useEffect(() => { loadLeagues() }, [loadLeagues])
loadData()
// 自动刷新任务状态(每 5 秒)
const timer = setInterval(loadData, 5000)
return () => clearInterval(timer)
}, [loadData])
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault() e.preventDefault()
@@ -54,13 +40,14 @@ export default function CollectionPage() {
try { try {
const body: CollectionRequest = { const body: CollectionRequest = {
source: source as CollectionRequest['source'], source: source as CollectionRequest['source'],
league_code: leagueCode || undefined, leagues: leagueCode ? [leagueCode] : undefined,
league: leagueCode || undefined,
season: season || undefined,
date_from: dateFrom || undefined, date_from: dateFrom || undefined,
date_to: dateTo || undefined, date_to: dateTo || undefined,
} }
const res = await triggerCollection(body) const res = await triggerCollection(body)
setSuccessMsg(`任务已创建: ${res.message}`) setSuccessMsg(`采集完成: ${JSON.stringify(res).slice(0, 200)}`)
loadData()
} catch (err: unknown) { } catch (err: unknown) {
setError(err instanceof Error ? err.message : '采集触发失败') setError(err instanceof Error ? err.message : '采集触发失败')
} finally { } finally {
@@ -70,167 +57,83 @@ export default function CollectionPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<SectionHeader <div>
title="数据采集" <h2 className="text-xl font-semibold text-white"></h2>
description="触发数据源采集任务,支持联赛筛选和日期范围" <p className="mt-1 text-sm text-gray-400">,</p>
/> </div>
<div className="grid gap-6 lg:grid-cols-5"> <div className="grid gap-6 lg:grid-cols-2">
{/* ── 采集配置表单 ── */}
<div className="lg:col-span-2">
<Card> <Card>
<CardHeader title="新建采集任务" /> <CardHeader title="新建采集任务" />
<CardBody> <CardBody>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
{/* 数据源 */}
<div> <div>
<label className="mb-1.5 block text-xs font-medium text-gray-400"> <label className="mb-1.5 block text-xs font-medium text-gray-400"></label>
<select value={source} onChange={e => setSource(e.target.value)}
</label> className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-white">
<div className="space-y-2"> {SOURCES.map(s => <option key={s.value} value={s.value}>{s.label} {s.desc}</option>)}
{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> </select>
</div> </div>
{/* 日期范围 */}
<div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="mb-1.5 block text-xs font-medium text-gray-400"> <label className="mb-1.5 block text-xs font-medium text-gray-400"></label>
<select value={leagueCode} onChange={e => setLeagueCode(e.target.value)}
</label> className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-white">
<input <option value=""></option>
type="date" {leagues.map(l => <option key={l.code} value={l.code}>{l.name_zh || l.name}</option>)}
value={dateFrom} </select>
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>
{/* 提示消息 */} {source === 'understat' && (
{error && ( <div>
<div className="rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-400"> <label className="mb-1.5 block text-xs font-medium text-gray-400">()</label>
{error} <input type="number" value={season} onChange={e => setSeason(e.target.value)}
</div> placeholder="2025" className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-white" />
)}
{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> </div>
)} )}
{/* 提交 */} {source !== 'injuries' && (
<button <>
type="submit" <div>
disabled={loading} <label className="mb-1.5 block text-xs font-medium text-gray-400"></label>
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" <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" />
{loading ? '提交中...' : '启动采集'} </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>
</>
)}
{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}
className="w-full rounded bg-blue-600 py-2 text-white hover:bg-blue-700 disabled:opacity-50">
{loading ? '采集中...' : '触发采集'}
</button> </button>
</form> </form>
</CardBody> </CardBody>
</Card> </Card>
</div>
{/* ── 任务列表 ── */}
<div className="lg:col-span-3">
<Card> <Card>
<CardHeader title="任务队列" /> <CardHeader title="数据源说明" />
<CardBody className="p-0"> <CardBody>
{tasks.length === 0 ? ( <div className="space-y-3">
<EmptyState text="暂无采集任务" /> {SOURCES.map(s => (
) : ( <div key={s.value} className="rounded border border-gray-700 p-3">
<div className="divide-y divide-gray-800"> <div className="flex items-center gap-2">
{tasks.map(task => ( <Badge status="info">{s.label}</Badge>
<TaskRow key={task.task_id} task={task} /> <span className="text-sm text-gray-300">{s.desc}</span>
</div>
</div>
))} ))}
</div> </div>
)}
</CardBody> </CardBody>
</Card> </Card>
</div> </div>
</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>
) )
} }
+42 -227
View File
@@ -1,245 +1,60 @@
/** /**
* Admin 后台 - 配置管理页面 * Admin 后台 - 配置管理
* *
* 功能: * 提示: API Key 配置通过 .env 文件管理,不在前端明文存储。
* - 查看当前 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() { 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
<SectionHeader <div>
title="配置管理" <h2 className="text-xl font-semibold text-white"></h2>
description="管理 API Key 和系统参数(存储在 .env 文件)" <p className="mt-1 text-sm text-gray-400">API Key </p>
/> </div>
{error && ( <div className="rounded border border-yellow-500/30 bg-yellow-500/10 p-4">
<div className="rounded-md border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-400"> <p className="text-sm text-yellow-300">
{error} API Key <code className="rounded bg-gray-800 px-1">.env</code> ,
</p>
</div> </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 ? ( <div className="rounded border border-gray-700 bg-gray-800 p-4">
<Card> <h3 className="mb-3 text-sm font-medium text-white"></h3>
<CardBody> <div className="space-y-2 text-sm">
<EmptyState text="加载配置中..." /> <div className="flex justify-between border-b border-gray-700 py-2">
</CardBody> <span className="text-gray-400">LLM_API_KEY</span>
</Card> <span className="text-gray-300"> .env </span>
) : configs.length === 0 ? ( </div>
<Card> <div className="flex justify-between border-b border-gray-700 py-2">
<CardBody> <span className="text-gray-400">LLM_BASE_URL</span>
<div className="py-8 text-center"> <span className="text-gray-300"> .env </span>
<p className="text-sm text-gray-500"></p> </div>
<p className="mt-1 text-xs text-gray-600"> <div className="flex justify-between border-b border-gray-700 py-2">
API, .env <span className="text-gray-400">BZZOIRO_KEY</span>
</p> <span className="text-gray-300"> .env </span>
</div>
<div className="flex justify-between py-2">
<span className="text-gray-400">API_FOOTBALL_KEY</span>
<span className="text-gray-300"> .env </span>
</div>
</div> </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> </div>
</CardBody>
</Card>
)
})
)}
{/* 配置说明 */} <div className="rounded border border-gray-700 bg-gray-800 p-4">
<Card> <h3 className="mb-3 text-sm font-medium text-white"></h3>
<CardHeader title="配置说明" /> <p className="text-sm text-gray-400">
<CardBody> SSH NAS,:
<div className="space-y-3 text-xs text-gray-500">
<p>
<strong className="text-gray-400">LLM :</strong> API Key
( OpenAIAnthropic)
</p>
<p>
<strong className="text-gray-400">:</strong>
( UnderstatBzzoiro)
</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> </p>
<pre className="mt-2 rounded bg-gray-900 p-3 text-xs text-green-400">
{`cd /vol2/1000/Docker/Profeto
# 编辑 .env 文件
LLM_API_KEY=sk-你的真实密钥
BZZOIRO_KEY=你的密钥
# 重启后端
docker compose restart api`}
</pre>
</div> </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> </div>
) )
} }
+36 -144
View File
@@ -1,29 +1,27 @@
/** /**
* Admin 后台 - 仪表盘 * Admin 后台 - 仪表盘
*
* 系统概览:
* - 数据库表行数统计
* - 最近采集状态
* - 预测统计
* - 最近错误日志
*/ */
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { fetchDashboard } from '../dal' import { fetchDashboard, fetchHealth } from '../dal'
import type { DashboardStats, TableStats, CollectionRecord, ErrorLog } from '../types' import type { DashboardStats } from '../types'
import { Card, CardBody, CardHeader, StatCard, DataTable, Badge, EmptyState } from '../components' import { Card, CardBody, CardHeader, StatCard, Badge } from '../components'
export default function Dashboard() { export default function Dashboard() {
const [data, setData] = useState<DashboardStats | null>(null) const [data, setData] = useState<DashboardStats | null>(null)
const [health, setHealth] = useState<any>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
let active = true let active = true
setLoading(true) setLoading(true)
fetchDashboard() Promise.all([fetchDashboard(), fetchHealth()])
.then((stats: DashboardStats) => { .then(([stats, h]) => {
if (active) setData(stats) if (active) {
setData(stats)
setHealth(h)
}
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
if (active) setError(err instanceof Error ? err.message : '加载失败') if (active) setError(err instanceof Error ? err.message : '加载失败')
@@ -31,9 +29,7 @@ export default function Dashboard() {
.finally(() => { .finally(() => {
if (active) setLoading(false) if (active) setLoading(false)
}) })
return () => { return () => { active = false }
active = false
}
}, []) }, [])
if (error) { if (error) {
@@ -41,147 +37,43 @@ export default function Dashboard() {
<div className="rounded-lg border border-red-500/30 bg-red-500/10 p-6 text-center text-red-400"> <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="text-lg font-medium"></p>
<p className="mt-1 text-sm">{error}</p> <p className="mt-1 text-sm">{error}</p>
<button
onClick={() => window.location.reload()}
className="mt-3 rounded bg-red-500/20 px-4 py-1 text-sm hover:bg-red-500/30"
>
</button>
</div> </div>
) )
} }
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* ── 统计卡片 ── */}
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4"> <div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<StatCard <StatCard label="健康状态" value={loading ? '—' : (health?.status === 'healthy' ? '✅ 正常' : '⚠️ 异常')} icon="●" />
label="数据库表数" <StatCard label="联赛数" value={loading ? '—' : data?.leagues.length ?? 0} icon="◫" />
value={loading ? '—' : data?.db_tables.length ?? 0} <StatCard label="比赛数" value={loading ? '—' : data?.total_matches ?? 0} icon="◆" />
icon="" <StatCard label="预测数" value={loading ? '—' : data?.total_predictions ?? 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>
<div className="grid gap-6 lg:grid-cols-2">
{/* ── 数据库表统计 ── */}
<Card> <Card>
<CardHeader title="数据库表状态" /> <CardHeader title="最近联赛" />
<CardBody className="p-0"> <CardBody>
{loading ? ( {loading ? (
<EmptyState text="加载中..." /> <p className="text-gray-400">...</p>
) : data && data.db_tables.length > 0 ? ( ) : data && data.leagues.length > 0 ? (
<DataTable <div className="flex flex-wrap gap-2">
columns={[ {data.leagues.map(l => (
{ key: 'name', label: '表名' }, <Badge key={l.code} status="info">
{ key: 'row_count', label: '行数', width: '80px' }, {l.name_zh || l.name} ({l.code})
{ </Badge>
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>
) : (
<p className="text-gray-400">,</p>
)}
</CardBody>
</Card>
</div> </div>
) )
} }
function statusLabel(status: string): string {
const map: Record<string, string> = {
running: '运行中',
success: '成功',
failed: '失败',
queued: '排队',
completed: '已完成',
}
return map[status] ?? status
}
+22 -225
View File
@@ -1,248 +1,45 @@
/** /**
* Admin 后台 - 监控面板 * Admin 后台 - 监控面板
*
* 功能:
* - 系统健康检查
* - 采集错误日志
* - 死信队列监控
* - 实时状态刷新
*/ */
import { useEffect, useState, useCallback } from 'react' import { useEffect, useState } from 'react'
import { fetchErrorLogs } from '../dal' import { fetchHealth } 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() { export default function MonitoringPage() {
const [logs, setLogs] = useState<ErrorLog[]>([]) const [health, setHealth] = useState<any>(null)
const [health, setHealth] = useState<HealthInfo | null>(null)
const [loading, setLoading] = useState(true) 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(() => { useEffect(() => {
loadData() fetchHealth().then(setHealth).finally(() => setLoading(false))
if (!autoRefresh) return }, [])
const timer = setInterval(loadData, 10_000)
return () => clearInterval(timer)
}, [loadData, autoRefresh])
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<SectionHeader <div>
title="监控面板" <h2 className="text-xl font-semibold text-white"></h2>
description="系统健康状态、错误日志和死信队列监控" <p className="mt-1 text-sm text-gray-400"></p>
/> </div>
{/* ── 系统健康 ── */} {loading ? (
<p className="text-gray-400">...</p>
) : health ? (
<div className="grid gap-4 lg:grid-cols-3"> <div className="grid gap-4 lg:grid-cols-3">
<Card> <div className="rounded border border-gray-700 bg-gray-800 p-4">
<CardHeader title="服务状态" /> <div className="text-sm text-gray-400"></div>
<CardBody> <div className="mt-1 text-xl font-bold text-green-400"> {health.status}</div>
{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>
<div className="space-y-1 text-xs text-gray-500"> <div className="rounded border border-gray-700 bg-gray-800 p-4">
<div>: {health.version ?? '—'}</div> <div className="text-sm text-gray-400"></div>
<div>: {health.uptime ?? '—'}</div> <div className="mt-1 text-xl font-bold text-white">{health.service || 'profeto'}</div>
</div> </div>
<div className="rounded border border-gray-700 bg-gray-800 p-4">
<div className="text-sm text-gray-400"></div>
<div className="mt-1 text-xl font-bold text-blue-400">11 </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> </div>
) : ( ) : (
<EmptyState text="暂无检查项数据" /> <p className="text-gray-400"></p>
)} )}
</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> </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}
/>
)
}
+55 -223
View File
@@ -1,89 +1,38 @@
/** /**
* Admin 后台 - 预测管理页面 * Admin 后台 - 预测管理页面
*
* 功能:
* - 触发手动预测(选择比赛或联赛)
* - 查看预测历史记录
* - 评估结算(回填实际结果)
* - 评估统计摘要
*/ */
import { useEffect, useState, useCallback } from 'react' import { useEffect, useState } from 'react'
import { import { triggerPrediction, fetchPredictions, fetchMatches } from '../dal'
triggerPrediction, import type { Match, Prediction } from '../types'
fetchPredictionHistory, import { Card, CardBody, CardHeader, Badge } from '../components'
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() { export default function PredictionsPage() {
const [leagues, setLeagues] = useState<League[]>([]) const [matches, setMatches] = useState<Match[]>([])
const [history, setHistory] = useState<PredictionHistoryItem[]>([]) const [predictions, setPredictions] = useState<Prediction[]>([])
const [evalData, setEvalData] = useState<EvalSummary | null>(null) const [matchId, setMatchId] = useState('')
const [leagueCode, setLeagueCode] = useState('')
const [mode, setMode] = useState<'single' | 'multi'>('multi') const [mode, setMode] = useState<'single' | 'multi'>('multi')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [page, setPage] = useState(1)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [successMsg, setSuccessMsg] = 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(() => { useEffect(() => {
loadData() fetchPredictions(20).then(setPredictions)
}, [loadData]) fetchMatches({ status: 'scheduled', limit: 20 }).then(d => setMatches(d.items))
}, [])
async function handlePredict(e: React.FormEvent) { async function handlePredict(e: React.FormEvent) {
e.preventDefault() e.preventDefault()
if (!matchId) return
setLoading(true)
setError(null) setError(null)
setSuccessMsg(null) setSuccessMsg(null)
setLoading(true)
try { try {
const body: PredictRequest = { await triggerPrediction({ match_id: parseInt(matchId), mode })
league_code: leagueCode || undefined, setSuccessMsg('预测任务已提交')
mode, fetchPredictions(20).then(setPredictions)
}
await triggerPrediction(body)
setSuccessMsg(`预测任务已启动 (${mode === 'multi' ? '五路专家模式' : '单一模型模式'})`)
loadData()
} catch (err: unknown) { } catch (err: unknown) {
setError(err instanceof Error ? err.message : '预测触发失败') 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 { } finally {
setLoading(false) setLoading(false)
} }
@@ -91,188 +40,71 @@ export default function PredictionsPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<SectionHeader <div>
title="预测管理" <h2 className="text-xl font-semibold text-white"></h2>
description="触发 LLM 预测任务,管理预测历史和评估结算" <p className="mt-1 text-sm text-gray-400"> LLM </p>
/>
{/* ── 评估摘要 ── */}
{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>
)}
<div className="grid gap-6 lg:grid-cols-3"> <div className="grid gap-6 lg:grid-cols-2">
{/* ── 预测触发表单 ── */}
<div className="lg:col-span-1">
<Card> <Card>
<CardHeader <CardHeader title="新建预测" />
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> <CardBody>
<form onSubmit={handlePredict} className="space-y-4"> <form onSubmit={handlePredict} className="space-y-4">
{/* 联赛选择 */}
<div> <div>
<label className="mb-1.5 block text-xs font-medium text-gray-400"></label> <label className="mb-1.5 block text-xs font-medium text-gray-400"></label>
<select <select value={matchId} onChange={e => setMatchId(e.target.value)}
value={leagueCode} className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-white">
onChange={e => setLeagueCode(e.target.value)} <option value=""></option>
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" {matches.map(m => (
> <option key={m.id} value={m.id}>
<option value=""></option> {m.home_team} vs {m.away_team} ({m.match_date?.slice(0, 10)})
{leagues.map(l => (
<option key={l.code} value={l.code}>
{l.name_zh ?? l.name} ({l.code})
</option> </option>
))} ))}
</select> </select>
</div> </div>
{/* 预测模式 */}
<div> <div>
<label className="mb-1.5 block text-xs font-medium text-gray-400"></label> <label className="mb-1.5 block text-xs font-medium text-gray-400"></label>
<div className="flex gap-2"> <select value={mode} onChange={e => setMode(e.target.value as any)}
{(['multi', 'single'] as const).map(m => ( className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-white">
<button <option value="multi"> Agent (5 + )</option>
key={m} <option value="single"></option>
type="button" </select>
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> </div>
{error && ( {error && <div className="rounded bg-red-500/10 p-3 text-sm text-red-400">{error}</div>}
<div className="rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-400"> {successMsg && <div className="rounded bg-green-500/10 p-3 text-sm text-green-400">{successMsg}</div>}
{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 <button type="submit" disabled={loading || !matchId}
type="submit" className="w-full rounded bg-blue-600 py-2 text-white hover:bg-blue-700 disabled:opacity-50">
disabled={loading} {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> </button>
</form> </form>
</CardBody> </CardBody>
</Card> </Card>
</div>
{/* ── 预测历史 ── */}
<div className="lg:col-span-2">
<Card> <Card>
<CardHeader <CardHeader title="最近预测" />
title="预测历史" <CardBody>
action={ {predictions.length === 0 ? (
<div className="flex gap-1"> <p className="text-gray-400"></p>
<button ) : (
onClick={() => setPage(p => Math.max(1, p - 1))} <div className="space-y-2">
disabled={page <= 1} {predictions.slice(0, 10).map(p => (
className="rounded border border-gray-700 px-2 py-0.5 text-xs text-gray-400 hover:border-gray-600 disabled:opacity-30" <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}
</button> </span>
<span className="px-2 text-xs text-gray-500">{page}</span> <Badge status={p.settled ? 'success' : 'warning'}>
<button {p.pred_1x2 || '?'} · {p.subjective_confidence ? `${(p.subjective_confidence * 100).toFixed(0)}%` : '—'}
onClick={() => setPage(p => p + 1)} </Badge>
className="rounded border border-gray-700 px-2 py-0.5 text-xs text-gray-400 hover:border-gray-600"
>
</button>
</div> </div>
} ))}
/> </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> </CardBody>
</Card> </Card>
</div> </div>
</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
}
+79 -174
View File
@@ -1,7 +1,7 @@
/** /**
* Admin 后台 - TypeScript 类型定义 * Admin 后台 - TypeScript 类型定义
* *
* 与 FastAPI 后端 Pydantic 模型对齐,确保类型安全 * 与 FastAPI 后端 Pydantic 模型对齐
*/ */
// ── 系统健康 ──────────────────────────────────────────────────── // ── 系统健康 ────────────────────────────────────────────────────
@@ -16,206 +16,111 @@ export interface HealthStatus {
// ── 仪表盘 ────────────────────────────────────────────────────── // ── 仪表盘 ──────────────────────────────────────────────────────
export interface DashboardStats { export interface DashboardStats {
/** 数据库表行数统计 */ leagues: League[]
db_tables: TableStats[] total_matches: number
/** 最近采集状态 */
last_collection: CollectionRecord[]
/** 预测统计 */
prediction_stats: PredictionStats
/** 最近错误日志 */
recent_errors: ErrorLog[]
}
export interface TableStats {
name: string
row_count: number
size_mb: number
last_updated: string | null
}
export interface CollectionRecord {
source: string
league_code: string | null
started_at: string
finished_at: string | null
status: 'running' | 'success' | 'failed'
records_count: number | null
error_message: string | null
}
export interface PredictionStats {
total_predictions: number total_predictions: number
today_predictions: number health: string
avg_latency_ms: number db_tables: { name: string; row_count: number; size_mb: number; last_updated: string | null }[]
success_rate: number last_collection: { source: string; league_code: string | null; started_at: string; finished_at: string | null; status: string; records_count: number | null; error_message: string | null }[]
recent_errors: { id: number; timestamp: string; source: string; message: string; level: string }[]
} }
export interface ErrorLog { // ── 联赛 & 比赛 ─────────────────────────────────────────────────
export interface League {
id?: number
code: string
name: string
name_zh?: string
country?: string
}
export interface Match {
id: number id: number
timestamp: string league_code?: string
source: string season?: string | null
message: string home_team: string
level: 'error' | 'warning' | 'info' away_team: string
home_team_zh?: string | null
away_team_zh?: string | null
match_date: string
match_status: string
home_goals?: number | null
away_goals?: number | null
match_stage?: string | null
} }
// ── 配置管理 ──────────────────────────────────────────────────── // ── 预测 ────────────────────────────────────────────────────────
export interface ConfigEntry { export interface Prediction {
key: string id: number
value: string /** 脱敏显示,如 sk-****abcd */ match_id: number
description: string provider: string
category: 'llm' | 'datasource' | 'system' model: string
updated_at: string | null prompt_version?: string
mode?: string
pred_home_goals?: number | null
pred_away_goals?: number | null
pred_1x2?: string | null
subjective_confidence?: number | null
reasoning?: string | null
created_at: string
actual_home_goals?: number | null
actual_away_goals?: number | null
settled?: boolean
} }
export interface ConfigUpdate { export interface PredictRequest {
key: string match_id: number
value: string mode?: 'single' | 'multi'
provider?: string
model?: string
} }
// ── 数据采集 ──────────────────────────────────────────────────── // ── 数据采集 ────────────────────────────────────────────────────
export interface CollectionRequest { export interface CollectionRequest {
source: 'bzzoiro' | 'understat' | 'injuries' source: 'bzzoiro' | 'understat' | 'injuries'
league_code?: string leagues?: string[]
league?: string
season?: string season?: string
date_from?: string date_from?: string
date_to?: string date_to?: string
} }
export interface CollectionResponse { // ── 评估 & 回测 ─────────────────────────────────────────────────
task_id: string
source: string
status: 'queued' | 'running' | 'completed' | 'failed'
message: string
}
export interface CollectionTask {
task_id: string
source: string
status: string
progress: number /** 0~100 */
total: number
processed: number
started_at: string
finished_at: string | null
error_message: string | null
}
// ── 预测管理 ────────────────────────────────────────────────────
export interface PredictRequest {
match_id?: number
league_code?: string
home_team?: string
away_team?: string
match_date?: string
mode: 'single' | 'multi'
}
export interface PredictionHistoryItem {
id: number
match_id: number | null
home_team: string | null
away_team: string | null
pred_home_goals: number | null
pred_away_goals: number | null
pred_1x2: string | null
confidence: number | null
created_at: string
status: 'pending' | 'success' | 'failed'
}
// ── 评估 & 结算 ─────────────────────────────────────────────────
export interface SettleResponse {
settled_count: number
failed_count: number
details: SettleDetail[]
}
export interface SettleDetail {
match_id: number
actual_result: string | null
predicted_result: string | null
is_correct: boolean | null
}
export interface EvalSummary { export interface EvalSummary {
total_settled: number summary: Array<{
accuracy_1x2: number provider: string
mae_goals: number model: string
calibration: number total: number
by_league: Record<string, LeagueEval> correct: number
}
export interface LeagueEval {
accuracy: number accuracy: number
count: number avg_rmse: number
calibration: number }>
} }
// ── 回测管理 ────────────────────────────────────────────────────
export interface BacktestRequest { export interface BacktestRequest {
strategy: string league_id?: number
league_codes: string[] date_from?: string
date_from: string date_to?: string
date_to: string mode?: 'single' | 'multi'
initial_bankroll: number limit?: number
model?: string
} }
export interface BacktestResult { export interface BacktestSummary {
task_id: string
status: 'queued' | 'running' | 'completed' | 'failed'
progress: number
metrics: BacktestMetrics | null
trades: BacktestTrade[] | null
error_message: string | null
}
export interface BacktestMetrics {
total_trades: number
win_rate: number
roi: number
profit_loss: number
max_drawdown: number
sharpe_ratio: number | null
final_bankroll: number
}
export interface BacktestTrade {
match_id: number
home_team: string
away_team: string
bet_type: string
stake: number
odds: number
result: 'win' | 'loss' | 'push'
profit: number
match_date: string
}
// ── 联赛 & 比赛 ─────────────────────────────────────────────────
export interface League {
code: string
name: string
name_zh: string | null
country: string | null
}
// ── 通用分页 ────────────────────────────────────────────────────
export interface PageParams {
page: number
page_size: number
}
export interface Paginated<T> {
items: T[]
total: number total: number
page: number scored: number
page_size: number accuracy_1x2?: number
has_next: boolean avg_score_rmse?: number
results?: Array<{
match_id: number
actual_home: number
actual_away: number
pred_home?: number
pred_away?: number
correct_1x2: boolean
}>
} }