F1: 统一前端 HTTP 客户端 - 新增 frontend/src/lib/http.ts(共享客户端,统一超时/错误/401 处理) - Matches.tsx 的列表/分页/进行中/预测请求改用 http 客户端 - 公开站与 Admin 行为一致 D2: events 采集按联赛分批提交 - _run_bzzoiro 改为 per-league 独立事务,避免超长事务 - 单联赛失败不影响其他联赛 F3: 仪表盘真实计数 - admin_stats 补充 matches/stats/standings 真实聚合 - Dashboard 展示比赛总数/已完赛/统计行/积分榜 F4: LLM 连通性测试不依赖 match_id=1 - 新增 POST /admin/llm/ping 端点(只发一次 chat,不依赖比赛) - testLLMConnection 优先调用 ping,失败回退旧方式 F5: 导航改用 React Router Link - App.tsx 中 <a href> 全部替换为 <Link to> D5: 删除 away_possession 死代码 - bzzoiro.py 中移除计算与注释 D1: 管线基础设施表文档化 - RawEvent/IngestFailure/DataQualityCheck/DataLineage 添加「预留未启用」注释 Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
179 lines
7.8 KiB
TypeScript
179 lines
7.8 KiB
TypeScript
/**
|
|
* Admin 后台 - 仪表盘(报刊风)
|
|
*
|
|
* 展示:
|
|
* - 数据流水线状态(采集 → 预测 → 评估,每步的实际数据量)
|
|
* - 近期预测活动(24h / 7d / 总计)
|
|
* - 快捷操作入口(带工作流引导)
|
|
*/
|
|
|
|
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: 赛程 / 积分榜 / 比赛统计(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 [stats, setStats] = useState<AdminStats | null>(null)
|
|
const [ingest, setIngest] = useState<IngestSourceStatus[]>([])
|
|
const [dash, setDash] = useState<DashboardStats | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true)
|
|
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)
|
|
}, [])
|
|
|
|
useEffect(() => { load() }, [load])
|
|
|
|
const sourceByName = Object.fromEntries(ingest.map(s => [s.name, s]))
|
|
const bzzoiro = sourceByName['bzzoiro']
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* ── 工作流引导(采集 → 预测 → 评估) ── */}
|
|
<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="数据源健康"
|
|
description="各源最近采集时间与数据量(只读快照,详细配置见「数据源」页)"
|
|
/>
|
|
<CardBody className="px-0">
|
|
{loading ? (
|
|
<div className="space-y-2 px-4 sm:px-5">
|
|
{[1, 2, 3].map(i => <SkeletonBlock key={i} className="h-8 w-full" />)}
|
|
</div>
|
|
) : (
|
|
<div>
|
|
{[
|
|
{ name: 'bzzoiro', label: 'Bzzoiro', st: bzzoiro },
|
|
].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="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>
|
|
{/* F3 修复: 真实比赛计数(非 limit=100 近似) */}
|
|
<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>
|
|
)
|
|
}
|