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
@@ -10,6 +10,32 @@ import { NavLink, Outlet, useLocation } from 'react-router-dom'
|
||||
import { fetchAuthState, logout, UNAUTHORIZED_EVENT } from './api'
|
||||
import { fetchHealth } from './dal'
|
||||
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 }[] }[] = [
|
||||
{
|
||||
@@ -32,9 +58,8 @@ const NAV_SECTIONS: { title: string; items: { to: string; label: string; icon: s
|
||||
{
|
||||
title: '系统设置',
|
||||
items: [
|
||||
{ to: '/admin/data-sources', label: '数据源', icon: '◫' },
|
||||
{ to: '/admin/llm-config', label: 'LLM 配置', icon: '◬' },
|
||||
{ to: '/admin/config', label: '系统配置', icon: '◑' },
|
||||
{ to: '/admin/settings', label: '设置', icon: '◑' },
|
||||
{ to: '/admin/logs', label: '日志', icon: '▤' },
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -54,6 +79,7 @@ export default function AdminLayout() {
|
||||
const [healthOk, setHealthOk] = useState<boolean | null>(null)
|
||||
const [authed, setAuthed] = useState<boolean | null>(null)
|
||||
const location = useLocation()
|
||||
const palette = useCommandPalette(NAV_PAGES)
|
||||
|
||||
// 登录门禁:挂载时探测会话,收到 401 事件(会话过期)自动切回登录页
|
||||
useEffect(() => {
|
||||
@@ -159,10 +185,10 @@ export default function AdminLayout() {
|
||||
to="/admin"
|
||||
end
|
||||
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
|
||||
? 'bg-press-wash/60 font-medium text-press'
|
||||
: 'text-ink-500 hover:bg-paper-100 hover:text-ink-900'
|
||||
? 'border-press bg-press-wash/60 font-medium text-press'
|
||||
: 'border-transparent text-ink-500 hover:bg-paper-100 hover:text-ink-900'
|
||||
}`
|
||||
}
|
||||
>
|
||||
@@ -181,19 +207,13 @@ export default function AdminLayout() {
|
||||
<NavLink
|
||||
to={item.to}
|
||||
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
|
||||
? 'bg-press-wash/60 font-medium text-press'
|
||||
: 'text-ink-500 hover:bg-paper-100 hover:text-ink-900'
|
||||
? 'border-press bg-press-wash/60 font-medium text-press'
|
||||
: '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">
|
||||
{item.icon}
|
||||
</span>
|
||||
@@ -207,7 +227,7 @@ export default function AdminLayout() {
|
||||
</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
|
||||
href="/"
|
||||
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>
|
||||
返回前台版面
|
||||
</a>
|
||||
<p className="text-2xs text-ink-300">Profeto v1.0</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* ── 主内容区 ── */}
|
||||
<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">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
@@ -232,6 +253,16 @@ export default function AdminLayout() {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
@@ -262,6 +293,15 @@ export default function AdminLayout() {
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* 命令面板(⌘K) */}
|
||||
<CommandPalette
|
||||
open={palette.open}
|
||||
query={palette.query}
|
||||
setQuery={palette.setQuery}
|
||||
items={palette.items}
|
||||
onClose={() => palette.setOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -277,11 +277,14 @@ export function Alert({
|
||||
title,
|
||||
message,
|
||||
onClose,
|
||||
action,
|
||||
}: {
|
||||
kind: 'error' | 'ok' | 'info' | 'warning'
|
||||
title: string
|
||||
message?: string
|
||||
onClose?: () => void
|
||||
/** 右侧操作按钮(如「去修复」) */
|
||||
action?: ReactNode
|
||||
}) {
|
||||
const style =
|
||||
kind === 'error'
|
||||
@@ -309,6 +312,8 @@ export function Alert({
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{action}
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
@@ -321,6 +326,7 @@ export function Alert({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,6 +265,50 @@ export default function CollectionPage() {
|
||||
</CardBody>
|
||||
</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>
|
||||
<CardHeader title="采集任务说明" />
|
||||
@@ -238,5 +334,6 @@ export default function CollectionPage() {
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -31,6 +31,25 @@ function pctColor(pct: number): string {
|
||||
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() {
|
||||
const [data, setData] = useState<DataCompletenessResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -95,19 +114,27 @@ export default function DataCompletenessPage() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 健康问题 */}
|
||||
{/* 健康问题(可操作) */}
|
||||
<Card>
|
||||
<CardHeader title="健康摘要" />
|
||||
<CardBody>
|
||||
<div className="space-y-2">
|
||||
{data.issues.map((issue, i) => (
|
||||
{data.issues.map((issue, i) => {
|
||||
const action = getIssueAction(issue)
|
||||
return (
|
||||
<Alert
|
||||
key={i}
|
||||
kind={issue.includes('良好') ? 'ok' : issue.includes('建议') || issue.includes('仅') ? 'warning' : 'error'}
|
||||
title={issue.includes('良好') ? '数据良好' : '需要关注'}
|
||||
message={issue}
|
||||
action={action ? (
|
||||
<a href={action.href} className="btn btn-sm whitespace-nowrap">
|
||||
{action.label}
|
||||
</a>
|
||||
) : undefined}
|
||||
/>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
@@ -179,6 +179,7 @@ export default function DataSourcesPage() {
|
||||
}
|
||||
|
||||
async function handleResetCooldown() {
|
||||
if (!window.confirm('确定重置所有 key 的冷却状态?这可能使被限流的 key 立即恢复请求。')) return
|
||||
try {
|
||||
const res = await resetKeyRingCooldown()
|
||||
setKeyRing(res.stats)
|
||||
|
||||
@@ -155,7 +155,7 @@ export default function EvalPage() {
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="准确率对比"
|
||||
description="按 provider × 模型 × prompt_version 聚合,仅统计有效预测"
|
||||
description={!loading && summary.length > 0 ? `共 ${summary.length} 组` : "按 provider × 模型 × prompt_version 聚合,仅统计有效预测"}
|
||||
/>
|
||||
<CardBody>
|
||||
{loading ? (
|
||||
@@ -165,6 +165,7 @@ export default function EvalPage() {
|
||||
) : summary.length === 0 ? (
|
||||
<EmptyState text="暂无评估数据,请调整筛选条件或先完成预测与结算" />
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'provider', label: '提供商' },
|
||||
@@ -202,6 +203,7 @@ export default function EvalPage() {
|
||||
rowKey={(row: any) => `${row.provider}-${row.model}-${row.prompt_version ?? ''}`}
|
||||
emptyText="暂无评估数据"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
@@ -52,6 +52,24 @@ export default function LogsPage() {
|
||||
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(() => {
|
||||
if (timerRef.current) clearInterval(timerRef.current)
|
||||
@@ -123,7 +141,7 @@ export default function LogsPage() {
|
||||
) : entries.length === 0 ? (
|
||||
<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) => {
|
||||
const badge = LEVEL_BADGE[e.level] ?? { status: 'info' as const, text: e.level }
|
||||
return (
|
||||
|
||||
@@ -41,6 +41,7 @@ export default function PredictionsPage() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [successMsg, setSuccessMsg] = useState<string | null>(null)
|
||||
const [leagueFilter, setLeagueFilter] = useState('') // 联赛筛选
|
||||
|
||||
// 结算表单
|
||||
const [settleId, setSettleId] = useState('')
|
||||
@@ -56,7 +57,8 @@ export default function PredictionsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
refreshPredictions()
|
||||
fetchMatches({ limit: 100 }).then(d => setMatches(d.items))
|
||||
// 只加载未开赛的比赛(用于预测)
|
||||
fetchMatches({ status: 'scheduled', limit: 200 }).then(d => setMatches(d.items))
|
||||
}, [refreshPredictions])
|
||||
|
||||
/** match_id → 中文名对阵 */
|
||||
@@ -72,6 +74,15 @@ export default function PredictionsPage() {
|
||||
|
||||
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) {
|
||||
e.preventDefault()
|
||||
if (!matchId) return
|
||||
@@ -131,15 +142,31 @@ export default function PredictionsPage() {
|
||||
<CardHeader title="新建预测" />
|
||||
<CardBody>
|
||||
<form onSubmit={handlePredict} className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
{leagues.length > 1 && (
|
||||
<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
|
||||
value={matchId}
|
||||
onChange={e => setMatchId(e.target.value)}
|
||||
className="field w-full"
|
||||
>
|
||||
<option value="">选择比赛</option>
|
||||
{matches.map(m => (
|
||||
{filteredMatches.map(m => (
|
||||
<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)}
|
||||
({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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">模式</label>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -13,9 +13,7 @@ import DataCompletenessPage from './pages/DataCompleteness'
|
||||
import PredictionsPage from './pages/Predictions'
|
||||
import BacktestPage from './pages/Backtest'
|
||||
import MonitoringPage from './pages/Monitoring'
|
||||
import DataSourcesPage from './pages/DataSources'
|
||||
import LLMConfigPage from './pages/LLMConfig'
|
||||
import ConfigPage from './pages/Config'
|
||||
import SettingsPage from './pages/Settings'
|
||||
import LogsPage from './pages/Logs'
|
||||
import EvalPage from './pages/EvalPage'
|
||||
|
||||
@@ -30,9 +28,7 @@ export const adminRoutes = [
|
||||
{ path: 'predictions', element: <PredictionsPage /> },
|
||||
{ path: 'backtest', element: <BacktestPage /> },
|
||||
{ path: 'monitoring', element: <MonitoringPage /> },
|
||||
{ path: 'data-sources', element: <DataSourcesPage /> },
|
||||
{ path: 'llm-config', element: <LLMConfigPage /> },
|
||||
{ path: 'config', element: <ConfigPage /> },
|
||||
{ path: 'settings', element: <SettingsPage /> },
|
||||
{ path: 'logs', element: <LogsPage /> },
|
||||
{ path: 'eval', element: <EvalPage /> },
|
||||
{ path: '*', element: <Navigate to="/admin" replace /> },
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -619,33 +619,19 @@ export default function Matches() {
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* 预测按钮:小屏独占一行 */}
|
||||
{/* 预测按钮(统一,响应式尺寸) */}
|
||||
{!finished && (
|
||||
<div className="flex justify-end sm:hidden" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex justify-end" onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={() => predict(m)}
|
||||
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="以多专家模式预测这场"
|
||||
>
|
||||
{busy ? (<><Spinner /> 预测中</>) : '预测'}
|
||||
</button>
|
||||
</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>{/* 关闭可点击行 */}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { fetchStandings } from '../admin/dal'
|
||||
import type { StandingsLeague, StandingRow } from '../admin/dal'
|
||||
import { Spinner } from '../admin/components'
|
||||
|
||||
const LEAGUES = [
|
||||
{ code: 'E0', name: '英超' },
|
||||
@@ -48,6 +49,7 @@ export default function StandingsPage() {
|
||||
const [leagues, setLeagues] = useState<StandingsLeague[]>([])
|
||||
const [activeLeague, setActiveLeague] = useState<string>('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [switching, setSwitching] = useState(false) // 切换联赛中
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(async (code?: string) => {
|
||||
@@ -68,6 +70,20 @@ export default function StandingsPage() {
|
||||
|
||||
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]
|
||||
|
||||
return (
|
||||
@@ -77,8 +93,9 @@ export default function StandingsPage() {
|
||||
{LEAGUES.map(l => (
|
||||
<button
|
||||
key={l.code}
|
||||
onClick={() => { setActiveLeague(l.code); load(l.code) }}
|
||||
className={`rounded border px-3 py-1.5 text-xs transition-colors ${
|
||||
onClick={() => switchLeague(l.code)}
|
||||
disabled={switching}
|
||||
className={`rounded border px-3 py-1.5 text-xs transition-colors disabled:opacity-50 ${
|
||||
activeLeague === l.code
|
||||
? 'border-ink-900 bg-ink-900 text-paper-50'
|
||||
: '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>
|
||||
)}
|
||||
|
||||
{/* 切换联赛时的轻量加载指示 */}
|
||||
{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 && (
|
||||
<div className="border-y border-ink-200 py-12 text-center">
|
||||
<p className="font-serif text-sm text-ink-600">暂无积分榜数据</p>
|
||||
@@ -124,7 +148,7 @@ export default function StandingsPage() {
|
||||
|
||||
{/* 积分榜表格 */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<table className="w-full min-w-[640px] text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-ink-200 text-left text-ink-400">
|
||||
<th className="w-8 py-2 font-medium">#</th>
|
||||
|
||||
Reference in New Issue
Block a user