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' interface TabMeta { id: SettingsTab label: string description: string icon: React.ComponentType<{ className?: string }> } const TAB_LIST: TabMeta[] = [ { id: 'general', label: '常规', description: '应用启动与通知', icon: Monitor }, { id: 'appearance', label: '外观', description: '主题与语言偏好', icon: Palette }, { id: 'terminal', label: '终端', description: '字体与配色方案', icon: Terminal }, { id: 'shortcuts', label: '快捷键', description: '键盘快捷键管理', icon: Keyboard }, { id: 'ssh', label: 'SSH', description: '连接与认证设置', icon: Shield }, { id: 'security', label: '安全', description: '加密与隐私设置', icon: Database }, { id: 'about', label: '关于', description: '版本与许可信息', icon: Info }, ] // ─── 快捷键录入 Hook ───────────────────────────────────────── // 监听键盘事件并格式化为键序列字符串 (如 "Ctrl+Shift+P") function useKeyRecording() { const [recordingId, setRecordingId] = useState(null) const [recordedKeys, setRecordedKeys] = useState('') 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('general') const [searchQuery, setSearchQuery] = useState('') const [appVersion, setAppVersion] = useState('—') const { recordingId, recordedKeys, startRecording, stopRecording } = useKeyRecording() useEffect(() => { loadSettings() }, []) // eslint-disable-line react-hooks/exhaustive-deps // 获取版本号(参考 StatusBar 实现) useEffect(() => { if (window.electronAPI) { window.electronAPI.getVersion().then((v: string) => setAppVersion(v)) } }, []) // 当前 tab 元信息 const currentMeta = useMemo(() => TAB_LIST.find((t) => t.id === activeTab)!, [activeTab]) // ── 搜索过滤 ────────────────────────────────────────────── const filtered = useMemo((): SettingsTab[] | null => { if (!searchQuery.trim()) return null // null 表示不过滤,正常显示 const q = searchQuery.toLowerCase() const keywords: Record = { general: ['启动', '恢复', '会话', '自动保存', '通知', 'restore', 'notification'], appearance: ['主题', '深色', '浅色', '语言', '中文', 'english', 'theme', 'language'], terminal: ['字体', 'font', '配色', 'terminal', 'monokai', 'dracula'], shortcuts: ['快捷键', '键盘', 'keyboard', 'shortcut', 'hotkey'], ssh: ['ssh', '连接', '端口', '超时', '心跳', '连接池', '重连', '快照'], security: ['加密', '安全', '密码', '锁屏', 'encrypt', 'lock'], about: ['版本', '关于', '许可', 'version', 'about', 'license'], } const matchIds = (Object.entries(keywords) as [SettingsTab, string[]][]) .filter(([, kws]) => kws.some((k: string) => k.includes(q) || q.includes(k))) .map(([id]) => id) return matchIds.length > 0 ? matchIds : [] }, [searchQuery]) // 如果搜索过滤后有结果,自动跳转到第一个匹配 tab useEffect(() => { if (filtered && filtered.length > 0 && !filtered.includes(activeTab)) { setActiveTab(filtered[0]) } }, [filtered]) // eslint-disable-line react-hooks/exhaustive-deps const visibleTabs = filtered ? TAB_LIST.filter((t) => filtered.includes(t.id)) : TAB_LIST // ── 加载态 ──────────────────────────────────────────────── if (!isLoaded) { return (
加载中...
) } // ── 渲染内容区 ──────────────────────────────────────────── const renderContent = () => { switch (activeTab) { // ──────── 常规 ───────── case 'general': return (
setSetting('restoreOnStart', v)} /> setSetting('autoSave', v)} /> setSetting('showNotifications', v)} />
) // ──────── 外观 ───────── case 'appearance': return (
主题
{[ { value: 'dark', label: '深色' }, { value: 'light', label: '浅色' }, { value: 'system', label: '跟随系统' }, ].map((theme) => ( ))}
语言
) // ──────── 终端 ───────── case 'terminal': return (
字体
终端配色
{Object.entries(themes).map(([key, theme]) => ( ))}
) // ──────── 快捷键 ─────── 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 (
{/* 重置全部按钮 */} {hasCustom && (
)} {categories.map((category) => { const defs = getKeybindingsByCategory(category) return (

{category}

{defs.map((def) => { const currentKeys = allKeys[def.id] const isCustom = keybindingState.customKeybindings[def.id] !== undefined const isRecordingThis = recordingId === def.id return (
{def.label} {isCustom && ( 自定义 )}
{isRecordingThis ? (
{recordedKeys || '按下快捷键...'} {recordedKeys && recordedConflict.length > 0 && (
与「{recordedConflict[0].label}」冲突
)}
) : ( <> {isCustom && ( )} )}
) })}
) })} {/* 录入确认弹层 */} {recordingId && recordedKeys && (
设置为 {recordedKeys}
)}

