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