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:
co-authored by
new-provider/LongCat-2.0 <
parent
ec8f36abb2
commit
f05dc1ae15
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* 主站 - 联赛积分榜页
|
||||
*
|
||||
* 展示各联赛最新积分榜(位置/积分/净胜/xG差/近期走势/分区),
|
||||
* 数据来自 bzzoiro /leagues/{id}/standings/ 管线采集。
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { fetchStandings } from '../admin/dal'
|
||||
import type { StandingsLeague, StandingRow } from '../admin/dal'
|
||||
|
||||
const LEAGUES = [
|
||||
{ code: 'E0', name: '英超' },
|
||||
{ code: 'SP1', name: '西甲' },
|
||||
{ code: 'D1', name: '德甲' },
|
||||
{ code: 'I1', name: '意甲' },
|
||||
{ code: 'F1', name: '法甲' },
|
||||
{ code: 'CL', name: '欧冠' },
|
||||
{ code: 'EL', name: '欧联' },
|
||||
]
|
||||
|
||||
const ZONE_META: Record<string, { label: string; cls: string }> = {
|
||||
'Champions League': { label: '欧冠区', cls: 'bg-emerald-100 text-emerald-700' },
|
||||
'Europa League': { label: '欧联区', cls: 'bg-amber-100 text-amber-700' },
|
||||
'Relegation': { label: '降级区', cls: 'bg-rose-100 text-rose-700' },
|
||||
}
|
||||
|
||||
function zoneBadge(zone?: string | null) {
|
||||
if (!zone) return null
|
||||
const meta = ZONE_META[zone] ?? { label: zone, cls: 'bg-ink-100 text-ink-600' }
|
||||
return <span className={`rounded px-1.5 py-0.5 text-2xs font-medium ${meta.cls}`}>{meta.label}</span>
|
||||
}
|
||||
|
||||
/** 近期走势串(W/D/L) → 彩色圆点 */
|
||||
function FormDots({ form }: { form?: string | null }) {
|
||||
if (!form) return <span className="text-2xs text-ink-400">—</span>
|
||||
const colorMap: Record<string, string> = { W: 'bg-emerald-500', D: 'bg-ink-300', L: 'bg-rose-500' }
|
||||
return (
|
||||
<span className="inline-flex gap-0.5">
|
||||
{form.slice(0, 5).split('').map((c, i) => (
|
||||
<span key={i} className={`inline-block h-1.5 w-1.5 rounded-full ${colorMap[c] ?? 'bg-ink-200'}`} />
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function StandingsPage() {
|
||||
const [leagues, setLeagues] = useState<StandingsLeague[]>([])
|
||||
const [activeLeague, setActiveLeague] = useState<string>('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(async (code?: string) => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await fetchStandings(code)
|
||||
setLeagues(data.leagues)
|
||||
if (!activeLeague && data.leagues.length > 0) {
|
||||
setActiveLeague(data.leagues[0].league_code)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [activeLeague])
|
||||
|
||||
useEffect(() => { load() }, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const active = leagues.find(l => l.league_code === activeLeague) ?? leagues[0]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 联赛切换 */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{LEAGUES.map(l => (
|
||||
<button
|
||||
key={l.code}
|
||||
onClick={() => { setActiveLeague(l.code); load(l.code) }}
|
||||
className={`rounded border px-3 py-1.5 text-xs transition-colors ${
|
||||
activeLeague === l.code
|
||||
? 'border-ink-900 bg-ink-900 text-paper-50'
|
||||
: 'border-ink-200 text-ink-500 hover:border-ink-300'
|
||||
}`}
|
||||
>
|
||||
{l.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="border border-rose-300 bg-rose-50 px-4 py-3 text-sm text-rose-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="flex justify-center py-12 text-xs text-ink-400">加载中…</div>
|
||||
)}
|
||||
|
||||
{!loading && !active && (
|
||||
<div className="border-y border-ink-200 py-12 text-center">
|
||||
<p className="font-serif text-sm text-ink-600">暂无积分榜数据</p>
|
||||
<p className="mt-1.5 text-xs text-ink-400">
|
||||
请先在管理后台「数据采集」页运行「积分榜」任务。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{active && (
|
||||
<div>
|
||||
<div className="mb-3 flex items-center justify-between border-b border-ink-200 pb-2">
|
||||
<div>
|
||||
<h2 className="font-serif text-lg font-bold text-ink-900">
|
||||
{active.league_name}
|
||||
</h2>
|
||||
<p className="text-xs text-ink-400">
|
||||
{active.season} 赛季 · {active.rows.length} 队
|
||||
{active.retrieved_at && ` · 更新于 ${new Date(active.retrieved_at).toLocaleDateString('zh-CN')}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 积分榜表格 */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-ink-200 text-left text-ink-400">
|
||||
<th className="w-8 py-2 font-medium">#</th>
|
||||
<th className="py-2 font-medium">球队</th>
|
||||
<th className="w-10 text-center py-2 font-medium">赛</th>
|
||||
<th className="w-10 text-center py-2 font-medium">胜</th>
|
||||
<th className="w-10 text-center py-2 font-medium">平</th>
|
||||
<th className="w-10 text-center py-2 font-medium">负</th>
|
||||
<th className="w-12 text-center py-2 font-medium">进/失</th>
|
||||
<th className="w-12 text-center py-2 font-medium">净</th>
|
||||
<th className="w-14 text-center py-2 font-medium">积分</th>
|
||||
<th className="w-16 text-center py-2 font-medium">xG±</th>
|
||||
<th className="w-20 text-center py-2 font-medium">走势</th>
|
||||
<th className="w-16 text-right py-2 font-medium">区</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{active.rows.map((r: StandingRow) => (
|
||||
<tr key={r.position} className="border-b border-ink-100 hover:bg-paper-100">
|
||||
<td className="py-2 font-medium text-ink-700">{r.position}</td>
|
||||
<td className="py-2 font-medium text-ink-900">{r.team}</td>
|
||||
<td className="text-center py-2 text-ink-500">{r.played}</td>
|
||||
<td className="text-center py-2 text-ink-500">{r.won}</td>
|
||||
<td className="text-center py-2 text-ink-500">{r.drawn}</td>
|
||||
<td className="text-center py-2 text-ink-500">{r.lost}</td>
|
||||
<td className="text-center py-2 text-ink-500">{r.goals_for}/{r.goals_against}</td>
|
||||
<td className={`text-center py-2 ${r.goal_diff > 0 ? 'text-emerald-600' : r.goal_diff < 0 ? 'text-rose-600' : 'text-ink-500'}`}>
|
||||
{r.goal_diff > 0 ? `+${r.goal_diff}` : r.goal_diff}
|
||||
</td>
|
||||
<td className="text-center py-2 font-bold text-ink-900">{r.points}</td>
|
||||
<td className="text-center py-2 text-ink-500">
|
||||
{r.xg_for != null && r.xg_against != null
|
||||
? `${(r.xg_for - r.xg_against).toFixed(1)}`
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="py-2"><div className="flex justify-center"><FormDots form={r.form} /></div></td>
|
||||
<td className="py-2 text-right">{zoneBadge(r.zone)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user