Files
opsxshell/src/components/SettingsView.tsx
T
Logan.Li 514d62644e v2.0.0: 主题系统+SSH私钥解析修复+会话持久化+连接池优化+UI打磨
核心修复:
- ssh-service.ts: 新增 resolvePrivateKey(),将私钥文件路径(~/.ssh/id_rsa)解析为内容后传给 ssh2,
  解决 'Cannot parse privateKey: Unsupported key format' 错误
- useTerminalConnection.ts + sessionHelper.ts: 根据 authType 过滤字段,password/agent 认证不再传 privateKey
- sessionStore.ts: addSession() 现在先调用 sessionCreate() 持久化到 SQLite,解决恢复标签'无可用连接信息'问题
- database-service.ts: updateSession() 补充 private_key_path 字段持久化
- SessionDialog.tsx: 默认 username='root'、privateKey='~/.ssh/id_rsa'

UI/UX:
- 语义化主题系统(app-theme.ts),强制深色模式,全局无边框设计
- macOS 标题栏支持双击最大化(window:toggleMaximize IPC)
- macOS 菜单栏('OpsXShell' 应用菜单 + Settings Cmd+,)
- WelcomeView 4卡片布局,移除 Settings 按钮
- TabBar/TabItem 背景色统一(bg-dark-800/900),消除白色边框线

架构优化:
- SFTP 生命周期修复(useSFTP.ts useRef + autoConnectAttempted guard)
- 连接池竞态修复,支持快速多标签连接
- IPC 接口规范化,安全错误处理
- Vite 代码分割优化

质量: lint零错误 | tsc零错误 | build成功 | 49 tests pass
2026-04-22 11:15:41 +08:00

617 lines
26 KiB
TypeScript

