admin: 信息架构重组 + Settings tab 化 + 待办驱动 Dashboard + 任务历史 #15

Merged
shangfangjian merged 5 commits from admin-ux-overhaul into main 2026-09-22 13:34:38 +08:00
3 changed files with 67 additions and 9 deletions
Showing only changes of commit d013407aa1 - Show all commits
+11
View File
@@ -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}` : ''}`)
}
/** /**
* 比赛详情(含最近预测摘要) * 比赛详情(含最近预测摘要)
*/ */
+55 -8
View File
@@ -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="采集任务说明" />
+1 -1
View File
@@ -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} />}