- Extend AppLanguage to 7 locales: zh-CN, zh-TW, en, ja, ko, vi, ru - Auto-detect system language via navigator.languages; fallback to English - Add BCP47 locale mapping for date formatting and sorting - Create 50 translation JSON files (5 languages × 10 namespaces, 1137 keys each) - Update language selector with native language names - Extend locale key parity test to cover all 7 languages (70 assertions) - Add VNC/RDP desktop, serial terminal, AI agent, port forwarding, and SFTP features - Add credential encryption, host metrics, session backup crypto, and zmodem bridge - Update Rust backend: desktop services, host metrics, SSH/SFTP enhancements
918 lines
39 KiB
TypeScript
918 lines
39 KiB
TypeScript
import { useEffect, useState, useMemo, useCallback } from 'react'
|
||
import { useTranslation } from 'react-i18next'
|
||
import { useSettingsStore } from '../../stores/settingsStore'
|
||
import { useKeybindingStore } from '../../stores/keybindingStore'
|
||
import type { AppLanguage } from '../../i18n/detectLocale'
|
||
import {
|
||
Monitor,
|
||
Palette,
|
||
Terminal,
|
||
Keyboard,
|
||
Shield,
|
||
Database,
|
||
Info,
|
||
Search,
|
||
X,
|
||
RotateCcw,
|
||
AlertTriangle,
|
||
Copy,
|
||
Check,
|
||
Sparkles,
|
||
} from 'lucide-react'
|
||
import AiProviderSettings from './AiProviderSettings'
|
||
import { themes, type TerminalThemeId } 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
|
||
labelKey: string
|
||
descriptionKey: 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', labelKey: 'nav.general', descriptionKey: 'nav.generalDesc', icon: Monitor },
|
||
{ id: 'appearance', labelKey: 'nav.appearance', descriptionKey: 'nav.appearanceDesc', icon: Palette },
|
||
{ id: 'terminal', labelKey: 'nav.terminal', descriptionKey: 'nav.terminalDesc', icon: Terminal },
|
||
{ id: 'shortcuts', labelKey: 'nav.shortcuts', descriptionKey: 'nav.shortcutsDesc', icon: Keyboard },
|
||
{ id: 'ssh', labelKey: 'nav.ssh', descriptionKey: 'nav.sshDesc', icon: Shield },
|
||
{ id: 'security', labelKey: 'nav.security', descriptionKey: 'nav.securityDesc', icon: Database },
|
||
{ id: 'ai', labelKey: 'nav.ai', descriptionKey: 'nav.aiDesc', icon: Sparkles },
|
||
{ id: 'about', labelKey: 'nav.about', descriptionKey: 'nav.aboutDesc', 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 { t } = useTranslation(['settings', 'common'])
|
||
const { settings, isLoaded, loadSettings, setSetting } = useSettingsStore()
|
||
const keybindingState = useKeybindingStore()
|
||
const [activeTab, setActiveTab] = useState<SettingsTab>('general')
|
||
const [searchQuery, setSearchQuery] = useState('')
|
||
const [runtimeInfo, setRuntimeInfo] = useState<RuntimeInfo | null>(null)
|
||
const [copiedField, setCopiedField] = useState<string | null>(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
|
||
const q = searchQuery.toLowerCase()
|
||
const keywords: Record<SettingsTab, string[]> = {
|
||
general: [t('nav.general'), t('general.sidebarWidth'), 'workspace', 'sidebar'],
|
||
appearance: [t('nav.appearance'), t('appearance.theme'), t('language.label'), 'theme', 'appearance'],
|
||
terminal: [t('nav.terminal'), t('terminal.fontFamily'), 'font', 'terminal', 'nord', 'tokyo', 'dracula', 'one-dark', 'default'],
|
||
ai: ['ai', t('nav.ai'), 'provider', 'deepseek', 'glm', 'minimax', 'custom'],
|
||
shortcuts: [t('nav.shortcuts'), 'keyboard', 'shortcut', 'hotkey'],
|
||
ssh: ['ssh', t('ssh.connection'), t('ssh.pool'), 'timeout', 'reconnect'],
|
||
security: [t('nav.security'), t('security.credentials'), 'encrypt', 'known hosts'],
|
||
about: [t('nav.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, t])
|
||
|
||
// 如果搜索过滤后有结果,自动跳转到第一个匹配 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}`,
|
||
`Data: ${runtimeInfo.dataPath}`,
|
||
].join('\n')
|
||
: `OpsXShell v${appVersion}`
|
||
|
||
// ── 加载态 ────────────────────────────────────────────────
|
||
if (!isLoaded) {
|
||
return (
|
||
<div className="flex h-full items-center justify-center bg-dark-900">
|
||
<div className="text-txt-muted">{t('common:status.loading')}</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── 渲染内容区 ────────────────────────────────────────────
|
||
const renderContent = () => {
|
||
switch (activeTab) {
|
||
// ──────── 常规 ─────────
|
||
case 'general':
|
||
return (
|
||
<div className="space-y-6">
|
||
<SettingGroup title={t('general.layout')}>
|
||
<SettingRow label={t('general.sidebarWidth')} description={t('general.sidebarWidthDesc')}>
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
type="range"
|
||
min={180}
|
||
max={400}
|
||
step={8}
|
||
value={settings.sidebarWidth}
|
||
onChange={(e) => setSetting('sidebarWidth', Number(e.target.value))}
|
||
className="w-28 accent-blue-500"
|
||
aria-label={t('general.sidebarWidth')}
|
||
/>
|
||
<span className="w-12 text-right text-xs tabular-nums text-txt-muted">{settings.sidebarWidth}px</span>
|
||
</div>
|
||
</SettingRow>
|
||
<SettingRow label={t('general.collapseSidebar')} description={t('general.collapseSidebarDesc')}>
|
||
<button
|
||
type="button"
|
||
role="switch"
|
||
aria-checked={settings.sidebarCollapsed}
|
||
onClick={() => setSetting('sidebarCollapsed', !settings.sidebarCollapsed)}
|
||
className={`relative h-6 w-11 rounded-full transition-colors ${
|
||
settings.sidebarCollapsed ? 'bg-accent-blue' : 'bg-dark-600'
|
||
}`}
|
||
>
|
||
<span
|
||
className={`absolute top-0.5 left-0.5 h-5 w-5 rounded-full bg-white transition-transform ${
|
||
settings.sidebarCollapsed ? 'translate-x-5' : 'translate-x-0'
|
||
}`}
|
||
/>
|
||
</button>
|
||
</SettingRow>
|
||
</SettingGroup>
|
||
</div>
|
||
)
|
||
|
||
// ──────── 外观 ─────────
|
||
case 'appearance':
|
||
return (
|
||
<div className="space-y-6">
|
||
<SettingGroup title={t('appearance.themeGroup')}>
|
||
<SettingRow label={t('appearance.theme')} description={t('appearance.themeDesc')}>
|
||
<select
|
||
value={settings.theme}
|
||
onChange={(e) => setSetting('theme', e.target.value as 'dark' | 'light' | 'system')}
|
||
className="ui-select-pill"
|
||
>
|
||
<option value="dark">{t('appearance.dark')}</option>
|
||
<option value="light">{t('appearance.light')}</option>
|
||
<option value="system">{t('appearance.system')}</option>
|
||
</select>
|
||
</SettingRow>
|
||
<SettingRow label={t('language.label')} description={t('language.description')}>
|
||
<select
|
||
value={settings.language}
|
||
onChange={(e) => setSetting('language', e.target.value as AppLanguage)}
|
||
className="ui-select-pill"
|
||
>
|
||
<option value="zh-CN">{t('language.zhCN')}</option>
|
||
<option value="zh-TW">{t('language.zhTW')}</option>
|
||
<option value="en">{t('language.en')}</option>
|
||
<option value="ja">{t('language.ja')}</option>
|
||
<option value="ko">{t('language.ko')}</option>
|
||
<option value="vi">{t('language.vi')}</option>
|
||
<option value="ru">{t('language.ru')}</option>
|
||
</select>
|
||
</SettingRow>
|
||
</SettingGroup>
|
||
</div>
|
||
)
|
||
|
||
// ──────── 终端 ─────────
|
||
case 'terminal':
|
||
return (
|
||
<div className="space-y-6">
|
||
<SettingGroup title={t('terminal.fontGroup')}>
|
||
<SettingRow label={t('terminal.fontFamily')} description={t('terminal.fontFamilyDesc')}>
|
||
<select
|
||
value={settings.fontFamily}
|
||
onChange={(e) => setSetting('fontFamily', e.target.value)}
|
||
className="ui-select-pill max-w-[220px]"
|
||
>
|
||
<option>MesloLGS NF</option>
|
||
<option>JetBrainsMono Nerd Font</option>
|
||
<option>Hack Nerd Font</option>
|
||
<option>FiraCode Nerd Font</option>
|
||
<option>JetBrains Mono</option>
|
||
<option>Menlo</option>
|
||
<option>Fira Code</option>
|
||
<option>Monaco</option>
|
||
<option>Consolas</option>
|
||
</select>
|
||
</SettingRow>
|
||
<SettingRow label={t('terminal.fontSize')} description={t('terminal.fontSizeDesc')}>
|
||
<div className="flex items-center gap-1">
|
||
<button
|
||
type="button"
|
||
className="h-8 w-8 rounded-lg border border-dark-600 bg-dark-700 text-content-secondary hover:bg-dark-600"
|
||
onClick={() => setSetting('fontSize', Math.max(10, (settings.fontSize || 14) - 1))}
|
||
>
|
||
−
|
||
</button>
|
||
<span className="w-12 text-center text-sm tabular-nums text-content-primary">
|
||
{settings.fontSize || 14}px
|
||
</span>
|
||
<button
|
||
type="button"
|
||
className="h-8 w-8 rounded-lg border border-dark-600 bg-dark-700 text-content-secondary hover:bg-dark-600"
|
||
onClick={() => setSetting('fontSize', Math.min(28, (settings.fontSize || 14) + 1))}
|
||
>
|
||
+
|
||
</button>
|
||
</div>
|
||
</SettingRow>
|
||
<SettingRow label={t('terminal.scrollback')} description={t('terminal.scrollbackDesc')}>
|
||
<input
|
||
type="number"
|
||
min={1000}
|
||
max={100000}
|
||
step={1000}
|
||
value={settings.scrollback}
|
||
onChange={(e) => setSetting('scrollback', Math.max(1000, Number(e.target.value) || 10000))}
|
||
className="ui-input-sm w-24 text-center"
|
||
/>
|
||
</SettingRow>
|
||
<SettingToggle
|
||
label={t('terminal.keywordHighlight')}
|
||
description={t('terminal.keywordHighlightDesc')}
|
||
checked={settings.keywordHighlightEnabled}
|
||
onChange={(value) => setSetting('keywordHighlightEnabled', value)}
|
||
/>
|
||
</SettingGroup>
|
||
|
||
<SettingGroup title={t('terminal.colorTheme')}>
|
||
<div className="px-1 py-1">
|
||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||
{Object.entries(themes).map(([key, theme]) => (
|
||
<button
|
||
key={key}
|
||
type="button"
|
||
onClick={() => setSetting('terminalTheme', key as TerminalThemeId)}
|
||
className={`rounded-xl border p-2.5 text-left text-xs transition-colors ${
|
||
settings.terminalTheme === key
|
||
? 'bg-accent-blue/10 ring-accent-blue/30 border-accent-blue ring-1'
|
||
: 'border-dark-600/60 hover:border-dark-500'
|
||
}`}
|
||
>
|
||
<div
|
||
className="mb-1.5 flex h-7 w-full overflow-hidden rounded-md border border-black/20"
|
||
style={{ backgroundColor: theme.background }}
|
||
aria-hidden
|
||
>
|
||
<span className="h-full w-1/4" style={{ backgroundColor: theme.blue }} />
|
||
<span className="h-full w-1/4" style={{ backgroundColor: theme.cyan }} />
|
||
<span className="h-full w-1/4" style={{ backgroundColor: theme.green }} />
|
||
<span className="h-full w-1/4" style={{ backgroundColor: theme.magenta }} />
|
||
</div>
|
||
<span className="text-content-secondary">{theme.name}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</SettingGroup>
|
||
</div>
|
||
)
|
||
|
||
case 'ai':
|
||
return <AiProviderSettings />
|
||
|
||
// ──────── 快捷键 ───────
|
||
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="hover:bg-dark-700/50 flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs text-txt-muted transition-colors hover:text-accent-blue"
|
||
>
|
||
<RotateCcw className="h-3.5 w-3.5" />
|
||
{t('shortcuts.resetAll')}
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{categories.map((category) => {
|
||
const defs = getKeybindingsByCategory(category)
|
||
return (
|
||
<SettingCard key={category}>
|
||
<h3 className="mb-3 text-sm font-semibold text-content-secondary">{t(`shortcuts.category.${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 rounded-lg px-3 py-2 transition-colors ${
|
||
isRecordingThis ? 'bg-accent-blue/10 border-accent-blue/30 border' : 'hover:bg-dark-800/60'
|
||
}`}
|
||
>
|
||
<div className="min-w-0 flex-1">
|
||
<span className="text-sm text-content-primary">{t(def.labelKey)}</span>
|
||
{isCustom && (
|
||
<span className="bg-accent-blue/10 ml-2 rounded px-1.5 py-0.5 text-[10px] text-accent-blue">
|
||
{t('shortcuts.custom')}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2">
|
||
{isRecordingThis ? (
|
||
<div className="flex items-center gap-2">
|
||
<kbd className="bg-accent-blue/20 border-accent-blue/40 inline-flex animate-pulse items-center rounded border px-3 py-1 font-mono text-xs text-accent-blue">
|
||
{recordedKeys || t('shortcuts.pressKeys')}
|
||
</kbd>
|
||
<button
|
||
onClick={() => stopRecording()}
|
||
className="text-xs text-txt-muted hover:text-content-secondary"
|
||
>
|
||
{t('common:action.cancel')}
|
||
</button>
|
||
{recordedKeys && recordedConflict.length > 0 && (
|
||
<div className="flex items-center gap-1 text-xs text-yellow-500">
|
||
<AlertTriangle className="h-3 w-3" />
|
||
<span>{t('shortcuts.conflict', { label: t(recordedConflict[0].labelKey) })}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<>
|
||
<button
|
||
onClick={() => {
|
||
startRecording(def.id)
|
||
}}
|
||
className="group relative"
|
||
>
|
||
<kbd
|
||
className={`inline-flex items-center rounded px-2.5 py-1 font-mono text-xs transition-colors ${
|
||
isCustom
|
||
? 'bg-accent-blue/10 border-accent-blue/30 border text-accent-blue'
|
||
: 'border border-dark-600 bg-dark-700 text-content-secondary'
|
||
} group-hover:border-accent-blue/50`}
|
||
>
|
||
{currentKeys}
|
||
</kbd>
|
||
</button>
|
||
{isCustom && (
|
||
<button
|
||
onClick={() => keybindingState.resetKeybinding(def.id)}
|
||
className="text-txt-faint transition-colors hover:text-txt-muted"
|
||
title={t('shortcuts.resetDefault')}
|
||
>
|
||
<RotateCcw className="h-3 w-3" />
|
||
</button>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</SettingCard>
|
||
)
|
||
})}
|
||
|
||
{/* 录入确认弹层 */}
|
||
{recordingId && recordedKeys && (
|
||
<div className="fixed bottom-6 left-1/2 z-50 flex -translate-x-1/2 items-center gap-3 rounded-xl border border-dark-600 bg-dark-800 px-5 py-3 shadow-2xl">
|
||
<span className="text-sm text-content-secondary">
|
||
{t('shortcuts.setTo', { keys: recordedKeys })}
|
||
</span>
|
||
<button
|
||
onClick={async () => {
|
||
await keybindingState.setCustomKeybinding(recordingId, recordedKeys)
|
||
stopRecording()
|
||
}}
|
||
className="hover:bg-accent-blue/80 rounded-lg bg-accent-blue px-3 py-1 text-xs text-content-primary transition-colors"
|
||
>
|
||
{t('common:action.confirm')}
|
||
</button>
|
||
<button
|
||
onClick={stopRecording}
|
||
className="rounded-lg px-3 py-1 text-xs text-txt-muted transition-colors hover:bg-dark-700 hover:text-content-primary"
|
||
>
|
||
{t('common:action.cancel')}
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
<p className="text-xs text-txt-faint">{t('shortcuts.hint')}</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ──────── SSH ──────────
|
||
case 'ssh':
|
||
return (
|
||
<div className="space-y-6">
|
||
<SettingGroup title={t('ssh.connection')}>
|
||
<SettingToggle
|
||
label={t('ssh.keepAlive')}
|
||
description={t('ssh.keepAliveDesc')}
|
||
checked={settings.keepAlive}
|
||
onChange={(v) => setSetting('keepAlive', v)}
|
||
/>
|
||
<SettingToggle
|
||
label={t('ssh.autoReconnect')}
|
||
description={t('ssh.autoReconnectDesc')}
|
||
checked={settings.autoReconnect !== false}
|
||
onChange={(v) => setSetting('autoReconnect', v)}
|
||
/>
|
||
<SettingRow label={t('ssh.timeout')} description={t('ssh.timeoutDesc')}>
|
||
<input
|
||
type="number"
|
||
min={5}
|
||
max={120}
|
||
value={settings.timeout}
|
||
onChange={(e) => setSetting('timeout', parseInt(e.target.value) || 30)}
|
||
className="ui-input-sm w-20 text-center"
|
||
/>
|
||
</SettingRow>
|
||
<SettingRow label={t('ssh.maxReconnect')} description={t('ssh.maxReconnectDesc')}>
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
max={20}
|
||
value={settings.maxReconnectAttempts}
|
||
onChange={(e) => setSetting('maxReconnectAttempts', parseInt(e.target.value) || 5)}
|
||
className="ui-input-sm w-20 text-center"
|
||
/>
|
||
</SettingRow>
|
||
</SettingGroup>
|
||
|
||
<SettingGroup title={t('ssh.pool')}>
|
||
<SettingRow label={t('ssh.maxConnections')} description={t('ssh.maxConnectionsDesc')}>
|
||
<input
|
||
type="number"
|
||
min={2}
|
||
max={50}
|
||
value={settings.maxConnections}
|
||
onChange={(e) => setSetting('maxConnections', parseInt(e.target.value) || 10)}
|
||
className="ui-input-sm w-20 text-center"
|
||
/>
|
||
</SettingRow>
|
||
<SettingRow label={t('ssh.maxBackground')} description={t('ssh.maxBackgroundDesc')}>
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
max={30}
|
||
value={settings.maxBackgroundConnections}
|
||
onChange={(e) => setSetting('maxBackgroundConnections', parseInt(e.target.value) || 5)}
|
||
className="ui-input-sm w-20 text-center"
|
||
/>
|
||
</SettingRow>
|
||
<SettingRow label={t('ssh.suspend')} description={t('ssh.suspendDesc')}>
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
max={120}
|
||
value={Math.round(settings.idleTimeoutMs / 60000)}
|
||
onChange={(e) => setSetting('idleTimeoutMs', (parseInt(e.target.value) || 5) * 60000)}
|
||
className="ui-input-sm w-20 text-center"
|
||
/>
|
||
</SettingRow>
|
||
</SettingGroup>
|
||
</div>
|
||
)
|
||
|
||
// ──────── 安全 ─────────
|
||
case 'security':
|
||
return (
|
||
<div className="space-y-6">
|
||
<SettingGroup title={t('security.credentials')}>
|
||
<SettingRow label={t('security.sessionPassword')} description={t('security.sessionPasswordDesc')}>
|
||
<span className="border-accent-green/25 bg-accent-green/10 rounded-md border px-2 py-1 text-xs font-medium text-accent-green">
|
||
{t('security.enabled')}
|
||
</span>
|
||
</SettingRow>
|
||
<SettingRow label={t('security.masterKey')} description={t('security.masterKeyDesc')}>
|
||
<code className="text-xs text-txt-muted break-all">
|
||
{runtimeInfo?.credentialPath ?? '~/.opsxshell/master.key'}
|
||
</code>
|
||
</SettingRow>
|
||
</SettingGroup>
|
||
<KnownHostsSettingsCard />
|
||
</div>
|
||
)
|
||
|
||
// ──────── 关于 ─────────
|
||
case 'about':
|
||
return (
|
||
<div className="max-w-2xl space-y-5">
|
||
<SettingCard>
|
||
<div className="flex items-start gap-4 py-2">
|
||
<div className="border-dark-600/50 flex h-16 w-16 flex-shrink-0 items-center justify-center rounded-2xl border bg-dark-700">
|
||
<Terminal className="h-8 w-8 text-content-primary" />
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
<h3 className="text-lg font-semibold text-content-primary">OpsXShell</h3>
|
||
<p className="mt-0.5 text-sm text-txt-muted">{t('about.tagline')}</p>
|
||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||
<span className="bg-accent-blue/15 border-accent-blue/20 inline-flex items-center rounded-md border px-2.5 py-1 font-mono text-xs font-medium text-accent-blue">
|
||
{t('about.appVersion', { version: appVersion })}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => copyText('version', diagnosticText)}
|
||
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs text-txt-muted transition-colors hover:bg-dark-700 hover:text-content-primary"
|
||
title={t('about.copyVersion')}
|
||
>
|
||
{copiedField === 'version' ? (
|
||
<Check className="h-3.5 w-3.5 text-accent-green" />
|
||
) : (
|
||
<Copy className="h-3.5 w-3.5" />
|
||
)}
|
||
{copiedField === 'version' ? t('common:action.copied') : t('common:action.copy')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</SettingCard>
|
||
|
||
<SettingCard>
|
||
<SettingSectionTitle>{t('about.runtime')}</SettingSectionTitle>
|
||
<div className="divide-dark-700/30 mt-3 divide-y text-sm">
|
||
<div className="flex items-center justify-between gap-4 py-2">
|
||
<span className="text-txt-muted">{t('about.system')}</span>
|
||
<code className="text-right text-xs text-content-secondary">
|
||
{runtimeInfo ? `${runtimeInfo.os} ${runtimeInfo.arch}` : t('common:status.reading')}
|
||
</code>
|
||
</div>
|
||
</div>
|
||
</SettingCard>
|
||
|
||
<SettingCard>
|
||
<SettingSectionTitle>{t('about.localData')}</SettingSectionTitle>
|
||
<div className="mt-3 space-y-3 text-xs">
|
||
<div>
|
||
<div className="mb-1 text-txt-muted">{t('about.sessionsSettings')}</div>
|
||
<code className="block break-all text-content-secondary">{runtimeInfo?.dataPath ?? t('common:status.reading')}</code>
|
||
</div>
|
||
<div>
|
||
<div className="mb-1 text-txt-muted">{t('about.masterKeyPath')}</div>
|
||
<code className="block break-all text-content-secondary">
|
||
{runtimeInfo?.credentialPath ?? '~/.opsxshell/master.key'}
|
||
</code>
|
||
</div>
|
||
<p className="leading-relaxed text-txt-faint">{t('about.privacyNote')}</p>
|
||
</div>
|
||
</SettingCard>
|
||
|
||
<p className="px-1 text-[11px] text-txt-faint">{t('about.license')}</p>
|
||
</div>
|
||
)
|
||
|
||
default:
|
||
return null
|
||
}
|
||
}
|
||
|
||
// ── 主布局 ────────────────────────────────────────────────
|
||
return (
|
||
<div className="flex h-full bg-dark-900">
|
||
{/* ── 左侧导航 ── */}
|
||
<aside className="bg-dark-800/80 border-dark-700/40 flex w-60 flex-shrink-0 flex-col border-r">
|
||
<div className="px-4 pb-3 pt-5">
|
||
<div className="mb-3 text-[11px] font-semibold uppercase tracking-wider text-txt-faint">{t('title')}</div>
|
||
<div className="relative">
|
||
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-txt-muted" />
|
||
<input
|
||
type="text"
|
||
placeholder={t('search.placeholder')}
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
className="ui-input-sm !pl-8 !pr-8"
|
||
/>
|
||
{searchQuery && (
|
||
<button
|
||
onClick={() => setSearchQuery('')}
|
||
className="absolute right-2 top-1/2 -translate-y-1/2 text-txt-muted hover:text-content-secondary"
|
||
>
|
||
<X className="h-3.5 w-3.5" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<nav className="flex-1 space-y-0.5 overflow-y-auto px-2 pb-3">
|
||
{visibleTabs.map((tab) => {
|
||
const Icon = tab.icon
|
||
const active = activeTab === tab.id
|
||
return (
|
||
<button
|
||
key={tab.id}
|
||
data-testid={`settings-tab-${tab.id}`}
|
||
onClick={() => setActiveTab(tab.id)}
|
||
className={`ui-nav-item ${active ? 'ui-nav-item-active' : ''} ${tab.id === 'ai' && !active ? 'text-accent-blue' : ''}`}
|
||
>
|
||
<Icon className={`h-4 w-4 flex-shrink-0 ${active || tab.id === 'ai' ? 'text-accent-blue' : ''}`} />
|
||
<div className="min-w-0 flex-1">
|
||
<div className="truncate text-sm font-medium leading-5">{t(tab.labelKey)}</div>
|
||
<div className={`truncate text-[11px] leading-4 ${active ? 'text-txt-muted' : 'text-txt-faint'}`}>
|
||
{t(tab.descriptionKey)}
|
||
</div>
|
||
</div>
|
||
</button>
|
||
)
|
||
})}
|
||
</nav>
|
||
</aside>
|
||
|
||
{/* ── 右侧内容区 ── */}
|
||
<div className="to-dark-900/95 flex min-w-0 flex-1 flex-col bg-gradient-to-b from-dark-900">
|
||
<div className="border-dark-700/30 flex-shrink-0 border-b px-8 pb-4 pt-6">
|
||
<h1 className="text-xl font-semibold tracking-tight text-content-primary">{t(currentMeta.labelKey)}</h1>
|
||
<p className="mt-1 text-sm text-txt-muted">{t(currentMeta.descriptionKey)}</p>
|
||
</div>
|
||
|
||
<div className="max-w-3xl flex-1 overflow-y-auto px-8 py-6">{renderContent()}</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── 子组件 ─────────────────────────────────────────────────
|
||
|
||
function KnownHostsSettingsCard() {
|
||
const { t } = useTranslation(['settings', 'common'])
|
||
const [entries, setEntries] = useState<Array<{ host: string; port: number; keyType: string; fingerprint: string }>>(
|
||
[]
|
||
)
|
||
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(t('security.clearConfirm'))) return
|
||
await window.opsxApi?.knownHostsClear?.()
|
||
await load()
|
||
}
|
||
|
||
return (
|
||
<SettingGroup title={t('security.knownHosts')}>
|
||
<div className="flex items-center justify-between gap-2 px-4 py-3">
|
||
<p className="flex-1 text-xs text-txt-muted">{t('security.knownHostsHint')}</p>
|
||
<div className="flex flex-shrink-0 gap-1.5">
|
||
<button
|
||
type="button"
|
||
onClick={() => void load()}
|
||
className="rounded-lg bg-dark-700 px-2 py-1 text-[11px] text-txt-muted hover:bg-dark-600"
|
||
>
|
||
{t('common:action.refresh')}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => void handleClear()}
|
||
className="rounded-lg bg-dark-700 px-2 py-1 text-[11px] text-red-300 hover:bg-red-900/40"
|
||
disabled={entries.length === 0}
|
||
>
|
||
{t('security.clearAll')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{loading ? (
|
||
<div className="px-4 pb-3 text-xs text-txt-muted">{t('common:status.loading')}</div>
|
||
) : entries.length === 0 ? (
|
||
<div className="px-4 pb-3 text-xs text-txt-faint">{t('security.empty')}</div>
|
||
) : (
|
||
<div className="divide-dark-700/30 max-h-48 divide-y overflow-y-auto">
|
||
{entries.map((e) => (
|
||
<div key={`${e.host}:${e.port}`} className="flex items-start justify-between gap-2 px-4 py-2.5">
|
||
<div className="min-w-0 font-mono text-[11px]">
|
||
<div className="truncate text-content-primary">
|
||
{e.host}:{e.port} <span className="text-txt-faint">({e.keyType})</span>
|
||
</div>
|
||
<div className="mt-0.5 break-all text-txt-muted opacity-80">SHA256:{e.fingerprint}</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => void handleRemove(e.host, e.port)}
|
||
title={t('security.removeHost')}
|
||
aria-label={t('security.removeHost')}
|
||
className="flex-shrink-0 rounded-full bg-red-500/10 px-2 py-1 text-[11px] text-red-300 hover:bg-red-500/20 hover:text-red-200"
|
||
>
|
||
{t('common:action.delete')}
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</SettingGroup>
|
||
)
|
||
}
|
||
|
||
/** 分组卡片:多行设置 */
|
||
function SettingGroup({ title, children }: { title?: string; children: React.ReactNode }) {
|
||
return (
|
||
<div>
|
||
{title && <h3 className="mb-2 px-0.5 text-sm font-medium text-content-secondary">{title}</h3>}
|
||
<div className="border-dark-700/45 bg-dark-800/50 divide-dark-700/35 divide-y overflow-hidden rounded-2xl border">
|
||
{children}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** 卡片容器(快捷键等复用) */
|
||
function SettingCard({ children, highlight = false }: { children: React.ReactNode; highlight?: boolean }) {
|
||
return (
|
||
<div
|
||
className={`rounded-2xl p-4 transition-colors ${
|
||
highlight ? 'bg-accent-blue/5 border-accent-blue/25 border' : 'bg-dark-800/50 border-dark-700/45 border'
|
||
}`}
|
||
>
|
||
{children}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** 区域小标题 */
|
||
function SettingSectionTitle({ children, className = '' }: { children: React.ReactNode; className?: string }) {
|
||
return <div className={`mb-1 text-sm font-semibold text-content-secondary ${className}`}>{children}</div>
|
||
}
|
||
|
||
/** 左文右控行 */
|
||
function SettingRow({
|
||
label,
|
||
description,
|
||
children,
|
||
}: {
|
||
label: string
|
||
description?: string
|
||
children: React.ReactNode
|
||
}) {
|
||
return (
|
||
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 px-4 py-3.5">
|
||
<div className="min-w-[11rem] flex-1 basis-[14rem]">
|
||
<div className="text-sm font-medium text-content-primary">{label}</div>
|
||
{description && <div className="mt-0.5 text-xs leading-relaxed text-txt-muted">{description}</div>}
|
||
</div>
|
||
<div className="ml-auto flex-shrink-0">{children}</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** 开关设置项(行内) */
|
||
function SettingToggle({
|
||
label,
|
||
description,
|
||
checked,
|
||
onChange,
|
||
}: {
|
||
label: string
|
||
description: string
|
||
checked: boolean
|
||
onChange: (value: boolean) => void
|
||
}) {
|
||
return (
|
||
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 px-4 py-3.5">
|
||
<div className="min-w-[11rem] flex-1 basis-[14rem]">
|
||
<div className="text-sm font-medium text-content-primary">{label}</div>
|
||
<div className="mt-0.5 text-xs leading-relaxed text-txt-muted">{description}</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
role="switch"
|
||
aria-checked={checked}
|
||
onClick={() => onChange(!checked)}
|
||
className={`relative ml-auto h-6 w-11 flex-shrink-0 rounded-full transition-colors ${
|
||
checked ? 'bg-accent-blue' : 'bg-dark-600'
|
||
}`}
|
||
>
|
||
<span
|
||
className={`absolute top-1 h-4 w-4 rounded-full bg-white shadow transition-transform ${
|
||
checked ? 'left-6' : 'left-1'
|
||
}`}
|
||
/>
|
||
</button>
|
||
</div>
|
||
)
|
||
}
|