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:
shangfangjian
2026-09-20 21:24:21 +08:00
co-authored by new-provider/LongCat-2.0 <
parent dd538d66d7
commit fb27f6e2d2
13 changed files with 854 additions and 107 deletions
+122 -25
View File
@@ -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>
)