Author SHA1 Message Date
shangfangjian 0d0ef2aeac Merge pull request 'fix(a11y): 对比度达标 + 中文衬线 webfont + 纸张噪点与入场编排' (#5) from fix-a11y-typography into admin-newspaper
Reviewed-on: #5
2026-09-18 11:06:34 +08:00
shangfangjian 1a37c75907 fix(a11y): ink-400 对比度达标,接入 Noto Serif SC webfont,补纸张噪点与入场编排
- ink-400 #9C9587(2.90:1)→ #756F61(paper-50 4.87:1 / paper-100 4.55:1),正文级小字全部达标
- serif 栈插入 Noto Serif SC(fontsource 子集加载),Windows 不再跌进 SimSun
- 首页加 feTurbulence 纸张噪点层(.grain);报头/赛程行/预测版 rise-in 错峰入场(60ms 步进)
- reduce 动效用户:隐藏态与动画同在 no-preference 块,不会白屏
- Switch 提到模块顶层(修 render 重建丢焦点);a→Link(SPA 内链);h3→h2/h4→h3 修标题层级
- 按钮/输入热区抬高(btn 40px / btn-sm 32px / field py-2);index.css 硬编码色值改 :root 令牌
2026-09-18 11:04:46 +08:00
shangfangjian cf0751d1ff feat: 管理界面报刊风统一改造 — 移除独立暗色主题,预测/回测/监控/配置页增强 2026-09-18 10:42:14 +08:00
21 changed files with 1388 additions and 877 deletions
+10
View File
@@ -8,6 +8,7 @@
"name": "profeto-frontend",
"version": "0.1.0",
"dependencies": {
"@fontsource/noto-serif-sc": "^5.3.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.30.6"
@@ -709,6 +710,15 @@
"node": ">=12"
}
},
"node_modules/@fontsource/noto-serif-sc": {
"version": "5.3.0",
"resolved": "https://registry.npmmirror.com/@fontsource/noto-serif-sc/-/noto-serif-sc-5.3.0.tgz",
"integrity": "sha512-0/zaEFkidiWldE62rTeD74x8ygUsQvejiSNtO0LQxQk3qpaHnlMZ3w4C7yH80B4KTIg8VKeFP4oSgwWMchY9+g==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+1
View File
@@ -9,6 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
"@fontsource/noto-serif-sc": "^5.3.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.30.6"
+8 -7
View File
@@ -6,7 +6,7 @@
* - /admin/* → 管理后台
*/
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { BrowserRouter, Routes, Route, Navigate, Link } from 'react-router-dom'
import { ErrorBoundary } from './components/ErrorBoundary'
import Matches from './pages/Matches'
import { adminRoutes } from './admin/routes'
@@ -23,9 +23,10 @@ function dateLine(): string {
function HomePage() {
return (
<div className="min-h-screen bg-paper-50">
// grain: 纸张噪点氛围层;报头、赛程行、预测版各自带 rise-in 入场
<div className="grain min-h-screen bg-paper-50">
{/* ── 报头:粗线 + 居中刊名 + 报眉 ── */}
<header className="masthead-rule">
<header className="masthead-rule rise-in">
<div className="mx-auto max-w-5xl px-5 sm:px-8">
<div className="border-b border-ink-900 py-5 text-center sm:py-6">
<h1 className="font-serif text-4xl font-bold tracking-widest text-ink-900">
@@ -40,13 +41,13 @@ function HomePage() {
</div>
<div className="flex items-center justify-between border-b border-ink-200 py-2 text-2xs text-ink-500">
<span>{dateLine()}</span>
<a
href="/admin"
className="flex items-center gap-1.5 text-press hover:text-press-dark transition-colors"
<Link
to="/admin"
className="flex min-h-[32px] items-center gap-1.5 text-press transition-colors hover:text-press-dark"
>
<span aria-hidden="true"></span>
</a>
</Link>
</div>
</div>
</header>
+99 -62
View File
@@ -1,13 +1,13 @@
/**
* Admin 后台 - 布局组件
* Admin 后台 - 布局组件(报刊风)
*
* 响应式布局: 移动端汉堡菜单 + 可折叠侧边栏
* 桌面端: 固定侧边栏 + 内容区
* 暗色主题,参考线性风格设计
* 与前台同一套纸色语言:报头式侧栏 + 报眉顶栏 + 细线分区。
* 响应式: 移动端汉堡菜单 + 抽屉侧栏,桌面端固定侧栏。
*/
import { useState, useEffect, useCallback } from 'react'
import { NavLink, Outlet, useLocation } from 'react-router-dom'
import { NavLink, Link, 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<boolean | null>(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 (
<div className="flex h-screen bg-gray-950 text-gray-200 overflow-hidden">
<div className="flex h-screen overflow-hidden bg-paper-50 text-ink-800">
{/* ── 移动端遮罩层 ── */}
{sidebarOpen && (
<div
className="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm lg:hidden"
className="fixed inset-0 z-40 bg-ink-900/40 lg:hidden"
onClick={() => setSidebarOpen(false)}
aria-hidden="true"
/>
@@ -54,33 +79,26 @@ export default function AdminLayout() {
{/* ── 侧边栏 ── */}
<aside
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
fixed inset-y-0 left-0 z-50 flex w-60 flex-shrink-0 flex-col border-r border-ink-900 bg-paper-50
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">
Profeto
<span className="ml-2 text-xs font-normal tracking-normal text-gray-500">Admin</span>
{/* 报头 */}
<div className="flex items-center justify-between border-b border-ink-900 px-5 py-4">
<h1 className="font-serif text-lg font-bold tracking-widest text-ink-900">
<span className="ml-2 align-baseline font-serif text-xs font-normal italic tracking-normal text-ink-500">
Profeto
</span>
</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>
<span className="text-2xs tracking-[0.25em] text-ink-400">ADMIN</span>
</div>
{/* 导航 */}
<nav className="flex-1 overflow-y-auto px-3 py-4">
{/* 导航:选中项以印报红方块标记,同前台胜平负选中样式 */}
<nav className="flex-1 overflow-y-auto px-3 py-4" aria-label="管理导航">
<ul className="space-y-0.5">
{NAV_ITEMS.map(item => (
<li key={item.to}>
@@ -88,17 +106,25 @@ export default function AdminLayout() {
to={item.to}
end={item.end}
className={({ isActive }) =>
`flex items-center gap-3 rounded-md px-3 py-2.5 text-sm transition-colors min-h-[44px] ${
`flex min-h-[44px] items-center gap-2.5 px-3 py-2.5 text-sm transition-colors ${
isActive
? 'bg-gray-800 text-white font-medium'
: 'text-gray-400 hover:bg-gray-800/60 hover:text-gray-200'
? 'bg-press-wash/60 font-medium text-press'
: 'text-ink-500 hover:bg-paper-100 hover:text-ink-900'
}`
}
>
<span className="text-base leading-none opacity-60" aria-hidden="true">
{item.icon}
</span>
{item.label}
{({ isActive }) => (
<>
<span
className={`inline-block h-1.5 w-1.5 flex-shrink-0 ${isActive ? 'bg-press' : 'bg-transparent'}`}
aria-hidden="true"
/>
<span className="text-base leading-none opacity-50" aria-hidden="true">
{item.icon}
</span>
{item.label}
</>
)}
</NavLink>
</li>
))}
@@ -106,48 +132,59 @@ export default function AdminLayout() {
</nav>
{/* 底部 */}
<div className="border-t border-gray-800 px-4 py-3">
<a
href="/"
className="flex items-center gap-2 text-xs text-gray-500 transition-colors hover:text-gray-300 min-h-[44px]"
<div className="border-t border-ink-200 px-4 py-3">
<Link
to="/"
className="flex min-h-[44px] items-center gap-2 text-xs text-ink-500 transition-colors hover:text-press"
>
<span aria-hidden="true"></span>
</a>
</Link>
</div>
</aside>
{/* ── 主内容区 ── */}
<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-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">
<span className="inline-block h-2 w-2 rounded-full bg-emerald-500" aria-hidden="true" />
<span className="hidden sm:inline"></span>
{/* 报眉:日期 + 系统状态 */}
<header className="flex h-11 flex-shrink-0 items-center justify-between border-b border-ink-200 px-4 lg:px-6">
<div className="flex items-center gap-4">
<button
onClick={() => setSidebarOpen(true)}
className="-ml-1 p-2 text-ink-500 hover:text-ink-900 lg:hidden"
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>
<span className="hidden text-2xs text-ink-500 sm:inline">{dateLine()}</span>
</div>
{/* 版本 */}
<div className="text-xs text-gray-500">
<span className="hidden sm:inline">Profeto Admin v1.0</span>
<span className="sm:hidden">v1.0</span>
<div className="flex items-center gap-4 text-2xs">
<span className="inline-flex items-center gap-1.5 text-ink-500">
<span
className={`inline-block h-1.5 w-1.5 ${
healthOk === null ? 'bg-ink-300' : healthOk ? 'bg-ink-900' : 'bg-press'
}`}
aria-hidden="true"
/>
{healthOk === null ? '检测中' : healthOk ? '系统正常' : '系统异常'}
</span>
<Link
to="/"
className="text-press transition-colors hover:text-press-dark sm:hidden"
aria-label="返回前台"
>
</Link>
</div>
</header>
{/* 页面内容 */}
<main className="flex-1 overflow-y-auto p-4 lg:p-6">
<Outlet />
<main className="flex-1 overflow-y-auto p-4 lg:p-8">
<div className="mx-auto max-w-6xl">
<Outlet />
</div>
</main>
</div>
</div>
+54 -51
View File
@@ -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 后台侧栏底部「返回前台版面」链接可回到 `/`
+32 -5
View File
@@ -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<T>(path: string, options: RequestInit = {}): Promise<T> {
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<T>(path: string, options: RequestInit = {}): Promise<T> {
} 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
+128 -76
View File
@@ -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 (
<div className={`rounded-lg border border-gray-800 bg-gray-900 ${className}`}>
{children}
</div>
<div className={`border border-ink-900 bg-paper-50 ${className}`}>{children}</div>
)
}
@@ -39,14 +32,12 @@ export function CardHeader({
action?: ReactNode
}) {
return (
<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>
<div className="border-b border-ink-900 bg-paper-100 px-4 py-2.5 sm:px-5">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="font-serif text-sm font-bold text-ink-900">{title}</h3>
{action}
</div>
{description && (
<p className="mt-1 text-xs text-gray-500">{description}</p>
)}
{description && <p className="mt-1 text-2xs text-ink-500">{description}</p>}
</div>
)
}
@@ -58,7 +49,7 @@ export function CardBody({
children: ReactNode
className?: string
}) {
return <div className={`px-5 py-4 ${className}`}>{children}</div>
return <div className={`px-4 py-4 sm:px-5 ${className}`}>{children}</div>
}
// ── 统计卡片 ────────────────────────────────────────────────────
@@ -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 (
<div className="rounded-lg border border-gray-800 bg-gray-900 px-5 py-4">
<div className="flex items-center justify-between">
<span className="text-xs text-gray-500">{label}</span>
{icon && (
<span className="text-lg opacity-40" aria-hidden="true">
{icon}
</span>
)}
<div className="border border-ink-900 bg-paper-50 px-4 py-3.5">
<span className="text-2xs tracking-[0.2em] text-ink-400">{label}</span>
<div className="mt-1.5 font-serif text-3xl font-bold tabular-nums leading-none text-ink-900">
{value}
</div>
<div className="mt-2 text-2xl font-semibold text-gray-100 tabular-nums">{value}</div>
{trend && (
<div
className={`mt-1 text-xs tabular-nums ${
trend.positive ? 'text-emerald-400' : 'text-red-400'
}`}
>
{trend.positive ? '↑' : '↓'} {Math.abs(trend.value)}%
</div>
)}
{hint && <div className="mt-1.5 text-2xs text-ink-400">{hint}</div>}
</div>
)
}
// ── 状态标 ────────────────────────────────────────────────────
// ── 状态标 ────────────────────────────────────────────────────
// 报刊不用彩色药丸:小方块 + 文字,红=异常/失败,墨=正常,灰=中性
const BADGE_COLORS: Record<string, string> = {
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<string, { text: string; mark: string }> = {
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 (
<span
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${colorClass}`}
>
<span className={`inline-flex items-center gap-1.5 whitespace-nowrap text-2xs ${s.text}`}>
<span className={`inline-block h-1.5 w-1.5 ${s.mark}`} aria-hidden="true" />
{children}
</span>
)
@@ -154,9 +131,9 @@ export function DataTable<T = any>({
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-800 text-xs text-gray-500">
<tr className="border-b border-ink-900 text-2xs tracking-wider text-ink-500">
{columns.map(col => (
<th key={col.key} className="px-3 py-2.5 font-medium" style={{ width: col.width }}>
<th key={col.key} className="px-3 py-2 font-medium" style={{ width: col.width }}>
{col.label}
</th>
))}
@@ -166,10 +143,10 @@ export function DataTable<T = any>({
{data.map(row => (
<tr
key={rowKey(row)}
className="border-b border-gray-800/50 transition-colors hover:bg-gray-800/30"
className="border-b border-ink-200 transition-colors hover:bg-paper-100"
>
{columns.map(col => (
<td key={col.key} className="px-3 py-2.5 text-gray-300">
<td key={col.key} className="px-3 py-2.5 text-ink-800">
{col.render
? col.render(row)
: row != null && typeof row === 'object' && col.key in row
@@ -185,26 +162,27 @@ export function DataTable<T = any>({
)
}
// ── 进度条 ──────────────────────────────────────────────────────
// ── 进度条:同前台置信度细线 ────────────────────────────────────
export function ProgressBar({ value }: { value: number }) {
const clamped = Math.max(0, Math.min(100, value))
return (
<div className="h-1.5 w-full rounded-full bg-gray-800" role="progressbar" aria-valuenow={clamped}>
<div className="h-px w-full bg-ink-200" role="progressbar" aria-valuenow={clamped}>
<div
className="h-full rounded-full bg-blue-500 transition-all duration-300"
className="h-px bg-press transition-[width] duration-500"
style={{ width: `${clamped}%` }}
/>
</div>
)
}
// ── 空状态 ──────────────────────────────────────────────────────
// ── 空状态:同前台「本版暂无赛程」 ──────────────────────────────
export function EmptyState({ text = '暂无数据' }: { text?: string }) {
export function EmptyState({ text = '暂无数据', sub }: { text?: string; sub?: string }) {
return (
<div className="flex items-center justify-center py-12 text-sm text-gray-600">
{text}
<div className="border-y border-ink-200 py-12 text-center">
<p className="font-serif text-sm text-ink-600">{text}</p>
{sub && <p className="mt-1.5 text-xs text-ink-400">{sub}</p>}
</div>
)
}
@@ -228,7 +206,7 @@ export function MobileCardList<T = any>({
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">
<div key={idx} className="border border-ink-900 bg-paper-50 p-4">
{renderCard(row, idx)}
</div>
))}
@@ -259,7 +237,7 @@ export function ResponsiveTable<T = any>({
return (
<>
{/* 桌面端表格 */}
<div className="hidden lg:block overflow-x-auto">
<div className="hidden overflow-x-auto lg:block">
<DataTable columns={columns} data={data} rowKey={rowKey} emptyText={emptyText} />
</div>
{/* 移动端卡片 */}
@@ -268,7 +246,7 @@ export function ResponsiveTable<T = any>({
)
}
// ── 小节标题 ────────────────────────────────────────────────────
// ── 小节标题:同前台 section-head ───────────────────────────────
export function SectionHeader({
title,
@@ -278,9 +256,83 @@ export function SectionHeader({
description?: string
}) {
return (
<div className="mb-4">
<h2 className="text-lg font-semibold text-gray-100">{title}</h2>
{description && <p className="mt-1 text-sm text-gray-500">{description}</p>}
<div className="mb-5">
<h2 className="section-head text-base">{title}</h2>
{description && <p className="mt-1.5 text-xs text-ink-500">{description}</p>}
</div>
)
}
// ── 提示条:错误红框(同前台) / 正常墨框 ────────────────────────
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 (
<div className={`flex items-start justify-between gap-3 border px-4 py-3 ${style}`}>
<div>
<p className={`flex items-center gap-1.5 text-sm font-medium ${titleCls}`}>
<span
className={`inline-block h-1.5 w-1.5 ${kind === 'error' ? 'bg-press' : 'bg-ink-900'}`}
aria-hidden="true"
/>
{title}
</p>
{message && (
<p className="mt-0.5 whitespace-pre-wrap text-xs leading-relaxed text-ink-600">
{message}
</p>
)}
</div>
{onClose && (
<button
onClick={onClose}
className="text-ink-400 transition-colors hover:text-ink-900"
aria-label="关闭"
>
<svg viewBox="0 0 20 20" className="h-4 w-4" fill="currentColor" aria-hidden="true">
<path d="M6.3 5.3a1 1 0 011.4 0L10 7.6l2.3-2.3a1 1 0 111.4 1.4L11.4 9l2.3 2.3a1 1 0 01-1.4 1.4L10 10.4l-2.3 2.3a1 1 0 01-1.4-1.4L8.6 9 6.3 6.7a1 1 0 010-1.4z" />
</svg>
</button>
)}
</div>
)
}
// ── 加载指示:同前台 Spinner ────────────────────────────────────
export function Spinner({ className = '' }: { className?: string }) {
return (
<svg
viewBox="0 0 20 20"
className={`h-3.5 w-3.5 animate-spin ${className}`}
fill="none"
aria-hidden="true"
>
<circle cx="10" cy="10" r="7.5" stroke="currentColor" strokeWidth="1.5" strokeOpacity="0.25" />
<path d="M17.5 10A7.5 7.5 0 0010 2.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
)
}
// ── 骨架占位 ────────────────────────────────────────────────────
export function SkeletonBlock({ className = '' }: { className?: string }) {
return <div className={`skeleton ${className}`} />
}
+192 -88
View File
@@ -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<string, string> = { '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<League[]>([])
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<string | null>(null)
const [result, setResult] = useState<any>(null)
const [result, setResult] = useState<BacktestResponse | null>(null)
const [evalSummary, setEvalSummary] = useState<EvalSummary | null>(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 (
<div className="space-y-6">
<SectionHeader
title="回测管理"
description="在历史数据上运行预测并评估准确率"
description="在历史数据上运行预测并评估准确率。逐场调用 LLM,成本高,建议先小场次试跑。"
/>
<div className="grid gap-6 lg:grid-cols-2">
@@ -62,138 +113,191 @@ export default function BacktestPage() {
<CardBody>
<form onSubmit={handleBacktest} className="space-y-4">
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-400"> ID ()</label>
<input
type="number"
<label className="mb-1.5 block text-xs text-ink-500"></label>
<select
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]"
/>
className="field w-full"
>
<option value=""></option>
{leagues.map(l => (
<option key={l.id ?? l.code} value={String(l.id)}>
{l.name_zh || l.name}
</option>
))}
</select>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-400"></label>
<label className="mb-1.5 block text-xs text-ink-500"></label>
<input
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]"
className="field w-full"
/>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-400"></label>
<label className="mb-1.5 block text-xs text-ink-500"></label>
<input
type="date"
value={dateTo}
onChange={e => setDateTo(e.target.value)}
className="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2.5 text-white min-h-[44px]"
className="field w-full"
/>
</div>
</div>
<div>
<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)}
className="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2.5 text-white min-h-[44px]"
/>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-400"></label>
<select
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="multi"> Agent ()</option>
</select>
</div>
{error && (
<div className="rounded-md bg-red-500/10 p-3 text-sm text-red-400 border border-red-500/30">
{error}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1.5 block text-xs text-ink-500"></label>
<input
type="number"
min={1}
max={200}
value={limit}
onChange={e => setLimit(parseInt(e.target.value) || 20)}
className="field w-full"
/>
</div>
)}
<div>
<label className="mb-1.5 block text-xs text-ink-500"></label>
<select
value={mode}
onChange={e => setMode(e.target.value as 'single' | 'multi')}
className="field w-full"
>
<option value="single"> ()</option>
<option value="multi"> (,)</option>
</select>
</div>
</div>
<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 ? '回测中...' : '开始回测'}
{error && <Alert kind="error" title="回测失败" message={error} onClose={() => setError(null)} />}
<button type="submit" disabled={loading} className="btn btn-solid w-full">
{loading ? (<><Spinner /> ,</>) : '开始回测'}
</button>
</form>
</CardBody>
</Card>
{/* 结果区域 */}
{/* 结果汇总 */}
<div className="space-y-6">
{result && (
{summary && (
<Card>
<CardHeader title="回测结果" />
<CardBody>
<div className="grid grid-cols-2 gap-3">
<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-xs text-gray-400 mt-1"></div>
</div>
<div className="rounded-lg bg-gray-800 p-4 text-center">
<div className="text-2xl font-bold text-blue-400">
{result.accuracy_1x2?.toFixed(1) ?? '—'}%
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div>
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">
{summary.scored}/{summary.total}
</div>
<div className="text-xs text-gray-400 mt-1">1X2 </div>
<div className="mt-1 text-2xs text-ink-400"> / </div>
</div>
<div>
<div className="font-serif text-2xl font-bold tabular-nums text-press">
{summary.accuracy_1x2 !== undefined && summary.accuracy_1x2 !== null
? `${summary.accuracy_1x2.toFixed(1)}%`
: '—'}
</div>
<div className="mt-1 text-2xs text-ink-400">1X2 </div>
</div>
<div>
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">
{summary.avg_score_rmse !== undefined && summary.avg_score_rmse !== null
? summary.avg_score_rmse.toFixed(2)
: '—'}
</div>
<div className="mt-1 text-2xs text-ink-400"> RMSE</div>
</div>
<div>
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">
{summary.avg_subjective_confidence !== undefined && summary.avg_subjective_confidence !== null
? `${Math.round(summary.avg_subjective_confidence * 100)}%`
: '—'}
</div>
<div className="mt-1 text-2xs text-ink-400"></div>
</div>
</div>
</CardBody>
</Card>
)}
{/* 模型评估 */}
<Card>
<CardHeader
title="模型评估"
description="已结算预测的准确率统计"
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 onClick={loadEval} disabled={evalLoading} className="btn btn-sm">
{evalLoading ? (<><Spinner /> </>) : '刷新'}
</button>
}
/>
<CardBody>
{!evalSummary ? (
<div className="text-center py-8 text-sm text-gray-500">
<p>"刷新"</p>
</div>
) : evalSummary.summary?.length > 0 ? (
<div className="space-y-2">
{evalSummary.summary.map((s: any, i: number) => (
{evalSummary && evalSummary.summary?.length > 0 ? (
<div className="space-y-2.5">
{evalSummary.summary.map((s, i) => (
<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"
className="flex flex-col gap-1 border-b border-ink-200 pb-2.5 last:border-b-0 last:pb-0 sm:flex-row sm:items-center sm:justify-between"
>
<span className="text-sm text-gray-300">{s.provider}/{s.model}</span>
<Badge status="info">
{s.accuracy?.toFixed(1)}% ({s.correct}/{s.total})
</Badge>
<span className="font-mono text-xs text-ink-700">{s.provider}/{s.model}</span>
<span className="text-2xs tabular-nums text-ink-500">
<span className="font-serif text-sm font-bold text-ink-900">
{s.accuracy_1x2 !== undefined && s.accuracy_1x2 !== null ? `${s.accuracy_1x2.toFixed(1)}%` : '—'}
</span>
<span className="ml-2">{s.total} </span>
{s.avg_score_rmse != null && <span className="ml-2">RMSE {s.avg_score_rmse.toFixed(2)}</span>}
</span>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-sm text-gray-500">
<p></p>
</div>
<p className="py-4 text-center text-xs text-ink-400">
,
</p>
)}
</CardBody>
</Card>
</div>
</div>
{/* 逐场明细 */}
{result && result.results?.length > 0 && (
<Card>
<CardHeader title="逐场明细" />
<CardBody className="px-0 sm:px-0">
<div>
{result.results.map(r => (
<div
key={r.match_id}
className="flex flex-col gap-1.5 border-b border-ink-200 px-4 py-3 last:border-b-0 sm:flex-row sm:items-center sm:gap-3 sm:px-5"
>
<span className="w-24 flex-shrink-0 text-2xs tabular-nums text-ink-400">
{fmtDate(r.match_date)}
</span>
<span className="min-w-0 flex-1 truncate text-sm text-ink-800">
{r.home_team} vs {r.away_team}
</span>
<span className="flex-shrink-0 text-xs tabular-nums text-ink-500">
<span className="font-serif font-bold text-ink-900">{r.actual_score}</span>
<span className="mx-2 text-ink-200">|</span>
<span className={`font-serif font-bold ${r.correct_1x2 ? 'text-ink-900' : 'text-ink-400'}`}>
{r.pred_home ?? '-'}:{r.pred_away ?? '-'}
</span>
</span>
<span className="flex-shrink-0 sm:w-16 sm:text-right">
{r.correct_1x2 ? <Badge status="success"></Badge> : <Badge status="loss"></Badge>}
</span>
</div>
))}
</div>
</CardBody>
</Card>
)}
</div>
)
}
+58 -41
View File
@@ -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<League[]>([])
const [source, setSource] = useState<string>('bzzoiro')
@@ -25,7 +49,7 @@ export default function CollectionPage() {
const [season, setSeason] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [successMsg, setSuccessMsg] = useState<string | null>(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() {
<div className="space-y-6">
<SectionHeader
title="数据采集"
description="触发数据源采集,支持联赛筛选和日期范围"
description="触发数据源采集,支持联赛筛选和日期范围。采集为同步执行,大范围日期耗时较长。"
/>
<div className="grid gap-6 lg:grid-cols-2">
@@ -73,11 +97,11 @@ export default function CollectionPage() {
<form onSubmit={handleSubmit} className="space-y-4">
{/* 数据源选择 */}
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-400"></label>
<label className="mb-1.5 block text-xs text-ink-500"></label>
<select
value={source}
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]"
className="field w-full"
>
{SOURCES.map(s => (
<option key={s.value} value={s.value}>
@@ -89,11 +113,11 @@ export default function CollectionPage() {
{/* 联赛选择 */}
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-400"></label>
<label className="mb-1.5 block text-xs text-ink-500"></label>
<select
value={leagueCode}
onChange={e => setLeagueCode(e.target.value)}
className="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2.5 text-white min-h-[44px]"
className="field w-full"
>
<option value=""></option>
{leagues.map(l => (
@@ -105,13 +129,13 @@ export default function CollectionPage() {
{/* Understat 专用: 赛季 */}
{source === 'understat' && (
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-400">()</label>
<label className="mb-1.5 block text-xs text-ink-500">()</label>
<input
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]"
className="field w-full"
/>
</div>
)}
@@ -120,45 +144,40 @@ export default function CollectionPage() {
{source !== 'injuries' && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-400"></label>
<label className="mb-1.5 block text-xs text-ink-500"></label>
<input
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]"
className="field w-full"
/>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-400"></label>
<label className="mb-1.5 block text-xs text-ink-500"></label>
<input
type="date"
value={dateTo}
onChange={e => setDateTo(e.target.value)}
className="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2.5 text-white min-h-[44px]"
className="field w-full"
/>
</div>
</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>
{error && <Alert kind="error" title="采集失败" message={error} onClose={() => setError(null)} />}
{result && (
<Alert
kind="ok"
title={result.title}
message={result.detail || undefined}
onClose={() => setResult(null)}
/>
)}
{/* 提交按钮 */}
<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 ? '采集中...' : '触发采集'}
<button type="submit" disabled={loading} className="btn btn-solid w-full">
{loading ? (<><Spinner /> </>) : '触发采集'}
</button>
</form>
</CardBody>
@@ -170,21 +189,19 @@ export default function CollectionPage() {
<CardBody>
<div className="space-y-3">
{SOURCES.map(s => (
<div key={s.value} className="rounded-lg border border-gray-800 p-4">
<div className="flex items-center gap-2 mb-2">
<div key={s.value} className="border-b border-ink-200 px-1 py-3 last:border-b-0">
<div className="flex items-center gap-3">
<Badge status="info">{s.label}</Badge>
<p className="text-xs text-ink-600">{s.desc}</p>
</div>
<p className="text-sm text-gray-400">{s.desc}</p>
</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>
<p className="mt-4 border-l-2 border-ink-300 pl-3 text-2xs leading-relaxed text-ink-500">
ADMIN_API_KEY,
401
</p>
</CardBody>
</Card>
</div>
+122 -88
View File
@@ -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<any[]>([])
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 (
<div className="space-y-6">
<SectionHeader
title="系统配置"
description="查看当前系统配置参数(通过 .env 文件管理)"
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>
<p className="text-sm font-medium text-yellow-300"></p>
<p className="mt-1 text-xs text-yellow-300/80">
(API Key) <code className="rounded bg-gray-800/50 px-1">.env</code>
, SSH
{/* 管理员密钥 */}
<Card>
<CardHeader
title="管理员密钥 (X-API-Key)"
description="后端设置 ADMIN_API_KEY 后,采集 / 回测 / 结算等接口需要此密钥"
/>
<CardBody>
<form onSubmit={handleSaveKey} className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row">
<input
type="password"
value={adminKey}
onChange={e => setAdminKeyInput(e.target.value)}
placeholder={keyExists ? '••••••••(已保存,输入新值可更换)' : '粘贴 ADMIN_API_KEY'}
autoComplete="off"
className="field flex-1"
/>
<div className="flex gap-2">
<button type="submit" disabled={!adminKey.trim()} className="btn btn-solid">
</button>
<button
type="button"
onClick={handleClearKey}
disabled={!keyExists}
className="btn btn-sm"
>
</button>
</div>
</div>
{keySaved && <Alert kind="ok" title="密钥已保存,后续请求将自动附带" />}
{keyExists && !keySaved && (
<p className="text-2xs text-ink-500">
:<Badge status="success"></Badge>
<span className="ml-2">,</span>
</p>
)}
<p className="border-l-2 border-ink-300 pl-3 text-2xs leading-relaxed text-ink-500">
.env ADMIN_API_KEY ()
401
</p>
</div>
</div>
</div>
</form>
</CardBody>
</Card>
{/* 快速导航 */}
<div className="grid gap-4 sm:grid-cols-2">
<a
href="/admin/data-sources"
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"
className="group flex items-center justify-between border border-ink-300 p-4 transition-colors hover:border-ink-900 hover:bg-paper-100"
>
<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 className="font-serif text-sm font-bold text-ink-900"></div>
<div className="mt-0.5 text-2xs text-ink-500"> API Key</div>
</div>
<span className="text-ink-300 transition-colors group-hover:text-press" aria-hidden="true"></span>
</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"
className="group flex items-center justify-between border border-ink-300 p-4 transition-colors hover:border-ink-900 hover:bg-paper-100"
>
<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 className="font-serif text-sm font-bold text-ink-900">LLM </div>
<div className="mt-0.5 text-2xs text-ink-500"></div>
</div>
<span className="text-ink-300 transition-colors group-hover:text-press" aria-hidden="true"></span>
</a>
</div>
@@ -82,53 +136,38 @@ export default function ConfigPage() {
<Card>
<CardHeader
title="当前配置"
description="脱敏展示,实际值在服务器 .env 文件中"
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 onClick={loadConfig} disabled={loading} className="btn btn-sm">
{loading ? (<><Spinner /> </>) : '刷新'}
</button>
}
/>
<CardBody>
<CardBody className="px-0 sm:px-0">
{loading ? (
<div className="space-y-3">
<div className="space-y-3 px-4 sm:px-5">
{[1, 2, 3, 4, 5].map(i => (
<div key={i} className="h-10 animate-pulse rounded bg-gray-800" />
<SkeletonBlock key={i} className="h-9 w-full" />
))}
</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>
{/* 配置行 */}
<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"
className="flex flex-col gap-1 border-b border-ink-200 px-4 py-2.5 last:border-b-0 sm:grid sm:grid-cols-[minmax(0,2fr)_minmax(0,3fr)_minmax(0,2fr)] sm:items-baseline sm:gap-4 sm:px-5"
>
<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}
<span className="font-mono text-xs text-ink-800">{item.key}</span>
{item.is_sensitive && <Badge status="warning"></Badge>}
</div>
<div className="break-all font-mono text-2xs text-ink-500">{item.value_masked}</div>
<div className="text-2xs text-ink-400">{item.description}</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-sm text-gray-500"></div>
<p className="py-8 text-center text-xs text-ink-400"></p>
)}
</CardBody>
</Card>
@@ -136,12 +175,12 @@ export default function ConfigPage() {
{/* 修改指南 */}
<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
<CardBody className="space-y-5">
<div>
<h4 className="mb-2 font-serif text-sm font-bold text-ink-900"> SSH .env</h4>
<pre className="overflow-x-auto border border-ink-200 bg-paper-100 p-3 font-mono text-2xs leading-relaxed text-ink-700">
{`# 连接到部署主机
ssh user@your-server-ip
# 进入项目目录
cd /vol2/1000/Docker/Profeto
@@ -157,29 +196,24 @@ docker compose logs -f api`}
</pre>
</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-4oclaude-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>
<h4 className="mb-2 font-serif text-sm font-bold text-ink-900"></h4>
<div className="space-y-2">
{[
['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]) => (
<div key={key} className="flex items-start gap-2.5">
<code className="flex-shrink-0 border border-ink-200 bg-paper-100 px-1.5 py-0.5 font-mono text-2xs text-ink-800">
{key}
</code>
<span className="text-xs leading-relaxed text-ink-600">{desc}</span>
</div>
))}
</div>
</div>
</CardBody>
+45 -46
View File
@@ -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<DashboardStats | null>(null)
@@ -36,65 +36,58 @@ export default function Dashboard() {
if (error) {
return (
<div className="rounded-lg border border-red-500/30 bg-red-500/10 p-6 text-center text-red-400">
<p className="text-lg font-medium"></p>
<p className="mt-1 text-sm">{error}</p>
<button
onClick={() => window.location.reload()}
className="mt-3 rounded-md bg-red-500/20 px-4 py-2 text-sm hover:bg-red-500/30 min-h-[44px]"
>
<div className="space-y-4">
<Alert kind="error" title="加载仪表盘失败" message={error} />
<button onClick={() => window.location.reload()} className="btn btn-sm">
</button>
</div>
)
}
const healthText =
health?.status === 'healthy' || health?.status === 'ok' ? '正常' : '异常'
return (
<div className="space-y-6">
{/* 统计卡片: 移动端 1 列 → 平板 2 列 → 桌面 4 列 */}
{/* 统计:报纸数字版式 */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
label="健康状态"
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="◇"
value={loading ? '—' : healthText}
hint={loading ? undefined : '每 60 秒随报眉自动复检'}
/>
<StatCard label="联赛数" value={loading ? '—' : data?.leagues.length ?? 0} />
<StatCard label="比赛数" value={loading ? '—' : data?.total_matches ?? 0} />
<StatCard label="预测数" value={loading ? '—' : data?.total_predictions ?? 0} />
</div>
{/* 联赛列表 */}
<Card>
<CardHeader title="最近联赛" />
<CardHeader title="已入库联赛" />
<CardBody>
{loading ? (
<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" />
<SkeletonBlock key={i} className="h-6 w-24" />
))}
</div>
) : data && data.leagues.length > 0 ? (
<div className="flex flex-wrap gap-2">
{data.leagues.map(l => (
<Badge key={l.code} status="info">
{l.name_zh || l.name} ({l.code})
</Badge>
<span
key={l.code}
className="border border-ink-200 px-2.5 py-1 text-xs text-ink-700"
>
{l.name_zh || l.name}
<span className="ml-1.5 font-mono text-2xs text-ink-400">{l.code}</span>
</span>
))}
</div>
) : (
<p className="text-gray-400">,</p>
<p className="text-xs text-ink-500">
,
</p>
)}
</CardBody>
</Card>
@@ -106,33 +99,39 @@ export default function Dashboard() {
<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"
className="group flex items-center justify-between border border-ink-300 p-4 transition-colors hover:border-ink-900 hover:bg-paper-100"
>
<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 className="font-serif text-sm font-bold text-ink-900"></div>
<div className="mt-0.5 text-2xs text-ink-500"></div>
</div>
<span className="text-ink-300 transition-colors group-hover:text-press" aria-hidden="true">
</span>
</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"
className="group flex items-center justify-between border border-ink-300 p-4 transition-colors hover:border-ink-900 hover:bg-paper-100"
>
<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 className="font-serif text-sm font-bold text-ink-900"></div>
<div className="mt-0.5 text-2xs text-ink-500"> LLM </div>
</div>
<span className="text-ink-300 transition-colors group-hover:text-press" aria-hidden="true">
</span>
</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"
className="group flex items-center justify-between border border-ink-300 p-4 transition-colors hover:border-ink-900 hover:bg-paper-100"
>
<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 className="font-serif text-sm font-bold text-ink-900"></div>
<div className="mt-0.5 text-2xs text-ink-500"></div>
</div>
<span className="text-ink-300 transition-colors group-hover:text-press" aria-hidden="true">
</span>
</a>
</div>
</CardBody>
+38 -61
View File
@@ -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<DataSourceStatus[]>([])
@@ -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() {
<div className="space-y-6">
<SectionHeader
title="数据源管理"
description="管理数据采集源配置与连接状态"
description="数据采集源配置状态与连通性测试。"
/>
{/* 数据源卡片 */}
@@ -60,10 +60,10 @@ export default function DataSourcesPage() {
{[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 className="space-y-3">
<SkeletonBlock className="h-4 w-24" />
<SkeletonBlock className="h-3 w-32" />
<SkeletonBlock className="h-8 w-full" />
</div>
</CardBody>
</Card>
@@ -77,47 +77,41 @@ export default function DataSourcesPage() {
<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 className="flex items-center justify-between border-b border-ink-200 pb-3">
<h3 className="font-serif text-sm font-bold text-ink-900">{source.label}</h3>
<Badge status={source.keyConfigured ? 'success' : 'error'}>
{source.keyConfigured ? '已配置' : '未配置'}
</Badge>
</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>
<span className="text-ink-400">API Key</span>
<span className="font-mono text-ink-600">{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>
<span className="text-ink-400"></span>
<span className="text-ink-600">{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>
<Alert
kind={result.success ? 'ok' : 'error'}
title={result.success ? '连接成功' : '连接失败'}
message={result.success ? undefined : result.message}
/>
)}
{/* 操作按钮 */}
<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]"
className="btn btn-sm w-full"
>
{testingSource === source.name ? '测试中...' : '测试连接'}
{testingSource === source.name ? (<><Spinner /> </>) : '测试连接'}
</button>
</CardBody>
</Card>
@@ -130,45 +124,28 @@ export default function DataSourcesPage() {
<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">
<div className="grid gap-x-6 gap-y-3 sm:grid-cols-3">
<div className="border-t border-ink-200 pt-3">
<Badge status="info">Bzzoiro</Badge>
<p className="mt-2 text-xs leading-relaxed text-ink-600">
, 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,
<div className="border-t border-ink-200 pt-3">
<Badge status="info">Understat</Badge>
<p className="mt-2 text-xs leading-relaxed text-ink-600">
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">
<div className="border-t border-ink-200 pt-3">
<Badge status="info">Injuries</Badge>
<p className="mt-2 text-xs leading-relaxed text-ink-600">
, 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>
)
}
+98 -125
View File
@@ -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() {
<div className="space-y-6">
<SectionHeader
title="LLM 配置"
description="管理大语言模型连接与使用统计"
description="大语言模型连接状态与使用统计。模型切换通过修改 .env 并重启服务完成。"
/>
<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>
<CardBody>
<div className="space-y-0">
{[
{ 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 => (
<div
key={row.label}
className="flex items-center justify-between gap-4 border-b border-ink-200 py-2.5 last:border-b-0"
>
<span className="text-xs text-ink-400">{row.label}</span>
<span className={`text-xs text-ink-800 ${row.mono ? 'break-all font-mono' : ''}`}>
{row.value}
</span>
</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 className="mt-4">
<Alert
kind={testResult.success ? 'ok' : 'error'}
title={testResult.success ? '连接正常' : '连接失败'}
message={testResult.success ? undefined : 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 onClick={handleTest} disabled={testing} className="btn btn-sm mt-4 w-full">
{testing ? (<><Spinner /> </>) : '测试 LLM 连接'}
</button>
<p className="mt-2 text-center text-2xs text-ink-400">
, LLM
</p>
</CardBody>
</Card>
@@ -131,126 +120,110 @@ export default function LLMConfigPage() {
<Card>
<CardHeader
title="使用统计"
description="从最近预测记录聚合"
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 onClick={loadStats} disabled={loading} className="btn btn-sm">
{loading ? (<><Spinner /> </>) : '刷新'}
</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 className="space-y-3">
<SkeletonBlock className="h-16 w-full" />
<SkeletonBlock className="h-16 w-full" />
</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 className="grid grid-cols-3 gap-4">
<div className="border-t-2 border-ink-900 pt-3 text-center">
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">
{stats.total_predictions}
</div>
<div className="mt-1 text-2xs text-ink-400"></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 className="border-t-2 border-ink-900 pt-3 text-center">
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">
{stats.avg_latency_ms > 0 ? `${(stats.avg_latency_ms / 1000).toFixed(1)}s` : '—'}
</div>
<div className="text-xs text-gray-500 mt-1"></div>
<div className="mt-1 text-2xs text-ink-400"></div>
</div>
<div className="border-t-2 border-press pt-3 text-center">
<div className="font-serif text-2xl font-bold tabular-nums text-press">
{stats.success_rate.toFixed(0)}%
</div>
<div className="mt-1 text-2xs text-ink-400"></div>
</div>
</div>
) : (
<div className="text-center py-8 text-sm text-gray-500">使</div>
<p className="py-6 text-center text-xs text-ink-400">使</p>
)}
</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>
<CardHeader title="可用模型" description="在 .env 中修改 LLM_MODEL 后重启服务生效" />
<CardBody className="px-0 sm:px-0">
{AVAILABLE_MODELS.map(model => {
const isCurrent = model.id === currentConfig.model
return (
<div
key={model.id}
className={`flex flex-col gap-2 border-b border-ink-200 px-4 py-3 last:border-b-0 sm:flex-row sm:items-center sm:justify-between sm:px-5 ${
isCurrent ? 'bg-press-wash/50' : ''
}`}
>
<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>
)}
<span className="text-sm font-medium text-ink-900">{model.label}</span>
{isCurrent && <Badge status="success"></Badge>}
</div>
<p className="mt-0.5 text-2xs text-ink-500">{model.description}</p>
</div>
)
})}
</div>
<div className="flex items-center gap-3">
<span className="font-mono text-2xs text-ink-400">{model.provider}</span>
{!isCurrent && <span className="text-2xs text-ink-400"> .env </span>}
</div>
</div>
)
})}
</CardBody>
</Card>
{/* 最近预测 */}
<Card>
<CardHeader title="最近预测记录" />
<CardBody>
<CardBody className="px-0 sm:px-0">
{loading ? (
<div className="space-y-2">
<div className="space-y-2 px-4 sm:px-5">
{[1, 2, 3].map(i => (
<div key={i} className="h-12 animate-pulse rounded bg-gray-800" />
<SkeletonBlock key={i} className="h-10 w-full" />
))}
</div>
) : stats && stats.recent_predictions.length > 0 ? (
<div className="space-y-2">
<div>
{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"
className="flex flex-col gap-1.5 border-b border-ink-200 px-4 py-2.5 last:border-b-0 sm:flex-row sm:items-center sm:justify-between sm:px-5"
>
<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 className="flex items-baseline gap-3">
<span className="text-2xs tabular-nums text-ink-400">#{p.id}</span>
<span className="text-xs text-ink-800"> #{p.match_id}</span>
<span className="font-mono text-2xs text-ink-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() : '—'}
<div className="flex items-center gap-3">
<span className="text-2xs tabular-nums text-ink-400">
{p.created_at ? new Date(p.created_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '—'}
</span>
<Badge status={p.status === 'success' ? 'success' : 'failed'}>
{p.status === 'success' ? '成功' : '失败'}
</Badge>
{p.status === 'success' ? <Badge status="success"></Badge> : <Badge status="error"></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>
<p className="py-8 text-center text-xs text-ink-400"></p>
)}
</CardBody>
</Card>
+129 -82
View File
@@ -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<any>(null)
const [ready, setReady] = useState<'ready' | 'not_ready' | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [lastCheck, setLastCheck] = useState<string>('')
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 (
<div className="space-y-6">
<SectionHeader
title="系统监控"
description="查看服务健康状态"
description="存活与数据库就绪检查,每 30 秒自动巡检一次。"
/>
{loading ? (
<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 ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{/* 服务状态 */}
<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="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="flex items-center justify-between">
<span className="text-2xs text-ink-400">
{lastCheck && `最近巡检 ${lastCheck}`}
</span>
<button onClick={refresh} disabled={loading} className="btn btn-sm">
{loading ? (<><Spinner /> </>) : '立即巡检'}
</button>
</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 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 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>
{error && (
<Alert
kind="error"
title="无法连接到后端"
message={`${error}\n请确认服务是否正常运行,以及管理员密钥是否需要配置。`}
/>
)}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{/* 存活状态 */}
<div className="border border-ink-900 bg-paper-50 p-5">
<div className="text-2xs tracking-[0.2em] text-ink-400"></div>
<div className="mt-2 flex items-center gap-2">
<span
className={`inline-block h-2 w-2 ${alive ? 'bg-ink-900' : 'bg-press'}`}
aria-hidden="true"
/>
<span className={`font-serif text-xl font-bold ${alive ? 'text-ink-900' : 'text-press'}`}>
{health ? (alive ? '正常' : String(health.status)) : '—'}
</span>
</div>
</div>
{/* 数据库就绪 */}
<div className="border border-ink-900 bg-paper-50 p-5">
<div className="text-2xs tracking-[0.2em] text-ink-400"></div>
<div className="mt-2 flex items-center gap-2">
<span
className={`inline-block h-2 w-2 ${ready === 'ready' ? 'bg-ink-900' : ready === null ? 'bg-ink-300' : 'bg-press'}`}
aria-hidden="true"
/>
<span
className={`font-serif text-xl font-bold ${ready === 'not_ready' ? 'text-press' : 'text-ink-900'}`}
>
{ready === 'ready' ? '就绪' : ready === 'not_ready' ? '未就绪' : '—'}
</span>
</div>
</div>
{/* 服务名 */}
<div className="border border-ink-900 bg-paper-50 p-5">
<div className="text-2xs tracking-[0.2em] text-ink-400"></div>
<div className="mt-2 font-serif text-xl font-bold text-ink-900">
{health?.service || 'profeto'}
</div>
</div>
{/* 版本 */}
{health?.version && (
<div className="border border-ink-900 bg-paper-50 p-5">
<div className="text-2xs tracking-[0.2em] text-ink-400"></div>
<div className="mt-2 font-serif text-xl font-bold tabular-nums text-ink-900">
{health.version}
</div>
</div>
)}
{/* 运行时间 */}
{health?.uptime_seconds != null && (
<div className="border border-ink-900 bg-paper-50 p-5">
<div className="text-2xs tracking-[0.2em] text-ink-400"></div>
<div className="mt-2 font-serif text-xl font-bold tabular-nums text-ink-900">
{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="border border-ink-900 bg-paper-50 p-5 sm:col-span-2 lg:col-span-1">
<div className="text-2xs tracking-[0.2em] text-ink-400"></div>
<div className="mt-2 space-y-1">
{Object.entries(health.checks).map(([key, val]) => (
<div key={key} className="flex items-center justify-between text-sm">
<span className="text-2xs text-ink-500">{key}</span>
<Badge status={String(val) === 'pass' ? 'success' : 'error'}>
{String(val)}
</Badge>
</div>
))}
</div>
</div>
)}
</div>
</div>
)
}
+261 -48
View File
@@ -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<string, string> = {
h2h: '历史交锋',
form: '近期状态',
stats: '攻防数据',
home_away: '主客因素',
injuries: '阵容完整性',
}
const OUTCOME_LABEL: Record<string, string> = { '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<Match[]>([])
@@ -19,11 +41,36 @@ export default function PredictionsPage() {
const [error, setError] = useState<string | null>(null)
const [successMsg, setSuccessMsg] = useState<string | null>(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<number, string>()
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 (
<div className="space-y-6">
<SectionHeader
title="预测管理"
description="触发 LLM 足球预测"
description="触发 LLM 预测;赛后录入实际比分完成结算,供准确率统计使用。"
/>
<div className="grid gap-6 lg:grid-cols-2">
@@ -55,84 +131,221 @@ export default function PredictionsPage() {
<CardBody>
<form onSubmit={handlePredict} className="space-y-4">
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-400"></label>
<label className="mb-1.5 block text-xs text-ink-500"></label>
<select
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]"
className="field w-full"
>
<option value=""></option>
{matches.map(m => (
<option key={m.id} value={m.id}>
{m.home_team} vs {m.away_team} ({m.match_date?.slice(0, 10)})
{(m.home_team_zh || m.home_team)} vs {(m.away_team_zh || m.away_team)}
({m.match_date?.slice(5, 10)})
</option>
))}
</select>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-400"></label>
<label className="mb-1.5 block text-xs text-ink-500"></label>
<select
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]"
className="field w-full"
>
<option value="multi"> Agent (5 + )</option>
<option value="single"></option>
<option value="multi"> (5 + ,)</option>
<option value="single"> ()</option>
</select>
</div>
{error && (
<div className="rounded-md bg-red-500/10 p-3 text-sm text-red-400 border border-red-500/30">
{error}
</div>
)}
{error && <Alert kind="error" title="预测失败" message={error} onClose={() => setError(null)} />}
{successMsg && (
<div className="rounded-md bg-emerald-500/10 p-3 text-sm text-emerald-400 border border-emerald-500/30">
{successMsg}
</div>
<Alert kind="ok" title={successMsg} onClose={() => setSuccessMsg(null)} />
)}
<button
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]"
className="btn btn-solid w-full"
>
{loading ? '预测中...' : '触发预测'}
{loading ? (<><Spinner /> , 20-60 </>) : '触发预测'}
</button>
</form>
</CardBody>
</Card>
{/* 最近预测 */}
{/* 结算 */}
<Card>
<CardHeader title="最近预测" />
<CardHeader
title="预测结算"
description="录入实际比分,系统据此统计 1X2 准确率与比分 RMSE"
/>
<CardBody>
{predictions.length === 0 ? (
<div className="text-center py-8 text-sm text-gray-500">
<p></p>
<p className="mt-1 text-xs"></p>
</div>
{unsettled.length === 0 ? (
<p className="py-6 text-center text-xs text-ink-400">
</p>
) : (
<div className="space-y-2">
{predictions.slice(0, 10).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"
<form onSubmit={handleSettle} className="space-y-4">
<div>
<label className="mb-1.5 block text-xs text-ink-500"></label>
<select
value={settleId}
onChange={e => setSettleId(e.target.value)}
className="field w-full"
>
<span className="text-sm text-gray-300">
Match #{p.match_id} · {p.model}
</span>
<Badge status={p.settled ? 'success' : 'warning'}>
{p.pred_1x2 || '?'} · {p.subjective_confidence ? `${(p.subjective_confidence * 100).toFixed(0)}%` : '—'}
</Badge>
<option value="">({unsettled.length} )</option>
{unsettled.map(p => (
<option key={p.id} value={p.id}>
#{p.id} {nameOf(p.match_id)} · {p.pred_home_goals ?? '-'}:{p.pred_away_goals ?? '-'}
</option>
))}
</select>
</div>
{settleTarget && (
<p className="border-l-2 border-ink-300 pl-3 text-2xs text-ink-500">
:{settleTarget.pred_home_goals ?? '-'} : {settleTarget.pred_away_goals ?? '-'}
({OUTCOME_LABEL[settleTarget.pred_1x2 ?? ''] ?? '?'})
<span className="ml-2">{fmtTime(settleTarget.created_at)}</span>
</p>
)}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1.5 block text-xs text-ink-500"></label>
<input
type="number"
min={0}
max={30}
value={homeGoals}
onChange={e => setHomeGoals(e.target.value)}
className="field w-full"
/>
</div>
))}
</div>
<div>
<label className="mb-1.5 block text-xs text-ink-500"></label>
<input
type="number"
min={0}
max={30}
value={awayGoals}
onChange={e => setAwayGoals(e.target.value)}
className="field w-full"
/>
</div>
</div>
{settleMsg && (
<Alert
kind={settleMsg.kind}
title={settleMsg.kind === 'ok' ? '结算完成' : '结算失败'}
message={settleMsg.kind === 'error' ? settleMsg.text : undefined}
onClose={() => setSettleMsg(null)}
/>
)}
<button
type="submit"
disabled={settling || !settleId || homeGoals === '' || awayGoals === ''}
className="btn btn-solid w-full"
>
{settling ? (<><Spinner /> </>) : '提交结算'}
</button>
</form>
)}
</CardBody>
</Card>
</div>
{/* 最近预测 */}
<Card>
<CardHeader title="预测记录" description="点击行可展开终裁理由与专家摘要" />
<CardBody className="px-0 sm:px-0">
{predictions.length === 0 ? (
<p className="py-10 text-center text-xs text-ink-400">
,
</p>
) : (
<div>
{predictions.map(p => {
const okAgents = (p.agent_outputs ?? []).filter(a => a.status === 'ok')
return (
<details key={p.id} className="group border-b border-ink-200 last:border-b-0">
<summary className="flex cursor-pointer list-none flex-wrap items-baseline gap-x-3 gap-y-1 px-4 py-3 transition-colors hover:bg-paper-100 sm:px-5">
<span className="text-2xs tabular-nums text-ink-400">#{p.id}</span>
<span className="text-sm font-medium text-ink-900">{nameOf(p.match_id)}</span>
<span className="font-serif text-sm font-bold tabular-nums text-ink-900">
{p.pred_home_goals ?? '-'}<span className="mx-0.5 font-normal text-ink-300">:</span>{p.pred_away_goals ?? '-'}
</span>
<span className="text-2xs text-ink-500">
{OUTCOME_LABEL[p.pred_1x2 ?? ''] ?? '—'}
{p.subjective_confidence !== null && p.subjective_confidence !== undefined &&
` · ${Math.round(p.subjective_confidence * 100)}%`}
</span>
<span className="ml-auto flex items-baseline gap-3">
{p.settled ? (
<Badge status="success"></Badge>
) : (
<Badge status="pending"></Badge>
)}
<span className="text-2xs tabular-nums text-ink-400">{fmtTime(p.created_at)}</span>
<svg
viewBox="0 0 20 20"
className="h-3 w-3 self-center text-ink-300 transition-transform group-open:rotate-90"
fill="currentColor"
aria-hidden="true"
>
<path d="M7.3 5.3a1 1 0 011.4 0l4 4a1 1 0 010 1.4l-4 4a1 1 0 01-1.4-1.4L10.6 10 7.3 6.7a1 1 0 010-1.4z" />
</svg>
</span>
</summary>
<div className="space-y-3 px-4 pb-4 pl-8 sm:px-6 sm:pl-9">
<p className="text-2xs text-ink-500">
{p.mode === 'multi' ? `多专家 · ${okAgents.length}/${p.agent_outputs?.length ?? 0} 路有效` : '单次模式'}
{p.model && <span className="ml-2 font-mono">{p.model}</span>}
</p>
{p.reasoning && (
<blockquote className="border-l-2 border-press pl-4">
<p className="whitespace-pre-wrap font-serif text-sm leading-loose text-ink-700">
{p.reasoning}
</p>
</blockquote>
)}
{p.agent_outputs && p.agent_outputs.length > 0 && (
<ul className="space-y-1">
{p.agent_outputs.map((a, i) => (
<li key={i} className="flex items-baseline gap-2.5 text-xs">
<span className={`inline-block h-1.5 w-1.5 flex-shrink-0 self-center ${a.status === 'ok' ? 'bg-ink-900' : 'bg-ink-300'}`} aria-hidden="true" />
<span className="text-ink-800">{AGENT_LABELS[a.agent] ?? a.agent}</span>
{a.probable_score && (
<span className="font-serif font-bold tabular-nums text-ink-800">{a.probable_score}</span>
)}
<span className="text-2xs text-ink-400">
{a.status === 'ok' ? '' : a.status === 'no_data' ? '无数据' : '失败'}
</span>
</li>
))}
</ul>
)}
{p.settled && (
<p className="border-t border-ink-100 pt-2.5 text-2xs text-ink-500">
{p.actual_home_goals ?? '-'} : {p.actual_away_goals ?? '-'}
</p>
)}
</div>
</details>
)
})}
</div>
)}
</CardBody>
</Card>
</div>
)
}
+14 -3
View File
@@ -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
}>
}
+6 -6
View File
@@ -30,16 +30,16 @@ export class ErrorBoundary extends Component<Props, State> {
return this.props.fallback
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 p-6">
<div className="bg-white rounded-lg border border-red-200 p-6 max-w-md text-center space-y-3">
<div className="text-4xl"></div>
<h2 className="text-lg font-semibold text-gray-800"></h2>
<p className="text-sm text-gray-500">
<div className="flex min-h-screen items-center justify-center bg-paper-50 p-6">
<div className="max-w-md space-y-3 border border-ink-900 bg-paper-50 p-6 text-center">
<p className="text-2xs tracking-[0.3em] text-ink-400">EXCEPTION</p>
<h2 className="font-serif text-lg font-bold text-ink-900"></h2>
<p className="text-sm leading-relaxed text-ink-500">
{this.state.error?.message || '未知错误'}
</p>
<button
onClick={() => this.setState({ hasError: false, error: null })}
className="bg-blue-600 text-white text-sm px-4 py-2 rounded hover:bg-blue-700"
className="btn btn-sm"
>
</button>
+50 -59
View File
@@ -3,6 +3,12 @@
@tailwind utilities;
@layer base {
/* 原生 CSS 里引用的令牌:与 tailwind.config 保持同步,避免硬编码色值散落 */
:root {
--c-press: #9E1B1B;
--c-ink-900: #17140F;
}
html {
-webkit-text-size-adjust: 100%;
text-rendering: optimizeLegibility;
@@ -19,7 +25,7 @@
/* 统一焦点环:印报红细线,键盘可达 */
:focus-visible {
outline: 2px solid #9e1b1b;
outline: 2px solid var(--c-press);
outline-offset: 2px;
}
@@ -53,29 +59,61 @@
@layer components {
/* ── 报头双线:粗线在上、细线在下 ── */
.masthead-rule {
border-top: 3px solid #17140f;
border-top: 3px solid var(--c-ink-900);
}
/* ── 按钮:方正边框式,悬停反白 ── */
/* 纸张噪点:feTurbulence 颗粒,给纸面一点纤维感
加在页面根元素上,fixed 全屏覆盖,不响应指针 */
.grain::after {
content: '';
position: fixed;
inset: 0;
z-index: 60;
pointer-events: none;
opacity: 0.04;
mix-blend-mode: multiply;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.8'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
/* 入场:整页一次编排好的升起序列
只动 opacity/transform;错峰由元素上的 animation-delay 内联指定
隐藏态必须与动画同在一个 no-preference 块里,否则 reduce 用户会看到永久空白 */
@media (prefers-reduced-motion: no-preference) {
.rise-in {
opacity: 0;
animation: rise-in 0.48s cubic-bezier(0.16, 1, 0.3, 1) both;
}
}
@keyframes rise-in {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: none;
}
}
/* ── 按钮:方正边框式,悬停反白;热区压到 40px(紧凑报纸行距下的折中) ── */
.btn {
@apply inline-flex items-center justify-center gap-1.5 border border-ink-300 bg-transparent px-3 py-1.5
@apply inline-flex min-h-[40px] items-center justify-center gap-1.5 border border-ink-300 bg-transparent px-3 py-1.5
text-sm text-ink-700 transition-colors duration-150
hover:border-ink-900 hover:bg-ink-900 hover:text-paper-50
disabled:cursor-not-allowed disabled:opacity-40
disabled:hover:border-ink-300 disabled:hover:bg-transparent disabled:hover:text-ink-700;
}
.btn-sm {
@apply px-2.5 py-1 text-xs;
@apply min-h-[32px] px-2.5 py-1 text-xs;
}
.btn-solid {
@apply border-ink-900 bg-ink-900 text-paper-50 hover:border-press hover:bg-press;
}
/* ── 表单控件:方正、无圆角 ── */
/* ── 表单控件:方正、无圆角;焦点态保留全局焦点环,只追加红边 ── */
.field {
@apply border border-ink-300 bg-transparent px-2.5 py-1.5 text-sm text-ink-800
transition-colors hover:border-ink-400
focus:border-press focus:outline-none;
@apply border border-ink-300 bg-transparent px-2.5 py-2 text-sm text-ink-800
transition-colors hover:border-ink-400 focus:border-press;
}
/* ── 版面切换文字标签(联赛/状态/模式) ── */
@@ -101,53 +139,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),
不再单独维护暗色主题 */
+5
View File
@@ -1,6 +1,11 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
// 中文衬线 webfont:按需子集加载(fontsource 按 unicode-range 切片,只拉用到的字块),
// font-display: swap 由包内 CSS 自带,不会白屏
import '@fontsource/noto-serif-sc/400.css'
import '@fontsource/noto-serif-sc/600.css'
import '@fontsource/noto-serif-sc/700.css'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
+33 -28
View File
@@ -176,6 +176,30 @@ function OutcomeLine({
)
}
/** 状态/模式一组的文字切换(模块级组件:定义在组件体内会每次 render 重建,导致焦点丢失) */
function Switch({ value, onChange, items }: {
value: string
onChange: (v: string) => void
items: { v: string; label: string; title?: string }[]
}) {
return (
<span className="inline-flex items-center gap-2.5">
{items.map((it, i) => (
<span key={it.v} className="inline-flex items-center gap-2.5">
{i > 0 && <span className="text-ink-300" aria-hidden="true">/</span>}
<button
onClick={() => onChange(it.v)}
title={it.title}
className={`relative tab ${value === it.v ? 'tab-on' : ''} text-xs`}
>
{it.label}
</button>
</span>
))}
</span>
)
}
export default function Matches() {
const [league, setLeague] = useState('E0')
const [status, setStatus] = useState('scheduled')
@@ -276,28 +300,6 @@ export default function Matches() {
const leagueName = LEAGUES.find(l => l.code === league)?.name ?? league
/** 状态/模式一组的文字切换 */
const Switch = ({ value, onChange, items }: {
value: string
onChange: (v: string) => void
items: { v: string; label: string; title?: string }[]
}) => (
<span className="inline-flex items-center gap-2.5">
{items.map((it, i) => (
<span key={it.v} className="inline-flex items-center gap-2.5">
{i > 0 && <span className="text-ink-300" aria-hidden="true">/</span>}
<button
onClick={() => onChange(it.v)}
title={it.title}
className={`relative tab ${value === it.v ? 'tab-on' : ''} text-xs`}
>
{it.label}
</button>
</span>
))}
</span>
)
return (
<div className="space-y-5">
{/* ── 联赛版面切换 ── */}
@@ -374,7 +376,7 @@ export default function Matches() {
</div>
)}
{!loading && matches.map(m => {
{!loading && matches.map((m, idx) => {
const st = STATUS_META[m.match_status] ?? { label: m.match_status, cls: 'text-ink-400' }
const homeName = m.home_team_zh || m.home_team
const awayName = m.away_team_zh || m.away_team
@@ -384,9 +386,11 @@ export default function Matches() {
return (
<div
key={m.id}
className={`border-b border-ink-200 px-1 py-3 transition-colors hover:bg-paper-100 ${
className={`rise-in border-b border-ink-200 px-1 py-3 transition-colors hover:bg-paper-100 ${
active ? 'bg-press-wash/50' : ''
}`}
// 入场错峰:60ms 步进,封顶 420ms,长列表不拖沓
style={{ animationDelay: `${Math.min(idx * 60, 420)}ms` }}
>
<div className="flex flex-col gap-2 sm:grid sm:grid-cols-[88px_minmax(0,1fr)_64px_minmax(0,1fr)_56px_auto] sm:items-center sm:gap-x-3 sm:gap-y-0">
{/* 日期 + 状态:移动端同行,桌面端日期单独归列 */}
@@ -496,12 +500,13 @@ function PredictionPanel({
const okReports = (prediction.agent_outputs ?? []).filter(r => r.status === 'ok')
return (
<article className="border border-ink-900 bg-paper-50">
// rise-in:预测版面整块入场,内部区块再错峰
<article className="rise-in border border-ink-900 bg-paper-50">
{/* 版头 */}
<div className="flex flex-wrap items-baseline justify-between gap-2 border-b border-ink-900 bg-paper-100 px-4 py-2.5 sm:px-5">
<h3 className="font-serif text-sm font-bold text-ink-900">
<h2 className="font-serif text-sm font-bold text-ink-900">
· {homeName} {awayName}
</h3>
</h2>
<span className="text-2xs tabular-nums text-ink-500">
{prediction.provider} / {prediction.model}
{prediction.latency_ms !== null && ` · ${(prediction.latency_ms / 1000).toFixed(1)}s`}
@@ -557,7 +562,7 @@ function PredictionPanel({
{/* ── 终裁意见:引文式,红竖线 ── */}
{prediction.reasoning && (
<section>
<h4 className="section-head mb-3"></h4>
<h3 className="section-head mb-3"></h3>
<blockquote className="border-l-2 border-press pl-4">
<p className="whitespace-pre-wrap font-serif text-sm leading-loose text-ink-700">
{prediction.reasoning}
+5 -1
View File
@@ -12,12 +12,13 @@ export default {
300: '#DDD7C7',
},
// 墨色:暖黑灰阶,替代冷调 slate
// 400 档压在 4.5:1 线上(paper-100 底 4.55:1 / paper-50 底 4.87:1),可作正文级小字
ink: {
50: '#FAF9F7',
100: '#F0EEE9',
200: '#E2DFD7',
300: '#C9C4B8',
400: '#9C9587',
400: '#756F61',
500: '#6E675B',
600: '#524C42',
700: '#3B362E',
@@ -33,8 +34,11 @@ export default {
},
fontFamily: {
// 标题与比分:宋体血统,报纸版面的骨架
// 拉丁字形走 Georgia(含真斜体,供 Profeto 刊名),中文走 Noto Serif SC webfont;
// 原栈在 Windows 上会跌进 SimSun(点阵感),webfont 兜底解决跨端不一致
serif: [
'Georgia',
'"Noto Serif SC"',
'"Times New Roman"',
'"Songti SC"',
'STSong',