From cf0751d1ffe657e0ecc42cbf4ed689fe8404f70c Mon Sep 17 00:00:00 2001 From: shangfangjian Date: Fri, 18 Sep 2026 10:42:14 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=AE=A1=E7=90=86=E7=95=8C=E9=9D=A2?= =?UTF-8?q?=E6=8A=A5=E5=88=8A=E9=A3=8E=E7=BB=9F=E4=B8=80=E6=94=B9=E9=80=A0?= =?UTF-8?q?=20=E2=80=94=20=E7=A7=BB=E9=99=A4=E7=8B=AC=E7=AB=8B=E6=9A=97?= =?UTF-8?q?=E8=89=B2=E4=B8=BB=E9=A2=98,=E9=A2=84=E6=B5=8B/=E5=9B=9E?= =?UTF-8?q?=E6=B5=8B/=E7=9B=91=E6=8E=A7/=E9=85=8D=E7=BD=AE=E9=A1=B5?= =?UTF-8?q?=E5=A2=9E=E5=BC=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/admin/AdminLayout.tsx | 153 +++++++---- frontend/src/admin/README.md | 105 ++++---- frontend/src/admin/api.ts | 37 ++- frontend/src/admin/components.tsx | 204 ++++++++------ frontend/src/admin/pages/Backtest.tsx | 280 ++++++++++++++------ frontend/src/admin/pages/Collection.tsx | 99 ++++--- frontend/src/admin/pages/Config.tsx | 210 +++++++++------ frontend/src/admin/pages/Dashboard.tsx | 91 ++++--- frontend/src/admin/pages/DataSources.tsx | 99 +++---- frontend/src/admin/pages/LLMConfig.tsx | 223 +++++++--------- frontend/src/admin/pages/Monitoring.tsx | 211 +++++++++------ frontend/src/admin/pages/Predictions.tsx | 309 ++++++++++++++++++---- frontend/src/admin/types.ts | 17 +- frontend/src/components/ErrorBoundary.tsx | 12 +- frontend/src/index.css | 53 +--- 15 files changed, 1275 insertions(+), 828 deletions(-) diff --git a/frontend/src/admin/AdminLayout.tsx b/frontend/src/admin/AdminLayout.tsx index 89c5e5f..bf9b952 100644 --- a/frontend/src/admin/AdminLayout.tsx +++ b/frontend/src/admin/AdminLayout.tsx @@ -1,13 +1,13 @@ /** - * Admin 后台 - 布局组件 + * Admin 后台 - 布局组件(报刊风) * - * 响应式布局: 移动端汉堡菜单 + 可折叠侧边栏 - * 桌面端: 固定侧边栏 + 内容区 - * 暗色主题,参考线性风格设计 + * 与前台同一套纸色语言:报头式侧栏 + 报眉顶栏 + 细线分区。 + * 响应式: 移动端汉堡菜单 + 抽屉侧栏,桌面端固定侧栏。 */ import { useState, useEffect, useCallback } from 'react' import { NavLink, Outlet, useLocation } from 'react-router-dom' +import { fetchHealth } from './dal' const NAV_ITEMS = [ { to: '/admin', label: '仪表盘', icon: '◇', end: true }, @@ -20,13 +20,38 @@ const NAV_ITEMS = [ { to: '/admin/config', label: '系统配置', icon: '◑' }, ] +/** 报眉日期行,与前台同款式 */ +function dateLine(): string { + return new Date().toLocaleDateString('zh-CN', { + year: 'numeric', + month: 'long', + day: 'numeric', + weekday: 'long', + }) +} + export default function AdminLayout() { const [sidebarOpen, setSidebarOpen] = useState(false) + const [healthOk, setHealthOk] = useState(null) const location = useLocation() + const checkHealth = useCallback(async () => { + try { + const h = await fetchHealth() + setHealthOk(h?.status === 'healthy' || h?.status === 'ok') + } catch { + setHealthOk(false) + } + }, []) + + useEffect(() => { + checkHealth() + const t = setInterval(checkHealth, 60_000) + return () => clearInterval(t) + }, [checkHealth]) + // 路由变化时关闭移动端菜单 const closeSidebar = useCallback(() => setSidebarOpen(false), []) - useEffect(() => { closeSidebar() }, [location.pathname, closeSidebar]) @@ -41,11 +66,11 @@ export default function AdminLayout() { }, []) return ( -
+
{/* ── 移动端遮罩层 ── */} {sidebarOpen && (
setSidebarOpen(false)} aria-hidden="true" /> @@ -54,33 +79,26 @@ export default function AdminLayout() { {/* ── 侧边栏 ── */} {/* ── 主内容区 ── */}
- {/* 顶栏 */} -
- {/* 移动端菜单按钮 */} - - - {/* 状态指示 */} -
-
diff --git a/frontend/src/admin/README.md b/frontend/src/admin/README.md index 734d939..82a36ea 100644 --- a/frontend/src/admin/README.md +++ b/frontend/src/admin/README.md @@ -3,73 +3,85 @@ ## 概述 Profeto 后台管理界面,为足球 LLM 预测系统提供运维管理能力。 -暗色主题设计,支持响应式布局(移动端 / 平板 / 桌面)。 +**与前台共用同一套「报刊风」设计语言**:纸色底(paper)、墨色字(ink)、印报红唯一强调(press), +宋体标题、方正边框、细线分隔,无圆角、无彩色药丸标签。 +支持响应式布局(移动端 / 平板 / 桌面)。 ## 文件结构 ``` src/admin/ ├── AdminApp.tsx # Admin 应用入口(独立路由) -├── AdminLayout.tsx # 管理后台布局(侧边栏 + 顶栏 + 内容区) -├── api.ts # 统一 API 客户端(带超时、错误处理、类型安全) +├── AdminLayout.tsx # 布局(报头式侧栏 + 报眉顶栏,健康状态每 60s 复检) +├── api.ts # 统一 API 客户端(超时、错误处理、X-API-Key 自动附带) ├── dal.ts # 数据访问层(封装所有 API 端点调用) -├── types.ts # TypeScript 类型定义(与 FastAPI Pydantic 对齐) -├── components.tsx # 通用 UI 组件(Card, Table, Badge, ResponsiveTable...) +├── types.ts # TypeScript 类型定义(与 FastAPI Pydantic 模型对齐) +├── components.tsx # 通用 UI 组件(报刊风 Card/Badge/DataTable/Alert...) ├── routes.tsx # 路由定义(/admin/*) └── pages/ ├── Dashboard.tsx # 仪表盘(系统概览) - ├── Collection.tsx # 数据采集(触发采集任务) - ├── Predictions.tsx # 预测管理(触发预测 + 评估结算) - ├── Backtest.tsx # 回测管理(策略验证) - ├── Monitoring.tsx # 监控面板(健康检查 + 错误日志) + ├── Collection.tsx # 数据采集(触发采集任务,结果结构化展示) + ├── Predictions.tsx # 预测管理(触发预测 + 结算 + 记录展开) + ├── Backtest.tsx # 回测管理(汇总指标 + 逐场明细 + 模型评估) + ├── Monitoring.tsx # 监控面板(存活 + 数据库就绪,30s 自动巡检) ├── DataSources.tsx # 数据源管理(数据源配置与测试) ├── LLMConfig.tsx # LLM 配置(模型连接与统计) - └── Config.tsx # 系统配置(.env 配置查看与修改指南) + └── Config.tsx # 系统配置(管理员密钥 + .env 查看与修改指南) ``` ## 页面说明 ### 1. 仪表盘 (`/admin`) -- 系统健康状态概览 -- 联赛 / 比赛 / 预测数量统计 -- 最近联赛列表 +- 系统健康状态、联赛 / 比赛 / 预测数量统计(报纸数字版式) +- 已入库联赛列表 - 快捷操作导航 ### 2. 数据采集 (`/admin/collection`) - 选择数据源: Bzzoiro / Understat / Injuries -- 选择联赛、日期范围 -- 触发采集任务 -- 实时任务进度显示(每 5 秒自动刷新) +- 选择联赛、日期范围(Understat 为赛季) +- 触发采集任务;结果结构化展示(新增 / 更新 / 跳过 / 未匹配 / 错误) ### 3. 预测管理 (`/admin/predictions`) -- 触发预测(选择比赛 + 模式) -- 模式切换: 五路专家 / 单一模型 -- 预测历史列表 +- 触发预测(选择比赛 + 模式:多专家 / 单次) +- **预测结算**: 录入实际比分,调用 /eval/settle,供准确率统计使用 +- 预测记录列表: 点击展开终裁理由与五路专家摘要 ### 4. 回测管理 (`/admin/backtest`) -- 回测配置: 联赛、日期范围、场数、模式 -- 回测结果: 准确率、已评分数 -- 模型评估统计 +- 回测配置: 联赛(下拉)、日期范围、场数、模式 +- 结果汇总: 已评分 / 1X2 准确率 / 比分 RMSE / 平均置信度 +- 逐场明细: 实际比分 vs 预测比分,正误标记 +- 模型评估: 各模型历史准确率(/eval/summary) ### 5. 监控面板 (`/admin/monitoring`) -- 系统健康状态(服务 + 检查项) -- 版本与运行时间 +- /health 存活检查 + /health/ready 数据库就绪检查 +- 每 30 秒自动巡检,可手动「立即巡检」 +- 服务名 / 版本 / 运行时间 / 检查项 -### 6. 数据源管理 (`/admin/data-sources`) ✨ 新增 +### 6. 数据源管理 (`/admin/data-sources`) - 数据源状态: API Key 配置状态(脱敏) - 测试连接: 调用采集 API 验证 - 数据源说明文档 -### 7. LLM 配置 (`/admin/llm-config`) ✨ 新增 +### 7. LLM 配置 (`/admin/llm-config`) - 当前配置: provider, model, base_url -- 连接测试: 调用 /predict 验证 -- 使用统计: 预测次数、延迟、成功率 +- 连接测试(会真实调用一次预测,产生 LLM 费用) +- 使用统计: 预测次数、延迟、有效率(从预测记录聚合) - 可用模型列表 -### 8. 系统配置 (`/admin/config`) 🔧 增强 -- 配置列表: 脱敏显示所有 .env 配置项 +### 8. 系统配置 (`/admin/config`) +- **管理员密钥管理**: 保存 X-API-Key 到本机 localStorage,之后所有请求自动附带; + 后端配置了 ADMIN_API_KEY 时,采集 / 回测 / 结算接口依赖此密钥 +- 配置列表: 脱敏显示 .env 配置项 - 配置修改指南: SSH 修改 .env + 重启服务 -- 快速导航: 数据源 / LLM 配置页面 + +## 鉴权说明 + +后端 `ADMIN_API_KEY` 的渐进式策略: +- 未配置 → 写入型接口无鉴权(本地开发) +- 已配置 → 采集 / 回测 / 结算接口必须带 `X-API-Key` 请求头 + +前端在 `api.ts` 统一注入该请求头;密钥在「系统配置」页设置, +仅存于本机浏览器 localStorage。401 错误会提示到该页填写密钥。 ## 路由设计 @@ -78,47 +90,38 @@ src/admin/ | `/` | 先知主站 | 报纸风格预测展示 | | `/admin` | 仪表盘 | 系统概览 | | `/admin/collection` | 数据采集 | 采集任务管理 | -| `/admin/predictions` | 预测管理 | 预测与评估 | +| `/admin/predictions` | 预测管理 | 预测、结算与记录 | | `/admin/backtest` | 回测管理 | 策略回测 | | `/admin/monitoring` | 监控面板 | 系统监控 | | `/admin/data-sources` | 数据源管理 | 数据源配置 | | `/admin/llm-config` | LLM 配置 | 模型管理 | -| `/admin/config` | 系统配置 | 参数配置 | +| `/admin/config` | 系统配置 | 密钥与参数配置 | ## 响应式布局 ### 移动端 (< 768px) - 侧边栏折叠为汉堡菜单,点击展开 - 表格隐藏,显示卡片视图 -- 表单单列布局 -- 按钮最小 44px 触摸目标 +- 表单单列布局;按钮最小 44px 触摸目标 - 统计卡片 1 列 -### 平板 (768px - 1024px) -- 侧边栏可折叠 -- 部分表格可用 -- 统计卡片 2 列 - ### 桌面 (> 1024px) -- 侧边栏固定显示 -- 完整表格视图 -- 统计卡片 4 列 +- 侧边栏固定显示;完整表格视图;统计卡片 4 列 ## 技术实现 - **路由**: `react-router-dom` v6 嵌套路由 -- **样式**: Tailwind CSS 暗色主题 (gray-900/800/700) -- **API**: 统一 fetch 客户端,30s 超时,类型安全 +- **样式**: Tailwind CSS,与前台共用 paper/ink/press 色板与组件类 + (.btn / .field / .tab / .section-head / .skeleton 见 `src/index.css`) +- **API**: 统一 fetch 客户端,30s 超时,类型安全,X-API-Key 自动附带 - **类型**: TypeScript strict mode,与后端 Pydantic 模型对齐 -- **错误处理**: ApiError 类 + 页面级错误展示 -- **响应式**: Tailwind 断点 (sm:, md:, lg:, xl:) -- **触摸友好**: 所有按钮 min-h-[44px] -- **移动端**: 可折叠侧边栏 + 卡片视图替代表格 +- **错误处理**: ApiError 类 + 页面级 Alert 展示 +- **触摸友好**: 所有可点元素 min-h-[44px] ## 启动方式 ```bash -cd /vol2/1000/Docker/Profeto/frontend +cd frontend npm run dev # 访问 http://localhost:5173/admin ``` @@ -126,4 +129,4 @@ npm run dev ## 访问入口 主站页面顶部「管理后台」链接可跳转至 `/admin`。 -Admin 后台左侧底栏「返回前台」链接可回到 `/`。 +Admin 后台侧栏底部「返回前台版面」链接可回到 `/`。 diff --git a/frontend/src/admin/api.ts b/frontend/src/admin/api.ts index 7dd31f2..a029e2b 100644 --- a/frontend/src/admin/api.ts +++ b/frontend/src/admin/api.ts @@ -1,10 +1,34 @@ /** * Admin 后台管理系统 - 统一 API 客户端 + * + * 写入型/高成本接口(采集、回测、结算)受 X-API-Key 保护: + * 密钥在「系统配置」页设置,存于本机 localStorage,每次请求自动附带。 */ const API_BASE = '/api/v1' const TIMEOUT_MS = 30_000 +const ADMIN_KEY_STORAGE = 'profeto_admin_key' + +/** 读取本机保存的管理员密钥 */ +export function getAdminKey(): string { + try { + return localStorage.getItem(ADMIN_KEY_STORAGE) ?? '' + } catch { + return '' + } +} + +/** 保存/清除管理员密钥(传空字符串即清除) */ +export function setAdminKey(key: string): void { + try { + if (key) localStorage.setItem(ADMIN_KEY_STORAGE, key) + else localStorage.removeItem(ADMIN_KEY_STORAGE) + } catch { + /* 隐私模式等场景下不可用,静默忽略 */ + } +} + export class ApiError extends Error { constructor( message: string, @@ -28,11 +52,13 @@ async function request(path: string, options: RequestInit = {}): Promise { const timer = setTimeout(() => controller.abort(), TIMEOUT_MS) try { + const adminKey = getAdminKey() const res = await fetch(url, { ...options, signal: controller.signal, headers: { 'Content-Type': 'application/json', + ...(adminKey ? { 'X-API-Key': adminKey } : {}), ...options.headers, }, }) @@ -44,13 +70,14 @@ async function request(path: string, options: RequestInit = {}): Promise { } catch { detail = await res.text() } - throw new ApiError( + let message = detail && typeof detail === 'object' && 'detail' in detail ? String((detail as { detail: unknown }).detail) - : `HTTP ${res.status}: ${res.statusText}`, - res.status, - detail, - ) + : `HTTP ${res.status}: ${res.statusText}` + if (res.status === 401) { + message += '\n请在「系统配置」页填写管理员密钥后重试。' + } + throw new ApiError(message, res.status, detail) } // 修复: 正确判断 204 No Content diff --git a/frontend/src/admin/components.tsx b/frontend/src/admin/components.tsx index 475aba2..ca36148 100644 --- a/frontend/src/admin/components.tsx +++ b/frontend/src/admin/components.tsx @@ -1,14 +1,9 @@ /** - * Admin 后台 - 通用 UI 组件集合 + * Admin 后台 - 通用 UI 组件集合(报刊风) * - * 提供管理界面使用的所有基础组件: - * - Card: 内容卡片 - * - StatCard: 统计数字卡片 - * - Badge: 状态标签 - * - DataTable: 通用数据表格 - * - ProgressBar: 进度条 - * - EmptyState: 空状态占位 - * - SectionHeader: 小节标题 + * 与前台共用同一套设计语言: + * - 纸色底(paper)、墨色字(ink)、印报红唯一强调(press) + * - 方正边框、细线分隔、宋体标题、无圆角、无彩色药丸标签 */ import { ReactNode } from 'react' @@ -23,9 +18,7 @@ export function Card({ className?: string }) { return ( -
- {children} -
+
{children}
) } @@ -39,14 +32,12 @@ export function CardHeader({ action?: ReactNode }) { return ( -
-
-

{title}

+
+
+

{title}

{action}
- {description && ( -

{description}

- )} + {description &&

{description}

}
) } @@ -58,7 +49,7 @@ export function CardBody({ children: ReactNode className?: string }) { - return
{children}
+ return
{children}
} // ── 统计卡片 ──────────────────────────────────────────────────── @@ -66,53 +57,40 @@ export function CardBody({ export function StatCard({ label, value, - icon, - trend, + hint, }: { label: string value: string | number - icon?: string - trend?: { value: number; positive: boolean } + hint?: string }) { return ( -
-
- {label} - {icon && ( - - )} +
+ {label} +
+ {value}
-
{value}
- {trend && ( -
- {trend.positive ? '↑' : '↓'} {Math.abs(trend.value)}% -
- )} + {hint &&
{hint}
}
) } -// ── 状态标签 ──────────────────────────────────────────────────── +// ── 状态标记 ──────────────────────────────────────────────────── +// 报刊不用彩色药丸:小方块 + 文字,红=异常/失败,墨=正常,灰=中性 -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', +const MARK_STYLES: Record = { + success: { text: 'text-ink-800', mark: 'bg-ink-900' }, + completed: { text: 'text-ink-800', mark: 'bg-ink-900' }, + win: { text: 'text-ink-800', mark: 'bg-ink-900' }, + ok: { text: 'text-ink-800', mark: 'bg-ink-900' }, + running: { text: 'text-ink-600', mark: 'bg-ink-400' }, + info: { text: 'text-ink-600', mark: 'bg-ink-400' }, + queued: { text: 'text-ink-500', mark: 'border border-ink-400' }, + pending: { text: 'text-ink-400', mark: 'bg-ink-300' }, + push: { text: 'text-ink-400', mark: 'bg-ink-300' }, + warning: { text: 'text-press', mark: 'border border-press' }, + failed: { text: 'text-press font-medium', mark: 'bg-press' }, + error: { text: 'text-press font-medium', mark: 'bg-press' }, + loss: { text: 'text-press font-medium', mark: 'bg-press' }, } export function Badge({ @@ -122,11 +100,10 @@ export function Badge({ status: string children: ReactNode }) { - const colorClass = BADGE_COLORS[status] ?? 'bg-gray-500/15 text-gray-400 border-gray-500/30' + const s = MARK_STYLES[status] ?? MARK_STYLES.pending return ( - + + ) @@ -154,9 +131,9 @@ export function DataTable({
- + {columns.map(col => ( - ))} @@ -166,10 +143,10 @@ export function DataTable({ {data.map(row => ( {columns.map(col => ( -
+ {col.label}
+ {col.render ? col.render(row) : row != null && typeof row === 'object' && col.key in row @@ -185,26 +162,27 @@ export function DataTable({ ) } -// ── 进度条 ────────────────────────────────────────────────────── +// ── 进度条:同前台置信度细线 ──────────────────────────────────── export function ProgressBar({ value }: { value: number }) { const clamped = Math.max(0, Math.min(100, value)) return ( -
+
) } -// ── 空状态 ────────────────────────────────────────────────────── +// ── 空状态:同前台「本版暂无赛程」 ────────────────────────────── -export function EmptyState({ text = '暂无数据' }: { text?: string }) { +export function EmptyState({ text = '暂无数据', sub }: { text?: string; sub?: string }) { return ( -
- {text} +
+

{text}

+ {sub &&

{sub}

}
) } @@ -228,7 +206,7 @@ export function MobileCardList({ return (
{data.map((row, idx) => ( -
+
{renderCard(row, idx)}
))} @@ -259,7 +237,7 @@ export function ResponsiveTable({ return ( <> {/* 桌面端表格 */} -
+
{/* 移动端卡片 */} @@ -268,7 +246,7 @@ export function ResponsiveTable({ ) } -// ── 小节标题 ──────────────────────────────────────────────────── +// ── 小节标题:同前台 section-head ─────────────────────────────── export function SectionHeader({ title, @@ -278,9 +256,83 @@ export function SectionHeader({ description?: string }) { return ( -
-

{title}

- {description &&

{description}

} +
+

{title}

+ {description &&

{description}

}
) } + +// ── 提示条:错误红框(同前台) / 正常墨框 ──────────────────────── + +export function Alert({ + kind, + title, + message, + onClose, +}: { + kind: 'error' | 'ok' | 'info' + title: string + message?: string + onClose?: () => void +}) { + const style = + kind === 'error' + ? 'border-press bg-press-wash' + : kind === 'ok' + ? 'border-ink-900 bg-paper-100' + : 'border-ink-300 bg-paper-50' + const titleCls = kind === 'error' ? 'text-press' : 'text-ink-900' + + return ( +
+
+

+

+ {message && ( +

+ {message} +

+ )} +
+ {onClose && ( + + )} +
+ ) +} + +// ── 加载指示:同前台 Spinner ──────────────────────────────────── + +export function Spinner({ className = '' }: { className?: string }) { + return ( + + ) +} + +// ── 骨架占位 ──────────────────────────────────────────────────── + +export function SkeletonBlock({ className = '' }: { className?: string }) { + return
+} diff --git a/frontend/src/admin/pages/Backtest.tsx b/frontend/src/admin/pages/Backtest.tsx index 0226f8a..591d2b2 100644 --- a/frontend/src/admin/pages/Backtest.tsx +++ b/frontend/src/admin/pages/Backtest.tsx @@ -1,16 +1,55 @@ /** - * Admin 后台 - 回测管理页面 + * Admin 后台 - 回测管理页面(报刊风) * - * 响应式布局: 移动端单列,桌面端双列 - * 触摸友好: 按钮最小 44px 高度 + * 功能: + * - 回测配置: 联赛(下拉)、日期范围、场数、模式 + * - 结果汇总: 已评分数、1X2 准确率、比分 RMSE、平均置信度 + * - 逐场明细: 实际比分 vs 预测比分,正误标记 + * - 模型评估: 各模型历史准确率(/eval/summary) + * + * 注意: 回测会对每场完赛比赛各发起一次 LLM 预测,成本高,需管理员密钥。 */ -import { useState } from 'react' -import { triggerBacktest, fetchEvalSummary } from '../dal' -import type { BacktestRequest, EvalSummary } from '../types' -import { Card, CardBody, CardHeader, Badge, SectionHeader } from '../components' +import { useEffect, useState } from 'react' +import { triggerBacktest, fetchEvalSummary, fetchLeagues } from '../dal' +import type { BacktestRequest, EvalSummary, League } from '../types' +import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components' + +interface BacktestResultRow { + match_id: number + league_code?: string | null + home_team: string + away_team: string + match_date?: string | null + actual_score: string + actual_1x2?: string + pred_home?: number | null + pred_away?: number | null + pred_1x2?: string | null + subjective_confidence?: number | null + correct_1x2: boolean +} + +interface BacktestResponse { + summary: { + total: number + scored: number + accuracy_1x2?: number + avg_score_rmse?: number + avg_subjective_confidence?: number + } + results: BacktestResultRow[] +} + +const OUTCOME_LABEL: Record = { '1': '主胜', X: '平局', '2': '客胜' } + +function fmtDate(s?: string | null): string { + if (!s) return '—' + return s.slice(0, 10) +} export default function BacktestPage() { + const [leagues, setLeagues] = useState([]) const [leagueId, setLeagueId] = useState('') const [dateFrom, setDateFrom] = useState('') const [dateTo, setDateTo] = useState('') @@ -18,8 +57,23 @@ export default function BacktestPage() { const [mode, setMode] = useState<'single' | 'multi'>('single') const [loading, setLoading] = useState(false) const [error, setError] = useState(null) - const [result, setResult] = useState(null) + const [result, setResult] = useState(null) const [evalSummary, setEvalSummary] = useState(null) + const [evalLoading, setEvalLoading] = useState(false) + + useEffect(() => { + fetchLeagues().then(setLeagues) + loadEval() + }, []) + + async function loadEval() { + setEvalLoading(true) + try { + setEvalSummary(await fetchEvalSummary()) + } finally { + setEvalLoading(false) + } + } async function handleBacktest(e: React.FormEvent) { e.preventDefault() @@ -34,8 +88,8 @@ export default function BacktestPage() { limit, mode, } - const res = await triggerBacktest(req) - setResult(res) + const res = await triggerBacktest(req as BacktestRequest) + setResult(res as unknown as BacktestResponse) } catch (err: unknown) { setError(err instanceof Error ? err.message : '回测失败') } finally { @@ -43,16 +97,13 @@ export default function BacktestPage() { } } - async function loadEval() { - const summary = await fetchEvalSummary() - setEvalSummary(summary) - } + const summary = result?.summary return (
@@ -62,138 +113,191 @@ export default function BacktestPage() {
- - 联赛 +
- + setDateFrom(e.target.value)} - className="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2.5 text-white min-h-[44px]" + className="field w-full" />
- + setDateTo(e.target.value)} - className="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2.5 text-white min-h-[44px]" + className="field w-full" />
-
- - setLimit(parseInt(e.target.value) || 20)} - className="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2.5 text-white min-h-[44px]" - /> -
- -
- - -
- - {error && ( -
- {error} +
+
+ + setLimit(parseInt(e.target.value) || 20)} + className="field w-full" + />
- )} +
+ + +
+
- - {/* 结果区域 */} + {/* 结果汇总 */}
- {result && ( + {summary && ( -
-
-
{result.scored}/{result.total}
-
已评分
-
-
-
- {result.accuracy_1x2?.toFixed(1) ?? '—'}% +
+
+
+ {summary.scored}/{summary.total}
-
1X2 准确率
+
已评分 / 总场数
+
+
+
+ {summary.accuracy_1x2 !== undefined && summary.accuracy_1x2 !== null + ? `${summary.accuracy_1x2.toFixed(1)}%` + : '—'} +
+
1X2 准确率
+
+
+
+ {summary.avg_score_rmse !== undefined && summary.avg_score_rmse !== null + ? summary.avg_score_rmse.toFixed(2) + : '—'} +
+
比分 RMSE
+
+
+
+ {summary.avg_subjective_confidence !== undefined && summary.avg_subjective_confidence !== null + ? `${Math.round(summary.avg_subjective_confidence * 100)}%` + : '—'} +
+
平均置信度
)} + {/* 模型评估 */} - 刷新 + } /> - {!evalSummary ? ( -
-

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

-
- ) : evalSummary.summary?.length > 0 ? ( -
- {evalSummary.summary.map((s: any, i: number) => ( + {evalSummary && evalSummary.summary?.length > 0 ? ( +
+ {evalSummary.summary.map((s, i) => (
- {s.provider}/{s.model} - - {s.accuracy?.toFixed(1)}% ({s.correct}/{s.total}) - + {s.provider}/{s.model} + + 准确率 + {s.accuracy_1x2 !== undefined && s.accuracy_1x2 !== null ? `${s.accuracy_1x2.toFixed(1)}%` : '—'} + + {s.total} 场 + {s.avg_score_rmse != null && RMSE {s.avg_score_rmse.toFixed(2)}} +
))}
) : ( -
-

暂无评估数据

-
+

+ 暂无评估数据。到「预测管理」完成结算后,这里会给出各模型准确率。 +

)}
+ + {/* 逐场明细 */} + {result && result.results?.length > 0 && ( + + + +
+ {result.results.map(r => ( +
+ + {fmtDate(r.match_date)} + + + {r.home_team} vs {r.away_team} + + + 实际 {r.actual_score} + | + 预测 + {r.pred_home ?? '-'}:{r.pred_away ?? '-'} + + + + {r.correct_1x2 ? 命中 : 未中} + +
+ ))} +
+
+
+ )}
) } diff --git a/frontend/src/admin/pages/Collection.tsx b/frontend/src/admin/pages/Collection.tsx index 7f60760..aa7b137 100644 --- a/frontend/src/admin/pages/Collection.tsx +++ b/frontend/src/admin/pages/Collection.tsx @@ -1,14 +1,13 @@ /** - * Admin 后台 - 数据采集页面 + * Admin 后台 - 数据采集页面(报刊风) * * 响应式布局: 移动端单列,桌面端双列 - * 触摸友好: 按钮最小 44px 高度 */ import { useEffect, useState, useCallback } from 'react' import { triggerCollection, fetchLeagues } from '../dal' import type { CollectionRequest, League } from '../types' -import { Card, CardBody, CardHeader, Badge, SectionHeader } from '../components' +import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components' const SOURCES = [ { value: 'bzzoiro', label: 'Bzzoiro', desc: '历史赛程与比分' }, @@ -16,6 +15,31 @@ const SOURCES = [ { value: 'injuries', label: 'Injuries', desc: '球员伤停' }, ] as const +/** 把采集接口返回摘要成一两行可读文字 */ +function summarizeResult(res: any, source: string): { title: string; detail: string } { + if (res && typeof res === 'object') { + if (source === 'bzzoiro' && ('total_inserted' in res || 'total_updated' in res)) { + return { + title: `采集完成:新增 ${res.total_inserted ?? 0} 条,更新 ${res.total_updated ?? 0} 条`, + detail: Array.isArray(res.errors) && res.errors.length > 0 ? res.errors.join('\n') : '', + } + } + if ('count' in res || 'updated' in res) { + const parts = [ + `新增 ${res.count ?? 0}`, + `更新 ${res.updated ?? 0}`, + `跳过 ${res.skipped ?? 0}`, + `未匹配 ${res.unmatched ?? 0}`, + ] + return { + title: `采集完成:${parts.join(' / ')}`, + detail: Array.isArray(res.errors) && res.errors.length > 0 ? res.errors.join('\n') : '', + } + } + } + return { title: '采集完成', detail: JSON.stringify(res)?.slice(0, 300) ?? '' } +} + export default function CollectionPage() { const [leagues, setLeagues] = useState([]) const [source, setSource] = useState('bzzoiro') @@ -25,7 +49,7 @@ export default function CollectionPage() { const [season, setSeason] = useState('') const [loading, setLoading] = useState(false) const [error, setError] = useState(null) - const [successMsg, setSuccessMsg] = useState(null) + const [result, setResult] = useState<{ title: string; detail: string } | null>(null) const loadLeagues = useCallback(async () => { const lg = await fetchLeagues() @@ -37,7 +61,7 @@ export default function CollectionPage() { async function handleSubmit(e: React.FormEvent) { e.preventDefault() setError(null) - setSuccessMsg(null) + setResult(null) setLoading(true) try { @@ -50,7 +74,7 @@ export default function CollectionPage() { date_to: dateTo || undefined, } const res = await triggerCollection(body) - setSuccessMsg(`采集完成: ${JSON.stringify(res).slice(0, 200)}`) + setResult(summarizeResult(res, source)) } catch (err: unknown) { setError(err instanceof Error ? err.message : '采集触发失败') } finally { @@ -62,7 +86,7 @@ export default function CollectionPage() {
@@ -73,11 +97,11 @@ export default function CollectionPage() {
{/* 数据源选择 */}
- + setLeagueCode(e.target.value)} - className="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2.5 text-white min-h-[44px]" + className="field w-full" > {leagues.map(l => ( @@ -105,13 +129,13 @@ export default function CollectionPage() { {/* Understat 专用: 赛季 */} {source === 'understat' && (
- + setSeason(e.target.value)} placeholder="2025" - className="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2.5 text-white min-h-[44px]" + className="field w-full" />
)} @@ -120,45 +144,40 @@ export default function CollectionPage() { {source !== 'injuries' && (
- + setDateFrom(e.target.value)} - className="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2.5 text-white min-h-[44px]" + className="field w-full" />
- + setDateTo(e.target.value)} - className="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2.5 text-white min-h-[44px]" + className="field w-full" />
)} {/* 消息提示 */} - {error && ( -
- {error} -
- )} - {successMsg && ( -
- {successMsg} -
+ {error && setError(null)} />} + {result && ( + setResult(null)} + /> )} {/* 提交按钮 */} - @@ -170,21 +189,19 @@ export default function CollectionPage() {
{SOURCES.map(s => ( -
-
+
+
{s.label} +

{s.desc}

-

{s.desc}

))}
- {/* 移动端提示 */} -
-

- 💡 在移动端,采集任务将在后台运行,完成后会显示结果通知。 -

-
+

+ 若后端配置了 ADMIN_API_KEY,采集接口需要管理员密钥。 + 遇到 401 请到「系统配置」页填写密钥。 +

diff --git a/frontend/src/admin/pages/Config.tsx b/frontend/src/admin/pages/Config.tsx index be19d96..274d732 100644 --- a/frontend/src/admin/pages/Config.tsx +++ b/frontend/src/admin/pages/Config.tsx @@ -1,20 +1,26 @@ /** - * Admin 后台 - 系统配置管理页面 + * Admin 后台 - 系统配置管理页面(报刊风) * * 功能: - * - 显示当前 .env 配置(脱敏) - * - 提供配置修改指南 - * - 快速导航到数据源和 LLM 配置 + * - 管理员密钥(X-API-Key):存本机浏览器,自动附带到采集/回测/结算等受保护接口 + * - 显示当前 .env 配置(脱敏;后端暂无配置端点,为静态说明) + * - 配置修改指南 */ import { useEffect, useState, useCallback } from 'react' import { fetchSystemConfig } from '../dal' -import { Card, CardBody, CardHeader, Badge, SectionHeader } from '../components' +import { getAdminKey, setAdminKey } from '../api' +import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner, SkeletonBlock } from '../components' export default function ConfigPage() { const [config, setConfig] = useState([]) const [loading, setLoading] = useState(true) + // 管理员密钥 + const [adminKey, setAdminKeyInput] = useState('') + const [keySaved, setKeySaved] = useState(false) + const [keyExists, setKeyExists] = useState(false) + const loadConfig = useCallback(async () => { setLoading(true) try { @@ -27,54 +33,102 @@ export default function ConfigPage() { } }, []) - useEffect(() => { loadConfig() }, [loadConfig]) + useEffect(() => { + loadConfig() + const stored = getAdminKey() + setKeyExists(stored !== '') + }, [loadConfig]) + + function handleSaveKey(e: React.FormEvent) { + e.preventDefault() + setAdminKey(adminKey.trim()) + setKeyExists(adminKey.trim() !== '') + setKeySaved(true) + setAdminKeyInput('') + setTimeout(() => setKeySaved(false), 3000) + } + + function handleClearKey() { + setAdminKey('') + setAdminKeyInput('') + setKeyExists(false) + } return (
- {/* 配置警告 */} -
-
- ⚠️ -
-

安全提示

-

- 所有敏感配置(API Key、数据库连接等)通过后端 .env 文件管理。 - 前端仅显示脱敏后的状态信息。如需修改,请通过 SSH 连接到服务器编辑配置文件。 + {/* 管理员密钥 */} + + + +

+
+ setAdminKeyInput(e.target.value)} + placeholder={keyExists ? '••••••••(已保存,输入新值可更换)' : '粘贴 ADMIN_API_KEY'} + autoComplete="off" + className="field flex-1" + /> +
+ + +
+
+ + {keySaved && } + {keyExists && !keySaved && ( +

+ 当前状态:已保存密钥 + 密钥仅保存在本机浏览器,不会上传到任何第三方。 +

+ )} +

+ 密钥与服务器 .env 中 ADMIN_API_KEY 一致即可。留空时后端默认不鉴权(本地开发模式)。 + 遇到 401 错误通常就是缺这个密钥。

-
-
-
+ + + {/* 快速导航 */} @@ -82,53 +136,38 @@ export default function ConfigPage() { - 刷新 + } /> - + {loading ? ( -
+
{[1, 2, 3, 4, 5].map(i => ( -
+ ))}
) : config.length > 0 ? ( -
- {/* 桌面端表头 */} -
- 配置项 - 当前值 - 说明 -
- {/* 配置行 */} +
{config.map(item => (
- {item.key} - {item.is_sensitive && ( - 敏感 - )} -
-
- {item.value_masked} -
-
- {item.description} + {item.key} + {item.is_sensitive && 敏感}
+
{item.value_masked}
+
{item.description}
))}
) : ( -
无法加载配置信息
+

无法加载配置信息

)} @@ -136,12 +175,12 @@ export default function ConfigPage() { {/* 修改指南 */} - -
-

通过 SSH 修改 .env

-
-{`# 连接到 NAS
-ssh user@your-nas-ip
+        
+          
+

通过 SSH 修改 .env

+
+{`# 连接到部署主机
+ssh user@your-server-ip
 
 # 进入项目目录
 cd /vol2/1000/Docker/Profeto
@@ -157,29 +196,24 @@ docker compose logs -f api`}
             
-
-

常用配置项说明

-
-
- LLM_API_KEY - LLM 服务商的 API 密钥,用于调用大模型 -
-
- LLM_MODEL - 使用的模型名称,如 gpt-4o、claude-3-5-sonnet -
-
- LLM_BASE_URL - API 基础地址,支持兼容 OpenAI 协议的服务商 -
-
- BZZOIRO_KEY - Bzzoiro 数据源 API 密钥 -
-
- DATABASE_URL - PostgreSQL 数据库连接字符串 -
+
+

常用配置项说明

+
+ {[ + ['LLM_API_KEY', 'LLM 服务商的 API 密钥,用于调用大模型'], + ['LLM_MODEL', '使用的模型名称,如 gpt-4o、claude-3-5-sonnet'], + ['LLM_BASE_URL', 'API 基础地址,支持兼容 OpenAI 协议的服务商'], + ['ADMIN_API_KEY', '管理后台写接口的鉴权密钥,配置后需在本页保存到浏览器'], + ['BZZOIRO_KEY', 'Bzzoiro 数据源 API 密钥'], + ['DATABASE_URL', 'PostgreSQL 数据库连接字符串'], + ].map(([key, desc]) => ( +
+ + {key} + + {desc} +
+ ))}
diff --git a/frontend/src/admin/pages/Dashboard.tsx b/frontend/src/admin/pages/Dashboard.tsx index 9d31281..6654790 100644 --- a/frontend/src/admin/pages/Dashboard.tsx +++ b/frontend/src/admin/pages/Dashboard.tsx @@ -1,13 +1,13 @@ /** - * Admin 后台 - 仪表盘 + * Admin 后台 - 仪表盘(报刊风) * - * 响应式布局: 移动端 1 列 → 平板 2 列 → 桌面 4 列 + * 响应式: 移动端 1 列 → 平板 2 列 → 桌面 4 列 */ import { useEffect, useState } from 'react' import { fetchDashboard, fetchHealth } from '../dal' import type { DashboardStats } from '../types' -import { Card, CardBody, CardHeader, StatCard, Badge } from '../components' +import { Card, CardBody, CardHeader, StatCard, Alert, SkeletonBlock } from '../components' export default function Dashboard() { const [data, setData] = useState(null) @@ -36,65 +36,58 @@ export default function Dashboard() { if (error) { return ( -
-

加载仪表盘失败

-

{error}

-
) } + const healthText = + health?.status === 'healthy' || health?.status === 'ok' ? '正常' : '异常' + return (
- {/* 统计卡片: 移动端 1 列 → 平板 2 列 → 桌面 4 列 */} + {/* 统计:报纸数字版式 */}
- - - + + +
{/* 联赛列表 */} - + {loading ? (
{[1, 2, 3, 4].map(i => ( -
+ ))}
) : data && data.leagues.length > 0 ? (
{data.leagues.map(l => ( - - {l.name_zh || l.name} ({l.code}) - + + {l.name_zh || l.name} + {l.code} + ))}
) : ( -

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

+

+ 暂无联赛数据,请先到「数据采集」导入比赛数据。 +

)} @@ -106,33 +99,39 @@ export default function Dashboard() { diff --git a/frontend/src/admin/pages/DataSources.tsx b/frontend/src/admin/pages/DataSources.tsx index 30c1598..11e6da7 100644 --- a/frontend/src/admin/pages/DataSources.tsx +++ b/frontend/src/admin/pages/DataSources.tsx @@ -1,17 +1,17 @@ /** - * Admin 后台 - 数据源管理页面 + * Admin 后台 - 数据源管理页面(报刊风) * * 功能: * - 显示当前数据源状态 (bzzoiro / understat / injuries) - * - 显示 API Key 配置状态(脱敏显示) - * - 测试连接按钮(调用采集 API 测试) - * - 采集历史记录 + * - 显示 API Key 配置状态(脱敏显示) + * - 测试连接按钮(调用采集 API 验证) + * - 数据源说明 */ import { useEffect, useState, useCallback } from 'react' import { fetchDataSourceStatuses, testDataSource } from '../dal' import type { DataSourceStatus } from '../types' -import { Card, CardBody, CardHeader, Badge, SectionHeader } from '../components' +import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner, SkeletonBlock } from '../components' export default function DataSourcesPage() { const [sources, setSources] = useState([]) @@ -37,7 +37,7 @@ export default function DataSourcesPage() { setTestingSource(sourceName) setTestResults(prev => ({ ...prev, [sourceName]: { success: false, message: '测试中...' } })) try { - await testDataSource(sourceName as any) + await testDataSource(sourceName as 'bzzoiro' | 'understat' | 'injuries') setTestResults(prev => ({ ...prev, [sourceName]: { success: true, message: '连接成功' } })) } catch (err: unknown) { const msg = err instanceof Error ? err.message : '连接失败' @@ -51,7 +51,7 @@ export default function DataSourcesPage() {
{/* 数据源卡片 */} @@ -60,10 +60,10 @@ export default function DataSourcesPage() { {[1, 2, 3].map(i => ( -
-
-
-
+
+ + +
@@ -77,47 +77,41 @@ export default function DataSourcesPage() { {/* 头部 */} -
-
-

{source.label}

- - {source.keyConfigured ? '已配置' : '未配置'} - -
+
+

{source.label}

+ + {source.keyConfigured ? '已配置' : '未配置'} +
{/* API Key 状态 */}
- API Key - {source.maskedKey} + API Key + {source.maskedKey}
- 最近采集 - {source.lastIngestion || '暂无记录'} + 最近采集 + {source.lastIngestion || '暂无记录'}
{/* 测试结果 */} {result && ( -
- {result.message} -
+ )} {/* 操作按钮 */} @@ -130,45 +124,28 @@ export default function DataSourcesPage() { -
-
-
- Bzzoiro -
-

+

+
+ Bzzoiro +

历史赛程与比分数据,覆盖全球主要联赛。需要 API Key 配置。

-
-
- Understat -
-

- xG (预期进球) 进阶数据,无需 API Key,通过网页抓取获取。 +

+ Understat +

+ xG(预期进球)进阶数据,无需 API Key,通过网页抓取获取。

-
-
- Injuries -
-

+

+ Injuries +

球员伤停信息,用于预测时考虑阵容完整性。需要 API Key 配置。

- - {/* 采集历史 */} - - - -
-

暂无采集历史记录

-

后端暂无采集历史端点,采集任务完成后将在此显示

-
-
-
) } diff --git a/frontend/src/admin/pages/LLMConfig.tsx b/frontend/src/admin/pages/LLMConfig.tsx index c797870..f67a057 100644 --- a/frontend/src/admin/pages/LLMConfig.tsx +++ b/frontend/src/admin/pages/LLMConfig.tsx @@ -1,24 +1,24 @@ /** - * Admin 后台 - LLM 配置管理页面 + * Admin 后台 - LLM 配置管理页面(报刊风) * * 功能: - * - 显示当前 LLM 配置(provider, model, base_url) - * - 测试 LLM 连接(调用 /predict 测试) - * - 显示 LLM 使用统计(预测次数、平均延迟、成功率) - * - 模型切换(显示可用模型列表) + * - 显示当前 LLM 配置(provider, model, base_url;后端暂无配置端点,当前值取自 .env 约定) + * - 测试 LLM 连接(会真实调用一次 /predict,产生 LLM 调用费用) + * - 显示 LLM 使用统计(从预测记录聚合) + * - 可用模型列表 */ import { useEffect, useState, useCallback } from 'react' import { testLLMConnection, fetchLLMUsageStats } from '../dal' import type { LLMUsageStats } from '../types' -import { Card, CardBody, CardHeader, Badge, SectionHeader } from '../components' +import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner, SkeletonBlock } from '../components' // 可用模型列表 const AVAILABLE_MODELS = [ - { id: 'gpt-4o', label: 'GPT-4o', provider: 'openai', description: '最强大模型,适合复杂分析' }, + { id: 'gpt-4o', label: 'GPT-4o', provider: 'openai', description: '综合能力最强,适合复杂分析' }, { id: 'gpt-4o-mini', label: 'GPT-4o Mini', provider: 'openai', description: '快速经济,适合批量预测' }, { id: 'claude-3-5-sonnet', label: 'Claude 3.5 Sonnet', provider: 'anthropic', description: '长上下文分析能力强' }, - { id: 'deepseek-chat', label: 'DeepSeek V3', provider: 'deepseek', description: '高性价比中文优化' }, + { id: 'deepseek-chat', label: 'DeepSeek V3', provider: 'deepseek', description: '高性价比,中文优化' }, ] export default function LLMConfigPage() { @@ -27,7 +27,7 @@ export default function LLMConfigPage() { const [testing, setTesting] = useState(false) const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null) - // 当前配置(模拟,后端暂无配置端点) + // 当前配置(后端暂无配置端点,取 .env 约定值展示) const currentConfig = { provider: 'openai', model: 'gpt-4o', @@ -68,62 +68,51 @@ export default function LLMConfigPage() {
{/* 当前配置 */} - -
-
- 提供商 - {currentConfig.provider} -
-
- 模型 - {currentConfig.model} -
-
- API 地址 - {currentConfig.base_url} -
-
- API Key -
- {currentConfig.api_key_masked} - - {currentConfig.api_key_configured ? '已配置' : '未配置'} - + +
+ {[ + { label: '提供商', value: currentConfig.provider, mono: false }, + { label: '模型', value: currentConfig.model, mono: true }, + { label: 'API 地址', value: currentConfig.base_url, mono: true }, + { label: 'API Key', value: currentConfig.api_key_masked, mono: true }, + { label: '模式', value: '多专家 (5 路 + 终裁)', mono: false }, + ].map(row => ( +
+ {row.label} + + {row.value} +
-
-
- 模式 - 多 Agent (5 专家 + 终裁) -
+ ))}
{/* 测试连接 */} {testResult && ( -
- {testResult.message} +
+
)} - +

+ 测试会真实调用一次单次模式预测,产生 LLM 费用。 +

@@ -131,126 +120,110 @@ export default function LLMConfigPage() { - 刷新 + } /> {loading ? ( -
-
-
-
+
+ +
) : stats ? ( -
-
-
-
{stats.total_predictions}
-
总预测数
-
-
-
{stats.avg_latency_ms}ms
-
平均延迟
+
+
+
+ {stats.total_predictions}
+
总预测数
-
-
- {stats.success_rate.toFixed(1)}% +
+
+ {stats.avg_latency_ms > 0 ? `${(stats.avg_latency_ms / 1000).toFixed(1)}s` : '—'}
-
成功率
+
平均延迟
+
+
+
+ {stats.success_rate.toFixed(0)}% +
+
有效率
) : ( -
暂无使用统计数据
+

暂无使用统计数据

)}
- {/* 模型切换 */} + {/* 可用模型 */} - - -
- {AVAILABLE_MODELS.map(model => { - const isCurrent = model.id === currentConfig.model - return ( -
-
-
-
- {model.label} - {isCurrent && 当前} -
-

{model.description}

-
-
+ + + {AVAILABLE_MODELS.map(model => { + const isCurrent = model.id === currentConfig.model + return ( +
+
- {model.provider} - {!isCurrent && ( - - 编辑 .env 切换 - - )} + {model.label} + {isCurrent && 当前}
+

{model.description}

- ) - })} -
+
+ {model.provider} + {!isCurrent && 编辑 .env 切换} +
+
+ ) + })} {/* 最近预测 */} - + {loading ? ( -
+
{[1, 2, 3].map(i => ( -
+ ))}
) : stats && stats.recent_predictions.length > 0 ? ( -
+
{stats.recent_predictions.map(p => (
-
- #{p.id} - Match #{p.match_id} - {p.model} +
+ #{p.id} + 比赛 #{p.match_id} + {p.model}
-
- - {p.created_at ? new Date(p.created_at).toLocaleString() : '—'} +
+ + {p.created_at ? new Date(p.created_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '—'} - - {p.status === 'success' ? '成功' : '失败'} - + {p.status === 'success' ? 成功 : 失败}
))}
) : ( -
-

暂无预测记录

-

触发预测后将在此显示记录

-
+

暂无预测记录

)} diff --git a/frontend/src/admin/pages/Monitoring.tsx b/frontend/src/admin/pages/Monitoring.tsx index 1f6bbf2..795c12a 100644 --- a/frontend/src/admin/pages/Monitoring.tsx +++ b/frontend/src/admin/pages/Monitoring.tsx @@ -1,105 +1,152 @@ /** - * Admin 后台 - 监控面板 + * Admin 后台 - 监控面板(报刊风) * - * 响应式布局: 移动端单列,桌面端三列 - * 触摸友好: 卡片可点击查看详情 + * 功能: + * - /health 存活检查(自动:30 秒一轮;可手动刷新) + * - /health/ready 数据库就绪检查 + * - 服务名 / 版本 / 运行时间 / 检查项(后端返回什么就展示什么) */ -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { fetchHealth } from '../dal' -import { SectionHeader } from '../components' +import { api } from '../api' +import { Card, CardBody, CardHeader, SectionHeader, Alert, Spinner, Badge } from '../components' export default function MonitoringPage() { const [health, setHealth] = useState(null) + const [ready, setReady] = useState<'ready' | 'not_ready' | null>(null) const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [lastCheck, setLastCheck] = useState('') + + const refresh = useCallback(async () => { + setLoading(true) + setError(null) + try { + const [h, r] = await Promise.allSettled([ + fetchHealth(), + api.get<{ status: string }>('/health/ready'), + ]) + setHealth(h.status === 'fulfilled' ? h.value : null) + setReady(r.status === 'fulfilled' ? (r.value?.status as 'ready' | 'not_ready') : null) + if (h.status === 'rejected') { + setError(h.reason instanceof Error ? h.reason.message : '无法连接到后端') + } + setLastCheck(new Date().toLocaleTimeString('zh-CN', { hour12: false })) + } finally { + setLoading(false) + } + }, []) useEffect(() => { - fetchHealth().then(setHealth).finally(() => setLoading(false)) - }, []) + refresh() + const t = setInterval(refresh, 30_000) + return () => clearInterval(t) + }, [refresh]) + + const alive = health?.status === 'healthy' || health?.status === 'ok' return (
- {loading ? ( -
- {[1, 2, 3].map(i => ( -
- ))} -
- ) : health ? ( -
- {/* 服务状态 */} -
-
状态
-
- - {health.status} -
-
+
+ + {lastCheck && `最近巡检 ${lastCheck}`} + + +
- {/* 服务名称 */} -
-
服务
-
{health.service || 'profeto'}
-
- - {/* 数据库 */} -
-
数据库
-
11 张表
-
- - {/* 版本 */} - {health.version && ( -
-
版本
-
{health.version}
-
- )} - - {/* 运行时间 */} - {health.uptime_seconds && ( -
-
运行时间
-
- {Math.floor(health.uptime_seconds / 3600)}h {Math.floor((health.uptime_seconds % 3600) / 60)}m -
-
- )} - - {/* 检查项 */} - {health.checks && Object.keys(health.checks).length > 0 && ( -
-
健康检查
-
- {Object.entries(health.checks).map(([key, val]) => ( -
- {key} - - {String(val)} - -
- ))} -
-
- )} -
- ) : ( -
-

无法连接到后端

-

请检查服务是否正常运行

- -
+ {error && ( + )} + +
+ {/* 存活状态 */} +
+
存活状态
+
+
+
+ + {/* 数据库就绪 */} +
+
数据库就绪
+
+
+
+ + {/* 服务名 */} +
+
服务
+
+ {health?.service || 'profeto'} +
+
+ + {/* 版本 */} + {health?.version && ( +
+
版本
+
+ {health.version} +
+
+ )} + + {/* 运行时间 */} + {health?.uptime_seconds != null && ( +
+
运行时间
+
+ {Math.floor(health.uptime_seconds / 3600)}h{' '} + {Math.floor((health.uptime_seconds % 3600) / 60)}m +
+
+ )} + + {/* 检查项 */} + {health?.checks && Object.keys(health.checks).length > 0 && ( +
+
健康检查
+
+ {Object.entries(health.checks).map(([key, val]) => ( +
+ {key} + + {String(val)} + +
+ ))} +
+
+ )} +
) } diff --git a/frontend/src/admin/pages/Predictions.tsx b/frontend/src/admin/pages/Predictions.tsx index 7a95608..e055b8c 100644 --- a/frontend/src/admin/pages/Predictions.tsx +++ b/frontend/src/admin/pages/Predictions.tsx @@ -1,14 +1,36 @@ /** - * Admin 后台 - 预测管理页面 + * Admin 后台 - 预测管理页面(报刊风) + * + * 功能: + * - 触发预测(选择比赛 + 模式) + * - 结算:录入实际比分,写入评估(接 /eval/settle) + * - 预测记录列表:可展开查看终裁理由与专家摘要 * * 响应式布局: 移动端单列,桌面端双列 - * 触摸友好: 按钮最小 44px 高度 */ -import { useEffect, useState } from 'react' -import { triggerPrediction, fetchPredictions, fetchMatches } from '../dal' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { triggerPrediction, fetchPredictions, fetchMatches, settlePrediction } from '../dal' import type { Match, Prediction } from '../types' -import { Card, CardBody, CardHeader, Badge, SectionHeader } from '../components' +import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components' + +const AGENT_LABELS: Record = { + h2h: '历史交锋', + form: '近期状态', + stats: '攻防数据', + home_away: '主客因素', + injuries: '阵容完整性', +} + +const OUTCOME_LABEL: Record = { '1': '主胜', X: '平局', '2': '客胜' } + +function fmtTime(s?: string | null): string { + if (!s) return '—' + const d = new Date(s) + return isNaN(d.getTime()) + ? s + : d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) +} export default function PredictionsPage() { const [matches, setMatches] = useState([]) @@ -19,11 +41,36 @@ export default function PredictionsPage() { const [error, setError] = useState(null) const [successMsg, setSuccessMsg] = useState(null) - useEffect(() => { - fetchPredictions(20).then(setPredictions) - fetchMatches({ status: 'scheduled', limit: 20 }).then(d => setMatches(d.items)) + // 结算表单 + const [settleId, setSettleId] = useState('') + const [homeGoals, setHomeGoals] = useState('') + const [awayGoals, setAwayGoals] = useState('') + const [settling, setSettling] = useState(false) + const [settleMsg, setSettleMsg] = useState<{ kind: 'error' | 'ok'; text: string } | null>(null) + + const refreshPredictions = useCallback(async () => { + const list = await fetchPredictions(50) + setPredictions(list) }, []) + useEffect(() => { + refreshPredictions() + fetchMatches({ limit: 100 }).then(d => setMatches(d.items)) + }, [refreshPredictions]) + + /** match_id → 中文名对阵 */ + const matchName = useMemo(() => { + const map = new Map() + for (const m of matches) { + const home = m.home_team_zh || m.home_team + const away = m.away_team_zh || m.away_team + map.set(m.id, `${home} vs ${away}`) + } + return map + }, [matches]) + + const nameOf = (id: number) => matchName.get(id) ?? `比赛 #${id}` + async function handlePredict(e: React.FormEvent) { e.preventDefault() if (!matchId) return @@ -32,8 +79,8 @@ export default function PredictionsPage() { setSuccessMsg(null) try { await triggerPrediction({ match_id: parseInt(matchId), mode }) - setSuccessMsg('预测任务已提交') - fetchPredictions(20).then(setPredictions) + setSuccessMsg('预测任务已完成,记录已更新') + await refreshPredictions() } catch (err: unknown) { setError(err instanceof Error ? err.message : '预测失败') } finally { @@ -41,11 +88,40 @@ export default function PredictionsPage() { } } + const unsettled = predictions.filter(p => !p.settled) + + async function handleSettle(e: React.FormEvent) { + e.preventDefault() + const pid = parseInt(settleId) + const hg = parseInt(homeGoals) + const ag = parseInt(awayGoals) + if (!pid || isNaN(hg) || isNaN(ag)) return + setSettling(true) + setSettleMsg(null) + try { + await settlePrediction(pid, hg, ag) + setSettleMsg({ kind: 'ok', text: '结算完成,准确率统计已更新' }) + setSettleId('') + setHomeGoals('') + setAwayGoals('') + await refreshPredictions() + } catch (err: unknown) { + setSettleMsg({ + kind: 'error', + text: err instanceof Error ? err.message : '结算失败', + }) + } finally { + setSettling(false) + } + } + + const settleTarget = predictions.find(p => p.id === parseInt(settleId)) + return (
@@ -55,84 +131,221 @@ export default function PredictionsPage() {
- +
- +
- {error && ( -
- {error} -
- )} + {error && setError(null)} />} {successMsg && ( -
- {successMsg} -
+ setSuccessMsg(null)} /> )}
- {/* 最近预测 */} + {/* 结算 */} - + - {predictions.length === 0 ? ( -
-

暂无预测记录

-

触发预测后将在此显示

-
+ {unsettled.length === 0 ? ( +

+ 没有待结算的预测记录。预测完成后可在此录入实际比分。 +

) : ( -
- {predictions.slice(0, 10).map(p => ( -
+
+ + +
+ + {settleTarget && ( +

+ 预测:{settleTarget.pred_home_goals ?? '-'} : {settleTarget.pred_away_goals ?? '-'} + ({OUTCOME_LABEL[settleTarget.pred_1x2 ?? ''] ?? '?'}) + {fmtTime(settleTarget.created_at)} +

+ )} + +
+
+ + setHomeGoals(e.target.value)} + className="field w-full" + />
- ))} -
+
+ + setAwayGoals(e.target.value)} + className="field w-full" + /> +
+
+ + {settleMsg && ( + setSettleMsg(null)} + /> + )} + + + )}
+ + {/* 最近预测 */} + + + + {predictions.length === 0 ? ( +

+ 暂无预测记录,触发预测后将在此显示 +

+ ) : ( +
+ {predictions.map(p => { + const okAgents = (p.agent_outputs ?? []).filter(a => a.status === 'ok') + return ( +
+ + #{p.id} + {nameOf(p.match_id)} + + {p.pred_home_goals ?? '-'}:{p.pred_away_goals ?? '-'} + + + {OUTCOME_LABEL[p.pred_1x2 ?? ''] ?? '—'} + {p.subjective_confidence !== null && p.subjective_confidence !== undefined && + ` · ${Math.round(p.subjective_confidence * 100)}%`} + + + {p.settled ? ( + 已结算 + ) : ( + 未结算 + )} + {fmtTime(p.created_at)} + + + + +
+

+ {p.mode === 'multi' ? `多专家 · ${okAgents.length}/${p.agent_outputs?.length ?? 0} 路有效` : '单次模式'} + {p.model && {p.model}} +

+ + {p.reasoning && ( +
+

+ {p.reasoning} +

+
+ )} + + {p.agent_outputs && p.agent_outputs.length > 0 && ( +
    + {p.agent_outputs.map((a, i) => ( +
  • +
  • + ))} +
+ )} + + {p.settled && ( +

+ 实际比分 {p.actual_home_goals ?? '-'} : {p.actual_away_goals ?? '-'} +

+ )} +
+
+ ) + })} +
+ )} +
+
) } diff --git a/frontend/src/admin/types.ts b/frontend/src/admin/types.ts index c456863..5be5fad 100644 --- a/frontend/src/admin/types.ts +++ b/frontend/src/admin/types.ts @@ -52,6 +52,15 @@ export interface Match { // ── 预测 ──────────────────────────────────────────────────────── +/** 列表接口返回的单路专家摘要字段 */ +export interface PredictionAgentOutput { + agent: string + status: string + analysis?: string | null + probable_score?: string | null + subjective_confidence?: number | null +} + export interface Prediction { id: number match_id: number @@ -64,6 +73,7 @@ export interface Prediction { pred_1x2?: string | null subjective_confidence?: number | null reasoning?: string | null + agent_outputs?: PredictionAgentOutput[] | null created_at: string actual_home_goals?: number | null actual_away_goals?: number | null @@ -95,9 +105,10 @@ export interface EvalSummary { provider: string model: string total: number - correct: number - accuracy: number - avg_rmse: number + /** 1X2 准确率,百分数 0-100 */ + accuracy_1x2?: number + avg_score_rmse?: number | null + avg_subjective_confidence?: number | null }> } diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx index 825efec..c49eaa8 100644 --- a/frontend/src/components/ErrorBoundary.tsx +++ b/frontend/src/components/ErrorBoundary.tsx @@ -30,16 +30,16 @@ export class ErrorBoundary extends Component { return this.props.fallback } return ( -
-
-
⚠️
-

页面出现错误

-

+

+
+

EXCEPTION

+

页面出现错误

+

{this.state.error?.message || '未知错误'}

diff --git a/frontend/src/index.css b/frontend/src/index.css index b8467f6..a56d78c 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -101,53 +101,6 @@ } } -/* ── 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; - } -} +/* ── Admin 后台 ── + 与前台共用同一套报刊风组件(.btn/.field/.tab/.section-head/.skeleton), + 不再单独维护暗色主题。 */ -- 2.39.5