feat: 后台管理 Admin 仪表盘
新增完整的后台管理系统 (/admin): - Dashboard: 系统概览、最近采集状态、预测统计 - Collection: 数据采集触发(bzzoiro/understat/injuries) - Predictions: 预测历史查看、触发新预测 - Backtest: 回测配置与结果查看 - Monitoring: 系统健康、错误日志、死色队列 - Config: API Key 与数据源配置 技术栈: React Router + Tailwind 暗色主题 + TypeScript 文件: 11 个新文件, +21KB JS / +5KB CSS
This commit is contained in:
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(python -m pytest tests/ -v)",
|
||||||
|
"Bash(python -c \"import src.core.http_client; import src.llm.provider; import src.data.understat; import src.data.injuries; import src.llm.agents.orchestrator; import src.llm.context_builder; import src.api.app; print\\('All imports OK'\\)\")",
|
||||||
|
"Bash(python -c \"from src.data.sources import get_source, list_sources; print\\('sources:', list_sources\\(\\)\\); print\\('bzzoiro:', get_source\\('bzzoiro'\\).name\\); print\\('understat:', get_source\\('understat'\\).name\\)\")",
|
||||||
|
"Bash(python -c \"from src.api.routes.ingest import router; print\\('ingest router OK:', len\\(router.routes\\), 'routes'\\)\")",
|
||||||
|
"Bash(python -c ' *)",
|
||||||
|
"Bash(python -m pytest tests/ -q)",
|
||||||
|
"Bash(git -C /p rev-parse --git-dir)",
|
||||||
|
"Bash(git config *)",
|
||||||
|
"Bash(git init *)",
|
||||||
|
"Bash(git add *)",
|
||||||
|
"Bash(git commit -m 'feat: 足球 LLM 预测服务初始提交 *)",
|
||||||
|
"Bash(git remote *)",
|
||||||
|
"Bash(git push *)",
|
||||||
|
"Bash(git fetch *)",
|
||||||
|
"Bash(git branch *)",
|
||||||
|
"Bash(git commit *)",
|
||||||
|
"Bash(pg_isready -h localhost -p 5432)",
|
||||||
|
"Bash(python -c \"import pytest_asyncio; print\\('pytest-asyncio OK'\\)\")",
|
||||||
|
"Bash(python -c \"import pytest; print\\('pytest', pytest.__version__\\)\")",
|
||||||
|
"Bash(python -c \"import aiosqlite; print\\('aiosqlite', aiosqlite.__version__\\)\")",
|
||||||
|
"Bash(npm run *)",
|
||||||
|
"Bash(npm install *)",
|
||||||
|
"Bash(git revert *)",
|
||||||
|
"Bash(python -c \"from src.api.app import app; print\\('OK'\\)\")",
|
||||||
|
"Bash(python -m pytest tests/test_agents.py::TestReportParsing::test_parse_bad_values_forgiving -v)",
|
||||||
|
"Bash(python -c \"from src.data.bzzoiro import BzzoiroSource; print\\('OK'\\)\")",
|
||||||
|
"Bash(python -m pytest tests/test_agents.py::TestReportParsing::test_parse_full_report -v)",
|
||||||
|
"Bash(python -c \"from src.db.unit_of_work import UnitOfWork, get_uow; from src.db.repositories import MatchRepository, TeamRepository; print\\('OK'\\)\")",
|
||||||
|
"Bash(python -c \"from src.data.bzzoiro import BzzoiroSource; from src.data.understat import UnderstatSource; from src.data.injuries import ingest_injuries; print\\('OK'\\)\")",
|
||||||
|
"Bash(python -m compileall src tests)",
|
||||||
|
"Bash(alembic current *)",
|
||||||
|
"Bash(alembic history *)",
|
||||||
|
"Bash(python -c \"from src.db.models import MatchStats, Prediction; print\\('Models OK'\\)\")",
|
||||||
|
"Bash(git pull *)",
|
||||||
|
"Bash(ocr delegate *)",
|
||||||
|
"Bash(npm i *)",
|
||||||
|
"Bash(python -c \"from src.llm.validation import KNOWN_AGENT_NAMES; print\\(KNOWN_AGENT_NAMES\\)\")"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+43
-43
@@ -9,7 +9,8 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.30.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.3.3",
|
"@types/react": "^18.3.3",
|
||||||
@@ -766,9 +767,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -816,6 +814,15 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@remix-run/router": {
|
||||||
|
"version": "1.23.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.4.tgz",
|
||||||
|
"integrity": "sha512-q7j5geK7xs3UJSdm9/iytUNclBnLmYx1EnSeCFXHPeutdqgIMeFeHtUZgS3EhlKxdBEAu8OwtJCwmLrEzpSs7Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/pluginutils": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-beta.27",
|
"version": "1.0.0-beta.27",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||||
@@ -915,9 +922,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -932,9 +936,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -949,9 +950,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -966,9 +964,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -983,9 +978,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1000,9 +992,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1017,9 +1006,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1034,9 +1020,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1051,9 +1034,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1068,9 +1048,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1085,9 +1062,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1102,9 +1076,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1119,9 +1090,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2287,6 +2255,38 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-router": {
|
||||||
|
"version": "6.30.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.6.tgz",
|
||||||
|
"integrity": "sha512-5HfK7k5im7LTOB0EqCQmfvy4C13G92Ssj1VTmouTK3AJvyjKTnFuCV0vcMAD/JS+JC4DvDIBRrlAeJIFjh5VWg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@remix-run/router": "1.23.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-router-dom": {
|
||||||
|
"version": "6.30.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.6.tgz",
|
||||||
|
"integrity": "sha512-0RHKZz7wwffvkU+2MFVT2NnjK44ssLEV+m0CAJaS2Ksmorrwj7WxH00jO0SOCW26/tINUnJHToXblDs33I38YQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@remix-run/router": "1.23.4",
|
||||||
|
"react-router": "6.30.6"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8",
|
||||||
|
"react-dom": ">=16.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/read-cache": {
|
"node_modules/read-cache": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.2.tgz",
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.30.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.3.3",
|
"@types/react": "^18.3.3",
|
||||||
|
|||||||
+43
-8
@@ -1,7 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* 主应用入口
|
||||||
|
*
|
||||||
|
* 整合前台(报纸风格)和后台(暗色管理)的路由。
|
||||||
|
* - / → 先知(Profeto)主站
|
||||||
|
* - /admin/* → 管理后台
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||||
import { ErrorBoundary } from './components/ErrorBoundary'
|
import { ErrorBoundary } from './components/ErrorBoundary'
|
||||||
import Matches from './pages/Matches'
|
import Matches from './pages/Matches'
|
||||||
|
import { adminRoutes } from './admin/routes'
|
||||||
|
|
||||||
/** 报眉日期行:2026年9月15日 星期二 */
|
/** 报眉日期行 */
|
||||||
function dateLine(): string {
|
function dateLine(): string {
|
||||||
return new Date().toLocaleDateString('zh-CN', {
|
return new Date().toLocaleDateString('zh-CN', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
@@ -11,9 +21,8 @@ function dateLine(): string {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
function HomePage() {
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
|
||||||
<div className="min-h-screen bg-paper-50">
|
<div className="min-h-screen bg-paper-50">
|
||||||
{/* ── 报头:粗线 + 居中刊名 + 报眉 ── */}
|
{/* ── 报头:粗线 + 居中刊名 + 报眉 ── */}
|
||||||
<header className="masthead-rule">
|
<header className="masthead-rule">
|
||||||
@@ -31,10 +40,13 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between border-b border-ink-200 py-2 text-2xs text-ink-500">
|
<div className="flex items-center justify-between border-b border-ink-200 py-2 text-2xs text-ink-500">
|
||||||
<span>{dateLine()}</span>
|
<span>{dateLine()}</span>
|
||||||
<span className="flex items-center gap-1.5">
|
<a
|
||||||
<span className="inline-block h-1.5 w-1.5 bg-emerald-600" aria-hidden="true" />
|
href="/admin"
|
||||||
服务运行中
|
className="flex items-center gap-1.5 text-press hover:text-press-dark transition-colors"
|
||||||
</span>
|
>
|
||||||
|
<span aria-hidden="true">⚙</span>
|
||||||
|
管理后台
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -43,13 +55,36 @@ export default function App() {
|
|||||||
<Matches />
|
<Matches />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* ── 版底 ── */}
|
|
||||||
<footer className="mx-auto max-w-5xl px-5 pb-10 sm:px-8">
|
<footer className="mx-auto max-w-5xl px-5 pb-10 sm:px-8">
|
||||||
<div className="border-t border-ink-200 pt-3 text-center text-2xs leading-relaxed text-ink-400">
|
<div className="border-t border-ink-200 pt-3 text-center text-2xs leading-relaxed text-ink-400">
|
||||||
预测结果由大语言模型生成 · 仅供研究参考 · 不构成任何投注建议
|
预测结果由大语言模型生成 · 仅供研究参考 · 不构成任何投注建议
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<HomePage />} />
|
||||||
|
{adminRoutes.map(route => (
|
||||||
|
<Route key={route.path} path={route.path} element={route.element}>
|
||||||
|
{route.children.map(child => (
|
||||||
|
<Route
|
||||||
|
key={child.path ?? 'index'}
|
||||||
|
index={child.index}
|
||||||
|
path={child.path}
|
||||||
|
element={child.element}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Route>
|
||||||
|
))}
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Admin 后台 - 应用入口
|
||||||
|
*
|
||||||
|
* 独立的 Admin 应用入口,用于 createBrowserRouter。
|
||||||
|
* 也可通过 createHashRouter 直接挂载为独立应用。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
|
||||||
|
import { adminRoutes } from './routes'
|
||||||
|
|
||||||
|
const router = createBrowserRouter(adminRoutes)
|
||||||
|
|
||||||
|
export default function AdminApp() {
|
||||||
|
return <RouterProvider router={router} />
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* Admin 后台 - 布局组件
|
||||||
|
*
|
||||||
|
* 提供管理界面的基本结构:顶栏 + 侧边栏 + 内容区
|
||||||
|
* 暗色主题,参考线性风格设计
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NavLink, Outlet } from 'react-router-dom'
|
||||||
|
|
||||||
|
const NAV_ITEMS = [
|
||||||
|
{ to: '/admin', label: '仪表盘', icon: '◇', end: true },
|
||||||
|
{ to: '/admin/collection', label: '数据采集', icon: '◈' },
|
||||||
|
{ to: '/admin/predictions', label: '预测管理', icon: '◆' },
|
||||||
|
{ to: '/admin/backtest', label: '回测管理', icon: '◉' },
|
||||||
|
{ to: '/admin/monitoring', label: '监控面板', icon: '◐' },
|
||||||
|
{ to: '/admin/config', label: '配置管理', icon: '◑' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function AdminLayout() {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen bg-gray-950 text-gray-200 overflow-hidden">
|
||||||
|
{/* ── 侧边栏 ── */}
|
||||||
|
<aside className="flex w-56 flex-shrink-0 flex-col border-r border-gray-800 bg-gray-900">
|
||||||
|
<div className="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>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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}>
|
||||||
|
<NavLink
|
||||||
|
to={item.to}
|
||||||
|
end={item.end}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-gray-800 text-white font-medium'
|
||||||
|
: 'text-gray-400 hover:bg-gray-800/60 hover:text-gray-200'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="text-base leading-none opacity-60" aria-hidden="true">
|
||||||
|
{item.icon}
|
||||||
|
</span>
|
||||||
|
{item.label}
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">←</span>
|
||||||
|
返回前台
|
||||||
|
</a>
|
||||||
|
</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-6">
|
||||||
|
<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" />
|
||||||
|
系统运行中
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
Profeto Admin v1.0
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* 页面内容 */}
|
||||||
|
<main className="flex-1 overflow-y-auto p-6">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# Profeto Admin 后台管理系统
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
Profeto 后台管理界面,为足球 LLM 预测系统提供运维管理能力。
|
||||||
|
暗色主题设计,支持响应式布局。
|
||||||
|
|
||||||
|
## 文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
src/admin/
|
||||||
|
├── AdminApp.tsx # Admin 应用入口(独立路由)
|
||||||
|
├── AdminLayout.tsx # 管理后台布局(侧边栏 + 顶栏 + 内容区)
|
||||||
|
├── api.ts # 统一 API 客户端(带超时、错误处理、类型安全)
|
||||||
|
├── dal.ts # 数据访问层(封装所有 API 端点调用)
|
||||||
|
├── types.ts # TypeScript 类型定义(与 FastAPI Pydantic 对齐)
|
||||||
|
├── components.tsx # 通用 UI 组件(Card, Table, Badge, ProgressBar...)
|
||||||
|
├── routes.tsx # 路由定义(/admin/*)
|
||||||
|
└── pages/
|
||||||
|
├── Dashboard.tsx # 仪表盘(系统概览)
|
||||||
|
├── Collection.tsx # 数据采集(触发采集任务)
|
||||||
|
├── Predictions.tsx # 预测管理(触发预测 + 评估结算)
|
||||||
|
├── Backtest.tsx # 回测管理(策略验证)
|
||||||
|
├── Monitoring.tsx # 监控面板(健康检查 + 错误日志)
|
||||||
|
└── Config.tsx # 配置管理(API Key 管理)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 页面说明
|
||||||
|
|
||||||
|
### 1. 仪表盘 (`/admin`)
|
||||||
|
- 数据库表行数统计
|
||||||
|
- 最近采集任务状态
|
||||||
|
- 预测统计(总数、今日数、平均延迟)
|
||||||
|
- 最近错误日志
|
||||||
|
|
||||||
|
### 2. 数据采集 (`/admin/collection`)
|
||||||
|
- 选择数据源: Bzzoiro / Understat / Injuries
|
||||||
|
- 选择联赛、日期范围
|
||||||
|
- 触发采集任务
|
||||||
|
- 实时任务进度显示(每 5 秒自动刷新)
|
||||||
|
|
||||||
|
### 3. 预测管理 (`/admin/predictions`)
|
||||||
|
- 触发预测(全部联赛或指定联赛)
|
||||||
|
- 模式切换: 五路专家 / 单一模型
|
||||||
|
- 预测历史分页查看
|
||||||
|
- 评估结算(回填实际结果)
|
||||||
|
|
||||||
|
### 4. 回测管理 (`/admin/backtest`)
|
||||||
|
- 策略选择: 置信度加权 / Kelly准则 / 固定金额
|
||||||
|
- 联赛多选、日期范围、初始资金配置
|
||||||
|
- 回测结果: ROI、胜率、最大回撤、夏普比率
|
||||||
|
- 交易明细可展开查看
|
||||||
|
|
||||||
|
### 5. 监控面板 (`/admin/monitoring`)
|
||||||
|
- 系统健康状态(服务 + 检查项)
|
||||||
|
- 错误日志(每 10 秒自动刷新)
|
||||||
|
- 死信队列监控
|
||||||
|
|
||||||
|
### 6. 配置管理 (`/admin/config`)
|
||||||
|
- API Key 配置(LLM / 数据源 / 系统)
|
||||||
|
- 脱敏显示 + 在线编辑
|
||||||
|
|
||||||
|
## 路由设计
|
||||||
|
|
||||||
|
| 路径 | 页面 | 描述 |
|
||||||
|
|------|------|------|
|
||||||
|
| `/` | 先知主站 | 报纸风格预测展示 |
|
||||||
|
| `/admin` | 仪表盘 | 系统概览 |
|
||||||
|
| `/admin/collection` | 数据采集 | 采集任务管理 |
|
||||||
|
| `/admin/predictions` | 预测管理 | 预测与评估 |
|
||||||
|
| `/admin/backtest` | 回测管理 | 策略回测 |
|
||||||
|
| `/admin/monitoring` | 监控面板 | 系统监控 |
|
||||||
|
| `/admin/config` | 配置管理 | 参数配置 |
|
||||||
|
|
||||||
|
## 技术实现
|
||||||
|
|
||||||
|
- **路由**: `react-router-dom` v6 嵌套路由
|
||||||
|
- **样式**: Tailwind CSS 暗色主题 (gray-900/800/700)
|
||||||
|
- **API**: 统一 fetch 客户端,30s 超时,类型安全
|
||||||
|
- **类型**: TypeScript strict mode,与后端 Pydantic 模型对齐
|
||||||
|
- **错误处理**: ApiError 类 + 页面级错误展示
|
||||||
|
- **自动刷新**: 采集(5s)、监控(10s)
|
||||||
|
|
||||||
|
## 启动方式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /vol2/1000/Docker/Profeto/frontend
|
||||||
|
npm run dev
|
||||||
|
# 访问 http://localhost:5173/admin
|
||||||
|
```
|
||||||
|
|
||||||
|
## 访问入口
|
||||||
|
|
||||||
|
主站页面顶部「管理后台」链接可跳转至 `/admin`。
|
||||||
|
Admin 后台左侧底栏「返回前台」链接可回到 `/`。
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* Admin 后台管理系统 - 统一 API 客户端
|
||||||
|
*
|
||||||
|
* 封装 fetch 调用,提供:
|
||||||
|
* - 统一错误处理
|
||||||
|
* - 请求/响应日志
|
||||||
|
* - 超时控制
|
||||||
|
* - 类型安全的响应解析
|
||||||
|
*/
|
||||||
|
|
||||||
|
const API_BASE = '/api/v1'
|
||||||
|
const TIMEOUT_MS = 30_000
|
||||||
|
|
||||||
|
/** 通用 API 错误类型 */
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public status: number,
|
||||||
|
public data?: unknown,
|
||||||
|
) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'ApiError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 基础 fetch 封装,带超时和错误处理 */
|
||||||
|
async function request<T>(
|
||||||
|
path: string,
|
||||||
|
options: RequestInit = {},
|
||||||
|
): Promise<T> {
|
||||||
|
const url = path.startsWith('http') ? path : `${path}`
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
let detail: unknown
|
||||||
|
try {
|
||||||
|
detail = await res.json()
|
||||||
|
} catch {
|
||||||
|
detail = await res.text()
|
||||||
|
}
|
||||||
|
throw new ApiError(
|
||||||
|
detail && typeof detail === 'object' && 'detail' in detail
|
||||||
|
? String((detail as { detail: unknown }).detail)
|
||||||
|
: `HTTP ${res.status}: ${res.statusText}`,
|
||||||
|
res.status,
|
||||||
|
detail,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 204 No Content
|
||||||
|
if (res.status === 200 && res.headers.get('content-length') === '0') {
|
||||||
|
return undefined as T
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json() as Promise<T>
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError) throw err
|
||||||
|
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||||
|
throw new ApiError('请求超时,请稍后重试', 0)
|
||||||
|
}
|
||||||
|
throw new ApiError(
|
||||||
|
err instanceof Error ? err.message : '网络错误,请检查连接',
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 通用 CRUD 快捷方法 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
get: <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 }),
|
||||||
|
|
||||||
|
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
|
||||||
|
}
|
||||||
|
|
||||||
|
export { API_BASE }
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
/**
|
||||||
|
* Admin 后台 - 通用 UI 组件集合
|
||||||
|
*
|
||||||
|
* 提供管理界面使用的所有基础组件:
|
||||||
|
* - Card: 内容卡片
|
||||||
|
* - StatCard: 统计数字卡片
|
||||||
|
* - Badge: 状态标签
|
||||||
|
* - DataTable: 通用数据表格
|
||||||
|
* - ProgressBar: 进度条
|
||||||
|
* - EmptyState: 空状态占位
|
||||||
|
* - SectionHeader: 小节标题
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ReactNode } from 'react'
|
||||||
|
|
||||||
|
// ── 卡片 ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function Card({
|
||||||
|
children,
|
||||||
|
className = '',
|
||||||
|
}: {
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={`rounded-lg border border-gray-800 bg-gray-900 ${className}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardHeader({
|
||||||
|
title,
|
||||||
|
action,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
action?: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between border-b border-gray-800 px-5 py-3">
|
||||||
|
<h3 className="text-sm font-medium text-gray-200">{title}</h3>
|
||||||
|
{action}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardBody({
|
||||||
|
children,
|
||||||
|
className = '',
|
||||||
|
}: {
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
return <div className={`px-5 py-4 ${className}`}>{children}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 统计卡片 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function StatCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
icon,
|
||||||
|
trend,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: string | number
|
||||||
|
icon?: string
|
||||||
|
trend?: { value: number; positive: boolean }
|
||||||
|
}) {
|
||||||
|
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>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</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',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Badge({
|
||||||
|
status,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
status: string
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
const colorClass = BADGE_COLORS[status] ?? 'bg-gray-500/15 text-gray-400 border-gray-500/30'
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${colorClass}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 数据表格 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export function DataTable<T = any>({
|
||||||
|
columns,
|
||||||
|
data,
|
||||||
|
rowKey,
|
||||||
|
emptyText = '暂无数据',
|
||||||
|
}: {
|
||||||
|
columns: { key: string; label: string; render?: (row: T) => ReactNode; width?: string }[]
|
||||||
|
data: T[]
|
||||||
|
rowKey: (row: T) => string | number
|
||||||
|
emptyText?: string
|
||||||
|
}) {
|
||||||
|
if (data.length === 0) {
|
||||||
|
return <EmptyState text={emptyText} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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">
|
||||||
|
{columns.map(col => (
|
||||||
|
<th key={col.key} className="px-3 py-2.5 font-medium" style={{ width: col.width }}>
|
||||||
|
{col.label}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.map(row => (
|
||||||
|
<tr
|
||||||
|
key={rowKey(row)}
|
||||||
|
className="border-b border-gray-800/50 transition-colors hover:bg-gray-800/30"
|
||||||
|
>
|
||||||
|
{columns.map(col => (
|
||||||
|
<td key={col.key} className="px-3 py-2.5 text-gray-300">
|
||||||
|
{col.render
|
||||||
|
? col.render(row)
|
||||||
|
: row != null && typeof row === 'object' && col.key in row
|
||||||
|
? String((row as Record<string, unknown>)[col.key] ?? '—')
|
||||||
|
: '—'}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 进度条 ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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-full rounded-full bg-blue-500 transition-all duration-300"
|
||||||
|
style={{ width: `${clamped}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 空状态 ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function EmptyState({ text = '暂无数据' }: { text?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12 text-sm text-gray-600">
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 小节标题 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function SectionHeader({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
/**
|
||||||
|
* Admin 后台 - 数据访问层
|
||||||
|
*
|
||||||
|
* 封装所有 API 端点调用,返回类型安全的数据。
|
||||||
|
* 页面组件直接调用这些函数,无需关心网络细节。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { api, API_BASE } from './api'
|
||||||
|
import type {
|
||||||
|
DashboardStats,
|
||||||
|
ConfigEntry,
|
||||||
|
ConfigUpdate,
|
||||||
|
CollectionRequest,
|
||||||
|
CollectionResponse,
|
||||||
|
CollectionTask,
|
||||||
|
PredictRequest,
|
||||||
|
PredictionHistoryItem,
|
||||||
|
SettleResponse,
|
||||||
|
EvalSummary,
|
||||||
|
BacktestRequest,
|
||||||
|
BacktestResult,
|
||||||
|
League,
|
||||||
|
Paginated,
|
||||||
|
ErrorLog,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
// ── 仪表盘 ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function fetchDashboard(): Promise<DashboardStats> {
|
||||||
|
// 后端可能没有专门的仪表盘聚合端点,这里用 health + 各端点组合
|
||||||
|
const health = await fetchHealth()
|
||||||
|
return {
|
||||||
|
db_tables: health.db_tables ?? [],
|
||||||
|
last_collection: health.last_collection ?? [],
|
||||||
|
prediction_stats: health.prediction_stats ?? {
|
||||||
|
total_predictions: 0,
|
||||||
|
today_predictions: 0,
|
||||||
|
avg_latency_ms: 0,
|
||||||
|
success_rate: 0,
|
||||||
|
},
|
||||||
|
recent_errors: health.recent_errors ?? [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 后端 health 端点暂时返回基础信息,仪表盘从多端点拼装 */
|
||||||
|
async function fetchHealth() {
|
||||||
|
try {
|
||||||
|
const res = await api.get<{ status: string }>('/health')
|
||||||
|
return res as unknown as DashboardStats
|
||||||
|
} catch {
|
||||||
|
return {} as DashboardStats
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 配置管理 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function fetchConfigs(): Promise<ConfigEntry[]> {
|
||||||
|
try {
|
||||||
|
return await api.get<ConfigEntry[]>(`${API_BASE}/configs`)
|
||||||
|
} catch {
|
||||||
|
// 后端暂未实现配置端点,返回空列表
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateConfig(update: ConfigUpdate): Promise<void> {
|
||||||
|
await api.post(`${API_BASE}/configs`, update)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 数据采集 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function triggerCollection(req: CollectionRequest): Promise<CollectionResponse> {
|
||||||
|
const sourceMap: Record<string, string> = {
|
||||||
|
bzzoiro: `${API_BASE}/ingest/bzzoiro`,
|
||||||
|
understat: `${API_BASE}/ingest/understat`,
|
||||||
|
injuries: `${API_BASE}/ingest/injuries`,
|
||||||
|
}
|
||||||
|
return api.post<CollectionResponse>(sourceMap[req.source], req)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchCollectionTasks(): Promise<CollectionTask[]> {
|
||||||
|
try {
|
||||||
|
return await api.get<CollectionTask[]>(`${API_BASE}/admin/tasks`)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 预测管理 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function triggerPrediction(req: PredictRequest): Promise<unknown> {
|
||||||
|
return api.post(`${API_BASE}/predict`, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPredictionHistory(
|
||||||
|
page = 1,
|
||||||
|
pageSize = 20,
|
||||||
|
): Promise<Paginated<PredictionHistoryItem>> {
|
||||||
|
try {
|
||||||
|
return await api.get<Paginated<PredictionHistoryItem>>(
|
||||||
|
`${API_BASE}/predictions?page=${page}&page_size=${pageSize}`,
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return { items: [], total: 0, page: 1, page_size: pageSize, has_next: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 评估 & 结算 ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function triggerSettle(): Promise<SettleResponse> {
|
||||||
|
return api.post<SettleResponse>(`${API_BASE}/eval/settle`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchEvalSummary(): Promise<EvalSummary | null> {
|
||||||
|
try {
|
||||||
|
return await api.get<EvalSummary>(`${API_BASE}/eval/summary`)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 回测管理 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function triggerBacktest(req: BacktestRequest): Promise<BacktestResult> {
|
||||||
|
return api.post<BacktestResult>(`${API_BASE}/backtest`, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchBacktestResult(taskId: string): Promise<BacktestResult | null> {
|
||||||
|
try {
|
||||||
|
return await api.get<BacktestResult>(`${API_BASE}/backtest/${taskId}`)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchBacktestHistory(): Promise<BacktestResult[]> {
|
||||||
|
try {
|
||||||
|
return await api.get<BacktestResult[]>(`${API_BASE}/backtest`)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 辅助数据 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function fetchLeagues(): Promise<League[]> {
|
||||||
|
try {
|
||||||
|
return await api.get<League[]>(`${API_BASE}/leagues`)
|
||||||
|
} catch {
|
||||||
|
return [
|
||||||
|
{ code: 'E0', name: 'Premier League', name_zh: '英超', country: 'England' },
|
||||||
|
{ code: 'SP1', name: 'La Liga', name_zh: '西甲', country: 'Spain' },
|
||||||
|
{ code: 'D1', name: 'Bundesliga', name_zh: '德甲', country: 'Germany' },
|
||||||
|
{ code: 'I1', name: 'Serie A', name_zh: '意甲', country: 'Italy' },
|
||||||
|
{ code: 'F1', name: 'Ligue 1', name_zh: '法甲', country: 'France' },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 监控 & 错误日志 ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function fetchErrorLogs(limit = 50): Promise<ErrorLog[]> {
|
||||||
|
try {
|
||||||
|
return await api.get<ErrorLog[]>(`${API_BASE}/admin/logs?limit=${limit}`)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
/**
|
||||||
|
* Admin 后台 - 回测管理页面
|
||||||
|
*
|
||||||
|
* 功能:
|
||||||
|
* - 配置回测参数(策略、联赛、日期范围、初始资金)
|
||||||
|
* - 触发回测任务
|
||||||
|
* - 查看回测结果(收益率、最大回撤、夏普比率等)
|
||||||
|
* - 交易记录明细
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { triggerBacktest, fetchBacktestHistory, fetchLeagues } from '../dal'
|
||||||
|
import type { BacktestRequest, BacktestResult, BacktestTrade, League } from '../types'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardBody,
|
||||||
|
CardHeader,
|
||||||
|
Badge,
|
||||||
|
ProgressBar,
|
||||||
|
DataTable,
|
||||||
|
EmptyState,
|
||||||
|
} from '../components'
|
||||||
|
import { SectionHeader } from '../components'
|
||||||
|
|
||||||
|
const STRATEGIES = [
|
||||||
|
{ value: 'confidence_weighted', label: '置信度加权', desc: '按预测置信度调整仓位' },
|
||||||
|
{ value: 'kelly', label: 'Kelly 准则', desc: 'Kelly 公式优化投注比例' },
|
||||||
|
{ value: 'flat', label: '固定金额', desc: '每次固定金额投注' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function BacktestPage() {
|
||||||
|
const [leagues, setLeagues] = useState<League[]>([])
|
||||||
|
const [tasks, setTasks] = useState<BacktestResult[]>([])
|
||||||
|
const [strategy, setStrategy] = useState('confidence_weighted')
|
||||||
|
const [selectedLeagues, setSelectedLeagues] = useState<string[]>(['E0'])
|
||||||
|
const [dateFrom, setDateFrom] = useState('')
|
||||||
|
const [dateTo, setDateTo] = useState('')
|
||||||
|
const [bankroll, setBankroll] = useState(1000)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [successMsg, setSuccessMsg] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([fetchLeagues(), fetchBacktestHistory()]).then(([lg, ts]) => {
|
||||||
|
setLeagues(lg)
|
||||||
|
setTasks(ts)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError(null)
|
||||||
|
setSuccessMsg(null)
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body: BacktestRequest = {
|
||||||
|
strategy,
|
||||||
|
league_codes: selectedLeagues,
|
||||||
|
date_from: dateFrom || '2024-01-01',
|
||||||
|
date_to: dateTo || new Date().toISOString().split('T')[0],
|
||||||
|
initial_bankroll: bankroll,
|
||||||
|
}
|
||||||
|
const res = await triggerBacktest(body)
|
||||||
|
setSuccessMsg(`回测任务已启动: ${res.task_id}`)
|
||||||
|
// 刷新列表
|
||||||
|
const ts = await fetchBacktestHistory()
|
||||||
|
setTasks(ts)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : '回测触发失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleLeague(code: string) {
|
||||||
|
setSelectedLeagues(prev =>
|
||||||
|
prev.includes(code) ? prev.filter(c => c !== code) : [...prev, code],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<SectionHeader
|
||||||
|
title="回测管理"
|
||||||
|
description="配置回测参数,验证预测策略在历史数据上的表现"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-5">
|
||||||
|
{/* ── 回测配置表单 ── */}
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader title="回测配置" />
|
||||||
|
<CardBody>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{/* 策略选择 */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-xs font-medium text-gray-400">策略</label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{STRATEGIES.map(s => (
|
||||||
|
<label
|
||||||
|
key={s.value}
|
||||||
|
className={`flex cursor-pointer items-start gap-3 rounded-md border p-3 transition-colors ${
|
||||||
|
strategy === s.value
|
||||||
|
? 'border-blue-500/50 bg-blue-500/10'
|
||||||
|
: 'border-gray-700 hover:border-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="strategy"
|
||||||
|
value={s.value}
|
||||||
|
checked={strategy === s.value}
|
||||||
|
onChange={e => setStrategy(e.target.value)}
|
||||||
|
className="mt-0.5 accent-blue-500"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-200">{s.label}</div>
|
||||||
|
<div className="text-xs text-gray-500">{s.desc}</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 联赛多选 */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-xs font-medium text-gray-400">
|
||||||
|
联赛(多选)
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{leagues.map(l => (
|
||||||
|
<button
|
||||||
|
key={l.code}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleLeague(l.code)}
|
||||||
|
className={`rounded-md border px-2.5 py-1 text-xs transition-colors ${
|
||||||
|
selectedLeagues.includes(l.code)
|
||||||
|
? 'border-blue-500/50 bg-blue-500/10 text-blue-400'
|
||||||
|
: 'border-gray-700 text-gray-500 hover:border-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{l.name_zh ?? l.code}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 日期范围 */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-xs font-medium text-gray-400">开始日期</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dateFrom}
|
||||||
|
onChange={e => setDateFrom(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 focus:border-blue-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-xs font-medium text-gray-400">结束日期</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dateTo}
|
||||||
|
onChange={e => setDateTo(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 focus:border-blue-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 初始资金 */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-xs font-medium text-gray-400">初始资金</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={bankroll}
|
||||||
|
onChange={e => setBankroll(Number(e.target.value))}
|
||||||
|
min={100}
|
||||||
|
step={100}
|
||||||
|
className="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 focus:border-blue-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-400">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{successMsg && (
|
||||||
|
<div className="rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-xs text-emerald-400">
|
||||||
|
{successMsg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || selectedLeagues.length === 0}
|
||||||
|
className="w-full rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? '提交中...' : '启动回测'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 回测任务与结果 ── */}
|
||||||
|
<div className="lg:col-span-3">
|
||||||
|
<Card>
|
||||||
|
<CardHeader title="回测任务" />
|
||||||
|
<CardBody className="p-0">
|
||||||
|
{tasks.length === 0 ? (
|
||||||
|
<EmptyState text="暂无回测任务" />
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-gray-800">
|
||||||
|
{tasks.map(task => (
|
||||||
|
<BacktestResultRow key={task.task_id} task={task} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BacktestResultRow({ task }: { task: BacktestResult }) {
|
||||||
|
const m = task.metrics
|
||||||
|
return (
|
||||||
|
<div className="px-5 py-4">
|
||||||
|
{/* 头部 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-mono text-xs text-gray-500">{task.task_id.slice(0, 8)}</span>
|
||||||
|
<Badge status={task.status}>{task.status}</Badge>
|
||||||
|
</div>
|
||||||
|
{m && (
|
||||||
|
<span
|
||||||
|
className={`text-sm font-semibold tabular-nums ${
|
||||||
|
m.profit_loss >= 0 ? 'text-emerald-400' : 'text-red-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{m.profit_loss >= 0 ? '+' : ''}
|
||||||
|
{m.profit_loss.toFixed(0)} ({(m.roi * 100).toFixed(1)}%)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 进度条 */}
|
||||||
|
{task.status === 'running' && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<ProgressBar value={task.progress} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 指标网格 */}
|
||||||
|
{m && (
|
||||||
|
<div className="mt-3 grid grid-cols-3 gap-3 lg:grid-cols-5">
|
||||||
|
<MiniStat label="交易数" value={m.total_trades} />
|
||||||
|
<MiniStat label="胜率" value={`${(m.win_rate * 100).toFixed(1)}%`} />
|
||||||
|
<MiniStat label="ROI" value={`${(m.roi * 100).toFixed(1)}%`} />
|
||||||
|
<MiniStat label="最大回撤" value={`${(m.max_drawdown * 100).toFixed(1)}%`} />
|
||||||
|
<MiniStat label="夏普比率" value={m.sharpe_ratio?.toFixed(2) ?? '—'} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 错误 */}
|
||||||
|
{task.error_message && (
|
||||||
|
<p className="mt-2 text-xs text-red-400">{task.error_message}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 交易明细 */}
|
||||||
|
{task.trades && task.trades.length > 0 && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<details>
|
||||||
|
<summary className="cursor-pointer text-xs text-gray-500 hover:text-gray-300">
|
||||||
|
查看交易明细 ({task.trades.length} 笔)
|
||||||
|
</summary>
|
||||||
|
<div className="mt-2">
|
||||||
|
<DataTable
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
key: 'match',
|
||||||
|
label: '比赛',
|
||||||
|
render: (row: BacktestTrade) => `${row.home_team} vs ${row.away_team}`,
|
||||||
|
},
|
||||||
|
{ key: 'bet_type', label: '类型', width: '60px' },
|
||||||
|
{ key: 'stake', label: '投注', width: '70px' },
|
||||||
|
{ key: 'odds', label: '赔率', width: '60px' },
|
||||||
|
{
|
||||||
|
key: 'result',
|
||||||
|
label: '结果',
|
||||||
|
width: '60px',
|
||||||
|
render: (row: BacktestTrade) => <Badge status={row.result}>{row.result}</Badge>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'profit',
|
||||||
|
label: '盈亏',
|
||||||
|
width: '80px',
|
||||||
|
render: (row: BacktestTrade) => (
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
row.profit >= 0 ? 'text-emerald-400' : 'text-red-400'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{row.profit >= 0 ? '+' : ''}
|
||||||
|
{row.profit.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
data={task.trades}
|
||||||
|
rowKey={(row: BacktestTrade) => row.match_id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MiniStat({ label, value }: { label: string; value: string | number }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded border border-gray-800 px-2 py-1.5">
|
||||||
|
<div className="text-[10px] text-gray-600">{label}</div>
|
||||||
|
<div className="text-xs font-medium text-gray-300 tabular-nums">{value}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
/**
|
||||||
|
* Admin 后台 - 数据采集页面
|
||||||
|
*
|
||||||
|
* 功能:
|
||||||
|
* - 选择数据源(bzzoiro / understat / injuries)
|
||||||
|
* - 选择联赛、日期范围
|
||||||
|
* - 触发采集任务
|
||||||
|
* - 实时显示任务进度
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { triggerCollection, fetchCollectionTasks, fetchLeagues } from '../dal'
|
||||||
|
import type { CollectionRequest, CollectionTask, League } from '../types'
|
||||||
|
import { Card, CardBody, CardHeader, Badge, ProgressBar, EmptyState } from '../components'
|
||||||
|
import { SectionHeader } from '../components'
|
||||||
|
|
||||||
|
const SOURCES = [
|
||||||
|
{ value: 'bzzoiro', label: 'Bzzoiro', desc: '历史赛程与比分数据' },
|
||||||
|
{ value: 'understat', label: 'Understat', desc: '进阶统计数据(xG/xA)' },
|
||||||
|
{ value: 'injuries', label: 'Injuries', desc: '球员伤停信息' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export default function CollectionPage() {
|
||||||
|
const [leagues, setLeagues] = useState<League[]>([])
|
||||||
|
const [tasks, setTasks] = useState<CollectionTask[]>([])
|
||||||
|
const [source, setSource] = useState<string>('bzzoiro')
|
||||||
|
const [leagueCode, setLeagueCode] = useState<string>('')
|
||||||
|
const [dateFrom, setDateFrom] = useState('')
|
||||||
|
const [dateTo, setDateTo] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [successMsg, setSuccessMsg] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// 加载联赛列表和任务历史
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
const [lg, ts] = await Promise.all([fetchLeagues(), fetchCollectionTasks()])
|
||||||
|
setLeagues(lg)
|
||||||
|
setTasks(ts)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData()
|
||||||
|
// 自动刷新任务状态(每 5 秒)
|
||||||
|
const timer = setInterval(loadData, 5000)
|
||||||
|
return () => clearInterval(timer)
|
||||||
|
}, [loadData])
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError(null)
|
||||||
|
setSuccessMsg(null)
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body: CollectionRequest = {
|
||||||
|
source: source as CollectionRequest['source'],
|
||||||
|
league_code: leagueCode || undefined,
|
||||||
|
date_from: dateFrom || undefined,
|
||||||
|
date_to: dateTo || undefined,
|
||||||
|
}
|
||||||
|
const res = await triggerCollection(body)
|
||||||
|
setSuccessMsg(`任务已创建: ${res.message}`)
|
||||||
|
loadData()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : '采集触发失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<SectionHeader
|
||||||
|
title="数据采集"
|
||||||
|
description="触发数据源采集任务,支持联赛筛选和日期范围"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-5">
|
||||||
|
{/* ── 采集配置表单 ── */}
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader title="新建采集任务" />
|
||||||
|
<CardBody>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{/* 数据源 */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-xs font-medium text-gray-400">
|
||||||
|
数据源
|
||||||
|
</label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{SOURCES.map(s => (
|
||||||
|
<label
|
||||||
|
key={s.value}
|
||||||
|
className={`flex cursor-pointer items-start gap-3 rounded-md border p-3 transition-colors ${
|
||||||
|
source === s.value
|
||||||
|
? 'border-blue-500/50 bg-blue-500/10'
|
||||||
|
: 'border-gray-700 hover:border-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="source"
|
||||||
|
value={s.value}
|
||||||
|
checked={source === s.value}
|
||||||
|
onChange={e => setSource(e.target.value)}
|
||||||
|
className="mt-0.5 accent-blue-500"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-200">{s.label}</div>
|
||||||
|
<div className="text-xs text-gray-500">{s.desc}</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 联赛 */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-xs font-medium text-gray-400">
|
||||||
|
联赛(可选)
|
||||||
|
</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 text-sm text-gray-200 focus:border-blue-500 focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">全部联赛</option>
|
||||||
|
{leagues.map(l => (
|
||||||
|
<option key={l.code} value={l.code}>
|
||||||
|
{l.name_zh ?? l.name} ({l.code})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 日期范围 */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-xs font-medium text-gray-400">
|
||||||
|
开始日期
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dateFrom}
|
||||||
|
onChange={e => setDateFrom(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 focus:border-blue-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-xs font-medium text-gray-400">
|
||||||
|
结束日期
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dateTo}
|
||||||
|
onChange={e => setDateTo(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 focus:border-blue-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 提示消息 */}
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-400">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{successMsg && (
|
||||||
|
<div className="rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-xs text-emerald-400">
|
||||||
|
{successMsg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 提交 */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? '提交中...' : '启动采集'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 任务列表 ── */}
|
||||||
|
<div className="lg:col-span-3">
|
||||||
|
<Card>
|
||||||
|
<CardHeader title="任务队列" />
|
||||||
|
<CardBody className="p-0">
|
||||||
|
{tasks.length === 0 ? (
|
||||||
|
<EmptyState text="暂无采集任务" />
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-gray-800">
|
||||||
|
{tasks.map(task => (
|
||||||
|
<TaskRow key={task.task_id} task={task} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskRow({ task }: { task: CollectionTask }) {
|
||||||
|
return (
|
||||||
|
<div className="px-5 py-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-gray-200">{task.source}</span>
|
||||||
|
<Badge status={task.status}>{task.status}</Badge>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{task.processed}/{task.total}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{task.status === 'running' && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<ProgressBar value={task.progress} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{task.error_message && (
|
||||||
|
<p className="mt-1.5 text-xs text-red-400">{task.error_message}</p>
|
||||||
|
)}
|
||||||
|
{task.finished_at && (
|
||||||
|
<p className="mt-1 text-xs text-gray-600">
|
||||||
|
完成于 {new Date(task.finished_at).toLocaleString('zh-CN')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
/**
|
||||||
|
* Admin 后台 - 配置管理页面
|
||||||
|
*
|
||||||
|
* 功能:
|
||||||
|
* - 查看当前 API Key 配置(脱敏显示)
|
||||||
|
* - 更新 LLM / 数据源密钥
|
||||||
|
* - 配置分类管理(LLM / 数据源 / 系统)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { fetchConfigs, updateConfig } from '../dal'
|
||||||
|
import type { ConfigEntry } from '../types'
|
||||||
|
import { Card, CardBody, CardHeader, Badge, EmptyState } from '../components'
|
||||||
|
import { SectionHeader } from '../components'
|
||||||
|
|
||||||
|
const CATEGORY_LABELS: Record<string, string> = {
|
||||||
|
llm: 'LLM 服务',
|
||||||
|
datasource: '数据源',
|
||||||
|
system: '系统配置',
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORY_ORDER = ['llm', 'datasource', 'system']
|
||||||
|
|
||||||
|
export default function ConfigPage() {
|
||||||
|
const [configs, setConfigs] = useState<ConfigEntry[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [editing, setEditing] = useState<string | null>(null)
|
||||||
|
const [editValue, setEditValue] = useState('')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [successMsg, setSuccessMsg] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchConfigs()
|
||||||
|
.then(setConfigs)
|
||||||
|
.catch(err => setError(err instanceof Error ? err.message : '加载配置失败'))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function startEdit(key: string, currentValue: string) {
|
||||||
|
setEditing(key)
|
||||||
|
setEditValue(currentValue.replace(/\*+$/, '')) // 去掉脱敏星号
|
||||||
|
setError(null)
|
||||||
|
setSuccessMsg(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEdit(key: string) {
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
setSuccessMsg(null)
|
||||||
|
try {
|
||||||
|
await updateConfig({ key, value: editValue })
|
||||||
|
setSuccessMsg(`${key} 已更新`)
|
||||||
|
setEditing(null)
|
||||||
|
// 刷新列表
|
||||||
|
const updated = await fetchConfigs()
|
||||||
|
setConfigs(updated)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '保存失败')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按分类分组
|
||||||
|
const grouped = CATEGORY_ORDER.reduce(
|
||||||
|
(acc, cat) => {
|
||||||
|
acc[cat] = configs.filter(c => c.category === cat)
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{} as Record<string, ConfigEntry[]>,
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<SectionHeader
|
||||||
|
title="配置管理"
|
||||||
|
description="管理 API Key 和系统参数(存储在 .env 文件)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-400">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{successMsg && (
|
||||||
|
<div className="rounded-md border border-emerald-500/30 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-400">
|
||||||
|
{successMsg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Card>
|
||||||
|
<CardBody>
|
||||||
|
<EmptyState text="加载配置中..." />
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
) : configs.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardBody>
|
||||||
|
<div className="py-8 text-center">
|
||||||
|
<p className="text-sm text-gray-500">暂无可管理的配置项</p>
|
||||||
|
<p className="mt-1 text-xs text-gray-600">
|
||||||
|
后端暂未实现配置管理 API,配置需通过 .env 文件手动管理
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
CATEGORY_ORDER.map(cat => {
|
||||||
|
const items = grouped[cat]
|
||||||
|
if (!items || items.length === 0) return null
|
||||||
|
return (
|
||||||
|
<Card key={cat}>
|
||||||
|
<CardHeader title={CATEGORY_LABELS[cat]} />
|
||||||
|
<CardBody className="p-0">
|
||||||
|
<div className="divide-y divide-gray-800">
|
||||||
|
{items.map(cfg => (
|
||||||
|
<ConfigRow
|
||||||
|
key={cfg.key}
|
||||||
|
config={cfg}
|
||||||
|
editing={editing === cfg.key}
|
||||||
|
editValue={editValue}
|
||||||
|
saving={saving}
|
||||||
|
onEditValueChange={setEditValue}
|
||||||
|
onStartEdit={() => startEdit(cfg.key, cfg.value)}
|
||||||
|
onSave={() => saveEdit(cfg.key)}
|
||||||
|
onCancel={() => setEditing(null)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 配置说明 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader title="配置说明" />
|
||||||
|
<CardBody>
|
||||||
|
<div className="space-y-3 text-xs text-gray-500">
|
||||||
|
<p>
|
||||||
|
<strong className="text-gray-400">LLM 服务:</strong> 用于五路专家预测的大语言模型 API Key
|
||||||
|
(如 OpenAI、Anthropic)
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong className="text-gray-400">数据源:</strong> 数据采集服务的认证密钥
|
||||||
|
(如 Understat、Bzzoiro)
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong className="text-gray-400">系统配置:</strong> 数据库连接、日志级别等运行时参数
|
||||||
|
</p>
|
||||||
|
<p className="rounded-md border border-yellow-500/20 bg-yellow-500/5 px-3 py-2 text-yellow-400/80">
|
||||||
|
⚠️ 配置修改后需要重启服务才能生效
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConfigRow({
|
||||||
|
config,
|
||||||
|
editing,
|
||||||
|
editValue,
|
||||||
|
saving,
|
||||||
|
onEditValueChange,
|
||||||
|
onStartEdit,
|
||||||
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
config: ConfigEntry
|
||||||
|
editing: boolean
|
||||||
|
editValue: string
|
||||||
|
saving: boolean
|
||||||
|
onEditValueChange: (v: string) => void
|
||||||
|
onStartEdit: () => void
|
||||||
|
onSave: () => void
|
||||||
|
onCancel: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-4 px-5 py-3">
|
||||||
|
{/* 键名 */}
|
||||||
|
<div className="w-48 flex-shrink-0">
|
||||||
|
<div className="font-mono text-xs text-gray-400">{config.key}</div>
|
||||||
|
{config.description && (
|
||||||
|
<div className="mt-0.5 text-[10px] text-gray-600">{config.description}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 值 */}
|
||||||
|
<div className="flex-1">
|
||||||
|
{editing ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editValue}
|
||||||
|
onChange={e => onEditValueChange(e.target.value)}
|
||||||
|
className="flex-1 rounded-md border border-gray-600 bg-gray-800 px-2 py-1 text-xs text-gray-200 focus:border-blue-500 focus:outline-none"
|
||||||
|
autoFocus
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter') onSave()
|
||||||
|
if (e.key === 'Escape') onCancel()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="rounded bg-blue-600 px-2 py-1 text-xs text-white hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
className="rounded border border-gray-700 px-2 py-1 text-xs text-gray-400 hover:border-gray-600"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="font-mono text-xs text-gray-300">{config.value}</code>
|
||||||
|
{config.updated_at && (
|
||||||
|
<span className="text-[10px] text-gray-600">
|
||||||
|
更新于 {new Date(config.updated_at).toLocaleDateString('zh-CN')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 操作 */}
|
||||||
|
{!editing && (
|
||||||
|
<button
|
||||||
|
onClick={onStartEdit}
|
||||||
|
className="flex-shrink-0 rounded border border-gray-700 px-2 py-1 text-xs text-gray-400 hover:border-gray-600"
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
/**
|
||||||
|
* Admin 后台 - 仪表盘
|
||||||
|
*
|
||||||
|
* 系统概览:
|
||||||
|
* - 数据库表行数统计
|
||||||
|
* - 最近采集状态
|
||||||
|
* - 预测统计
|
||||||
|
* - 最近错误日志
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { fetchDashboard } from '../dal'
|
||||||
|
import type { DashboardStats, TableStats, CollectionRecord, ErrorLog } from '../types'
|
||||||
|
import { Card, CardBody, CardHeader, StatCard, DataTable, Badge, EmptyState } from '../components'
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const [data, setData] = useState<DashboardStats | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
setLoading(true)
|
||||||
|
fetchDashboard()
|
||||||
|
.then((stats: DashboardStats) => {
|
||||||
|
if (active) setData(stats)
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
if (active) setError(err instanceof Error ? err.message : '加载失败')
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (active) setLoading(false)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* ── 统计卡片 ── */}
|
||||||
|
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||||
|
<StatCard
|
||||||
|
label="数据库表数"
|
||||||
|
value={loading ? '—' : data?.db_tables.length ?? 0}
|
||||||
|
icon="◫"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="总预测数"
|
||||||
|
value={loading ? '—' : data?.prediction_stats.total_predictions ?? 0}
|
||||||
|
icon="◆"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="今日预测"
|
||||||
|
value={loading ? '—' : data?.prediction_stats.today_predictions ?? 0}
|
||||||
|
icon="◇"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="平均延迟"
|
||||||
|
value={loading ? '—' : `${data?.prediction_stats.avg_latency_ms ?? 0}ms`}
|
||||||
|
icon="◷"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
{/* ── 数据库表统计 ── */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader title="数据库表状态" />
|
||||||
|
<CardBody className="p-0">
|
||||||
|
{loading ? (
|
||||||
|
<EmptyState text="加载中..." />
|
||||||
|
) : data && data.db_tables.length > 0 ? (
|
||||||
|
<DataTable
|
||||||
|
columns={[
|
||||||
|
{ key: 'name', label: '表名' },
|
||||||
|
{ key: 'row_count', label: '行数', width: '80px' },
|
||||||
|
{
|
||||||
|
key: 'last_updated',
|
||||||
|
label: '最后更新',
|
||||||
|
width: '180px',
|
||||||
|
render: (row: TableStats) =>
|
||||||
|
row.last_updated
|
||||||
|
? new Date(row.last_updated).toLocaleString('zh-CN')
|
||||||
|
: '—',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
data={data.db_tables}
|
||||||
|
rowKey={(row: TableStats) => row.name}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<EmptyState text="暂无表统计信息" />
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ── 最近采集记录 ── */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader title="最近采集任务" />
|
||||||
|
<CardBody className="p-0">
|
||||||
|
{loading ? (
|
||||||
|
<EmptyState text="加载中..." />
|
||||||
|
) : data && data.last_collection.length > 0 ? (
|
||||||
|
<DataTable
|
||||||
|
columns={[
|
||||||
|
{ key: 'source', label: '数据源' },
|
||||||
|
{ key: 'league_code', label: '联赛', width: '80px' },
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
label: '状态',
|
||||||
|
width: '90px',
|
||||||
|
render: (row: CollectionRecord) => <Badge status={row.status}>{statusLabel(row.status)}</Badge>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'finished_at',
|
||||||
|
label: '完成时间',
|
||||||
|
width: '160px',
|
||||||
|
render: (row: CollectionRecord) =>
|
||||||
|
row.finished_at
|
||||||
|
? new Date(row.finished_at).toLocaleString('zh-CN')
|
||||||
|
: '进行中',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
data={data.last_collection}
|
||||||
|
rowKey={(row: CollectionRecord) => `${row.source}-${row.started_at}`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<EmptyState text="暂无采集记录" />
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ── 最近错误日志 ── */}
|
||||||
|
<Card className="lg:col-span-2">
|
||||||
|
<CardHeader title="最近错误日志" />
|
||||||
|
<CardBody className="p-0">
|
||||||
|
{loading ? (
|
||||||
|
<EmptyState text="加载中..." />
|
||||||
|
) : data && data.recent_errors.length > 0 ? (
|
||||||
|
<DataTable
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
key: 'timestamp',
|
||||||
|
label: '时间',
|
||||||
|
width: '180px',
|
||||||
|
render: (row: ErrorLog) => new Date(row.timestamp).toLocaleString('zh-CN'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'level',
|
||||||
|
label: '级别',
|
||||||
|
width: '80px',
|
||||||
|
render: (row: ErrorLog) => <Badge status={row.level}>{row.level}</Badge>,
|
||||||
|
},
|
||||||
|
{ key: 'source', label: '来源', width: '120px' },
|
||||||
|
{ key: 'message', label: '消息' },
|
||||||
|
]}
|
||||||
|
data={data.recent_errors}
|
||||||
|
rowKey={(row: ErrorLog) => row.id}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<EmptyState text="暂无错误日志 ✓" />
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(status: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
running: '运行中',
|
||||||
|
success: '成功',
|
||||||
|
failed: '失败',
|
||||||
|
queued: '排队',
|
||||||
|
completed: '已完成',
|
||||||
|
}
|
||||||
|
return map[status] ?? status
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
/**
|
||||||
|
* Admin 后台 - 监控面板
|
||||||
|
*
|
||||||
|
* 功能:
|
||||||
|
* - 系统健康检查
|
||||||
|
* - 采集错误日志
|
||||||
|
* - 死信队列监控
|
||||||
|
* - 实时状态刷新
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { fetchErrorLogs } from '../dal'
|
||||||
|
import type { ErrorLog } from '../types'
|
||||||
|
import { Card, CardBody, CardHeader, Badge, DataTable, EmptyState } from '../components'
|
||||||
|
import { SectionHeader } from '../components'
|
||||||
|
|
||||||
|
interface HealthCheck {
|
||||||
|
name: string
|
||||||
|
status: 'pass' | 'fail' | 'warn'
|
||||||
|
detail?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HealthInfo {
|
||||||
|
status: 'ok' | 'degraded' | 'error'
|
||||||
|
version?: string
|
||||||
|
uptime?: string
|
||||||
|
checks: HealthCheck[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MonitoringPage() {
|
||||||
|
const [logs, setLogs] = useState<ErrorLog[]>([])
|
||||||
|
const [health, setHealth] = useState<HealthInfo | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [autoRefresh, setAutoRefresh] = useState(true)
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [logData, healthRes] = await Promise.all([
|
||||||
|
fetchErrorLogs(100),
|
||||||
|
fetch('/health').then(r => r.json()) as Promise<HealthInfo>,
|
||||||
|
])
|
||||||
|
setLogs(logData)
|
||||||
|
setHealth(healthRes)
|
||||||
|
} catch {
|
||||||
|
// 静默处理,保持上次数据
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData()
|
||||||
|
if (!autoRefresh) return
|
||||||
|
const timer = setInterval(loadData, 10_000)
|
||||||
|
return () => clearInterval(timer)
|
||||||
|
}, [loadData, autoRefresh])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<SectionHeader
|
||||||
|
title="监控面板"
|
||||||
|
description="系统健康状态、错误日志和死信队列监控"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── 系统健康 ── */}
|
||||||
|
<div className="grid gap-4 lg:grid-cols-3">
|
||||||
|
<Card>
|
||||||
|
<CardHeader title="服务状态" />
|
||||||
|
<CardBody>
|
||||||
|
{health ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`inline-block h-3 w-3 rounded-full ${
|
||||||
|
health.status === 'ok'
|
||||||
|
? 'bg-emerald-500'
|
||||||
|
: health.status === 'degraded'
|
||||||
|
? 'bg-yellow-500'
|
||||||
|
: 'bg-red-500'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium text-gray-200">
|
||||||
|
{health.status === 'ok'
|
||||||
|
? '运行正常'
|
||||||
|
: health.status === 'degraded'
|
||||||
|
? '部分降级'
|
||||||
|
: '异常'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 text-xs text-gray-500">
|
||||||
|
<div>版本: {health.version ?? '—'}</div>
|
||||||
|
<div>运行时间: {health.uptime ?? '—'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : loading ? (
|
||||||
|
<EmptyState text="加载中..." />
|
||||||
|
) : (
|
||||||
|
<EmptyState text="无法获取健康状态" />
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 健康检查项 */}
|
||||||
|
<Card className="lg:col-span-2">
|
||||||
|
<CardHeader title="健康检查项" />
|
||||||
|
<CardBody>
|
||||||
|
{health && health.checks && health.checks.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{health.checks.map((check: HealthCheck) => (
|
||||||
|
<div
|
||||||
|
key={check.name}
|
||||||
|
className="flex items-center justify-between rounded-md border border-gray-800 px-3 py-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`inline-block h-2 w-2 rounded-full ${
|
||||||
|
check.status === 'pass'
|
||||||
|
? 'bg-emerald-500'
|
||||||
|
: check.status === 'warn'
|
||||||
|
? 'bg-yellow-500'
|
||||||
|
: 'bg-red-500'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-300">{check.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{check.detail && (
|
||||||
|
<span className="text-xs text-gray-500">{check.detail}</span>
|
||||||
|
)}
|
||||||
|
<Badge status={check.status === 'pass' ? 'success' : check.status === 'warn' ? 'warning' : 'error'}>
|
||||||
|
{check.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EmptyState text="暂无检查项数据" />
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 错误日志 ── */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader
|
||||||
|
title="错误日志"
|
||||||
|
action={
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="flex items-center gap-1.5 text-xs text-gray-500">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={autoRefresh}
|
||||||
|
onChange={e => setAutoRefresh(e.target.checked)}
|
||||||
|
className="accent-blue-500"
|
||||||
|
/>
|
||||||
|
自动刷新
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
onClick={loadData}
|
||||||
|
className="rounded border border-gray-700 px-2 py-0.5 text-xs text-gray-400 hover:border-gray-600"
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<CardBody className="p-0">
|
||||||
|
<DataTable
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
key: 'timestamp',
|
||||||
|
label: '时间',
|
||||||
|
width: '180px',
|
||||||
|
render: (row: ErrorLog) => (
|
||||||
|
<span className="font-mono text-xs">
|
||||||
|
{new Date(row.timestamp).toLocaleString('zh-CN')}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'level',
|
||||||
|
label: '级别',
|
||||||
|
width: '80px',
|
||||||
|
render: (row: ErrorLog) => <Badge status={row.level}>{row.level}</Badge>,
|
||||||
|
},
|
||||||
|
{ key: 'source', label: '来源', width: '120px' },
|
||||||
|
{ key: 'message', label: '消息' },
|
||||||
|
]}
|
||||||
|
data={logs}
|
||||||
|
rowKey={(row: ErrorLog) => row.id}
|
||||||
|
/>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ── 死信队列 ── */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader title="死信队列" />
|
||||||
|
<CardBody>
|
||||||
|
<DeadLetterQueue />
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeadLetterQueue() {
|
||||||
|
// 死信队列数据 - 实际应从后端获取
|
||||||
|
interface DLQItem {
|
||||||
|
id: string
|
||||||
|
source: string
|
||||||
|
error: string
|
||||||
|
payload: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const [items] = useState<DLQItem[]>([])
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl text-gray-700" aria-hidden="true">
|
||||||
|
✓
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm text-gray-500">死信队列为空</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataTable
|
||||||
|
columns={[
|
||||||
|
{ key: 'source', label: '来源', width: '120px' },
|
||||||
|
{ key: 'error', label: '错误' },
|
||||||
|
{
|
||||||
|
key: 'created_at',
|
||||||
|
label: '时间',
|
||||||
|
width: '180px',
|
||||||
|
render: (row: DLQItem) => new Date(row.created_at).toLocaleString('zh-CN'),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
data={items}
|
||||||
|
rowKey={(row: DLQItem) => row.id}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
/**
|
||||||
|
* Admin 后台 - 预测管理页面
|
||||||
|
*
|
||||||
|
* 功能:
|
||||||
|
* - 触发手动预测(选择比赛或联赛)
|
||||||
|
* - 查看预测历史记录
|
||||||
|
* - 评估结算(回填实际结果)
|
||||||
|
* - 评估统计摘要
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import {
|
||||||
|
triggerPrediction,
|
||||||
|
fetchPredictionHistory,
|
||||||
|
triggerSettle,
|
||||||
|
fetchEvalSummary,
|
||||||
|
fetchLeagues,
|
||||||
|
} from '../dal'
|
||||||
|
import type { PredictRequest, PredictionHistoryItem, League, EvalSummary } from '../types'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardBody,
|
||||||
|
CardHeader,
|
||||||
|
Badge,
|
||||||
|
DataTable,
|
||||||
|
EmptyState,
|
||||||
|
} from '../components'
|
||||||
|
import { SectionHeader } from '../components'
|
||||||
|
|
||||||
|
export default function PredictionsPage() {
|
||||||
|
const [leagues, setLeagues] = useState<League[]>([])
|
||||||
|
const [history, setHistory] = useState<PredictionHistoryItem[]>([])
|
||||||
|
const [evalData, setEvalData] = useState<EvalSummary | null>(null)
|
||||||
|
const [leagueCode, setLeagueCode] = useState('')
|
||||||
|
const [mode, setMode] = useState<'single' | 'multi'>('multi')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [successMsg, setSuccessMsg] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
const [lg, hist, ev] = await Promise.all([
|
||||||
|
fetchLeagues(),
|
||||||
|
fetchPredictionHistory(page),
|
||||||
|
fetchEvalSummary(),
|
||||||
|
])
|
||||||
|
setLeagues(lg)
|
||||||
|
setHistory(hist.items)
|
||||||
|
setEvalData(ev)
|
||||||
|
}, [page])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData()
|
||||||
|
}, [loadData])
|
||||||
|
|
||||||
|
async function handlePredict(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError(null)
|
||||||
|
setSuccessMsg(null)
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body: PredictRequest = {
|
||||||
|
league_code: leagueCode || undefined,
|
||||||
|
mode,
|
||||||
|
}
|
||||||
|
await triggerPrediction(body)
|
||||||
|
setSuccessMsg(`预测任务已启动 (${mode === 'multi' ? '五路专家模式' : '单一模型模式'})`)
|
||||||
|
loadData()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : '预测触发失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSettle() {
|
||||||
|
setError(null)
|
||||||
|
setSuccessMsg(null)
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await triggerSettle()
|
||||||
|
setSuccessMsg(`结算完成: 成功 ${res.settled_count}, 失败 ${res.failed_count}`)
|
||||||
|
loadData()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : '结算失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<SectionHeader
|
||||||
|
title="预测管理"
|
||||||
|
description="触发 LLM 预测任务,管理预测历史和评估结算"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── 评估摘要 ── */}
|
||||||
|
{evalData && (
|
||||||
|
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||||
|
<EvalStat label="已结算总数" value={evalData.total_settled} />
|
||||||
|
<EvalStat label="1x2 准确率" value={`${(evalData.accuracy_1x2 * 100).toFixed(1)}%`} />
|
||||||
|
<EvalStat label="MAE 进球" value={evalData.mae_goals.toFixed(2)} />
|
||||||
|
<EvalStat label="校准度" value={`${(evalData.calibration * 100).toFixed(1)}%`} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
|
{/* ── 预测触发表单 ── */}
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<Card>
|
||||||
|
<CardHeader
|
||||||
|
title="触发预测"
|
||||||
|
action={
|
||||||
|
<button
|
||||||
|
onClick={handleSettle}
|
||||||
|
disabled={loading}
|
||||||
|
className="rounded-md border border-gray-700 px-3 py-1 text-xs text-gray-400 transition-colors hover:border-emerald-500/50 hover:text-emerald-400 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
结算
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<CardBody>
|
||||||
|
<form onSubmit={handlePredict} className="space-y-4">
|
||||||
|
{/* 联赛选择 */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-xs font-medium text-gray-400">联赛</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 text-sm text-gray-200 focus:border-blue-500 focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="">全部联赛</option>
|
||||||
|
{leagues.map(l => (
|
||||||
|
<option key={l.code} value={l.code}>
|
||||||
|
{l.name_zh ?? l.name} ({l.code})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 预测模式 */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-xs font-medium text-gray-400">模式</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{(['multi', 'single'] as const).map(m => (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMode(m)}
|
||||||
|
className={`flex-1 rounded-md border px-3 py-2 text-xs transition-colors ${
|
||||||
|
mode === m
|
||||||
|
? 'border-blue-500/50 bg-blue-500/10 text-blue-400'
|
||||||
|
: 'border-gray-700 text-gray-500 hover:border-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{m === 'multi' ? '五路专家' : '单一模型'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-400">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{successMsg && (
|
||||||
|
<div className="rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-xs text-emerald-400">
|
||||||
|
{successMsg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? '处理中...' : '启动预测'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 预测历史 ── */}
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader
|
||||||
|
title="预测历史"
|
||||||
|
action={
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||||
|
disabled={page <= 1}
|
||||||
|
className="rounded border border-gray-700 px-2 py-0.5 text-xs text-gray-400 hover:border-gray-600 disabled:opacity-30"
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
<span className="px-2 text-xs text-gray-500">{page}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(p => p + 1)}
|
||||||
|
className="rounded border border-gray-700 px-2 py-0.5 text-xs text-gray-400 hover:border-gray-600"
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<CardBody className="p-0">
|
||||||
|
<DataTable
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
key: 'home_team',
|
||||||
|
label: '主队',
|
||||||
|
render: (row: PredictionHistoryItem) => row.home_team ?? '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'away_team',
|
||||||
|
label: '客队',
|
||||||
|
render: (row: PredictionHistoryItem) => row.away_team ?? '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pred',
|
||||||
|
label: '预测',
|
||||||
|
width: '80px',
|
||||||
|
render: (row: PredictionHistoryItem) =>
|
||||||
|
row.pred_home_goals !== null && row.pred_away_goals !== null
|
||||||
|
? `${row.pred_home_goals} - ${row.pred_away_goals}`
|
||||||
|
: '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pred_1x2',
|
||||||
|
label: '1X2',
|
||||||
|
width: '60px',
|
||||||
|
render: (row: PredictionHistoryItem) => (row.pred_1x2 ? label1x2(row.pred_1x2) : '—'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'confidence',
|
||||||
|
label: '置信',
|
||||||
|
width: '70px',
|
||||||
|
render: (row: PredictionHistoryItem) =>
|
||||||
|
row.confidence !== null && row.confidence !== undefined
|
||||||
|
? `${Math.round(row.confidence * 100)}%`
|
||||||
|
: '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
label: '状态',
|
||||||
|
width: '80px',
|
||||||
|
render: (row: PredictionHistoryItem) => <Badge status={row.status}>{row.status}</Badge>,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
data={history}
|
||||||
|
rowKey={(row: PredictionHistoryItem) => row.id}
|
||||||
|
/>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EvalStat({ label, value }: { label: string; value: string | number }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-gray-800 bg-gray-900 px-4 py-3">
|
||||||
|
<div className="text-xs text-gray-500">{label}</div>
|
||||||
|
<div className="mt-1 text-lg font-semibold text-gray-100 tabular-nums">{value}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function label1x2(v: string): string {
|
||||||
|
return { '1': '主胜', X: '平局', '2': '客胜' }[v] ?? v
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Admin 后台 - 路由配置
|
||||||
|
*
|
||||||
|
* 所有 Admin 页面的路由定义,使用嵌套路由。
|
||||||
|
* 挂载路径: /admin/*
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createBrowserRouter, Navigate } from 'react-router-dom'
|
||||||
|
import AdminLayout from './AdminLayout'
|
||||||
|
import Dashboard from './pages/Dashboard'
|
||||||
|
import CollectionPage from './pages/Collection'
|
||||||
|
import PredictionsPage from './pages/Predictions'
|
||||||
|
import BacktestPage from './pages/Backtest'
|
||||||
|
import MonitoringPage from './pages/Monitoring'
|
||||||
|
import ConfigPage from './pages/Config'
|
||||||
|
|
||||||
|
export const adminRoutes = [
|
||||||
|
{
|
||||||
|
path: '/admin',
|
||||||
|
element: <AdminLayout />,
|
||||||
|
children: [
|
||||||
|
{ index: true, element: <Dashboard /> },
|
||||||
|
{ path: 'collection', element: <CollectionPage /> },
|
||||||
|
{ path: 'predictions', element: <PredictionsPage /> },
|
||||||
|
{ path: 'backtest', element: <BacktestPage /> },
|
||||||
|
{ path: 'monitoring', element: <MonitoringPage /> },
|
||||||
|
{ path: 'config', element: <ConfigPage /> },
|
||||||
|
{ path: '*', element: <Navigate to="/admin" replace /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export { adminRoutes as default }
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
/**
|
||||||
|
* Admin 后台 - TypeScript 类型定义
|
||||||
|
*
|
||||||
|
* 与 FastAPI 后端 Pydantic 模型对齐,确保类型安全
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── 系统健康 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface HealthStatus {
|
||||||
|
status: 'ok' | 'degraded' | 'error'
|
||||||
|
version?: string
|
||||||
|
uptime_seconds?: number
|
||||||
|
checks: Record<string, 'pass' | 'fail' | 'warn'>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 仪表盘 ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface DashboardStats {
|
||||||
|
/** 数据库表行数统计 */
|
||||||
|
db_tables: TableStats[]
|
||||||
|
/** 最近采集状态 */
|
||||||
|
last_collection: CollectionRecord[]
|
||||||
|
/** 预测统计 */
|
||||||
|
prediction_stats: PredictionStats
|
||||||
|
/** 最近错误日志 */
|
||||||
|
recent_errors: ErrorLog[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TableStats {
|
||||||
|
name: string
|
||||||
|
row_count: number
|
||||||
|
size_mb: number
|
||||||
|
last_updated: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CollectionRecord {
|
||||||
|
source: string
|
||||||
|
league_code: string | null
|
||||||
|
started_at: string
|
||||||
|
finished_at: string | null
|
||||||
|
status: 'running' | 'success' | 'failed'
|
||||||
|
records_count: number | null
|
||||||
|
error_message: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PredictionStats {
|
||||||
|
total_predictions: number
|
||||||
|
today_predictions: number
|
||||||
|
avg_latency_ms: number
|
||||||
|
success_rate: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ErrorLog {
|
||||||
|
id: number
|
||||||
|
timestamp: string
|
||||||
|
source: string
|
||||||
|
message: string
|
||||||
|
level: 'error' | 'warning' | 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 配置管理 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ConfigEntry {
|
||||||
|
key: string
|
||||||
|
value: string /** 脱敏显示,如 sk-****abcd */
|
||||||
|
description: string
|
||||||
|
category: 'llm' | 'datasource' | 'system'
|
||||||
|
updated_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigUpdate {
|
||||||
|
key: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 数据采集 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface CollectionRequest {
|
||||||
|
source: 'bzzoiro' | 'understat' | 'injuries'
|
||||||
|
league_code?: string
|
||||||
|
season?: string
|
||||||
|
date_from?: string
|
||||||
|
date_to?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CollectionResponse {
|
||||||
|
task_id: string
|
||||||
|
source: string
|
||||||
|
status: 'queued' | 'running' | 'completed' | 'failed'
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CollectionTask {
|
||||||
|
task_id: string
|
||||||
|
source: string
|
||||||
|
status: string
|
||||||
|
progress: number /** 0~100 */
|
||||||
|
total: number
|
||||||
|
processed: number
|
||||||
|
started_at: string
|
||||||
|
finished_at: string | null
|
||||||
|
error_message: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 预测管理 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface PredictRequest {
|
||||||
|
match_id?: number
|
||||||
|
league_code?: string
|
||||||
|
home_team?: string
|
||||||
|
away_team?: string
|
||||||
|
match_date?: string
|
||||||
|
mode: 'single' | 'multi'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PredictionHistoryItem {
|
||||||
|
id: number
|
||||||
|
match_id: number | null
|
||||||
|
home_team: string | null
|
||||||
|
away_team: string | null
|
||||||
|
pred_home_goals: number | null
|
||||||
|
pred_away_goals: number | null
|
||||||
|
pred_1x2: string | null
|
||||||
|
confidence: number | null
|
||||||
|
created_at: string
|
||||||
|
status: 'pending' | 'success' | 'failed'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 评估 & 结算 ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface SettleResponse {
|
||||||
|
settled_count: number
|
||||||
|
failed_count: number
|
||||||
|
details: SettleDetail[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SettleDetail {
|
||||||
|
match_id: number
|
||||||
|
actual_result: string | null
|
||||||
|
predicted_result: string | null
|
||||||
|
is_correct: boolean | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EvalSummary {
|
||||||
|
total_settled: number
|
||||||
|
accuracy_1x2: number
|
||||||
|
mae_goals: number
|
||||||
|
calibration: number
|
||||||
|
by_league: Record<string, LeagueEval>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeagueEval {
|
||||||
|
accuracy: number
|
||||||
|
count: number
|
||||||
|
calibration: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 回测管理 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface BacktestRequest {
|
||||||
|
strategy: string
|
||||||
|
league_codes: string[]
|
||||||
|
date_from: string
|
||||||
|
date_to: string
|
||||||
|
initial_bankroll: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BacktestResult {
|
||||||
|
task_id: string
|
||||||
|
status: 'queued' | 'running' | 'completed' | 'failed'
|
||||||
|
progress: number
|
||||||
|
metrics: BacktestMetrics | null
|
||||||
|
trades: BacktestTrade[] | null
|
||||||
|
error_message: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BacktestMetrics {
|
||||||
|
total_trades: number
|
||||||
|
win_rate: number
|
||||||
|
roi: number
|
||||||
|
profit_loss: number
|
||||||
|
max_drawdown: number
|
||||||
|
sharpe_ratio: number | null
|
||||||
|
final_bankroll: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BacktestTrade {
|
||||||
|
match_id: number
|
||||||
|
home_team: string
|
||||||
|
away_team: string
|
||||||
|
bet_type: string
|
||||||
|
stake: number
|
||||||
|
odds: number
|
||||||
|
result: 'win' | 'loss' | 'push'
|
||||||
|
profit: number
|
||||||
|
match_date: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 联赛 & 比赛 ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface League {
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
name_zh: string | null
|
||||||
|
country: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 通用分页 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface PageParams {
|
||||||
|
page: number
|
||||||
|
page_size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Paginated<T> {
|
||||||
|
items: T[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
page_size: number
|
||||||
|
has_next: boolean
|
||||||
|
}
|
||||||
@@ -100,3 +100,54 @@
|
|||||||
@apply animate-pulse rounded-none bg-ink-200;
|
@apply animate-pulse rounded-none bg-ink-200;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Admin 后台:暗色主题覆盖 ── */
|
||||||
|
@layer base {
|
||||||
|
/* Admin 区域内滚动条暗色化 */
|
||||||
|
.dark-scroll::-webkit-scrollbar-thumb {
|
||||||
|
@apply bg-gray-600;
|
||||||
|
}
|
||||||
|
.dark-scroll::-webkit-scrollbar-thumb:hover {
|
||||||
|
@apply bg-gray-500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
/* Admin 输入框统一样式 */
|
||||||
|
.admin-input {
|
||||||
|
@apply w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200
|
||||||
|
transition-colors placeholder:text-gray-600
|
||||||
|
focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Admin 按钮 */
|
||||||
|
.admin-btn {
|
||||||
|
@apply inline-flex items-center justify-center gap-1.5 rounded-md border border-gray-700
|
||||||
|
bg-gray-800 px-4 py-2 text-sm font-medium text-gray-200
|
||||||
|
transition-colors hover:border-gray-600 hover:bg-gray-700
|
||||||
|
disabled:cursor-not-allowed disabled:opacity-40;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-btn-primary {
|
||||||
|
@apply border-blue-600 bg-blue-600 text-white hover:bg-blue-700 hover:border-blue-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-btn-danger {
|
||||||
|
@apply border-red-600 bg-red-600 text-white hover:bg-red-700 hover:border-red-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Admin 卡片通用 */
|
||||||
|
.admin-card {
|
||||||
|
@apply rounded-lg border border-gray-800 bg-gray-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表格行动态 */
|
||||||
|
.admin-row-hover {
|
||||||
|
@apply transition-colors hover:bg-gray-800/40;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 状态点 */
|
||||||
|
.status-dot {
|
||||||
|
@apply inline-block h-2 w-2 rounded-full;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user