feat: release OpsXShell 2.1.0 native Tauri edition

This commit is contained in:
Logan.Li committed 2026-07-30 23:32:51 +08:00
1 parent e9a0d21758
commit a3dba0c6ed
311 files changed
+31748 -14897

No files matched your search

@@ -0,0 +1,391 @@
import { useEffect, useMemo, useState } from 'react'
import { Check, CheckCircle2, ChevronDown, KeyRound, Loader2, Plus, Save, Trash2, Wifi } from 'lucide-react'
import type { AiModelConfig, AiProviderConfig } from '../../../shared/types/ai.types'
import {
AI_PROVIDER_PRESETS,
createCustomProvider,
createProviderFromPreset,
} from '../../ai/providerPresets'
import { useAiProviderStore } from '../../stores/aiProviderStore'
type TestState = 'idle' | 'testing' | 'success' | 'error'
function selectedModel(provider: AiProviderConfig | null): AiModelConfig | null {
if (!provider) return null
return provider.models.find((model) => model.id === provider.defaultModelId) || null
}
function connectionSnapshot(provider: AiProviderConfig, apiKey: string): string {
const model = selectedModel(provider)
return JSON.stringify({
id: provider.id,
name: provider.name.trim(),
protocol: provider.protocol,
authStyle: provider.authStyle,
baseUrl: provider.baseUrl.trim(),
allowInsecureHttp: provider.allowInsecureHttp,
model,
credential: apiKey.trim() ? `entered:${apiKey.trim()}` : provider.hasApiKey ? 'stored' : 'missing',
})
}
function validDraft(provider: AiProviderConfig | null, apiKey: string): boolean {
if (!provider?.name.trim() || !provider.baseUrl.trim() || !selectedModel(provider)?.model.trim()) return false
return provider.authStyle === 'none' || provider.hasApiKey === true || apiKey.trim().length > 0
}
export default function AiProviderSettings() {
const store = useAiProviderStore()
const [selectedId, setSelectedId] = useState('')
const [presetId, setPresetId] = useState('deepseek')
const [draft, setDraft] = useState<AiProviderConfig | null>(null)
const [apiKey, setApiKey] = useState('')
const [manualModel, setManualModel] = useState(false)
const [advancedOpen, setAdvancedOpen] = useState(false)
const [testState, setTestState] = useState<TestState>('idle')
const [verifiedSnapshot, setVerifiedSnapshot] = useState<string | null>(null)
const [message, setMessage] = useState('')
const [testAnswer, setTestAnswer] = useState('')
const [saving, setSaving] = useState(false)
useEffect(() => {
void store.load()
}, []) // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!selectedId && store.providers[0]) setSelectedId(store.providers[0].id)
}, [selectedId, store.providers])
useEffect(() => {
const provider = store.providers.find((item) => item.id === selectedId)
if (!provider) return
setDraft(structuredClone(provider))
setApiKey('')
setManualModel(provider.models.length === 0)
setTestState('idle')
setVerifiedSnapshot(null)
setMessage('')
setTestAnswer('')
}, [selectedId, store.providers])
const currentSnapshot = useMemo(() => (draft ? connectionSnapshot(draft, apiKey) : ''), [apiKey, draft])
const verified = !!currentSnapshot && verifiedSnapshot === currentSnapshot && testState === 'success'
const currentModel = selectedModel(draft)
const isStored = !!draft && store.providers.some((provider) => provider.id === draft.id)
const canTest = validDraft(draft, apiKey) && testState !== 'testing'
const chooseDraft = (provider: AiProviderConfig) => {
setSelectedId(provider.id)
setDraft(provider)
setApiKey('')
setManualModel(provider.models.length === 0)
setAdvancedOpen(false)
setTestState('idle')
setVerifiedSnapshot(null)
setMessage('请填写配置并测试连接;测试通过前不会保存任何内容')
setTestAnswer('')
}
const addPreset = () => chooseDraft(createProviderFromPreset(presetId))
const addCustom = () => chooseDraft(createCustomProvider())
const updateDraft = (changes: Partial<AiProviderConfig>) => {
if (!draft) return
setDraft({ ...draft, ...changes })
if (testState !== 'idle') setMessage('连接字段已变更,请重新测试')
}
const chooseModel = (modelId: string) => {
if (!draft) return
if (modelId === '__manual') {
setManualModel(true)
return
}
setManualModel(false)
updateDraft({ defaultModelId: modelId })
}
const updateManualModel = (modelId: string) => {
if (!draft) return
const active = selectedModel(draft)
if (active) {
const models = draft.models.map((model) =>
model.id === active.id ? { ...model, model: modelId, label: modelId || '自定义模型' } : model
)
updateDraft({ models })
return
}
const id = `manual-${Date.now().toString(36)}`
updateDraft({
models: [{ id, model: modelId, label: modelId || '自定义模型', capability: 'chat', billing: 'payg' }],
defaultModelId: id,
})
}
const testConnection = async () => {
if (!draft || !currentModel || !canTest) return
setTestState('testing')
setVerifiedSnapshot(null)
setMessage('正在发送最小请求验证 Endpoint、模型和凭据...')
setTestAnswer('')
const snapshot = currentSnapshot
try {
const result = await window.opsxApi.aiTestProvider({
provider: draft,
model: currentModel,
apiKey: apiKey.trim() || undefined,
})
if (!result.success) throw new Error(result.error || '连接测试失败')
setTestState('success')
setVerifiedSnapshot(snapshot)
setTestAnswer(result.answer?.trim() || '')
setMessage(`测试成功 · HTTP ${result.status ?? 200} · ${result.model || currentModel.model} · ${result.latencyMs ?? '-'} ms`)
} catch (error) {
setTestState('error')
setMessage(error instanceof Error ? error.message : String(error))
}
}
const save = async () => {
if (!draft || !verified) return
setSaving(true)
try {
await store.saveProvider(draft, apiKey)
if (!store.activeProviderId || !isStored) await store.setActiveProvider(draft.id)
setApiKey('')
setMessage('配置已安全保存,并可用于终端助手')
} catch (error) {
setMessage(error instanceof Error ? error.message : String(error))
setTestState('error')
} finally {
setSaving(false)
}
}
const remove = async () => {
if (!draft) return
if (isStored) await store.deleteProvider(draft.id)
setDraft(null)
setSelectedId('')
setApiKey('')
setVerifiedSnapshot(null)
setMessage('')
setTestAnswer('')
}
return (
<div className="space-y-4">
<section className="border-dark-700/50 bg-dark-800/45 rounded-xl border p-4">
<div className="flex flex-wrap items-center gap-2">
<select
aria-label="供应商预设"
value={presetId}
onChange={(event) => setPresetId(event.target.value)}
className="ui-input-sm min-w-52 flex-1"
>
{AI_PROVIDER_PRESETS.map((preset) => (
<option key={preset.id} value={preset.id}>
{preset.name} · {preset.description}
</option>
))}
</select>
<button type="button" onClick={addPreset} className="ui-btn-primary flex items-center gap-1.5">
<Plus className="h-3.5 w-3.5" /> 添加
</button>
<button type="button" onClick={addCustom} className="ui-btn-secondary">
自定义
</button>
</div>
<p className="mt-2 text-xs text-txt-muted">
新配置只在当前表单中暂存。真实请求测试成功后才可保存;API Key 仅由 Rust 加密存储。
</p>
</section>
<div className="border-dark-700/50 bg-dark-800/45 grid min-h-[440px] grid-cols-[180px_minmax(0,1fr)] overflow-hidden rounded-xl border">
<aside className="border-dark-700/50 border-r p-2">
<div className="px-2 pb-2 text-[11px] font-medium text-txt-faint">已配置</div>
{store.providers.length === 0 ? (
<p className="px-2 py-4 text-xs leading-relaxed text-txt-faint">尚无配置,从上方选择供应商添加。</p>
) : (
store.providers.map((provider) => (
<button
key={provider.id}
type="button"
onClick={() => setSelectedId(provider.id)}
className={`mb-1 w-full rounded-lg px-3 py-2 text-left ${selectedId === provider.id ? 'bg-accent-blue/15 text-accent-blue' : 'text-content-secondary hover:bg-dark-700/60'}`}
>
<div className="flex items-center gap-1.5">
{store.activeProviderId === provider.id && <Check className="h-3 w-3 flex-shrink-0" />}
<span className="truncate text-sm font-medium">{provider.name}</span>
</div>
<div className={`mt-0.5 truncate text-[10px] ${provider.hasApiKey || provider.authStyle === 'none' ? 'text-emerald-400' : 'text-amber-400'}`}>
{provider.models.find((model) => model.id === provider.defaultModelId)?.label || '未选择模型'}
</div>
</button>
))
)}
</aside>
<main className="min-w-0 p-5">
{!draft ? (
<div className="flex h-full items-center justify-center text-sm text-txt-faint">选择已有配置,或添加新供应商</div>
) : (
<div className="mx-auto max-w-2xl space-y-4">
<div className="grid grid-cols-2 gap-3">
<Field label="供应商">
<input value={draft.name} onChange={(event) => updateDraft({ name: event.target.value })} className="ui-input-sm" />
</Field>
<Field label="模型">
{manualModel || draft.models.length === 0 ? (
<input
aria-label="模型"
value={currentModel?.model || ''}
onChange={(event) => updateManualModel(event.target.value)}
placeholder="例如 deepseek-chat"
className="ui-input-sm font-mono"
/>
) : (
<select value={draft.defaultModelId} onChange={(event) => chooseModel(event.target.value)} className="ui-input-sm">
{draft.models.map((model) => (
<option key={model.id} value={model.id}>{model.label || model.model}</option>
))}
<option value="__manual">手动输入模型...</option>
</select>
)}
</Field>
</div>
{manualModel && draft.models.length > 0 && (
<button type="button" className="text-xs text-accent-blue hover:underline" onClick={() => setManualModel(false)}>
返回预设模型列表
</button>
)}
<Field label="Endpoint">
<input
value={draft.baseUrl}
onChange={(event) => updateDraft({ baseUrl: event.target.value })}
placeholder="https://api.example.com/v1"
className="ui-input-sm font-mono"
/>
</Field>
{currentModel?.baseUrl && (
<p className="-mt-2 text-[10px] text-txt-faint">当前模型使用专用 Endpoint:{currentModel.baseUrl}</p>
)}
<Field label={draft.authStyle === 'none' ? 'API Key(无需填写)' : 'API Key'}>
<div className="relative">
<KeyRound className="absolute left-2.5 top-2 h-3.5 w-3.5 text-txt-faint" />
<input
type="password"
autoComplete="off"
value={apiKey}
disabled={draft.authStyle === 'none'}
onChange={(event) => setApiKey(event.target.value)}
placeholder={draft.hasApiKey ? '已保存;留空使用现有密钥' : draft.authStyle === 'none' ? '本地服务无需认证' : 'sk-...'}
className="ui-input-sm !pl-8"
/>
</div>
</Field>
<button
type="button"
onClick={() => setAdvancedOpen((open) => !open)}
className="flex items-center gap-1 text-xs text-txt-muted hover:text-content-secondary"
>
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${advancedOpen ? 'rotate-180' : ''}`} />
高级设置
</button>
{advancedOpen && (
<div className="border-dark-700/40 bg-dark-900/30 grid grid-cols-2 gap-3 rounded-lg border p-3">
<Field label="接口协议">
<select
value={draft.protocol}
onChange={(event) => updateDraft({ protocol: event.target.value as AiProviderConfig['protocol'] })}
className="ui-input-sm"
>
<option value="openai-chat">OpenAI Chat</option>
<option value="openai-responses">OpenAI Responses</option>
<option value="anthropic">Anthropic Messages</option>
</select>
</Field>
<Field label="认证方式">
<select
value={draft.authStyle}
onChange={(event) => updateDraft({ authStyle: event.target.value as AiProviderConfig['authStyle'] })}
className="ui-input-sm"
>
<option value="bearer">Bearer Token</option>
<option value="x-api-key">x-api-key</option>
<option value="both">Bearer + x-api-key</option>
<option value="none">无需认证</option>
</select>
</Field>
<label className="col-span-2 flex items-start gap-2 text-xs text-txt-muted">
<input
type="checkbox"
checked={draft.allowInsecureHttp}
onChange={(event) => updateDraft({ allowInsecureHttp: event.target.checked })}
className="mt-0.5"
/>
允许 HTTP(只用于可信本机或内网模型服务)
</label>
</div>
)}
<div className="border-dark-700/40 flex flex-wrap items-center gap-2 border-t pt-4">
<button
type="button"
onClick={() => void testConnection()}
disabled={!canTest}
className="ui-btn-secondary flex items-center gap-1.5"
>
{testState === 'testing' ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Wifi className="h-3.5 w-3.5" />}
{testState === 'testing' ? '测试中...' : '测试连接'}
</button>
<button
type="button"
onClick={() => void save()}
disabled={!verified || saving}
className="ui-btn-primary flex items-center gap-1.5"
>
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />}
保存
</button>
{isStored && store.activeProviderId !== draft.id && (
<button type="button" onClick={() => void store.setActiveProvider(draft.id)} className="ui-btn-secondary">
设为当前
</button>
)}
<div className="flex-1" />
<button type="button" title="删除配置" onClick={() => void remove()} className="ui-btn-secondary text-red-300">
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
<div className={`flex min-h-5 items-center gap-1.5 text-xs ${verified ? 'text-emerald-400' : testState === 'error' ? 'text-red-300' : 'text-txt-muted'}`} role={testState === 'error' ? 'alert' : 'status'}>
{verified && <CheckCircle2 className="h-3.5 w-3.5" />}
<span>{message || '修改连接字段后需要重新测试'}</span>
</div>
{verified && testAnswer && (
<div className="border-emerald-500/20 bg-emerald-500/5 rounded-lg border px-3 py-2 text-xs text-content-secondary">
<span className="text-txt-faint">模型回答:</span>{testAnswer}
</div>
)}
</div>
)}
</main>
</div>
</div>
)
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label>
<span className="mb-1 block text-[11px] text-txt-muted">{label}</span>
{children}
</label>
)
}
+886
View File
@@ -0,0 +1,886 @@
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}
onClick={() => setActiveTab(tab.id)}
className={`ui-nav-item ${active ? 'ui-nav-item-active' : ''}`}
>
<Icon className={`h-4 w-4 flex-shrink-0 ${active ? '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>
)
}
+4
View File
@@ -0,0 +1,4 @@
/**
* Settings — 设置模块
*/
export { default as SettingsView } from './SettingsView'