refactor: 以 bzzoiro 为唯一数据源的全面重构

数据源统一为 bzzoiro,移除 Understat 与 injuries:
- 删除 src/data/understat.py / injuries.py 及相关测试
- 删除 injuries 模型与表;扩展 match_stats(xG 之外增加 big_chances/fouls)
- 新增 standings 表(联赛积分榜:位置/积分/xG/走势/分区)
- matches 表增加 source_event_id 血缘列,支撑统计回填

采集管线(bzzoiro 三条管线):
- events:赛程/比分(/events/),记录 source_event_id
- standings:积分榜快照(/leagues/{id}/standings/)
- stats:已完赛比赛详细统计回填(/events/{id}/stats/)

预测增强:
- standings_slice 替代 injuries_slice;积分榜专家替代阵容完整性专家
- AGENT_META runtime_config 同步更新

管理后台:
- ingest 路由重写为单一 bzzoiro 入口 + task 参数(events/standings/stats/all)
- 新增 /admin/data-completeness 数据完整性分析 API
- 数据源状态页简化为 bzzoiro 单源

前端:
- 采集页重构为任务驱动(比赛/积分榜/统计回填/全量)
- 新增「数据完整性」可视化页(覆盖率矩阵/字段完整率/健康摘要)
- 新增主站积分榜页(/standings)与比赛详情完整统计面板
- agent 名称同步更新(injuries→standings)

迁移 0015_bzzoiro_single_source 已在容器内验证通过,后端测试全部通过。

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
This commit is contained in:
shangfangjian
2026-09-20 19:13:07 +08:00
co-authored by new-provider/LongCat-2.0 <
parent ec8f36abb2
commit f05dc1ae15
41 changed files with 1603 additions and 1885 deletions
@@ -0,0 +1,183 @@
/**
* Admin 后台 - 数据完整性分析页
*
* 回答三个问题:
* 1. 数据是否齐全(各联赛比赛/统计/积分榜量级)
* 2. 字段是否齐全(每张统计表各字段非空率)
* 3. 覆盖是否新鲜(最近一场/最近一次采集)
*/
import { useEffect, useState, useCallback } from 'react'
import { fetchDataCompleteness } from '../dal'
import type { DataCompletenessResponse } from '../dal'
import {
Card, CardBody, CardHeader, SectionHeader, Alert,
ProgressBar, Spinner, EmptyState,
} from '../components'
const FIELD_LABELS: Record<string, string> = {
xg: 'xG 预期进球',
shots: '射门',
possession: '控球率',
corners: '角球',
fouls: '犯规',
big_chances: '绝佳机会',
cards: '红黄牌',
}
function pctColor(pct: number): string {
if (pct >= 80) return 'bg-emerald-500'
if (pct >= 50) return 'bg-amber-500'
return 'bg-rose-500'
}
export default function DataCompletenessPage() {
const [data, setData] = useState<DataCompletenessResponse | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const d = await fetchDataCompleteness()
setData(d)
} catch (err) {
setError(err instanceof Error ? err.message : '加载失败')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
return (
<div className="space-y-6">
<SectionHeader
title="数据完整性"
description="按联赛统计 bzzoiro 数据采集覆盖度。每 5 秒自动刷新,或点击右上角按钮手动刷新。"
action={
<button onClick={load} disabled={loading} className="btn-sm btn-outline">
{loading ? <><Spinner /> </> : '刷新'}
</button>
}
/>
{error && <Alert kind="error" title="加载失败" message={error} onClose={() => setError(null)} />}
{loading && !data && (
<div className="flex justify-center py-12"><Spinner /></div>
)}
{data && (
<>
{/* 全局概览 */}
<div className="grid gap-4 sm:grid-cols-3">
<Card>
<CardBody className="text-center">
<p className="text-2xl font-bold text-ink-900">{data.totals.finished_matches}</p>
<p className="text-xs text-ink-500">()</p>
</CardBody>
</Card>
<Card>
<CardBody className="text-center">
<p className="text-2xl font-bold text-ink-900">{data.totals.stats_rows}</p>
<p className="text-xs text-ink-500"></p>
</CardBody>
</Card>
<Card>
<CardBody className="text-center">
<p className="text-2xl font-bold text-ink-900">
{data.totals.stats_coverage_pct}%
</p>
<p className="text-xs text-ink-500">( / )</p>
</CardBody>
</Card>
</div>
{/* 健康问题 */}
<Card>
<CardHeader title="健康摘要" />
<CardBody>
<div className="space-y-2">
{data.issues.map((issue, i) => (
<Alert
key={i}
kind={issue.includes('良好') ? 'ok' : issue.includes('建议') || issue.includes('仅') ? 'warning' : 'error'}
title={issue.includes('良好') ? '数据良好' : '需要关注'}
message={issue}
/>
))}
</div>
</CardBody>
</Card>
{/* 各联赛详情 */}
<div className="space-y-4">
{data.leagues.map(league => {
const finished = league.matches.finished
const statsPct = finished > 0 ? Math.round((league.stats.rows / finished) * 100) : 0
return (
<Card key={league.code}>
<CardHeader
title={league.name}
description={[
league.country,
`已完赛 ${finished} 场 / 未开赛 ${league.matches.scheduled}`,
league.matches.latest_match ? `最近: ${league.matches.latest_match.slice(0, 10)}` : '',
league.standings.rows > 0 ? `积分榜 ${league.standings.rows}` : '',
].filter(Boolean).join(' · ')}
/>
<CardBody className="space-y-4">
{/* 统计覆盖率进度条 */}
<div>
<div className="mb-1 flex items-center justify-between text-xs">
<span className="text-ink-600"></span>
<span className="font-medium text-ink-900">
{league.stats.rows} / {finished} ({statsPct}%)
</span>
</div>
<ProgressBar value={statsPct} />
</div>
{/* 字段覆盖率矩阵 */}
{league.stats.rows > 0 ? (
<div>
<p className="mb-2 text-xs font-medium text-ink-600">( / )</p>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
{Object.entries(league.stats.fields).map(([key, info]) => (
<div key={key} className="rounded border border-ink-100 px-3 py-2">
<div className="mb-1 flex items-center justify-between">
<span className="text-xs text-ink-600">{FIELD_LABELS[key] ?? key}</span>
<span className="text-xs font-medium text-ink-900">{info.pct}%</span>
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-ink-100">
<div
className={`h-full rounded-full ${pctColor(info.pct)}`}
style={{ width: `${info.pct}%` }}
/>
</div>
<p className="mt-0.5 text-2xs text-ink-400">{info.count} / {league.stats.rows} </p>
</div>
))}
</div>
</div>
) : finished > 0 ? (
<Alert kind="warning" title="缺少统计数据" message="该联赛有已完赛比赛但无统计行,请运行「统计回填」采集。" />
) : (
<Alert kind="warning" title="缺少比赛数据" message="该联赛暂无已完赛比赛,请运行「比赛数据」采集。" />
)}
</CardBody>
</Card>
)
})}
</div>
<p className="text-center text-2xs text-ink-400">
: {new Date(data.generated_at).toLocaleString('zh-CN', { hour12: false })}
</p>
</>
)}
</div>
)
}