/** * Admin 后台 - 数据采集页面(bzzoiro 单一数据源) * * 三个采集任务: * events — 比赛日程与比分 * standings — 联赛积分榜 * stats — 已完赛比赛详细统计回填 * all — 依次执行以上三项 * * 响应式布局: 移动端单列,桌面端双列 */ import { useEffect, useState, useCallback, useRef } from 'react' import { triggerCollection, fetchLeagues, fetchIngestJob } from '../dal' import type { IngestJob, League } from '../types' import type { CollectionRequest } from '../types' import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components' 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 type TaskUIStatus = 'idle' | 'running' | 'done' | 'error' const TERMINAL_STATUSES: ReadonlySet = new Set(['success', 'failed']) export default function CollectionPage() { const [leagues, setLeagues] = useState([]) const [task, setTask] = useState('events') const [leagueCode, setLeagueCode] = useState('') // 从 URL 查询参数预填充(支持从「数据完整性」页跳转) useEffect(() => { const sp = new URLSearchParams(window.location.search) const taskParam = sp.get('task') const leagueParam = sp.get('league') if (taskParam && ['events', 'standings', 'stats', 'all'].includes(taskParam)) { setTask(taskParam) } if (leagueParam) { setLeagueCode(leagueParam) } }, []) 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(null) // 任务进度反馈:跟踪真实 ingest_job 状态 const [taskStatus, setTaskStatus] = useState('idle') const [jobId, setJobId] = useState(null) const [jobInfo, setJobInfo] = useState(null) const [taskStartedAt, setTaskStartedAt] = useState(null) const pollRef = useRef | null>(null) const loadLeagues = useCallback(async () => { const lg = await fetchLeagues() setLeagues(lg) }, []) useEffect(() => { loadLeagues() }, [loadLeagues]) // 轮询采集 job 直到终态(success/failed) const stopPolling = useCallback(() => { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null } }, []) useEffect(() => () => stopPolling(), [stopPolling]) const startJobPolling = useCallback((id: string) => { stopPolling() const tick = async () => { try { const job = await fetchIngestJob(id) setJobInfo(job) if (TERMINAL_STATUSES.has(job.status)) { setTaskStatus(job.status === 'success' ? 'done' : 'error') stopPolling() } } catch { /* 单次轮询失败不影响后续 */ } } tick() pollRef.current = setInterval(tick, 3_000) }, [stopPolling]) const isEventsTask = task === 'events' || task === 'all' // 友好汇总 job.result const jobSummary = (j: IngestJob | null): { title: string; detail: string } | null => { if (!j) return null if (j.status === 'failed') { return { title: '采集失败', detail: j.error || '采集任务异常终止,请到「系统日志」查看详细堆栈。' } } if (j.status !== 'success') return null const r = j.result as Record | null if (!r) return { title: '采集完成', detail: '任务成功(无汇总数据)。' } const ev = r.events as Record | undefined const evTotal = ev ? (ev.total_inserted as number ?? 0) + (ev.total_updated as number ?? 0) : 0 const st = r.standings as Record | undefined const stTotal = st ? (st.total_upserted as number ?? 0) : 0 const stats = r.stats as Record | undefined const statsTotal = stats ? (stats.created as number ?? 0) + (stats.updated as number ?? 0) : 0 const evErr = (ev?.errors as string[] | undefined)?.length ?? 0 const stErr = (st?.errors as string[] | undefined)?.length ?? 0 const statsErr = (stats?.errors as string[] | undefined)?.length ?? 0 const totalErr = evErr + stErr + statsErr const parts: string[] = [] if (ev) parts.push(`比赛 +${evTotal}`) if (st) parts.push(`积分榜 +${stTotal}`) if (stats) parts.push(`统计 +${statsTotal}`) const detail = parts.length ? `共更新: ${parts.join(' / ')}${totalErr ? `,错误 ${totalErr} 条(见日志)` : ''}` : '任务成功' return { title: '采集完成', detail } } async function handleSubmit(e: React.FormEvent) { e.preventDefault() setError(null) setJobInfo(null) setJobId(null) setLoading(true) setTaskStatus('running') setTaskStartedAt(Date.now()) try { const body: CollectionRequest = { source: 'bzzoiro', leagues: leagueCode ? [leagueCode] : undefined, task: task as CollectionRequest['task'], limit, season: season || undefined, status: ingestStatus || undefined, date_from: isEventsTask ? dateFrom || undefined : undefined, date_to: isEventsTask ? dateTo || undefined : undefined, } const res = await triggerCollection(body) const id: string | undefined = res?.job_id if (id) { setJobId(id) startJobPolling(id) } else { // 后端未返回 job_id(旧版兼容):退化为原逻辑 setTimeout(() => { setTaskStatus('done'); }, 30_000) } } catch (err: unknown) { setTaskStatus('error') setError(err instanceof Error ? err.message : '采集触发失败') stopPolling() } finally { setLoading(false) } } const elapsed = taskStartedAt ? Math.round((Date.now() - taskStartedAt) / 1000) : 0 const summary = jobInfo ? jobSummary(jobInfo) : null return (
{/* 采集表单 */}
{/* 任务类型 */}
{TASKS.map(t => ( ))}
{/* 联赛选择 */}
{/* events/all 任务专用: 比赛状态 + 日期 */} {isEventsTask && ( <>
setDateFrom(e.target.value)} className="field w-full" />
setDateTo(e.target.value)} className="field w-full" />
)} {/* standings 任务专用: 赛季 */} {(task === 'standings') && (
setSeason(e.target.value)} placeholder="如 2026-2027" className="field w-full" />
)} {/* stats 任务专用: 回填数量 */} {(task === 'stats') && (
setLimit(parseInt(e.target.value) || 100)} className="field w-full" />

仅回填已有 source_event_id 且无统计的比赛(增量),上游限速约 1.2 秒/次。

)} {/* 消息提示 */} {error && setError(null)} />} {summary && ( )} {/* 提交按钮 */}
{/* 任务状态 + 数据源说明 */}
{/* 任务进度 */} {taskStatus === 'idle' && (

尚未触发任务。

)} {taskStatus === 'running' && (
任务执行中{jobId ? `(job ${jobId.slice(0, 8)}…)` : ''},已运行 {elapsed}s…

后台异步执行,关闭页面不影响结果。每 3 秒自动轮询进度。

)} {taskStatus === 'done' && (
采集完成{jobId ? `(job ${jobId.slice(0, 8)}…)` : ''}
{summary &&

{summary.detail}

}
)} {taskStatus === 'error' && (

采集失败{jobId ? `(job ${jobId.slice(0, 8)}…)` : ''}

{jobInfo?.error && (

{jobInfo.error.slice(0, 200)}

)}
)} {jobInfo?.created_at && (

创建于 {new Date(jobInfo.created_at).toLocaleString('zh-CN', { hour12: false })} {jobInfo.finished_at && ` · 完成于 ${new Date(jobInfo.finished_at).toLocaleString('zh-CN', { hour12: false })}`}

)}
{/* 数据源说明 */}
{TASKS.map(t => (
{t.icon} {t.label}

{t.desc}

))}

采集接口需要管理员登录(401 表示登录已过期)。 各管线基于 bzzoiro 单一数据源(Understat / injuries 已移除)。 「统计回填」依赖「比赛数据」管线写入的 source_event_id,请先完成比赛采集。

) }