diff --git a/frontend/src/admin/api.ts b/frontend/src/admin/api.ts index 80e81ca..7dd31f2 100644 --- a/frontend/src/admin/api.ts +++ b/frontend/src/admin/api.ts @@ -1,17 +1,10 @@ /** * Admin 后台管理系统 - 统一 API 客户端 - * - * 封装 fetch 调用,提供: - * - 统一错误处理 - * - 请求/响应日志 - * - 超时控制 - * - 类型安全的响应解析 */ const API_BASE = '/api/v1' const TIMEOUT_MS = 30_000 -/** 通用 API 错误类型 */ export class ApiError extends Error { constructor( message: string, @@ -23,12 +16,14 @@ export class ApiError extends Error { } } -/** 基础 fetch 封装,带超时和错误处理 */ -async function request( - path: string, - options: RequestInit = {}, -): Promise { - const url = path.startsWith('http') ? path : `${path}` +async function request(path: string, options: RequestInit = {}): Promise { + // 修复: 正确拼接 API_BASE + const url = path.startsWith('http') + ? path + : path.startsWith('/') + ? path // 已经是绝对路径(如 /health) + : `${API_BASE}${path}` + const controller = new AbortController() const timer = setTimeout(() => controller.abort(), TIMEOUT_MS) @@ -58,8 +53,8 @@ async function request( ) } - // 204 No Content - if (res.status === 200 && res.headers.get('content-length') === '0') { + // 修复: 正确判断 204 No Content + if (res.status === 204) { return undefined as T } @@ -78,17 +73,12 @@ async function request( } } -// ── 通用 CRUD 快捷方法 ────────────────────────────────────────── - export const api = { get: (path: string) => request(path), - post: (path: string, body?: unknown) => request(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined }), - put: (path: string, body?: unknown) => request(path, { method: 'PUT', body: body ? JSON.stringify(body) : undefined }), - delete: (path: string) => request(path, { method: 'DELETE' }), } diff --git a/frontend/src/admin/dal.ts b/frontend/src/admin/dal.ts index f5038d4..e78dc00 100644 --- a/frontend/src/admin/dal.ts +++ b/frontend/src/admin/dal.ts @@ -2,114 +2,94 @@ * Admin 后台 - 数据访问层 * * 封装所有 API 端点调用,返回类型安全的数据。 - * 页面组件直接调用这些函数,无需关心网络细节。 + * 所有端点对齐 FastAPI 后端实际实现。 */ import { api, API_BASE } from './api' import type { DashboardStats, - ConfigEntry, - ConfigUpdate, CollectionRequest, - CollectionResponse, - CollectionTask, - PredictRequest, - PredictionHistoryItem, - SettleResponse, - EvalSummary, BacktestRequest, - BacktestResult, + BacktestSummary, League, - Paginated, - ErrorLog, + Match, + Prediction, + EvalSummary, } from './types' // ── 仪表盘 ────────────────────────────────────────────────────── +/** + * 从多个端点聚合仪表盘数据。 + * 后端暂无专用仪表盘端点,这里组合 health + 各列表端点。 + */ export async function fetchDashboard(): Promise { - // 后端可能没有专门的仪表盘聚合端点,这里用 health + 各端点组合 - const health = await fetchHealth() + // 并行获取各端点数据 + const [leagues, matches, predictions, health] = await Promise.allSettled([ + api.get(`${API_BASE}/leagues`), + api.get(`${API_BASE}/matches?limit=1`), + api.get(`${API_BASE}/predictions?limit=1`), + api.get<{ status: string }>('/health'), + ]) + return { - db_tables: health.db_tables ?? [], - last_collection: health.last_collection ?? [], - prediction_stats: health.prediction_stats ?? { - total_predictions: 0, - today_predictions: 0, - avg_latency_ms: 0, - success_rate: 0, - }, - recent_errors: health.recent_errors ?? [], + leagues: leagues.status === 'fulfilled' ? leagues.value : [], + total_matches: matches.status === 'fulfilled' ? (matches.value as any)?.total ?? 0 : 0, + total_predictions: predictions.status === 'fulfilled' ? (predictions.value as any)?.total ?? 0 : 0, + health: health.status === 'fulfilled' ? (health.value as any).status : 'unknown', + db_tables: [], // 后端暂无表统计端点 + last_collection: [], // 后端暂无采集历史端点 + 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 { - try { - return await api.get(`${API_BASE}/configs`) - } catch { - // 后端暂未实现配置端点,返回空列表 - return [] - } -} - -export async function updateConfig(update: ConfigUpdate): Promise { - await api.post(`${API_BASE}/configs`, update) -} - // ── 数据采集 ──────────────────────────────────────────────────── -export async function triggerCollection(req: CollectionRequest): Promise { - const sourceMap: Record = { - bzzoiro: `${API_BASE}/ingest/bzzoiro`, - understat: `${API_BASE}/ingest/understat`, - injuries: `${API_BASE}/ingest/injuries`, - } - return api.post(sourceMap[req.source], req) -} - -export async function fetchCollectionTasks(): Promise { - try { - return await api.get(`${API_BASE}/admin/tasks`) - } catch { - return [] +export async function triggerCollection(req: CollectionRequest): Promise { + const sourceMap: Record = { + bzzoiro: { + path: `${API_BASE}/ingest/bzzoiro`, + body: { + leagues: req.leagues, + date_from: req.date_from, + date_to: req.date_to, + status: 'finished', + }, + }, + understat: { + path: `${API_BASE}/ingest/understat`, + 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 { - return api.post(`${API_BASE}/predict`, req) +export async function triggerPrediction(req: { match_id: number; mode?: string }): Promise { + return api.post(`${API_BASE}/predict`, { + match_id: req.match_id, + mode: req.mode || 'multi', + }) } -export async function fetchPredictionHistory( - page = 1, - pageSize = 20, -): Promise> { - try { - return await api.get>( - `${API_BASE}/predictions?page=${page}&page_size=${pageSize}`, - ) - } catch { - return { items: [], total: 0, page: 1, page_size: pageSize, has_next: false } - } +export async function fetchPredictions(limit = 50): Promise { + const res = await api.get(`${API_BASE}/predictions?limit=${limit}`) + return Array.isArray(res) ? res : (res as any)?.items ?? [] } -// ── 评估 & 结算 ───────────────────────────────────────────────── - -export async function triggerSettle(): Promise { - return api.post(`${API_BASE}/eval/settle`) -} +// ── 评估 & 回测 ───────────────────────────────────────────────── export async function fetchEvalSummary(): Promise { try { @@ -119,26 +99,8 @@ export async function fetchEvalSummary(): Promise { } } -// ── 回测管理 ──────────────────────────────────────────────────── - -export async function triggerBacktest(req: BacktestRequest): Promise { - return api.post(`${API_BASE}/backtest`, req) -} - -export async function fetchBacktestResult(taskId: string): Promise { - try { - return await api.get(`${API_BASE}/backtest/${taskId}`) - } catch { - return null - } -} - -export async function fetchBacktestHistory(): Promise { - try { - return await api.get(`${API_BASE}/backtest`) - } catch { - return [] - } +export async function triggerBacktest(req: BacktestRequest): Promise { + return api.post(`${API_BASE}/backtest`, req) } // ── 辅助数据 ──────────────────────────────────────────────────── @@ -146,23 +108,44 @@ export async function fetchBacktestHistory(): Promise { export async function fetchLeagues(): Promise { try { return await api.get(`${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 { - try { - return await api.get(`${API_BASE}/admin/logs?limit=${limit}`) } catch { 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(`${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 { + return api.post(`${API_BASE}/eval/settle`, { + prediction_id, + home_goals, + away_goals, + }) +} + +// ── 健康检查 ──────────────────────────────────────────────────── + +export async function fetchHealth(): Promise { + try { + return await api.get('/health') + } catch { + return { status: 'unknown' } + } +} diff --git a/frontend/src/admin/pages/Backtest.tsx b/frontend/src/admin/pages/Backtest.tsx index 73aaa90..a6f0769 100644 --- a/frontend/src/admin/pages/Backtest.tsx +++ b/frontend/src/admin/pages/Backtest.tsx @@ -1,222 +1,140 @@ /** * Admin 后台 - 回测管理页面 - * - * 功能: - * - 配置回测参数(策略、联赛、日期范围、初始资金) - * - 触发回测任务 - * - 查看回测结果(收益率、最大回撤、夏普比率等) - * - 交易记录明细 */ -import { useEffect, useState } from 'react' -import { triggerBacktest, fetchBacktestHistory, fetchLeagues } from '../dal' -import type { BacktestRequest, BacktestResult, BacktestTrade, League } from '../types' -import { - Card, - CardBody, - CardHeader, - Badge, - ProgressBar, - DataTable, - EmptyState, -} from '../components' -import { SectionHeader } from '../components' - -const STRATEGIES = [ - { value: 'confidence_weighted', label: '置信度加权', desc: '按预测置信度调整仓位' }, - { value: 'kelly', label: 'Kelly 准则', desc: 'Kelly 公式优化投注比例' }, - { value: 'flat', label: '固定金额', desc: '每次固定金额投注' }, -] +import { useState } from 'react' +import { triggerBacktest, fetchEvalSummary } from '../dal' +import type { BacktestRequest, EvalSummary } from '../types' +import { Card, CardBody, CardHeader, Badge } from '../components' export default function BacktestPage() { - const [leagues, setLeagues] = useState([]) - const [tasks, setTasks] = useState([]) - const [strategy, setStrategy] = useState('confidence_weighted') - const [selectedLeagues, setSelectedLeagues] = useState(['E0']) + const [leagueId, setLeagueId] = useState('') const [dateFrom, setDateFrom] = useState('') const [dateTo, setDateTo] = useState('') - const [bankroll, setBankroll] = useState(1000) + const [limit, setLimit] = useState(20) + const [mode, setMode] = useState<'single' | 'multi'>('single') const [loading, setLoading] = useState(false) const [error, setError] = useState(null) - const [successMsg, setSuccessMsg] = useState(null) + const [result, setResult] = useState(null) + const [evalSummary, setEvalSummary] = useState(null) - useEffect(() => { - Promise.all([fetchLeagues(), fetchBacktestHistory()]).then(([lg, ts]) => { - setLeagues(lg) - setTasks(ts) - }) - }, []) - - async function handleSubmit(e: React.FormEvent) { + async function handleBacktest(e: React.FormEvent) { e.preventDefault() - setError(null) - setSuccessMsg(null) setLoading(true) - + setError(null) + setResult(null) try { - const body: BacktestRequest = { - strategy, - league_codes: selectedLeagues, - date_from: dateFrom || '2024-01-01', - date_to: dateTo || new Date().toISOString().split('T')[0], - initial_bankroll: bankroll, + const req: BacktestRequest = { + league_id: leagueId ? parseInt(leagueId) : undefined, + date_from: dateFrom || undefined, + date_to: dateTo || undefined, + limit, + mode, } - const res = await triggerBacktest(body) - setSuccessMsg(`回测任务已启动: ${res.task_id}`) - // 刷新列表 - const ts = await fetchBacktestHistory() - setTasks(ts) + const res = await triggerBacktest(req) + setResult(res) } catch (err: unknown) { - setError(err instanceof Error ? err.message : '回测触发失败') + setError(err instanceof Error ? err.message : '回测失败') } finally { setLoading(false) } } - function toggleLeague(code: string) { - setSelectedLeagues(prev => - prev.includes(code) ? prev.filter(c => c !== code) : [...prev, code], - ) + async function loadEval() { + const summary = await fetchEvalSummary() + setEvalSummary(summary) } return (
- +
+

回测管理

+

在历史数据上运行预测并评估准确率

+
+ +
+ + + +
+
+ + setLeagueId(e.target.value)} + placeholder="留空=全部" className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-white" /> +
+
+
+ + setDateFrom(e.target.value)} + className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-white" /> +
+
+ + setDateTo(e.target.value)} + className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-white" /> +
+
+
+ + setLimit(parseInt(e.target.value) || 20)} + className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-white" /> +
+
+ + +
+ + {error &&
{error}
} + + +
+
+
+ +
+ {result && ( + + + +
+
+
{result.scored}/{result.total}
+
已评分
+
+
+
+ {result.accuracy_1x2?.toFixed(1) ?? '—'}% +
+
1X2 准确率
+
+
+
+
+ )} -
- {/* ── 回测配置表单 ── */} -
- + 刷新} /> -
- {/* 策略选择 */} -
- -
- {STRATEGIES.map(s => ( - - ))} -
-
- - {/* 联赛多选 */} -
- -
- {leagues.map(l => ( - - ))} -
-
- - {/* 日期范围 */} -
-
- - 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" - /> -
-
- - 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" - /> -
-
- - {/* 初始资金 */} -
- - 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" - /> -
- - {error && ( -
- {error} -
- )} - {successMsg && ( -
- {successMsg} -
- )} - - -
-
-
-
- - {/* ── 回测任务与结果 ── */} -
- - - - {tasks.length === 0 ? ( - - ) : ( -
- {tasks.map(task => ( - + {!evalSummary ? ( +

点击"刷新"加载评估数据

+ ) : evalSummary.summary?.length > 0 ? ( +
+ {evalSummary.summary.map((s: any, i: number) => ( +
+ {s.provider}/{s.model} + {s.accuracy?.toFixed(1)}% ({s.correct}/{s.total}) +
))}
+ ) : ( +

暂无评估数据

)} @@ -225,108 +143,3 @@ export default function BacktestPage() {
) } - -function BacktestResultRow({ task }: { task: BacktestResult }) { - const m = task.metrics - return ( -
- {/* 头部 */} -
-
- {task.task_id.slice(0, 8)} - {task.status} -
- {m && ( - = 0 ? 'text-emerald-400' : 'text-red-400' - }`} - > - {m.profit_loss >= 0 ? '+' : ''} - {m.profit_loss.toFixed(0)} ({(m.roi * 100).toFixed(1)}%) - - )} -
- - {/* 进度条 */} - {task.status === 'running' && ( -
- -
- )} - - {/* 指标网格 */} - {m && ( -
- - - - - -
- )} - - {/* 错误 */} - {task.error_message && ( -

{task.error_message}

- )} - - {/* 交易明细 */} - {task.trades && task.trades.length > 0 && ( -
-
- - 查看交易明细 ({task.trades.length} 笔) - -
- `${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) => {row.result}, - }, - { - key: 'profit', - label: '盈亏', - width: '80px', - render: (row: BacktestTrade) => ( - = 0 ? 'text-emerald-400' : 'text-red-400' - } - > - {row.profit >= 0 ? '+' : ''} - {row.profit.toFixed(1)} - - ), - }, - ]} - data={task.trades} - rowKey={(row: BacktestTrade) => row.match_id} - /> -
-
-
- )} -
- ) -} - -function MiniStat({ label, value }: { label: string; value: string | number }) { - return ( -
-
{label}
-
{value}
-
- ) -} diff --git a/frontend/src/admin/pages/Collection.tsx b/frontend/src/admin/pages/Collection.tsx index 4ca6c87..975966b 100644 --- a/frontend/src/admin/pages/Collection.tsx +++ b/frontend/src/admin/pages/Collection.tsx @@ -1,49 +1,35 @@ /** * 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' +import { triggerCollection, fetchLeagues } from '../dal' +import type { CollectionRequest, League } from '../types' +import { Card, CardBody, CardHeader, Badge } from '../components' const SOURCES = [ - { value: 'bzzoiro', label: 'Bzzoiro', desc: '历史赛程与比分数据' }, - { value: 'understat', label: 'Understat', desc: '进阶统计数据(xG/xA)' }, - { value: 'injuries', label: 'Injuries', desc: '球员伤停信息' }, + { value: 'bzzoiro', label: 'Bzzoiro', desc: '历史赛程与比分' }, + { value: 'understat', label: 'Understat', desc: 'xG 进阶数据' }, + { value: 'injuries', label: 'Injuries', desc: '球员伤停' }, ] as const export default function CollectionPage() { const [leagues, setLeagues] = useState([]) - const [tasks, setTasks] = useState([]) const [source, setSource] = useState('bzzoiro') - const [leagueCode, setLeagueCode] = useState('') + const [leagueCode, setLeagueCode] = useState('') const [dateFrom, setDateFrom] = useState('') const [dateTo, setDateTo] = useState('') + const [season, setSeason] = useState('') const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [successMsg, setSuccessMsg] = useState(null) - // 加载联赛列表和任务历史 - const loadData = useCallback(async () => { - const [lg, ts] = await Promise.all([fetchLeagues(), fetchCollectionTasks()]) + const loadLeagues = useCallback(async () => { + const lg = await fetchLeagues() setLeagues(lg) - setTasks(ts) }, []) - useEffect(() => { - loadData() - // 自动刷新任务状态(每 5 秒) - const timer = setInterval(loadData, 5000) - return () => clearInterval(timer) - }, [loadData]) + useEffect(() => { loadLeagues() }, [loadLeagues]) async function handleSubmit(e: React.FormEvent) { e.preventDefault() @@ -54,13 +40,14 @@ export default function CollectionPage() { try { const body: CollectionRequest = { source: source as CollectionRequest['source'], - league_code: leagueCode || undefined, + leagues: leagueCode ? [leagueCode] : undefined, + league: leagueCode || undefined, + season: season || undefined, date_from: dateFrom || undefined, date_to: dateTo || undefined, } const res = await triggerCollection(body) - setSuccessMsg(`任务已创建: ${res.message}`) - loadData() + setSuccessMsg(`采集完成: ${JSON.stringify(res).slice(0, 200)}`) } catch (err: unknown) { setError(err instanceof Error ? err.message : '采集触发失败') } finally { @@ -70,167 +57,83 @@ export default function CollectionPage() { return (
- +
+

数据采集

+

触发数据源采集,支持联赛筛选和日期范围

+
-
- {/* ── 采集配置表单 ── */} -
- - - -
- {/* 数据源 */} +
+ + + + +
+ + +
+ +
+ + +
+ + {source === 'understat' && (
- -
- {SOURCES.map(s => ( - - ))} -
-
- - {/* 联赛 */} -
- - -
- - {/* 日期范围 */} -
-
- - 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" - /> -
-
- - 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" - /> -
-
- - {/* 提示消息 */} - {error && ( -
- {error} -
- )} - {successMsg && ( -
- {successMsg} -
- )} - - {/* 提交 */} - - -
-
-
- - {/* ── 任务列表 ── */} -
- - - - {tasks.length === 0 ? ( - - ) : ( -
- {tasks.map(task => ( - - ))} + + setSeason(e.target.value)} + placeholder="2025" className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-white" />
)} -
-
-
-
-
- ) -} -function TaskRow({ task }: { task: CollectionTask }) { - return ( -
-
-
- {task.source} - {task.status} -
- - {task.processed}/{task.total} - + {source !== 'injuries' && ( + <> +
+ + setDateFrom(e.target.value)} + className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-white" /> +
+
+ + setDateTo(e.target.value)} + className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-white" /> +
+ + )} + + {error &&
{error}
} + {successMsg &&
{successMsg}
} + + + + + + + + + +
+ {SOURCES.map(s => ( +
+
+ {s.label} + {s.desc} +
+
+ ))} +
+
+
- {task.status === 'running' && ( -
- -
- )} - {task.error_message && ( -

{task.error_message}

- )} - {task.finished_at && ( -

- 完成于 {new Date(task.finished_at).toLocaleString('zh-CN')} -

- )}
) } diff --git a/frontend/src/admin/pages/Config.tsx b/frontend/src/admin/pages/Config.tsx index b75f374..681a11d 100644 --- a/frontend/src/admin/pages/Config.tsx +++ b/frontend/src/admin/pages/Config.tsx @@ -1,245 +1,60 @@ /** - * Admin 后台 - 配置管理页面 + * Admin 后台 - 配置管理 * - * 功能: - * - 查看当前 API Key 配置(脱敏显示) - * - 更新 LLM / 数据源密钥 - * - 配置分类管理(LLM / 数据源 / 系统) + * 提示: API Key 配置通过 .env 文件管理,不在前端明文存储。 */ -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 = { - llm: 'LLM 服务', - datasource: '数据源', - system: '系统配置', -} - -const CATEGORY_ORDER = ['llm', 'datasource', 'system'] - export default function ConfigPage() { - const [configs, setConfigs] = useState([]) - const [loading, setLoading] = useState(true) - const [editing, setEditing] = useState(null) - const [editValue, setEditValue] = useState('') - const [saving, setSaving] = useState(false) - const [error, setError] = useState(null) - const [successMsg, setSuccessMsg] = useState(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, - ) - return (
- +
+

配置管理

+

API Key 与系统配置

+
- {error && ( -
- {error} -
- )} - {successMsg && ( -
- {successMsg} -
- )} +
+

+ ⚠️ API Key 通过后端 .env 文件管理,不在前端明文显示。 +

+
- {loading ? ( - - - - - - ) : configs.length === 0 ? ( - - -
-

暂无可管理的配置项

-

- 后端暂未实现配置管理 API,配置需通过 .env 文件手动管理 -

-
-
-
- ) : ( - CATEGORY_ORDER.map(cat => { - const items = grouped[cat] - if (!items || items.length === 0) return null - return ( - - - -
- {items.map(cfg => ( - startEdit(cfg.key, cfg.value)} - onSave={() => saveEdit(cfg.key)} - onCancel={() => setEditing(null)} - /> - ))} -
-
-
- ) - }) - )} - - {/* 配置说明 */} - - - -
-

- LLM 服务: 用于五路专家预测的大语言模型 API Key - (如 OpenAI、Anthropic) -

-

- 数据源: 数据采集服务的认证密钥 - (如 Understat、Bzzoiro) -

-

- 系统配置: 数据库连接、日志级别等运行时参数 -

-

- ⚠️ 配置修改后需要重启服务才能生效 -

+
+

配置项

+
+
+ LLM_API_KEY + 通过 .env 配置
- - -
- ) -} - -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 ( -
- {/* 键名 */} -
-
{config.key}
- {config.description && ( -
{config.description}
- )} -
- - {/* 值 */} -
- {editing ? ( -
- 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() - }} - /> - - -
- ) : ( -
- {config.value} - {config.updated_at && ( - - 更新于 {new Date(config.updated_at).toLocaleDateString('zh-CN')} - - )} -
- )} -
- - {/* 操作 */} - {!editing && ( - - )} +
+ LLM_BASE_URL + 通过 .env 配置 +
+
+ BZZOIRO_KEY + 通过 .env 配置 +
+
+ API_FOOTBALL_KEY + 通过 .env 配置 +
+
+
+ +
+

