Files
opsxshell/src/components/settings/SettingsView.tsx
T
Logan.Li b0d0e3a5f3 feat(i18n): add multi-language support (ja/ko/vi/ru/zh-TW) with system auto-detection
- 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
2026-08-12 00:07:48 +08:00

918 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}