VetNet-LCPS / vetcopilot-app /src /components /KeyboardShortcutPanel.tsx
thomasm291678
VetCopilot-LCPS: LCPS 2.0 本地推理引擎替换 DeepSeek API
793e953
Raw History Blame Contribute Delete
7.55 kB
/**
* KeyboardShortcutPanel 组件
*
* 按 Ctrl+/ 或 ? 显示快捷键面板,模态框列出所有可用快捷键。
* 支持搜索过滤和分组显示(导航/编辑/视图/其他)。
*
* 在 useKeyboardShortcuts 中注册 Ctrl+/ 和 ? 快捷键来触发此面板。
*/
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { useTranslation } from '../i18n'
import { useFocusTrap } from '../hooks/useKeyboardShortcuts'
interface ShortcutEntry {
keys: string[]
label: string
descriptionKey: string
group: 'navigation' | 'editing' | 'view' | 'other'
}
const SHORTCUTS: ShortcutEntry[] = [
{ keys: ['Ctrl', 'N'], label: 'Ctrl + N', descriptionKey: 'shortcut_newConsultation', group: 'navigation' },
{ keys: ['Ctrl', 'H'], label: 'Ctrl + H', descriptionKey: 'shortcut_history', group: 'navigation' },
{ keys: ['Ctrl', 'D'], label: 'Ctrl + D', descriptionKey: 'shortcut_goDashboard', group: 'navigation' },
{ keys: ['Ctrl', 'P'], label: 'Ctrl + P', descriptionKey: 'shortcut_patients', group: 'navigation' },
{ keys: ['Ctrl', 'S'], label: 'Ctrl + S', descriptionKey: 'shortcut_save', group: 'editing' },
{ keys: ['Ctrl', 'K'], label: 'Ctrl + K', descriptionKey: 'shortcut_search', group: 'navigation' },
{ keys: ['Escape'], label: 'Esc', descriptionKey: 'shortcut_close', group: 'view' },
{ keys: ['Ctrl', '/'], label: 'Ctrl + /', descriptionKey: 'shortcut_shortcutPanel', group: 'view' },
{ keys: ['?'], label: '?', descriptionKey: 'shortcut_shortcutPanel', group: 'view' },
]
const GROUP_ORDER: ShortcutEntry['group'][] = ['navigation', 'editing', 'view', 'other']
interface KeyboardShortcutPanelProps {
isOpen: boolean
onClose: () => void
}
export default function KeyboardShortcutPanel({ isOpen, onClose }: KeyboardShortcutPanelProps) {
const { t } = useTranslation()
const [search, setSearch] = useState('')
const panelRef = useRef<HTMLDivElement>(null)
const searchInputRef = useRef<HTMLInputElement>(null)
// 聚焦陷阱
useFocusTrap(panelRef, isOpen)
// 打开时聚焦搜索框
useEffect(() => {
if (isOpen) {
setSearch('')
// 延迟聚焦以确保 DOM 已渲染
setTimeout(() => searchInputRef.current?.focus(), 50)
}
}, [isOpen])
// Escape 关闭
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
onClose()
}
},
[onClose]
)
// 过滤快捷键
const filteredShortcuts = useMemo(() => {
const query = search.toLowerCase().trim()
if (!query) return SHORTCUTS
return SHORTCUTS.filter(
(s) =>
s.label.toLowerCase().includes(query) ||
t(`ergonomics.${s.descriptionKey}`).toLowerCase().includes(query) ||
s.group.toLowerCase().includes(query)
)
}, [search, t])
// 分组
const grouped = useMemo(() => {
const groups: Record<string, ShortcutEntry[]> = {}
for (const g of GROUP_ORDER) {
const items = filteredShortcuts.filter((s) => s.group === g)
if (items.length > 0) {
groups[g] = items
}
}
return groups
}, [filteredShortcuts])
if (!isOpen) return null
const tGroupLabel = (group: string): string => {
const key = `ergonomics.shortcutGroup_${group}`
const translated = t(key)
return translated !== key ? translated : group
}
return (
<div
className="fixed inset-0 z-[200] flex items-start justify-center pt-[10vh] p-4"
onClick={(e) => {
if (e.target === e.currentTarget) onClose()
}}
>
{/* 半透明遮罩 */}
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" />
{/* 面板 */}
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-label={t('ergonomics.shortcutPanel')}
className="relative bg-white rounded-2xl shadow-2xl w-full max-w-lg max-h-[70vh] overflow-hidden flex flex-col"
onKeyDown={handleKeyDown}
>
{/* 标题栏 */}
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100">
<h3 className="text-base font-semibold text-gray-800">
{t('ergonomics.shortcutPanel')}
</h3>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors min-h-[32px] min-w-[32px] flex items-center justify-center rounded-lg hover:bg-gray-100"
aria-label={t('common.close')}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* 搜索框 */}
<div className="px-5 py-3 border-b border-gray-100">
<div className="relative">
<svg
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
ref={searchInputRef}
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('ergonomics.searchShortcuts')}
className="w-full pl-9 pr-3 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:border-medical-400 focus:ring-1 focus:ring-medical-400 transition-colors"
/>
</div>
</div>
{/* 快捷键列表 */}
<div className="flex-1 overflow-y-auto px-5 py-4">
{Object.keys(grouped).length === 0 ? (
<div className="text-center py-8 text-sm text-gray-400">
{t('ergonomics.noShortcutsFound')}
</div>
) : (
Object.entries(grouped).map(([group, entries]) => (
<div key={group} className="mb-5 last:mb-0">
<h4 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">
{tGroupLabel(group)}
</h4>
<div className="space-y-1">
{entries.map((entry, idx) => (
<div
key={idx}
className="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-gray-50 transition-colors"
>
<span className="text-sm text-gray-700">
{t(`ergonomics.${entry.descriptionKey}`)}
</span>
<div className="flex items-center gap-1">
{entry.keys.map((key, ki) => (
<React.Fragment key={ki}>
{ki > 0 && (
<span className="text-xs text-gray-300">+</span>
)}
<kbd className="px-1.5 py-0.5 text-xs font-mono bg-gray-100 border border-gray-200 rounded text-gray-600 min-w-[22px] text-center">
{key}
</kbd>
</React.Fragment>
))}
</div>
</div>
))}
</div>
</div>
))
)}
</div>
</div>
</div>
)
}