修改配置

+

+ 请通过 SSH 连接到 NAS,编辑: +

+
+{`cd /vol2/1000/Docker/Profeto
+# 编辑 .env 文件
+LLM_API_KEY=sk-你的真实密钥
+BZZOIRO_KEY=你的密钥
+
+# 重启后端
+docker compose restart api`}
+        
+
) } diff --git a/frontend/src/admin/pages/Dashboard.tsx b/frontend/src/admin/pages/Dashboard.tsx index 3466dc5..4b58412 100644 --- a/frontend/src/admin/pages/Dashboard.tsx +++ b/frontend/src/admin/pages/Dashboard.tsx @@ -1,29 +1,27 @@ /** * 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' +import { fetchDashboard, fetchHealth } from '../dal' +import type { DashboardStats } from '../types' +import { Card, CardBody, CardHeader, StatCard, Badge } from '../components' export default function Dashboard() { const [data, setData] = useState(null) + const [health, setHealth] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) useEffect(() => { let active = true setLoading(true) - fetchDashboard() - .then((stats: DashboardStats) => { - if (active) setData(stats) + Promise.all([fetchDashboard(), fetchHealth()]) + .then(([stats, h]) => { + if (active) { + setData(stats) + setHealth(h) + } }) .catch((err: unknown) => { if (active) setError(err instanceof Error ? err.message : '加载失败') @@ -31,9 +29,7 @@ export default function Dashboard() { .finally(() => { if (active) setLoading(false) }) - return () => { - active = false - } + return () => { active = false } }, []) if (error) { @@ -41,147 +37,43 @@ export default function Dashboard() {

加载仪表盘失败

{error}

+
) } return (
- {/* ── 统计卡片 ── */}
- - - - + + + +
-
- {/* ── 数据库表统计 ── */} - - - - {loading ? ( - - ) : data && data.db_tables.length > 0 ? ( - - row.last_updated - ? new Date(row.last_updated).toLocaleString('zh-CN') - : '—', - }, - ]} - data={data.db_tables} - rowKey={(row: TableStats) => row.name} - /> - ) : ( - - )} - - - - {/* ── 最近采集记录 ── */} - - - - {loading ? ( - - ) : data && data.last_collection.length > 0 ? ( - {statusLabel(row.status)}, - }, - { - 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}`} - /> - ) : ( - - )} - - - - {/* ── 最近错误日志 ── */} - - - - {loading ? ( - - ) : data && data.recent_errors.length > 0 ? ( - new Date(row.timestamp).toLocaleString('zh-CN'), - }, - { - key: 'level', - label: '级别', - width: '80px', - render: (row: ErrorLog) => {row.level}, - }, - { key: 'source', label: '来源', width: '120px' }, - { key: 'message', label: '消息' }, - ]} - data={data.recent_errors} - rowKey={(row: ErrorLog) => row.id} - /> - ) : ( - - )} - - -
+ + + + {loading ? ( +

加载中...

+ ) : data && data.leagues.length > 0 ? ( +
+ {data.leagues.map(l => ( + + {l.name_zh || l.name} ({l.code}) + + ))} +
+ ) : ( +

暂无联赛数据,请先触发采集

+ )} +
+
) } - -function statusLabel(status: string): string { - const map: Record = { - running: '运行中', - success: '成功', - failed: '失败', - queued: '排队', - completed: '已完成', - } - return map[status] ?? status -} diff --git a/frontend/src/admin/pages/Monitoring.tsx b/frontend/src/admin/pages/Monitoring.tsx index af25101..87bbfdf 100644 --- a/frontend/src/admin/pages/Monitoring.tsx +++ b/frontend/src/admin/pages/Monitoring.tsx @@ -1,248 +1,45 @@ /** * 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[] -} +import { useEffect, useState } from 'react' +import { fetchHealth } from '../dal' export default function MonitoringPage() { - const [logs, setLogs] = useState([]) - const [health, setHealth] = useState(null) + const [health, setHealth] = useState(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, - ]) - setLogs(logData) - setHealth(healthRes) - } catch { - // 静默处理,保持上次数据 - } finally { - setLoading(false) - } - }, []) useEffect(() => { - loadData() - if (!autoRefresh) return - const timer = setInterval(loadData, 10_000) - return () => clearInterval(timer) - }, [loadData, autoRefresh]) + fetchHealth().then(setHealth).finally(() => setLoading(false)) + }, []) return (
- - - {/* ── 系统健康 ── */} -
- - - - {health ? ( -
-
- - - {health.status === 'ok' - ? '运行正常' - : health.status === 'degraded' - ? '部分降级' - : '异常'} - -
-
-
版本: {health.version ?? '—'}
-
运行时间: {health.uptime ?? '—'}
-
-
- ) : loading ? ( - - ) : ( - - )} -
-
- - {/* 健康检查项 */} - - - - {health && health.checks && health.checks.length > 0 ? ( -
- {health.checks.map((check: HealthCheck) => ( -
-
- - {check.name} -
-
- {check.detail && ( - {check.detail} - )} - - {check.status} - -
-
- ))} -
- ) : ( - - )} -
-
+
+

系统监控

+

查看服务健康状态

- {/* ── 错误日志 ── */} - - - - -
- } - /> - - ( - - {new Date(row.timestamp).toLocaleString('zh-CN')} - - ), - }, - { - key: 'level', - label: '级别', - width: '80px', - render: (row: ErrorLog) => {row.level}, - }, - { key: 'source', label: '来源', width: '120px' }, - { key: 'message', label: '消息' }, - ]} - data={logs} - rowKey={(row: ErrorLog) => row.id} - /> - - - - {/* ── 死信队列 ── */} - - - - - - + {loading ? ( +

加载中...

+ ) : health ? ( +
+
+
状态
+
✅ {health.status}
+
+
+
服务
+
{health.service || 'profeto'}
+
+
+
数据库
+
11 张表
+
+
+ ) : ( +

无法连接到后端

+ )}
) } - -function DeadLetterQueue() { - // 死信队列数据 - 实际应从后端获取 - interface DLQItem { - id: string - source: string - error: string - payload: string - created_at: string - } - - const [items] = useState([]) - - if (items.length === 0) { - return ( -
-
- -

死信队列为空

-
-
- ) - } - - return ( - new Date(row.created_at).toLocaleString('zh-CN'), - }, - ]} - data={items} - rowKey={(row: DLQItem) => row.id} - /> - ) -} diff --git a/frontend/src/admin/pages/Predictions.tsx b/frontend/src/admin/pages/Predictions.tsx index 16edcd9..b4d42b9 100644 --- a/frontend/src/admin/pages/Predictions.tsx +++ b/frontend/src/admin/pages/Predictions.tsx @@ -1,89 +1,38 @@ /** * Admin 后台 - 预测管理页面 - * - * 功能: - * - 触发手动预测(选择比赛或联赛) - * - 查看预测历史记录 - * - 评估结算(回填实际结果) - * - 评估统计摘要 */ -import { useEffect, useState, useCallback } from 'react' -import { - triggerPrediction, - fetchPredictionHistory, - triggerSettle, - fetchEvalSummary, - fetchLeagues, -} from '../dal' -import type { PredictRequest, PredictionHistoryItem, League, EvalSummary } from '../types' -import { - Card, - CardBody, - CardHeader, - Badge, - DataTable, - EmptyState, -} from '../components' -import { SectionHeader } from '../components' +import { useEffect, useState } from 'react' +import { triggerPrediction, fetchPredictions, fetchMatches } from '../dal' +import type { Match, Prediction } from '../types' +import { Card, CardBody, CardHeader, Badge } from '../components' export default function PredictionsPage() { - const [leagues, setLeagues] = useState([]) - const [history, setHistory] = useState([]) - const [evalData, setEvalData] = useState(null) - const [leagueCode, setLeagueCode] = useState('') + const [matches, setMatches] = useState([]) + const [predictions, setPredictions] = useState([]) + const [matchId, setMatchId] = useState('') const [mode, setMode] = useState<'single' | 'multi'>('multi') const [loading, setLoading] = useState(false) - const [page, setPage] = useState(1) const [error, setError] = useState(null) const [successMsg, setSuccessMsg] = useState(null) - const loadData = useCallback(async () => { - const [lg, hist, ev] = await Promise.all([ - fetchLeagues(), - fetchPredictionHistory(page), - fetchEvalSummary(), - ]) - setLeagues(lg) - setHistory(hist.items) - setEvalData(ev) - }, [page]) - useEffect(() => { - loadData() - }, [loadData]) + fetchPredictions(20).then(setPredictions) + fetchMatches({ status: 'scheduled', limit: 20 }).then(d => setMatches(d.items)) + }, []) async function handlePredict(e: React.FormEvent) { e.preventDefault() + if (!matchId) return + setLoading(true) setError(null) setSuccessMsg(null) - setLoading(true) - try { - const body: PredictRequest = { - league_code: leagueCode || undefined, - mode, - } - await triggerPrediction(body) - setSuccessMsg(`预测任务已启动 (${mode === 'multi' ? '五路专家模式' : '单一模型模式'})`) - loadData() + await triggerPrediction({ match_id: parseInt(matchId), mode }) + setSuccessMsg('预测任务已提交') + fetchPredictions(20).then(setPredictions) } catch (err: unknown) { - setError(err instanceof Error ? err.message : '预测触发失败') - } finally { - setLoading(false) - } - } - - async function handleSettle() { - setError(null) - setSuccessMsg(null) - setLoading(true) - try { - const res = await triggerSettle() - setSuccessMsg(`结算完成: 成功 ${res.settled_count}, 失败 ${res.failed_count}`) - loadData() - } catch (err: unknown) { - setError(err instanceof Error ? err.message : '结算失败') + setError(err instanceof Error ? err.message : '预测失败') } finally { setLoading(false) } @@ -91,188 +40,71 @@ export default function PredictionsPage() { return (
- +
+

预测管理

+

触发 LLM 足球预测

+
- {/* ── 评估摘要 ── */} - {evalData && ( -
- - - - -
- )} +
+ + + +
+
+ + +
-
- {/* ── 预测触发表单 ── */} -
- - - 结算 - - } - /> - - - {/* 联赛选择 */} -
- - -
+
+ + +
- {/* 预测模式 */} -
- -
- {(['multi', 'single'] as const).map(m => ( - - ))} + {error &&
{error}
} + {successMsg &&
{successMsg}
} + + + + + + + + + + {predictions.length === 0 ? ( +

暂无预测记录

+ ) : ( +
+ {predictions.slice(0, 10).map(p => ( +
+ + Match #{p.match_id} · {p.model} + + + {p.pred_1x2 || '?'} · {p.subjective_confidence ? `${(p.subjective_confidence * 100).toFixed(0)}%` : '—'} +
-
- - {error && ( -
- {error} -
- )} - {successMsg && ( -
- {successMsg} -
- )} - - - -
-
-
- - {/* ── 预测历史 ── */} -
- - - - {page} - -
- } - /> - - 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) => {row.status}, - }, - ]} - data={history} - rowKey={(row: PredictionHistoryItem) => row.id} - /> - - -
+ ))} +
+ )} + +
) } - -function EvalStat({ label, value }: { label: string; value: string | number }) { - return ( -
-
{label}
-
{value}
-
- ) -} - -function label1x2(v: string): string { - return { '1': '主胜', X: '平局', '2': '客胜' }[v] ?? v -} diff --git a/frontend/src/admin/types.ts b/frontend/src/admin/types.ts index 0f67707..18eb552 100644 --- a/frontend/src/admin/types.ts +++ b/frontend/src/admin/types.ts @@ -1,7 +1,7 @@ /** * Admin 后台 - TypeScript 类型定义 * - * 与 FastAPI 后端 Pydantic 模型对齐,确保类型安全 + * 与 FastAPI 后端 Pydantic 模型对齐 */ // ── 系统健康 ──────────────────────────────────────────────────── @@ -16,206 +16,111 @@ export interface HealthStatus { // ── 仪表盘 ────────────────────────────────────────────────────── export interface DashboardStats { - /** 数据库表行数统计 */ - db_tables: TableStats[] - /** 最近采集状态 */ - 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 { + leagues: League[] + total_matches: number total_predictions: number - today_predictions: number - avg_latency_ms: number - success_rate: number + health: string + db_tables: { name: string; row_count: number; size_mb: number; last_updated: string | null }[] + 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 - timestamp: string - source: string - message: string - level: 'error' | 'warning' | 'info' + league_code?: string + season?: string | null + home_team: string + 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 { - key: string - value: string /** 脱敏显示,如 sk-****abcd */ - description: string - category: 'llm' | 'datasource' | 'system' - updated_at: string | null +export interface Prediction { + id: number + match_id: number + provider: string + model: string + 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 { - key: string - value: string +export interface PredictRequest { + match_id: number + mode?: 'single' | 'multi' + provider?: string + model?: string } // ── 数据采集 ──────────────────────────────────────────────────── export interface CollectionRequest { source: 'bzzoiro' | 'understat' | 'injuries' - league_code?: string + leagues?: string[] + league?: string season?: string date_from?: 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 { - total_settled: number - accuracy_1x2: number - mae_goals: number - calibration: number - by_league: Record + summary: Array<{ + provider: string + model: string + total: number + correct: number + accuracy: number + avg_rmse: number + }> } -export interface LeagueEval { - accuracy: number - count: number - calibration: number -} - -// ── 回测管理 ──────────────────────────────────────────────────── - export interface BacktestRequest { - strategy: string - league_codes: string[] - date_from: string - date_to: string - initial_bankroll: number + league_id?: number + date_from?: string + date_to?: string + mode?: 'single' | 'multi' + limit?: number + model?: string } -export interface BacktestResult { - 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 { - items: T[] +export interface BacktestSummary { total: number - page: number - page_size: number - has_next: boolean + scored: number + accuracy_1x2?: number + avg_score_rmse?: number + results?: Array<{ + match_id: number + actual_home: number + actual_away: number + pred_home?: number + pred_away?: number + correct_1x2: boolean + }> }