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:
shangfangjian
2026-09-17 02:00:14 +08:00
parent 1219b4fd18
commit d3284c48c3
19 changed files with 2671 additions and 81 deletions
+248
View File
@@ -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}
/>
)
}