feat(keybindings): 快捷键自定义系统 — store+冲突检测+交互式重新绑定UI | 13 files +1032/-137 | 72 tests passing tsc零错误
This commit is contained in:
1 parent
514d62644e
commit
3f636b1b0e
13 files changed
+1033
-138
No files matched your search
+202
-50
@@ -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':
|
||||
|
||||
Reference in new issue
Block a user