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
This commit is contained in:
1 parent
fea6d8db04
commit
b0d0e3a5f3
301 files changed
+28002
-2705
No files matched your search
@@ -1,16 +1,19 @@
|
||||
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 { useTranslation } from 'react-i18next'
|
||||
import { Check, CheckCircle2, ChevronDown, Loader2, Plus, Save, Trash2, Wifi } from 'lucide-react'
|
||||
import type { AiBillingMode, AiProviderConfig } from '../../../shared/types/ai.types'
|
||||
import {
|
||||
AI_PROVIDER_PRESETS,
|
||||
AI_PROVIDER_FAMILY_ORDER,
|
||||
createCustomProvider,
|
||||
createProviderFromPreset,
|
||||
presetsByFamily,
|
||||
resolveProviderFamily,
|
||||
} from '../../ai/providerPresets'
|
||||
import { useAiProviderStore } from '../../stores/aiProviderStore'
|
||||
|
||||
type TestState = 'idle' | 'testing' | 'success' | 'error'
|
||||
|
||||
function selectedModel(provider: AiProviderConfig | null): AiModelConfig | null {
|
||||
function selectedModel(provider: AiProviderConfig | null) {
|
||||
if (!provider) return null
|
||||
return provider.models.find((model) => model.id === provider.defaultModelId) || null
|
||||
}
|
||||
@@ -35,6 +38,7 @@ function validDraft(provider: AiProviderConfig | null, apiKey: string): boolean
|
||||
}
|
||||
|
||||
export default function AiProviderSettings() {
|
||||
const { t } = useTranslation(['settings', 'common'])
|
||||
const store = useAiProviderStore()
|
||||
const [selectedId, setSelectedId] = useState('')
|
||||
const [presetId, setPresetId] = useState('deepseek')
|
||||
@@ -62,6 +66,7 @@ export default function AiProviderSettings() {
|
||||
setDraft(structuredClone(provider))
|
||||
setApiKey('')
|
||||
setManualModel(provider.models.length === 0)
|
||||
setAdvancedOpen(resolveProviderFamily(provider) === 'custom' || provider.presetId === 'vllm')
|
||||
setTestState('idle')
|
||||
setVerifiedSnapshot(null)
|
||||
setMessage('')
|
||||
@@ -73,16 +78,18 @@ export default function AiProviderSettings() {
|
||||
const currentModel = selectedModel(draft)
|
||||
const isStored = !!draft && store.providers.some((provider) => provider.id === draft.id)
|
||||
const canTest = validDraft(draft, apiKey) && testState !== 'testing'
|
||||
const family = draft ? resolveProviderFamily(draft) : null
|
||||
const needsEndpoint = family === 'custom' || family === 'local' || draft?.presetId === 'vllm'
|
||||
|
||||
const chooseDraft = (provider: AiProviderConfig) => {
|
||||
setSelectedId(provider.id)
|
||||
setDraft(provider)
|
||||
setApiKey('')
|
||||
setManualModel(provider.models.length === 0)
|
||||
setAdvancedOpen(false)
|
||||
setAdvancedOpen(resolveProviderFamily(provider) === 'custom' || provider.presetId === 'vllm')
|
||||
setTestState('idle')
|
||||
setVerifiedSnapshot(null)
|
||||
setMessage('请填写配置并测试连接;测试通过前不会保存任何内容')
|
||||
setMessage('')
|
||||
setTestAnswer('')
|
||||
}
|
||||
|
||||
@@ -92,7 +99,7 @@ export default function AiProviderSettings() {
|
||||
const updateDraft = (changes: Partial<AiProviderConfig>) => {
|
||||
if (!draft) return
|
||||
setDraft({ ...draft, ...changes })
|
||||
if (testState !== 'idle') setMessage('连接字段已变更,请重新测试')
|
||||
if (testState !== 'idle') setMessage(t('ai.retest'))
|
||||
}
|
||||
|
||||
const chooseModel = (modelId: string) => {
|
||||
@@ -105,19 +112,28 @@ export default function AiProviderSettings() {
|
||||
updateDraft({ defaultModelId: modelId })
|
||||
}
|
||||
|
||||
const defaultManualBilling = (): AiBillingMode => {
|
||||
if (!draft) return 'payg'
|
||||
const resolved = resolveProviderFamily(draft)
|
||||
if (resolved === 'local') return 'local'
|
||||
if (resolved === 'plan') return 'plan'
|
||||
return 'payg'
|
||||
}
|
||||
|
||||
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 })
|
||||
updateDraft({
|
||||
models: draft.models.map((model) =>
|
||||
model.id === active.id ? { ...model, model: modelId, label: modelId || t('ai.customModel') } : model
|
||||
),
|
||||
})
|
||||
return
|
||||
}
|
||||
const id = `manual-${Date.now().toString(36)}`
|
||||
updateDraft({
|
||||
models: [{ id, model: modelId, label: modelId || '自定义模型', capability: 'chat', billing: 'payg' }],
|
||||
models: [{ id, model: modelId, label: modelId || t('ai.customModel'), capability: 'chat', billing: defaultManualBilling() }],
|
||||
defaultModelId: id,
|
||||
})
|
||||
}
|
||||
@@ -126,7 +142,7 @@ export default function AiProviderSettings() {
|
||||
if (!draft || !currentModel || !canTest) return
|
||||
setTestState('testing')
|
||||
setVerifiedSnapshot(null)
|
||||
setMessage('正在发送最小请求验证 Endpoint、模型和凭据...')
|
||||
setMessage(t('ai.testingMessage'))
|
||||
setTestAnswer('')
|
||||
const snapshot = currentSnapshot
|
||||
try {
|
||||
@@ -135,11 +151,11 @@ export default function AiProviderSettings() {
|
||||
model: currentModel,
|
||||
apiKey: apiKey.trim() || undefined,
|
||||
})
|
||||
if (!result.success) throw new Error(result.error || '连接测试失败')
|
||||
if (!result.success) throw new Error(result.error || t('ai.testFailed'))
|
||||
setTestState('success')
|
||||
setVerifiedSnapshot(snapshot)
|
||||
setTestAnswer(result.answer?.trim() || '')
|
||||
setMessage(`测试成功 · HTTP ${result.status ?? 200} · ${result.model || currentModel.model} · ${result.latencyMs ?? '-'} ms`)
|
||||
setMessage(t('ai.available', { latency: result.latencyMs ?? '-' }))
|
||||
} catch (error) {
|
||||
setTestState('error')
|
||||
setMessage(error instanceof Error ? error.message : String(error))
|
||||
@@ -153,7 +169,7 @@ export default function AiProviderSettings() {
|
||||
await store.saveProvider(draft, apiKey)
|
||||
if (!store.activeProviderId || !isStored) await store.setActiveProvider(draft.id)
|
||||
setApiKey('')
|
||||
setMessage('配置已安全保存,并可用于终端助手')
|
||||
setMessage(t('ai.saved'))
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : String(error))
|
||||
setTestState('error')
|
||||
@@ -174,82 +190,96 @@ export default function AiProviderSettings() {
|
||||
}
|
||||
|
||||
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="space-y-5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
aria-label={t('ai.presets')}
|
||||
value={presetId}
|
||||
onChange={(event) => setPresetId(event.target.value)}
|
||||
className="ui-input-sm min-w-52 flex-1"
|
||||
>
|
||||
{AI_PROVIDER_FAMILY_ORDER.map((familyId) => (
|
||||
<optgroup key={familyId} label={t(`ai.family.${familyId}`)}>
|
||||
{presetsByFamily(familyId).map((preset) => (
|
||||
<option key={preset.id} value={preset.id}>
|
||||
{preset.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (presetId === 'custom' ? addCustom() : addPreset())}
|
||||
className="ui-btn-primary flex items-center gap-1.5"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" /> {t('ai.add')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<div className="grid min-h-[400px] grid-cols-[168px_minmax(0,1fr)] gap-6">
|
||||
<aside className="space-y-0.5">
|
||||
<div className="px-2 pb-2 text-[11px] font-medium text-txt-faint">{t('ai.configured')}</div>
|
||||
{store.providers.length === 0 ? (
|
||||
<p className="px-2 py-4 text-xs leading-relaxed text-txt-faint">尚无配置,从上方选择供应商添加。</p>
|
||||
<p className="px-2 py-6 text-xs leading-relaxed text-txt-faint">{t('ai.configuredHint')}</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>
|
||||
))
|
||||
store.providers.map((provider) => {
|
||||
const model = provider.models.find((item) => item.id === provider.defaultModelId)
|
||||
return (
|
||||
<button
|
||||
key={provider.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(provider.id)}
|
||||
className={`w-full rounded-lg px-2.5 py-2 text-left transition-colors ${
|
||||
selectedId === provider.id
|
||||
? 'bg-accent-blue/12 text-accent-blue'
|
||||
: 'text-content-secondary hover:bg-dark-700/50'
|
||||
}`}
|
||||
>
|
||||
<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 pl-4 text-[10px] text-txt-faint">
|
||||
{model?.label || t('ai.noModel')}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<main className="min-w-0 p-5">
|
||||
<main className="min-w-0">
|
||||
{!draft ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-txt-faint">选择已有配置,或添加新供应商</div>
|
||||
<div className="flex h-full items-center justify-center text-sm text-txt-faint">{t('ai.emptyHint')}</div>
|
||||
) : (
|
||||
<div className="mx-auto max-w-2xl space-y-4">
|
||||
<div className="max-w-xl space-y-3.5">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="供应商">
|
||||
<Field label={t('ai.name')}>
|
||||
<input value={draft.name} onChange={(event) => updateDraft({ name: event.target.value })} className="ui-input-sm" />
|
||||
</Field>
|
||||
<Field label="模型">
|
||||
<Field label={t('ai.model')}>
|
||||
{manualModel || draft.models.length === 0 ? (
|
||||
<input
|
||||
aria-label="模型"
|
||||
aria-label={t('ai.model')}
|
||||
value={currentModel?.model || ''}
|
||||
onChange={(event) => updateManualModel(event.target.value)}
|
||||
placeholder="例如 deepseek-chat"
|
||||
placeholder="deepseek-v4-pro"
|
||||
className="ui-input-sm font-mono"
|
||||
/>
|
||||
) : (
|
||||
<select value={draft.defaultModelId} onChange={(event) => chooseModel(event.target.value)} className="ui-input-sm">
|
||||
<select
|
||||
aria-label={t('ai.model')}
|
||||
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 key={model.id} value={model.id}>
|
||||
{model.label || model.model}
|
||||
</option>
|
||||
))}
|
||||
<option value="__manual">手动输入模型...</option>
|
||||
<option value="__manual">{t('ai.otherModels')}</option>
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
@@ -257,49 +287,47 @@ export default function AiProviderSettings() {
|
||||
|
||||
{manualModel && draft.models.length > 0 && (
|
||||
<button type="button" className="text-xs text-accent-blue hover:underline" onClick={() => setManualModel(false)}>
|
||||
返回预设模型列表
|
||||
{t('ai.backToList')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<Field label="Endpoint">
|
||||
<Field label={draft.authStyle === 'none' ? t('ai.apiKeyOptional') : 'API Key'}>
|
||||
<input
|
||||
value={draft.baseUrl}
|
||||
onChange={(event) => updateDraft({ baseUrl: event.target.value })}
|
||||
placeholder="https://api.example.com/v1"
|
||||
className="ui-input-sm font-mono"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={apiKey}
|
||||
disabled={draft.authStyle === 'none'}
|
||||
onChange={(event) => setApiKey(event.target.value)}
|
||||
placeholder={draft.hasApiKey ? t('ai.savedKeepBlank') : draft.authStyle === 'none' ? t('ai.notRequired') : 'sk-...'}
|
||||
className="ui-input-sm"
|
||||
/>
|
||||
</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" />
|
||||
{(needsEndpoint || advancedOpen) && (
|
||||
<Field label="Endpoint">
|
||||
<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"
|
||||
aria-label="Endpoint"
|
||||
value={currentModel?.baseUrl || draft.baseUrl}
|
||||
onChange={(event) => updateDraft({ baseUrl: event.target.value })}
|
||||
placeholder="https://api.example.com/v1"
|
||||
className="ui-input-sm font-mono"
|
||||
disabled={!!currentModel?.baseUrl && !needsEndpoint}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAdvancedOpen((open) => !open)}
|
||||
className="flex items-center gap-1 text-xs text-txt-muted hover:text-content-secondary"
|
||||
className="flex items-center gap-1 text-xs text-txt-faint hover:text-txt-muted"
|
||||
>
|
||||
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${advancedOpen ? 'rotate-180' : ''}`} />
|
||||
高级设置
|
||||
{t('ai.advanced')}
|
||||
</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="接口协议">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label={t('ai.protocol')}>
|
||||
<select
|
||||
value={draft.protocol}
|
||||
onChange={(event) => updateDraft({ protocol: event.target.value as AiProviderConfig['protocol'] })}
|
||||
@@ -307,34 +335,33 @@ export default function AiProviderSettings() {
|
||||
>
|
||||
<option value="openai-chat">OpenAI Chat</option>
|
||||
<option value="openai-responses">OpenAI Responses</option>
|
||||
<option value="anthropic">Anthropic Messages</option>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="认证方式">
|
||||
<Field label={t('ai.auth')}>
|
||||
<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="bearer">Bearer</option>
|
||||
<option value="x-api-key">x-api-key</option>
|
||||
<option value="both">Bearer + x-api-key</option>
|
||||
<option value="none">无需认证</option>
|
||||
<option value="none">{t('ai.authNone')}</option>
|
||||
</select>
|
||||
</Field>
|
||||
<label className="col-span-2 flex items-start gap-2 text-xs text-txt-muted">
|
||||
<label className="col-span-2 flex items-center 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(只用于可信本机或内网模型服务)
|
||||
{t('ai.allowHttp')}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-dark-700/40 flex flex-wrap items-center gap-2 border-t pt-4">
|
||||
<div className="flex flex-wrap items-center gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void testConnection()}
|
||||
@@ -342,7 +369,7 @@ export default function AiProviderSettings() {
|
||||
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' ? '测试中...' : '测试连接'}
|
||||
{testState === 'testing' ? t('ai.testing') : t('ai.test')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -351,28 +378,27 @@ export default function AiProviderSettings() {
|
||||
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" />}
|
||||
保存
|
||||
{t('common:action.save')}
|
||||
</button>
|
||||
{isStored && store.activeProviderId !== draft.id && (
|
||||
<button type="button" onClick={() => void store.setActiveProvider(draft.id)} className="ui-btn-secondary">
|
||||
设为当前
|
||||
{t('ai.setActive')}
|
||||
</button>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<button type="button" title="删除配置" onClick={() => void remove()} className="ui-btn-secondary text-red-300">
|
||||
<button type="button" title={t('ai.deleteTitle')} onClick={() => void remove()} className="rounded p-1.5 text-txt-faint hover:bg-dark-700 hover: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'}>
|
||||
<div
|
||||
className={`flex min-h-5 items-center gap-1.5 text-xs ${verified ? 'text-emerald-400' : testState === 'error' ? 'text-red-300' : 'text-txt-faint'}`}
|
||||
role={testState === 'error' ? 'alert' : 'status'}
|
||||
>
|
||||
{verified && <CheckCircle2 className="h-3.5 w-3.5" />}
|
||||
<span>{message || '修改连接字段后需要重新测试'}</span>
|
||||
<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>
|
||||
)}
|
||||
{verified && testAnswer && <p className="text-xs text-content-secondary">{testAnswer}</p>}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
@@ -383,7 +409,7 @@ export default function AiProviderSettings() {
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<label>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-[11px] text-txt-muted">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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,
|
||||
@@ -18,7 +20,7 @@ import {
|
||||
Sparkles,
|
||||
} from 'lucide-react'
|
||||
import AiProviderSettings from './AiProviderSettings'
|
||||
import { themes } from '../../themes/terminal'
|
||||
import { themes, type TerminalThemeId } from '../../themes/terminal'
|
||||
import { getKeybindingCategories, getKeybindingsByCategory, type KeybindingActionId } from '../../constants/keybindings'
|
||||
|
||||
// ─── Tab 类型 ───────────────────────────────────────────────
|
||||
@@ -26,8 +28,8 @@ type SettingsTab = 'general' | 'appearance' | 'terminal' | 'ai' | 'shortcuts' |
|
||||
|
||||
interface TabMeta {
|
||||
id: SettingsTab
|
||||
label: string
|
||||
description: string
|
||||
labelKey: string
|
||||
descriptionKey: string
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
}
|
||||
|
||||
@@ -42,14 +44,14 @@ interface RuntimeInfo {
|
||||
}
|
||||
|
||||
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 },
|
||||
{ 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 ─────────────────────────────────────────
|
||||
@@ -110,6 +112,7 @@ function useKeyRecording() {
|
||||
|
||||
// ─── 主组件 ─────────────────────────────────────────────────
|
||||
export default function SettingsView() {
|
||||
const { t } = useTranslation(['settings', 'common'])
|
||||
const { settings, isLoaded, loadSettings, setSetting } = useSettingsStore()
|
||||
const keybindingState = useKeybindingStore()
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>('general')
|
||||
@@ -147,23 +150,23 @@ export default function SettingsView() {
|
||||
|
||||
// ── 搜索过滤 ──────────────────────────────────────────────
|
||||
const filtered = useMemo((): SettingsTab[] | null => {
|
||||
if (!searchQuery.trim()) return null // null 表示不过滤,正常显示
|
||||
if (!searchQuery.trim()) return 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'],
|
||||
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])
|
||||
}, [searchQuery, t])
|
||||
|
||||
// 如果搜索过滤后有结果,自动跳转到第一个匹配 tab
|
||||
useEffect(() => {
|
||||
@@ -186,7 +189,7 @@ export default function SettingsView() {
|
||||
if (!isLoaded) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-dark-900">
|
||||
<div className="text-txt-muted">加载中...</div>
|
||||
<div className="text-txt-muted">{t('common:status.loading')}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -198,8 +201,8 @@ export default function SettingsView() {
|
||||
case 'general':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SettingGroup title="工作区布局">
|
||||
<SettingRow label="侧栏宽度" description="会话侧栏的默认宽度">
|
||||
<SettingGroup title={t('general.layout')}>
|
||||
<SettingRow label={t('general.sidebarWidth')} description={t('general.sidebarWidthDesc')}>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
@@ -209,12 +212,12 @@ export default function SettingsView() {
|
||||
value={settings.sidebarWidth}
|
||||
onChange={(e) => setSetting('sidebarWidth', Number(e.target.value))}
|
||||
className="w-28 accent-blue-500"
|
||||
aria-label="侧栏宽度"
|
||||
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="折叠侧栏" description="隐藏会话树以专心操作终端,可用 Ctrl/Cmd+B 切换">
|
||||
<SettingRow label={t('general.collapseSidebar')} description={t('general.collapseSidebarDesc')}>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
@@ -239,16 +242,31 @@ export default function SettingsView() {
|
||||
case 'appearance':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SettingGroup title="外观">
|
||||
<SettingRow label="主题" description="应用界面配色">
|
||||
<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">深色</option>
|
||||
<option value="light">浅色</option>
|
||||
<option value="system">跟随系统</option>
|
||||
<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>
|
||||
@@ -259,12 +277,12 @@ export default function SettingsView() {
|
||||
case 'terminal':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SettingGroup title="字体">
|
||||
<SettingRow label="字体" description="需 Nerd Font 才能正确显示 Zsh 提示符图标">
|
||||
<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"
|
||||
className="ui-select-pill max-w-[220px]"
|
||||
>
|
||||
<option>MesloLGS NF</option>
|
||||
<option>JetBrainsMono Nerd Font</option>
|
||||
@@ -277,7 +295,7 @@ export default function SettingsView() {
|
||||
<option>Consolas</option>
|
||||
</select>
|
||||
</SettingRow>
|
||||
<SettingRow label="字号" description="终端文字大小">
|
||||
<SettingRow label={t('terminal.fontSize')} description={t('terminal.fontSizeDesc')}>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
@@ -298,7 +316,7 @@ export default function SettingsView() {
|
||||
</button>
|
||||
</div>
|
||||
</SettingRow>
|
||||
<SettingRow label="回滚缓冲" description="每个终端保留的历史行数">
|
||||
<SettingRow label={t('terminal.scrollback')} description={t('terminal.scrollbackDesc')}>
|
||||
<input
|
||||
type="number"
|
||||
min={1000}
|
||||
@@ -310,23 +328,21 @@ export default function SettingsView() {
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingToggle
|
||||
label="关键词高亮"
|
||||
description="突出显示错误、警告、IP 地址和成功状态"
|
||||
label={t('terminal.keywordHighlight')}
|
||||
description={t('terminal.keywordHighlightDesc')}
|
||||
checked={settings.keywordHighlightEnabled}
|
||||
onChange={(value) => setSetting('keywordHighlightEnabled', value)}
|
||||
/>
|
||||
</SettingGroup>
|
||||
|
||||
<SettingGroup title="配色">
|
||||
<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 'dracula' | 'monokai' | 'solarized' | 'one-dark')
|
||||
}
|
||||
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'
|
||||
@@ -334,9 +350,15 @@ export default function SettingsView() {
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="mb-1.5 h-7 w-full rounded-md border border-black/20"
|
||||
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>
|
||||
))}
|
||||
@@ -369,7 +391,7 @@ export default function SettingsView() {
|
||||
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>
|
||||
)}
|
||||
@@ -378,7 +400,7 @@ export default function SettingsView() {
|
||||
const defs = getKeybindingsByCategory(category)
|
||||
return (
|
||||
<SettingCard key={category}>
|
||||
<h3 className="mb-3 text-sm font-semibold text-content-secondary">{category}</h3>
|
||||
<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]
|
||||
@@ -393,10 +415,10 @@ export default function SettingsView() {
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-sm text-content-primary">{def.label}</span>
|
||||
<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>
|
||||
@@ -405,18 +427,18 @@ export default function SettingsView() {
|
||||
{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 || '按下快捷键...'}
|
||||
{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>与「{recordedConflict[0].label}」冲突</span>
|
||||
<span>{t('shortcuts.conflict', { label: t(recordedConflict[0].labelKey) })}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -442,7 +464,7 @@ export default function SettingsView() {
|
||||
<button
|
||||
onClick={() => keybindingState.resetKeybinding(def.id)}
|
||||
className="text-txt-faint transition-colors hover:text-txt-muted"
|
||||
title="重置为默认"
|
||||
title={t('shortcuts.resetDefault')}
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
</button>
|
||||
@@ -462,10 +484,7 @@ export default function SettingsView() {
|
||||
{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>
|
||||
{t('shortcuts.setTo', { keys: recordedKeys })}
|
||||
</span>
|
||||
<button
|
||||
onClick={async () => {
|
||||
@@ -474,18 +493,18 @@ export default function SettingsView() {
|
||||
}}
|
||||
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">点击快捷键可重新绑定 · 按 Esc 取消录入</p>
|
||||
<p className="text-xs text-txt-faint">{t('shortcuts.hint')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -494,20 +513,20 @@ export default function SettingsView() {
|
||||
case 'ssh':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SettingGroup title="连接">
|
||||
<SettingGroup title={t('ssh.connection')}>
|
||||
<SettingToggle
|
||||
label="保持心跳"
|
||||
description="空闲时发送心跳,减少断线"
|
||||
label={t('ssh.keepAlive')}
|
||||
description={t('ssh.keepAliveDesc')}
|
||||
checked={settings.keepAlive}
|
||||
onChange={(v) => setSetting('keepAlive', v)}
|
||||
/>
|
||||
<SettingToggle
|
||||
label="自动重连"
|
||||
description="意外断开后自动尝试重连"
|
||||
label={t('ssh.autoReconnect')}
|
||||
description={t('ssh.autoReconnectDesc')}
|
||||
checked={settings.autoReconnect !== false}
|
||||
onChange={(v) => setSetting('autoReconnect', v)}
|
||||
/>
|
||||
<SettingRow label="连接超时" description="建立 SSH 连接的最长等待时间(秒)">
|
||||
<SettingRow label={t('ssh.timeout')} description={t('ssh.timeoutDesc')}>
|
||||
<input
|
||||
type="number"
|
||||
min={5}
|
||||
@@ -517,7 +536,7 @@ export default function SettingsView() {
|
||||
className="ui-input-sm w-20 text-center"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="自动重连次数" description="意外断线后的最大尝试次数">
|
||||
<SettingRow label={t('ssh.maxReconnect')} description={t('ssh.maxReconnectDesc')}>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
@@ -529,8 +548,8 @@ export default function SettingsView() {
|
||||
</SettingRow>
|
||||
</SettingGroup>
|
||||
|
||||
<SettingGroup title="连接池">
|
||||
<SettingRow label="最大连接数" description="SSH、SFTP 与批量连接共享此上限">
|
||||
<SettingGroup title={t('ssh.pool')}>
|
||||
<SettingRow label={t('ssh.maxConnections')} description={t('ssh.maxConnectionsDesc')}>
|
||||
<input
|
||||
type="number"
|
||||
min={2}
|
||||
@@ -540,7 +559,7 @@ export default function SettingsView() {
|
||||
className="ui-input-sm w-20 text-center"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="后台连接上限" description="超过上限时优先回收最久未使用的连接">
|
||||
<SettingRow label={t('ssh.maxBackground')} description={t('ssh.maxBackgroundDesc')}>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
@@ -550,7 +569,7 @@ export default function SettingsView() {
|
||||
className="ui-input-sm w-20 text-center"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="后台挂起" description="标签进入后台后多少分钟释放活跃通道">
|
||||
<SettingRow label={t('ssh.suspend')} description={t('ssh.suspendDesc')}>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
@@ -568,15 +587,15 @@ export default function SettingsView() {
|
||||
case 'security':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SettingGroup title="凭据保护">
|
||||
<SettingRow label="会话密码与密钥口令" description="保存前由 Rust 使用 AES-256-GCM 加密,无法关闭">
|
||||
<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="本地主密钥" description="仅当前系统用户可读取">
|
||||
<SettingRow label={t('security.masterKey')} description={t('security.masterKeyDesc')}>
|
||||
<code className="text-xs text-txt-muted break-all">
|
||||
{runtimeInfo?.credentialPath ?? '~/.opsxshsell/master.key'}
|
||||
{runtimeInfo?.credentialPath ?? '~/.opsxshell/master.key'}
|
||||
</code>
|
||||
</SettingRow>
|
||||
</SettingGroup>
|
||||
@@ -595,23 +614,23 @@ export default function SettingsView() {
|
||||
</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>
|
||||
<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">
|
||||
应用 v{appVersion}
|
||||
{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="复制版本信息"
|
||||
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' ? '已复制' : '复制'}
|
||||
{copiedField === 'version' ? t('common:action.copied') : t('common:action.copy')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -619,37 +638,35 @@ export default function SettingsView() {
|
||||
</SettingCard>
|
||||
|
||||
<SettingCard>
|
||||
<SettingSectionTitle>运行环境</SettingSectionTitle>
|
||||
<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">系统</span>
|
||||
<span className="text-txt-muted">{t('about.system')}</span>
|
||||
<code className="text-right text-xs text-content-secondary">
|
||||
{runtimeInfo ? `${runtimeInfo.os} ${runtimeInfo.arch}` : '读取中...'}
|
||||
{runtimeInfo ? `${runtimeInfo.os} ${runtimeInfo.arch}` : t('common:status.reading')}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</SettingCard>
|
||||
|
||||
<SettingCard>
|
||||
<SettingSectionTitle>本地数据</SettingSectionTitle>
|
||||
<SettingSectionTitle>{t('about.localData')}</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 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">加密主密钥</div>
|
||||
<div className="mb-1 text-txt-muted">{t('about.masterKeyPath')}</div>
|
||||
<code className="block break-all text-content-secondary">
|
||||
{runtimeInfo?.credentialPath ?? '~/.opsxshsell/master.key'}
|
||||
{runtimeInfo?.credentialPath ?? '~/.opsxshell/master.key'}
|
||||
</code>
|
||||
</div>
|
||||
<p className="leading-relaxed text-txt-faint">
|
||||
密码与密钥口令由 Rust 使用 AES-256-GCM 加密。OpsXShell 不保存终端命令历史。
|
||||
</p>
|
||||
<p className="leading-relaxed text-txt-faint">{t('about.privacyNote')}</p>
|
||||
</div>
|
||||
</SettingCard>
|
||||
|
||||
<p className="px-1 text-[11px] text-txt-faint">MIT License · Rust · XTerm.js</p>
|
||||
<p className="px-1 text-[11px] text-txt-faint">{t('about.license')}</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -664,12 +681,12 @@ export default function SettingsView() {
|
||||
{/* ── 左侧导航 ── */}
|
||||
<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="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="搜索设置..."
|
||||
placeholder={t('search.placeholder')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="ui-input-sm !pl-8 !pr-8"
|
||||
@@ -697,10 +714,10 @@ export default function SettingsView() {
|
||||
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 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>
|
||||
@@ -712,8 +729,8 @@ export default function SettingsView() {
|
||||
{/* ── 右侧内容区 ── */}
|
||||
<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>
|
||||
<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>
|
||||
@@ -725,6 +742,7 @@ export default function SettingsView() {
|
||||
// ─── 子组件 ─────────────────────────────────────────────────
|
||||
|
||||
function KnownHostsSettingsCard() {
|
||||
const { t } = useTranslation(['settings', 'common'])
|
||||
const [entries, setEntries] = useState<Array<{ host: string; port: number; keyType: string; fingerprint: string }>>(
|
||||
[]
|
||||
)
|
||||
@@ -752,22 +770,22 @@ function KnownHostsSettingsCard() {
|
||||
}
|
||||
|
||||
const handleClear = async () => {
|
||||
if (!confirm('确定清空全部已知主机密钥?下次连接将重新确认。')) return
|
||||
if (!confirm(t('security.clearConfirm'))) return
|
||||
await window.opsxApi?.knownHostsClear?.()
|
||||
await load()
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingGroup title="已知主机">
|
||||
<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">首次连接会确认主机指纹。此处可管理已信任记录。</p>
|
||||
<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"
|
||||
@@ -775,14 +793,14 @@ function KnownHostsSettingsCard() {
|
||||
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">加载中...</div>
|
||||
<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">暂无记录</div>
|
||||
<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) => (
|
||||
@@ -796,9 +814,11 @@ function KnownHostsSettingsCard() {
|
||||
<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"
|
||||
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>
|
||||
))}
|
||||
@@ -849,12 +869,12 @@ function SettingRow({
|
||||
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="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="flex-shrink-0">{children}</div>
|
||||
<div className="ml-auto flex-shrink-0">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -872,8 +892,8 @@ function SettingToggle({
|
||||
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="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>
|
||||
@@ -882,7 +902,7 @@ function SettingToggle({
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`relative h-6 w-11 flex-shrink-0 rounded-full transition-colors ${
|
||||
className={`relative ml-auto h-6 w-11 flex-shrink-0 rounded-full transition-colors ${
|
||||
checked ? 'bg-accent-blue' : 'bg-dark-600'
|
||||
}`}
|
||||
>
|
||||
|
||||
Reference in new issue
Block a user