feat: v0.3.0 版本 - SSH终端、SFTP、端口转发功能实现

核心功能:
- SSH终端: 密码/密钥认证、XTerm.js终端、多主题配色、IP高亮
- SFTP管理: 双栏文件管理、上传下载、传输队列
- 端口转发: 本地/远程转发、SOCKS5动态代理、规则管理

架构改进:
- 拆分大型组件 (SFTPView/TabBar/Sidebar)
- 添加统一日志和错误处理
- 修复React严格模式下的终端黑屏问题

UI更新:
- 新增端口转发视图
- 更新UI预览文档
- 左侧栏右键菜单支持SFTP快捷打开
This commit is contained in:
Logan.Li committed 2026-04-06 01:20:47 +08:00
1 parent cf1c054381
commit 484d850abd
105 files changed
+25330 -64

No files matched your search

+394
View File
@@ -0,0 +1,394 @@
import { useEffect, useState } from 'react'
import { useSettingsStore } from '../stores/settingsStore'
import {
Monitor, Palette, Keyboard, Shield, Database, Terminal
} from 'lucide-react'
import { themes } from '../themes/terminal'
type SettingsTab = 'general' | 'appearance' | 'terminal' | 'ssh' | 'security' | 'about'
export default function SettingsView() {
const { settings, isLoaded, loadSettings, setSetting } = useSettingsStore()
const [activeTab, setActiveTab] = useState<SettingsTab>('general')
useEffect(() => {
loadSettings()
}, [])
const tabs = [
{ id: 'general' as SettingsTab, label: '常规', icon: Monitor },
{ id: 'appearance' as SettingsTab, label: '外观', icon: Palette },
{ id: 'terminal' as SettingsTab, label: '终端', icon: Keyboard },
{ id: 'ssh' as SettingsTab, label: 'SSH', icon: Shield },
{ id: 'security' as SettingsTab, label: '安全', icon: Database },
{ id: 'about' as SettingsTab, label: '关于', icon: Monitor },
]
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-6">
<h2 className="text-xl font-semibold text-gray-200">常规设置</h2>
<div className="space-y-4">
<SettingToggle
label="启动时恢复会话"
description="应用启动时自动恢复上次的会话"
checked={settings.restoreOnStart}
onChange={(v) => setSetting('restoreOnStart', v)}
/>
<SettingToggle
label="自动保存会话"
description="自动保存会话配置更改"
checked={settings.autoSave}
onChange={(v) => setSetting('autoSave', v)}
/>
<SettingToggle
label="显示通知"
description="在桌面显示连接状态通知"
checked={settings.showNotifications}
onChange={(v) => setSetting('showNotifications', v)}
/>
</div>
</div>
)
case 'appearance':
return (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-gray-200">外观设置</h2>
<div className="space-y-4">
<div className="p-4 bg-dark-800 rounded-lg">
<div className="font-medium text-gray-200 mb-3">主题</div>
<div className="grid grid-cols-3 gap-3">
{[
{ value: 'dark', label: '深色' },
{ value: 'light', label: '浅色' },
{ value: 'system', label: '跟随系统' },
].map((theme) => (
<button
key={theme.value}
onClick={() => setSetting('theme', theme.value as any)}
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 hover:border-dark-600 text-gray-300'
}`}
>
{theme.label}
</button>
))}
</div>
</div>
<div className="p-4 bg-dark-800 rounded-lg">
<div className="font-medium text-gray-200 mb-3">语言</div>
<select
value={settings.language}
onChange={(e) => setSetting('language', e.target.value as any)}
className="w-full px-3 py-2 bg-dark-900 border border-dark-700 rounded text-gray-300 focus:outline-none focus:border-accent-blue"
>
<option value="zh-CN">简体中文</option>
<option value="en-US">English</option>
</select>
</div>
</div>
</div>
)
case 'terminal':
return (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-gray-200">终端设置</h2>
<div className="space-y-4">
<div className="p-4 bg-dark-800 rounded-lg">
<div className="font-medium text-gray-200 mb-3">字体</div>
<div className="grid grid-cols-2 gap-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 rounded text-gray-300"
>
<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 rounded text-gray-300"
>
{[12, 13, 14, 15, 16, 18, 20].map((size) => (
<option key={size} value={size}>{size}px</option>
))}
</select>
</div>
</div>
</div>
<div className="p-4 bg-dark-800 rounded-lg">
<div className="font-medium text-gray-200 mb-3">终端配色</div>
<div className="grid grid-cols-4 gap-2">
{Object.entries(themes).map(([key, theme]) => (
<button
key={key}
onClick={() => setSetting('terminalTheme', key as any)}
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 hover:border-dark-600 text-gray-300'
}`}
>
<div
className="w-full h-6 rounded mb-1"
style={{ backgroundColor: theme.background }}
/>
{theme.name}
</button>
))}
</div>
</div>
</div>
</div>
)
case 'ssh':
return (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-gray-200">SSH 设置</h2>
<div className="space-y-4">
<SettingToggle
label="保持连接活跃"
description="定期发送心跳包保持连接"
checked={settings.keepAlive}
onChange={(v) => setSetting('keepAlive', v)}
/>
<div className="p-4 bg-dark-800 rounded-lg">
<div className="font-medium text-gray-200 mb-3">默认端口</div>
<input
type="number"
value={settings.defaultPort}
onChange={(e) => setSetting('defaultPort', parseInt(e.target.value) || 22)}
className="w-32 px-3 py-2 bg-dark-900 border border-dark-700 rounded text-gray-300"
/>
</div>
<div className="p-4 bg-dark-800 rounded-lg">
<div className="font-medium text-gray-200 mb-3">超时设置 (秒)</div>
<input
type="number"
value={settings.timeout}
onChange={(e) => setSetting('timeout', parseInt(e.target.value) || 30)}
className="w-32 px-3 py-2 bg-dark-900 border border-dark-700 rounded text-gray-300"
/>
</div>
{/* 专业连接管理设置 */}
<div className="p-4 bg-dark-800 rounded-lg border border-accent-blue/30">
<div className="font-medium text-gray-200 mb-2 flex items-center gap-2">
<span className="text-accent-blue">专业版</span>
连接管理
</div>
<div className="text-sm text-gray-500 mb-4">
配置连接池行为,优化资源使用和性能
</div>
<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 rounded text-gray-300"
/>
<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 rounded text-gray-300"
/>
<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 rounded text-gray-300"
/>
<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 rounded text-gray-300"
/>
<div className="text-xs text-gray-600 mt-1">挂起后自动断开</div>
</div>
</div>
<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>
</div>
</div>
)
case 'security':
return (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-gray-200">安全设置</h2>
<div className="space-y-4">
<SettingToggle
label="加密存储密码"
description="使用 AES-256 加密存储密码"
checked={settings.encryptPasswords}
onChange={(v) => setSetting('encryptPasswords', v)}
/>
<SettingToggle
label="锁定屏幕时断开连接"
description="系统锁屏时自动断开 SSH 连接"
checked={settings.lockOnSleep}
onChange={(v) => setSetting('lockOnSleep', v)}
/>
</div>
</div>
)
case 'about':
return (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-gray-200">关于 OpsXShell</h2>
<div className="p-6 bg-dark-800 rounded-lg text-center">
<div className="w-20 h-20 bg-dark-700 rounded-2xl flex items-center justify-center mx-auto 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">版本 1.0.0</p>
<p className="text-sm text-gray-400 max-w-md mx-auto">
跨平台 SSH/SFTP 终端工具 · 简洁高效 · 安全可靠 · 操作便捷人性化
</p>
</div>
</div>
)
default:
return null
}
}
return (
<div className="h-full flex bg-dark-900">
{/* Sidebar */}
<div className="w-48 bg-dark-800 border-r border-dark-700 p-4">
<nav className="space-y-1">
{tabs.map((tab) => {
const Icon = tab.icon
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors ${
activeTab === tab.id
? 'bg-accent-blue text-white'
: 'text-gray-400 hover:bg-dark-700 hover:text-gray-200'
}`}
>
<Icon className="w-4 h-4" />
{tab.label}
</button>
)
})}
</nav>
</div>
{/* Content */}
<div className="flex-1 p-8 overflow-y-auto">
{renderContent()}
</div>
</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 p-4 bg-dark-800 rounded-lg">
<div>
<div className="font-medium text-gray-200">{label}</div>
<div className="text-sm text-gray-500">{description}</div>
</div>
<button
onClick={() => onChange(!checked)}
className={`relative w-12 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-7' : 'left-1'
}`}
/>
</button>
</div>
)
}