点击快捷键可重新绑定 · 按 Esc 取消录入

) } // ──────── SSH ────────── case 'ssh': return (
setSetting('keepAlive', v)} /> 默认端口 setSetting('defaultPort', parseInt(e.target.value) || 22)} className="w-32 mt-2 px-3 py-2 bg-dark-900 border border-dark-700/30 rounded-lg text-content-secondary focus:outline-none focus:border-accent-blue" /> 超时设置 (秒) setSetting('timeout', parseInt(e.target.value) || 30)} className="w-32 mt-2 px-3 py-2 bg-dark-900 border border-dark-700/30 rounded-lg text-content-secondary focus:outline-none focus:border-accent-blue" /> {/* 专业连接管理 */}
Pro 连接管理

配置连接池行为,优化资源使用和性能

setSetting('maxConnections', parseInt(e.target.value) || 10)} className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700/30 rounded-lg text-content-secondary focus:outline-none focus:border-accent-blue" />
最大并发 SSH 连接数
setSetting('maxBackgroundConnections', parseInt(e.target.value) || 5)} className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700/30 rounded-lg text-content-secondary focus:outline-none focus:border-accent-blue" />
非活跃标签保持连接数
setSetting('idleTimeoutMs', (parseInt(e.target.value) || 5) * 60000)} className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700/30 rounded-lg text-content-secondary focus:outline-none focus:border-accent-blue" />
无操作后进入挂起
setSetting('suspendTimeoutMs', (parseInt(e.target.value) || 30) * 60000)} className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700/30 rounded-lg text-content-secondary focus:outline-none focus:border-accent-blue" />
挂起后自动断开
setSetting('enableSnapshots', v)} /> setSetting('autoReconnect', v)} />
) // ──────── 安全 ───────── case 'security': return (
setSetting('encryptPasswords', v)} /> setSetting('lockOnSleep', v)} />
) // ──────── 关于 ───────── case 'about': return (

OpsXShell

版本 {appVersion}

跨平台 SSH/SFTP 终端工具 · 简洁高效 · 安全可靠 · 操作便捷人性化

技术栈
{[ ['Electron', '28+'], ['React', '18'], ['TypeScript', '5'], ['Vite', '5'], ['Zustand', '4.4+'], ['XTerm.js', '5.5+'], ['Tailwind CSS', '3.3+'], ['Lucide React', '0.294+'], ].map(([name, ver]) => (
{name} {ver}
))}
开源许可

OpsXShell 基于 MIT 许可证开源。本项目使用了以下开源组件:

  • ssh2 — MIT License
  • xterm.js — MIT License
  • zmodem-ts — MIT License
  • better-sqlite3 — MIT License
  • lucide-react — ISC License
) default: return null } } // ── 主布局 ──────────────────────────────────────────────── return (
{/* ── 左侧导航 ── */}
{/* 搜索框 */}
setSearchQuery(e.target.value)} className="w-full pl-8 pr-8 py-1.5 text-sm bg-dark-900 border border-dark-700/30 rounded-lg text-content-secondary placeholder-ph focus:outline-none focus:border-accent-blue/60" /> {searchQuery && ( )}
{/* Tab 列表 */}
{/* ── 右侧内容区 ── */}
{/* Header bar */}

{currentMeta.label}

{currentMeta.description}

{/* 内容 */}
{renderContent()}
) } // ─── 子组件 ───────────────────────────────────────────────── /** 卡片容器 */ function SettingCard({ children, highlight = false, }: { children: React.ReactNode highlight?: boolean }) { return (
{children}
) } /** 区域小标题 */ function SettingSectionTitle({ children, className = '', }: { children: React.ReactNode className?: string }) { return (
{children}
) } /** 开关设置项 */ function SettingToggle({ label, description, checked, onChange, }: { label: string description: string checked: boolean onChange: (value: boolean) => void }) { return (
{label}
{description}
) }