- Settings(496 行五块内容单页)拆为 4 个 tab:数据源/LLM/定时任务/登录认证; tab 状态写入 URL(?tab=llm)可深链;数据加载与 API 调用不变 - Dashboard 改待办驱动:死信待处理/联赛数据缺口/可结算预测, 有待办才显示,每项直达处理页;全部清零时显示状态行 - 三步工作流卡仅在库里无比赛时显示(首次引导,老用户不再占版面) - 修文案漂移:「数据源健康」卡不再引用已不存在的「数据源」页; 「运行预测」链接语义修正(预测在前台比赛详情页发起) - 数据源单源硬编码列表简化为一行;未配 Key 直达设置分区 - <a href> 全改 <Link>,不再整页刷新
241 lines
11 KiB
TypeScript
241 lines
11 KiB
TypeScript
/**
|
|
* Admin 后台 - 仪表盘(报刊风·待办驱动)
|
|
*
|
|
* 设计原则:单人管理员的注意力应该花在「现在需要处理什么」上,
|
|
* 而不是扫描一堆常驻数字。
|
|
* - 顶部待办行:只有真有待办才出现(死信 / 缺数据联赛 / 可结算预测),
|
|
* 每项直达处理页面 —— 引导出现在需要时,而不是永远占着版面
|
|
* - 三步工作流卡只在库里还没有比赛时显示(首次使用引导)
|
|
* - 数据概览合并为一卡:预测活动 + 各表数据量
|
|
*/
|
|
|
|
import { useEffect, useState, useCallback } from 'react'
|
|
import { Link } from 'react-router-dom'
|
|
import { fetchAdminStats, fetchIngestStatus, fetchDashboard, fetchIngestFailures, fetchDataCompleteness, fetchPredictions } from '../dal'
|
|
import type { DataCompletenessResponse, IngestFailureItem } from '../dal'
|
|
import type { AdminStats, IngestSourceStatus, DashboardStats } from '../types'
|
|
import { Card, CardBody, CardHeader, SkeletonBlock } from '../components'
|
|
|
|
/** 工作流引导(仅首次使用——库里还没有比赛时显示) */
|
|
const STEPS = [
|
|
{ to: '/admin/collection', step: '1', title: '采集数据', desc: 'bzzoiro: 赛程 / 积分榜 / 比赛统计(xG、射门、控球等)' },
|
|
{ to: '/admin/predictions', step: '2', title: '预测与结算', desc: '在前台比赛详情页发起预测;赛后回到「预测历史」结算' },
|
|
{ to: '/admin/eval', step: '3', title: '评估准确率', desc: '结算后查看 1X2 命中率与校准度' },
|
|
]
|
|
|
|
interface TodoItem {
|
|
key: string
|
|
count: number
|
|
label: string
|
|
to: string
|
|
/** 无上限确认时数字近似,展示为 N+ */
|
|
approx?: boolean
|
|
}
|
|
|
|
export default function Dashboard() {
|
|
const [stats, setStats] = useState<AdminStats | null>(null)
|
|
const [ingest, setIngest] = useState<IngestSourceStatus[]>([])
|
|
const [dash, setDash] = useState<DashboardStats | null>(null)
|
|
const [failures, setFailures] = useState<IngestFailureItem[]>([])
|
|
const [completeness, setCompleteness] = useState<DataCompletenessResponse | null>(null)
|
|
const [recentPreds, setRecentPreds] = useState<Array<{ settled?: boolean; actual_home_goals?: number | null; actual_away_goals?: number | null }>>([])
|
|
const [loading, setLoading] = useState(true)
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true)
|
|
// 各数据源独立容错:单接口失败只降级对应卡片,不拖垮整页
|
|
const [s, i, d, f, c, p] = await Promise.allSettled([
|
|
fetchAdminStats(),
|
|
fetchIngestStatus(),
|
|
fetchDashboard(),
|
|
fetchIngestFailures(),
|
|
fetchDataCompleteness(),
|
|
fetchPredictions(100),
|
|
])
|
|
if (s.status === 'fulfilled') setStats(s.value)
|
|
if (i.status === 'fulfilled') setIngest(i.value.sources)
|
|
if (d.status === 'fulfilled') setDash(d.value)
|
|
if (f.status === 'fulfilled') setFailures(f.value)
|
|
if (c.status === 'fulfilled') setCompleteness(c.value)
|
|
if (p.status === 'fulfilled' && Array.isArray(p.value)) setRecentPreds(p.value)
|
|
setLoading(false)
|
|
}, [])
|
|
|
|
useEffect(() => { load() }, [load])
|
|
|
|
// ── 待办计算 ──
|
|
const deadLetterCount = failures.filter(f => f.status !== 'resolved').length
|
|
const missingStatsLeagues = completeness?.leagues.filter(
|
|
l => l.matches.finished > 0 && l.stats.rows === 0,
|
|
).length ?? 0
|
|
const missingStandingsLeagues = completeness?.leagues.filter(
|
|
l => l.matches.total > 0 && l.standings.rows === 0,
|
|
).length ?? 0
|
|
const missingLeagues = missingStatsLeagues + missingStandingsLeagues
|
|
// 近 100 条内「比赛已出比分但未结算」的预测(列表接口有上限,数字近似)
|
|
const settleable = recentPreds.filter(
|
|
p => !p.settled && p.actual_home_goals != null && p.actual_away_goals != null,
|
|
).length
|
|
|
|
const todos: TodoItem[] = [
|
|
deadLetterCount > 0 && { key: 'deadletter', count: deadLetterCount, label: '采集失败待处理', to: '/admin/data-pipeline' },
|
|
missingLeagues > 0 && { key: 'completeness', count: missingLeagues, label: '联赛数据缺口', to: '/admin/data-completeness' },
|
|
settleable > 0 && { key: 'settle', count: settleable, label: '预测可结算', to: '/admin/predictions', approx: true },
|
|
].filter((t): t is TodoItem => t !== false)
|
|
|
|
const sourceByName = Object.fromEntries(ingest.map(s => [s.name, s]))
|
|
const bzzoiro = sourceByName['bzzoiro']
|
|
const hasMatches = (stats?.matches?.total ?? 0) > 0
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* ── 待办行:只有真有待办才出现 ── */}
|
|
{loading ? (
|
|
<SkeletonBlock className="h-14 w-full" />
|
|
) : todos.length > 0 ? (
|
|
<div className="grid gap-3 sm:grid-cols-3">
|
|
{todos.map(t => (
|
|
<Link
|
|
key={t.key}
|
|
to={t.to}
|
|
className="group flex items-center gap-3 border border-press bg-press-wash/40 px-4 py-3 transition-colors hover:bg-press-wash"
|
|
>
|
|
<span className="font-serif text-2xl font-bold tabular-nums text-press">
|
|
{t.count}{t.approx ? '+' : ''}
|
|
</span>
|
|
<span className="text-xs text-ink-700">{t.label}</span>
|
|
<span className="ml-auto text-press transition-transform group-hover:translate-x-0.5" aria-hidden="true">→</span>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="flex items-center gap-2 border-b border-ink-200 pb-3 text-2xs text-ink-400">
|
|
<span className="inline-block h-1.5 w-1.5 bg-ink-900" aria-hidden="true" />
|
|
流水线无待办:没有失败记录、数据缺口或待结算预测。
|
|
</p>
|
|
)}
|
|
|
|
{/* ── 三步引导:仅首次使用(库里还没有比赛)时显示 ── */}
|
|
{!loading && !hasMatches && (
|
|
<div className="grid gap-4 sm:grid-cols-3">
|
|
{STEPS.map(s => (
|
|
<Link
|
|
key={s.to}
|
|
to={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>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* ── 数据源:单源一行即足,不装成列表 ── */}
|
|
<Card>
|
|
<CardHeader
|
|
title="数据源"
|
|
description="bzzoiro 最近采集情况,Key 与轮换配置见「设置 → 数据源」"
|
|
/>
|
|
<CardBody className="px-0">
|
|
{loading ? (
|
|
<div className="px-4 sm:px-5"><SkeletonBlock className="h-8 w-full" /></div>
|
|
) : (
|
|
(() => {
|
|
const st = bzzoiro
|
|
const hasData = st && st.recent_count > 0
|
|
const keyOk = st?.key_configured !== false
|
|
return (
|
|
<div className="flex items-center justify-between px-4 py-2.5 sm:px-5">
|
|
<span className="text-xs font-medium text-ink-700">Bzzoiro</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>
|
|
) : (
|
|
<Link to="/admin/settings?tab=datasource" className="text-press underline underline-offset-2">未配置 Key,去设置</Link>
|
|
)}
|
|
<span aria-hidden="true" className={`inline-block h-1.5 w-1.5 ${hasData && keyOk ? 'bg-ink-900' : 'bg-press'}`} />
|
|
</span>
|
|
</div>
|
|
)
|
|
})()
|
|
)}
|
|
</CardBody>
|
|
</Card>
|
|
|
|
{/* ── 数据概览:预测活动 + 数据量,合并一卡 ── */}
|
|
<Card>
|
|
<CardHeader title="数据概览" description="预测调用量与各表数据量" />
|
|
<CardBody>
|
|
{stats ? (
|
|
<div className="space-y-4">
|
|
<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="grid grid-cols-4 gap-3 border-t border-ink-200 pt-3 text-center">
|
|
<div>
|
|
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">{stats.matches?.total ?? 0}</div>
|
|
<div className="mt-0.5 text-2xs text-ink-400">比赛总数</div>
|
|
</div>
|
|
<div>
|
|
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">{stats.matches?.finished ?? 0}</div>
|
|
<div className="mt-0.5 text-2xs text-ink-400">已完赛</div>
|
|
</div>
|
|
<div>
|
|
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">{stats.stats?.total ?? 0}</div>
|
|
<div className="mt-0.5 text-2xs text-ink-400">统计行</div>
|
|
</div>
|
|
<div>
|
|
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">{stats.standings?.total ?? 0}</div>
|
|
<div className="mt-0.5 text-2xs text-ink-400">积分榜</div>
|
|
</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">
|
|
{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>
|
|
</CardBody>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|