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,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
|
||||
}
|
||||
Reference in New Issue
Block a user