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>>
156 lines
4.9 KiB
TypeScript
156 lines
4.9 KiB
TypeScript
/**
|
|
* 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>
|
|
)
|
|
}
|