feat(keybindings): 快捷键自定义系统 — store+冲突检测+交互式重新绑定UI | 13 files +1032/-137 | 72 tests passing tsc零错误

This commit is contained in:
OpsXShell Dev committed 2026-04-24 05:11:49 +08:00
1 parent 514d62644e
commit 3f636b1b0e
13 files changed
+1033 -138

No files matched your search

+202 -50
View File
@@ -1,9 +1,16 @@
import { useEffect, useState, useMemo } from 'react'
import { useEffect, useState, useMemo, useCallback } from 'react'
import { useSettingsStore } from '../stores/settingsStore'
import { useKeybindingStore } from '../stores/keybindingStore'
import {
Monitor, Palette, Terminal, Keyboard, Shield, Database, Info, Search, X,
RotateCcw, AlertTriangle,
} from 'lucide-react'
import { themes } from '../themes/terminal'
import {
getKeybindingCategories,
getKeybindingsByCategory,
type KeybindingActionId,
} from '../constants/keybindings'
// ─── Tab 类型 ───────────────────────────────────────────────
type SettingsTab = 'general' | 'appearance' | 'terminal' | 'shortcuts' | 'ssh' | 'security' | 'about'
@@ -25,31 +32,70 @@ const TAB_LIST: TabMeta[] = [
{ id: 'about', label: '关于', description: '版本与许可信息', icon: Info },
]
// ─── 快捷键数据 ─────────────────────────────────────────────
interface ShortcutEntry {
action: string
keys: string
category: string
}
// ─── 快捷键录入 Hook ─────────────────────────────────────────
// 监听键盘事件并格式化为键序列字符串 (如 "Ctrl+Shift+P")
function useKeyRecording() {
const [recordingId, setRecordingId] = useState<KeybindingActionId | null>(null)
const [recordedKeys, setRecordedKeys] = useState<string>('')
const SHORTCUTS: ShortcutEntry[] = [
{ action: '切换到下一个标签', keys: 'Ctrl+Tab', category: '标签管理' },
{ action: '切换到上一个标签', keys: 'Ctrl+Shift+Tab', category: '标签管理' },
{ action: '跳转到第 N 个标签', keys: 'Ctrl+1 … Ctrl+9', category: '标签管理' },
{ action: '关闭当前标签', keys: 'Ctrl+W', category: '标签管理' },
{ action: '新建标签', keys: 'Ctrl+Shift+N', category: '标签管理' },
{ action: '切换全屏', keys: 'F11', category: '视图' },
{ action: '新建会话', keys: 'Ctrl+N', category: '会话' },
{ action: '快速连接', keys: 'Ctrl+K', category: '会话' },
{ action: '命令面板', keys: 'Ctrl+Shift+P', category: '工具' },
]
const startRecording = useCallback((actionId: KeybindingActionId) => {
setRecordingId(actionId)
setRecordedKeys('')
}, [])
const stopRecording = useCallback(() => {
setRecordingId(null)
setRecordedKeys('')
}, [])
// 全局键盘监听
useEffect(() => {
if (!recordingId) return
const handleKeyDown = (e: KeyboardEvent) => {
e.preventDefault()
e.stopPropagation()
// Escape 取消录入
if (e.key === 'Escape') {
stopRecording()
return
}
// 忽略单独的修饰键
if (['Control', 'Shift', 'Alt', 'Meta'].includes(e.key)) return
const parts: string[] = []
if (e.ctrlKey || e.metaKey) parts.push('Ctrl')
if (e.shiftKey) parts.push('Shift')
if (e.altKey) parts.push('Alt')
// 主键
let mainKey = e.key
if (mainKey === ' ') mainKey = 'Space'
else if (mainKey === 'Tab') mainKey = 'Tab'
else if (mainKey.length === 1) mainKey = mainKey.toUpperCase()
parts.push(mainKey)
const keySequence = parts.join('+')
setRecordedKeys(keySequence)
}
window.addEventListener('keydown', handleKeyDown, true)
return () => window.removeEventListener('keydown', handleKeyDown, true)
}, [recordingId, stopRecording])
return { recordingId, recordedKeys, startRecording, stopRecording }
}
// ─── 主组件 ─────────────────────────────────────────────────
export default function SettingsView() {
const { settings, isLoaded, loadSettings, setSetting } = useSettingsStore()
const keybindingState = useKeybindingStore()
const [activeTab, setActiveTab] = useState<SettingsTab>('general')
const [searchQuery, setSearchQuery] = useState('')
const [appVersion, setAppVersion] = useState<string>('—')
const { recordingId, recordedKeys, startRecording, stopRecording } = useKeyRecording()
useEffect(() => { loadSettings() }, []) // eslint-disable-line react-hooks/exhaustive-deps
@@ -236,43 +282,149 @@ export default function SettingsView() {
)
// ──────── 快捷键 ───────
case 'shortcuts':
case 'shortcuts': {
const allKeys = keybindingState.getAllEffective()
const categories = getKeybindingCategories()
const hasCustom = Object.keys(keybindingState.customKeybindings).length > 0
// 检查录入的键是否与当前录制匹配
const recordedConflict = recordedKeys && recordingId
? keybindingState.getConflicts(recordingId, recordedKeys)
: []
return (
<div className="space-y-5">
<SettingCard>
<div className="overflow-hidden rounded-lg border border-dark-700/30">
<table className="w-full text-sm">
<thead>
<tr className="bg-dark-800/60 text-gray-400 text-left">
<th className="px-4 py-2.5 font-medium">操作</th>
<th className="px-4 py-2.5 font-medium">快捷键</th>
<th className="px-4 py-2.5 font-medium">分类</th>
</tr>
</thead>
<tbody>
{SHORTCUTS.map((s, i) => (
<tr
key={i}
className="border-t border-dark-700/20 hover:bg-dark-800/40 transition-colors"
>
<td className="px-4 py-2.5 text-gray-200">{s.action}</td>
<td className="px-4 py-2.5">
<kbd className="inline-flex items-center gap-1 px-2 py-0.5 rounded bg-dark-700 border border-dark-600 text-xs text-gray-300 font-mono">
{s.keys}
</kbd>
</td>
<td className="px-4 py-2.5 text-gray-500">{s.category}</td>
</tr>
))}
</tbody>
</table>
{/* 重置全部按钮 */}
{hasCustom && (
<div className="flex justify-end">
<button
onClick={() => keybindingState.resetAll()}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-gray-400 hover:text-accent-blue rounded-lg hover:bg-dark-700/50 transition-colors"
>
<RotateCcw className="w-3.5 h-3.5" />
重置全部为默认
</button>
</div>
<p className="text-xs text-gray-600 mt-3">
快捷键自定义功能将在后续版本中支持。
</p>
</SettingCard>
)}
{categories.map((category) => {
const defs = getKeybindingsByCategory(category)
return (
<SettingCard key={category}>
<h3 className="text-sm font-semibold text-gray-300 mb-3">{category}</h3>
<div className="space-y-1">
{defs.map((def) => {
const currentKeys = allKeys[def.id]
const isCustom = keybindingState.customKeybindings[def.id] !== undefined
const isRecordingThis = recordingId === def.id
return (
<div
key={def.id}
className={`flex items-center justify-between px-3 py-2 rounded-lg transition-colors ${
isRecordingThis
? 'bg-accent-blue/10 border border-accent-blue/30'
: 'hover:bg-dark-800/60'
}`}
>
<div className="flex-1 min-w-0">
<span className="text-sm text-gray-200">{def.label}</span>
{isCustom && (
<span className="ml-2 text-[10px] text-accent-blue bg-accent-blue/10 px-1.5 py-0.5 rounded">
自定义
</span>
)}
</div>
<div className="flex items-center gap-2">
{isRecordingThis ? (
<div className="flex items-center gap-2">
<kbd className="inline-flex items-center px-3 py-1 rounded bg-accent-blue/20 border border-accent-blue/40 text-xs text-accent-blue font-mono animate-pulse">
{recordedKeys || '按下快捷键...'}
</kbd>
<button
onClick={() => stopRecording()}
className="text-xs text-gray-500 hover:text-gray-300"
>
取消
</button>
{recordedKeys && recordedConflict.length > 0 && (
<div className="flex items-center gap-1 text-xs text-yellow-500">
<AlertTriangle className="w-3 h-3" />
<span>
与「{recordedConflict[0].label}」冲突
</span>
</div>
)}
</div>
) : (
<>
<button
onClick={() => {
startRecording(def.id)
}}
className="group relative"
>
<kbd
className={`inline-flex items-center px-2.5 py-1 rounded text-xs font-mono transition-colors ${
isCustom
? 'bg-accent-blue/10 border border-accent-blue/30 text-accent-blue'
: 'bg-dark-700 border border-dark-600 text-gray-300'
} group-hover:border-accent-blue/50`}
>
{currentKeys}
</kbd>
</button>
{isCustom && (
<button
onClick={() => keybindingState.resetKeybinding(def.id)}
className="text-gray-600 hover:text-gray-400 transition-colors"
title="重置为默认"
>
<RotateCcw className="w-3 h-3" />
</button>
)}
</>
)}
</div>
</div>
)
})}
</div>
</SettingCard>
)
})}
{/* 录入确认弹层 */}
{recordingId && recordedKeys && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 flex items-center gap-3 px-5 py-3 bg-dark-800 border border-dark-600 rounded-xl shadow-2xl">
<span className="text-sm text-gray-300">
设置为 <kbd className="px-1.5 py-0.5 rounded bg-dark-700 text-accent-blue font-mono text-xs">{recordedKeys}</kbd>
</span>
<button
onClick={async () => {
await keybindingState.setCustomKeybinding(recordingId, recordedKeys)
stopRecording()
}}
className="px-3 py-1 text-xs bg-accent-blue text-white rounded-lg hover:bg-accent-blue/80 transition-colors"
>
确认
</button>
<button
onClick={stopRecording}
className="px-3 py-1 text-xs text-gray-400 hover:text-gray-200 rounded-lg hover:bg-dark-700 transition-colors"
>
取消
</button>
</div>
)}
<p className="text-xs text-gray-600">
点击快捷键可重新绑定 · 按 Esc 取消录入
</p>
</div>
)
}
// ──────── SSH ──────────
case 'ssh':