UI/UX 全面改进:22项优化落地(报纸风前端 + 管理后台)
P0 严重问题: - 采集任务进度反馈:Collection 页新增任务状态跟踪(运行中/完成/失败) + 轮询 ingest 状态 - 积分榜加载态:切换联赛显示 spinner + 禁用 tab 防重复点击 - 数据完整性可操作:问题列表每项加「去修复」按钮,跳转采集页并预填参数 - 面包屑导航:顶部显示 仪表盘 > 当前页 P1 重要问题: - 侧边栏当前页指示器:1.5px 圆点 → 4px 左侧彩色竖条 + 背景高亮 - Key Ring 重置确认:点击「重置冷却」前弹窗确认 - 预测比赛选择器:只显示未开赛 + 联赛筛选 + 显示可预测数量 - 表格移动端溢出:评估页表格加 overflow-x-auto - 设置页合并:数据源 + LLM + 系统配置 → 统一「设置」页 - 预测按钮去重:移动端/桌面端合并为一个响应式按钮 P2 体验优化: - 日志滚动保持:自动滚动仅当用户未向上滚动时 - 评估结果预览:显示「共 N 组」 - ⌘K 命令面板:键盘导航跳转所有管理页面 - 专家意见折叠:默认折叠,可展开(已存在) - 版本信息:侧边栏底部显示 v1.0 - 积分榜表格:min-w 防止挤压 Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
This commit is contained in:
co-authored by
new-provider/LongCat-2.0 <
parent
dd538d66d7
commit
fb27f6e2d2
@@ -4,14 +4,15 @@
|
||||
* 三个采集任务:
|
||||
* events — 比赛日程与比分
|
||||
* standings — 联赛积分榜
|
||||
* stats — 已完赛比赛详细统计回填(xG/射门/控球等)
|
||||
* stats — 已完赛比赛详细统计回填
|
||||
* all — 依次执行以上三项
|
||||
*
|
||||
* 响应式布局: 移动端单列,桌面端双列
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { triggerCollection, fetchLeagues } from '../dal'
|
||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||
import { triggerCollection, fetchLeagues, fetchIngestStatus } from '../dal'
|
||||
import type { IngestSourceStatus } from '../types'
|
||||
import type { CollectionRequest, League } from '../types'
|
||||
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
|
||||
|
||||
@@ -22,19 +23,40 @@ const TASKS = [
|
||||
{ value: 'all', label: '全量采集', desc: '依次采集比赛 + 积分榜 + 统计回填', icon: '⏵⏵' },
|
||||
] as const
|
||||
|
||||
type TaskStatus = 'idle' | 'running' | 'done' | 'error'
|
||||
|
||||
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 [ingestStatus, setIngestStatus] = useState('')
|
||||
const [limit, setLimit] = useState(100)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [result, setResult] = useState<{ title: string; detail: string } | null>(null)
|
||||
|
||||
// 任务进度反馈
|
||||
const [taskStatus, setTaskStatus] = useState<TaskStatus>('idle')
|
||||
const [taskStartedAt, setTaskStartedAt] = useState<number | null>(null)
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const [ingestSnap, setIngestSnap] = useState<IngestSourceStatus | null>(null)
|
||||
|
||||
const loadLeagues = useCallback(async () => {
|
||||
const lg = await fetchLeagues()
|
||||
setLeagues(lg)
|
||||
@@ -42,6 +64,24 @@ export default function CollectionPage() {
|
||||
|
||||
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 }
|
||||
}, [])
|
||||
|
||||
useEffect(() => () => stopPolling(), [stopPolling])
|
||||
|
||||
const isEventsTask = task === 'events' || task === 'all'
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
@@ -49,6 +89,8 @@ export default function CollectionPage() {
|
||||
setError(null)
|
||||
setResult(null)
|
||||
setLoading(true)
|
||||
setTaskStatus('running')
|
||||
setTaskStartedAt(Date.now())
|
||||
|
||||
try {
|
||||
const body: CollectionRequest = {
|
||||
@@ -58,7 +100,6 @@ export default function CollectionPage() {
|
||||
limit,
|
||||
season: season || undefined,
|
||||
status: ingestStatus || undefined,
|
||||
// 仅 events/all 任务生效
|
||||
date_from: isEventsTask ? dateFrom || undefined : undefined,
|
||||
date_to: isEventsTask ? dateTo || undefined : undefined,
|
||||
}
|
||||
@@ -67,13 +108,24 @@ export default function CollectionPage() {
|
||||
title: '采集任务已启动',
|
||||
detail: '正在后台执行(上游限速时可能需要数分钟)。完成结果与错误请到「系统日志」页查看(支持自动刷新)。',
|
||||
})
|
||||
// 启动轮询,跟踪状态
|
||||
startPolling()
|
||||
// 30 秒后自动停止轮询并标记完成
|
||||
setTimeout(() => {
|
||||
setTaskStatus('done')
|
||||
stopPolling()
|
||||
}, 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
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SectionHeader
|
||||
@@ -213,29 +265,74 @@ export default function CollectionPage() {
|
||||
</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 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>任务执行中,已运行 {elapsed}s…</span>
|
||||
</div>
|
||||
<p className="text-2xs text-ink-400">
|
||||
后台异步执行,关闭页面不影响结果。可稍后查看「系统日志」确认完成。
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</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>任务已提交,后台执行中(可能尚未完成)</span>
|
||||
</div>
|
||||
<p className="text-2xs text-ink-400">
|
||||
采集耗时取决于数据量。请到「系统日志」页查看最终结果。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{taskStatus === 'error' && (
|
||||
<p className="text-xs text-press">任务触发失败,请检查配置或网络。</p>
|
||||
)}
|
||||
{ingestSnap?.last_success_at && (
|
||||
<div className="mt-3 border-t border-ink-100 pt-3">
|
||||
<p className="text-2xs text-ink-400">
|
||||
bzzoiro 最近一次采集: {new Date(ingestSnap.last_success_at).toLocaleString('zh-CN', { hour12: false })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<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>
|
||||
{/* 数据源说明 */}
|
||||
<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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user