import { useEffect, useState, useMemo } from 'react'
import { useSettingsStore } from '../stores/settingsStore'
import {
Monitor, Palette, Terminal, Keyboard, Shield, Database, Info, Search, X,
} from 'lucide-react'
import { themes } from '../themes/terminal'
// ─── 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 },
]
// ─── 快捷键数据 ─────────────────────────────────────────────
interface ShortcutEntry {
action: string
keys: string
category: 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: '工具' },
]
// ─── 主组件 ─────────────────────────────────────────────────
export default function SettingsView() {
const { settings, isLoaded, loadSettings, setSetting } = useSettingsStore()
const [activeTab, setActiveTab] = useState<SettingsTab>('general')
const [searchQuery, setSearchQuery] = useState('')
const [appVersion, setAppVersion] = useState<string>('—')
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<SettingsTab, string[]> = {
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 (
<div className="h-full flex items-center justify-center bg-dark-900">
<div className="text-gray-400">加载中...</div>
</div>
)
}
// ── 渲染内容区 ────────────────────────────────────────────
const renderContent = () => {
switch (activeTab) {
// ──────── 常规 ─────────
case 'general':
return (
<div className="space-y-5">
<SettingCard>
<SettingToggle
label="启动时恢复会话"
description="应用启动时自动恢复上次的会话"
checked={settings.restoreOnStart}
onChange={(v) => setSetting('restoreOnStart', v)}
/>
</SettingCard>
<SettingCard>
<SettingToggle
label="自动保存会话"
description="自动保存会话配置更改"
checked={settings.autoSave}
onChange={(v) => setSetting('autoSave', v)}
/>
</SettingCard>
<SettingCard>
<SettingToggle
label="显示通知"
description="在桌面显示连接状态通知"
checked={settings.showNotifications}
onChange={(v) => setSetting('showNotifications', v)}
/>
</SettingCard>
</div>
)
// ──────── 外观 ─────────
case 'appearance':
return (
<div className="space-y-5">
<SettingCard>
<SettingSectionTitle>主题</SettingSectionTitle>
<div className="grid grid-cols-3 gap-3 mt-3">
{[
{ value: 'dark', label: '深色' },
{ value: 'light', label: '浅色' },
{ value: 'system', label: '跟随系统' },
].map((theme) => (
<button
key={theme.value}
onClick={() => setSetting('theme', theme.value as 'dark' | 'light' | 'system')}
className={`p-3 rounded-lg border text-sm transition-colors ${
settings.theme === theme.value
? 'border-accent-blue bg-accent-blue/20 text-accent-blue'
: 'border-dark-700/30 hover:border-dark-600 text-gray-300'
}`}
>
{theme.label}
</button>
))}
</div>
</SettingCard>
<SettingCard>
<SettingSectionTitle>语言</SettingSectionTitle>
<select
value={settings.language}
onChange={(e) => setSetting('language', e.target.value as 'zh-CN' | 'en-US')}
className="w-full mt-3 px-3 py-2 bg-dark-900 border border-dark-700/30 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue"
>
<option value="zh-CN">简体中文</option>
<option value="en-US">English</option>
</select>
</SettingCard>
</div>
)
// ──────── 终端 ─────────
case 'terminal':
return (
<div className="space-y-5">
<SettingCard>
<SettingSectionTitle>字体</SettingSectionTitle>
<div className="grid grid-cols-2 gap-4 mt-3">
<div>
<label className="text-sm text-gray-500">字体族</label>
<select
value={settings.fontFamily}
onChange={(e) => setSetting('fontFamily', e.target.value)}
className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700/30 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue"
>
<option>JetBrains Mono</option>
<option>Fira Code</option>
<option>Consolas</option>
<option>Monaco</option>
</select>
</div>
<div>
<label className="text-sm text-gray-500">字体大小</label>
<select
value={settings.fontSize}
onChange={(e) => setSetting('fontSize', parseInt(e.target.value))}
className="w-full mt-1 px-3 py-2 bg-dark-900 border border-dark-700/30 rounded-lg text-gray-300 focus:outline-none focus:border-accent-blue"
>
{[12, 13, 14, 15, 16, 18, 20].map((size) => (
<option key={size} value={size}>{size}px</option>
))}
</select>
</div>
</div>
</SettingCard>
<SettingCard>
<SettingSectionTitle>终端配色</SettingSectionTitle>
<div className="grid grid-cols-4 gap-2 mt-3">
{Object.entries(themes).map(([key, theme]) => (
<button
key={key}
onClick={() => setSetting('terminalTheme', key as 'dracula' | 'monokai' | 'solarized' | 'one-dark')}
className={`p-3 rounded-lg border text-sm transition-colors ${
settings.terminalTheme === key
? 'border-accent-blue bg-accent-blue/20 text-accent-blue'
: 'border-dark-700/30 hover:border-dark-600 text-gray-300'
}`}
>
<div
className="w-full h-6 rounded mb-1"
style={{ backgroundColor: theme.background }}
/>
{theme.name}
</button>
))}
</div>
</SettingCard>
</div>
)
// ──────── 快捷键 ───────
case 'shortcuts':
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>
</div>
<p className="text-xs text-gray-600 mt-3">
快捷键自定义功能将在后续版本中支持。
</p>
</SettingCard>
</div>
)
// ──────── SSH ──────────
case 'ssh':
return (
<div className="space-y-5">
<SettingCard>
<SettingToggle
label="保持连接活跃"
description="定期发送心跳包保持连接"
checked={settings.keepAlive}
onChange={(v) => setSetting('keepAlive', v)}
/>
</SettingCard>
<SettingCard>
<SettingSectionTitle>默认端口</SettingSectionTitle>
<input
type="number"
value={settings.defaultPort}
onChange={(e) => 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-gray-300 focus:outline-none focus:border-accent-blue"
/>
</SettingCard>
<SettingCard>
<SettingSectionTitle>超时设置 (秒)</SettingSectionTitle>
<input
type="number"
value={settings.timeout}
onChange={(e) => 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-gray-300 focus:outline-none focus:border-accent-blue"
/>
</SettingCard>
{/* 专业连接管理 */}
<SettingCard highlight>
<div className="flex items-center gap-2 mb-1">
<span className="text-accent-blue text-xs font-semibold uppercase tracking-wider">Pro</span>
<SettingSectionTitle className="mb-0">连接管理</SettingSectionTitle>
</div>
<p className="text-sm text-gray-500 mb-4">配置连接池行为,优化资源使用和性能</p>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm text-gray-500">最大连接数</label>
<input
type="number"
min={1}
max={50}
value={settings.maxConnections || 10}
onChange={(e) => 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-gray-300 focus:outline-none focus:border-accent-blue"
/>
<div className="text-xs text-gray-600 mt-1">最大并发 SSH 连接数</div>
</div>
<div>
<label className="text-sm text-gray-500">最大后台连接</label>
<input
type="number"
min={1}
max={20}
value={settings.maxBackgroundConnections || 5}
onChange={(e) => 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-gray-300 focus:outline-none focus:border-accent-blue"
/>
<div className="text-xs text-gray-600 mt-1">非活跃标签保持连接数</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm text-gray-500">空闲超时 (分钟)</label>
<input
type="number"
min={1}
max={120}
value={Math.floor((settings.idleTimeoutMs || 5 * 60 * 1000) / 60000)}
onChange={(e) => 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-gray-300 focus:outline-none focus:border-accent-blue"
/>
<div className="text-xs text-gray-600 mt-1">无操作后进入挂起</div>
</div>
<div>
<label className="text-sm text-gray-500">挂起超时 (分钟)</label>
<input
type="number"
min={1}
max={240}
value={Math.floor((settings.suspendTimeoutMs || 30 * 60 * 1000) / 60000)}
onChange={(e) => 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-gray-300 focus:outline-none focus:border-accent-blue"
/>
<div className="text-xs text-gray-600 mt-1">挂起后自动断开</div>
</div>
</div>
<div className="pt-2 space-y-4">
<SettingToggle
label="启用终端快照"
description="切换标签时保存终端内容,快速恢复显示"
checked={settings.enableSnapshots !== false}
onChange={(v) => setSetting('enableSnapshots', v)}
/>
<SettingToggle
label="自动重连"
description="连接断开后自动尝试重连(最多 3 次)"
checked={settings.autoReconnect !== false}
onChange={(v) => setSetting('autoReconnect', v)}
/>
</div>
</div>
</SettingCard>
</div>
)
// ──────── 安全 ─────────
case 'security':
return (
<div className="space-y-5">
<SettingCard>
<SettingToggle
label="加密存储密码"
description="使用 AES-256-GCM 加密存储密码"
checked={settings.encryptPasswords}
onChange={(v) => setSetting('encryptPasswords', v)}
/>
</SettingCard>
<SettingCard>
<SettingToggle
label="锁定屏幕时断开连接"
description="系统锁屏时自动断开 SSH 连接"
checked={settings.lockOnSleep}
onChange={(v) => setSetting('lockOnSleep', v)}
/>
</SettingCard>
</div>
)
// ──────── 关于 ─────────
case 'about':
return (
<div className="space-y-5">
<SettingCard>
<div className="flex flex-col items-center py-6">
<div className="w-20 h-20 bg-dark-700 rounded-2xl flex items-center justify-center mb-4">
<Terminal className="w-10 h-10 text-white" />
</div>
<h3 className="text-xl font-semibold text-gray-200 mb-1">OpsXShell</h3>
<p className="text-sm text-gray-500 mb-4">版本 {appVersion}</p>
<p className="text-sm text-gray-400 max-w-md text-center">
跨平台 SSH/SFTP 终端工具 · 简洁高效 · 安全可靠 · 操作便捷人性化
</p>
</div>
</SettingCard>
<SettingCard>
<SettingSectionTitle>技术栈</SettingSectionTitle>
<div className="grid grid-cols-2 gap-x-6 gap-y-2 mt-3 text-sm">
{[
['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]) => (
<div key={name} className="flex justify-between py-1 border-b border-dark-700/20">
<span className="text-gray-400">{name}</span>
<span className="text-gray-500 font-mono">{ver}</span>
</div>
))}
</div>
</SettingCard>
<SettingCard>
<SettingSectionTitle>开源许可</SettingSectionTitle>
<p className="text-sm text-gray-400 mt-2">
OpsXShell 基于 MIT 许可证开源。本项目使用了以下开源组件:
</p>
<ul className="mt-2 text-sm text-gray-500 list-disc list-inside space-y-1">
<li>ssh2 — MIT License</li>
<li>xterm.js — MIT License</li>
<li>zmodem-ts — MIT License</li>
<li>better-sqlite3 — MIT License</li>
<li>lucide-react — ISC License</li>
</ul>
</SettingCard>
</div>
)
default:
return null
}
}
// ── 主布局 ────────────────────────────────────────────────
return (
<div className="h-full flex bg-dark-900">
{/* ── 左侧导航 ── */}
<div className="w-56 flex-shrink-0 bg-dark-800 flex flex-col">
{/* 搜索框 */}
<div className="p-3 border-b border-dark-700/20">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-500" />
<input
type="text"
placeholder="搜索设置..."
value={searchQuery}
onChange={(e) => 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-gray-300 placeholder-gray-600 focus:outline-none focus:border-accent-blue/60"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
{/* Tab 列表 */}
<nav className="flex-1 p-2 space-y-0.5 overflow-y-auto">
{visibleTabs.map((tab) => {
const Icon = tab.icon
const active = activeTab === tab.id
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-all ${
active
? 'bg-accent-blue text-white shadow-sm'
: 'text-gray-400 hover:bg-dark-700/50 hover:text-gray-200'
}`}
>
<Icon className={`w-4 h-4 flex-shrink-0 ${active ? 'text-white' : ''}`} />
<div className="min-w-0">
<div className="text-sm font-medium truncate">{tab.label}</div>
<div className={`text-xs truncate ${active ? 'text-white/70' : 'text-gray-600'}`}>
{tab.description}
</div>
</div>
</button>
)
})}
</nav>
</div>
{/* ── 右侧内容区 ── */}
<div className="flex-1 flex flex-col min-w-0">
{/* Header bar */}
<div className="flex-shrink-0 px-8 pt-6 pb-4 border-b border-dark-700/20">
<h1 className="text-xl font-semibold text-gray-100">{currentMeta.label}</h1>
<p className="text-sm text-gray-500 mt-0.5">{currentMeta.description}</p>
</div>
{/* 内容 */}
<div className="flex-1 px-8 py-6 overflow-y-auto">
{renderContent()}
</div>
</div>
</div>
)
}
// ─── 子组件 ─────────────────────────────────────────────────
/** 卡片容器 */
function SettingCard({
children,
highlight = false,
}: {
children: React.ReactNode
highlight?: boolean
}) {
return (
<div
className={`rounded-xl p-5 ${
highlight
? 'bg-dark-800/80 border border-accent-blue/20'
: 'bg-dark-800/50 border border-dark-700/30'
}`}
>
{children}
</div>
)
}
/** 区域小标题 */
function SettingSectionTitle({
children,
className = '',
}: {
children: React.ReactNode
className?: string
}) {
return (
<div className={`text-sm font-semibold text-gray-300 mb-1 ${className}`}>
{children}
</div>
)
}
/** 开关设置项 */
function SettingToggle({
label,
description,
checked,
onChange,
}: {
label: string
description: string
checked: boolean
onChange: (value: boolean) => void
}) {
return (
<div className="flex items-center justify-between">
<div className="mr-4">
<div className="font-medium text-gray-200 text-sm">{label}</div>
<div className="text-xs text-gray-500 mt-0.5">{description}</div>
</div>
<button
onClick={() => onChange(!checked)}
className={`relative flex-shrink-0 w-11 h-6 rounded-full transition-colors ${
checked ? 'bg-accent-blue' : 'bg-dark-600'
}`}
>
<span
className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${
checked ? 'left-6' : 'left-1'
}`}
/>
</button>
</div>
)
}