/** * Admin 后台 - 数据采集页面(bzzoiro 单一数据源) * * 三个采集任务: * events — 比赛日程与比分 * standings — 联赛积分榜 * stats — 已完赛比赛详细统计回填 * all — 依次执行以上三项 * * 响应式布局: 移动端单列,桌面端双列 */ import { useEffect, useState, useCallback, useRef } from 'react' import { triggerCollection, fetchLeagues, fetchIngestStatus, fetchIngestJob } from '../dal' import type { IngestSourceStatus } from '../types' import type { CollectionRequest, League, IngestJob } 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 TaskStatus = 'idle' | 'running' | 'done' | 'error' const JOB_STATUS_LABEL: Record = { pending: '等待中', running: '执行中', success: '已完成', failed: '失败', } /** job.result 摘要 → 多行文本(错误只显示条数,明细见「采集失败」页与系统日志) */ function summarizeResult(result: Record | null): string[] { if (!result) return [] return Object.entries(result).map(([k, v]) => { if (v && typeof v === 'object') { const bits: string[] = [] for (const [kk, vv] of Object.entries(v)) { if (kk === 'errors') { if (Array.isArray(vv) && vv.length > 0) bits.push(`错误 ${vv.length} 条`) } else if (vv !== null && vv !== undefined) { bits.push(`${kk} ${vv}`) } } return `${k}: ${bits.length ? bits.join(' · ') : '无变更'}` } return `${k}: ${String(v)}` }) } 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) const [result, setResult] = useState<{ title: string; detail: string } | null>(null) // 任务进度反馈 const [taskStatus, setTaskStatus] = useState('idle') const [taskStartedAt, setTaskStartedAt] = useState(null) const pollRef = useRef | null>(null) const jobPollRef = useRef | null>(null) const [ingestSnap, setIngestSnap] = useState(null) // 采集任务状态(ingest_jobs):提交后有 job_id 即轮询,替代「30 秒盲等」 const [job, setJob] = useState(null) const loadLeagues = useCallback(async () => { const lg = await fetchLeagues() setLeagues(lg) }, []) useEffect(() => { loadLeagues() }, [loadLeagues]) // 轮询采集状态(任务启动后) const startPolling = useCallback(() => { if (pollRef.current) clearInterval(pollRef.current) pollRef.current = setInterval(async () => { try { const { sources } = await fetchIngestStatus() const bz = sources.find(s => s.name === 'bzzoiro') if (bz) setIngestSnap(bz) } catch { /* ignore */ } }, 5_000) }, []) const stopPolling = useCallback(() => { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null } }, []) // job 轮询:3 秒一次,终态(success/failed)自动停止;10 分钟兜底防泄漏 const stopJobPolling = useCallback(() => { if (jobPollRef.current) { clearInterval(jobPollRef.current); jobPollRef.current = null } }, []) const startJobPolling = useCallback((jobId: string) => { stopJobPolling() let ticks = 0 jobPollRef.current = setInterval(async () => { ticks += 1 if (ticks > 200) { stopJobPolling(); setTaskStatus('done'); return } try { const j = await fetchIngestJob(jobId) setJob(j) if (j.status === 'success') { stopJobPolling(); setTaskStatus('done') } else if (j.status === 'failed') { stopJobPolling(); setTaskStatus('error') } } catch { /* 网络抖动忽略,下个周期重试 */ } }, 3_000) }, [stopJobPolling]) useEffect(() => () => { stopPolling(); stopJobPolling() }, [stopPolling, stopJobPolling]) const isEventsTask = task === 'events' || task === 'all' async function handleSubmit(e: React.FormEvent) { e.preventDefault() setError(null) setResult(null) setJob(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 resp = await triggerCollection(body) const jobId: string | undefined = resp?.job_id if (jobId) { // 有 job_id:轮询任务状态直至终态(3 秒/次) setResult({ title: '采集任务已启动', detail: `任务 ID: ${jobId}。正在跟踪执行进度,完成后展示结果摘要。`, }) startJobPolling(jobId) } else { // 兜底:后端未返回 job_id(旧版本),退回「30 秒盲等 + 系统日志」 setResult({ title: '采集任务已启动', detail: '正在后台执行(上游限速时可能需要数分钟)。完成结果与错误请到「系统日志」页查看(支持自动刷新)。', }) startPolling() setTimeout(() => { setTaskStatus('done') stopPolling() }, 30_000) } } catch (err: unknown) { setTaskStatus('error') setError(err instanceof Error ? err.message : '采集触发失败') stopPolling() stopJobPolling() } finally { setLoading(false) } } const elapsed = taskStartedAt ? Math.round((Date.now() - taskStartedAt) / 1000) : 0 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)} />} {result && ( setResult(null)} /> )} {/* 提交按钮 */}
{/* 任务状态 + 数据源说明 */}
{/* 任务进度 */} {taskStatus === 'idle' && (

尚未触发任务。

)} {taskStatus === 'running' && (
{job ? `${JOB_STATUS_LABEL[job.status]}(轮询中),已运行 ${elapsed}s…` : `任务执行中,已运行 ${elapsed}s…`}

后台异步执行,关闭页面不影响结果。可稍后查看「系统日志」确认完成。

)} {taskStatus === 'done' && (
{job ? `采集完成(${JOB_STATUS_LABEL[job.status]})` : '任务已提交,后台执行中(可能尚未完成)'}
{job?.result && summarizeResult(job.result).length > 0 && (
{summarizeResult(job.result).map((line, i) => (

{line}

))}
)} {!job && (

采集耗时取决于数据量。请到「系统日志」页查看最终结果。

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

{job?.status === 'failed' ? '采集任务失败' : '任务触发失败,请检查配置或网络。'}

{job?.error && (

{job.error}

)}
)} {ingestSnap?.last_success_at && (

bzzoiro 最近一次采集: {new Date(ingestSnap.last_success_at).toLocaleString('zh-CN', { hour12: false })}

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

{t.desc}

))}

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

) }