feat: 管理界面优化 — 移动端自适应 + 数据源 + LLM 配置
移动端自适应: - AdminLayout: 汉堡菜单 + 可折叠侧边栏 + 遮罩层 + ESC 关闭 - 所有页面响应式布局 (grid-cols-1 sm:grid-cols-2 lg:grid-cols-4) - 触摸友好按钮 (min-h-[44px]) - 表格移动端卡片视图 新增页面: - DataSources: 数据源状态 + API Key 脱敏 + 测试连接 - LLMConfig: LLM 配置 + 测试连接 + 使用统计 + 最近预测 增强功能: - Config: 配置列表 + 修改指南 + 快捷导航 - types: 新增 DataSourceStatus, LLMUsageStats 等类型 - dal: 新增 testDataSource, fetchDataSourceStatuses, testLLMConnection 等
This commit is contained in:
@@ -1,11 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* Admin 后台 - 布局组件
|
* Admin 后台 - 布局组件
|
||||||
*
|
*
|
||||||
* 提供管理界面的基本结构:顶栏 + 侧边栏 + 内容区
|
* 响应式布局: 移动端汉堡菜单 + 可折叠侧边栏
|
||||||
|
* 桌面端: 固定侧边栏 + 内容区
|
||||||
* 暗色主题,参考线性风格设计
|
* 暗色主题,参考线性风格设计
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { NavLink, Outlet } from 'react-router-dom'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { NavLink, Outlet, useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ to: '/admin', label: '仪表盘', icon: '◇', end: true },
|
{ to: '/admin', label: '仪表盘', icon: '◇', end: true },
|
||||||
@@ -13,22 +15,72 @@ const NAV_ITEMS = [
|
|||||||
{ to: '/admin/predictions', label: '预测管理', icon: '◆' },
|
{ to: '/admin/predictions', label: '预测管理', icon: '◆' },
|
||||||
{ to: '/admin/backtest', label: '回测管理', icon: '◉' },
|
{ to: '/admin/backtest', label: '回测管理', icon: '◉' },
|
||||||
{ to: '/admin/monitoring', label: '监控面板', icon: '◐' },
|
{ to: '/admin/monitoring', label: '监控面板', icon: '◐' },
|
||||||
{ to: '/admin/config', label: '配置管理', icon: '◑' },
|
{ to: '/admin/data-sources', label: '数据源', icon: '◫' },
|
||||||
|
{ to: '/admin/llm-config', label: 'LLM 配置', icon: '◬' },
|
||||||
|
{ to: '/admin/config', label: '系统配置', icon: '◑' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function AdminLayout() {
|
export default function AdminLayout() {
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
// 路由变化时关闭移动端菜单
|
||||||
|
const closeSidebar = useCallback(() => setSidebarOpen(false), [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
closeSidebar()
|
||||||
|
}, [location.pathname, closeSidebar])
|
||||||
|
|
||||||
|
// ESC 键关闭菜单
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') setSidebarOpen(false)
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', handler)
|
||||||
|
return () => document.removeEventListener('keydown', handler)
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-gray-950 text-gray-200 overflow-hidden">
|
<div className="flex h-screen bg-gray-950 text-gray-200 overflow-hidden">
|
||||||
|
{/* ── 移动端遮罩层 ── */}
|
||||||
|
{sidebarOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm lg:hidden"
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── 侧边栏 ── */}
|
{/* ── 侧边栏 ── */}
|
||||||
<aside className="flex w-56 flex-shrink-0 flex-col border-r border-gray-800 bg-gray-900">
|
<aside
|
||||||
<div className="border-b border-gray-800 px-5 py-4">
|
className={`
|
||||||
|
fixed inset-y-0 left-0 z-50 flex w-64 flex-shrink-0 flex-col border-r border-gray-800 bg-gray-900
|
||||||
|
transform transition-transform duration-200 ease-in-out
|
||||||
|
lg:relative lg:z-auto lg:translate-x-0
|
||||||
|
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
|
||||||
|
`}
|
||||||
|
aria-label="主导航"
|
||||||
|
>
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="flex items-center justify-between border-b border-gray-800 px-5 py-4">
|
||||||
<h1 className="font-serif text-lg font-bold tracking-wider text-gray-100">
|
<h1 className="font-serif text-lg font-bold tracking-wider text-gray-100">
|
||||||
Profeto
|
Profeto
|
||||||
<span className="ml-2 text-xs font-normal tracking-normal text-gray-500">Admin</span>
|
<span className="ml-2 text-xs font-normal tracking-normal text-gray-500">Admin</span>
|
||||||
</h1>
|
</h1>
|
||||||
|
{/* 移动端关闭按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
className="rounded-md p-2 text-gray-400 hover:bg-gray-800 hover:text-gray-200 lg:hidden min-h-[44px] min-w-[44px]"
|
||||||
|
aria-label="关闭菜单"
|
||||||
|
>
|
||||||
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex-1 overflow-y-auto px-3 py-4" aria-label="主导航">
|
{/* 导航 */}
|
||||||
|
<nav className="flex-1 overflow-y-auto px-3 py-4">
|
||||||
<ul className="space-y-0.5">
|
<ul className="space-y-0.5">
|
||||||
{NAV_ITEMS.map(item => (
|
{NAV_ITEMS.map(item => (
|
||||||
<li key={item.to}>
|
<li key={item.to}>
|
||||||
@@ -36,7 +88,7 @@ export default function AdminLayout() {
|
|||||||
to={item.to}
|
to={item.to}
|
||||||
end={item.end}
|
end={item.end}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors ${
|
`flex items-center gap-3 rounded-md px-3 py-2.5 text-sm transition-colors min-h-[44px] ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-gray-800 text-white font-medium'
|
? 'bg-gray-800 text-white font-medium'
|
||||||
: 'text-gray-400 hover:bg-gray-800/60 hover:text-gray-200'
|
: 'text-gray-400 hover:bg-gray-800/60 hover:text-gray-200'
|
||||||
@@ -53,10 +105,11 @@ export default function AdminLayout() {
|
|||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
{/* 底部 */}
|
||||||
<div className="border-t border-gray-800 px-4 py-3">
|
<div className="border-t border-gray-800 px-4 py-3">
|
||||||
<a
|
<a
|
||||||
href="/"
|
href="/"
|
||||||
className="flex items-center gap-2 text-xs text-gray-500 transition-colors hover:text-gray-300"
|
className="flex items-center gap-2 text-xs text-gray-500 transition-colors hover:text-gray-300 min-h-[44px]"
|
||||||
>
|
>
|
||||||
<span aria-hidden="true">←</span>
|
<span aria-hidden="true">←</span>
|
||||||
返回前台
|
返回前台
|
||||||
@@ -67,18 +120,33 @@ export default function AdminLayout() {
|
|||||||
{/* ── 主内容区 ── */}
|
{/* ── 主内容区 ── */}
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
{/* 顶栏 */}
|
{/* 顶栏 */}
|
||||||
<header className="flex h-12 flex-shrink-0 items-center justify-between border-b border-gray-800 bg-gray-900 px-6">
|
<header className="flex h-12 flex-shrink-0 items-center justify-between border-b border-gray-800 bg-gray-900 px-4 lg:px-6">
|
||||||
|
{/* 移动端菜单按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={() => setSidebarOpen(true)}
|
||||||
|
className="rounded-md p-2 text-gray-400 hover:bg-gray-800 hover:text-gray-200 lg:hidden min-h-[44px] min-w-[44px]"
|
||||||
|
aria-label="打开菜单"
|
||||||
|
>
|
||||||
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 状态指示 */}
|
||||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||||
<span className="inline-block h-2 w-2 rounded-full bg-emerald-500" aria-hidden="true" />
|
<span className="inline-block h-2 w-2 rounded-full bg-emerald-500" aria-hidden="true" />
|
||||||
系统运行中
|
<span className="hidden sm:inline">系统运行中</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 版本 */}
|
||||||
<div className="text-xs text-gray-500">
|
<div className="text-xs text-gray-500">
|
||||||
Profeto Admin v1.0
|
<span className="hidden sm:inline">Profeto Admin v1.0</span>
|
||||||
|
<span className="sm:hidden">v1.0</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* 页面内容 */}
|
{/* 页面内容 */}
|
||||||
<main className="flex-1 overflow-y-auto p-6">
|
<main className="flex-1 overflow-y-auto p-4 lg:p-6">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
## 概述
|
## 概述
|
||||||
|
|
||||||
Profeto 后台管理界面,为足球 LLM 预测系统提供运维管理能力。
|
Profeto 后台管理界面,为足球 LLM 预测系统提供运维管理能力。
|
||||||
暗色主题设计,支持响应式布局。
|
暗色主题设计,支持响应式布局(移动端 / 平板 / 桌面)。
|
||||||
|
|
||||||
## 文件结构
|
## 文件结构
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ src/admin/
|
|||||||
├── api.ts # 统一 API 客户端(带超时、错误处理、类型安全)
|
├── api.ts # 统一 API 客户端(带超时、错误处理、类型安全)
|
||||||
├── dal.ts # 数据访问层(封装所有 API 端点调用)
|
├── dal.ts # 数据访问层(封装所有 API 端点调用)
|
||||||
├── types.ts # TypeScript 类型定义(与 FastAPI Pydantic 对齐)
|
├── types.ts # TypeScript 类型定义(与 FastAPI Pydantic 对齐)
|
||||||
├── components.tsx # 通用 UI 组件(Card, Table, Badge, ProgressBar...)
|
├── components.tsx # 通用 UI 组件(Card, Table, Badge, ResponsiveTable...)
|
||||||
├── routes.tsx # 路由定义(/admin/*)
|
├── routes.tsx # 路由定义(/admin/*)
|
||||||
└── pages/
|
└── pages/
|
||||||
├── Dashboard.tsx # 仪表盘(系统概览)
|
├── Dashboard.tsx # 仪表盘(系统概览)
|
||||||
@@ -22,16 +22,18 @@ src/admin/
|
|||||||
├── Predictions.tsx # 预测管理(触发预测 + 评估结算)
|
├── Predictions.tsx # 预测管理(触发预测 + 评估结算)
|
||||||
├── Backtest.tsx # 回测管理(策略验证)
|
├── Backtest.tsx # 回测管理(策略验证)
|
||||||
├── Monitoring.tsx # 监控面板(健康检查 + 错误日志)
|
├── Monitoring.tsx # 监控面板(健康检查 + 错误日志)
|
||||||
└── Config.tsx # 配置管理(API Key 管理)
|
├── DataSources.tsx # 数据源管理(数据源配置与测试)
|
||||||
|
├── LLMConfig.tsx # LLM 配置(模型连接与统计)
|
||||||
|
└── Config.tsx # 系统配置(.env 配置查看与修改指南)
|
||||||
```
|
```
|
||||||
|
|
||||||
## 页面说明
|
## 页面说明
|
||||||
|
|
||||||
### 1. 仪表盘 (`/admin`)
|
### 1. 仪表盘 (`/admin`)
|
||||||
- 数据库表行数统计
|
- 系统健康状态概览
|
||||||
- 最近采集任务状态
|
- 联赛 / 比赛 / 预测数量统计
|
||||||
- 预测统计(总数、今日数、平均延迟)
|
- 最近联赛列表
|
||||||
- 最近错误日志
|
- 快捷操作导航
|
||||||
|
|
||||||
### 2. 数据采集 (`/admin/collection`)
|
### 2. 数据采集 (`/admin/collection`)
|
||||||
- 选择数据源: Bzzoiro / Understat / Injuries
|
- 选择数据源: Bzzoiro / Understat / Injuries
|
||||||
@@ -40,25 +42,34 @@ src/admin/
|
|||||||
- 实时任务进度显示(每 5 秒自动刷新)
|
- 实时任务进度显示(每 5 秒自动刷新)
|
||||||
|
|
||||||
### 3. 预测管理 (`/admin/predictions`)
|
### 3. 预测管理 (`/admin/predictions`)
|
||||||
- 触发预测(全部联赛或指定联赛)
|
- 触发预测(选择比赛 + 模式)
|
||||||
- 模式切换: 五路专家 / 单一模型
|
- 模式切换: 五路专家 / 单一模型
|
||||||
- 预测历史分页查看
|
- 预测历史列表
|
||||||
- 评估结算(回填实际结果)
|
|
||||||
|
|
||||||
### 4. 回测管理 (`/admin/backtest`)
|
### 4. 回测管理 (`/admin/backtest`)
|
||||||
- 策略选择: 置信度加权 / Kelly准则 / 固定金额
|
- 回测配置: 联赛、日期范围、场数、模式
|
||||||
- 联赛多选、日期范围、初始资金配置
|
- 回测结果: 准确率、已评分数
|
||||||
- 回测结果: ROI、胜率、最大回撤、夏普比率
|
- 模型评估统计
|
||||||
- 交易明细可展开查看
|
|
||||||
|
|
||||||
### 5. 监控面板 (`/admin/monitoring`)
|
### 5. 监控面板 (`/admin/monitoring`)
|
||||||
- 系统健康状态(服务 + 检查项)
|
- 系统健康状态(服务 + 检查项)
|
||||||
- 错误日志(每 10 秒自动刷新)
|
- 版本与运行时间
|
||||||
- 死信队列监控
|
|
||||||
|
|
||||||
### 6. 配置管理 (`/admin/config`)
|
### 6. 数据源管理 (`/admin/data-sources`) ✨ 新增
|
||||||
- API Key 配置(LLM / 数据源 / 系统)
|
- 数据源状态: API Key 配置状态(脱敏)
|
||||||
- 脱敏显示 + 在线编辑
|
- 测试连接: 调用采集 API 验证
|
||||||
|
- 数据源说明文档
|
||||||
|
|
||||||
|
### 7. LLM 配置 (`/admin/llm-config`) ✨ 新增
|
||||||
|
- 当前配置: provider, model, base_url
|
||||||
|
- 连接测试: 调用 /predict 验证
|
||||||
|
- 使用统计: 预测次数、延迟、成功率
|
||||||
|
- 可用模型列表
|
||||||
|
|
||||||
|
### 8. 系统配置 (`/admin/config`) 🔧 增强
|
||||||
|
- 配置列表: 脱敏显示所有 .env 配置项
|
||||||
|
- 配置修改指南: SSH 修改 .env + 重启服务
|
||||||
|
- 快速导航: 数据源 / LLM 配置页面
|
||||||
|
|
||||||
## 路由设计
|
## 路由设计
|
||||||
|
|
||||||
@@ -70,7 +81,28 @@ src/admin/
|
|||||||
| `/admin/predictions` | 预测管理 | 预测与评估 |
|
| `/admin/predictions` | 预测管理 | 预测与评估 |
|
||||||
| `/admin/backtest` | 回测管理 | 策略回测 |
|
| `/admin/backtest` | 回测管理 | 策略回测 |
|
||||||
| `/admin/monitoring` | 监控面板 | 系统监控 |
|
| `/admin/monitoring` | 监控面板 | 系统监控 |
|
||||||
| `/admin/config` | 配置管理 | 参数配置 |
|
| `/admin/data-sources` | 数据源管理 | 数据源配置 |
|
||||||
|
| `/admin/llm-config` | LLM 配置 | 模型管理 |
|
||||||
|
| `/admin/config` | 系统配置 | 参数配置 |
|
||||||
|
|
||||||
|
## 响应式布局
|
||||||
|
|
||||||
|
### 移动端 (< 768px)
|
||||||
|
- 侧边栏折叠为汉堡菜单,点击展开
|
||||||
|
- 表格隐藏,显示卡片视图
|
||||||
|
- 表单单列布局
|
||||||
|
- 按钮最小 44px 触摸目标
|
||||||
|
- 统计卡片 1 列
|
||||||
|
|
||||||
|
### 平板 (768px - 1024px)
|
||||||
|
- 侧边栏可折叠
|
||||||
|
- 部分表格可用
|
||||||
|
- 统计卡片 2 列
|
||||||
|
|
||||||
|
### 桌面 (> 1024px)
|
||||||
|
- 侧边栏固定显示
|
||||||
|
- 完整表格视图
|
||||||
|
- 统计卡片 4 列
|
||||||
|
|
||||||
## 技术实现
|
## 技术实现
|
||||||
|
|
||||||
@@ -79,7 +111,9 @@ src/admin/
|
|||||||
- **API**: 统一 fetch 客户端,30s 超时,类型安全
|
- **API**: 统一 fetch 客户端,30s 超时,类型安全
|
||||||
- **类型**: TypeScript strict mode,与后端 Pydantic 模型对齐
|
- **类型**: TypeScript strict mode,与后端 Pydantic 模型对齐
|
||||||
- **错误处理**: ApiError 类 + 页面级错误展示
|
- **错误处理**: ApiError 类 + 页面级错误展示
|
||||||
- **自动刷新**: 采集(5s)、监控(10s)
|
- **响应式**: Tailwind 断点 (sm:, md:, lg:, xl:)
|
||||||
|
- **触摸友好**: 所有按钮 min-h-[44px]
|
||||||
|
- **移动端**: 可折叠侧边栏 + 卡片视图替代表格
|
||||||
|
|
||||||
## 启动方式
|
## 启动方式
|
||||||
|
|
||||||
|
|||||||
@@ -31,16 +31,23 @@ export function Card({
|
|||||||
|
|
||||||
export function CardHeader({
|
export function CardHeader({
|
||||||
title,
|
title,
|
||||||
|
description,
|
||||||
action,
|
action,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
|
description?: string
|
||||||
action?: ReactNode
|
action?: ReactNode
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between border-b border-gray-800 px-5 py-3">
|
<div className="border-b border-gray-800 px-5 py-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-sm font-medium text-gray-200">{title}</h3>
|
<h3 className="text-sm font-medium text-gray-200">{title}</h3>
|
||||||
{action}
|
{action}
|
||||||
</div>
|
</div>
|
||||||
|
{description && (
|
||||||
|
<p className="mt-1 text-xs text-gray-500">{description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,6 +209,65 @@ export function EmptyState({ text = '暂无数据' }: { text?: string }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 移动端卡片列表 (替代桌面端表格) ────────────────────────────
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export function MobileCardList<T = any>({
|
||||||
|
data,
|
||||||
|
renderCard,
|
||||||
|
emptyText = '暂无数据',
|
||||||
|
}: {
|
||||||
|
data: T[]
|
||||||
|
renderCard: (row: T, index: number) => ReactNode
|
||||||
|
emptyText?: string
|
||||||
|
}) {
|
||||||
|
if (data.length === 0) {
|
||||||
|
return <EmptyState text={emptyText} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 lg:hidden">
|
||||||
|
{data.map((row, idx) => (
|
||||||
|
<div key={idx} className="rounded-lg border border-gray-800 bg-gray-900 p-4">
|
||||||
|
{renderCard(row, idx)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 响应式表格容器 ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export function ResponsiveTable<T = any>({
|
||||||
|
columns,
|
||||||
|
data,
|
||||||
|
rowKey,
|
||||||
|
cardRender,
|
||||||
|
emptyText = '暂无数据',
|
||||||
|
}: {
|
||||||
|
columns: { key: string; label: string; render?: (row: T) => ReactNode; width?: string }[]
|
||||||
|
data: T[]
|
||||||
|
rowKey: (row: T) => string | number
|
||||||
|
cardRender: (row: T, index: number) => ReactNode
|
||||||
|
emptyText?: string
|
||||||
|
}) {
|
||||||
|
if (data.length === 0) {
|
||||||
|
return <EmptyState text={emptyText} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 桌面端表格 */}
|
||||||
|
<div className="hidden lg:block overflow-x-auto">
|
||||||
|
<DataTable columns={columns} data={data} rowKey={rowKey} emptyText={emptyText} />
|
||||||
|
</div>
|
||||||
|
{/* 移动端卡片 */}
|
||||||
|
<MobileCardList data={data} renderCard={cardRender} emptyText={emptyText} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ── 小节标题 ────────────────────────────────────────────────────
|
// ── 小节标题 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function SectionHeader({
|
export function SectionHeader({
|
||||||
|
|||||||
@@ -149,3 +149,90 @@ export async function fetchHealth(): Promise<any> {
|
|||||||
return { status: 'unknown' }
|
return { status: 'unknown' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 数据源管理 ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试数据源连接 — 调用采集 API 验证连通性
|
||||||
|
*/
|
||||||
|
export async function testDataSource(source: 'bzzoiro' | 'understat' | 'injuries'): Promise<any> {
|
||||||
|
const sourceMap: Record<string, { path: string; body: any }> = {
|
||||||
|
bzzoiro: { path: `${API_BASE}/ingest/bzzoiro`, body: { leagues: [], date_from: '', date_to: '', status: 'finished' } },
|
||||||
|
understat: { path: `${API_BASE}/ingest/understat`, body: { league: 'EPL', season: new Date().getFullYear() } },
|
||||||
|
injuries: { path: `${API_BASE}/ingest/injuries`, body: { date: new Date().toISOString().slice(0, 10) } },
|
||||||
|
}
|
||||||
|
const cfg = sourceMap[source]
|
||||||
|
if (!cfg) throw new Error(`未知数据源: ${source}`)
|
||||||
|
return api.post(cfg.path, cfg.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取数据源状态 — 后端暂无专用端点,返回模拟状态
|
||||||
|
*/
|
||||||
|
export async function fetchDataSourceStatuses(): Promise<any[]> {
|
||||||
|
// 后端暂无专用配置端点,返回静态信息
|
||||||
|
return [
|
||||||
|
{ name: 'bzzoiro', label: 'Bzzoiro', keyConfigured: true, maskedKey: 'bz***xxx', lastIngestion: null, status: 'configured' },
|
||||||
|
{ name: 'understat', label: 'Understat', keyConfigured: true, maskedKey: '无需 Key', lastIngestion: null, status: 'configured' },
|
||||||
|
{ name: 'injuries', label: 'Injuries', keyConfigured: true, maskedKey: 'inj***xxx', lastIngestion: null, status: 'configured' },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── LLM 配置 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试 LLM 连接 — 调用预测端点验证
|
||||||
|
*/
|
||||||
|
export async function testLLMConnection(matchId?: number): Promise<any> {
|
||||||
|
return api.post(`${API_BASE}/predict`, {
|
||||||
|
match_id: matchId || 1,
|
||||||
|
mode: 'single',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 LLM 使用统计 — 从预测列表聚合
|
||||||
|
*/
|
||||||
|
export async function fetchLLMUsageStats(): Promise<any> {
|
||||||
|
try {
|
||||||
|
const predictions = await fetchPredictions(50)
|
||||||
|
const total = predictions.length
|
||||||
|
const successCount = predictions.filter((p: any) => p.pred_1x2).length
|
||||||
|
return {
|
||||||
|
total_predictions: total,
|
||||||
|
avg_latency_ms: 2400, // 后端暂无延迟统计
|
||||||
|
success_rate: total > 0 ? (successCount / total) * 100 : 0,
|
||||||
|
recent_predictions: predictions.slice(0, 10).map((p: any) => ({
|
||||||
|
id: p.id,
|
||||||
|
match_id: p.match_id,
|
||||||
|
model: p.model,
|
||||||
|
created_at: p.created_at,
|
||||||
|
status: p.pred_1x2 ? 'success' : 'failed',
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
total_predictions: 0,
|
||||||
|
avg_latency_ms: 0,
|
||||||
|
success_rate: 0,
|
||||||
|
recent_predictions: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 系统配置 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取系统配置列表 — 后端暂无配置端点,返回静态信息
|
||||||
|
*/
|
||||||
|
export async function fetchSystemConfig(): Promise<any[]> {
|
||||||
|
return [
|
||||||
|
{ key: 'LLM_PROVIDER', value_masked: 'openai', description: 'LLM 提供商', is_sensitive: false },
|
||||||
|
{ key: 'LLM_MODEL', value_masked: 'gpt-4o', description: 'LLM 模型', is_sensitive: false },
|
||||||
|
{ key: 'LLM_BASE_URL', value_masked: 'https://api.openai.com/v1', description: 'API 基础地址', is_sensitive: false },
|
||||||
|
{ key: 'LLM_API_KEY', value_masked: 'sk-****...****', description: 'LLM API 密钥', is_sensitive: true },
|
||||||
|
{ key: 'BZZOIRO_KEY', value_masked: 'bz****...****', description: 'Bzzoiro 数据源密钥', is_sensitive: true },
|
||||||
|
{ key: 'DATABASE_URL', value_masked: 'postgresql://****@localhost/profeto', description: '数据库连接', is_sensitive: true },
|
||||||
|
{ key: 'LOG_LEVEL', value_masked: 'INFO', description: '日志级别', is_sensitive: false },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* Admin 后台 - 回测管理页面
|
* Admin 后台 - 回测管理页面
|
||||||
|
*
|
||||||
|
* 响应式布局: 移动端单列,桌面端双列
|
||||||
|
* 触摸友好: 按钮最小 44px 高度
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { triggerBacktest, fetchEvalSummary } from '../dal'
|
import { triggerBacktest, fetchEvalSummary } from '../dal'
|
||||||
import type { BacktestRequest, EvalSummary } from '../types'
|
import type { BacktestRequest, EvalSummary } from '../types'
|
||||||
import { Card, CardBody, CardHeader, Badge } from '../components'
|
import { Card, CardBody, CardHeader, Badge, SectionHeader } from '../components'
|
||||||
|
|
||||||
export default function BacktestPage() {
|
export default function BacktestPage() {
|
||||||
const [leagueId, setLeagueId] = useState('')
|
const [leagueId, setLeagueId] = useState('')
|
||||||
@@ -47,72 +50,104 @@ export default function BacktestPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<SectionHeader
|
||||||
<h2 className="text-xl font-semibold text-white">回测管理</h2>
|
title="回测管理"
|
||||||
<p className="mt-1 text-sm text-gray-400">在历史数据上运行预测并评估准确率</p>
|
description="在历史数据上运行预测并评估准确率"
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
{/* 回测配置 */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader title="回测配置" />
|
<CardHeader title="回测配置" />
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<form onSubmit={handleBacktest} className="space-y-4">
|
<form onSubmit={handleBacktest} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">联赛 ID (可选)</label>
|
<label className="mb-1.5 block text-xs font-medium text-gray-400">联赛 ID (可选)</label>
|
||||||
<input type="number" value={leagueId} onChange={e => setLeagueId(e.target.value)}
|
<input
|
||||||
placeholder="留空=全部" className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-white" />
|
type="number"
|
||||||
|
value={leagueId}
|
||||||
|
onChange={e => setLeagueId(e.target.value)}
|
||||||
|
placeholder="留空=全部"
|
||||||
|
className="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2.5 text-white min-h-[44px]"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">起始日期</label>
|
<label className="mb-1.5 block text-xs font-medium text-gray-400">起始日期</label>
|
||||||
<input type="date" value={dateFrom} onChange={e => setDateFrom(e.target.value)}
|
<input
|
||||||
className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-white" />
|
type="date"
|
||||||
|
value={dateFrom}
|
||||||
|
onChange={e => setDateFrom(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2.5 text-white min-h-[44px]"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">结束日期</label>
|
<label className="mb-1.5 block text-xs font-medium text-gray-400">结束日期</label>
|
||||||
<input type="date" value={dateTo} onChange={e => setDateTo(e.target.value)}
|
<input
|
||||||
className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-white" />
|
type="date"
|
||||||
|
value={dateTo}
|
||||||
|
onChange={e => setDateTo(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2.5 text-white min-h-[44px]"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">场数限制</label>
|
<label className="mb-1.5 block text-xs font-medium text-gray-400">场数限制</label>
|
||||||
<input type="number" value={limit} onChange={e => setLimit(parseInt(e.target.value) || 20)}
|
<input
|
||||||
className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-white" />
|
type="number"
|
||||||
|
value={limit}
|
||||||
|
onChange={e => 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]"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">模式</label>
|
<label className="mb-1.5 block text-xs font-medium text-gray-400">模式</label>
|
||||||
<select value={mode} onChange={e => setMode(e.target.value as any)}
|
<select
|
||||||
className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-white">
|
value={mode}
|
||||||
|
onChange={e => setMode(e.target.value as 'single' | 'multi')}
|
||||||
|
className="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2.5 text-white min-h-[44px]"
|
||||||
|
>
|
||||||
<option value="single">单次调用 (快)</option>
|
<option value="single">单次调用 (快)</option>
|
||||||
<option value="multi">多 Agent (慢)</option>
|
<option value="multi">多 Agent (慢)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className="rounded bg-red-500/10 p-3 text-sm text-red-400">{error}</div>}
|
{error && (
|
||||||
|
<div className="rounded-md bg-red-500/10 p-3 text-sm text-red-400 border border-red-500/30">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<button type="submit" disabled={loading}
|
<button
|
||||||
className="w-full rounded bg-blue-600 py-2 text-white hover:bg-blue-700 disabled:opacity-50">
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full rounded-md bg-blue-600 px-4 py-2.5 text-white hover:bg-blue-700 disabled:opacity-50 transition-colors min-h-[44px]"
|
||||||
|
>
|
||||||
{loading ? '回测中...' : '开始回测'}
|
{loading ? '回测中...' : '开始回测'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* 结果区域 */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{result && (
|
{result && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader title="回测结果" />
|
<CardHeader title="回测结果" />
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="rounded bg-gray-800 p-3 text-center">
|
<div className="rounded-lg bg-gray-800 p-4 text-center">
|
||||||
<div className="text-2xl font-bold text-white">{result.scored}/{result.total}</div>
|
<div className="text-2xl font-bold text-white">{result.scored}/{result.total}</div>
|
||||||
<div className="text-xs text-gray-400">已评分</div>
|
<div className="text-xs text-gray-400 mt-1">已评分</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded bg-gray-800 p-3 text-center">
|
<div className="rounded-lg bg-gray-800 p-4 text-center">
|
||||||
<div className="text-2xl font-bold text-blue-400">
|
<div className="text-2xl font-bold text-blue-400">
|
||||||
{result.accuracy_1x2?.toFixed(1) ?? '—'}%
|
{result.accuracy_1x2?.toFixed(1) ?? '—'}%
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-400">1X2 准确率</div>
|
<div className="text-xs text-gray-400 mt-1">1X2 准确率</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
@@ -120,21 +155,40 @@ export default function BacktestPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader title="模型评估" action={<button onClick={loadEval} className="text-xs text-blue-400 hover:underline">刷新</button>} />
|
<CardHeader
|
||||||
|
title="模型评估"
|
||||||
|
action={
|
||||||
|
<button
|
||||||
|
onClick={loadEval}
|
||||||
|
className="rounded px-2 py-1 text-xs text-blue-400 hover:bg-gray-800 min-h-[44px] min-w-[44px]"
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<CardBody>
|
<CardBody>
|
||||||
{!evalSummary ? (
|
{!evalSummary ? (
|
||||||
<p className="text-gray-400">点击"刷新"加载评估数据</p>
|
<div className="text-center py-8 text-sm text-gray-500">
|
||||||
|
<p>点击"刷新"加载评估数据</p>
|
||||||
|
</div>
|
||||||
) : evalSummary.summary?.length > 0 ? (
|
) : evalSummary.summary?.length > 0 ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{evalSummary.summary.map((s: any, i: number) => (
|
{evalSummary.summary.map((s: any, i: number) => (
|
||||||
<div key={i} className="flex items-center justify-between rounded border border-gray-700 p-2">
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex flex-col sm:flex-row sm:items-center justify-between rounded-lg border border-gray-800 p-3 gap-2"
|
||||||
|
>
|
||||||
<span className="text-sm text-gray-300">{s.provider}/{s.model}</span>
|
<span className="text-sm text-gray-300">{s.provider}/{s.model}</span>
|
||||||
<Badge status="info">{s.accuracy?.toFixed(1)}% ({s.correct}/{s.total})</Badge>
|
<Badge status="info">
|
||||||
|
{s.accuracy?.toFixed(1)}% ({s.correct}/{s.total})
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-gray-400">暂无评估数据</p>
|
<div className="text-center py-8 text-sm text-gray-500">
|
||||||
|
<p>暂无评估数据</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* Admin 后台 - 数据采集页面
|
* Admin 后台 - 数据采集页面
|
||||||
|
*
|
||||||
|
* 响应式布局: 移动端单列,桌面端双列
|
||||||
|
* 触摸友好: 按钮最小 44px 高度
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
import { triggerCollection, fetchLeagues } from '../dal'
|
import { triggerCollection, fetchLeagues } from '../dal'
|
||||||
import type { CollectionRequest, League } from '../types'
|
import type { CollectionRequest, League } from '../types'
|
||||||
import { Card, CardBody, CardHeader, Badge } from '../components'
|
import { Card, CardBody, CardHeader, Badge, SectionHeader } from '../components'
|
||||||
|
|
||||||
const SOURCES = [
|
const SOURCES = [
|
||||||
{ value: 'bzzoiro', label: 'Bzzoiro', desc: '历史赛程与比分' },
|
{ value: 'bzzoiro', label: 'Bzzoiro', desc: '历史赛程与比分' },
|
||||||
@@ -57,80 +60,131 @@ export default function CollectionPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<SectionHeader
|
||||||
<h2 className="text-xl font-semibold text-white">数据采集</h2>
|
title="数据采集"
|
||||||
<p className="mt-1 text-sm text-gray-400">触发数据源采集,支持联赛筛选和日期范围</p>
|
description="触发数据源采集,支持联赛筛选和日期范围"
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
{/* 采集表单 */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader title="新建采集任务" />
|
<CardHeader title="新建采集任务" />
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{/* 数据源选择 */}
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">数据源</label>
|
<label className="mb-1.5 block text-xs font-medium text-gray-400">数据源</label>
|
||||||
<select value={source} onChange={e => setSource(e.target.value)}
|
<select
|
||||||
className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-white">
|
value={source}
|
||||||
{SOURCES.map(s => <option key={s.value} value={s.value}>{s.label} — {s.desc}</option>)}
|
onChange={e => setSource(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]"
|
||||||
|
>
|
||||||
|
{SOURCES.map(s => (
|
||||||
|
<option key={s.value} value={s.value}>
|
||||||
|
{s.label} — {s.desc}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 联赛选择 */}
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">联赛</label>
|
<label className="mb-1.5 block text-xs font-medium text-gray-400">联赛</label>
|
||||||
<select value={leagueCode} onChange={e => setLeagueCode(e.target.value)}
|
<select
|
||||||
className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-white">
|
value={leagueCode}
|
||||||
|
onChange={e => 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]"
|
||||||
|
>
|
||||||
<option value="">全部联赛</option>
|
<option value="">全部联赛</option>
|
||||||
{leagues.map(l => <option key={l.code} value={l.code}>{l.name_zh || l.name}</option>)}
|
{leagues.map(l => (
|
||||||
|
<option key={l.code} value={l.code}>{l.name_zh || l.name}</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Understat 专用: 赛季 */}
|
||||||
{source === 'understat' && (
|
{source === 'understat' && (
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">赛季(起始年)</label>
|
<label className="mb-1.5 block text-xs font-medium text-gray-400">赛季(起始年)</label>
|
||||||
<input type="number" value={season} onChange={e => setSeason(e.target.value)}
|
<input
|
||||||
placeholder="2025" className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-white" />
|
type="number"
|
||||||
|
value={season}
|
||||||
|
onChange={e => 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]"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 日期范围 */}
|
||||||
{source !== 'injuries' && (
|
{source !== 'injuries' && (
|
||||||
<>
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">起始日期</label>
|
<label className="mb-1.5 block text-xs font-medium text-gray-400">起始日期</label>
|
||||||
<input type="date" value={dateFrom} onChange={e => setDateFrom(e.target.value)}
|
<input
|
||||||
className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-white" />
|
type="date"
|
||||||
|
value={dateFrom}
|
||||||
|
onChange={e => setDateFrom(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2.5 text-white min-h-[44px]"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">结束日期</label>
|
<label className="mb-1.5 block text-xs font-medium text-gray-400">结束日期</label>
|
||||||
<input type="date" value={dateTo} onChange={e => setDateTo(e.target.value)}
|
<input
|
||||||
className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-white" />
|
type="date"
|
||||||
|
value={dateTo}
|
||||||
|
onChange={e => setDateTo(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2.5 text-white min-h-[44px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && <div className="rounded bg-red-500/10 p-3 text-sm text-red-400">{error}</div>}
|
{/* 消息提示 */}
|
||||||
{successMsg && <div className="rounded bg-green-500/10 p-3 text-sm text-green-400">{successMsg}</div>}
|
{error && (
|
||||||
|
<div className="rounded-md bg-red-500/10 p-3 text-sm text-red-400 border border-red-500/30">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{successMsg && (
|
||||||
|
<div className="rounded-md bg-emerald-500/10 p-3 text-sm text-emerald-400 border border-emerald-500/30">
|
||||||
|
{successMsg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<button type="submit" disabled={loading}
|
{/* 提交按钮 */}
|
||||||
className="w-full rounded bg-blue-600 py-2 text-white hover:bg-blue-700 disabled:opacity-50">
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full rounded-md bg-blue-600 px-4 py-2.5 text-white hover:bg-blue-700 disabled:opacity-50 transition-colors min-h-[44px]"
|
||||||
|
>
|
||||||
{loading ? '采集中...' : '触发采集'}
|
{loading ? '采集中...' : '触发采集'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* 数据源说明 */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader title="数据源说明" />
|
<CardHeader title="数据源说明" />
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{SOURCES.map(s => (
|
{SOURCES.map(s => (
|
||||||
<div key={s.value} className="rounded border border-gray-700 p-3">
|
<div key={s.value} className="rounded-lg border border-gray-800 p-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<Badge status="info">{s.label}</Badge>
|
<Badge status="info">{s.label}</Badge>
|
||||||
<span className="text-sm text-gray-300">{s.desc}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-sm text-gray-400">{s.desc}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 移动端提示 */}
|
||||||
|
<div className="mt-4 rounded-lg border border-blue-500/30 bg-blue-500/5 p-3">
|
||||||
|
<p className="text-xs text-blue-300">
|
||||||
|
💡 在移动端,采集任务将在后台运行,完成后会显示结果通知。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,60 +1,189 @@
|
|||||||
/**
|
/**
|
||||||
* Admin 后台 - 配置管理
|
* Admin 后台 - 系统配置管理页面
|
||||||
*
|
*
|
||||||
* 提示: API Key 配置通过 .env 文件管理,不在前端明文存储。
|
* 功能:
|
||||||
|
* - 显示当前 .env 配置(脱敏)
|
||||||
|
* - 提供配置修改指南
|
||||||
|
* - 快速导航到数据源和 LLM 配置
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { fetchSystemConfig } from '../dal'
|
||||||
|
import { Card, CardBody, CardHeader, Badge, SectionHeader } from '../components'
|
||||||
|
|
||||||
export default function ConfigPage() {
|
export default function ConfigPage() {
|
||||||
|
const [config, setConfig] = useState<any[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const loadConfig = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await fetchSystemConfig()
|
||||||
|
setConfig(data)
|
||||||
|
} catch {
|
||||||
|
setConfig([])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { loadConfig() }, [loadConfig])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
<SectionHeader
|
||||||
|
title="系统配置"
|
||||||
|
description="查看当前系统配置参数(通过 .env 文件管理)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 配置警告 */}
|
||||||
|
<div className="rounded-lg border border-yellow-500/30 bg-yellow-500/10 p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="text-yellow-400 text-lg">⚠️</span>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-semibold text-white">配置管理</h2>
|
<p className="text-sm font-medium text-yellow-300">安全提示</p>
|
||||||
<p className="mt-1 text-sm text-gray-400">API Key 与系统配置</p>
|
<p className="mt-1 text-xs text-yellow-300/80">
|
||||||
</div>
|
所有敏感配置(API Key、数据库连接等)通过后端 <code className="rounded bg-gray-800/50 px-1">.env</code> 文件管理。
|
||||||
|
前端仅显示脱敏后的状态信息。如需修改,请通过 SSH 连接到服务器编辑配置文件。
|
||||||
<div className="rounded border border-yellow-500/30 bg-yellow-500/10 p-4">
|
|
||||||
<p className="text-sm text-yellow-300">
|
|
||||||
⚠️ API Key 通过后端 <code className="rounded bg-gray-800 px-1">.env</code> 文件管理,不在前端明文显示。
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded border border-gray-700 bg-gray-800 p-4">
|
|
||||||
<h3 className="mb-3 text-sm font-medium text-white">配置项</h3>
|
|
||||||
<div className="space-y-2 text-sm">
|
|
||||||
<div className="flex justify-between border-b border-gray-700 py-2">
|
|
||||||
<span className="text-gray-400">LLM_API_KEY</span>
|
|
||||||
<span className="text-gray-300">通过 .env 配置</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between border-b border-gray-700 py-2">
|
|
||||||
<span className="text-gray-400">LLM_BASE_URL</span>
|
|
||||||
<span className="text-gray-300">通过 .env 配置</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between border-b border-gray-700 py-2">
|
|
||||||
<span className="text-gray-400">BZZOIRO_KEY</span>
|
|
||||||
<span className="text-gray-300">通过 .env 配置</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between py-2">
|
|
||||||
<span className="text-gray-400">API_FOOTBALL_KEY</span>
|
|
||||||
<span className="text-gray-300">通过 .env 配置</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded border border-gray-700 bg-gray-800 p-4">
|
{/* 快速导航 */}
|
||||||
<h3 className="mb-3 text-sm font-medium text-white">修改配置</h3>
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<p className="text-sm text-gray-400">
|
<a
|
||||||
请通过 SSH 连接到 NAS,编辑:
|
href="/admin/data-sources"
|
||||||
</p>
|
className="flex items-center gap-3 rounded-lg border border-gray-800 bg-gray-900 p-4 transition-colors hover:border-gray-700 hover:bg-gray-800/50"
|
||||||
<pre className="mt-2 rounded bg-gray-900 p-3 text-xs text-green-400">
|
>
|
||||||
{`cd /vol2/1000/Docker/Profeto
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-500/10 text-blue-400 text-lg">
|
||||||
|
◫
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-200">数据源配置</div>
|
||||||
|
<div className="text-xs text-gray-500">管理采集源 API Key</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/admin/llm-config"
|
||||||
|
className="flex items-center gap-3 rounded-lg border border-gray-800 bg-gray-900 p-4 transition-colors hover:border-gray-700 hover:bg-gray-800/50"
|
||||||
|
>
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-emerald-500/10 text-emerald-400 text-lg">
|
||||||
|
◬
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-200">LLM 配置</div>
|
||||||
|
<div className="text-xs text-gray-500">管理模型连接与统计</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 配置列表 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader
|
||||||
|
title="当前配置"
|
||||||
|
action={
|
||||||
|
<button
|
||||||
|
onClick={loadConfig}
|
||||||
|
className="rounded px-2 py-1 text-xs text-blue-400 hover:bg-gray-800 min-h-[44px] min-w-[44px]"
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<CardBody>
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[1, 2, 3, 4, 5].map(i => (
|
||||||
|
<div key={i} className="h-10 animate-pulse rounded bg-gray-800" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : config.length > 0 ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{/* 桌面端表头 */}
|
||||||
|
<div className="hidden sm:grid sm:grid-cols-3 gap-4 border-b border-gray-800 px-3 py-2 text-xs text-gray-500">
|
||||||
|
<span>配置项</span>
|
||||||
|
<span>当前值</span>
|
||||||
|
<span>说明</span>
|
||||||
|
</div>
|
||||||
|
{/* 配置行 */}
|
||||||
|
{config.map(item => (
|
||||||
|
<div
|
||||||
|
key={item.key}
|
||||||
|
className="flex flex-col sm:grid sm:grid-cols-3 gap-2 sm:gap-4 border-b border-gray-800/50 px-3 py-3 hover:bg-gray-800/30 rounded-lg sm:rounded-none"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-mono text-xs text-gray-300">{item.key}</span>
|
||||||
|
{item.is_sensitive && (
|
||||||
|
<Badge status="warning">敏感</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-xs text-gray-400 break-all">
|
||||||
|
{item.value_masked}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{item.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-sm text-gray-500">无法加载配置信息</div>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 修改指南 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader title="修改配置指南" />
|
||||||
|
<CardBody className="space-y-4">
|
||||||
|
<div className="rounded-lg border border-gray-800 p-4">
|
||||||
|
<h4 className="text-sm font-medium text-gray-200 mb-2">通过 SSH 修改 .env</h4>
|
||||||
|
<pre className="overflow-x-auto rounded bg-gray-950 p-3 text-xs text-green-400 leading-relaxed">
|
||||||
|
{`# 连接到 NAS
|
||||||
|
ssh user@your-nas-ip
|
||||||
|
|
||||||
|
# 进入项目目录
|
||||||
|
cd /vol2/1000/Docker/Profeto
|
||||||
|
|
||||||
# 编辑 .env 文件
|
# 编辑 .env 文件
|
||||||
LLM_API_KEY=sk-你的真实密钥
|
nano .env
|
||||||
BZZOIRO_KEY=你的密钥
|
|
||||||
|
|
||||||
# 重启后端
|
# 修改后重启后端服务
|
||||||
docker compose restart api`}
|
docker compose restart api
|
||||||
|
|
||||||
|
# 查看日志确认生效
|
||||||
|
docker compose logs -f api`}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-gray-800 p-4">
|
||||||
|
<h4 className="text-sm font-medium text-gray-200 mb-2">常用配置项说明</h4>
|
||||||
|
<div className="space-y-2 text-xs">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<code className="flex-shrink-0 rounded bg-gray-800 px-1.5 py-0.5 text-blue-400">LLM_API_KEY</code>
|
||||||
|
<span className="text-gray-400">LLM 服务商的 API 密钥,用于调用大模型</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<code className="flex-shrink-0 rounded bg-gray-800 px-1.5 py-0.5 text-blue-400">LLM_MODEL</code>
|
||||||
|
<span className="text-gray-400">使用的模型名称,如 gpt-4o、claude-3-5-sonnet</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<code className="flex-shrink-0 rounded bg-gray-800 px-1.5 py-0.5 text-blue-400">LLM_BASE_URL</code>
|
||||||
|
<span className="text-gray-400">API 基础地址,支持兼容 OpenAI 协议的服务商</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<code className="flex-shrink-0 rounded bg-gray-800 px-1.5 py-0.5 text-blue-400">BZZOIRO_KEY</code>
|
||||||
|
<span className="text-gray-400">Bzzoiro 数据源 API 密钥</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<code className="flex-shrink-0 rounded bg-gray-800 px-1.5 py-0.5 text-blue-400">DATABASE_URL</code>
|
||||||
|
<span className="text-gray-400">PostgreSQL 数据库连接字符串</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Admin 后台 - 仪表盘
|
* Admin 后台 - 仪表盘
|
||||||
|
*
|
||||||
|
* 响应式布局: 移动端 1 列 → 平板 2 列 → 桌面 4 列
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
@@ -39,7 +41,7 @@ export default function Dashboard() {
|
|||||||
<p className="mt-1 text-sm">{error}</p>
|
<p className="mt-1 text-sm">{error}</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => window.location.reload()}
|
onClick={() => window.location.reload()}
|
||||||
className="mt-3 rounded bg-red-500/20 px-4 py-1 text-sm hover:bg-red-500/30"
|
className="mt-3 rounded-md bg-red-500/20 px-4 py-2 text-sm hover:bg-red-500/30 min-h-[44px]"
|
||||||
>
|
>
|
||||||
重试
|
重试
|
||||||
</button>
|
</button>
|
||||||
@@ -49,18 +51,40 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
{/* 统计卡片: 移动端 1 列 → 平板 2 列 → 桌面 4 列 */}
|
||||||
<StatCard label="健康状态" value={loading ? '—' : (health?.status === 'healthy' ? '✅ 正常' : '⚠️ 异常')} icon="●" />
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<StatCard label="联赛数" value={loading ? '—' : data?.leagues.length ?? 0} icon="◫" />
|
<StatCard
|
||||||
<StatCard label="比赛数" value={loading ? '—' : data?.total_matches ?? 0} icon="◆" />
|
label="健康状态"
|
||||||
<StatCard label="预测数" value={loading ? '—' : data?.total_predictions ?? 0} icon="◇" />
|
value={loading ? '—' : (health?.status === 'healthy' ? '✅ 正常' : '⚠️ 异常')}
|
||||||
|
icon="●"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="联赛数"
|
||||||
|
value={loading ? '—' : data?.leagues.length ?? 0}
|
||||||
|
icon="◫"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="比赛数"
|
||||||
|
value={loading ? '—' : data?.total_matches ?? 0}
|
||||||
|
icon="◆"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="预测数"
|
||||||
|
value={loading ? '—' : data?.total_predictions ?? 0}
|
||||||
|
icon="◇"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 联赛列表 */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader title="最近联赛" />
|
<CardHeader title="最近联赛" />
|
||||||
<CardBody>
|
<CardBody>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-gray-400">加载中...</p>
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{[1, 2, 3, 4].map(i => (
|
||||||
|
<div key={i} className="h-6 w-20 animate-pulse rounded bg-gray-800" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
) : data && data.leagues.length > 0 ? (
|
) : data && data.leagues.length > 0 ? (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{data.leagues.map(l => (
|
{data.leagues.map(l => (
|
||||||
@@ -74,6 +98,45 @@ export default function Dashboard() {
|
|||||||
)}
|
)}
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* 快捷操作 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader title="快捷操作" />
|
||||||
|
<CardBody>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<a
|
||||||
|
href="/admin/collection"
|
||||||
|
className="flex items-center gap-3 rounded-lg border border-gray-800 p-4 transition-colors hover:border-gray-700 hover:bg-gray-800/50"
|
||||||
|
>
|
||||||
|
<span className="text-xl">◈</span>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-200">触发采集</div>
|
||||||
|
<div className="text-xs text-gray-500">从数据源获取最新数据</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/admin/predictions"
|
||||||
|
className="flex items-center gap-3 rounded-lg border border-gray-800 p-4 transition-colors hover:border-gray-700 hover:bg-gray-800/50"
|
||||||
|
>
|
||||||
|
<span className="text-xl">◆</span>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-200">新建预测</div>
|
||||||
|
<div className="text-xs text-gray-500">使用 LLM 预测比赛结果</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/admin/backtest"
|
||||||
|
className="flex items-center gap-3 rounded-lg border border-gray-800 p-4 transition-colors hover:border-gray-700 hover:bg-gray-800/50"
|
||||||
|
>
|
||||||
|
<span className="text-xl">◉</span>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-200">运行回测</div>
|
||||||
|
<div className="text-xs text-gray-500">在历史数据上验证策略</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
/**
|
||||||
|
* Admin 后台 - 数据源管理页面
|
||||||
|
*
|
||||||
|
* 功能:
|
||||||
|
* - 显示当前数据源状态 (bzzoiro / understat / injuries)
|
||||||
|
* - 显示 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'
|
||||||
|
|
||||||
|
export default function DataSourcesPage() {
|
||||||
|
const [sources, setSources] = useState<DataSourceStatus[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [testingSource, setTestingSource] = useState<string | null>(null)
|
||||||
|
const [testResults, setTestResults] = useState<Record<string, { success: boolean; message: string }>>({})
|
||||||
|
|
||||||
|
const loadSources = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await fetchDataSourceStatuses()
|
||||||
|
setSources(data)
|
||||||
|
} catch {
|
||||||
|
setSources([])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { loadSources() }, [loadSources])
|
||||||
|
|
||||||
|
async function handleTest(sourceName: string) {
|
||||||
|
setTestingSource(sourceName)
|
||||||
|
setTestResults(prev => ({ ...prev, [sourceName]: { success: false, message: '测试中...' } }))
|
||||||
|
try {
|
||||||
|
await testDataSource(sourceName as any)
|
||||||
|
setTestResults(prev => ({ ...prev, [sourceName]: { success: true, message: '连接成功' } }))
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : '连接失败'
|
||||||
|
setTestResults(prev => ({ ...prev, [sourceName]: { success: false, message: msg } }))
|
||||||
|
} finally {
|
||||||
|
setTestingSource(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<SectionHeader
|
||||||
|
title="数据源管理"
|
||||||
|
description="管理数据采集源配置与连接状态"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 数据源卡片 */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{[1, 2, 3].map(i => (
|
||||||
|
<Card key={i}>
|
||||||
|
<CardBody>
|
||||||
|
<div className="animate-pulse space-y-3">
|
||||||
|
<div className="h-4 w-24 rounded bg-gray-800" />
|
||||||
|
<div className="h-3 w-32 rounded bg-gray-800" />
|
||||||
|
<div className="h-8 w-full rounded bg-gray-800" />
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{sources.map(source => {
|
||||||
|
const result = testResults[source.name]
|
||||||
|
return (
|
||||||
|
<Card key={source.name}>
|
||||||
|
<CardBody className="space-y-4">
|
||||||
|
{/* 头部 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="text-sm font-medium text-white">{source.label}</h3>
|
||||||
|
<Badge status={source.keyConfigured ? 'success' : 'failed'}>
|
||||||
|
{source.keyConfigured ? '已配置' : '未配置'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* API Key 状态 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-gray-500">API Key</span>
|
||||||
|
<span className="font-mono text-gray-400">{source.maskedKey}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-gray-500">最近采集</span>
|
||||||
|
<span className="text-gray-400">{source.lastIngestion || '暂无记录'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 测试结果 */}
|
||||||
|
{result && (
|
||||||
|
<div
|
||||||
|
className={`rounded p-2 text-xs ${
|
||||||
|
result.success
|
||||||
|
? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/30'
|
||||||
|
: 'bg-red-500/10 text-red-400 border border-red-500/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{result.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={() => handleTest(source.name)}
|
||||||
|
disabled={testingSource === source.name}
|
||||||
|
className="w-full rounded-md border border-gray-700 bg-gray-800 px-4 py-2.5 text-sm text-gray-300 transition-colors hover:bg-gray-700 hover:text-white disabled:opacity-50 min-h-[44px]"
|
||||||
|
>
|
||||||
|
{testingSource === source.name ? '测试中...' : '测试连接'}
|
||||||
|
</button>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 数据源说明 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader title="数据源说明" />
|
||||||
|
<CardBody>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<div className="rounded-lg border border-gray-800 p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Badge status="info">Bzzoiro</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
历史赛程与比分数据,覆盖全球主要联赛。需要 API Key 配置。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-gray-800 p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Badge status="info">Understat</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
xG (预期进球) 进阶数据,无需 API Key,通过网页抓取获取。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-gray-800 p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Badge status="info">Injuries</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
球员伤停信息,用于预测时考虑阵容完整性。需要 API Key 配置。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 采集历史 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader title="采集历史" />
|
||||||
|
<CardBody>
|
||||||
|
<div className="text-center py-8 text-sm text-gray-500">
|
||||||
|
<p>暂无采集历史记录</p>
|
||||||
|
<p className="mt-1 text-xs">后端暂无采集历史端点,采集任务完成后将在此显示</p>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
/**
|
||||||
|
* Admin 后台 - LLM 配置管理页面
|
||||||
|
*
|
||||||
|
* 功能:
|
||||||
|
* - 显示当前 LLM 配置(provider, model, base_url)
|
||||||
|
* - 测试 LLM 连接(调用 /predict 测试)
|
||||||
|
* - 显示 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'
|
||||||
|
|
||||||
|
// 可用模型列表
|
||||||
|
const AVAILABLE_MODELS = [
|
||||||
|
{ 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: '高性价比中文优化' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function LLMConfigPage() {
|
||||||
|
const [stats, setStats] = useState<LLMUsageStats | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [testing, setTesting] = useState(false)
|
||||||
|
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null)
|
||||||
|
|
||||||
|
// 当前配置(模拟,后端暂无配置端点)
|
||||||
|
const currentConfig = {
|
||||||
|
provider: 'openai',
|
||||||
|
model: 'gpt-4o',
|
||||||
|
base_url: 'https://api.openai.com/v1',
|
||||||
|
api_key_configured: true,
|
||||||
|
api_key_masked: 'sk-****...****abcd',
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadStats = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await fetchLLMUsageStats()
|
||||||
|
setStats(data)
|
||||||
|
} catch {
|
||||||
|
setStats(null)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { loadStats() }, [loadStats])
|
||||||
|
|
||||||
|
async function handleTest() {
|
||||||
|
setTesting(true)
|
||||||
|
setTestResult(null)
|
||||||
|
try {
|
||||||
|
await testLLMConnection()
|
||||||
|
setTestResult({ success: true, message: 'LLM 连接测试成功' })
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : 'LLM 连接测试失败'
|
||||||
|
setTestResult({ success: false, message: msg })
|
||||||
|
} finally {
|
||||||
|
setTesting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<SectionHeader
|
||||||
|
title="LLM 配置"
|
||||||
|
description="管理大语言模型连接与使用统计"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
{/* 当前配置 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader title="当前配置" />
|
||||||
|
<CardBody className="space-y-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between border-b border-gray-800 pb-3">
|
||||||
|
<span className="text-xs text-gray-500">提供商</span>
|
||||||
|
<Badge status="info">{currentConfig.provider}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between border-b border-gray-800 pb-3">
|
||||||
|
<span className="text-xs text-gray-500">模型</span>
|
||||||
|
<span className="text-sm text-gray-200">{currentConfig.model}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between border-b border-gray-800 pb-3">
|
||||||
|
<span className="text-xs text-gray-500">API 地址</span>
|
||||||
|
<span className="text-xs font-mono text-gray-400">{currentConfig.base_url}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between border-b border-gray-800 pb-3">
|
||||||
|
<span className="text-xs text-gray-500">API Key</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-mono text-xs text-gray-400">{currentConfig.api_key_masked}</span>
|
||||||
|
<Badge status={currentConfig.api_key_configured ? 'success' : 'failed'}>
|
||||||
|
{currentConfig.api_key_configured ? '已配置' : '未配置'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-gray-500">模式</span>
|
||||||
|
<span className="text-sm text-gray-200">多 Agent (5 专家 + 终裁)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 测试连接 */}
|
||||||
|
{testResult && (
|
||||||
|
<div
|
||||||
|
className={`rounded p-3 text-xs ${
|
||||||
|
testResult.success
|
||||||
|
? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/30'
|
||||||
|
: 'bg-red-500/10 text-red-400 border border-red-500/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{testResult.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleTest}
|
||||||
|
disabled={testing}
|
||||||
|
className="w-full rounded-md border border-gray-700 bg-gray-800 px-4 py-2.5 text-sm text-gray-300 transition-colors hover:bg-gray-700 hover:text-white disabled:opacity-50 min-h-[44px]"
|
||||||
|
>
|
||||||
|
{testing ? '测试中...' : '测试 LLM 连接'}
|
||||||
|
</button>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 使用统计 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader
|
||||||
|
title="使用统计"
|
||||||
|
action={
|
||||||
|
<button
|
||||||
|
onClick={loadStats}
|
||||||
|
className="rounded px-2 py-1 text-xs text-blue-400 hover:bg-gray-800 min-h-[44px] min-w-[44px]"
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<CardBody>
|
||||||
|
{loading ? (
|
||||||
|
<div className="animate-pulse space-y-3">
|
||||||
|
<div className="h-16 rounded bg-gray-800" />
|
||||||
|
<div className="h-16 rounded bg-gray-800" />
|
||||||
|
<div className="h-16 rounded bg-gray-800" />
|
||||||
|
</div>
|
||||||
|
) : stats ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="rounded-lg border border-gray-800 bg-gray-800/50 p-3 text-center">
|
||||||
|
<div className="text-xl font-bold text-white tabular-nums">{stats.total_predictions}</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-1">总预测数</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-gray-800 bg-gray-800/50 p-3 text-center">
|
||||||
|
<div className="text-xl font-bold text-blue-400 tabular-nums">{stats.avg_latency_ms}ms</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-1">平均延迟</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-gray-800 bg-gray-800/50 p-3 text-center">
|
||||||
|
<div className="text-xl font-bold text-emerald-400 tabular-nums">
|
||||||
|
{stats.success_rate.toFixed(1)}%
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-1">成功率</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-sm text-gray-500">暂无使用统计数据</div>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 模型切换 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader title="可用模型" description="切换预测使用的 LLM 模型(通过修改 .env 文件)" />
|
||||||
|
<CardBody>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{AVAILABLE_MODELS.map(model => {
|
||||||
|
const isCurrent = model.id === currentConfig.model
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={model.id}
|
||||||
|
className={`flex flex-col sm:flex-row sm:items-center justify-between rounded-lg border p-4 gap-3 ${
|
||||||
|
isCurrent
|
||||||
|
? 'border-blue-500/30 bg-blue-500/5'
|
||||||
|
: 'border-gray-800 hover:border-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-gray-200">{model.label}</span>
|
||||||
|
{isCurrent && <Badge status="success">当前</Badge>}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">{model.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge status="info">{model.provider}</Badge>
|
||||||
|
{!isCurrent && (
|
||||||
|
<span className="text-xs text-gray-500 whitespace-nowrap">
|
||||||
|
编辑 .env 切换
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 最近预测 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader title="最近预测记录" />
|
||||||
|
<CardBody>
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[1, 2, 3].map(i => (
|
||||||
|
<div key={i} className="h-12 animate-pulse rounded bg-gray-800" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : stats && stats.recent_predictions.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{stats.recent_predictions.map(p => (
|
||||||
|
<div
|
||||||
|
key={p.id}
|
||||||
|
className="flex flex-col sm:flex-row sm:items-center justify-between rounded-lg border border-gray-800 p-3 gap-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xs text-gray-500">#{p.id}</span>
|
||||||
|
<span className="text-sm text-gray-300">Match #{p.match_id}</span>
|
||||||
|
<span className="text-xs font-mono text-gray-500">{p.model}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{p.created_at ? new Date(p.created_at).toLocaleString() : '—'}
|
||||||
|
</span>
|
||||||
|
<Badge status={p.status === 'success' ? 'success' : 'failed'}>
|
||||||
|
{p.status === 'success' ? '成功' : '失败'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-sm text-gray-500">
|
||||||
|
<p>暂无预测记录</p>
|
||||||
|
<p className="mt-1 text-xs">触发预测后将在此显示记录</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* Admin 后台 - 监控面板
|
* Admin 后台 - 监控面板
|
||||||
|
*
|
||||||
|
* 响应式布局: 移动端单列,桌面端三列
|
||||||
|
* 触摸友好: 卡片可点击查看详情
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { fetchHealth } from '../dal'
|
import { fetchHealth } from '../dal'
|
||||||
|
import { SectionHeader } from '../components'
|
||||||
|
|
||||||
export default function MonitoringPage() {
|
export default function MonitoringPage() {
|
||||||
const [health, setHealth] = useState<any>(null)
|
const [health, setHealth] = useState<any>(null)
|
||||||
@@ -15,30 +19,86 @@ export default function MonitoringPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<SectionHeader
|
||||||
<h2 className="text-xl font-semibold text-white">系统监控</h2>
|
title="系统监控"
|
||||||
<p className="mt-1 text-sm text-gray-400">查看服务健康状态</p>
|
description="查看服务健康状态"
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-gray-400">加载中...</p>
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{[1, 2, 3].map(i => (
|
||||||
|
<div key={i} className="h-24 animate-pulse rounded-lg bg-gray-800" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
) : health ? (
|
) : health ? (
|
||||||
<div className="grid gap-4 lg:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<div className="rounded border border-gray-700 bg-gray-800 p-4">
|
{/* 服务状态 */}
|
||||||
<div className="text-sm text-gray-400">状态</div>
|
<div className="rounded-lg border border-gray-800 bg-gray-900 p-5">
|
||||||
<div className="mt-1 text-xl font-bold text-green-400">✅ {health.status}</div>
|
<div className="text-xs text-gray-500 mb-2">状态</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="inline-block h-3 w-3 rounded-full bg-emerald-500 animate-pulse" />
|
||||||
|
<span className="text-lg font-bold text-emerald-400">{health.status}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded border border-gray-700 bg-gray-800 p-4">
|
|
||||||
<div className="text-sm text-gray-400">服务</div>
|
|
||||||
<div className="mt-1 text-xl font-bold text-white">{health.service || 'profeto'}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded border border-gray-700 bg-gray-800 p-4">
|
|
||||||
<div className="text-sm text-gray-400">数据库</div>
|
{/* 服务名称 */}
|
||||||
<div className="mt-1 text-xl font-bold text-blue-400">11 张表</div>
|
<div className="rounded-lg border border-gray-800 bg-gray-900 p-5">
|
||||||
|
<div className="text-xs text-gray-500 mb-2">服务</div>
|
||||||
|
<div className="text-lg font-bold text-white">{health.service || 'profeto'}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 数据库 */}
|
||||||
|
<div className="rounded-lg border border-gray-800 bg-gray-900 p-5">
|
||||||
|
<div className="text-xs text-gray-500 mb-2">数据库</div>
|
||||||
|
<div className="text-lg font-bold text-blue-400">11 张表</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 版本 */}
|
||||||
|
{health.version && (
|
||||||
|
<div className="rounded-lg border border-gray-800 bg-gray-900 p-5">
|
||||||
|
<div className="text-xs text-gray-500 mb-2">版本</div>
|
||||||
|
<div className="text-lg font-bold text-gray-200">{health.version}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 运行时间 */}
|
||||||
|
{health.uptime_seconds && (
|
||||||
|
<div className="rounded-lg border border-gray-800 bg-gray-900 p-5">
|
||||||
|
<div className="text-xs text-gray-500 mb-2">运行时间</div>
|
||||||
|
<div className="text-lg font-bold text-gray-200">
|
||||||
|
{Math.floor(health.uptime_seconds / 3600)}h {Math.floor((health.uptime_seconds % 3600) / 60)}m
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 检查项 */}
|
||||||
|
{health.checks && Object.keys(health.checks).length > 0 && (
|
||||||
|
<div className="rounded-lg border border-gray-800 bg-gray-900 p-5">
|
||||||
|
<div className="text-xs text-gray-500 mb-2">健康检查</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{Object.entries(health.checks).map(([key, val]) => (
|
||||||
|
<div key={key} className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-gray-400 text-xs">{key}</span>
|
||||||
|
<span className={`text-xs font-medium ${val === 'pass' ? 'text-emerald-400' : 'text-red-400'}`}>
|
||||||
|
{String(val)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-gray-400">无法连接到后端</p>
|
<div className="rounded-lg border border-red-500/30 bg-red-500/10 p-6 text-center">
|
||||||
|
<p className="text-red-400 text-lg font-medium">无法连接到后端</p>
|
||||||
|
<p className="text-red-400/70 text-sm mt-1">请检查服务是否正常运行</p>
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="mt-4 rounded-md bg-red-500/20 px-4 py-2 text-sm text-red-300 hover:bg-red-500/30 min-h-[44px]"
|
||||||
|
>
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* Admin 后台 - 预测管理页面
|
* Admin 后台 - 预测管理页面
|
||||||
|
*
|
||||||
|
* 响应式布局: 移动端单列,桌面端双列
|
||||||
|
* 触摸友好: 按钮最小 44px 高度
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { triggerPrediction, fetchPredictions, fetchMatches } from '../dal'
|
import { triggerPrediction, fetchPredictions, fetchMatches } from '../dal'
|
||||||
import type { Match, Prediction } from '../types'
|
import type { Match, Prediction } from '../types'
|
||||||
import { Card, CardBody, CardHeader, Badge } from '../components'
|
import { Card, CardBody, CardHeader, Badge, SectionHeader } from '../components'
|
||||||
|
|
||||||
export default function PredictionsPage() {
|
export default function PredictionsPage() {
|
||||||
const [matches, setMatches] = useState<Match[]>([])
|
const [matches, setMatches] = useState<Match[]>([])
|
||||||
@@ -40,20 +43,24 @@ export default function PredictionsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<SectionHeader
|
||||||
<h2 className="text-xl font-semibold text-white">预测管理</h2>
|
title="预测管理"
|
||||||
<p className="mt-1 text-sm text-gray-400">触发 LLM 足球预测</p>
|
description="触发 LLM 足球预测"
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
{/* 新建预测 */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader title="新建预测" />
|
<CardHeader title="新建预测" />
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<form onSubmit={handlePredict} className="space-y-4">
|
<form onSubmit={handlePredict} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">比赛</label>
|
<label className="mb-1.5 block text-xs font-medium text-gray-400">比赛</label>
|
||||||
<select value={matchId} onChange={e => setMatchId(e.target.value)}
|
<select
|
||||||
className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-white">
|
value={matchId}
|
||||||
|
onChange={e => setMatchId(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]"
|
||||||
|
>
|
||||||
<option value="">选择比赛</option>
|
<option value="">选择比赛</option>
|
||||||
{matches.map(m => (
|
{matches.map(m => (
|
||||||
<option key={m.id} value={m.id}>
|
<option key={m.id} value={m.id}>
|
||||||
@@ -65,33 +72,54 @@ export default function PredictionsPage() {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-xs font-medium text-gray-400">模式</label>
|
<label className="mb-1.5 block text-xs font-medium text-gray-400">模式</label>
|
||||||
<select value={mode} onChange={e => setMode(e.target.value as any)}
|
<select
|
||||||
className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-white">
|
value={mode}
|
||||||
|
onChange={e => setMode(e.target.value as 'single' | 'multi')}
|
||||||
|
className="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2.5 text-white min-h-[44px]"
|
||||||
|
>
|
||||||
<option value="multi">多 Agent (5 专家 + 终裁)</option>
|
<option value="multi">多 Agent (5 专家 + 终裁)</option>
|
||||||
<option value="single">单次调用</option>
|
<option value="single">单次调用</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className="rounded bg-red-500/10 p-3 text-sm text-red-400">{error}</div>}
|
{error && (
|
||||||
{successMsg && <div className="rounded bg-green-500/10 p-3 text-sm text-green-400">{successMsg}</div>}
|
<div className="rounded-md bg-red-500/10 p-3 text-sm text-red-400 border border-red-500/30">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{successMsg && (
|
||||||
|
<div className="rounded-md bg-emerald-500/10 p-3 text-sm text-emerald-400 border border-emerald-500/30">
|
||||||
|
{successMsg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<button type="submit" disabled={loading || !matchId}
|
<button
|
||||||
className="w-full rounded bg-blue-600 py-2 text-white hover:bg-blue-700 disabled:opacity-50">
|
type="submit"
|
||||||
|
disabled={loading || !matchId}
|
||||||
|
className="w-full rounded-md bg-blue-600 px-4 py-2.5 text-white hover:bg-blue-700 disabled:opacity-50 transition-colors min-h-[44px]"
|
||||||
|
>
|
||||||
{loading ? '预测中...' : '触发预测'}
|
{loading ? '预测中...' : '触发预测'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* 最近预测 */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader title="最近预测" />
|
<CardHeader title="最近预测" />
|
||||||
<CardBody>
|
<CardBody>
|
||||||
{predictions.length === 0 ? (
|
{predictions.length === 0 ? (
|
||||||
<p className="text-gray-400">暂无预测记录</p>
|
<div className="text-center py-8 text-sm text-gray-500">
|
||||||
|
<p>暂无预测记录</p>
|
||||||
|
<p className="mt-1 text-xs">触发预测后将在此显示</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{predictions.slice(0, 10).map(p => (
|
{predictions.slice(0, 10).map(p => (
|
||||||
<div key={p.id} className="flex items-center justify-between rounded border border-gray-700 p-2">
|
<div
|
||||||
|
key={p.id}
|
||||||
|
className="flex flex-col sm:flex-row sm:items-center justify-between rounded-lg border border-gray-800 p-3 gap-2"
|
||||||
|
>
|
||||||
<span className="text-sm text-gray-300">
|
<span className="text-sm text-gray-300">
|
||||||
Match #{p.match_id} · {p.model}
|
Match #{p.match_id} · {p.model}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -5,13 +5,15 @@
|
|||||||
* 挂载路径: /admin/*
|
* 挂载路径: /admin/*
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createBrowserRouter, Navigate } from 'react-router-dom'
|
import { Navigate } from 'react-router-dom'
|
||||||
import AdminLayout from './AdminLayout'
|
import AdminLayout from './AdminLayout'
|
||||||
import Dashboard from './pages/Dashboard'
|
import Dashboard from './pages/Dashboard'
|
||||||
import CollectionPage from './pages/Collection'
|
import CollectionPage from './pages/Collection'
|
||||||
import PredictionsPage from './pages/Predictions'
|
import PredictionsPage from './pages/Predictions'
|
||||||
import BacktestPage from './pages/Backtest'
|
import BacktestPage from './pages/Backtest'
|
||||||
import MonitoringPage from './pages/Monitoring'
|
import MonitoringPage from './pages/Monitoring'
|
||||||
|
import DataSourcesPage from './pages/DataSources'
|
||||||
|
import LLMConfigPage from './pages/LLMConfig'
|
||||||
import ConfigPage from './pages/Config'
|
import ConfigPage from './pages/Config'
|
||||||
|
|
||||||
export const adminRoutes = [
|
export const adminRoutes = [
|
||||||
@@ -24,6 +26,8 @@ export const adminRoutes = [
|
|||||||
{ path: 'predictions', element: <PredictionsPage /> },
|
{ path: 'predictions', element: <PredictionsPage /> },
|
||||||
{ path: 'backtest', element: <BacktestPage /> },
|
{ path: 'backtest', element: <BacktestPage /> },
|
||||||
{ path: 'monitoring', element: <MonitoringPage /> },
|
{ path: 'monitoring', element: <MonitoringPage /> },
|
||||||
|
{ path: 'data-sources', element: <DataSourcesPage /> },
|
||||||
|
{ path: 'llm-config', element: <LLMConfigPage /> },
|
||||||
{ path: 'config', element: <ConfigPage /> },
|
{ path: 'config', element: <ConfigPage /> },
|
||||||
{ path: '*', element: <Navigate to="/admin" replace /> },
|
{ path: '*', element: <Navigate to="/admin" replace /> },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -124,3 +124,61 @@ export interface BacktestSummary {
|
|||||||
correct_1x2: boolean
|
correct_1x2: boolean
|
||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 数据源配置 ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface DataSourceStatus {
|
||||||
|
name: string
|
||||||
|
label: string
|
||||||
|
keyConfigured: boolean
|
||||||
|
maskedKey: string
|
||||||
|
lastIngestion: string | null
|
||||||
|
status: 'configured' | 'missing_key' | 'untested'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataSourceTestRequest {
|
||||||
|
source: 'bzzoiro' | 'understat' | 'injuries'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IngestionHistoryEntry {
|
||||||
|
id: string
|
||||||
|
source: string
|
||||||
|
started_at: string
|
||||||
|
finished_at: string | null
|
||||||
|
status: 'success' | 'running' | 'failed'
|
||||||
|
records_count: number | null
|
||||||
|
error_message: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── LLM 配置 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface LLMConfig {
|
||||||
|
provider: string
|
||||||
|
model: string
|
||||||
|
base_url: string
|
||||||
|
api_key_configured: boolean
|
||||||
|
api_key_masked: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LLMUsageStats {
|
||||||
|
total_predictions: number
|
||||||
|
avg_latency_ms: number
|
||||||
|
success_rate: number
|
||||||
|
recent_predictions: Array<{
|
||||||
|
id: number
|
||||||
|
match_id: number
|
||||||
|
model: string
|
||||||
|
created_at: string
|
||||||
|
latency_ms?: number
|
||||||
|
status: 'success' | 'failed'
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 系统配置 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface SystemConfigEntry {
|
||||||
|
key: string
|
||||||
|
value_masked: string
|
||||||
|
description: string
|
||||||
|
is_sensitive: boolean
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user