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, Copy, Check, Sparkles, } from 'lucide-react' import AiProviderSettings from './AiProviderSettings' import { themes } from '../../themes/terminal' import { getKeybindingCategories, getKeybindingsByCategory, type KeybindingActionId } from '../../constants/keybindings' // ─── Tab 类型 ─────────────────────────────────────────────── type SettingsTab = 'general' | 'appearance' | 'terminal' | 'ai' | 'shortcuts' | 'ssh' | 'security' | 'about' interface TabMeta { id: SettingsTab label: string description: string icon: React.ComponentType<{ className?: string }> } interface RuntimeInfo { appVersion: string os: string arch: string nativeBackend: string sshBackend: string dataPath: string credentialPath: string } const TAB_LIST: TabMeta[] = [ { id: 'general', label: '工作区', description: '布局与资源', icon: Monitor }, { id: 'appearance', label: '外观', description: '主题与终端配色', icon: Palette }, { id: 'terminal', label: '终端', description: '字体与配色', icon: Terminal }, { id: 'ai', label: '大模型', description: 'Provider、模型与测试', icon: Sparkles }, { 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 [runtimeInfo, setRuntimeInfo] = useState(null) const [copiedField, setCopiedField] = useState(null) const { recordingId, recordedKeys, startRecording, stopRecording } = useKeyRecording() useEffect(() => { loadSettings() }, []) // eslint-disable-line react-hooks/exhaustive-deps // Runtime facts come from Rust so diagnostics match the installed build. useEffect(() => { if (window.opsxApi) { window.opsxApi .appRuntimeInfo() .then(setRuntimeInfo) .catch(() => undefined) } }, []) const copyText = useCallback(async (label: string, text: string) => { try { await navigator.clipboard.writeText(text) setCopiedField(label) setTimeout(() => setCopiedField(null), 1500) } catch { // ignore } }, []) // 当前 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: ['工作区', '侧栏', '宽度', '资源', 'workspace', 'sidebar'], appearance: ['主题', '深色', '浅色', 'theme'], terminal: ['字体', 'font', '配色', 'terminal', 'monokai', 'dracula'], ai: ['ai', '大模型', 'provider', '模型', 'deepseek', 'glm', 'minimax', 'new-api'], shortcuts: ['快捷键', '键盘', 'keyboard', 'shortcut', 'hotkey'], ssh: ['ssh', '连接', '端口', '超时', '心跳', '连接池', '重连', '快照'], security: ['加密', '安全', '密码', '主密钥', 'known hosts', 'encrypt'], 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 const appVersion = runtimeInfo?.appVersion ?? '—' const diagnosticText = runtimeInfo ? [ `OpsXShell v${runtimeInfo.appVersion}`, `${runtimeInfo.os} ${runtimeInfo.arch}`, runtimeInfo.nativeBackend, runtimeInfo.sshBackend, `Data: ${runtimeInfo.dataPath}`, ].join('\n') : `OpsXShell v${appVersion}` // ── 加载态 ──────────────────────────────────────────────── if (!isLoaded) { return (
加载中...
) } // ── 渲染内容区 ──────────────────────────────────────────── const renderContent = () => { switch (activeTab) { // ──────── 常规 ───────── case 'general': return (
setSetting('sidebarWidth', Number(e.target.value))} className="w-28 accent-blue-500" aria-label="侧栏宽度" /> {settings.sidebarWidth}px
) // ──────── 外观 ───────── case 'appearance': return (
) // ──────── 终端 ───────── case 'terminal': return (
{settings.fontSize || 14}px
setSetting('scrollback', Math.max(1000, Number(e.target.value) || 10000))} className="ui-input-sm w-24 text-center" /> setSetting('keywordHighlightEnabled', value)} />
{Object.entries(themes).map(([key, theme]) => ( ))}
) case 'ai': return // ──────── 快捷键 ─────── 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('autoReconnect', v)} /> setSetting('timeout', parseInt(e.target.value) || 30)} className="ui-input-sm w-20 text-center" /> setSetting('maxReconnectAttempts', parseInt(e.target.value) || 5)} className="ui-input-sm w-20 text-center" /> setSetting('maxConnections', parseInt(e.target.value) || 10)} className="ui-input-sm w-20 text-center" /> setSetting('maxBackgroundConnections', parseInt(e.target.value) || 5)} className="ui-input-sm w-20 text-center" /> setSetting('idleTimeoutMs', (parseInt(e.target.value) || 5) * 60000)} className="ui-input-sm w-20 text-center" />
) // ──────── 安全 ───────── case 'security': return (
已启用 ~/.opsxshsell/master.key
) // ──────── 关于 ───────── case 'about': return (

OpsXShell

轻量原生的 SSH / SFTP 运维终端

应用 v{appVersion} Tauri 2 · Rust
运行环境
{[ ['系统', runtimeInfo ? `${runtimeInfo.os} ${runtimeInfo.arch}` : '读取中...'], ['原生运行时', runtimeInfo?.nativeBackend ?? '读取中...'], ['SSH 后端', runtimeInfo?.sshBackend ?? '读取中...'], ].map(([label, value]) => (
{label} {value}
))}
本地数据
会话与设置
{runtimeInfo?.dataPath ?? '读取中...'}
加密主密钥
{runtimeInfo?.credentialPath ?? '~/.opsxshsell/master.key'}

密码与密钥口令由 Rust 使用 AES-256-GCM 加密。OpsXShell 不保存终端命令历史。

MIT License · Tauri 2 · Rust · React · XTerm.js

) default: return null } } // ── 主布局 ──────────────────────────────────────────────── return (
{/* ── 左侧导航 ── */} {/* ── 右侧内容区 ── */}

{currentMeta.label}

{currentMeta.description}

{renderContent()}
) } // ─── 子组件 ───────────────────────────────────────────────── function KnownHostsSettingsCard() { const [entries, setEntries] = useState>( [] ) const [loading, setLoading] = useState(false) const load = useCallback(async () => { setLoading(true) try { const result = await window.opsxApi?.knownHostsList?.() if (result?.success && result.entries) { setEntries(result.entries) } } finally { setLoading(false) } }, []) useEffect(() => { void load() }, [load]) const handleRemove = async (host: string, port: number) => { await window.opsxApi?.knownHostsRemove?.(host, port) await load() } const handleClear = async () => { if (!confirm('确定清空全部已知主机密钥?下次连接将重新确认。')) return await window.opsxApi?.knownHostsClear?.() await load() } return (

首次连接会确认主机指纹。此处可管理已信任记录。

{loading ? (
加载中...
) : entries.length === 0 ? (
暂无记录
) : (
{entries.map((e) => (
{e.host}:{e.port} ({e.keyType})
SHA256:{e.fingerprint}
))}
)}
) } /** 分组卡片:多行设置 */ function SettingGroup({ title, children }: { title?: string; children: React.ReactNode }) { return (
{title &&

{title}

}
{children}
) } /** 卡片容器(快捷键等复用) */ function SettingCard({ children, highlight = false }: { children: React.ReactNode; highlight?: boolean }) { return (
{children}
) } /** 区域小标题 */ function SettingSectionTitle({ children, className = '' }: { children: React.ReactNode; className?: string }) { return
{children}
} /** 左文右控行 */ function SettingRow({ label, description, children, }: { label: string description?: string children: React.ReactNode }) { return (
{label}
{description &&
{description}
}
{children}
) } /** 开关设置项(行内) */ function SettingToggle({ label, description, checked, onChange, }: { label: string description: string checked: boolean onChange: (value: boolean) => void }) { return (
{label}
{description}
) }