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
+220
View File
@@ -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>
)
}