142 lines
5.1 KiB
TypeScript
142 lines
5.1 KiB
TypeScript
/**
|
|
* Admin 后台 - 仪表盘(报刊风)
|
|
*
|
|
* 响应式: 移动端 1 列 → 平板 2 列 → 桌面 4 列
|
|
*/
|
|
|
|
import { useEffect, useState } from 'react'
|
|
import { fetchDashboard, fetchHealth } from '../dal'
|
|
import type { DashboardStats } from '../types'
|
|
import { Card, CardBody, CardHeader, StatCard, Alert, SkeletonBlock } from '../components'
|
|
|
|
export default function Dashboard() {
|
|
const [data, setData] = useState<DashboardStats | null>(null)
|
|
const [health, setHealth] = useState<any>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
useEffect(() => {
|
|
let active = true
|
|
setLoading(true)
|
|
Promise.all([fetchDashboard(), fetchHealth()])
|
|
.then(([stats, h]) => {
|
|
if (active) {
|
|
setData(stats)
|
|
setHealth(h)
|
|
}
|
|
})
|
|
.catch((err: unknown) => {
|
|
if (active) setError(err instanceof Error ? err.message : '加载失败')
|
|
})
|
|
.finally(() => {
|
|
if (active) setLoading(false)
|
|
})
|
|
return () => { active = false }
|
|
}, [])
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="space-y-4">
|
|
<Alert kind="error" title="加载仪表盘失败" message={error} />
|
|
<button onClick={() => window.location.reload()} className="btn btn-sm">
|
|
重试
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const healthText =
|
|
health?.status === 'healthy' || health?.status === 'ok' ? '正常' : '异常'
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* 统计:报纸数字版式 */}
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
<StatCard
|
|
label="健康状态"
|
|
value={loading ? '—' : healthText}
|
|
hint={loading ? undefined : '每 60 秒随报眉自动复检'}
|
|
/>
|
|
<StatCard label="联赛数" value={loading ? '—' : data?.leagues.length ?? 0} />
|
|
<StatCard label="比赛数" value={loading ? '—' : data?.total_matches ?? 0} />
|
|
<StatCard label="预测数" value={loading ? '—' : data?.total_predictions ?? 0} />
|
|
</div>
|
|
|
|
{/* 联赛列表 */}
|
|
<Card>
|
|
<CardHeader title="已入库联赛" />
|
|
<CardBody>
|
|
{loading ? (
|
|
<div className="flex flex-wrap gap-2">
|
|
{[1, 2, 3, 4].map(i => (
|
|
<SkeletonBlock key={i} className="h-6 w-24" />
|
|
))}
|
|
</div>
|
|
) : data && data.leagues.length > 0 ? (
|
|
<div className="flex flex-wrap gap-2">
|
|
{data.leagues.map(l => (
|
|
<span
|
|
key={l.code}
|
|
className="border border-ink-200 px-2.5 py-1 text-xs text-ink-700"
|
|
>
|
|
{l.name_zh || l.name}
|
|
<span className="ml-1.5 font-mono text-2xs text-ink-400">{l.code}</span>
|
|
</span>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-xs text-ink-500">
|
|
暂无联赛数据,请先到「数据采集」导入比赛数据。
|
|
</p>
|
|
)}
|
|
</CardBody>
|
|
</Card>
|
|
|
|
{/* 快捷操作 */}
|
|
<Card>
|
|
<CardHeader title="快捷操作" />
|
|
<CardBody>
|
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
<a
|
|
href="/admin/collection"
|
|
className="group flex items-center justify-between border border-ink-300 p-4 transition-colors hover:border-ink-900 hover:bg-paper-100"
|
|
>
|
|
<div>
|
|
<div className="font-serif text-sm font-bold text-ink-900">触发采集</div>
|
|
<div className="mt-0.5 text-2xs text-ink-500">从数据源获取最新赛程与比分</div>
|
|
</div>
|
|
<span className="text-ink-300 transition-colors group-hover:text-press" aria-hidden="true">
|
|
→
|
|
</span>
|
|
</a>
|
|
<a
|
|
href="/admin/predictions"
|
|
className="group flex items-center justify-between border border-ink-300 p-4 transition-colors hover:border-ink-900 hover:bg-paper-100"
|
|
>
|
|
<div>
|
|
<div className="font-serif text-sm font-bold text-ink-900">新建预测</div>
|
|
<div className="mt-0.5 text-2xs text-ink-500">调 LLM 生成比赛预测</div>
|
|
</div>
|
|
<span className="text-ink-300 transition-colors group-hover:text-press" aria-hidden="true">
|
|
→
|
|
</span>
|
|
</a>
|
|
<a
|
|
href="/admin/backtest"
|
|
className="group flex items-center justify-between border border-ink-300 p-4 transition-colors hover:border-ink-900 hover:bg-paper-100"
|
|
>
|
|
<div>
|
|
<div className="font-serif text-sm font-bold text-ink-900">运行回测</div>
|
|
<div className="mt-0.5 text-2xs text-ink-500">在历史数据上检验准确率</div>
|
|
</div>
|
|
<span className="text-ink-300 transition-colors group-hover:text-press" aria-hidden="true">
|
|
→
|
|
</span>
|
|
</a>
|
|
</div>
|
|
</CardBody>
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|