新增 ingest_jobs 表(UUID/task/params/status/result/error/时间戳),
POST /ingest/bzzoiro 启动前插入 job(pending)→ 后台 running → success/failed,
响应新增 job_id(兼容原 message)。
Admin GET /admin/ingest/jobs/{id} 与 /admin/ingest/jobs?limit= 只读查询;
Collection 页提交后轮询 job 至终态,展示真实 result/error 汇总。
迁移 0019_ingest_jobs + 分批 get_uow/BzzoiroSource/IngestFailure 不变。
全量测试 270 通过。
376 lines
15 KiB
TypeScript
376 lines
15 KiB
TypeScript
/**
|
|
* 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<string> = new Set(['success', 'failed'])
|
|
|
|
export default function CollectionPage() {
|
|
const [leagues, setLeagues] = useState<League[]>([])
|
|
const [task, setTask] = useState<string>('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<string | null>(null)
|
|
|
|
// 任务进度反馈:跟踪真实 ingest_job 状态
|
|
const [taskStatus, setTaskStatus] = useState<TaskUIStatus>('idle')
|
|
const [jobId, setJobId] = useState<string | null>(null)
|
|
const [jobInfo, setJobInfo] = useState<IngestJob | null>(null)
|
|
const [taskStartedAt, setTaskStartedAt] = useState<number | null>(null)
|
|
const pollRef = useRef<ReturnType<typeof setInterval> | 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<string, unknown> | null
|
|
if (!r) return { title: '采集完成', detail: '任务成功(无汇总数据)。' }
|
|
const ev = r.events as Record<string, unknown> | undefined
|
|
const evTotal = ev ? (ev.total_inserted as number ?? 0) + (ev.total_updated as number ?? 0) : 0
|
|
const st = r.standings as Record<string, unknown> | undefined
|
|
const stTotal = st ? (st.total_upserted as number ?? 0) : 0
|
|
const stats = r.stats as Record<string, unknown> | 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 (
|
|
<div className="space-y-6">
|
|
<SectionHeader
|
|
title="数据采集"
|
|
description="bzzoiro 单一数据源:比赛数据、积分榜、比赛统计三条管线。采集为后台异步执行。"
|
|
/>
|
|
|
|
<div className="grid gap-6 lg:grid-cols-2">
|
|
{/* 采集表单 */}
|
|
<Card>
|
|
<CardHeader title="新建采集任务" />
|
|
<CardBody>
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
{/* 任务类型 */}
|
|
<div>
|
|
<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>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 联赛选择 */}
|
|
<div>
|
|
<label className="mb-1.5 block text-xs text-ink-500">联赛</label>
|
|
<select
|
|
value={leagueCode}
|
|
onChange={e => setLeagueCode(e.target.value)}
|
|
className="field w-full"
|
|
>
|
|
<option value="">全部联赛</option>
|
|
{leagues.map(l => (
|
|
<option key={l.code} value={l.code}>{l.name_zh || l.name}</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>
|
|
</>
|
|
)}
|
|
|
|
{/* standings 任务专用: 赛季 */}
|
|
{(task === 'standings') && (
|
|
<div>
|
|
<label className="mb-1.5 block text-xs text-ink-500">赛季(留空取当前赛季)</label>
|
|
<input
|
|
type="text"
|
|
value={season}
|
|
onChange={e => setSeason(e.target.value)}
|
|
placeholder="如 2026-2027"
|
|
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>
|
|
)}
|
|
|
|
{/* 消息提示 */}
|
|
{error && <Alert kind="error" title="采集失败" message={error} onClose={() => setError(null)} />}
|
|
{summary && (
|
|
<Alert
|
|
kind={jobInfo?.status === 'success' ? 'ok' : 'error'}
|
|
title={summary.title}
|
|
message={summary.detail}
|
|
/>
|
|
)}
|
|
|
|
{/* 提交按钮 */}
|
|
<button type="submit" disabled={loading} className="btn btn-solid w-full">
|
|
{loading ? (<><Spinner /> 采集中</>) : '触发采集'}
|
|
</button>
|
|
</form>
|
|
</CardBody>
|
|
</Card>
|
|
|
|
{/* 任务状态 + 数据源说明 */}
|
|
<div className="space-y-6">
|
|
{/* 任务进度 */}
|
|
<Card>
|
|
<CardHeader title="任务状态" />
|
|
<CardBody>
|
|
{taskStatus === 'idle' && (
|
|
<p className="text-xs text-ink-400">尚未触发任务。</p>
|
|
)}
|
|
{taskStatus === 'running' && (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center gap-2 text-xs text-ink-700">
|
|
<Spinner />
|
|
<span>任务执行中{jobId ? `(job ${jobId.slice(0, 8)}…)` : ''},已运行 {elapsed}s…</span>
|
|
</div>
|
|
<p className="text-2xs text-ink-400">
|
|
后台异步执行,关闭页面不影响结果。每 3 秒自动轮询进度。
|
|
</p>
|
|
</div>
|
|
)}
|
|
{taskStatus === 'done' && (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2 text-xs text-emerald-700">
|
|
<span className="inline-block h-2 w-2 rounded-full bg-emerald-500" />
|
|
<span>采集完成{jobId ? `(job ${jobId.slice(0, 8)}…)` : ''}</span>
|
|
</div>
|
|
{summary && <p className="text-2xs text-ink-500">{summary.detail}</p>}
|
|
</div>
|
|
)}
|
|
{taskStatus === 'error' && (
|
|
<div className="space-y-1">
|
|
<p className="text-xs text-press">采集失败{jobId ? `(job ${jobId.slice(0, 8)}…)` : ''}</p>
|
|
{jobInfo?.error && (
|
|
<p className="text-2xs text-ink-500">{jobInfo.error.slice(0, 200)}</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
{jobInfo?.created_at && (
|
|
<p className="mt-2 text-2xs text-ink-400">
|
|
创建于 {new Date(jobInfo.created_at).toLocaleString('zh-CN', { hour12: false })}
|
|
{jobInfo.finished_at && ` · 完成于 ${new Date(jobInfo.finished_at).toLocaleString('zh-CN', { hour12: false })}`}
|
|
</p>
|
|
)}
|
|
</CardBody>
|
|
</Card>
|
|
|
|
{/* 数据源说明 */}
|
|
<Card>
|
|
<CardHeader title="采集任务说明" />
|
|
<CardBody>
|
|
<div className="space-y-3">
|
|
{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">
|
|
<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 表示登录已过期)。
|
|
各管线基于 bzzoiro 单一数据源(Understat / injuries 已移除)。
|
|
「统计回填」依赖「比赛数据」管线写入的 source_event_id,请先完成比赛采集。
|
|
</p>
|
|
</CardBody>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|