Files
opsxshell/src/components/settings/SettingsView.tsx
T

888 lines
37 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 { useSettingsStore } from '../../stores/settingsStore'
import { useKeybindingStore } from '../../stores/keybindingStore'
import {
Monitor,
Palette,
Terminal,
Keyboard,
Shield,
Database,
Info,
Search,
X,
RotateCcw,
AlertTriangle,
Copy,
Check,
Sparkles,
} from 'lucide-react'
import AiProviderSettings from './AiProviderSettings'
import { themes } 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
label: string
description: 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', label: '工作区', description: '布局与资源', icon: Monitor },
{ id: 'appearance', label: '外观', description: '主题与终端配色', icon: Palette },
{ id: 'terminal', label: '终端', description: '字体与配色', icon: Terminal },
{ id: 'ai', label: '大模型', description: 'Provider、模型与测试', icon: Sparkles },
{ 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 [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 // null 表示不过滤,正常显示
const q = searchQuery.toLowerCase()
const keywords: Record<SettingsTab, string[]> = {
general: ['工作区', '侧栏', '宽度', '资源', 'workspace', 'sidebar'],
appearance: ['主题', '深色', '浅色', 'theme'],
terminal: ['字体', 'font', '配色', 'terminal', 'monokai', 'dracula'],
ai: ['ai', '大模型', 'provider', '模型', 'deepseek', 'glm', 'minimax', 'new-api'],
shortcuts: ['快捷键', '键盘', 'keyboard', 'shortcut', 'hotkey'],
ssh: ['ssh', '连接', '端口', '超时', '心跳', '连接池', '重连', '快照'],
security: ['加密', '安全', '密码', '主密钥', 'known hosts', 'encrypt'],
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
const appVersion = runtimeInfo?.appVersion ?? '—'
const diagnosticText = runtimeInfo
? [
`OpsXShell v${runtimeInfo.appVersion}`,
`${runtimeInfo.os} ${runtimeInfo.arch}`,
runtimeInfo.nativeBackend,
runtimeInfo.sshBackend,
`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">加载中...</div>
</div>
)
}
// ── 渲染内容区 ────────────────────────────────────────────
const renderContent = () => {
switch (activeTab) {
// ──────── 常规 ─────────
case 'general':
return (
<div className="space-y-6">
<SettingGroup title="工作区布局">
<SettingRow label="侧栏宽度" description="会话侧栏的默认宽度">
<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="侧栏宽度"
/>
<span className="w-12 text-right text-xs tabular-nums text-txt-muted">{settings.sidebarWidth}px</span>
</div>
</SettingRow>
</SettingGroup>
</div>
)
// ──────── 外观 ─────────
case 'appearance':
return (
<div className="space-y-6">
<SettingGroup title="外观">
<SettingRow label="主题" description="应用界面配色">
<select
value={settings.theme}
onChange={(e) => setSetting('theme', e.target.value as 'dark' | 'light' | 'system')}
className="ui-select-pill"
>
<option value="dark">深色</option>
<option value="light">浅色</option>
<option value="system">跟随系统</option>
</select>
</SettingRow>
</SettingGroup>
</div>
)
// ──────── 终端 ─────────
case 'terminal':
return (
<div className="space-y-6">
<SettingGroup title="字体">
<SettingRow label="字体" description="需 Nerd Font 才能正确显示 Zsh 提示符图标">
<select
value={settings.fontFamily}
onChange={(e) => setSetting('fontFamily', e.target.value)}
className="ui-select-pill"
>
<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="字号" description="终端文字大小">
<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="回滚缓冲" description="每个终端保留的历史行数">
<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="关键词高亮"
description="突出显示错误、警告、IP 地址和成功状态"
checked={settings.keywordHighlightEnabled}
onChange={(value) => setSetting('keywordHighlightEnabled', value)}
/>
</SettingGroup>
<SettingGroup title="配色">
<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 'dracula' | 'monokai' | 'solarized' | 'one-dark')
}
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 h-7 w-full rounded-md border border-black/20"
style={{ backgroundColor: theme.background }}
/>
<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" />
重置全部为默认
</button>
</div>
)}
{categories.map((category) => {
const defs = getKeybindingsByCategory(category)
return (
<SettingCard key={category}>
<h3 className="mb-3 text-sm font-semibold text-content-secondary">{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">{def.label}</span>
{isCustom && (
<span className="bg-accent-blue/10 ml-2 rounded px-1.5 py-0.5 text-[10px] text-accent-blue">
自定义
</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 || '按下快捷键...'}
</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="h-3 w-3" />
<span>与「{recordedConflict[0].label}」冲突</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="重置为默认"
>
<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">
设置为{' '}
<kbd className="rounded bg-dark-700 px-1.5 py-0.5 font-mono text-xs text-accent-blue">
{recordedKeys}
</kbd>
</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"
>
确认
</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"
>
取消
</button>
</div>
)}
<p className="text-xs text-txt-faint">点击快捷键可重新绑定 · 按 Esc 取消录入</p>
</div>
)
}
// ──────── SSH ──────────
case 'ssh':
return (
<div className="space-y-6">
<SettingGroup title="连接">
<SettingToggle
label="保持心跳"
description="空闲时发送心跳,减少断线"
checked={settings.keepAlive}
onChange={(v) => setSetting('keepAlive', v)}
/>
<SettingToggle
label="自动重连"
description="意外断开后自动尝试重连"
checked={settings.autoReconnect !== false}
onChange={(v) => setSetting('autoReconnect', v)}
/>
<SettingRow label="连接超时" description="建立 SSH 连接的最长等待时间(秒)">
<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="自动重连次数" description="意外断线后的最大尝试次数">
<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="连接池">
<SettingRow label="最大连接数" description="SSH、SFTP 与批量连接共享此上限">
<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="后台连接上限" description="超过上限时优先回收最久未使用的连接">
<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="后台挂起" description="标签进入后台后多少分钟释放活跃通道">
<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="凭据保护">
<SettingRow label="会话密码与密钥口令" description="保存前由 Rust 使用 AES-256-GCM 加密,无法关闭">
<span className="border-accent-green/25 bg-accent-green/10 rounded-md border px-2 py-1 text-xs font-medium text-accent-green">
已启用
</span>
</SettingRow>
<SettingRow label="本地主密钥" description="仅当前系统用户可读取">
<code className="text-xs text-txt-muted">~/.opsxshsell/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">轻量原生的 SSH / SFTP 运维终端</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">
应用 v{appVersion}
</span>
<span className="border-dark-600/50 inline-flex items-center rounded-md border bg-dark-700 px-2.5 py-1 font-mono text-xs text-txt-muted">
Tauri 2 · Rust
</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="复制版本信息"
>
{copiedField === 'version' ? (
<Check className="h-3.5 w-3.5 text-accent-green" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
{copiedField === 'version' ? '已复制' : '复制'}
</button>
</div>
</div>
</div>
</SettingCard>
<SettingCard>
<SettingSectionTitle>运行环境</SettingSectionTitle>
<div className="divide-dark-700/30 mt-3 divide-y text-sm">
{[
['系统', runtimeInfo ? `${runtimeInfo.os} ${runtimeInfo.arch}` : '读取中...'],
['原生运行时', runtimeInfo?.nativeBackend ?? '读取中...'],
['SSH 后端', runtimeInfo?.sshBackend ?? '读取中...'],
].map(([label, value]) => (
<div key={label} className="flex items-center justify-between gap-4 py-2">
<span className="text-txt-muted">{label}</span>
<code className="text-right text-xs text-content-secondary">{value}</code>
</div>
))}
</div>
</SettingCard>
<SettingCard>
<SettingSectionTitle>本地数据</SettingSectionTitle>
<div className="mt-3 space-y-3 text-xs">
<div>
<div className="mb-1 text-txt-muted">会话与设置</div>
<code className="block break-all text-content-secondary">{runtimeInfo?.dataPath ?? '读取中...'}</code>
</div>
<div>
<div className="mb-1 text-txt-muted">加密主密钥</div>
<code className="block break-all text-content-secondary">
{runtimeInfo?.credentialPath ?? '~/.opsxshsell/master.key'}
</code>
</div>
<p className="leading-relaxed text-txt-faint">
密码与密钥口令由 Rust 使用 AES-256-GCM 加密。OpsXShell 不保存终端命令历史。
</p>
</div>
</SettingCard>
<p className="px-1 text-[11px] text-txt-faint">MIT License · Tauri 2 · Rust · React · XTerm.js</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">设置</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="搜索设置..."
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">
<div className="truncate text-sm font-medium">{tab.label}</div>
<div className={`truncate text-[11px] ${active ? 'text-txt-muted' : 'text-txt-faint'}`}>
{tab.description}
</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">{currentMeta.label}</h1>
<p className="mt-1 text-sm text-txt-muted">{currentMeta.description}</p>
</div>
<div className="max-w-3xl flex-1 overflow-y-auto px-8 py-6">{renderContent()}</div>
</div>
</div>
)
}
// ─── 子组件 ─────────────────────────────────────────────────
function KnownHostsSettingsCard() {
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('确定清空全部已知主机密钥?下次连接将重新确认。')) return
await window.opsxApi?.knownHostsClear?.()
await load()
}
return (
<SettingGroup title="已知主机">
<div className="flex items-center justify-between gap-2 px-4 py-3">
<p className="flex-1 text-xs text-txt-muted">首次连接会确认主机指纹。此处可管理已信任记录。</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"
>
刷新
</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}
>
清空
</button>
</div>
</div>
{loading ? (
<div className="px-4 pb-3 text-xs text-txt-muted">加载中...</div>
) : entries.length === 0 ? (
<div className="px-4 pb-3 text-xs text-txt-faint">暂无记录</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)}
className="flex-shrink-0 px-1 text-[11px] text-red-300 hover:text-red-200"
>
删除
</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 items-center justify-between gap-4 px-4 py-3.5">
<div className="mr-2 min-w-0 flex-1">
<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="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 items-center justify-between gap-4 px-4 py-3.5">
<div className="mr-2 min-w-0 flex-1">
<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 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>
)
}