admin: 信息架构重组 + Settings tab 化 + 待办驱动 Dashboard + 任务历史 #15
@@ -370,6 +370,17 @@ export function fetchIngestJob(jobId: string): Promise<IngestJob> {
|
|||||||
return api.get<IngestJob>(`${API_BASE}/admin/ingest/jobs/${jobId}`)
|
return api.get<IngestJob>(`${API_BASE}/admin/ingest/jobs/${jobId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 采集任务历史列表(GET /admin/ingest/jobs,最新在前)
|
||||||
|
*/
|
||||||
|
export function fetchIngestJobs(params: { limit?: number; status?: string } = {}): Promise<IngestJob[]> {
|
||||||
|
const q = new URLSearchParams()
|
||||||
|
if (params.limit != null) q.set('limit', String(params.limit))
|
||||||
|
if (params.status) q.set('status', params.status)
|
||||||
|
const qs = q.toString()
|
||||||
|
return api.get<IngestJob[]>(`${API_BASE}/admin/ingest/jobs${qs ? `?${qs}` : ''}`)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 比赛详情(含最近预测摘要)
|
* 比赛详情(含最近预测摘要)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -11,16 +11,17 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
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 { IngestJob, League } from '../types'
|
||||||
import type { CollectionRequest } from '../types'
|
import type { CollectionRequest } from '../types'
|
||||||
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
|
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
|
||||||
|
|
||||||
|
// 图标用与全站一致的几何字符(Dashboard 工作流卡同款),不混用 emoji
|
||||||
const TASKS = [
|
const TASKS = [
|
||||||
{ value: 'events', label: '比赛数据', desc: '赛程 / 比分 / 未开赛安排', icon: '⚽' },
|
{ value: 'events', label: '比赛数据', desc: '赛程 / 比分 / 未开赛安排', icon: '◈' },
|
||||||
{ value: 'standings', label: '积分榜', desc: '联赛排名 / 积分 / xG差 / 近期走势', icon: '🏆' },
|
{ value: 'standings', label: '积分榜', desc: '联赛排名 / 积分 / xG差 / 近期走势', icon: '◇' },
|
||||||
{ value: 'stats', label: '统计回填', desc: '已完赛比赛的 xG / 射门 / 控球等详细统计', icon: '📊' },
|
{ value: 'stats', label: '统计回填', desc: '已完赛比赛的 xG / 射门 / 控球等详细统计', icon: '▤' },
|
||||||
{ value: 'all', label: '全量采集', desc: '依次采集比赛 + 积分榜 + 统计回填', icon: '⏵⏵' },
|
{ value: 'all', label: '全量采集', desc: '依次采集比赛 + 积分榜 + 统计回填', icon: '⇉' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
type TaskUIStatus = 'idle' | 'running' | 'done' | 'error'
|
type TaskUIStatus = 'idle' | 'running' | 'done' | 'error'
|
||||||
@@ -73,6 +74,16 @@ export default function CollectionPage() {
|
|||||||
|
|
||||||
useEffect(() => () => stopPolling(), [stopPolling])
|
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) => {
|
const startJobPolling = useCallback((id: string) => {
|
||||||
stopPolling()
|
stopPolling()
|
||||||
const tick = async () => {
|
const tick = async () => {
|
||||||
@@ -82,12 +93,13 @@ export default function CollectionPage() {
|
|||||||
if (TERMINAL_STATUSES.has(job.status)) {
|
if (TERMINAL_STATUSES.has(job.status)) {
|
||||||
setTaskStatus(job.status === 'success' ? 'done' : 'error')
|
setTaskStatus(job.status === 'success' ? 'done' : 'error')
|
||||||
stopPolling()
|
stopPolling()
|
||||||
|
loadRecentJobs() // 终态后刷新历史列表
|
||||||
}
|
}
|
||||||
} catch { /* 单次轮询失败不影响后续 */ }
|
} catch { /* 单次轮询失败不影响后续 */ }
|
||||||
}
|
}
|
||||||
tick()
|
tick()
|
||||||
pollRef.current = setInterval(tick, 3_000)
|
pollRef.current = setInterval(tick, 3_000)
|
||||||
}, [stopPolling])
|
}, [stopPolling, loadRecentJobs])
|
||||||
|
|
||||||
const isEventsTask = task === 'events' || task === 'all'
|
const isEventsTask = task === 'events' || task === 'all'
|
||||||
|
|
||||||
@@ -183,9 +195,9 @@ export default function CollectionPage() {
|
|||||||
key={t.value}
|
key={t.value}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setTask(t.value)}
|
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
|
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'
|
: 'border-ink-200 text-ink-600 hover:border-ink-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -345,6 +357,41 @@ export default function CollectionPage() {
|
|||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</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>
|
<Card>
|
||||||
<CardHeader title="采集任务说明" />
|
<CardHeader title="采集任务说明" />
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export default function LogsPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="系统日志"
|
title="系统日志"
|
||||||
description="应用运行日志(登录、配置变更、采集、预测、异常等)。内存缓冲最近 2000 条,重启后清零。"
|
description="应用运行日志(登录、配置变更、采集、预测、异常等)。内存缓冲最近 2000 条,重启后清零;若后端已配置 LOG_FILE,完整日志同时滚动写入服务器文件(单文件 10MB × 5 份),可登录宿主机查看。"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{error && <Alert kind="error" title="无法加载日志" message={error} />}
|
{error && <Alert kind="error" title="无法加载日志" message={error} />}
|
||||||
|
|||||||
Reference in New Issue
Block a user