diff --git a/.claude/settings.local.json b/.claude/settings.local.json
new file mode 100644
index 0000000..e33aeee
--- /dev/null
+++ b/.claude/settings.local.json
@@ -0,0 +1,43 @@
+{
+ "permissions": {
+ "allow": [
+ "Bash(python -m pytest tests/ -v)",
+ "Bash(python -c \"import src.core.http_client; import src.llm.provider; import src.data.understat; import src.data.injuries; import src.llm.agents.orchestrator; import src.llm.context_builder; import src.api.app; print\\('All imports OK'\\)\")",
+ "Bash(python -c \"from src.data.sources import get_source, list_sources; print\\('sources:', list_sources\\(\\)\\); print\\('bzzoiro:', get_source\\('bzzoiro'\\).name\\); print\\('understat:', get_source\\('understat'\\).name\\)\")",
+ "Bash(python -c \"from src.api.routes.ingest import router; print\\('ingest router OK:', len\\(router.routes\\), 'routes'\\)\")",
+ "Bash(python -c ' *)",
+ "Bash(python -m pytest tests/ -q)",
+ "Bash(git -C /p rev-parse --git-dir)",
+ "Bash(git config *)",
+ "Bash(git init *)",
+ "Bash(git add *)",
+ "Bash(git commit -m 'feat: 足球 LLM 预测服务初始提交 *)",
+ "Bash(git remote *)",
+ "Bash(git push *)",
+ "Bash(git fetch *)",
+ "Bash(git branch *)",
+ "Bash(git commit *)",
+ "Bash(pg_isready -h localhost -p 5432)",
+ "Bash(python -c \"import pytest_asyncio; print\\('pytest-asyncio OK'\\)\")",
+ "Bash(python -c \"import pytest; print\\('pytest', pytest.__version__\\)\")",
+ "Bash(python -c \"import aiosqlite; print\\('aiosqlite', aiosqlite.__version__\\)\")",
+ "Bash(npm run *)",
+ "Bash(npm install *)",
+ "Bash(git revert *)",
+ "Bash(python -c \"from src.api.app import app; print\\('OK'\\)\")",
+ "Bash(python -m pytest tests/test_agents.py::TestReportParsing::test_parse_bad_values_forgiving -v)",
+ "Bash(python -c \"from src.data.bzzoiro import BzzoiroSource; print\\('OK'\\)\")",
+ "Bash(python -m pytest tests/test_agents.py::TestReportParsing::test_parse_full_report -v)",
+ "Bash(python -c \"from src.db.unit_of_work import UnitOfWork, get_uow; from src.db.repositories import MatchRepository, TeamRepository; print\\('OK'\\)\")",
+ "Bash(python -c \"from src.data.bzzoiro import BzzoiroSource; from src.data.understat import UnderstatSource; from src.data.injuries import ingest_injuries; print\\('OK'\\)\")",
+ "Bash(python -m compileall src tests)",
+ "Bash(alembic current *)",
+ "Bash(alembic history *)",
+ "Bash(python -c \"from src.db.models import MatchStats, Prediction; print\\('Models OK'\\)\")",
+ "Bash(git pull *)",
+ "Bash(ocr delegate *)",
+ "Bash(npm i *)",
+ "Bash(python -c \"from src.llm.validation import KNOWN_AGENT_NAMES; print\\(KNOWN_AGENT_NAMES\\)\")"
+ ]
+ }
+}
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 2736a31..b4859ac 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -9,7 +9,8 @@
"version": "0.1.0",
"dependencies": {
"react": "^18.3.1",
- "react-dom": "^18.3.1"
+ "react-dom": "^18.3.1",
+ "react-router-dom": "^6.30.6"
},
"devDependencies": {
"@types/react": "^18.3.3",
@@ -766,9 +767,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -816,6 +814,15 @@
"node": ">= 8"
}
},
+ "node_modules/@remix-run/router": {
+ "version": "1.23.4",
+ "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.4.tgz",
+ "integrity": "sha512-q7j5geK7xs3UJSdm9/iytUNclBnLmYx1EnSeCFXHPeutdqgIMeFeHtUZgS3EhlKxdBEAu8OwtJCwmLrEzpSs7Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@@ -915,9 +922,6 @@
"arm"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -932,9 +936,6 @@
"arm"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -949,9 +950,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -966,9 +964,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -983,9 +978,6 @@
"loong64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1000,9 +992,6 @@
"loong64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1017,9 +1006,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1034,9 +1020,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1051,9 +1034,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1068,9 +1048,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1085,9 +1062,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1102,9 +1076,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1119,9 +1090,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2287,6 +2255,38 @@
"node": ">=0.10.0"
}
},
+ "node_modules/react-router": {
+ "version": "6.30.6",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.6.tgz",
+ "integrity": "sha512-5HfK7k5im7LTOB0EqCQmfvy4C13G92Ssj1VTmouTK3AJvyjKTnFuCV0vcMAD/JS+JC4DvDIBRrlAeJIFjh5VWg==",
+ "license": "MIT",
+ "dependencies": {
+ "@remix-run/router": "1.23.4"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8"
+ }
+ },
+ "node_modules/react-router-dom": {
+ "version": "6.30.6",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.6.tgz",
+ "integrity": "sha512-0RHKZz7wwffvkU+2MFVT2NnjK44ssLEV+m0CAJaS2Ksmorrwj7WxH00jO0SOCW26/tINUnJHToXblDs33I38YQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@remix-run/router": "1.23.4",
+ "react-router": "6.30.6"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8",
+ "react-dom": ">=16.8"
+ }
+ },
"node_modules/read-cache": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.2.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index ffc1ee8..5eca45c 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -10,7 +10,8 @@
},
"dependencies": {
"react": "^18.3.1",
- "react-dom": "^18.3.1"
+ "react-dom": "^18.3.1",
+ "react-router-dom": "^6.30.6"
},
"devDependencies": {
"@types/react": "^18.3.3",
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 2b9b890..b52a89c 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,7 +1,17 @@
+/**
+ * 主应用入口
+ *
+ * 整合前台(报纸风格)和后台(暗色管理)的路由。
+ * - / → 先知(Profeto)主站
+ * - /admin/* → 管理后台
+ */
+
+import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { ErrorBoundary } from './components/ErrorBoundary'
import Matches from './pages/Matches'
+import { adminRoutes } from './admin/routes'
-/** 报眉日期行:2026年9月15日 星期二 */
+/** 报眉日期行 */
function dateLine(): string {
return new Date().toLocaleDateString('zh-CN', {
year: 'numeric',
@@ -11,45 +21,70 @@ function dateLine(): string {
})
}
+function HomePage() {
+ return (
+
+ {/* ── 报头:粗线 + 居中刊名 + 报眉 ── */}
+
+
+
+
+ 先知
+
+ Profeto
+
+
+
+ 足球比分预测 · 五路专家 · 终裁汇总
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
export default function App() {
return (
-
- {/* ── 报头:粗线 + 居中刊名 + 报眉 ── */}
-
-
-
-
-
-
- {/* ── 版底 ── */}
-
-
+
+
+ } />
+ {adminRoutes.map(route => (
+
+ {route.children.map(child => (
+
+ ))}
+
+ ))}
+ } />
+
+
)
}
diff --git a/frontend/src/admin/AdminApp.tsx b/frontend/src/admin/AdminApp.tsx
new file mode 100644
index 0000000..6fde49b
--- /dev/null
+++ b/frontend/src/admin/AdminApp.tsx
@@ -0,0 +1,15 @@
+/**
+ * Admin 后台 - 应用入口
+ *
+ * 独立的 Admin 应用入口,用于 createBrowserRouter。
+ * 也可通过 createHashRouter 直接挂载为独立应用。
+ */
+
+import { createBrowserRouter, RouterProvider } from 'react-router-dom'
+import { adminRoutes } from './routes'
+
+const router = createBrowserRouter(adminRoutes)
+
+export default function AdminApp() {
+ return
+}
diff --git a/frontend/src/admin/AdminLayout.tsx b/frontend/src/admin/AdminLayout.tsx
new file mode 100644
index 0000000..55e194a
--- /dev/null
+++ b/frontend/src/admin/AdminLayout.tsx
@@ -0,0 +1,87 @@
+/**
+ * Admin 后台 - 布局组件
+ *
+ * 提供管理界面的基本结构:顶栏 + 侧边栏 + 内容区
+ * 暗色主题,参考线性风格设计
+ */
+
+import { NavLink, Outlet } from 'react-router-dom'
+
+const NAV_ITEMS = [
+ { to: '/admin', label: '仪表盘', icon: '◇', end: true },
+ { to: '/admin/collection', label: '数据采集', icon: '◈' },
+ { to: '/admin/predictions', label: '预测管理', icon: '◆' },
+ { to: '/admin/backtest', label: '回测管理', icon: '◉' },
+ { to: '/admin/monitoring', label: '监控面板', icon: '◐' },
+ { to: '/admin/config', label: '配置管理', icon: '◑' },
+]
+
+export default function AdminLayout() {
+ return (
+
+ {/* ── 侧边栏 ── */}
+
+
+ {/* ── 主内容区 ── */}
+
+ {/* 顶栏 */}
+
+
+
+ 系统运行中
+
+
+ Profeto Admin v1.0
+
+
+
+ {/* 页面内容 */}
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/admin/README.md b/frontend/src/admin/README.md
new file mode 100644
index 0000000..6efa68b
--- /dev/null
+++ b/frontend/src/admin/README.md
@@ -0,0 +1,95 @@
+# Profeto Admin 后台管理系统
+
+## 概述
+
+Profeto 后台管理界面,为足球 LLM 预测系统提供运维管理能力。
+暗色主题设计,支持响应式布局。
+
+## 文件结构
+
+```
+src/admin/
+├── AdminApp.tsx # Admin 应用入口(独立路由)
+├── AdminLayout.tsx # 管理后台布局(侧边栏 + 顶栏 + 内容区)
+├── api.ts # 统一 API 客户端(带超时、错误处理、类型安全)
+├── dal.ts # 数据访问层(封装所有 API 端点调用)
+├── types.ts # TypeScript 类型定义(与 FastAPI Pydantic 对齐)
+├── components.tsx # 通用 UI 组件(Card, Table, Badge, ProgressBar...)
+├── routes.tsx # 路由定义(/admin/*)
+└── pages/
+ ├── Dashboard.tsx # 仪表盘(系统概览)
+ ├── Collection.tsx # 数据采集(触发采集任务)
+ ├── Predictions.tsx # 预测管理(触发预测 + 评估结算)
+ ├── Backtest.tsx # 回测管理(策略验证)
+ ├── Monitoring.tsx # 监控面板(健康检查 + 错误日志)
+ └── Config.tsx # 配置管理(API Key 管理)
+```
+
+## 页面说明
+
+### 1. 仪表盘 (`/admin`)
+- 数据库表行数统计
+- 最近采集任务状态
+- 预测统计(总数、今日数、平均延迟)
+- 最近错误日志
+
+### 2. 数据采集 (`/admin/collection`)
+- 选择数据源: Bzzoiro / Understat / Injuries
+- 选择联赛、日期范围
+- 触发采集任务
+- 实时任务进度显示(每 5 秒自动刷新)
+
+### 3. 预测管理 (`/admin/predictions`)
+- 触发预测(全部联赛或指定联赛)
+- 模式切换: 五路专家 / 单一模型
+- 预测历史分页查看
+- 评估结算(回填实际结果)
+
+### 4. 回测管理 (`/admin/backtest`)
+- 策略选择: 置信度加权 / Kelly准则 / 固定金额
+- 联赛多选、日期范围、初始资金配置
+- 回测结果: ROI、胜率、最大回撤、夏普比率
+- 交易明细可展开查看
+
+### 5. 监控面板 (`/admin/monitoring`)
+- 系统健康状态(服务 + 检查项)
+- 错误日志(每 10 秒自动刷新)
+- 死信队列监控
+
+### 6. 配置管理 (`/admin/config`)
+- API Key 配置(LLM / 数据源 / 系统)
+- 脱敏显示 + 在线编辑
+
+## 路由设计
+
+| 路径 | 页面 | 描述 |
+|------|------|------|
+| `/` | 先知主站 | 报纸风格预测展示 |
+| `/admin` | 仪表盘 | 系统概览 |
+| `/admin/collection` | 数据采集 | 采集任务管理 |
+| `/admin/predictions` | 预测管理 | 预测与评估 |
+| `/admin/backtest` | 回测管理 | 策略回测 |
+| `/admin/monitoring` | 监控面板 | 系统监控 |
+| `/admin/config` | 配置管理 | 参数配置 |
+
+## 技术实现
+
+- **路由**: `react-router-dom` v6 嵌套路由
+- **样式**: Tailwind CSS 暗色主题 (gray-900/800/700)
+- **API**: 统一 fetch 客户端,30s 超时,类型安全
+- **类型**: TypeScript strict mode,与后端 Pydantic 模型对齐
+- **错误处理**: ApiError 类 + 页面级错误展示
+- **自动刷新**: 采集(5s)、监控(10s)
+
+## 启动方式
+
+```bash
+cd /vol2/1000/Docker/Profeto/frontend
+npm run dev
+# 访问 http://localhost:5173/admin
+```
+
+## 访问入口
+
+主站页面顶部「管理后台」链接可跳转至 `/admin`。
+Admin 后台左侧底栏「返回前台」链接可回到 `/`。
diff --git a/frontend/src/admin/api.ts b/frontend/src/admin/api.ts
new file mode 100644
index 0000000..80e81ca
--- /dev/null
+++ b/frontend/src/admin/api.ts
@@ -0,0 +1,95 @@
+/**
+ * Admin 后台管理系统 - 统一 API 客户端
+ *
+ * 封装 fetch 调用,提供:
+ * - 统一错误处理
+ * - 请求/响应日志
+ * - 超时控制
+ * - 类型安全的响应解析
+ */
+
+const API_BASE = '/api/v1'
+const TIMEOUT_MS = 30_000
+
+/** 通用 API 错误类型 */
+export class ApiError extends Error {
+ constructor(
+ message: string,
+ public status: number,
+ public data?: unknown,
+ ) {
+ super(message)
+ this.name = 'ApiError'
+ }
+}
+
+/** 基础 fetch 封装,带超时和错误处理 */
+async function request(
+ path: string,
+ options: RequestInit = {},
+): Promise {
+ const url = path.startsWith('http') ? path : `${path}`
+ const controller = new AbortController()
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS)
+
+ try {
+ const res = await fetch(url, {
+ ...options,
+ signal: controller.signal,
+ headers: {
+ 'Content-Type': 'application/json',
+ ...options.headers,
+ },
+ })
+
+ if (!res.ok) {
+ let detail: unknown
+ try {
+ detail = await res.json()
+ } catch {
+ detail = await res.text()
+ }
+ throw new ApiError(
+ detail && typeof detail === 'object' && 'detail' in detail
+ ? String((detail as { detail: unknown }).detail)
+ : `HTTP ${res.status}: ${res.statusText}`,
+ res.status,
+ detail,
+ )
+ }
+
+ // 204 No Content
+ if (res.status === 200 && res.headers.get('content-length') === '0') {
+ return undefined as T
+ }
+
+ return res.json() as Promise
+ } catch (err) {
+ if (err instanceof ApiError) throw err
+ if (err instanceof DOMException && err.name === 'AbortError') {
+ throw new ApiError('请求超时,请稍后重试', 0)
+ }
+ throw new ApiError(
+ err instanceof Error ? err.message : '网络错误,请检查连接',
+ 0,
+ )
+ } finally {
+ clearTimeout(timer)
+ }
+}
+
+// ── 通用 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' }),
+}
+
+export { API_BASE }
diff --git a/frontend/src/admin/components.tsx b/frontend/src/admin/components.tsx
new file mode 100644
index 0000000..04cfa7a
--- /dev/null
+++ b/frontend/src/admin/components.tsx
@@ -0,0 +1,220 @@
+/**
+ * Admin 后台 - 通用 UI 组件集合
+ *
+ * 提供管理界面使用的所有基础组件:
+ * - Card: 内容卡片
+ * - StatCard: 统计数字卡片
+ * - Badge: 状态标签
+ * - DataTable: 通用数据表格
+ * - ProgressBar: 进度条
+ * - EmptyState: 空状态占位
+ * - SectionHeader: 小节标题
+ */
+
+import { ReactNode } from 'react'
+
+// ── 卡片 ────────────────────────────────────────────────────────
+
+export function Card({
+ children,
+ className = '',
+}: {
+ children: ReactNode
+ className?: string
+}) {
+ return (
+
+ {children}
+
+ )
+}
+
+export function CardHeader({
+ title,
+ action,
+}: {
+ title: string
+ action?: ReactNode
+}) {
+ return (
+
+
{title}
+ {action}
+
+ )
+}
+
+export function CardBody({
+ children,
+ className = '',
+}: {
+ children: ReactNode
+ className?: string
+}) {
+ return {children}
+}
+
+// ── 统计卡片 ────────────────────────────────────────────────────
+
+export function StatCard({
+ label,
+ value,
+ icon,
+ trend,
+}: {
+ label: string
+ value: string | number
+ icon?: string
+ trend?: { value: number; positive: boolean }
+}) {
+ return (
+
+
+ {label}
+ {icon && (
+
+ {icon}
+
+ )}
+
+
{value}
+ {trend && (
+
+ {trend.positive ? '↑' : '↓'} {Math.abs(trend.value)}%
+
+ )}
+
+ )
+}
+
+// ── 状态标签 ────────────────────────────────────────────────────
+
+const BADGE_COLORS: Record = {
+ success: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30',
+ running: 'bg-blue-500/15 text-blue-400 border-blue-500/30',
+ queued: 'bg-yellow-500/15 text-yellow-400 border-yellow-500/30',
+ failed: 'bg-red-500/15 text-red-400 border-red-500/30',
+ pending: 'bg-gray-500/15 text-gray-400 border-gray-500/30',
+ completed: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30',
+ error: 'bg-red-500/15 text-red-400 border-red-500/30',
+ warning: 'bg-yellow-500/15 text-yellow-400 border-yellow-500/30',
+ info: 'bg-blue-500/15 text-blue-400 border-blue-500/30',
+ win: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30',
+ loss: 'bg-red-500/15 text-red-400 border-red-500/30',
+ push: 'bg-gray-500/15 text-gray-400 border-gray-500/30',
+}
+
+export function Badge({
+ status,
+ children,
+}: {
+ status: string
+ children: ReactNode
+}) {
+ const colorClass = BADGE_COLORS[status] ?? 'bg-gray-500/15 text-gray-400 border-gray-500/30'
+ return (
+
+ {children}
+
+ )
+}
+
+// ── 数据表格 ────────────────────────────────────────────────────
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function DataTable({
+ columns,
+ data,
+ rowKey,
+ emptyText = '暂无数据',
+}: {
+ columns: { key: string; label: string; render?: (row: T) => ReactNode; width?: string }[]
+ data: T[]
+ rowKey: (row: T) => string | number
+ emptyText?: string
+}) {
+ if (data.length === 0) {
+ return
+ }
+
+ return (
+
+
+
+
+ {columns.map(col => (
+ |
+ {col.label}
+ |
+ ))}
+
+
+
+ {data.map(row => (
+
+ {columns.map(col => (
+ |
+ {col.render
+ ? col.render(row)
+ : row != null && typeof row === 'object' && col.key in row
+ ? String((row as Record)[col.key] ?? '—')
+ : '—'}
+ |
+ ))}
+
+ ))}
+
+
+
+ )
+}
+
+// ── 进度条 ──────────────────────────────────────────────────────
+
+export function ProgressBar({ value }: { value: number }) {
+ const clamped = Math.max(0, Math.min(100, value))
+ return (
+
+ )
+}
+
+// ── 空状态 ──────────────────────────────────────────────────────
+
+export function EmptyState({ text = '暂无数据' }: { text?: string }) {
+ return (
+
+ {text}
+
+ )
+}
+
+// ── 小节标题 ────────────────────────────────────────────────────
+
+export function SectionHeader({
+ title,
+ description,
+}: {
+ title: string
+ description?: string
+}) {
+ return (
+
+
{title}
+ {description &&
{description}
}
+
+ )
+}
diff --git a/frontend/src/admin/dal.ts b/frontend/src/admin/dal.ts
new file mode 100644
index 0000000..f5038d4
--- /dev/null
+++ b/frontend/src/admin/dal.ts
@@ -0,0 +1,168 @@
+/**
+ * Admin 后台 - 数据访问层
+ *
+ * 封装所有 API 端点调用,返回类型安全的数据。
+ * 页面组件直接调用这些函数,无需关心网络细节。
+ */
+
+import { api, API_BASE } from './api'
+import type {
+ DashboardStats,
+ ConfigEntry,
+ ConfigUpdate,
+ CollectionRequest,
+ CollectionResponse,
+ CollectionTask,
+ PredictRequest,
+ PredictionHistoryItem,
+ SettleResponse,
+ EvalSummary,
+ BacktestRequest,
+ BacktestResult,
+ League,
+ Paginated,
+ ErrorLog,
+} from './types'
+
+// ── 仪表盘 ──────────────────────────────────────────────────────
+
+export async function fetchDashboard(): Promise {
+ // 后端可能没有专门的仪表盘聚合端点,这里用 health + 各端点组合
+ const health = await fetchHealth()
+ 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 ?? [],
+ }
+}
+
+/** 后端 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 triggerPrediction(req: PredictRequest): Promise {
+ return api.post(`${API_BASE}/predict`, req)
+}
+
+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 triggerSettle(): Promise {
+ return api.post(`${API_BASE}/eval/settle`)
+}
+
+export async function fetchEvalSummary(): Promise {
+ try {
+ return await api.get(`${API_BASE}/eval/summary`)
+ } catch {
+ return null
+ }
+}
+
+// ── 回测管理 ────────────────────────────────────────────────────
+
+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 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 []
+ }
+}
diff --git a/frontend/src/admin/pages/Backtest.tsx b/frontend/src/admin/pages/Backtest.tsx
new file mode 100644
index 0000000..73aaa90
--- /dev/null
+++ b/frontend/src/admin/pages/Backtest.tsx
@@ -0,0 +1,332 @@
+/**
+ * Admin 后台 - 回测管理页面
+ *
+ * 功能:
+ * - 配置回测参数(策略、联赛、日期范围、初始资金)
+ * - 触发回测任务
+ * - 查看回测结果(收益率、最大回撤、夏普比率等)
+ * - 交易记录明细
+ */
+
+import { useEffect, useState } from 'react'
+import { triggerBacktest, fetchBacktestHistory, fetchLeagues } from '../dal'
+import type { BacktestRequest, BacktestResult, BacktestTrade, League } from '../types'
+import {
+ Card,
+ CardBody,
+ CardHeader,
+ Badge,
+ ProgressBar,
+ DataTable,
+ EmptyState,
+} from '../components'
+import { SectionHeader } from '../components'
+
+const STRATEGIES = [
+ { value: 'confidence_weighted', label: '置信度加权', desc: '按预测置信度调整仓位' },
+ { value: 'kelly', label: 'Kelly 准则', desc: 'Kelly 公式优化投注比例' },
+ { value: 'flat', label: '固定金额', desc: '每次固定金额投注' },
+]
+
+export default function BacktestPage() {
+ const [leagues, setLeagues] = useState([])
+ const [tasks, setTasks] = useState([])
+ const [strategy, setStrategy] = useState('confidence_weighted')
+ const [selectedLeagues, setSelectedLeagues] = useState(['E0'])
+ const [dateFrom, setDateFrom] = useState('')
+ const [dateTo, setDateTo] = useState('')
+ const [bankroll, setBankroll] = useState(1000)
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+ const [successMsg, setSuccessMsg] = useState(null)
+
+ useEffect(() => {
+ Promise.all([fetchLeagues(), fetchBacktestHistory()]).then(([lg, ts]) => {
+ setLeagues(lg)
+ setTasks(ts)
+ })
+ }, [])
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault()
+ setError(null)
+ setSuccessMsg(null)
+ setLoading(true)
+
+ try {
+ const body: BacktestRequest = {
+ strategy,
+ league_codes: selectedLeagues,
+ date_from: dateFrom || '2024-01-01',
+ date_to: dateTo || new Date().toISOString().split('T')[0],
+ initial_bankroll: bankroll,
+ }
+ const res = await triggerBacktest(body)
+ setSuccessMsg(`回测任务已启动: ${res.task_id}`)
+ // 刷新列表
+ const ts = await fetchBacktestHistory()
+ setTasks(ts)
+ } catch (err: unknown) {
+ setError(err instanceof Error ? err.message : '回测触发失败')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ function toggleLeague(code: string) {
+ setSelectedLeagues(prev =>
+ prev.includes(code) ? prev.filter(c => c !== code) : [...prev, code],
+ )
+ }
+
+ return (
+
+
+
+
+ {/* ── 回测配置表单 ── */}
+
+
+ {/* ── 回测任务与结果 ── */}
+
+
+
+
+ {tasks.length === 0 ? (
+
+ ) : (
+
+ {tasks.map(task => (
+
+ ))}
+
+ )}
+
+
+
+
+
+ )
+}
+
+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 (
+
+ )
+}
diff --git a/frontend/src/admin/pages/Collection.tsx b/frontend/src/admin/pages/Collection.tsx
new file mode 100644
index 0000000..4ca6c87
--- /dev/null
+++ b/frontend/src/admin/pages/Collection.tsx
@@ -0,0 +1,236 @@
+/**
+ * Admin 后台 - 数据采集页面
+ *
+ * 功能:
+ * - 选择数据源(bzzoiro / understat / injuries)
+ * - 选择联赛、日期范围
+ * - 触发采集任务
+ * - 实时显示任务进度
+ */
+
+import { useEffect, useState, useCallback } from 'react'
+import { triggerCollection, fetchCollectionTasks, fetchLeagues } from '../dal'
+import type { CollectionRequest, CollectionTask, League } from '../types'
+import { Card, CardBody, CardHeader, Badge, ProgressBar, EmptyState } from '../components'
+import { SectionHeader } from '../components'
+
+const SOURCES = [
+ { value: 'bzzoiro', label: 'Bzzoiro', desc: '历史赛程与比分数据' },
+ { value: 'understat', label: 'Understat', desc: '进阶统计数据(xG/xA)' },
+ { value: 'injuries', label: 'Injuries', desc: '球员伤停信息' },
+] as const
+
+export default function CollectionPage() {
+ const [leagues, setLeagues] = useState([])
+ const [tasks, setTasks] = useState([])
+ const [source, setSource] = useState('bzzoiro')
+ const [leagueCode, setLeagueCode] = useState('')
+ const [dateFrom, setDateFrom] = useState('')
+ const [dateTo, setDateTo] = 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()])
+ setLeagues(lg)
+ setTasks(ts)
+ }, [])
+
+ useEffect(() => {
+ loadData()
+ // 自动刷新任务状态(每 5 秒)
+ const timer = setInterval(loadData, 5000)
+ return () => clearInterval(timer)
+ }, [loadData])
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault()
+ setError(null)
+ setSuccessMsg(null)
+ setLoading(true)
+
+ try {
+ const body: CollectionRequest = {
+ source: source as CollectionRequest['source'],
+ league_code: leagueCode || undefined,
+ date_from: dateFrom || undefined,
+ date_to: dateTo || undefined,
+ }
+ const res = await triggerCollection(body)
+ setSuccessMsg(`任务已创建: ${res.message}`)
+ loadData()
+ } catch (err: unknown) {
+ setError(err instanceof Error ? err.message : '采集触发失败')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+
+
+
+
+ {/* ── 采集配置表单 ── */}
+
+
+ {/* ── 任务列表 ── */}
+
+
+
+
+ {tasks.length === 0 ? (
+
+ ) : (
+
+ {tasks.map(task => (
+
+ ))}
+
+ )}
+
+
+
+
+
+ )
+}
+
+function TaskRow({ task }: { task: CollectionTask }) {
+ return (
+
+
+
+ {task.source}
+ {task.status}
+
+
+ {task.processed}/{task.total}
+
+
+ {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
new file mode 100644
index 0000000..b75f374
--- /dev/null
+++ b/frontend/src/admin/pages/Config.tsx
@@ -0,0 +1,245 @@
+/**
+ * Admin 后台 - 配置管理页面
+ *
+ * 功能:
+ * - 查看当前 API Key 配置(脱敏显示)
+ * - 更新 LLM / 数据源密钥
+ * - 配置分类管理(LLM / 数据源 / 系统)
+ */
+
+import { useEffect, useState } from 'react'
+import { fetchConfigs, updateConfig } from '../dal'
+import type { ConfigEntry } from '../types'
+import { Card, CardBody, CardHeader, Badge, EmptyState } from '../components'
+import { SectionHeader } from '../components'
+
+const CATEGORY_LABELS: Record = {
+ 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 (
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+ {successMsg && (
+
+ {successMsg}
+
+ )}
+
+ {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)
+
+
+ 系统配置: 数据库连接、日志级别等运行时参数
+
+
+ ⚠️ 配置修改后需要重启服务才能生效
+
+
+
+
+
+ )
+}
+
+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 && (
+
+ )}
+
+ )
+}
diff --git a/frontend/src/admin/pages/Dashboard.tsx b/frontend/src/admin/pages/Dashboard.tsx
new file mode 100644
index 0000000..3466dc5
--- /dev/null
+++ b/frontend/src/admin/pages/Dashboard.tsx
@@ -0,0 +1,187 @@
+/**
+ * Admin 后台 - 仪表盘
+ *
+ * 系统概览:
+ * - 数据库表行数统计
+ * - 最近采集状态
+ * - 预测统计
+ * - 最近错误日志
+ */
+
+import { useEffect, useState } from 'react'
+import { fetchDashboard } from '../dal'
+import type { DashboardStats, TableStats, CollectionRecord, ErrorLog } from '../types'
+import { Card, CardBody, CardHeader, StatCard, DataTable, Badge, EmptyState } from '../components'
+
+export default function Dashboard() {
+ const [data, setData] = useState(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)
+ })
+ .catch((err: unknown) => {
+ if (active) setError(err instanceof Error ? err.message : '加载失败')
+ })
+ .finally(() => {
+ if (active) setLoading(false)
+ })
+ return () => {
+ active = false
+ }
+ }, [])
+
+ if (error) {
+ return (
+
+ )
+ }
+
+ 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}
+ />
+ ) : (
+
+ )}
+
+
+
+
+ )
+}
+
+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
new file mode 100644
index 0000000..af25101
--- /dev/null
+++ b/frontend/src/admin/pages/Monitoring.tsx
@@ -0,0 +1,248 @@
+/**
+ * Admin 后台 - 监控面板
+ *
+ * 功能:
+ * - 系统健康检查
+ * - 采集错误日志
+ * - 死信队列监控
+ * - 实时状态刷新
+ */
+
+import { useEffect, useState, useCallback } from 'react'
+import { fetchErrorLogs } from '../dal'
+import type { ErrorLog } from '../types'
+import { Card, CardBody, CardHeader, Badge, DataTable, EmptyState } from '../components'
+import { SectionHeader } from '../components'
+
+interface HealthCheck {
+ name: string
+ status: 'pass' | 'fail' | 'warn'
+ detail?: string
+}
+
+interface HealthInfo {
+ status: 'ok' | 'degraded' | 'error'
+ version?: string
+ uptime?: string
+ checks: HealthCheck[]
+}
+
+export default function MonitoringPage() {
+ const [logs, setLogs] = useState([])
+ 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])
+
+ 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}
+ />
+
+
+
+ {/* ── 死信队列 ── */}
+
+
+
+
+
+
+
+ )
+}
+
+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
new file mode 100644
index 0000000..16edcd9
--- /dev/null
+++ b/frontend/src/admin/pages/Predictions.tsx
@@ -0,0 +1,278 @@
+/**
+ * Admin 后台 - 预测管理页面
+ *
+ * 功能:
+ * - 触发手动预测(选择比赛或联赛)
+ * - 查看预测历史记录
+ * - 评估结算(回填实际结果)
+ * - 评估统计摘要
+ */
+
+import { useEffect, useState, useCallback } from 'react'
+import {
+ triggerPrediction,
+ fetchPredictionHistory,
+ triggerSettle,
+ fetchEvalSummary,
+ fetchLeagues,
+} from '../dal'
+import type { PredictRequest, PredictionHistoryItem, League, EvalSummary } from '../types'
+import {
+ Card,
+ CardBody,
+ CardHeader,
+ Badge,
+ DataTable,
+ EmptyState,
+} from '../components'
+import { SectionHeader } from '../components'
+
+export default function PredictionsPage() {
+ const [leagues, setLeagues] = useState([])
+ const [history, setHistory] = useState([])
+ const [evalData, setEvalData] = useState(null)
+ const [leagueCode, setLeagueCode] = useState('')
+ const [mode, setMode] = useState<'single' | 'multi'>('multi')
+ const [loading, setLoading] = useState(false)
+ const [page, setPage] = useState(1)
+ const [error, setError] = useState(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])
+
+ async function handlePredict(e: React.FormEvent) {
+ e.preventDefault()
+ setError(null)
+ setSuccessMsg(null)
+ setLoading(true)
+
+ try {
+ const body: PredictRequest = {
+ league_code: leagueCode || undefined,
+ mode,
+ }
+ await triggerPrediction(body)
+ setSuccessMsg(`预测任务已启动 (${mode === 'multi' ? '五路专家模式' : '单一模型模式'})`)
+ loadData()
+ } catch (err: unknown) {
+ setError(err instanceof Error ? err.message : '预测触发失败')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ async function handleSettle() {
+ setError(null)
+ setSuccessMsg(null)
+ setLoading(true)
+ try {
+ const res = await triggerSettle()
+ setSuccessMsg(`结算完成: 成功 ${res.settled_count}, 失败 ${res.failed_count}`)
+ loadData()
+ } catch (err: unknown) {
+ setError(err instanceof Error ? err.message : '结算失败')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+
+
+
+ {/* ── 评估摘要 ── */}
+ {evalData && (
+
+
+
+
+
+
+ )}
+
+
+ {/* ── 预测触发表单 ── */}
+
+
+
+ 结算
+
+ }
+ />
+
+
+
+
+
+
+ {/* ── 预测历史 ── */}
+
+
+
+
+ {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 (
+
+ )
+}
+
+function label1x2(v: string): string {
+ return { '1': '主胜', X: '平局', '2': '客胜' }[v] ?? v
+}
diff --git a/frontend/src/admin/routes.tsx b/frontend/src/admin/routes.tsx
new file mode 100644
index 0000000..4ddafc9
--- /dev/null
+++ b/frontend/src/admin/routes.tsx
@@ -0,0 +1,33 @@
+/**
+ * Admin 后台 - 路由配置
+ *
+ * 所有 Admin 页面的路由定义,使用嵌套路由。
+ * 挂载路径: /admin/*
+ */
+
+import { createBrowserRouter, Navigate } from 'react-router-dom'
+import AdminLayout from './AdminLayout'
+import Dashboard from './pages/Dashboard'
+import CollectionPage from './pages/Collection'
+import PredictionsPage from './pages/Predictions'
+import BacktestPage from './pages/Backtest'
+import MonitoringPage from './pages/Monitoring'
+import ConfigPage from './pages/Config'
+
+export const adminRoutes = [
+ {
+ path: '/admin',
+ element: ,
+ children: [
+ { index: true, element: },
+ { path: 'collection', element: },
+ { path: 'predictions', element: },
+ { path: 'backtest', element: },
+ { path: 'monitoring', element: },
+ { path: 'config', element: },
+ { path: '*', element: },
+ ],
+ },
+]
+
+export { adminRoutes as default }
diff --git a/frontend/src/admin/types.ts b/frontend/src/admin/types.ts
new file mode 100644
index 0000000..0f67707
--- /dev/null
+++ b/frontend/src/admin/types.ts
@@ -0,0 +1,221 @@
+/**
+ * Admin 后台 - TypeScript 类型定义
+ *
+ * 与 FastAPI 后端 Pydantic 模型对齐,确保类型安全
+ */
+
+// ── 系统健康 ────────────────────────────────────────────────────
+
+export interface HealthStatus {
+ status: 'ok' | 'degraded' | 'error'
+ version?: string
+ uptime_seconds?: number
+ checks: Record
+}
+
+// ── 仪表盘 ──────────────────────────────────────────────────────
+
+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 {
+ total_predictions: number
+ today_predictions: number
+ avg_latency_ms: number
+ success_rate: number
+}
+
+export interface ErrorLog {
+ id: number
+ timestamp: string
+ source: string
+ message: string
+ level: 'error' | 'warning' | 'info'
+}
+
+// ── 配置管理 ────────────────────────────────────────────────────
+
+export interface ConfigEntry {
+ key: string
+ value: string /** 脱敏显示,如 sk-****abcd */
+ description: string
+ category: 'llm' | 'datasource' | 'system'
+ updated_at: string | null
+}
+
+export interface ConfigUpdate {
+ key: string
+ value: string
+}
+
+// ── 数据采集 ────────────────────────────────────────────────────
+
+export interface CollectionRequest {
+ source: 'bzzoiro' | 'understat' | 'injuries'
+ league_code?: 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
+}
+
+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
+}
+
+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[]
+ total: number
+ page: number
+ page_size: number
+ has_next: boolean
+}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 21a4454..b8467f6 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -100,3 +100,54 @@
@apply animate-pulse rounded-none bg-ink-200;
}
}
+
+/* ── Admin 后台:暗色主题覆盖 ── */
+@layer base {
+ /* Admin 区域内滚动条暗色化 */
+ .dark-scroll::-webkit-scrollbar-thumb {
+ @apply bg-gray-600;
+ }
+ .dark-scroll::-webkit-scrollbar-thumb:hover {
+ @apply bg-gray-500;
+ }
+}
+
+@layer components {
+ /* Admin 输入框统一样式 */
+ .admin-input {
+ @apply w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200
+ transition-colors placeholder:text-gray-600
+ focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500;
+ }
+
+ /* Admin 按钮 */
+ .admin-btn {
+ @apply inline-flex items-center justify-center gap-1.5 rounded-md border border-gray-700
+ bg-gray-800 px-4 py-2 text-sm font-medium text-gray-200
+ transition-colors hover:border-gray-600 hover:bg-gray-700
+ disabled:cursor-not-allowed disabled:opacity-40;
+ }
+
+ .admin-btn-primary {
+ @apply border-blue-600 bg-blue-600 text-white hover:bg-blue-700 hover:border-blue-700;
+ }
+
+ .admin-btn-danger {
+ @apply border-red-600 bg-red-600 text-white hover:bg-red-700 hover:border-red-700;
+ }
+
+ /* Admin 卡片通用 */
+ .admin-card {
+ @apply rounded-lg border border-gray-800 bg-gray-900;
+ }
+
+ /* 表格行动态 */
+ .admin-row-hover {
+ @apply transition-colors hover:bg-gray-800/40;
+ }
+
+ /* 状态点 */
+ .status-dot {
+ @apply inline-block h-2 w-2 rounded-full;
+ }
+}