fix: 管理后台页面修复
- AdminLayout: 侧边栏布局和导航修复 - dal.ts: 数据访问层 API 调用修复 - Dashboard: 仪表盘统计卡片和数据加载修复 - Backtest: 回测配置和结果展示修复 - LLMConfig: LLM 配置页面修复 - Predictions: 预测管理页面修复
This commit is contained in:
@@ -1,141 +1,161 @@
|
||||
/**
|
||||
* Admin 后台 - 仪表盘(报刊风)
|
||||
*
|
||||
* 响应式: 移动端 1 列 → 平板 2 列 → 桌面 4 列
|
||||
* 展示:
|
||||
* - 数据流水线状态(采集 → 预测 → 评估,每步的实际数据量)
|
||||
* - 近期预测活动(24h / 7d / 总计)
|
||||
* - 快捷操作入口(带工作流引导)
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { fetchDashboard, fetchHealth } from '../dal'
|
||||
import type { DashboardStats } from '../types'
|
||||
import { Card, CardBody, CardHeader, StatCard, Alert, SkeletonBlock } from '../components'
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { fetchAdminStats, fetchIngestStatus, fetchDashboard } from '../dal'
|
||||
import type { AdminStats, IngestSourceStatus, DashboardStats } from '../types'
|
||||
import { Card, CardBody, CardHeader, Alert, SkeletonBlock } from '../components'
|
||||
|
||||
/** 工作流步骤卡片 */
|
||||
const STEPS = [
|
||||
{ to: '/admin/collection', step: '1', title: '采集数据', desc: '从 bzzoiro / understat 获取赛程与 xG', icon: '◈' },
|
||||
{ to: '/admin/predictions', step: '2', title: '运行预测', desc: '调 LLM 多专家生成比分预测', icon: '◆' },
|
||||
{ to: '/admin/eval', step: '3', title: '评估准确率', desc: '结算后查看 1X2 命中率与校准度', icon: '◈' },
|
||||
]
|
||||
|
||||
export default function Dashboard() {
|
||||
const [data, setData] = useState<DashboardStats | null>(null)
|
||||
const [health, setHealth] = useState<any>(null)
|
||||
const [stats, setStats] = useState<AdminStats | null>(null)
|
||||
const [ingest, setIngest] = useState<IngestSourceStatus[]>([])
|
||||
const [dash, setDash] = useState<DashboardStats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
const load = useCallback(async () => {
|
||||
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 }
|
||||
const [s, i, d] = await Promise.allSettled([
|
||||
fetchAdminStats(),
|
||||
fetchIngestStatus(),
|
||||
fetchDashboard(),
|
||||
])
|
||||
if (s.status === 'fulfilled') setStats(s.value)
|
||||
if (i.status === 'fulfilled') setIngest(i.value.sources)
|
||||
if (d.status === 'fulfilled') setDash(d.value)
|
||||
setLoading(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>
|
||||
)
|
||||
}
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const healthText =
|
||||
health?.status === 'healthy' || health?.status === 'ok' ? '正常' : '异常'
|
||||
const sourceByName = Object.fromEntries(ingest.map(s => [s.name, s]))
|
||||
const bzzoiro = sourceByName['bzzoiro']
|
||||
const understat = sourceByName['understat']
|
||||
const injuries = sourceByName['injuries']
|
||||
|
||||
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 className="grid gap-4 sm:grid-cols-3">
|
||||
{STEPS.map((s, i) => (
|
||||
<a
|
||||
key={s.to}
|
||||
href={s.to}
|
||||
className="group border border-ink-300 p-4 transition-colors hover:border-ink-900 hover:bg-paper-100"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="flex h-7 w-7 items-center justify-center border border-ink-900 font-serif text-xs font-bold text-ink-900">
|
||||
{s.step}
|
||||
</span>
|
||||
<span className="font-serif text-sm font-bold text-ink-900">{s.title}</span>
|
||||
<span className="ml-auto text-ink-300 transition-colors group-hover:text-press" aria-hidden="true">→</span>
|
||||
</div>
|
||||
<p className="mt-2 text-2xs leading-relaxed text-ink-500">{s.desc}</p>
|
||||
{i < STEPS.length - 1 && <span className="sr-only">下一步</span>}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 联赛列表 */}
|
||||
{/* ── 数据源健康一览 ── */}
|
||||
<Card>
|
||||
<CardHeader title="已入库联赛" />
|
||||
<CardBody>
|
||||
<CardHeader
|
||||
title="数据源健康"
|
||||
description="各源最近采集时间与数据量(只读快照,详细配置见「数据源」页)"
|
||||
/>
|
||||
<CardBody className="px-0">
|
||||
{loading ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[1, 2, 3, 4].map(i => (
|
||||
<SkeletonBlock key={i} className="h-6 w-24" />
|
||||
))}
|
||||
<div className="space-y-2 px-4 sm:px-5">
|
||||
{[1, 2, 3].map(i => <SkeletonBlock key={i} className="h-8 w-full" />)}
|
||||
</div>
|
||||
) : data && data.leagues.length > 0 ? (
|
||||
) : (
|
||||
<div>
|
||||
{[
|
||||
{ name: 'bzzoiro', label: 'Bzzoiro', st: bzzoiro },
|
||||
{ name: 'understat', label: 'Understat (xG)', st: understat },
|
||||
{ name: 'injuries', label: 'Injuries (伤停)', st: injuries },
|
||||
].map(({ name, label, st }) => {
|
||||
const hasData = st && st.recent_count > 0
|
||||
const keyOk = st?.key_configured !== false
|
||||
return (
|
||||
<div key={name} className="flex items-center justify-between border-b border-ink-200 px-4 py-2.5 last:border-b-0 sm:px-5">
|
||||
<span className="text-xs font-medium text-ink-700">{label}</span>
|
||||
<span className="flex items-center gap-3 text-2xs">
|
||||
{hasData ? (
|
||||
<>
|
||||
<span className="tabular-nums text-ink-500">{st.recent_count.toLocaleString()} 条</span>
|
||||
<span className="text-ink-400">{st.last_success_at ? new Date(st.last_success_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }) : ''}</span>
|
||||
</>
|
||||
) : keyOk ? (
|
||||
<span className="text-ink-400">无数据</span>
|
||||
) : (
|
||||
<span className="text-press">未配置 Key</span>
|
||||
)}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`inline-block h-1.5 w-1.5 ${hasData && keyOk ? 'bg-ink-900' : 'bg-press'}`}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* ── 近期预测活动 ── */}
|
||||
<Card>
|
||||
<CardHeader title="近期预测活动" description="预测 API 的调用量统计" />
|
||||
<CardBody>
|
||||
{stats ? (
|
||||
<div className="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<div className="font-serif text-3xl font-bold tabular-nums text-ink-900">{stats.predictions.last_24h}</div>
|
||||
<div className="mt-1 text-2xs text-ink-400">近 24 小时</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-serif text-3xl font-bold tabular-nums text-ink-900">{stats.predictions.last_7d}</div>
|
||||
<div className="mt-1 text-2xs text-ink-400">近 7 天</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-serif text-3xl font-bold tabular-nums text-ink-900">{stats.predictions.total}</div>
|
||||
<div className="mt-1 text-2xs text-ink-400">累计</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-center py-4"><SkeletonBlock className="h-12 w-64" /></div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* ── 已入库联赛 ── */}
|
||||
{dash && dash.leagues.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader title="已入库联赛" />
|
||||
<CardBody>
|
||||
<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"
|
||||
>
|
||||
{dash.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>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user