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
@@ -1,5 +1,11 @@
|
||||
/**
|
||||
* Admin 后台 - 数据采集页面(报刊风)
|
||||
* Admin 后台 - 数据采集页面(bzzoiro 单一数据源)
|
||||
*
|
||||
* 三个采集任务:
|
||||
* events — 比赛日程与比分
|
||||
* standings — 联赛积分榜
|
||||
* stats — 已完赛比赛详细统计回填(xG/射门/控球等)
|
||||
* all — 依次执行以上三项
|
||||
*
|
||||
* 响应式布局: 移动端单列,桌面端双列
|
||||
*/
|
||||
@@ -9,45 +15,22 @@ import { triggerCollection, fetchLeagues } from '../dal'
|
||||
import type { CollectionRequest, League } from '../types'
|
||||
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
|
||||
|
||||
const SOURCES = [
|
||||
{ value: 'bzzoiro', label: 'Bzzoiro', desc: '历史赛程与比分' },
|
||||
{ value: 'understat', label: 'Understat', desc: 'xG 进阶数据' },
|
||||
{ value: 'injuries', label: 'Injuries', desc: '球员伤停' },
|
||||
const TASKS = [
|
||||
{ value: 'events', label: '比赛数据', desc: '赛程 / 比分 / 未开赛安排', icon: '⚽' },
|
||||
{ value: 'standings', label: '积分榜', desc: '联赛排名 / 积分 / xG差 / 近期走势', icon: '🏆' },
|
||||
{ value: 'stats', label: '统计回填', desc: '已完赛比赛的 xG / 射门 / 控球等详细统计', icon: '📊' },
|
||||
{ value: 'all', label: '全量采集', desc: '依次采集比赛 + 积分榜 + 统计回填', icon: '⏵⏵' },
|
||||
] as const
|
||||
|
||||
/** 把采集接口返回摘要成一两行可读文字 */
|
||||
function summarizeResult(res: any, source: string): { title: string; detail: string } {
|
||||
if (res && typeof res === 'object') {
|
||||
if (source === 'bzzoiro' && ('total_inserted' in res || 'total_updated' in res)) {
|
||||
return {
|
||||
title: `采集完成:新增 ${res.total_inserted ?? 0} 条,更新 ${res.total_updated ?? 0} 条`,
|
||||
detail: Array.isArray(res.errors) && res.errors.length > 0 ? res.errors.join('\n') : '',
|
||||
}
|
||||
}
|
||||
if ('count' in res || 'updated' in res) {
|
||||
const parts = [
|
||||
`新增 ${res.count ?? 0}`,
|
||||
`更新 ${res.updated ?? 0}`,
|
||||
`跳过 ${res.skipped ?? 0}`,
|
||||
`未匹配 ${res.unmatched ?? 0}`,
|
||||
]
|
||||
return {
|
||||
title: `采集完成:${parts.join(' / ')}`,
|
||||
detail: Array.isArray(res.errors) && res.errors.length > 0 ? res.errors.join('\n') : '',
|
||||
}
|
||||
}
|
||||
}
|
||||
return { title: '采集完成', detail: JSON.stringify(res)?.slice(0, 300) ?? '' }
|
||||
}
|
||||
|
||||
export default function CollectionPage() {
|
||||
const [leagues, setLeagues] = useState<League[]>([])
|
||||
const [source, setSource] = useState<string>('bzzoiro')
|
||||
const [task, setTask] = useState<string>('events')
|
||||
const [leagueCode, setLeagueCode] = useState('')
|
||||
const [dateFrom, setDateFrom] = useState('')
|
||||
const [dateTo, setDateTo] = useState('')
|
||||
const [season, setSeason] = useState('')
|
||||
const [ingestStatus, setIngestStatus] = useState('') // 空 = 已完赛+未开赛
|
||||
const [limit, setLimit] = useState(100)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [result, setResult] = useState<{ title: string; detail: string } | null>(null)
|
||||
@@ -59,6 +42,8 @@ export default function CollectionPage() {
|
||||
|
||||
useEffect(() => { loadLeagues() }, [loadLeagues])
|
||||
|
||||
const isEventsTask = task === 'events' || task === 'all'
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
@@ -67,18 +52,20 @@ export default function CollectionPage() {
|
||||
|
||||
try {
|
||||
const body: CollectionRequest = {
|
||||
source: source as CollectionRequest['source'],
|
||||
source: 'bzzoiro',
|
||||
leagues: leagueCode ? [leagueCode] : undefined,
|
||||
league: leagueCode || undefined,
|
||||
task: task as CollectionRequest['task'],
|
||||
limit,
|
||||
season: season || undefined,
|
||||
status: ingestStatus || undefined,
|
||||
date_from: dateFrom || undefined,
|
||||
date_to: dateTo || undefined,
|
||||
// 仅 events/all 任务生效
|
||||
date_from: isEventsTask ? dateFrom || undefined : undefined,
|
||||
date_to: isEventsTask ? dateTo || undefined : undefined,
|
||||
}
|
||||
await triggerCollection(body)
|
||||
setResult({
|
||||
title: '采集任务已启动',
|
||||
detail: '正在后台执行(上游限速时可能需要几分钟)。完成结果与错误请到「系统日志」页查看(支持自动刷新)。',
|
||||
detail: '正在后台执行(上游限速时可能需要数分钟)。完成结果与错误请到「系统日志」页查看(支持自动刷新)。',
|
||||
})
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : '采集触发失败')
|
||||
@@ -91,7 +78,7 @@ export default function CollectionPage() {
|
||||
<div className="space-y-6">
|
||||
<SectionHeader
|
||||
title="数据采集"
|
||||
description="触发数据源采集,支持联赛筛选和日期范围。采集为同步执行,大范围日期耗时较长。"
|
||||
description="bzzoiro 单一数据源:比赛数据、积分榜、比赛统计三条管线。采集为后台异步执行。"
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
@@ -100,20 +87,26 @@ export default function CollectionPage() {
|
||||
<CardHeader title="新建采集任务" />
|
||||
<CardBody>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* 数据源选择 */}
|
||||
{/* 任务类型 */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">数据源</label>
|
||||
<select
|
||||
value={source}
|
||||
onChange={e => setSource(e.target.value)}
|
||||
className="field w-full"
|
||||
>
|
||||
{SOURCES.map(s => (
|
||||
<option key={s.value} value={s.value}>
|
||||
{s.label} — {s.desc}
|
||||
</option>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">采集任务</label>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
{TASKS.map(t => (
|
||||
<button
|
||||
key={t.value}
|
||||
type="button"
|
||||
onClick={() => setTask(t.value)}
|
||||
className={`rounded-lg border px-3 py-2 text-left text-xs transition-colors ${
|
||||
task === t.value
|
||||
? 'border-brand-500 bg-brand-50 text-brand-700'
|
||||
: 'border-ink-200 text-ink-600 hover:border-ink-300'
|
||||
}`}
|
||||
>
|
||||
<span className="mr-1">{t.icon}</span>
|
||||
<span className="font-medium">{t.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 联赛选择 */}
|
||||
@@ -131,57 +124,73 @@ export default function CollectionPage() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Bzzoiro 专用: 比赛状态 */}
|
||||
{source === 'bzzoiro' && (
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">比赛状态</label>
|
||||
<select
|
||||
value={ingestStatus}
|
||||
onChange={e => setIngestStatus(e.target.value)}
|
||||
className="field w-full"
|
||||
>
|
||||
<option value="">全部(已完赛 + 未开赛)</option>
|
||||
<option value="finished">仅已完赛</option>
|
||||
<option value="scheduled">仅未开赛</option>
|
||||
</select>
|
||||
</div>
|
||||
{/* events/all 任务专用: 比赛状态 + 日期 */}
|
||||
{isEventsTask && (
|
||||
<>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">比赛状态</label>
|
||||
<select
|
||||
value={ingestStatus}
|
||||
onChange={e => setIngestStatus(e.target.value)}
|
||||
className="field w-full"
|
||||
>
|
||||
<option value="">全部(已完赛 + 未开赛)</option>
|
||||
<option value="finished">仅已完赛</option>
|
||||
<option value="scheduled">仅未开赛</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">起始日期</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={e => setDateFrom(e.target.value)}
|
||||
className="field w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">结束日期</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={e => setDateTo(e.target.value)}
|
||||
className="field w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Understat 专用: 赛季 */}
|
||||
{source === 'understat' && (
|
||||
{/* standings 任务专用: 赛季 */}
|
||||
{(task === 'standings') && (
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">赛季(起始年)</label>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">赛季(留空取当前赛季)</label>
|
||||
<input
|
||||
type="number"
|
||||
type="text"
|
||||
value={season}
|
||||
onChange={e => setSeason(e.target.value)}
|
||||
placeholder="2025"
|
||||
placeholder="如 2026-2027"
|
||||
className="field w-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 日期范围 */}
|
||||
{source !== 'injuries' && (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">起始日期</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={e => setDateFrom(e.target.value)}
|
||||
className="field w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">结束日期</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={e => setDateTo(e.target.value)}
|
||||
className="field w-full"
|
||||
/>
|
||||
</div>
|
||||
{/* stats 任务专用: 回填数量 */}
|
||||
{(task === 'stats') && (
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">单次最大回填比赛数(1-500)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={500}
|
||||
value={limit}
|
||||
onChange={e => setLimit(parseInt(e.target.value) || 100)}
|
||||
className="field w-full"
|
||||
/>
|
||||
<p className="mt-1 text-2xs text-ink-400">
|
||||
仅回填已有 source_event_id 且无统计的比赛(增量),上游限速约 1.2 秒/次。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -206,22 +215,24 @@ export default function CollectionPage() {
|
||||
|
||||
{/* 数据源说明 */}
|
||||
<Card>
|
||||
<CardHeader title="数据源说明" />
|
||||
<CardHeader title="采集任务说明" />
|
||||
<CardBody>
|
||||
<div className="space-y-3">
|
||||
{SOURCES.map(s => (
|
||||
<div key={s.value} className="border-b border-ink-200 px-1 py-3 last:border-b-0">
|
||||
{TASKS.map(t => (
|
||||
<div key={t.value} className="border-b border-ink-200 px-1 py-3 last:border-b-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge status="info">{s.label}</Badge>
|
||||
<p className="text-xs text-ink-600">{s.desc}</p>
|
||||
<span>{t.icon}</span>
|
||||
<Badge status="info">{t.label}</Badge>
|
||||
<p className="text-xs text-ink-600">{t.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="mt-4 border-l-2 border-ink-300 pl-3 text-2xs leading-relaxed text-ink-500">
|
||||
采集接口需要管理员登录。
|
||||
遇到 401 表示登录已过期,请重新登录。
|
||||
采集接口需要管理员登录(401 表示登录已过期)。
|
||||
各管线基于 bzzoiro 单一数据源(Understat / injuries 已移除)。
|
||||
「统计回填」依赖「比赛数据」管线写入的 source_event_id,请先完成比赛采集。
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
Reference in New Issue
Block a user