feat: 核心模块增强 — 加密 + 运行时配置 + 日志缓冲

- crypto.py: API Key 加密/解密工具
- runtime_config.py: 运行时动态配置管理
- log_buffer.py: 内存日志缓冲区
- config.py: 新增加密配置项
- http_client.py: 增强重试和错误处理
This commit is contained in:
shangfangjian
2026-09-19 11:58:03 +08:00
parent b3e2c52b49
commit 786f10aa11
57 changed files with 3178 additions and 488 deletions
+4
View File
@@ -10,6 +10,10 @@ server {
location /api/ {
proxy_pass http://api:8000/api/;
proxy_http_version 1.1;
# LLM 多专家预测/回测耗时长(可达数分钟),默认 60s 会掐断请求返回 504
proxy_connect_timeout 10s;
proxy_send_timeout 60s;
proxy_read_timeout 300s;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
+45
View File
@@ -7,7 +7,9 @@
import { useState, useEffect, useCallback } from 'react'
import { NavLink, Outlet, useLocation } from 'react-router-dom'
import { fetchAuthState, logout, UNAUTHORIZED_EVENT } from './api'
import { fetchHealth } from './dal'
import Login from './Login'
const NAV_ITEMS = [
{ to: '/admin', label: '仪表盘', icon: '◇', end: true },
@@ -18,6 +20,7 @@ const NAV_ITEMS = [
{ to: '/admin/data-sources', label: '数据源', icon: '◫' },
{ to: '/admin/llm-config', label: 'LLM 配置', icon: '◬' },
{ to: '/admin/config', label: '系统配置', icon: '◑' },
{ to: '/admin/logs', label: '系统日志', icon: '▤' },
]
/** 报眉日期行,与前台同款式 */
@@ -33,8 +36,32 @@ function dateLine(): string {
export default function AdminLayout() {
const [sidebarOpen, setSidebarOpen] = useState(false)
const [healthOk, setHealthOk] = useState<boolean | null>(null)
const [authed, setAuthed] = useState<boolean | null>(null)
const location = useLocation()
// 登录门禁:挂载时探测会话,收到 401 事件(会话过期)自动切回登录页
useEffect(() => {
let alive = true
fetchAuthState()
.then(s => alive && setAuthed(s.authenticated))
.catch(() => alive && setAuthed(false))
const onUnauthorized = () => setAuthed(false)
window.addEventListener(UNAUTHORIZED_EVENT, onUnauthorized)
return () => {
alive = false
window.removeEventListener(UNAUTHORIZED_EVENT, onUnauthorized)
}
}, [])
const handleLogout = useCallback(async () => {
try {
await logout()
} catch {
/* 会话可能已失效,直接切回登录页 */
}
setAuthed(false)
}, [])
const checkHealth = useCallback(async () => {
try {
const h = await fetchHealth()
@@ -65,6 +92,18 @@ export default function AdminLayout() {
return () => document.removeEventListener('keydown', handler)
}, [])
// 登录门禁:未登录只渲染登录页,不泄露后台任何内容
if (authed === null) {
return (
<div className="flex h-screen items-center justify-center bg-paper-50 text-xs text-ink-400">
</div>
)
}
if (!authed) {
return <Login onSuccess={() => setAuthed(true)} />
}
return (
<div className="flex h-screen overflow-hidden bg-paper-50 text-ink-800">
{/* ── 移动端遮罩层 ── */}
@@ -177,6 +216,12 @@ export default function AdminLayout() {
>
</a>
<button
onClick={handleLogout}
className="text-ink-500 transition-colors hover:text-press"
>
</button>
</div>
</header>
+201
View File
@@ -0,0 +1,201 @@
/**
* Admin 后台 - 专家与终裁独立 LLM 配置卡片
*
* 每个角色(5 专家 + 终裁)可独立覆盖 模型 / 接口地址 / API Key;
* 留空字段不改动,「恢复继承」删除该角色全部覆盖。
*/
import { useCallback, useEffect, useState } from 'react'
import { fetchLLMAgents, updateSetting, clearSetting } from './dal'
import type { LLMAgentConfig } from './types'
import { Card, CardBody, CardHeader, Badge, Alert, Spinner, SkeletonBlock } from './components'
type FieldKey = 'model' | 'base_url' | 'api_key'
const FIELD_META: { key: FieldKey; label: string; sensitive: boolean; hint: string }[] = [
{ key: 'model', label: '模型', sensitive: false, hint: '留空保持现状;未覆盖时继承默认' },
{ key: 'base_url', label: '接口地址', sensitive: false, hint: '留空保持现状;未覆盖时继承全局' },
{ key: 'api_key', label: 'API Key', sensitive: true, hint: '留空保持现状;未覆盖时继承全局' },
]
const KEY_BY_FIELD: Record<FieldKey, (id: string) => string> = {
model: id => `AGENT_${id.toUpperCase()}_MODEL`,
base_url: id => `AGENT_${id.toUpperCase()}_BASE_URL`,
api_key: id => `AGENT_${id.toUpperCase()}_API_KEY`,
}
export default function AgentLLMCard() {
const [agents, setAgents] = useState<LLMAgentConfig[]>([])
const [loading, setLoading] = useState(true)
const [expandedId, setExpandedId] = useState<string | null>(null)
const [form, setForm] = useState<Record<FieldKey, string>>({ model: '', base_url: '', api_key: '' })
const [busy, setBusy] = useState(false)
const [notice, setNotice] = useState<{ ok: boolean; text: string } | null>(null)
const load = useCallback(async () => {
setLoading(true)
try {
setAgents(await fetchLLMAgents())
} catch {
setAgents([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
load()
}, [load])
function toggleExpand(agent: LLMAgentConfig) {
if (expandedId === agent.id) {
setExpandedId(null)
return
}
setExpandedId(agent.id)
setNotice(null)
// 预填非敏感覆盖值;API Key 不回填
setForm({
model: agent.fields.model.origin === 'db' ? agent.fields.model.masked : '',
base_url: agent.fields.base_url.origin === 'db' ? agent.fields.base_url.masked : '',
api_key: '',
})
}
async function handleSave(agent: LLMAgentConfig) {
setBusy(true)
setNotice(null)
try {
const nonEmpty = (FIELD_META.filter(f => form[f.key].trim())).map(f => f)
if (nonEmpty.length === 0) {
setNotice({ ok: false, text: '没有需要保存的修改(全部为空)' })
return
}
for (const f of nonEmpty) {
await updateSetting(KEY_BY_FIELD[f.key](agent.id), form[f.key].trim())
}
setNotice({ ok: true, text: `${agent.label} 配置已保存,立即生效` })
setExpandedId(null)
await load()
} catch (err) {
setNotice({ ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '保存失败' })
} finally {
setBusy(false)
}
}
async function handleReset(agent: LLMAgentConfig) {
setBusy(true)
setNotice(null)
try {
for (const f of FIELD_META) {
await clearSetting(KEY_BY_FIELD[f.key](agent.id))
}
setNotice({ ok: true, text: `${agent.label} 已恢复继承默认` })
setExpandedId(null)
await load()
} catch (err) {
setNotice({ ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '恢复失败' })
} finally {
setBusy(false)
}
}
const hasOverride = (agent: LLMAgentConfig) =>
Object.values(agent.fields).some(f => f.origin === 'db')
return (
<Card>
<CardHeader
title="专家与终裁 LLM 配置"
description="可为每个角色单独指定模型、接口地址或 API Key;未覆盖的角色按「专家层/终裁层默认 → 全局」继承"
action={
<button onClick={load} disabled={loading} className="btn btn-sm">
{loading ? (<><Spinner /> </>) : '刷新'}
</button>
}
/>
<CardBody className="px-0 sm:px-0">
{loading ? (
<div className="space-y-2 px-4 sm:px-5">
{[1, 2, 3, 4, 5, 6].map(i => <SkeletonBlock key={i} className="h-9 w-full" />)}
</div>
) : agents.length === 0 ? (
<p className="py-6 text-center text-xs text-ink-400"></p>
) : (
<>
{notice && (
<div className="px-4 pb-2 sm:px-5">
<Alert kind={notice.ok ? 'ok' : 'error'} title={notice.text} />
</div>
)}
{agents.map(agent => {
const expanded = expandedId === agent.id
return (
<div key={agent.id} className="border-b border-ink-200 last:border-b-0">
{/* 行:角色名 + 生效模型 + 配置按钮 */}
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1 px-4 py-2.5 sm:px-5">
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
<span className="font-serif text-sm font-bold text-ink-900">{agent.label}</span>
{hasOverride(agent) && <Badge status="success"></Badge>}
</div>
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-mono text-2xs text-ink-500">{agent.effective_model}</span>
<button onClick={() => toggleExpand(agent)} disabled={busy} className="btn btn-sm flex-shrink-0">
{expanded ? '收起' : '配置'}
</button>
</div>
</div>
{/* 展开的编辑表单 */}
{expanded && (
<div className="space-y-3 border-t border-ink-200 bg-paper-100/40 px-4 py-3 sm:px-5">
{FIELD_META.map(f => {
const state = agent.fields[f.key]
return (
<div key={f.key} className="grid gap-1 sm:grid-cols-[96px_minmax(0,1fr)] sm:items-center sm:gap-3">
<label className="text-xs text-ink-500">{f.label}</label>
<div>
<input
type={f.sensitive ? 'password' : 'text'}
value={form[f.key]}
onChange={e => setForm(prev => ({ ...prev, [f.key]: e.target.value }))}
placeholder={
f.key === 'api_key' && state.origin === 'db'
? `已覆盖(${state.masked}),留空保持不变`
: f.hint
}
autoComplete="off"
className="field w-full"
/>
</div>
</div>
)
})}
<div className="flex items-center justify-between gap-2 pt-1">
<p className="text-2xs text-ink-400">
:{agent.effective_model}
</p>
<div className="flex gap-2">
{hasOverride(agent) && (
<button onClick={() => handleReset(agent)} disabled={busy} className="btn btn-sm">
</button>
)}
<button onClick={() => handleSave(agent)} disabled={busy} className="btn btn-solid btn-sm">
{busy ? (<><Spinner /> </>) : '保存'}
</button>
</div>
</div>
</div>
)}
</div>
)
})}
</>
)}
</CardBody>
</Card>
)
}
+96
View File
@@ -0,0 +1,96 @@
/**
* Admin 后台 - 登录页(报刊风)
*
* 密码验证通过后由服务端写入 HttpOnly 会话 Cookie。
*/
import { useState } from 'react'
import { ApiError, login } from './api'
export default function Login({ onSuccess }: { onSuccess: () => void }) {
const [password, setPassword] = useState('')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState('')
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!password || submitting) return
setSubmitting(true)
setError('')
try {
await login(password)
onSuccess()
} catch (err) {
setError(
err instanceof ApiError
? err.message.split('\n')[0]
: '登录失败,请检查网络连接',
)
} finally {
setSubmitting(false)
}
}
return (
<div className="flex min-h-screen flex-col bg-paper-50 text-ink-800">
<header className="masthead-rule">
<div className="mx-auto w-full max-w-md px-5 pt-12 sm:pt-16">
<div className="border-b border-ink-900 py-5 text-center">
<h1 className="font-serif text-3xl font-bold tracking-widest text-ink-900">
<span className="ml-3 align-baseline font-serif text-sm font-normal italic tracking-normal text-ink-500">
Profeto
</span>
</h1>
<p className="mt-2 text-2xs tracking-[0.4em] text-ink-500"> · </p>
</div>
</div>
</header>
<main className="flex flex-1 items-start justify-center px-5 py-10">
<form
onSubmit={handleSubmit}
className="w-full max-w-sm border border-ink-300 bg-white p-6 shadow-[4px_4px_0_0_rgba(0,0,0,0.06)]"
>
<label htmlFor="admin-password" className="block text-xs font-medium tracking-wide text-ink-700">
</label>
<input
id="admin-password"
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
placeholder="输入服务器 .env 中的 ADMIN_PASSWORD"
autoFocus
autoComplete="current-password"
className="field mt-2 w-full"
/>
{error && (
<p className="mt-3 border-l-2 border-press bg-press-wash/40 px-3 py-2 text-2xs leading-relaxed text-press-dark">
{error}
</p>
)}
<button
type="submit"
disabled={!password || submitting}
className="btn btn-solid mt-5 w-full justify-center"
>
{submitting ? '验证中…' : '登 录'}
</button>
<p className="mt-4 border-t border-ink-200 pt-3 text-center text-2xs leading-relaxed text-ink-400">
.env,; 5 10
</p>
</form>
</main>
<footer className="pb-8 text-center text-2xs text-ink-400">
<a href="/" className="transition-colors hover:text-press">
</a>
</footer>
</div>
)
}
+3 -3
View File
@@ -26,7 +26,7 @@ src/admin/
├── Monitoring.tsx # 监控面板(存活 + 数据库就绪,30s 自动巡检)
├── DataSources.tsx # 数据源管理(数据源配置与测试)
├── LLMConfig.tsx # LLM 配置(模型连接与统计)
└── Config.tsx # 系统配置(管理员密钥 + .env 查看与修改指南)
└── Config.tsx # 系统配置(登录鉴权说明 + .env 查看与修改指南)
```
## 页面说明
@@ -69,8 +69,8 @@ src/admin/
- 可用模型列表
### 8. 系统配置 (`/admin/config`)
- **管理员密钥管理**: 保存 X-API-Key 到本机 localStorage,之后所有请求自动附带;
后端配置了 ADMIN_API_KEY 时,采集 / 回测 / 结算接口依赖此密钥
- **登录与鉴权**: 后台由密码登录保护(服务器 .env 的 ADMIN_PASSWORD),
会话以 HttpOnly Cookie 保存;脚本直连接口可使用 ADMIN_API_KEY(X-API-Key 请求头)
- 配置列表: 脱敏显示 .env 配置项
- 配置修改指南: SSH 修改 .env + 重启服务
+160
View File
@@ -0,0 +1,160 @@
/**
* Admin 后台 - 配置项行组件(报刊风)
*
* 展示态:键名 + 来源徽标(数据库覆盖 / .env 默认 / 未配置) + 脱敏值 + 操作按钮
* 编辑态:输入框 + 保存/取消
* 由数据源页与 LLM 配置页共用。
*/
import { useEffect, useState } from 'react'
import type { DataSourceSetting } from './types'
import { Badge, Spinner } from './components'
export const ORIGIN_BADGE: Record<DataSourceSetting['origin'], { text: string; status: 'success' | 'info' | 'error' }> = {
db: { text: '数据库覆盖', status: 'success' },
env: { text: '.env 默认', status: 'info' },
none: { text: '未配置', status: 'error' },
}
export default function SettingRow({
setting,
editing,
busy,
onEdit,
onCancel,
onSave,
onClear,
detectModels,
}: {
setting: DataSourceSetting
editing: boolean
busy: boolean
onEdit: () => void
onCancel: () => void
onSave: (value: string) => void
onClear: () => void
/** 可选:编辑态提供「检测可用模型」能力(如 LLM_MODEL 行) */
detectModels?: () => Promise<string[]>
}) {
const [value, setValue] = useState('')
const origin = ORIGIN_BADGE[setting.origin]
// 行内模型检测
const [detecting, setDetecting] = useState(false)
const [detected, setDetected] = useState<string[] | null>(null)
const [detectError, setDetectError] = useState('')
// 进入编辑态时清空上次的检测结果
useEffect(() => {
if (editing) {
setDetected(null)
setDetectError('')
}
}, [editing])
async function handleDetect() {
if (!detectModels || detecting) return
setDetecting(true)
setDetectError('')
try {
setDetected(await detectModels())
} catch (err) {
setDetected(null)
setDetectError(err instanceof Error ? err.message : '检测失败')
} finally {
setDetecting(false)
}
}
if (editing) {
return (
<div className="border-b border-ink-200 py-2.5 last:border-b-0">
<div className="mb-1.5 flex flex-wrap items-center gap-1.5 text-2xs text-ink-500">
<span className="break-all font-mono text-ink-800">{setting.key}</span>
{setting.sensitive && <Badge status="warning"></Badge>}
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<input
type={setting.sensitive ? 'password' : 'text'}
value={value}
onChange={e => setValue(e.target.value)}
placeholder={`输入新的 ${setting.label}`}
autoFocus
autoComplete="off"
className="field flex-1"
/>
<div className="flex gap-2">
<button
onClick={() => onSave(value)}
disabled={!value.trim() || busy}
className="btn btn-solid btn-sm"
>
{busy ? (<><Spinner /> </>) : '保存'}
</button>
<button onClick={onCancel} disabled={busy} className="btn btn-sm">
</button>
</div>
</div>
{detectModels && (
<div className="mt-2 space-y-2">
<button onClick={handleDetect} disabled={detecting} className="btn btn-sm">
{detecting ? (<><Spinner /> </>) : detected ? '重新检测' : '检测可用模型'}
</button>
{detectError && (
<p className="border-l-2 border-press bg-press-wash/40 px-3 py-1.5 text-2xs leading-relaxed text-press-dark">
{detectError}
</p>
)}
{detected && detected.length > 0 && (
<div className="max-h-48 overflow-y-auto border border-ink-200">
{detected.map(id => (
<button
key={id}
type="button"
onClick={() => setValue(id)}
className={`flex w-full items-center justify-between gap-3 border-b border-ink-200 px-3 py-1.5 text-left last:border-b-0 hover:bg-paper-100 ${
value === id ? 'bg-press-wash/50' : ''
}`}
>
<span className="min-w-0 break-all font-mono text-2xs text-ink-800">{id}</span>
{value === id && <Badge status="success"></Badge>}
</button>
))}
</div>
)}
{detected && detected.length === 0 && !detectError && (
<p className="text-2xs text-ink-400"></p>
)}
</div>
)}
</div>
)
}
return (
<div className="space-y-1.5 border-b border-ink-200 py-2.5 last:border-b-0">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="break-all font-mono text-2xs text-ink-800">{setting.key}</span>
<Badge status={origin.status}>{origin.text}</Badge>
</div>
<div className="break-all font-mono text-2xs leading-relaxed text-ink-500">
{setting.configured ? setting.masked : '—'}
</div>
<div className="flex justify-end gap-2">
<button onClick={onEdit} disabled={busy} className="btn btn-sm">
{setting.configured ? '更换' : '配置'}
</button>
{setting.origin === 'db' && (
<button onClick={onClear} disabled={busy} className="btn btn-sm" title="删除数据库覆盖值,回落 .env">
.env
</button>
)}
</div>
</div>
)
}
+48 -33
View File
@@ -1,33 +1,16 @@
/**
* Admin 后台管理系统 - 统一 API 客户端
*
* 写入型/高成本接口(采集、回测、结算)受 X-API-Key 保护:
* 密钥在「系统配置」页设置,存于本机 localStorage,每次请求自动附带
* 鉴权:通过 POST /api/v1/auth/login 用密码换取 HttpOnly Cookie 会话,
* 同源请求自动携带 Cookie,无需手动管理密钥
* 收到 401 时广播 `profeto:unauthorized` 事件,由 AdminLayout 切回登录页。
*/
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 {
/* 隐私模式等场景下不可用,静默忽略 */
}
}
/** 会话失效事件名,AdminLayout 监听后弹出登录页 */
export const UNAUTHORIZED_EVENT = 'profeto:unauthorized'
export class ApiError extends Error {
constructor(
@@ -40,7 +23,10 @@ export class ApiError extends Error {
}
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
async function request<T>(
path: string,
options: RequestInit & { timeoutMs?: number } = {},
): Promise<T> {
// 修复: 正确拼接 API_BASE
const url = path.startsWith('http')
? path
@@ -48,18 +34,17 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
? path // 已经是绝对路径(如 /health)
: `${API_BASE}${path}`
const { timeoutMs = TIMEOUT_MS, ...fetchOptions } = options
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS)
const timer = setTimeout(() => controller.abort(), timeoutMs)
try {
const adminKey = getAdminKey()
const res = await fetch(url, {
...options,
...fetchOptions,
signal: controller.signal,
headers: {
'Content-Type': 'application/json',
...(adminKey ? { 'X-API-Key': adminKey } : {}),
...options.headers,
...fetchOptions.headers,
},
})
@@ -75,7 +60,8 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
? String((detail as { detail: unknown }).detail)
: `HTTP ${res.status}: ${res.statusText}`
if (res.status === 401) {
message += '\n请在「系统配置」页填写管理员密钥后重试。'
message += '\n登录已过期,请重新登录。'
window.dispatchEvent(new CustomEvent(UNAUTHORIZED_EVENT))
}
throw new ApiError(message, res.status, detail)
}
@@ -102,11 +88,40 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body?: unknown) =>
request<T>(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined }),
put: <T>(path: string, body?: unknown) =>
request<T>(path, { method: 'PUT', body: body ? JSON.stringify(body) : undefined }),
post: <T>(path: string, body?: unknown, opts?: { timeoutMs?: number }) =>
request<T>(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined, ...opts }),
put: <T>(path: string, body?: unknown, opts?: { timeoutMs?: number }) =>
request<T>(path, { method: 'PUT', body: body ? JSON.stringify(body) : undefined, ...opts }),
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
}
// ── 认证 ────────────────────────────────────────────────────────
/** 密码登录,成功后服务端写入 HttpOnly 会话 Cookie */
export function login(password: string): Promise<{ ok: boolean }> {
return api.post(`${API_BASE}/auth/login`, { password })
}
/** 退出登录,清除会话 Cookie */
export function logout(): Promise<{ ok: boolean }> {
return api.post(`${API_BASE}/auth/logout`)
}
/** 探测当前登录状态 */
export function fetchAuthState(): Promise<{
authenticated: boolean
enabled: boolean
password_origin?: 'db' | 'env' | 'none'
}> {
return api.get(`${API_BASE}/auth/me`)
}
/** 修改管理员密码(成功后所有会话失效,需重新登录) */
export function changePassword(currentPassword: string, newPassword: string): Promise<{ ok: boolean; message: string }> {
return api.post(`${API_BASE}/auth/change-password`, {
current_password: currentPassword,
new_password: newPassword,
})
}
export { API_BASE }
+78 -29
View File
@@ -15,6 +15,11 @@ import type {
Match,
Prediction,
EvalSummary,
DataSourceStatus,
DataSourceSetting,
DataSourceTestResult,
LLMAgentConfig,
LogEntry,
} from './types'
// ── 仪表盘 ──────────────────────────────────────────────────────
@@ -53,7 +58,7 @@ export async function triggerCollection(req: CollectionRequest): Promise<any> {
leagues: req.leagues,
date_from: req.date_from,
date_to: req.date_to,
status: 'finished',
status: req.status || undefined, // 空 = 已完赛 + 未开赛都采集
},
},
understat: {
@@ -78,10 +83,14 @@ export async function triggerCollection(req: CollectionRequest): Promise<any> {
// ── 预测管理 ────────────────────────────────────────────────────
export async function triggerPrediction(req: { match_id: number; mode?: string }): Promise<any> {
return api.post(`${API_BASE}/predict`, {
match_id: req.match_id,
mode: req.mode || 'multi',
})
return api.post(
`${API_BASE}/predict`,
{
match_id: req.match_id,
mode: req.mode || 'multi',
},
{ timeoutMs: 300_000 },
)
}
export async function fetchPredictions(limit = 50): Promise<any[]> {
@@ -100,7 +109,7 @@ export async function fetchEvalSummary(): Promise<EvalSummary | null> {
}
export async function triggerBacktest(req: BacktestRequest): Promise<BacktestSummary> {
return api.post<BacktestSummary>(`${API_BASE}/backtest`, req)
return api.post<BacktestSummary>(`${API_BASE}/backtest`, req, { timeoutMs: 300_000 })
}
// ── 辅助数据 ────────────────────────────────────────────────────
@@ -153,29 +162,63 @@ export async function fetchHealth(): Promise<any> {
// ── 数据源管理 ──────────────────────────────────────────────────
/**
* 测试数据源连调用采集 API 验证连通性
* 测试数据源连通性后端真实请求上游一次,不触发入库
*/
export async function testDataSource(source: 'bzzoiro' | 'understat' | 'injuries'): Promise<any> {
const sourceMap: Record<string, { path: string; body: any }> = {
bzzoiro: { path: `${API_BASE}/ingest/bzzoiro`, body: { leagues: [], date_from: '', date_to: '', status: 'finished' } },
understat: { path: `${API_BASE}/ingest/understat`, body: { league: 'EPL', season: new Date().getFullYear() } },
injuries: { path: `${API_BASE}/ingest/injuries`, body: { date: new Date().toISOString().slice(0, 10) } },
}
const cfg = sourceMap[source]
if (!cfg) throw new Error(`未知数据源: ${source}`)
return api.post(cfg.path, cfg.body)
export function testDataSourceConnection(name: string): Promise<DataSourceTestResult> {
return api.post<DataSourceTestResult>(`${API_BASE}/admin/datasources/${name}/test`)
}
/**
* 获取数据源状态 — 后端暂无专用端点,返回模拟状态
* 获取数据源状态与配置(脱敏)
*/
export async function fetchDataSourceStatuses(): Promise<any[]> {
// 后端暂无专用配置端点,返回静态信息
return [
{ name: 'bzzoiro', label: 'Bzzoiro', keyConfigured: true, maskedKey: 'bz***xxx', lastIngestion: null, status: 'configured' },
{ name: 'understat', label: 'Understat', keyConfigured: true, maskedKey: '无需 Key', lastIngestion: null, status: 'configured' },
{ name: 'injuries', label: 'Injuries', keyConfigured: true, maskedKey: 'inj***xxx', lastIngestion: null, status: 'configured' },
]
export function fetchDataSourceStatuses(): Promise<DataSourceStatus[]> {
return api.get<DataSourceStatus[]>(`${API_BASE}/admin/datasources`)
}
/**
* 探测当前 LLM 服务可用模型(只读,不产生费用)
*/
export function fetchLLMModels(): Promise<{ ok: boolean; models: string[]; latency_ms?: number; detail: string }> {
return api.get(`${API_BASE}/admin/llm/models`)
}
/**
* 各专家/终裁的独立 LLM 配置状态
*/
export function fetchLLMAgents(): Promise<LLMAgentConfig[]> {
return api.get<LLMAgentConfig[]>(`${API_BASE}/admin/llm/agents`)
}
/**
* 查询系统日志(内存缓冲,最新在前)
*/
export function fetchLogs(params: { level?: string; keyword?: string; limit?: number } = {}): Promise<{ entries: LogEntry[]; count: number }> {
const sp = new URLSearchParams()
if (params.level) sp.set('level', params.level)
if (params.keyword) sp.set('keyword', params.keyword)
if (params.limit) sp.set('limit', String(params.limit))
return api.get<{ entries: LogEntry[]; count: number }>(`${API_BASE}/admin/logs?${sp}`)
}
/**
* 全部可配置项(脱敏),供各配置页渲染
*/
export function fetchSettings(): Promise<DataSourceSetting[]> {
return api.get<DataSourceSetting[]>(`${API_BASE}/admin/settings`)
}
/**
* 更新配置项(写入 app_settings,覆盖 .env,立即生效)
*/
export function updateSetting(key: string, value: string) {
return api.put<{ key: string; masked: string; origin: string }>(`${API_BASE}/admin/settings/${key}`, { value })
}
/**
* 清除配置项的 DB 覆盖值,回落 .env
*/
export function clearSetting(key: string) {
return api.delete<{ key: string; masked: string; origin: string }>(`${API_BASE}/admin/settings/${key}`)
}
// ── LLM 配置 ────────────────────────────────────────────────────
@@ -184,10 +227,14 @@ export async function fetchDataSourceStatuses(): Promise<any[]> {
* 测试 LLM 连接 — 调用预测端点验证
*/
export async function testLLMConnection(matchId?: number): Promise<any> {
return api.post(`${API_BASE}/predict`, {
match_id: matchId || 1,
mode: 'single',
})
return api.post(
`${API_BASE}/predict`,
{
match_id: matchId || 1,
mode: 'single',
},
{ timeoutMs: 300_000 },
)
}
/**
@@ -231,7 +278,9 @@ export async function fetchSystemConfig(): Promise<any[]> {
{ key: 'LLM_MODEL', value_masked: 'gpt-4o', description: 'LLM 模型', is_sensitive: false },
{ key: 'LLM_BASE_URL', value_masked: 'https://api.openai.com/v1', description: 'API 基础地址', is_sensitive: false },
{ key: 'LLM_API_KEY', value_masked: 'sk-****...****', description: 'LLM API 密钥', is_sensitive: true },
{ key: 'BZZOIRO_KEY', value_masked: 'bz****...****', description: 'Bzzoiro 数据源密钥', is_sensitive: true },
{ key: 'ADMIN_PASSWORD', value_masked: '••••••(已配置)', description: '管理后台登录密码', is_sensitive: true },
{ key: 'ADMIN_API_KEY', value_masked: '未配置时脚本调用不可用', description: '接口鉴权密钥 (X-API-Key)', is_sensitive: true },
{ key: 'BZZOIRO_KEY', value_masked: 'bz****...****', description: 'Bzzoiro 数据源密钥(可在「数据源」页在线配置)', is_sensitive: true },
{ key: 'DATABASE_URL', value_masked: 'postgresql://****@localhost/profeto', description: '数据库连接', is_sensitive: true },
{ key: 'LOG_LEVEL', value_masked: 'INFO', description: '日志级别', is_sensitive: false },
]
+9 -2
View File
@@ -14,12 +14,15 @@ 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'
import TeamSideTag from '../../components/TeamSideTag'
interface BacktestResultRow {
match_id: number
league_code?: string | null
home_team: string
away_team: string
home_team_zh?: string | null
away_team_zh?: string | null
match_date?: string | null
actual_score: string
actual_1x2?: string
@@ -279,8 +282,12 @@ export default function BacktestPage() {
<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 className="flex min-w-0 flex-1 items-center gap-1.5 text-sm text-ink-800">
<TeamSideTag side="home" />
<span className="truncate">{r.home_team_zh || r.home_team}</span>
<span className="flex-shrink-0 text-ink-300">vs</span>
<TeamSideTag side="away" />
<span className="truncate">{r.away_team_zh || r.away_team}</span>
</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>
+25 -4
View File
@@ -47,6 +47,7 @@ export default function CollectionPage() {
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [season, setSeason] = useState('')
const [ingestStatus, setIngestStatus] = useState('') // 空 = 已完赛+未开赛
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [result, setResult] = useState<{ title: string; detail: string } | null>(null)
@@ -70,11 +71,15 @@ export default function CollectionPage() {
leagues: leagueCode ? [leagueCode] : undefined,
league: leagueCode || undefined,
season: season || undefined,
status: ingestStatus || undefined,
date_from: dateFrom || undefined,
date_to: dateTo || undefined,
}
const res = await triggerCollection(body)
setResult(summarizeResult(res, source))
await triggerCollection(body)
setResult({
title: '采集任务已启动',
detail: '正在后台执行(上游限速时可能需要几分钟)。完成结果与错误请到「系统日志」页查看(支持自动刷新)。',
})
} catch (err: unknown) {
setError(err instanceof Error ? err.message : '采集触发失败')
} finally {
@@ -126,6 +131,22 @@ export default function CollectionPage() {
</select>
</div>
{/* Bzzoiro 专用: 比赛状态 */}
{source === 'bzzoiro' && (
<div>
<label className="mb-1.5 block text-xs text-ink-500"></label>
<select
value={ingestStatus}
onChange={e => setIngestStatus(e.target.value)}
className="field w-full"
>
<option value="">( + )</option>
<option value="finished"></option>
<option value="scheduled"></option>
</select>
</div>
)}
{/* Understat 专用: 赛季 */}
{source === 'understat' && (
<div>
@@ -199,8 +220,8 @@ export default function CollectionPage() {
</div>
<p className="mt-4 border-l-2 border-ink-300 pl-3 text-2xs leading-relaxed text-ink-500">
ADMIN_API_KEY,
401
401 ,
</p>
</CardBody>
</Card>
+101 -57
View File
@@ -2,24 +2,27 @@
* Admin 后台 - 系统配置管理页面(报刊风)
*
* 功能:
* - 管理员密钥(X-API-Key):存本机浏览器,自动附带到采集/回测/结算等受保护接口
* - 登录与鉴权说明(ADMIN_PASSWORD,HttpOnly 会话 Cookie)
* - 显示当前 .env 配置(脱敏;后端暂无配置端点,为静态说明)
* - 配置修改指南
*/
import { useEffect, useState, useCallback } from 'react'
import { fetchSystemConfig } from '../dal'
import { getAdminKey, setAdminKey } from '../api'
import { changePassword, fetchAuthState, UNAUTHORIZED_EVENT } 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 [passwordOrigin, setPasswordOrigin] = useState<'db' | 'env' | 'none' | null>(null)
// 管理员密钥
const [adminKey, setAdminKeyInput] = useState('')
const [keySaved, setKeySaved] = useState(false)
const [keyExists, setKeyExists] = useState(false)
// 修改密码表单
const [currentPwd, setCurrentPwd] = useState('')
const [newPwd, setNewPwd] = useState('')
const [confirmPwd, setConfirmPwd] = useState('')
const [pwdBusy, setPwdBusy] = useState(false)
const [pwdNotice, setPwdNotice] = useState<{ ok: boolean; text: string } | null>(null)
const loadConfig = useCallback(async () => {
setLoading(true)
@@ -35,75 +38,115 @@ export default function ConfigPage() {
useEffect(() => {
loadConfig()
const stored = getAdminKey()
setKeyExists(stored !== '')
fetchAuthState()
.then(s => setPasswordOrigin(s.password_origin ?? null))
.catch(() => setPasswordOrigin(null))
}, [loadConfig])
function handleSaveKey(e: React.FormEvent) {
async function handleChangePassword(e: React.FormEvent) {
e.preventDefault()
setAdminKey(adminKey.trim())
setKeyExists(adminKey.trim() !== '')
setKeySaved(true)
setAdminKeyInput('')
setTimeout(() => setKeySaved(false), 3000)
}
function handleClearKey() {
setAdminKey('')
setAdminKeyInput('')
setKeyExists(false)
setPwdNotice(null)
if (newPwd !== confirmPwd) {
setPwdNotice({ ok: false, text: '两次输入的新密码不一致' })
return
}
setPwdBusy(true)
try {
const res = await changePassword(currentPwd, newPwd)
setPwdNotice({ ok: true, text: res.message })
// 密码即会话密钥,修改后所有会话失效:主动切回登录页
setTimeout(() => window.dispatchEvent(new CustomEvent(UNAUTHORIZED_EVENT)), 1500)
} catch (err) {
setPwdNotice({ ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '修改失败' })
} finally {
setPwdBusy(false)
}
}
return (
<div className="space-y-6">
<SectionHeader
title="系统配置"
description="管理员密钥管理与系统参数查看。敏感配置一律通过服务器 .env 文件管理。"
description="登录鉴权说明与系统参数查看。敏感配置一律通过服务器 .env 文件管理。"
/>
{/* 管理员密钥 */}
{/* 登录与鉴权 */}
<Card>
<CardHeader
title="管理员密钥 (X-API-Key)"
description="后端设置 ADMIN_API_KEY 后,采集 / 回测 / 结算等接口需要此密钥"
title="登录与鉴权"
description="本后台通过密码登录保护,会话以 HttpOnly Cookie 保存,有效期默认 7 天"
/>
<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>
<Alert kind="ok" title="已通过密码登录" />
<p className="mt-3 text-2xs leading-relaxed text-ink-500">
<code className="font-mono">.env</code> {' '}
<code className="font-mono">ADMIN_PASSWORD</code>(),{' '}
<code className="font-mono">scrypt</code> ,
,,
<code className="font-mono">ADMIN_API_KEY</code>( X-API-Key)
</p>
{passwordOrigin && (
<p className="mt-2 flex items-center gap-2 text-2xs text-ink-500">
:
{passwordOrigin === 'db' ? (
<Badge status="success">(scrypt )</Badge>
) : passwordOrigin === 'env' ? (
<Badge status="info">.env </Badge>
) : (
<Badge status="error"></Badge>
)}
</p>
)}
{/* 修改密码表单 */}
<form onSubmit={handleChangePassword} className="mt-5 space-y-3 border-t border-ink-200 pt-4">
<div className="grid gap-3 sm:grid-cols-3">
<div>
<label className="mb-1 block text-2xs text-ink-500"></label>
<input
type="password"
value={currentPwd}
onChange={e => setCurrentPwd(e.target.value)}
autoComplete="current-password"
className="field w-full"
/>
</div>
<div>
<label className="mb-1 block text-2xs text-ink-500">( 8 )</label>
<input
type="password"
value={newPwd}
onChange={e => setNewPwd(e.target.value)}
autoComplete="new-password"
className="field w-full"
/>
</div>
<div>
<label className="mb-1 block text-2xs text-ink-500"></label>
<input
type="password"
value={confirmPwd}
onChange={e => setConfirmPwd(e.target.value)}
autoComplete="new-password"
className="field w-full"
/>
</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>
{pwdNotice && (
<Alert kind={pwdNotice.ok ? 'ok' : 'error'} title={pwdNotice.text} />
)}
<p className="border-l-2 border-ink-300 pl-3 text-2xs leading-relaxed text-ink-500">
.env ADMIN_API_KEY ()
401
</p>
<div className="flex items-center justify-between gap-2">
<p className="text-2xs text-ink-400">退,</p>
<button
type="submit"
disabled={pwdBusy || !currentPwd || !newPwd || !confirmPwd}
className="btn btn-solid btn-sm flex-shrink-0"
>
{pwdBusy ? (<><Spinner /> </>) : '修改密码'}
</button>
</div>
</form>
</CardBody>
</Card>
@@ -203,7 +246,8 @@ docker compose logs -f api`}
['LLM_API_KEY', 'LLM 服务商的 API 密钥,用于调用大模型'],
['LLM_MODEL', '使用的模型名称,如 gpt-4o、claude-3-5-sonnet'],
['LLM_BASE_URL', 'API 基础地址,支持兼容 OpenAI 协议的服务商'],
['ADMIN_API_KEY', '管理后台写接口的鉴权密钥,配置后需在本页保存到浏览器'],
['ADMIN_PASSWORD', '管理后台登录密码,修改后重启 api 容器生效'],
['ADMIN_API_KEY', '脚本直连接口的鉴权密钥(请求头 X-API-Key)'],
['BZZOIRO_KEY', 'Bzzoiro 数据源 API 密钥'],
['DATABASE_URL', 'PostgreSQL 数据库连接字符串'],
].map(([key, desc]) => (
+143 -59
View File
@@ -2,58 +2,113 @@
* Admin 后台 - 数据源管理页面(报刊风)
*
* 功能:
* - 显示当前数据源状态 (bzzoiro / understat / injuries)
* - 显示 API Key 配置状态(脱敏显示)
* - 测试连接按钮(调用采集 API 验证)
* - 数据源说明
* - 显示数据源配置状态(脱敏),标明值来源:DB 覆盖 / .env 默认 / 未配置
* - 在线修改数据源 API Key(写入 app_settings,覆盖 .env;清除则回落)
* - 测试连接按钮(后端真实请求上游一次,不触发入库)
*/
import { useEffect, useState, useCallback } from 'react'
import { fetchDataSourceStatuses, testDataSource } from '../dal'
import type { DataSourceStatus } from '../types'
import {
fetchDataSourceStatuses,
updateSetting,
clearSetting,
testDataSourceConnection,
} from '../dal'
import type { DataSourceStatus, DataSourceTestResult } from '../types'
import SettingRow from '../SettingRow'
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner, SkeletonBlock } from '../components'
function formatTime(iso: string | null): string {
if (!iso) return '暂无记录'
try {
return new Date(iso).toLocaleString('zh-CN', { hour12: false })
} catch {
return iso
}
}
export default function DataSourcesPage() {
const [sources, setSources] = useState<DataSourceStatus[]>([])
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState('')
const [testingSource, setTestingSource] = useState<string | null>(null)
const [testResults, setTestResults] = useState<Record<string, { success: boolean; message: string }>>({})
const [testResults, setTestResults] = useState<Record<string, DataSourceTestResult>>({})
const [editingKey, setEditingKey] = useState<string | null>(null)
const [busyKey, setBusyKey] = useState<string | null>(null)
const [rowNotice, setRowNotice] = useState<{ key: string; ok: boolean; text: string } | null>(null)
const loadSources = useCallback(async () => {
setLoading(true)
setLoadError('')
try {
const data = await fetchDataSourceStatuses()
setSources(data)
} catch {
setSources(await fetchDataSourceStatuses())
} catch (err) {
setLoadError(err instanceof Error ? err.message.split('\n')[0] : '加载失败')
setSources([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => { loadSources() }, [loadSources])
useEffect(() => {
loadSources()
}, [loadSources])
async function handleTest(sourceName: string) {
setTestingSource(sourceName)
setTestResults(prev => ({ ...prev, [sourceName]: { success: false, message: '测试中...' } }))
try {
await testDataSource(sourceName as 'bzzoiro' | 'understat' | 'injuries')
setTestResults(prev => ({ ...prev, [sourceName]: { success: true, message: '连接成功' } }))
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : '连接失败'
setTestResults(prev => ({ ...prev, [sourceName]: { success: false, message: msg } }))
const result = await testDataSourceConnection(sourceName)
setTestResults(prev => ({ ...prev, [sourceName]: result }))
} catch (err) {
const msg = err instanceof Error ? err.message.split('\n')[0] : '连接失败'
setTestResults(prev => ({ ...prev, [sourceName]: { ok: false, status: null, latency_ms: 0, detail: msg } }))
} finally {
setTestingSource(null)
}
}
async function handleSave(key: string, value: string) {
setBusyKey(key)
setRowNotice(null)
try {
await updateSetting(key, value)
setRowNotice({ key, ok: true, text: '已保存,立即生效' })
setEditingKey(null)
await loadSources()
} catch (err) {
setRowNotice({ key, ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '保存失败' })
} finally {
setBusyKey(null)
}
}
async function handleClear(key: string) {
setBusyKey(key)
setRowNotice(null)
try {
await clearSetting(key)
setRowNotice({ key, ok: true, text: '已清除数据库覆盖,回落 .env 默认值' })
await loadSources()
} catch (err) {
setRowNotice({ key, ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '清除失败' })
} finally {
setBusyKey(null)
}
}
return (
<div className="space-y-6">
<SectionHeader
title="数据源管理"
description="数据采集源的配置状态与连通性测试。"
description="数据采集源的 API 配置与连通性测试。修改保存到数据库并立即生效,无需重启;「回落 .env」删除覆盖值。"
/>
{loadError && (
<Alert kind="error" title="无法加载数据源配置" message={loadError} />
)}
{/* 数据源卡片 */}
{loading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
@@ -73,39 +128,73 @@ export default function DataSourcesPage() {
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{sources.map(source => {
const result = testResults[source.name]
const cardKeys = source.settings.map(s => s.key)
return (
<Card key={source.name}>
<CardBody className="space-y-4">
{/* 头部 */}
<div className="flex items-center justify-between border-b border-ink-200 pb-3">
<div className="flex flex-wrap items-center justify-between gap-x-2 gap-y-1 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 status={source.key_configured ? 'success' : 'error'}>
{source.key_configured ? '已就绪' : '配置'}
</Badge>
</div>
{/* API Key 状态 */}
<div className="space-y-2">
<div className="flex items-center justify-between text-xs">
<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-ink-400"></span>
<span className="text-ink-600">{source.lastIngestion || '暂无记录'}</span>
</div>
</div>
<p className="text-2xs leading-relaxed text-ink-500">{source.description}</p>
{/* 测试结果 */}
{result && (
{/* 配置项 */}
{source.settings.length > 0 ? (
<div>
{source.settings.map(setting => (
<SettingRow
key={setting.key}
setting={setting}
editing={editingKey === setting.key}
busy={busyKey === setting.key}
onEdit={() => {
setEditingKey(setting.key)
setRowNotice(null)
}}
onCancel={() => setEditingKey(null)}
onSave={v => handleSave(setting.key, v)}
onClear={() => handleClear(setting.key)}
/>
))}
</div>
) : (
<p className="text-2xs text-ink-400"> API Key</p>
)}
{/* 行级操作提示 */}
{rowNotice && cardKeys.includes(rowNotice.key) && (
<Alert
kind={result.success ? 'ok' : 'error'}
title={result.success ? '连接成功' : '连接失败'}
message={result.success ? undefined : result.message}
kind={rowNotice.ok ? 'ok' : 'error'}
title={rowNotice.text}
/>
)}
{/* 操作按钮 */}
{/* 最近采集 */}
<div className="flex items-center justify-between text-xs">
<span className="text-ink-400"></span>
<span className="text-ink-600">{formatTime(source.last_ingestion)}</span>
</div>
{/* 测试结果(进行中不渲染,避免占位被误读为失败) */}
{result && testingSource !== source.name && (
<Alert
kind={result.ok ? 'ok' : 'error'}
title={
result.ok
? `连接成功(${result.latency_ms}ms)`
: result.status
? `HTTP ${result.status}`
: '连接失败'
}
message={result.ok ? undefined : result.detail}
/>
)}
{/* 测试按钮 */}
<button
onClick={() => handleTest(source.name)}
disabled={testingSource === source.name}
@@ -120,29 +209,24 @@ export default function DataSourcesPage() {
</div>
)}
{/* 数据源说明 */}
{/* 配置说明 */}
<Card>
<CardHeader title="数据源说明" />
<CardHeader title="配置说明" />
<CardBody>
<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="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="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 className="space-y-3 text-xs leading-relaxed text-ink-600">
<p className="border-l-2 border-ink-300 pl-3">
<code className="font-mono">app_settings</code> <b></b>,
<code className="font-mono">.env</code> ; .env
,Key
</p>
<p className="border-l-2 border-ink-300 pl-3">
4 ( 8 ),
</p>
<p className="border-l-2 border-ink-300 pl-3">
会真实请求上游接口一次:连通且密钥有效 ;
HTTP 401/403 ;
</p>
</div>
</CardBody>
</Card>
+110 -67
View File
@@ -9,17 +9,14 @@
*/
import { useEffect, useState, useCallback } from 'react'
import { testLLMConnection, fetchLLMUsageStats } from '../dal'
import { testLLMConnection, fetchLLMUsageStats, fetchSettings, fetchLLMModels, updateSetting, clearSetting } from '../dal'
import type { LLMUsageStats } from '../types'
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner, SkeletonBlock } from '../components'
import SettingRow from '../SettingRow'
import AgentLLMCard from '../AgentLLMCard'
import type { DataSourceSetting } from '../types'
// 可用模型列表
const AVAILABLE_MODELS = [
{ id: 'gpt-4o', label: 'GPT-4o', provider: 'openai', description: '综合能力最强,适合复杂分析' },
{ id: 'gpt-4o-mini', label: 'GPT-4o Mini', provider: 'openai', description: '快速经济,适合批量预测' },
{ id: 'claude-3-5-sonnet', label: 'Claude 3.5 Sonnet', provider: 'anthropic', description: '长上下文分析能力强' },
{ id: 'deepseek-chat', label: 'DeepSeek V3', provider: 'deepseek', description: '高性价比,中文优化' },
]
const LLM_SETTING_KEYS = ['LLM_API_KEY', 'LLM_BASE_URL', 'LLM_MODEL']
export default function LLMConfigPage() {
const [stats, setStats] = useState<LLMUsageStats | null>(null)
@@ -27,14 +24,12 @@ 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',
base_url: 'https://api.openai.com/v1',
api_key_configured: true,
api_key_masked: 'sk-****...****abcd',
}
// LLM 连接配置(运行时配置,DB 覆盖 .env)
const [llmSettings, setLlmSettings] = useState<DataSourceSetting[]>([])
const [settingsLoading, setSettingsLoading] = useState(true)
const [editingKey, setEditingKey] = useState<string | null>(null)
const [busyKey, setBusyKey] = useState<string | null>(null)
const [rowNotice, setRowNotice] = useState<{ key: string; ok: boolean; text: string } | null>(null)
const loadStats = useCallback(async () => {
setLoading(true)
@@ -48,7 +43,58 @@ export default function LLMConfigPage() {
}
}, [])
useEffect(() => { loadStats() }, [loadStats])
const loadSettings = useCallback(async () => {
setSettingsLoading(true)
try {
const all = await fetchSettings()
setLlmSettings(all.filter(x => LLM_SETTING_KEYS.includes(x.key)))
} catch {
setLlmSettings([])
} finally {
setSettingsLoading(false)
}
}, [])
useEffect(() => {
loadStats()
loadSettings()
}, [loadStats, loadSettings])
/** 供 LLM_MODEL 行内检测:探测当前服务可用模型,失败抛错由行内展示 */
const detectLLMModels = useCallback(async (): Promise<string[]> => {
const r = await fetchLLMModels()
if (!r.ok) throw new Error(r.detail)
return r.models
}, [])
async function handleSave(key: string, value: string) {
setBusyKey(key)
setRowNotice(null)
try {
await updateSetting(key, value)
setRowNotice({ key, ok: true, text: '已保存,立即生效' })
setEditingKey(null)
await loadSettings()
} catch (err) {
setRowNotice({ key, ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '保存失败' })
} finally {
setBusyKey(null)
}
}
async function handleClear(key: string) {
setBusyKey(key)
setRowNotice(null)
try {
await clearSetting(key)
setRowNotice({ key, ok: true, text: '已清除数据库覆盖,回落 .env 默认值' })
await loadSettings()
} catch (err) {
setRowNotice({ key, ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '清除失败' })
} finally {
setBusyKey(null)
}
}
async function handleTest() {
setTesting(true)
@@ -72,29 +118,53 @@ export default function LLMConfigPage() {
/>
<div className="grid gap-6 lg:grid-cols-2">
{/* 当前配置 */}
{/* LLM 连接配置 */}
<Card>
<CardHeader title="当前配置" />
<CardHeader
title="连接配置"
description="保存到数据库并立即生效,优先于服务器 .env"
action={
<button onClick={loadSettings} disabled={settingsLoading} className="btn btn-sm">
{settingsLoading ? (<><Spinner /> </>) : '刷新'}
</button>
}
/>
<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>
{settingsLoading ? (
<div className="space-y-3">
{[1, 2, 3].map(i => <SkeletonBlock key={i} className="h-9 w-full" />)}
</div>
) : (
<div>
{llmSettings.map(setting => (
<SettingRow
key={setting.key}
setting={setting}
editing={editingKey === setting.key}
busy={busyKey === setting.key}
onEdit={() => {
setEditingKey(setting.key)
setRowNotice(null)
}}
onCancel={() => setEditingKey(null)}
onSave={v => handleSave(setting.key, v)}
onClear={() => handleClear(setting.key)}
detectModels={setting.key === 'LLM_MODEL' ? detectLLMModels : undefined}
/>
))}
</div>
)}
{rowNotice && (
<div className="mt-3">
<Alert kind={rowNotice.ok ? 'ok' : 'error'} title={rowNotice.text} />
</div>
)}
<p className="mt-3 border-l-2 border-ink-300 pl-3 text-2xs leading-relaxed text-ink-500">
模式: 多专家 (5 + ) OpenAI ( DeepSeek
) LLM
</p>
{/* 测试连接 */}
{testResult && (
@@ -161,35 +231,8 @@ export default function LLMConfigPage() {
</Card>
</div>
{/* 可用模型 */}
<Card>
<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">
<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 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>
{/* 专家与终裁独立配置 */}
<AgentLLMCard />
{/* 最近预测 */}
<Card>
+153
View File
@@ -0,0 +1,153 @@
/**
* Admin 后台 - 系统日志页面(报刊风)
*
* 查看应用运行日志(内存缓冲,最新在前):
* - 级别筛选 + 关键字搜索
* - 自动刷新(10s)可开关
* - 缓冲上限 2000 条,进程重启后清零
*/
import { useEffect, useState, useCallback, useRef } from 'react'
import { fetchLogs } from '../dal'
import type { LogEntry } from '../types'
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner, SkeletonBlock } from '../components'
const LEVELS = ['', 'INFO', 'WARNING', 'ERROR'] as const
const LEVEL_BADGE: Record<string, { status: 'success' | 'info' | 'warning' | 'error'; text: string }> = {
DEBUG: { status: 'info', text: 'DEBUG' },
INFO: { status: 'info', text: 'INFO' },
WARNING: { status: 'warning', text: 'WARN' },
ERROR: { status: 'error', text: 'ERROR' },
CRITICAL: { status: 'error', text: 'FATAL' },
}
function fmtTs(ts: number): string {
return new Date(ts * 1000).toLocaleString('zh-CN', { hour12: false })
}
export default function LogsPage() {
const [entries, setEntries] = useState<LogEntry[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [level, setLevel] = useState<string>('')
const [keyword, setKeyword] = useState('')
const [autoRefresh, setAutoRefresh] = useState(true)
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const load = useCallback(async () => {
try {
const d = await fetchLogs({ level: level || undefined, keyword: keyword || undefined, limit: 300 })
setEntries(d.entries)
setError('')
} catch (err) {
setError(err instanceof Error ? err.message.split('\n')[0] : '加载失败')
} finally {
setLoading(false)
}
}, [level, keyword])
// 筛选条件变化 → 立即拉取
useEffect(() => {
load()
}, [load])
// 自动刷新
useEffect(() => {
if (timerRef.current) clearInterval(timerRef.current)
if (autoRefresh) {
timerRef.current = setInterval(load, 10_000)
}
return () => {
if (timerRef.current) clearInterval(timerRef.current)
}
}, [autoRefresh, load])
return (
<div className="space-y-6">
<SectionHeader
title="系统日志"
description="应用运行日志(登录、配置变更、采集、预测、异常等)。内存缓冲最近 2000 条,重启后清零。"
/>
{error && <Alert kind="error" title="无法加载日志" message={error} />}
<Card>
<CardHeader
title="日志查看"
description={entries.length > 0 ? `显示最新 ${entries.length}` : undefined}
action={
<div className="flex items-center gap-2">
<label className="flex cursor-pointer items-center gap-1.5 text-2xs text-ink-500">
<input
type="checkbox"
checked={autoRefresh}
onChange={e => setAutoRefresh(e.target.checked)}
className="accent-current"
/>
10s
</label>
<button onClick={load} disabled={loading} className="btn btn-sm">
{loading ? (<><Spinner /> </>) : '刷新'}
</button>
</div>
}
/>
<CardBody className="px-0 sm:px-0">
{/* 筛选栏 */}
<div className="flex flex-col gap-2 border-b border-ink-200 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-5">
<div className="flex flex-wrap gap-1.5">
{LEVELS.map(lv => (
<button
key={lv || 'all'}
onClick={() => setLevel(lv)}
className={`btn btn-sm ${level === lv ? 'btn-solid' : ''}`}
>
{lv || '全部'}
</button>
))}
</div>
<input
value={keyword}
onChange={e => setKeyword(e.target.value)}
placeholder="搜索关键字(消息 / logger)…"
className="field w-full sm:w-64"
/>
</div>
{/* 日志列表 */}
{loading && entries.length === 0 ? (
<div className="space-y-2 px-4 py-3 sm:px-5">
{[1, 2, 3, 4, 5].map(i => <SkeletonBlock key={i} className="h-8 w-full" />)}
</div>
) : entries.length === 0 ? (
<p className="py-10 text-center text-xs text-ink-400"></p>
) : (
<div>
{entries.map((e, i) => {
const badge = LEVEL_BADGE[e.level] ?? { status: 'info' as const, text: e.level }
return (
<div
key={`${e.ts}-${i}`}
className="flex flex-col gap-0.5 border-b border-ink-200 px-4 py-2 last:border-b-0 sm:flex-row sm:items-baseline sm:gap-3 sm:px-5"
>
<span className="w-40 flex-shrink-0 text-2xs tabular-nums text-ink-400">{fmtTs(e.ts)}</span>
<span className="w-14 flex-shrink-0">
<Badge status={badge.status}>{badge.text}</Badge>
</span>
<span className="w-40 flex-shrink-0 truncate font-mono text-2xs text-ink-400" title={e.logger}>
{e.logger}
</span>
<span className="min-w-0 flex-1 break-all font-mono text-2xs leading-relaxed text-ink-800">
{e.message}
</span>
</div>
)
})}
</div>
)}
</CardBody>
</Card>
</div>
)
}
+1 -1
View File
@@ -66,7 +66,7 @@ export default function MonitoringPage() {
<Alert
kind="error"
title="无法连接到后端"
message={`${error}\n请确认服务是否正常运行,以及管理员密钥是否需要配置`}
message={`${error}\n请确认服务是否正常运行,以及登录会话是否已过期`}
/>
)}
+8 -7
View File
@@ -13,13 +13,14 @@ 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, Alert, Spinner } from '../components'
import { teamSidePrefix } from '../../components/TeamSideTag'
const AGENT_LABELS: Record<string, string> = {
h2h: '历史交锋',
form: '近期状态',
stats: '攻防数据',
home_away: '主客因素',
injuries: '阵容完整性',
h2h: '历史交锋分析专家',
form: '近期状态分析专家',
stats: '攻防数据分析专家',
home_away: '主客因素分析专家',
injuries: '阵容完整性分析专家',
}
const OUTCOME_LABEL: Record<string, string> = { '1': '主胜', X: '平局', '2': '客胜' }
@@ -64,7 +65,7 @@ export default function PredictionsPage() {
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}`)
map.set(m.id, `${teamSidePrefix('home')}${home} vs ${teamSidePrefix('away')}${away}`)
}
return map
}, [matches])
@@ -140,7 +141,7 @@ export default function PredictionsPage() {
<option value=""></option>
{matches.map(m => (
<option key={m.id} value={m.id}>
{(m.home_team_zh || m.home_team)} vs {(m.away_team_zh || m.away_team)}
{teamSidePrefix('home')}{(m.home_team_zh || m.home_team)} vs {teamSidePrefix('away')}{(m.away_team_zh || m.away_team)}
({m.match_date?.slice(5, 10)})
</option>
))}
+2
View File
@@ -15,6 +15,7 @@ import MonitoringPage from './pages/Monitoring'
import DataSourcesPage from './pages/DataSources'
import LLMConfigPage from './pages/LLMConfig'
import ConfigPage from './pages/Config'
import LogsPage from './pages/Logs'
export const adminRoutes = [
{
@@ -29,6 +30,7 @@ export const adminRoutes = [
{ path: 'data-sources', element: <DataSourcesPage /> },
{ path: 'llm-config', element: <LLMConfigPage /> },
{ path: 'config', element: <ConfigPage /> },
{ path: 'logs', element: <LogsPage /> },
{ path: '*', element: <Navigate to="/admin" replace /> },
],
},
+50 -4
View File
@@ -90,6 +90,7 @@ export interface PredictRequest {
// ── 数据采集 ────────────────────────────────────────────────────
export interface CollectionRequest {
status?: string
source: 'bzzoiro' | 'understat' | 'injuries'
leagues?: string[]
league?: string
@@ -138,13 +139,30 @@ export interface BacktestSummary {
// ── 数据源配置 ──────────────────────────────────────────────────
export interface DataSourceSetting {
key: string
label: string
description: string
sensitive: boolean
configured: boolean
masked: string
origin: 'db' | 'env' | 'none'
}
export interface DataSourceStatus {
name: string
label: string
keyConfigured: boolean
maskedKey: string
lastIngestion: string | null
status: 'configured' | 'missing_key' | 'untested'
description: string
key_configured: boolean
last_ingestion: string | null
settings: DataSourceSetting[]
}
export interface DataSourceTestResult {
ok: boolean
status: number | null
latency_ms: number
detail: string
}
export interface DataSourceTestRequest {
@@ -193,3 +211,31 @@ export interface SystemConfigEntry {
description: string
is_sensitive: boolean
}
// ── 专家/终裁独立 LLM 配置 ────────────────────────────────────────
export interface LLMAgentFieldState {
configured: boolean
masked: string
origin: 'db' | 'env' | 'none'
}
export interface LLMAgentConfig {
id: string
label: string
effective_model: string
fields: {
model: LLMAgentFieldState
base_url: LLMAgentFieldState
api_key: LLMAgentFieldState
}
}
// ── 系统日志 ─────────────────────────────────────────────────────
export interface LogEntry {
ts: number
level: string
logger: string
message: string
}
+26
View File
@@ -0,0 +1,26 @@
/**
* 主客队标志:报刊风小方框字。
*
* 主队 = 反白实心块,客队 = 细线框,与整版纸色语言一致。
* 用法: <TeamSideTag side="home" /> 队名
*/
export default function TeamSideTag({ side }: { side: 'home' | 'away' }) {
return (
<span
aria-label={side === 'home' ? '主队' : '客队'}
className={`inline-block flex-shrink-0 border px-1 text-center font-sans text-2xs leading-4 ${
side === 'home'
? 'border-ink-900 bg-ink-900 text-paper-50'
: 'border-ink-300 text-ink-400'
}`}
>
{side === 'home' ? '主' : '客'}
</span>
)
}
/** 纯文本场景(<option>、字符串拼接)的主客队前缀 */
export function teamSidePrefix(side: 'home' | 'away'): string {
return side === 'home' ? '[主] ' : '[客] '
}
+237 -56
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import TeamSideTag from '../components/TeamSideTag'
interface Match {
id: number
@@ -25,6 +26,8 @@ interface Prediction {
mode: string
pred_home_goals: number | null
pred_away_goals: number | null
alt_pred_home_goals: number | null
alt_pred_away_goals: number | null
pred_1x2: string | null
subjective_confidence: number | null
reasoning: string | null
@@ -50,11 +53,11 @@ interface AgentReport {
}
const AGENT_LABELS: Record<string, string> = {
h2h: '历史交锋',
form: '近期状态',
stats: '攻防数据',
home_away: '主客因素',
injuries: '阵容完整性',
h2h: '历史交锋分析专家',
form: '近期状态分析专家',
stats: '攻防数据分析专家',
home_away: '主客因素分析专家',
injuries: '阵容完整性分析专家',
}
const LEAGUES = [
@@ -193,6 +196,17 @@ export default function Matches() {
// 把旧结果覆盖到新筛选上。用递增序号只认最后一次请求的响应。
const loadSeq = useRef(0)
const predictSeq = useRef(0)
// 预测请求控制器:关闭弹窗时中止
const predictAbort = useRef<AbortController | null>(null)
function closePredict() {
predictAbort.current?.abort()
predictSeq.current++ // 令中止请求的 catch/then 全部失效,不再写入错误
setPredictingId(null)
setPrediction(null)
setPredictionFor(null)
setError(null)
}
const load = useCallback(async () => {
const seq = ++loadSeq.current
@@ -247,11 +261,16 @@ export default function Matches() {
setError(null)
setPrediction(null)
setPredictionFor(m)
// LLM 多专家预测耗时可达数分钟,给足超时(与 nginx 代理 300s 对齐)
const controller = new AbortController()
predictAbort.current = controller
const timer = setTimeout(() => controller.abort(), 300_000)
try {
const res = await fetch('/api/v1/predict', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ match_id: m.id, mode }),
signal: controller.signal,
})
if (seq !== predictSeq.current) return
if (!res.ok) {
@@ -263,8 +282,15 @@ export default function Matches() {
setPrediction(data)
} catch (e) {
if (seq !== predictSeq.current) return
setError(e instanceof Error ? e.message : String(e))
setError(
e instanceof DOMException && e.name === 'AbortError'
? '预测超时(5 分钟),请稍后重试或改用单次模式'
: e instanceof Error
? e.message
: String(e),
)
} finally {
clearTimeout(timer)
if (seq === predictSeq.current) setPredictingId(null)
}
}
@@ -363,6 +389,18 @@ export default function Matches() {
</div>
)}
{/* ── 预测弹窗:进行中可视化 / 结果面板 ── */}
{predictionFor && (
<PredictModal
match={predictionFor}
mode={mode}
predicting={predictingId === predictionFor.id}
prediction={predictingId === predictionFor.id ? null : prediction}
error={predictingId === predictionFor.id ? null : error}
onClose={closePredict}
/>
)}
{/* ── 赛程栏:表格化,行间细线 ── */}
<section aria-label="赛程">
{loading && <SkeletonRows n={4} />}
@@ -380,6 +418,7 @@ export default function Matches() {
const awayName = m.away_team_zh || m.away_team
const busy = predictingId === m.id
const active = predictionFor?.id === m.id
const finished = m.match_status === 'finished'
return (
<div
@@ -398,7 +437,8 @@ export default function Matches() {
{/* 对阵:移动端主队/比分/客队同一行,桌面端 sm:contents 拆回 grid 列 */}
<div className="flex items-center gap-2 sm:contents">
{/* 主队(右对齐) */}
<div className="flex min-w-0 flex-1 items-center justify-end">
<div className="flex min-w-0 flex-1 items-center justify-end gap-1.5">
<TeamSideTag side="home" />
<span className="truncate text-sm font-medium text-ink-900">{homeName}</span>
</div>
@@ -419,7 +459,8 @@ export default function Matches() {
</div>
{/* 客队(左对齐) */}
<div className="flex min-w-0 flex-1 items-center">
<div className="flex min-w-0 flex-1 items-center gap-1.5">
<TeamSideTag side="away" />
<span className="truncate text-sm font-medium text-ink-900">{awayName}</span>
</div>
</div>
@@ -429,14 +470,16 @@ export default function Matches() {
{/* 预测按钮 */}
<div className="flex justify-end">
<button
onClick={() => predict(m)}
disabled={busy}
className="btn btn-sm w-[76px]"
title={`${mode === 'multi' ? '多专家' : '单次'}模式预测这场`}
>
{busy ? (<><Spinner /> </>) : '预测'}
</button>
{!finished && (
<button
onClick={() => predict(m)}
disabled={busy}
className="btn btn-sm w-[76px]"
title={`${mode === 'multi' ? '多专家' : '单次'}模式预测这场`}
>
{busy ? (<><Spinner /> </>) : '预测'}
</button>
)}
</div>
</div>
</div>
@@ -452,32 +495,164 @@ export default function Matches() {
)}
</section>
{/* ── 预测中占位 ── */}
{predictingId && !prediction && (
<div className="border border-ink-900">
<div className="flex items-center gap-2 border-b border-ink-900 bg-paper-100 px-4 py-2.5">
<Spinner className="text-press" />
<span className="text-sm font-medium text-ink-800"></span>
<span className="text-2xs text-ink-500">
{mode === 'multi' ? '五路专家并行分析后终裁,约需 20-60 秒' : '单次调用,约需 5-15 秒'}
</span>
</div>
<div className="space-y-4 px-4 py-6">
<div className="flex items-center justify-center gap-6">
<div className="skeleton h-4 w-20" />
<div className="skeleton h-10 w-24" />
<div className="skeleton h-4 w-20" />
</div>
<div className="skeleton mx-auto h-px w-64" />
<div className="skeleton h-16 w-full" />
</div>
</div>
</div>
)
}
/** 预测过程阶段(按时长模拟;结果到达即跳到完成) */
function PredictProgress({ mode }: { mode: 'single' | 'multi' }) {
const [elapsed, setElapsed] = useState(0)
useEffect(() => {
const t = setInterval(() => setElapsed(e => e + 0.5), 500)
return () => clearInterval(t)
}, [])
// 阶段阈值(秒): 切片 → 专家(各路依次点亮) → 终裁
const SLICE_END = mode === 'multi' ? 3 : 3
const AGENT_START = 4
const AGENT_STEP = 8 // 每路专家约 8s 点亮一路
const AGG_START = mode === 'multi' ? AGENT_START + AGENT_STEP * 5 : SLICE_END + 1
const agents = ['form', 'stats', 'home_away', 'injuries', 'h2h']
const phase = elapsed < SLICE_END ? 'slice'
: mode === 'single'
? 'model'
: elapsed < AGG_START ? 'agents' : 'agg'
const pct = Math.min(95, Math.round((elapsed / (mode === 'multi' ? 70 : 20)) * 100))
return (
<div className="px-5 py-8 sm:px-8">
{/* 阶段标题 */}
<div className="flex items-center justify-center gap-2">
<Spinner className="text-press" />
<span className="font-serif text-sm font-bold text-ink-900">
{phase === 'slice' && '正在组装比赛数据切片'}
{phase === 'agents' && '五路专家并行分析中'}
{phase === 'model' && '模型分析中'}
{phase === 'agg' && '终裁专家汇总裁定中'}
</span>
<span className="text-2xs tabular-nums text-ink-400">{elapsed.toFixed(0)}s</span>
</div>
{/* 进度条:渐进式,不封顶到 100% */}
<div className="mx-auto mt-5 h-1 w-full max-w-md overflow-hidden bg-ink-100" role="progressbar" aria-valuenow={pct}>
<div
className={`h-full bg-press transition-all duration-500 ${phase === 'agg' || phase === 'model' ? 'animate-pulse' : ''}`}
style={{ width: `${pct}%` }}
/>
</div>
{/* 专家灯序(多专家模式) */}
{mode === 'multi' && (
<ul className="mx-auto mt-6 max-w-md space-y-1.5">
{agents.map((a, i) => {
const lit = elapsed >= AGENT_START + AGENT_STEP * (i + 1)
const activeNow = !lit && elapsed >= AGENT_START + AGENT_STEP * i
return (
<li
key={a}
className={`flex items-center justify-between border-b border-ink-200 pb-1.5 text-xs transition-colors ${
lit ? 'text-ink-800' : activeNow ? 'text-ink-900' : 'text-ink-300'
}`}
>
<span className="flex items-center gap-2">
<span
aria-hidden="true"
className={`inline-block h-1.5 w-1.5 ${lit ? 'bg-ink-900' : activeNow ? 'bg-press animate-pulse' : 'bg-ink-200'}`}
/>
{AGENT_LABELS[a] ?? a}
</span>
{lit && <span className="text-2xs text-ink-400"> </span>}
{activeNow && <span className="text-2xs text-press"></span>}
</li>
)
})}
</ul>
)}
{/* ── 预测版 ── */}
{prediction && predictionFor && (
<PredictionPanel prediction={prediction} match={predictionFor} mode={mode} />
)}
<p className="mt-6 text-center text-2xs text-ink-400">
{mode === 'multi' ? '五路专家并行分析后终裁,约需 30-90 秒;关闭窗口即取消' : '单次调用,约需 5-20 秒;关闭窗口即取消'}
</p>
</div>
)
}
/** 预测弹窗:进行中显示过程可视化,完成后显示预测版,失败显示原因 */
function PredictModal({
match,
mode,
predicting,
prediction,
error,
onClose,
}: {
match: Match
mode: 'single' | 'multi'
predicting: boolean
prediction: Prediction | null
error: string | null
onClose: () => void
}) {
const homeName = match.home_team_zh || match.home_team
const awayName = match.away_team_zh || match.away_team
useEffect(() => {
const h = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', h)
return () => document.removeEventListener('keydown', h)
}, [onClose])
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-ink-900/50 p-4 sm:items-center"
role="dialog"
aria-modal="true"
aria-label={`预测 ${homeName}${awayName}`}
onClick={e => {
if (e.target === e.currentTarget) onClose()
}}
>
<div className="relative w-full max-w-2xl bg-paper-50 shadow-2xl">
{/* 弹窗报头 */}
<div className="flex items-center justify-between border-b border-ink-900 bg-paper-100 px-4 py-2.5 sm:px-5">
<h3 className="flex flex-wrap items-center gap-1.5 font-serif text-sm font-bold text-ink-900">
·
<TeamSideTag side="home" />
{homeName}
<span></span>
<TeamSideTag side="away" />
{awayName}
</h3>
<button
onClick={onClose}
className="flex h-7 w-7 items-center justify-center text-ink-400 transition-colors hover:text-ink-900"
aria-label="关闭"
>
<svg className="h-4 w-4" viewBox="0 0 20 20" 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>
{/* 弹窗体 */}
{predicting ? (
<PredictProgress mode={mode} />
) : error ? (
<div className="px-5 py-10 text-center sm:px-8">
<p className="font-serif text-sm font-bold text-press"></p>
<p className="mx-auto mt-3 max-w-md whitespace-pre-wrap text-left text-xs leading-relaxed text-ink-600">
{error}
</p>
<button onClick={onClose} className="btn btn-sm mt-6"></button>
</div>
) : prediction ? (
<PredictionPanel prediction={prediction} match={match} mode={mode} embedded />
) : null}
</div>
</div>
)
}
@@ -486,27 +661,37 @@ function PredictionPanel({
prediction,
match,
mode,
embedded = false,
}: {
prediction: Prediction
match: Match
mode: 'single' | 'multi'
/** 弹窗嵌入模式:弹窗已提供报头,这里省略自带版头 */
embedded?: boolean
}) {
const homeName = match.home_team_zh || match.home_team
const awayName = match.away_team_zh || match.away_team
const okReports = (prediction.agent_outputs ?? []).filter(r => r.status === 'ok')
return (
<article className="border border-ink-900 bg-paper-50">
{/* 版头 */}
<article className={embedded ? 'bg-paper-50' : 'border border-ink-900 bg-paper-50'}>
{/* 版头(嵌入模式由弹窗报头承担) */}
{!embedded && (
<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">
· {homeName} {awayName}
<h3 className="flex flex-wrap items-center gap-1.5 font-serif text-sm font-bold text-ink-900">
·
<TeamSideTag side="home" />
{homeName}
<span></span>
<TeamSideTag side="away" />
{awayName}
</h3>
<span className="text-2xs tabular-nums text-ink-500">
{prediction.provider} / {prediction.model}
{prediction.latency_ms !== null && ` · ${(prediction.latency_ms / 1000).toFixed(1)}s`}
</span>
</div>
)}
<div className="space-y-7 px-4 py-6 sm:px-5">
{/* ── 预测比分:版面核心,大号宋体 ── */}
@@ -517,6 +702,14 @@ function PredictionPanel({
{prediction.pred_away_goals ?? '-'}
</p>
<p className="mt-3 text-2xs tracking-[0.5em] text-ink-400"></p>
{prediction.alt_pred_home_goals !== null && prediction.alt_pred_away_goals !== null && (
<p className="mt-2 text-2xs tabular-nums text-ink-400">
{' '}
<span className="font-serif text-sm font-bold tabular-nums text-ink-600">
{prediction.alt_pred_home_goals}<span className="mx-0.5 font-normal text-ink-300">:</span>{prediction.alt_pred_away_goals}
</span>
</p>
)}
</div>
{/* ── 胜平负 ── */}
@@ -566,18 +759,6 @@ function PredictionPanel({
</section>
)}
{/* ── 原始上下文 ── */}
<details className="group">
<summary className="flex cursor-pointer list-none items-center gap-1.5 text-xs text-ink-500 transition-colors hover:text-ink-800">
<svg viewBox="0 0 20 20" className="h-3 w-3 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>
</summary>
<pre className="mt-2 max-h-80 overflow-auto border border-ink-200 bg-paper-100 p-3 font-mono text-2xs leading-relaxed text-ink-600">
{prediction.context}
</pre>
</details>
</div>
</article>
)