/** * 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(null) const [ingest, setIngest] = useState([]) const [dash, setDash] = useState(null) const [failures, setFailures] = useState([]) const [completeness, setCompleteness] = useState(null) const [recentPreds, setRecentPreds] = useState>([]) 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 (
{/* ── 待办行:只有真有待办才出现 ── */} {loading ? ( ) : todos.length > 0 ? (
{todos.map(t => ( {t.count}{t.approx ? '+' : ''} {t.label} ))}
) : (

)} {/* ── 三步引导:仅首次使用(库里还没有比赛)时显示 ── */} {!loading && !hasMatches && (
{STEPS.map(s => (
{s.step} {s.title}

{s.desc}

))}
)} {/* ── 数据源:单源一行即足,不装成列表 ── */} {loading ? (
) : ( (() => { const st = bzzoiro const hasData = st && st.recent_count > 0 const keyOk = st?.key_configured !== false return (
Bzzoiro {hasData ? ( <> {st.recent_count.toLocaleString()} 条 {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 }) : ''} ) : keyOk ? ( 无数据 ) : ( 未配置 Key,去设置 )}
) })() )}
{/* ── 数据概览:预测活动 + 数据量,合并一卡 ── */} {stats ? (
{stats.predictions.last_24h}
预测 · 近 24 小时
{stats.predictions.last_7d}
预测 · 近 7 天
{stats.predictions.total}
预测 · 累计
{stats.matches?.total ?? 0}
比赛总数
{stats.matches?.finished ?? 0}
已完赛
{stats.stats?.total ?? 0}
统计行
{stats.standings?.total ?? 0}
积分榜
) : (
)}
{/* ── 已入库联赛 ── */} {dash && dash.leagues.length > 0 && (
{dash.leagues.map(l => ( {l.name_zh || l.name} {l.code} ))}
)}
) }