Download vetcopilot-app/src/components/KeyboardShortcutPanel.tsx from WWsCa/VetNet-LCPS: direct link, hf CLI and curl.
- Browser
- Download file 7.55 kB
-
https://huggingface.co/WWsCa/VetNet-LCPS/resolve/main/vetcopilot-app/src/components/KeyboardShortcutPanel.tsx
- Command line
-
hf download hf://WWsCa/VetNet-LCPS/vetcopilot-app/src/components/KeyboardShortcutPanel.tsx
-
curl -L -o KeyboardShortcutPanel.tsx https://huggingface.co/WWsCa/VetNet-LCPS/resolve/main/vetcopilot-app/src/components/KeyboardShortcutPanel.tsx
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> | |
| ) | |
| } | |