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
+57 -17
View File
@@ -10,6 +10,32 @@ import { NavLink, Outlet, useLocation } from 'react-router-dom'
import { fetchAuthState, logout, UNAUTHORIZED_EVENT } from './api' import { fetchAuthState, logout, UNAUTHORIZED_EVENT } from './api'
import { fetchHealth } from './dal' import { fetchHealth } from './dal'
import Login from './Login' import Login from './Login'
import { useCommandPalette, CommandPalette } from './useCommandPalette'
const NAV_PAGES: Array<{ to: string; label: string; group: string }> = [
{ to: '/admin', label: '仪表盘', group: '概览' },
{ to: '/admin/collection', label: '数据采集', group: '数据流水线' },
{ to: '/admin/data-completeness', label: '数据完整性', group: '数据流水线' },
{ to: '/admin/predictions', label: '预测管理', group: '数据流水线' },
{ to: '/admin/backtest', label: '回测', group: '数据流水线' },
{ to: '/admin/monitoring', label: '监控', group: '评估与监控' },
{ to: '/admin/eval', label: '评估', group: '评估与监控' },
{ to: '/admin/settings', label: '设置', group: '系统' },
{ to: '/admin/logs', label: '日志', group: '系统' },
]
// 路由 → 面包屑标签
const ROUTE_LABELS: Record<string, string> = {
'/admin': '仪表盘',
'/admin/collection': '数据采集',
'/admin/data-completeness': '数据完整性',
'/admin/predictions': '预测管理',
'/admin/backtest': '回测',
'/admin/monitoring': '监控',
'/admin/settings': '设置',
'/admin/logs': '日志',
'/admin/eval': '评估',
}
const NAV_SECTIONS: { title: string; items: { to: string; label: string; icon: string; end?: boolean }[] }[] = [ const NAV_SECTIONS: { title: string; items: { to: string; label: string; icon: string; end?: boolean }[] }[] = [
{ {
@@ -32,9 +58,8 @@ const NAV_SECTIONS: { title: string; items: { to: string; label: string; icon: s
{ {
title: '系统设置', title: '系统设置',
items: [ items: [
{ to: '/admin/data-sources', label: '数据源', icon: '' }, { to: '/admin/settings', label: '设置', icon: '' },
{ to: '/admin/llm-config', label: 'LLM 配置', icon: '' }, { to: '/admin/logs', label: '日志', icon: '' },
{ to: '/admin/config', label: '系统配置', icon: '◑' },
], ],
}, },
] ]
@@ -54,6 +79,7 @@ export default function AdminLayout() {
const [healthOk, setHealthOk] = useState<boolean | null>(null) const [healthOk, setHealthOk] = useState<boolean | null>(null)
const [authed, setAuthed] = useState<boolean | null>(null) const [authed, setAuthed] = useState<boolean | null>(null)
const location = useLocation() const location = useLocation()
const palette = useCommandPalette(NAV_PAGES)
// 登录门禁:挂载时探测会话,收到 401 事件(会话过期)自动切回登录页 // 登录门禁:挂载时探测会话,收到 401 事件(会话过期)自动切回登录页
useEffect(() => { useEffect(() => {
@@ -159,10 +185,10 @@ export default function AdminLayout() {
to="/admin" to="/admin"
end end
className={({ isActive }) => className={({ isActive }) =>
`flex min-h-[40px] items-center gap-2.5 px-3 text-sm transition-colors ${ `flex min-h-[40px] items-center gap-2.5 border-l-4 px-3 text-sm transition-colors ${
isActive isActive
? 'bg-press-wash/60 font-medium text-press' ? 'border-press bg-press-wash/60 font-medium text-press'
: 'text-ink-500 hover:bg-paper-100 hover:text-ink-900' : 'border-transparent text-ink-500 hover:bg-paper-100 hover:text-ink-900'
}` }`
} }
> >
@@ -181,19 +207,13 @@ export default function AdminLayout() {
<NavLink <NavLink
to={item.to} to={item.to}
className={({ isActive }) => className={({ isActive }) =>
`flex min-h-[40px] items-center gap-2.5 px-3 text-sm transition-colors ${ `flex min-h-[40px] items-center gap-2.5 border-l-4 px-3 text-sm transition-colors ${
isActive isActive
? 'bg-press-wash/60 font-medium text-press' ? 'border-press bg-press-wash/60 font-medium text-press'
: 'text-ink-500 hover:bg-paper-100 hover:text-ink-900' : 'border-transparent text-ink-500 hover:bg-paper-100 hover:text-ink-900'
}` }`
} }
> >
<span
className={`inline-block h-1.5 w-1.5 flex-shrink-0 ${
location.pathname.startsWith(item.to) ? 'bg-press' : 'bg-transparent'
}`}
aria-hidden="true"
/>
<span className="text-base leading-none opacity-50" aria-hidden="true"> <span className="text-base leading-none opacity-50" aria-hidden="true">
{item.icon} {item.icon}
</span> </span>
@@ -207,7 +227,7 @@ export default function AdminLayout() {
</nav> </nav>
{/* 底部 */} {/* 底部 */}
<div className="border-t border-ink-200 px-4 py-3"> <div className="border-t border-ink-200 px-4 py-3 space-y-2">
<a <a
href="/" href="/"
className="flex min-h-[36px] items-center gap-2 text-xs text-ink-500 transition-colors hover:text-press" className="flex min-h-[36px] items-center gap-2 text-xs text-ink-500 transition-colors hover:text-press"
@@ -215,12 +235,13 @@ export default function AdminLayout() {
<span aria-hidden="true"></span> <span aria-hidden="true"></span>
</a> </a>
<p className="text-2xs text-ink-300">Profeto v1.0</p>
</div> </div>
</aside> </aside>
{/* ── 主内容区 ── */} {/* ── 主内容区 ── */}
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
{/* 报眉:日期 + 系统状态 */} {/* 报眉:面包屑 + 日期 + 系统状态 */}
<header className="flex h-11 flex-shrink-0 items-center justify-between border-b border-ink-200 px-4 lg:px-6"> <header className="flex h-11 flex-shrink-0 items-center justify-between border-b border-ink-200 px-4 lg:px-6">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<button <button
@@ -232,6 +253,16 @@ export default function AdminLayout() {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg> </svg>
</button> </button>
{/* 面包屑 */}
<nav className="hidden items-center gap-1.5 text-2xs text-ink-400 sm:flex" aria-label="面包屑">
<a href="/admin" className="hover:text-ink-700"></a>
{location.pathname !== '/admin' && (
<>
<span aria-hidden="true">/</span>
<span className="text-ink-600">{ROUTE_LABELS[location.pathname] ?? '未知页面'}</span>
</>
)}
</nav>
<span className="hidden text-2xs text-ink-500 sm:inline">{dateLine()}</span> <span className="hidden text-2xs text-ink-500 sm:inline">{dateLine()}</span>
</div> </div>
@@ -262,6 +293,15 @@ export default function AdminLayout() {
</div> </div>
</main> </main>
</div> </div>
{/* 命令面板(⌘K) */}
<CommandPalette
open={palette.open}
query={palette.query}
setQuery={palette.setQuery}
items={palette.items}
onClose={() => palette.setOpen(false)}
/>
</div> </div>
) )
} }
+6
View File
@@ -277,11 +277,14 @@ export function Alert({
title, title,
message, message,
onClose, onClose,
action,
}: { }: {
kind: 'error' | 'ok' | 'info' | 'warning' kind: 'error' | 'ok' | 'info' | 'warning'
title: string title: string
message?: string message?: string
onClose?: () => void onClose?: () => void
/** 右侧操作按钮(如「去修复」) */
action?: ReactNode
}) { }) {
const style = const style =
kind === 'error' kind === 'error'
@@ -309,6 +312,8 @@ export function Alert({
</p> </p>
)} )}
</div> </div>
<div className="flex items-center gap-2">
{action}
{onClose && ( {onClose && (
<button <button
onClick={onClose} onClick={onClose}
@@ -321,6 +326,7 @@ export function Alert({
</button> </button>
)} )}
</div> </div>
</div>
) )
} }
+102 -5
View File
@@ -4,14 +4,15 @@
* 三个采集任务: * 三个采集任务:
* events — 比赛日程与比分 * events — 比赛日程与比分
* standings — 联赛积分榜 * standings — 联赛积分榜
* stats — 已完赛比赛详细统计回填(xG/射门/控球等) * stats — 已完赛比赛详细统计回填
* all — 依次执行以上三项 * all — 依次执行以上三项
* *
* 响应式布局: 移动端单列,桌面端双列 * 响应式布局: 移动端单列,桌面端双列
*/ */
import { useEffect, useState, useCallback } from 'react' import { useEffect, useState, useCallback, useRef } from 'react'
import { triggerCollection, fetchLeagues } from '../dal' import { triggerCollection, fetchLeagues, fetchIngestStatus } from '../dal'
import type { IngestSourceStatus } from '../types'
import type { CollectionRequest, League } from '../types' import type { CollectionRequest, League } from '../types'
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components' import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
@@ -22,19 +23,40 @@ const TASKS = [
{ value: 'all', label: '全量采集', desc: '依次采集比赛 + 积分榜 + 统计回填', icon: '⏵⏵' }, { value: 'all', label: '全量采集', desc: '依次采集比赛 + 积分榜 + 统计回填', icon: '⏵⏵' },
] as const ] as const
type TaskStatus = 'idle' | 'running' | 'done' | 'error'
export default function CollectionPage() { export default function CollectionPage() {
const [leagues, setLeagues] = useState<League[]>([]) const [leagues, setLeagues] = useState<League[]>([])
const [task, setTask] = useState<string>('events') const [task, setTask] = useState<string>('events')
const [leagueCode, setLeagueCode] = useState('') 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 [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('') const [dateTo, setDateTo] = useState('')
const [season, setSeason] = useState('') const [season, setSeason] = useState('')
const [ingestStatus, setIngestStatus] = useState('') // 空 = 已完赛+未开赛 const [ingestStatus, setIngestStatus] = useState('')
const [limit, setLimit] = useState(100) const [limit, setLimit] = useState(100)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [result, setResult] = useState<{ title: string; detail: 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 loadLeagues = useCallback(async () => {
const lg = await fetchLeagues() const lg = await fetchLeagues()
setLeagues(lg) setLeagues(lg)
@@ -42,6 +64,24 @@ export default function CollectionPage() {
useEffect(() => { loadLeagues() }, [loadLeagues]) 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' const isEventsTask = task === 'events' || task === 'all'
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
@@ -49,6 +89,8 @@ export default function CollectionPage() {
setError(null) setError(null)
setResult(null) setResult(null)
setLoading(true) setLoading(true)
setTaskStatus('running')
setTaskStartedAt(Date.now())
try { try {
const body: CollectionRequest = { const body: CollectionRequest = {
@@ -58,7 +100,6 @@ export default function CollectionPage() {
limit, limit,
season: season || undefined, season: season || undefined,
status: ingestStatus || undefined, status: ingestStatus || undefined,
// 仅 events/all 任务生效
date_from: isEventsTask ? dateFrom || undefined : undefined, date_from: isEventsTask ? dateFrom || undefined : undefined,
date_to: isEventsTask ? dateTo || undefined : undefined, date_to: isEventsTask ? dateTo || undefined : undefined,
} }
@@ -67,13 +108,24 @@ export default function CollectionPage() {
title: '采集任务已启动', title: '采集任务已启动',
detail: '正在后台执行(上游限速时可能需要数分钟)。完成结果与错误请到「系统日志」页查看(支持自动刷新)。', detail: '正在后台执行(上游限速时可能需要数分钟)。完成结果与错误请到「系统日志」页查看(支持自动刷新)。',
}) })
// 启动轮询,跟踪状态
startPolling()
// 30 秒后自动停止轮询并标记完成
setTimeout(() => {
setTaskStatus('done')
stopPolling()
}, 30_000)
} catch (err: unknown) { } catch (err: unknown) {
setTaskStatus('error')
setError(err instanceof Error ? err.message : '采集触发失败') setError(err instanceof Error ? err.message : '采集触发失败')
stopPolling()
} finally { } finally {
setLoading(false) setLoading(false)
} }
} }
const elapsed = taskStartedAt ? Math.round((Date.now() - taskStartedAt) / 1000) : 0
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<SectionHeader <SectionHeader
@@ -213,6 +265,50 @@ export default function CollectionPage() {
</CardBody> </CardBody>
</Card> </Card>
{/* 任务状态 + 数据源说明 */}
<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>
)}
{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>
{/* 数据源说明 */} {/* 数据源说明 */}
<Card> <Card>
<CardHeader title="采集任务说明" /> <CardHeader title="采集任务说明" />
@@ -238,5 +334,6 @@ export default function CollectionPage() {
</Card> </Card>
</div> </div>
</div> </div>
</div>
) )
} }
+30 -3
View File
@@ -31,6 +31,25 @@ function pctColor(pct: number): string {
return 'bg-rose-500' return 'bg-rose-500'
} }
/** 根据问题描述生成可操作的修复链接 */
function getIssueAction(issue: string): { href: string; label: string } | null {
// 提取联赛代码(大写字母+数字,如 E0, SP1)
const codeMatch = issue.match(/\b([A-Z]{1,2}\d?)\b/)
const code = codeMatch ? codeMatch[1] : ''
if (!code) return null
if (issue.includes('无已完赛比赛')) {
return { href: `/admin/collection?task=events&league=${code}`, label: '去采集' }
}
if (issue.includes('无统计回填') || issue.includes('统计覆盖率')) {
return { href: `/admin/collection?task=stats&league=${code}`, label: '去回填' }
}
if (issue.includes('无积分榜')) {
return { href: `/admin/collection?task=standings&league=${code}`, label: '去采集' }
}
return null
}
export default function DataCompletenessPage() { export default function DataCompletenessPage() {
const [data, setData] = useState<DataCompletenessResponse | null>(null) const [data, setData] = useState<DataCompletenessResponse | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@@ -95,19 +114,27 @@ export default function DataCompletenessPage() {
</Card> </Card>
</div> </div>
{/* 健康问题 */} {/* 健康问题(可操作) */}
<Card> <Card>
<CardHeader title="健康摘要" /> <CardHeader title="健康摘要" />
<CardBody> <CardBody>
<div className="space-y-2"> <div className="space-y-2">
{data.issues.map((issue, i) => ( {data.issues.map((issue, i) => {
const action = getIssueAction(issue)
return (
<Alert <Alert
key={i} key={i}
kind={issue.includes('良好') ? 'ok' : issue.includes('建议') || issue.includes('仅') ? 'warning' : 'error'} kind={issue.includes('良好') ? 'ok' : issue.includes('建议') || issue.includes('仅') ? 'warning' : 'error'}
title={issue.includes('良好') ? '数据良好' : '需要关注'} title={issue.includes('良好') ? '数据良好' : '需要关注'}
message={issue} message={issue}
action={action ? (
<a href={action.href} className="btn btn-sm whitespace-nowrap">
{action.label}
</a>
) : undefined}
/> />
))} )
})}
</div> </div>
</CardBody> </CardBody>
</Card> </Card>
+1
View File
@@ -179,6 +179,7 @@ export default function DataSourcesPage() {
} }
async function handleResetCooldown() { async function handleResetCooldown() {
if (!window.confirm('确定重置所有 key 的冷却状态?这可能使被限流的 key 立即恢复请求。')) return
try { try {
const res = await resetKeyRingCooldown() const res = await resetKeyRingCooldown()
setKeyRing(res.stats) setKeyRing(res.stats)
+3 -1
View File
@@ -155,7 +155,7 @@ export default function EvalPage() {
<Card> <Card>
<CardHeader <CardHeader
title="准确率对比" title="准确率对比"
description="按 provider × 模型 × prompt_version 聚合,仅统计有效预测" description={!loading && summary.length > 0 ? `${summary.length}` : "按 provider × 模型 × prompt_version 聚合,仅统计有效预测"}
/> />
<CardBody> <CardBody>
{loading ? ( {loading ? (
@@ -165,6 +165,7 @@ export default function EvalPage() {
) : summary.length === 0 ? ( ) : summary.length === 0 ? (
<EmptyState text="暂无评估数据,请调整筛选条件或先完成预测与结算" /> <EmptyState text="暂无评估数据,请调整筛选条件或先完成预测与结算" />
) : ( ) : (
<div className="overflow-x-auto">
<DataTable <DataTable
columns={[ columns={[
{ key: 'provider', label: '提供商' }, { key: 'provider', label: '提供商' },
@@ -202,6 +203,7 @@ export default function EvalPage() {
rowKey={(row: any) => `${row.provider}-${row.model}-${row.prompt_version ?? ''}`} rowKey={(row: any) => `${row.provider}-${row.model}-${row.prompt_version ?? ''}`}
emptyText="暂无评估数据" emptyText="暂无评估数据"
/> />
</div>
)} )}
</CardBody> </CardBody>
</Card> </Card>
+19 -1
View File
@@ -52,6 +52,24 @@ export default function LogsPage() {
load() load()
}, [load]) }, [load])
const scrollRef = useRef<HTMLDivElement>(null)
const userScrolledUp = useRef(false)
// 检测用户是否向上滚动过
const handleScroll = () => {
const el = scrollRef.current
if (!el) return
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 50
userScrolledUp.current = !atBottom
}
// 加载后自动滚动到底部(仅当用户未向上滚动时)
useEffect(() => {
if (autoRefresh && !userScrolledUp.current && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
}, [entries, autoRefresh])
// 自动刷新 // 自动刷新
useEffect(() => { useEffect(() => {
if (timerRef.current) clearInterval(timerRef.current) if (timerRef.current) clearInterval(timerRef.current)
@@ -123,7 +141,7 @@ export default function LogsPage() {
) : entries.length === 0 ? ( ) : entries.length === 0 ? (
<p className="py-10 text-center text-xs text-ink-400"></p> <p className="py-10 text-center text-xs text-ink-400"></p>
) : ( ) : (
<div> <div ref={scrollRef} onScroll={handleScroll} className="max-h-[60vh] overflow-y-auto">
{entries.map((e, i) => { {entries.map((e, i) => {
const badge = LEVEL_BADGE[e.level] ?? { status: 'info' as const, text: e.level } const badge = LEVEL_BADGE[e.level] ?? { status: 'info' as const, text: e.level }
return ( return (
+31 -3
View File
@@ -41,6 +41,7 @@ export default function PredictionsPage() {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [successMsg, setSuccessMsg] = useState<string | null>(null) const [successMsg, setSuccessMsg] = useState<string | null>(null)
const [leagueFilter, setLeagueFilter] = useState('') // 联赛筛选
// 结算表单 // 结算表单
const [settleId, setSettleId] = useState('') const [settleId, setSettleId] = useState('')
@@ -56,7 +57,8 @@ export default function PredictionsPage() {
useEffect(() => { useEffect(() => {
refreshPredictions() refreshPredictions()
fetchMatches({ limit: 100 }).then(d => setMatches(d.items)) // 只加载未开赛的比赛(用于预测)
fetchMatches({ status: 'scheduled', limit: 200 }).then(d => setMatches(d.items))
}, [refreshPredictions]) }, [refreshPredictions])
/** match_id → 中文名对阵 */ /** match_id → 中文名对阵 */
@@ -72,6 +74,15 @@ export default function PredictionsPage() {
const nameOf = (id: number) => matchName.get(id) ?? `比赛 #${id}` const nameOf = (id: number) => matchName.get(id) ?? `比赛 #${id}`
// 联赛列表(从比赛中提取) + 筛选后的比赛
const leagues = useMemo(() => {
const set = new Set(matches.map(m => m.league_code).filter(Boolean))
return [...set].sort()
}, [matches])
const filteredMatches = leagueFilter
? matches.filter(m => m.league_code === leagueFilter)
: matches
async function handlePredict(e: React.FormEvent) { async function handlePredict(e: React.FormEvent) {
e.preventDefault() e.preventDefault()
if (!matchId) return if (!matchId) return
@@ -131,15 +142,31 @@ export default function PredictionsPage() {
<CardHeader title="新建预测" /> <CardHeader title="新建预测" />
<CardBody> <CardBody>
<form onSubmit={handlePredict} className="space-y-4"> <form onSubmit={handlePredict} className="space-y-4">
<div className="space-y-3">
{leagues.length > 1 && (
<div> <div>
<label className="mb-1.5 block text-xs text-ink-500"></label> <label className="mb-1.5 block text-xs text-ink-500"></label>
<select
value={leagueFilter}
onChange={e => { setLeagueFilter(e.target.value); setMatchId('') }}
className="field w-full"
>
<option value=""></option>
{leagues.map(code => (
<option key={code} value={code}>{code}</option>
))}
</select>
</div>
)}
<div>
<label className="mb-1.5 block text-xs text-ink-500"> <span className="text-ink-400">({filteredMatches.length} )</span></label>
<select <select
value={matchId} value={matchId}
onChange={e => setMatchId(e.target.value)} onChange={e => setMatchId(e.target.value)}
className="field w-full" className="field w-full"
> >
<option value=""></option> <option value=""></option>
{matches.map(m => ( {filteredMatches.map(m => (
<option key={m.id} value={m.id}> <option key={m.id} value={m.id}>
{teamSidePrefix('home')}{(m.home_team_zh || m.home_team)} vs {teamSidePrefix('away')}{(m.away_team_zh || m.away_team)} {teamSidePrefix('home')}{(m.home_team_zh || m.home_team)} vs {teamSidePrefix('away')}{(m.away_team_zh || m.away_team)}
({m.match_date ? new Date(m.match_date).toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' }) : '—'}) ({m.match_date ? new Date(m.match_date).toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' }) : '—'})
@@ -147,6 +174,7 @@ export default function PredictionsPage() {
))} ))}
</select> </select>
</div> </div>
</div>
<div> <div>
<label className="mb-1.5 block text-xs text-ink-500"></label> <label className="mb-1.5 block text-xs text-ink-500"></label>
+367
View File
@@ -0,0 +1,367 @@
/**
* Admin 后台 - 统一设置页(报刊风)
*
* 合并原「数据源」「LLM 配置」「系统配置」三页:
* 1. 数据源 — bzzoiro API Key + Key Ring 状态
* 2. LLM — 连接配置 + 使用统计 + 专家独立配置
* 3. 认证 — 修改密码
* 4. 系统 — 配置查看 + 修改指南
*/
import { useEffect, useState, useCallback } from 'react'
import {
fetchSettings, updateSetting, clearSetting,
testLLMConnection, fetchLLMUsageStats, fetchLLMModels,
fetchKeyRingStatus, resetKeyRingCooldown,
} from '../dal'
import { changePassword, fetchAuthState, UNAUTHORIZED_EVENT } from '../api'
import type { LLMUsageStats, DataSourceSetting } from '../types'
import type { KeyRingStatusResponse } from '../dal'
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner, SkeletonBlock } from '../components'
import SettingRow from '../SettingRow'
import AgentLLMCard from '../AgentLLMCard'
const DATA_SOURCE_KEYS = ['BZZOIRO_KEY', 'BZZOIRO_BASE']
const LLM_KEYS = ['LLM_API_KEY', 'LLM_BASE_URL', 'LLM_MODEL']
export default function SettingsPage() {
const [allSettings, setAllSettings] = useState<DataSourceSetting[]>([])
const [settingsLoading, setSettingsLoading] = useState(true)
const [editingKey, setEditingKey] = useState<string | null>(null)
const [busyKey, setBusyKey] = useState<string | null>(null)
const [rowNotice, setRowNotice] = useState<{ key: string; ok: boolean; text: string } | null>(null)
// LLM
const [llmStats, setLlmStats] = useState<LLMUsageStats | null>(null)
const [llmLoading, setLlmLoading] = useState(true)
const [testing, setTesting] = useState(false)
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null)
// Key Ring
const [keyRing, setKeyRing] = useState<KeyRingStatusResponse | null>(null)
const [ringLoading, setRingLoading] = useState(false)
// 密码
const [passwordOrigin, setPasswordOrigin] = useState<'db' | 'env' | 'none' | null>(null)
const [currentPwd, setCurrentPwd] = useState('')
const [newPwd, setNewPwd] = useState('')
const [confirmPwd, setConfirmPwd] = useState('')
const [pwdBusy, setPwdBusy] = useState(false)
const [pwdNotice, setPwdNotice] = useState<{ ok: boolean; text: string } | null>(null)
// ── 数据加载 ──
const loadSettings = useCallback(async () => {
setSettingsLoading(true)
try {
const all = await fetchSettings()
setAllSettings(all)
} catch {
setAllSettings([])
} finally {
setSettingsLoading(false)
}
}, [])
const loadLlmStats = useCallback(async () => {
setLlmLoading(true)
try {
setLlmStats(await fetchLLMUsageStats())
} catch {
setLlmStats(null)
} finally {
setLlmLoading(false)
}
}, [])
const loadKeyRing = useCallback(async () => {
setRingLoading(true)
try {
setKeyRing(await fetchKeyRingStatus())
} catch {
setKeyRing(null)
} finally {
setRingLoading(false)
}
}, [])
useEffect(() => {
loadSettings()
loadLlmStats()
loadKeyRing()
fetchAuthState().then(s => setPasswordOrigin(s.password_origin ?? null)).catch(() => {})
}, [loadSettings, loadLlmStats, loadKeyRing])
const dataSourceSettings = allSettings.filter(s => DATA_SOURCE_KEYS.includes(s.key))
const llmSettings = allSettings.filter(s => LLM_KEYS.includes(s.key))
// ── 操作 ──
const handleSave = async (key: string, value: string) => {
setBusyKey(key)
setRowNotice(null)
try {
await updateSetting(key, value)
setRowNotice({ key, ok: true, text: '已保存,立即生效' })
setEditingKey(null)
await loadSettings()
if (key === 'BZZOIRO_KEY') await loadKeyRing()
} catch (err) {
setRowNotice({ key, ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '保存失败' })
} finally {
setBusyKey(null)
}
}
const handleClear = async (key: string) => {
setBusyKey(key)
setRowNotice(null)
try {
await clearSetting(key)
setRowNotice({ key, ok: true, text: '已清除数据库覆盖,回落 .env 默认值' })
await loadSettings()
} catch (err) {
setRowNotice({ key, ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '清除失败' })
} finally {
setBusyKey(null)
}
}
const detectLLMModels = useCallback(async (): Promise<string[]> => {
const r = await fetchLLMModels()
if (!r.ok) throw new Error(r.detail)
return r.models
}, [])
const handleTest = async () => {
setTesting(true)
setTestResult(null)
try {
await testLLMConnection()
setTestResult({ success: true, message: 'LLM 连接测试成功' })
} catch (err: unknown) {
setTestResult({ success: false, message: err instanceof Error ? err.message : 'LLM 连接测试失败' })
} finally {
setTesting(false)
}
}
const handleResetCooldown = async () => {
if (!window.confirm('确定重置所有 key 的冷却状态?这可能使被限流的 key 立即恢复请求。')) return
try {
const res = await resetKeyRingCooldown()
setKeyRing(res.stats)
setRowNotice({ key: "__ring", ok: true, text: res.message })
} catch (err) {
setRowNotice({ key: "__ring", ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '重置失败' })
}
}
const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault()
setPwdNotice(null)
if (newPwd !== confirmPwd) {
setPwdNotice({ ok: false, text: '两次输入的新密码不一致' })
return
}
setPwdBusy(true)
try {
const res = await changePassword(currentPwd, newPwd)
setPwdNotice({ ok: true, text: res.message })
setTimeout(() => window.dispatchEvent(new CustomEvent(UNAUTHORIZED_EVENT)), 1500)
} catch (err) {
setPwdNotice({ ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '修改失败' })
} finally {
setPwdBusy(false)
}
}
return (
<div className="space-y-8">
<SectionHeader
title="系统设置"
description="数据源、LLM、认证等全部配置。保存到数据库并立即生效,优先于 .env。"
/>
{/* ── 1. 数据源 ── */}
<section>
<h2 className="section-head mb-3"></h2>
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader
title="Bzzoiro API"
description="赛程 / 比分 / 积分榜 / 比赛统计的唯一数据源"
action={<button onClick={loadSettings} disabled={settingsLoading} className="btn btn-sm">{settingsLoading ? <><Spinner /> </> : '刷新'}</button>}
/>
<CardBody>
{settingsLoading ? (
<div className="space-3">{dataSourceSettings.map((_, i) => <SkeletonBlock key={i} className="h-9 w-full" />)}</div>
) : (
dataSourceSettings.map(setting => (
<SettingRow
key={setting.key}
setting={setting}
editing={editingKey === setting.key}
busy={busyKey === setting.key}
onEdit={() => { setEditingKey(setting.key); setRowNotice(null) }}
onCancel={() => setEditingKey(null)}
onSave={v => handleSave(setting.key, v)}
onClear={() => handleClear(setting.key)}
/>
))
)}
{rowNotice && !rowNotice.key.startsWith('__') && dataSourceSettings.some(s => s.key === rowNotice.key) && (
<div className="mt-3"><Alert kind={rowNotice.ok ? 'ok' : 'error'} title={rowNotice.text} /></div>
)}
</CardBody>
</Card>
{/* Key Ring */}
<Card>
<CardHeader
title="API Key 轮换环"
description={keyRing?.has_multiple ? `已配置 ${keyRing.total} 个 key,遇限流自动切换` : '当前仅 1 个 key,无法轮换'}
action={<button onClick={handleResetCooldown} disabled={ringLoading} className="btn-sm btn-outline"></button>}
/>
<CardBody>
{keyRing && keyRing.total > 0 ? (
<div className="space-y-2">
{keyRing.keys.map((k, i) => {
const isBlocked = k.blocked_remaining > 0
return (
<div key={i} className={`flex items-center justify-between gap-3 border-b border-ink-100 py-2 last:border-b-0 ${isBlocked ? 'opacity-70' : ''}`}>
<div className="flex items-center gap-2">
<span className={`inline-block h-2 w-2 rounded-full ${isBlocked ? 'bg-amber-500' : 'bg-emerald-500'}`} />
<span className="font-mono text-xs text-ink-700">{k.masked}</span>
{i === keyRing.active_index && <span className="rounded bg-ink-900 px-1.5 py-0.5 text-2xs text-paper-500"></span>}
</div>
<span className={`text-2xs tabular-nums ${isBlocked ? 'text-amber-600' : 'text-ink-400'}`}>
{isBlocked ? `冷却中 ${k.blocked_remaining.toFixed(0)}s` : '可用'}
</span>
</div>
)
})}
</div>
) : (
<p className="text-xs text-ink-400"> key </p>
)}
</CardBody>
</Card>
</div>
</section>
{/* ── 2. LLM ── */}
<section>
<h2 className="section-head mb-3"></h2>
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader
title="连接配置"
description="OpenAI 兼容接口(DeepSeek / 智谱 / 通义等)"
action={<button onClick={loadSettings} disabled={settingsLoading} className="btn btn-sm">{settingsLoading ? <><Spinner /> </> : '刷新'}</button>}
/>
<CardBody>
{settingsLoading ? (
<div className="space-y-3">{llmSettings.map((_, i) => <SkeletonBlock key={i} className="h-9 w-full" />)}</div>
) : (
llmSettings.map(setting => (
<SettingRow
key={setting.key}
setting={setting}
editing={editingKey === setting.key}
busy={busyKey === setting.key}
onEdit={() => { setEditingKey(setting.key); setRowNotice(null) }}
onCancel={() => setEditingKey(null)}
onSave={v => handleSave(setting.key, v)}
onClear={() => handleClear(setting.key)}
detectModels={setting.key === 'LLM_MODEL' ? detectLLMModels : undefined}
/>
))
)}
{rowNotice && !rowNotice.key.startsWith('__') && llmSettings.some(s => s.key === rowNotice.key) && (
<div className="mt-3"><Alert kind={rowNotice.ok ? 'ok' : 'error'} title={rowNotice.text} /></div>
)}
{testResult && (
<div className="mt-4">
<Alert kind={testResult.success ? 'ok' : 'error'} title={testResult.success ? '连接正常' : '连接失败'} message={testResult.success ? undefined : testResult.message} />
</div>
)}
<button onClick={handleTest} disabled={testing} className="btn btn-sm mt-4 w-full">
{testing ? <><Spinner /> </> : '测试 LLM 连接'}
</button>
<p className="mt-2 text-center text-2xs text-ink-400"> LLM ,</p>
</CardBody>
</Card>
<Card>
<CardHeader title="使用统计" description="从最近预测记录聚合" action={<button onClick={loadLlmStats} disabled={llmLoading} className="btn btn-sm">{llmLoading ? <><Spinner /> </> : '刷新'}</button>} />
<CardBody>
{llmLoading ? (
<div className="space-y-3"><SkeletonBlock className="h-16 w-full" /><SkeletonBlock className="h-16 w-full" /></div>
) : llmStats ? (
<div className="grid grid-cols-3 gap-4">
<div className="border-t-2 border-ink-900 pt-3 text-center">
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">{llmStats.total_predictions}</div>
<div className="mt-1 text-2xs text-ink-400"></div>
</div>
<div className="border-t-2 border-ink-900 pt-3 text-center">
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">{llmStats.avg_latency_ms > 0 ? `${(llmStats.avg_latency_ms / 1000).toFixed(1)}s` : '—'}</div>
<div className="mt-1 text-2xs text-ink-400"></div>
</div>
<div className="border-t-2 border-press pt-3 text-center">
<div className="font-serif text-2xl font-bold tabular-nums text-press">{llmStats.success_rate.toFixed(0)}%</div>
<div className="mt-1 text-2xs text-ink-400"></div>
</div>
</div>
) : (
<p className="py-6 text-center text-xs text-ink-400">使</p>
)}
</CardBody>
</Card>
</div>
<div className="mt-6">
<AgentLLMCard />
</div>
</section>
{/* ── 3. 认证 ── */}
<section>
<h2 className="section-head mb-3"></h2>
<Card>
<CardHeader title="修改密码" description="密码即会话签名密钥,修改后所有已登录会话失效,需重新登录" />
<CardBody>
{passwordOrigin && (
<p className="mb-3 flex items-center gap-2 text-2xs text-ink-500">
:
{passwordOrigin === 'db' ? <Badge status="success">(scrypt )</Badge>
: passwordOrigin === 'env' ? <Badge status="info">.env </Badge>
: <Badge status="error"></Badge>}
</p>
)}
<form onSubmit={handleChangePassword} className="space-y-3">
<div className="grid gap-3 sm:grid-cols-3">
<div>
<label className="mb-1 block text-2xs text-ink-500"></label>
<input type="password" value={currentPwd} onChange={e => setCurrentPwd(e.target.value)} autoComplete="current-password" className="field w-full" />
</div>
<div>
<label className="mb-1 block text-2xs text-ink-500">( 8 )</label>
<input type="password" value={newPwd} onChange={e => setNewPwd(e.target.value)} autoComplete="new-password" className="field w-full" />
</div>
<div>
<label className="mb-1 block text-2xs text-ink-500"></label>
<input type="password" value={confirmPwd} onChange={e => setConfirmPwd(e.target.value)} autoComplete="new-password" className="field w-full" />
</div>
</div>
{pwdNotice && <Alert kind={pwdNotice.ok ? 'ok' : 'error'} title={pwdNotice.text} />}
<div className="flex items-center justify-between gap-2">
<p className="text-2xs text-ink-400">退</p>
<button type="submit" disabled={pwdBusy || !currentPwd || !newPwd || !confirmPwd} className="btn btn-solid btn-sm flex-shrink-0">
{pwdBusy ? <><Spinner /> </> : '修改密码'}
</button>
</div>
</form>
</CardBody>
</Card>
</section>
</div>
)
}
+2 -6
View File
@@ -13,9 +13,7 @@ import DataCompletenessPage from './pages/DataCompleteness'
import PredictionsPage from './pages/Predictions' import PredictionsPage from './pages/Predictions'
import BacktestPage from './pages/Backtest' import BacktestPage from './pages/Backtest'
import MonitoringPage from './pages/Monitoring' import MonitoringPage from './pages/Monitoring'
import DataSourcesPage from './pages/DataSources' import SettingsPage from './pages/Settings'
import LLMConfigPage from './pages/LLMConfig'
import ConfigPage from './pages/Config'
import LogsPage from './pages/Logs' import LogsPage from './pages/Logs'
import EvalPage from './pages/EvalPage' import EvalPage from './pages/EvalPage'
@@ -30,9 +28,7 @@ export const adminRoutes = [
{ path: 'predictions', element: <PredictionsPage /> }, { path: 'predictions', element: <PredictionsPage /> },
{ path: 'backtest', element: <BacktestPage /> }, { path: 'backtest', element: <BacktestPage /> },
{ path: 'monitoring', element: <MonitoringPage /> }, { path: 'monitoring', element: <MonitoringPage /> },
{ path: 'data-sources', element: <DataSourcesPage /> }, { path: 'settings', element: <SettingsPage /> },
{ path: 'llm-config', element: <LLMConfigPage /> },
{ path: 'config', element: <ConfigPage /> },
{ path: 'logs', element: <LogsPage /> }, { path: 'logs', element: <LogsPage /> },
{ path: 'eval', element: <EvalPage /> }, { path: 'eval', element: <EvalPage /> },
{ path: '*', element: <Navigate to="/admin" replace /> }, { path: '*', element: <Navigate to="/admin" replace /> },
+155
View File
@@ -0,0 +1,155 @@
/**
* Admin 后台 - 键盘快捷键: Cmd+K 命令面板
*
* 提供全局快捷键:
* Cmd/Ctrl+K — 打开命令面板(页面跳转)
* / — 聚焦搜索(日志页)
* r — 刷新当前页面数据(通用)
*/
import { useEffect, useState, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
interface CommandItem {
id: string
label: string
group: string
action: () => void
}
export function useCommandPalette(pages: Array<{ to: string; label: string; group: string }>) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const navigate = useNavigate()
const items: CommandItem[] = pages.map(p => ({
id: p.to,
label: p.label,
group: p.group,
action: () => { navigate(p.to); setOpen(false) },
}))
const filtered = query
? items.filter(i => i.label.toLowerCase().includes(query.toLowerCase()) || i.id.includes(query.toLowerCase()))
: items
useEffect(() => {
const handler = (e: KeyboardEvent) => {
// Cmd/Ctrl+K → 命令面板
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault()
setOpen(o => !o)
setQuery('')
}
// Escape → 关闭
if (e.key === 'Escape' && open) {
setOpen(false)
setQuery('')
}
}
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [open])
return { open, setOpen, query, setQuery, items: filtered }
}
/** 命令面板弹窗 */
export function CommandPalette({
open,
query,
setQuery,
items,
onClose,
}: {
open: boolean
query: string
setQuery: (q: string) => void
items: CommandItem[]
onClose: () => void
}) {
const [selected, setSelected] = useState(0)
// 重置选中项当列表变化
useEffect(() => { setSelected(0) }, [items.length])
useEffect(() => {
if (!open) return
const handler = (e: KeyboardEvent) => {
if (e.key === 'ArrowDown') { e.preventDefault(); setSelected(s => Math.min(s + 1, items.length - 1)) }
if (e.key === 'ArrowUp') { e.preventDefault(); setSelected(s => Math.max(s - 1, 0)) }
if (e.key === 'Enter' && items[selected]) { items[selected].action(); onClose() }
}
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [open, items, selected, onClose])
if (!open) return null
// 按分组聚合
const grouped: Record<string, CommandItem[]> = {}
for (const item of items) {
grouped[item.group] = grouped[item.group] || []
grouped[item.group].push(item)
}
return (
<div
className="fixed inset-0 z-[60] flex items-start justify-center bg-ink-900/50 p-4 pt-[15vh]"
onClick={onClose}
role="dialog"
aria-modal="true"
aria-label="命令面板"
>
<div
className="w-full max-w-lg border border-ink-900 bg-paper-50 shadow-2xl"
onClick={e => e.stopPropagation()}
>
{/* 搜索框 */}
<div className="flex items-center gap-2 border-b border-ink-200 px-3 py-2.5">
<span className="text-ink-400"></span>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="输入页面名或路径…"
autoFocus
className="flex-1 bg-transparent text-sm outline-none placeholder:text-ink-400"
/>
<kbd className="border border-ink-200 px-1.5 py-0.5 text-2xs text-ink-400">ESC</kbd>
</div>
{/* 结果列表 */}
<div className="max-h-[50vh] overflow-y-auto py-2">
{items.length === 0 ? (
<p className="py-6 text-center text-xs text-ink-400"></p>
) : (
Object.entries(grouped).map(([group, groupItems]) => (
<div key={group}>
<p className="px-3 py-1 text-2xs font-medium uppercase tracking-widest text-ink-400">{group}</p>
{groupItems.map((item, i) => (
<button
key={item.id}
onClick={() => { item.action(); onClose() }}
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors ${
selected === items.indexOf(item) ? 'bg-paper-100 text-press' : 'text-ink-700 hover:bg-paper-100'
}`}
>
<span className="truncate">{item.label}</span>
<span className="ml-auto text-2xs text-ink-400">{item.id}</span>
</button>
))}
</div>
))
)}
</div>
{/* 底部提示 */}
<div className="flex items-center gap-3 border-t border-ink-200 px-3 py-1.5 text-2xs text-ink-400">
<span> </span>
<span> </span>
<span>ESC </span>
</div>
</div>
</div>
)
}
+3 -17
View File
@@ -619,33 +619,19 @@ export default function Matches() {
</span> </span>
</span> </span>
{/* 预测按钮:小屏独占一行 */} {/* 预测按钮(统一,响应式尺寸) */}
{!finished && ( {!finished && (
<div className="flex justify-end sm:hidden" onClick={e => e.stopPropagation()}> <div className="flex justify-end" onClick={e => e.stopPropagation()}>
<button <button
onClick={() => predict(m)} onClick={() => predict(m)}
disabled={busy} disabled={busy}
className="btn min-h-[44px] w-full px-4" className={`btn ${busy ? '' : 'btn-solid'} w-full min-h-[44px] sm:w-[84px] sm:min-h-0 sm:btn-sm`}
title="以多专家模式预测这场" title="以多专家模式预测这场"
> >
{busy ? (<><Spinner /> </>) : '预测'} {busy ? (<><Spinner /> </>) : '预测'}
</button> </button>
</div> </div>
)} )}
{/* 预测按钮:桌面 */}
<div className="hidden justify-end sm:flex" onClick={e => e.stopPropagation()}>
{!finished && (
<button
onClick={() => predict(m)}
disabled={busy}
className={`btn btn-sm w-[84px] ${busy ? '' : 'btn-solid'}`}
title="以多专家模式预测这场"
>
{busy ? (<><Spinner /> </>) : '预测'}
</button>
)}
</div>
</div> </div>
</div>{/* 关闭可点击行 */} </div>{/* 关闭可点击行 */}
+27 -3
View File
@@ -8,6 +8,7 @@
import { useEffect, useState, useCallback } from 'react' import { useEffect, useState, useCallback } from 'react'
import { fetchStandings } from '../admin/dal' import { fetchStandings } from '../admin/dal'
import type { StandingsLeague, StandingRow } from '../admin/dal' import type { StandingsLeague, StandingRow } from '../admin/dal'
import { Spinner } from '../admin/components'
const LEAGUES = [ const LEAGUES = [
{ code: 'E0', name: '英超' }, { code: 'E0', name: '英超' },
@@ -48,6 +49,7 @@ export default function StandingsPage() {
const [leagues, setLeagues] = useState<StandingsLeague[]>([]) const [leagues, setLeagues] = useState<StandingsLeague[]>([])
const [activeLeague, setActiveLeague] = useState<string>('') const [activeLeague, setActiveLeague] = useState<string>('')
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [switching, setSwitching] = useState(false) // 切换联赛中
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const load = useCallback(async (code?: string) => { const load = useCallback(async (code?: string) => {
@@ -68,6 +70,20 @@ export default function StandingsPage() {
useEffect(() => { load() }, []) // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { load() }, []) // eslint-disable-line react-hooks/exhaustive-deps
// 切换联赛(带加载态,禁用 tab 防重复点击)
const switchLeague = async (code: string) => {
if (code === activeLeague || switching) return
setSwitching(true)
setActiveLeague(code)
try {
await fetchStandings(code).then(data => setLeagues(data.leagues))
} catch (err) {
setError(err instanceof Error ? err.message : '加载失败')
} finally {
setSwitching(false)
}
}
const active = leagues.find(l => l.league_code === activeLeague) ?? leagues[0] const active = leagues.find(l => l.league_code === activeLeague) ?? leagues[0]
return ( return (
@@ -77,8 +93,9 @@ export default function StandingsPage() {
{LEAGUES.map(l => ( {LEAGUES.map(l => (
<button <button
key={l.code} key={l.code}
onClick={() => { setActiveLeague(l.code); load(l.code) }} onClick={() => switchLeague(l.code)}
className={`rounded border px-3 py-1.5 text-xs transition-colors ${ disabled={switching}
className={`rounded border px-3 py-1.5 text-xs transition-colors disabled:opacity-50 ${
activeLeague === l.code activeLeague === l.code
? 'border-ink-900 bg-ink-900 text-paper-50' ? 'border-ink-900 bg-ink-900 text-paper-50'
: 'border-ink-200 text-ink-500 hover:border-ink-300' : 'border-ink-200 text-ink-500 hover:border-ink-300'
@@ -99,6 +116,13 @@ export default function StandingsPage() {
<div className="flex justify-center py-12 text-xs text-ink-400"></div> <div className="flex justify-center py-12 text-xs text-ink-400"></div>
)} )}
{/* 切换联赛时的轻量加载指示 */}
{switching && !loading && (
<div className="flex items-center gap-2 border-b border-ink-200 px-1 py-2 text-xs text-ink-400">
<Spinner />
</div>
)}
{!loading && !active && ( {!loading && !active && (
<div className="border-y border-ink-200 py-12 text-center"> <div className="border-y border-ink-200 py-12 text-center">
<p className="font-serif text-sm text-ink-600"></p> <p className="font-serif text-sm text-ink-600"></p>
@@ -124,7 +148,7 @@ export default function StandingsPage() {
{/* 积分榜表格 */} {/* 积分榜表格 */}
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-xs"> <table className="w-full min-w-[640px] text-xs">
<thead> <thead>
<tr className="border-b border-ink-200 text-left text-ink-400"> <tr className="border-b border-ink-200 text-left text-ink-400">
<th className="w-8 py-2 font-medium">#</th> <th className="w-8 py-2 font-medium">#</th>