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
+245
View File
@@ -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
( OpenAIAnthropic)
</p>
<p>
<strong className="text-gray-400">:</strong>
( UnderstatBzzoiro)
</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>
)
}