参考设计系统: - Warp: 温暖暗色调、自然层次、克制配色 - Linear: 亮度阶梯层次、半透明边框、精确排版 核心改进: - app-theme.ts: 完整重写深色/浅色调色板 (+surface-hover/active, border-subtle/focus, feedback-bg, placeholder) - useTheme.ts: 修复硬编码 return 'dark',支持 dark/light/system 完整切换 - index.css: 消除硬编码颜色,全部使用 CSS 变量 - tailwind.config.js: 新增 txt/content/surface/border/ph/feedback 语义 token - 44 文件批量迁移: 292 处 text-gray/bg-gray/text-white/placeholder-gray → 主题感知语义 token | 44 files +611/-482 | tsc零错误 | 96/96 tests passing
769 lines
33 KiB
TypeScript
769 lines
33 KiB
TypeScript
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<KeybindingActionId | null>(null)
|
|
const [recordedKeys, setRecordedKeys] = useState<string>('')
|
|
|
|
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
|
|
|
|
// 获取版本号(参考 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-txt-muted">加载中...</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-surface-baseccent-blue/20 text-accent-blue'
|
|
: 'border-dark-700/30 hover:border-dark-600 text-content-secondary'
|
|
}`}
|
|
>
|
|
{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-content-secondary 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-txt-muted">字体族</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-content-secondary 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-txt-muted">字体大小</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-content-secondary 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-surface-baseccent-blue/20 text-accent-blue'
|
|
: 'border-dark-700/30 hover:border-dark-600 text-content-secondary'
|
|
}`}
|
|
>
|
|
<div
|
|
className="w-full h-6 rounded mb-1"
|
|
style={{ backgroundColor: theme.background }}
|
|
/>
|
|
{theme.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</SettingCard>
|
|
</div>
|
|
)
|
|
|
|
// ──────── 快捷键 ───────
|
|
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">
|
|
{/* 重置全部按钮 */}
|
|
{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-txt-muted hover:text-accent-blue rounded-lg hover:bg-dark-700/50 transition-colors"
|
|
>
|
|
<RotateCcw className="w-3.5 h-3.5" />
|
|
重置全部为默认
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{categories.map((category) => {
|
|
const defs = getKeybindingsByCategory(category)
|
|
return (
|
|
<SettingCard key={category}>
|
|
<h3 className="text-sm font-semibold text-content-secondary 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-surface-baseccent-blue/10 border border-accent-blue/30'
|
|
: 'hover:bg-dark-800/60'
|
|
}`}
|
|
>
|
|
<div className="flex-1 min-w-0">
|
|
<span className="text-sm text-content-primary">{def.label}</span>
|
|
{isCustom && (
|
|
<span className="ml-2 text-[10px] text-accent-blue bg-surface-baseccent-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-surface-baseccent-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-txt-muted hover:text-content-secondary"
|
|
>
|
|
取消
|
|
</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-surface-baseccent-blue/10 border border-accent-blue/30 text-accent-blue'
|
|
: 'bg-dark-700 border border-dark-600 text-content-secondary'
|
|
} group-hover:border-accent-blue/50`}
|
|
>
|
|
{currentKeys}
|
|
</kbd>
|
|
</button>
|
|
{isCustom && (
|
|
<button
|
|
onClick={() => keybindingState.resetKeybinding(def.id)}
|
|
className="text-txt-faint hover:text-txt-muted 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-content-secondary">
|
|
设置为 <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-surface-baseccent-blue text-content-primary rounded-lg hover:bg-surface-baseccent-blue/80 transition-colors"
|
|
>
|
|
确认
|
|
</button>
|
|
<button
|
|
onClick={stopRecording}
|
|
className="px-3 py-1 text-xs text-txt-muted hover:text-content-primary rounded-lg hover:bg-dark-700 transition-colors"
|
|
>
|
|
取消
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
<p className="text-xs text-txt-faint">
|
|
点击快捷键可重新绑定 · 按 Esc 取消录入
|
|
</p>
|
|
</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-content-secondary 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-content-secondary 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-txt-muted mb-4">配置连接池行为,优化资源使用和性能</p>
|
|
|
|
<div className="space-y-4">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="text-sm text-txt-muted">最大连接数</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-content-secondary focus:outline-none focus:border-accent-blue"
|
|
/>
|
|
<div className="text-xs text-txt-faint mt-1">最大并发 SSH 连接数</div>
|
|
</div>
|
|
<div>
|
|
<label className="text-sm text-txt-muted">最大后台连接</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-content-secondary focus:outline-none focus:border-accent-blue"
|
|
/>
|
|
<div className="text-xs text-txt-faint mt-1">非活跃标签保持连接数</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="text-sm text-txt-muted">空闲超时 (分钟)</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-content-secondary focus:outline-none focus:border-accent-blue"
|
|
/>
|
|
<div className="text-xs text-txt-faint mt-1">无操作后进入挂起</div>
|
|
</div>
|
|
<div>
|
|
<label className="text-sm text-txt-muted">挂起超时 (分钟)</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-content-secondary focus:outline-none focus:border-accent-blue"
|
|
/>
|
|
<div className="text-xs text-txt-faint 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-content-primary" />
|
|
</div>
|
|
<h3 className="text-xl font-semibold text-content-primary mb-1">OpsXShell</h3>
|
|
<p className="text-sm text-txt-muted mb-4">版本 {appVersion}</p>
|
|
<p className="text-sm text-txt-muted 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-txt-muted">{name}</span>
|
|
<span className="text-txt-muted font-mono">{ver}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</SettingCard>
|
|
|
|
<SettingCard>
|
|
<SettingSectionTitle>开源许可</SettingSectionTitle>
|
|
<p className="text-sm text-txt-muted mt-2">
|
|
OpsXShell 基于 MIT 许可证开源。本项目使用了以下开源组件:
|
|
</p>
|
|
<ul className="mt-2 text-sm text-txt-muted 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-txt-muted" />
|
|
<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-content-secondary placeholder-ph focus:outline-none focus:border-accent-blue/60"
|
|
/>
|
|
{searchQuery && (
|
|
<button
|
|
onClick={() => setSearchQuery('')}
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-txt-muted hover:text-content-secondary"
|
|
>
|
|
<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-surface-baseccent-blue text-content-primary shadow-sm'
|
|
: 'text-txt-muted hover:bg-dark-700/50 hover:text-content-primary'
|
|
}`}
|
|
>
|
|
<Icon className={`w-4 h-4 flex-shrink-0 ${active ? 'text-content-primary' : ''}`} />
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-medium truncate">{tab.label}</div>
|
|
<div className={`text-xs truncate ${active ? 'text-content-primary/70' : 'text-txt-faint'}`}>
|
|
{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-content-primary">{currentMeta.label}</h1>
|
|
<p className="text-sm text-txt-muted 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-content-secondary 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-content-primary text-sm">{label}</div>
|
|
<div className="text-xs text-txt-muted 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-surface-baseccent-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>
|
|
)
|
|
}
|