|
|
|
@@ -11,16 +11,17 @@
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { useEffect, useState, useCallback, useRef } from 'react'
|
|
|
|
|
import { triggerCollection, fetchLeagues, fetchIngestJob } from '../dal'
|
|
|
|
|
import { triggerCollection, fetchLeagues, fetchIngestJob, fetchIngestJobs } from '../dal'
|
|
|
|
|
import type { IngestJob, League } from '../types'
|
|
|
|
|
import type { CollectionRequest } from '../types'
|
|
|
|
|
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
|
|
|
|
|
|
|
|
|
|
// 图标用与全站一致的几何字符(Dashboard 工作流卡同款),不混用 emoji
|
|
|
|
|
const TASKS = [
|
|
|
|
|
{ value: 'events', label: '比赛数据', desc: '赛程 / 比分 / 未开赛安排', icon: '⚽' },
|
|
|
|
|
{ value: 'standings', label: '积分榜', desc: '联赛排名 / 积分 / xG差 / 近期走势', icon: '🏆' },
|
|
|
|
|
{ value: 'stats', label: '统计回填', desc: '已完赛比赛的 xG / 射门 / 控球等详细统计', icon: '📊' },
|
|
|
|
|
{ value: 'all', label: '全量采集', desc: '依次采集比赛 + 积分榜 + 统计回填', icon: '⏵⏵' },
|
|
|
|
|
{ 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'
|
|
|
|
@@ -73,6 +74,16 @@ export default function CollectionPage() {
|
|
|
|
|
|
|
|
|
|
useEffect(() => () => stopPolling(), [stopPolling])
|
|
|
|
|
|
|
|
|
|
// ── 最近任务历史(GET /admin/ingest/jobs,最新在前) ──
|
|
|
|
|
// 声明须在 startJobPolling 之前(其终态回调会刷新历史)
|
|
|
|
|
const [recentJobs, setRecentJobs] = useState<IngestJob[] | null>(null)
|
|
|
|
|
const loadRecentJobs = useCallback(async () => {
|
|
|
|
|
try {
|
|
|
|
|
setRecentJobs(await fetchIngestJobs({ limit: 10 }))
|
|
|
|
|
} catch { /* 历史列表失败不影响主流程 */ }
|
|
|
|
|
}, [])
|
|
|
|
|
useEffect(() => { loadRecentJobs() }, [loadRecentJobs])
|
|
|
|
|
|
|
|
|
|
const startJobPolling = useCallback((id: string) => {
|
|
|
|
|
stopPolling()
|
|
|
|
|
const tick = async () => {
|
|
|
|
@@ -82,12 +93,13 @@ export default function CollectionPage() {
|
|
|
|
|
if (TERMINAL_STATUSES.has(job.status)) {
|
|
|
|
|
setTaskStatus(job.status === 'success' ? 'done' : 'error')
|
|
|
|
|
stopPolling()
|
|
|
|
|
loadRecentJobs() // 终态后刷新历史列表
|
|
|
|
|
}
|
|
|
|
|
} catch { /* 单次轮询失败不影响后续 */ }
|
|
|
|
|
}
|
|
|
|
|
tick()
|
|
|
|
|
pollRef.current = setInterval(tick, 3_000)
|
|
|
|
|
}, [stopPolling])
|
|
|
|
|
}, [stopPolling, loadRecentJobs])
|
|
|
|
|
|
|
|
|
|
const isEventsTask = task === 'events' || task === 'all'
|
|
|
|
|
|
|
|
|
@@ -183,9 +195,9 @@ export default function CollectionPage() {
|
|
|
|
|
key={t.value}
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setTask(t.value)}
|
|
|
|
|
className={`rounded-lg border px-3 py-2 text-left text-xs transition-colors ${
|
|
|
|
|
className={`border px-3 py-2 text-left text-xs transition-colors ${
|
|
|
|
|
task === t.value
|
|
|
|
|
? 'border-brand-500 bg-brand-50 text-brand-700'
|
|
|
|
|
? 'border-press bg-press-wash text-press'
|
|
|
|
|
: 'border-ink-200 text-ink-600 hover:border-ink-300'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
@@ -345,6 +357,41 @@ export default function CollectionPage() {
|
|
|
|
|
</CardBody>
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
{/* 最近任务历史 */}
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader
|
|
|
|
|
title="最近任务"
|
|
|
|
|
description="后台采集任务执行历史(最新在前)"
|
|
|
|
|
action={<button onClick={loadRecentJobs} className="btn btn-sm">刷新</button>}
|
|
|
|
|
/>
|
|
|
|
|
<CardBody className="px-0">
|
|
|
|
|
{recentJobs === null ? (
|
|
|
|
|
<p className="px-4 py-2 text-xs text-ink-400 sm:px-5">加载中…</p>
|
|
|
|
|
) : recentJobs.length === 0 ? (
|
|
|
|
|
<p className="px-4 py-2 text-xs text-ink-400 sm:px-5">还没有任务记录,触发一次采集后这里会出现历史。</p>
|
|
|
|
|
) : (
|
|
|
|
|
<div>
|
|
|
|
|
{recentJobs.map(j => {
|
|
|
|
|
const st = j.status
|
|
|
|
|
const dot = st === 'success' ? 'bg-ink-900' : st === 'failed' ? 'bg-press' : 'bg-amber-500 animate-pulse'
|
|
|
|
|
const label = st === 'success' ? '完成' : st === 'failed' ? '失败' : st === 'running' ? '执行中' : '排队中'
|
|
|
|
|
return (
|
|
|
|
|
<div key={j.id} className="flex items-center gap-3 border-b border-ink-200 px-4 py-2.5 last:border-b-0 sm:px-5">
|
|
|
|
|
<span aria-hidden="true" className={`inline-block h-1.5 w-1.5 flex-shrink-0 ${dot}`} />
|
|
|
|
|
<Badge status={st === 'failed' ? 'error' : 'info'}>{j.task}</Badge>
|
|
|
|
|
<span className="text-2xs text-ink-600">{label}</span>
|
|
|
|
|
<span className="ml-auto text-right text-2xs tabular-nums text-ink-400">
|
|
|
|
|
{j.created_at && new Date(j.created_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false })}
|
|
|
|
|
{' '}{jobSummary(j)?.detail ?? (j.error ? j.error.slice(0, 40) : '')}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</CardBody>
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
{/* 数据源说明 */}
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader title="采集任务说明" />
|
|
|
|
|