chore: 保存工作副本——客户端授权新契约(双端点/验签/令牌/周检)与桌面会话可靠性改进
This commit is contained in:
1 parent
f71eef0eba
commit
ae6093c9b3
163 files changed
+4911
-2494
No files matched your search
@@ -200,12 +200,12 @@ export default function AiProviderSettings() {
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex 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"
|
||||
className="ui-select-pill"
|
||||
>
|
||||
{AI_PROVIDER_FAMILY_ORDER.map((familyId) => (
|
||||
<optgroup key={familyId} label={t(`ai.family.${familyId}`)}>
|
||||
@@ -228,11 +228,11 @@ export default function AiProviderSettings() {
|
||||
</Button>
|
||||
</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>
|
||||
<div className="grid min-h-[22rem] grid-cols-[12rem_minmax(0,1fr)] gap-5">
|
||||
<aside className="border-dark-700/45 bg-dark-800/50 h-fit overflow-hidden rounded-2xl border py-2">
|
||||
<div className="px-3 pb-1.5 text-[11px] font-medium text-txt-faint">{t('ai.configured')}</div>
|
||||
{store.providers.length === 0 ? (
|
||||
<p className="px-2 py-6 text-xs leading-relaxed text-txt-faint">{t('ai.configuredHint')}</p>
|
||||
<p className="px-3 py-5 text-xs leading-relaxed text-txt-faint">{t('ai.configuredHint')}</p>
|
||||
) : (
|
||||
store.providers.map((provider) => {
|
||||
const model = provider.models.find((item) => item.id === provider.defaultModelId)
|
||||
@@ -241,7 +241,7 @@ export default function AiProviderSettings() {
|
||||
key={provider.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(provider.id)}
|
||||
className={`w-full rounded-lg px-2.5 py-2 text-left transition-colors ${
|
||||
className={`w-full px-3 py-2 text-left transition-colors ${
|
||||
selectedId === provider.id
|
||||
? 'bg-accent-blue/12 text-accent-blue'
|
||||
: 'hover:bg-dark-700/50 text-content-secondary'
|
||||
@@ -260,34 +260,40 @@ export default function AiProviderSettings() {
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<main className="min-w-0">
|
||||
<main className="min-w-0 max-w-lg">
|
||||
{!draft ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-txt-faint">{t('ai.emptyHint')}</div>
|
||||
<div className="border-dark-600/80 flex h-40 items-center justify-center rounded-2xl border border-dashed text-sm text-txt-faint">
|
||||
{t('ai.emptyHint')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-w-xl space-y-3.5">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label={t('ai.name')}>
|
||||
<input
|
||||
value={draft.name}
|
||||
onChange={(event) => updateDraft({ name: event.target.value })}
|
||||
className="ui-input-sm"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t('ai.model')}>
|
||||
{manualModel || draft.models.length === 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="border-dark-700/45 bg-dark-800/50 divide-dark-700/35 divide-y overflow-hidden rounded-2xl border">
|
||||
<FormRow label={t('ai.name')}>
|
||||
<CompactField>
|
||||
<input
|
||||
aria-label={t('ai.model')}
|
||||
value={currentModel?.model || ''}
|
||||
onChange={(event) => updateManualModel(event.target.value)}
|
||||
placeholder="deepseek-v4-pro"
|
||||
className="ui-input-sm font-mono"
|
||||
value={draft.name}
|
||||
onChange={(event) => updateDraft({ name: event.target.value })}
|
||||
className="ui-input-sm"
|
||||
/>
|
||||
</CompactField>
|
||||
</FormRow>
|
||||
<FormRow label={t('ai.model')}>
|
||||
{manualModel || draft.models.length === 0 ? (
|
||||
<CompactField>
|
||||
<input
|
||||
aria-label={t('ai.model')}
|
||||
value={currentModel?.model || ''}
|
||||
onChange={(event) => updateManualModel(event.target.value)}
|
||||
placeholder="deepseek-v4-pro"
|
||||
className="ui-input-sm font-mono"
|
||||
/>
|
||||
</CompactField>
|
||||
) : (
|
||||
<select
|
||||
aria-label={t('ai.model')}
|
||||
value={draft.defaultModelId}
|
||||
onChange={(event) => chooseModel(event.target.value)}
|
||||
className="ui-input-sm"
|
||||
className="ui-select-pill"
|
||||
>
|
||||
{draft.models.map((model) => (
|
||||
<option key={model.id} value={model.id}>
|
||||
@@ -297,100 +303,103 @@ export default function AiProviderSettings() {
|
||||
<option value="__manual">{t('ai.otherModels')}</option>
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
</FormRow>
|
||||
{manualModel && draft.models.length > 0 && (
|
||||
<div className="flex justify-end px-4 py-2">
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-accent-blue hover:underline"
|
||||
onClick={() => setManualModel(false)}
|
||||
>
|
||||
{t('ai.backToList')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<FormRow label={draft.authStyle === 'none' ? t('ai.apiKeyOptional') : t('ai.apiKey')}>
|
||||
<CompactField wide>
|
||||
<input
|
||||
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"
|
||||
/>
|
||||
</CompactField>
|
||||
</FormRow>
|
||||
{(needsEndpoint || advancedOpen) && (
|
||||
<FormRow label={t('ai.endpoint')}>
|
||||
<CompactField wide>
|
||||
<input
|
||||
aria-label={t('ai.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}
|
||||
/>
|
||||
</CompactField>
|
||||
</FormRow>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{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={draft.authStyle === 'none' ? t('ai.apiKeyOptional') : 'API Key'}>
|
||||
<input
|
||||
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>
|
||||
|
||||
{(needsEndpoint || advancedOpen) && (
|
||||
<Field label="Endpoint">
|
||||
<input
|
||||
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}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAdvancedOpen((open) => !open)}
|
||||
className="flex items-center gap-1 text-xs text-txt-faint hover:text-txt-muted"
|
||||
className="flex items-center gap-1 px-0.5 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="grid grid-cols-2 gap-3">
|
||||
<Field label={t('ai.protocol')}>
|
||||
<div className="border-dark-700/45 bg-dark-800/50 divide-dark-700/35 divide-y overflow-hidden rounded-2xl border">
|
||||
<FormRow label={t('ai.protocol')}>
|
||||
<select
|
||||
value={draft.protocol}
|
||||
onChange={(event) =>
|
||||
updateDraft({ protocol: event.target.value as AiProviderConfig['protocol'] })
|
||||
}
|
||||
className="ui-input-sm"
|
||||
className="ui-select-pill"
|
||||
>
|
||||
<option value="openai-chat">OpenAI Chat</option>
|
||||
<option value="openai-responses">OpenAI Responses</option>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t('ai.auth')}>
|
||||
</FormRow>
|
||||
<FormRow label={t('ai.auth')}>
|
||||
<select
|
||||
value={draft.authStyle}
|
||||
onChange={(event) =>
|
||||
updateDraft({ authStyle: event.target.value as AiProviderConfig['authStyle'] })
|
||||
}
|
||||
className="ui-input-sm"
|
||||
className="ui-select-pill"
|
||||
>
|
||||
<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">{t('ai.authNone')}</option>
|
||||
</select>
|
||||
</Field>
|
||||
<label className="col-span-2 flex items-center gap-2 text-xs text-txt-muted">
|
||||
</FormRow>
|
||||
<FormRow label={t('ai.allowHttp')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.allowInsecureHttp}
|
||||
onChange={(event) => updateDraft({ allowInsecureHttp: event.target.checked })}
|
||||
className="h-3.5 w-3.5 accent-blue-500"
|
||||
/>
|
||||
{t('ai.allowHttp')}
|
||||
</label>
|
||||
</FormRow>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 pt-1">
|
||||
<div className="flex flex-wrap items-center gap-2 pt-0.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
@@ -450,11 +459,15 @@ export default function AiProviderSettings() {
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
function FormRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-[11px] text-txt-muted">{label}</span>
|
||||
{children}
|
||||
<label className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1.5 px-4 py-3">
|
||||
<span className="min-w-[6.5rem] text-sm text-content-primary">{label}</span>
|
||||
<div className="ml-auto flex-shrink-0">{children}</div>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function CompactField({ children, wide = false }: { children: React.ReactNode; wide?: boolean }) {
|
||||
return <div className={wide ? 'w-[17.5rem] max-w-full' : 'w-[12.5rem] max-w-full'}>{children}</div>
|
||||
}
|
||||
@@ -18,12 +18,21 @@ import {
|
||||
Check,
|
||||
Sparkles,
|
||||
UserRound,
|
||||
ExternalLink,
|
||||
} from 'lucide-react'
|
||||
import AiProviderSettings from './AiProviderSettings'
|
||||
import AccountSettings from './AccountSettings'
|
||||
import { themes, type TerminalThemeId } from '../../themes/terminal'
|
||||
import { getKeybindingCategories, getKeybindingsByCategory, type KeybindingActionId } from '../../constants/keybindings'
|
||||
import { filterSettingsTabsByQuery, type SettingsSearchTab } from '../../utils/settingsSearch'
|
||||
import {
|
||||
buildBugReportUrl,
|
||||
formatRuntimeSystem,
|
||||
GITHUB_DISCUSSIONS_URL,
|
||||
GITHUB_LICENSE_URL,
|
||||
GITHUB_RELEASES_URL,
|
||||
WECHAT_ID,
|
||||
} from '../../utils/bugReport'
|
||||
|
||||
// ─── Tab 类型 ───────────────────────────────────────────────
|
||||
type SettingsTab = 'general' | 'account' | 'terminal' | 'ai' | 'shortcuts' | 'ssh' | 'security' | 'about'
|
||||
@@ -148,6 +157,17 @@ export default function SettingsView() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const openBugReport = useCallback(() => {
|
||||
const url = buildBugReportUrl({
|
||||
appVersion: runtimeInfo?.appVersion,
|
||||
os: runtimeInfo?.os,
|
||||
arch: runtimeInfo?.arch,
|
||||
logPath: runtimeInfo?.logPath,
|
||||
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : undefined,
|
||||
})
|
||||
openHttpUrl(url)
|
||||
}, [runtimeInfo])
|
||||
|
||||
// 当前 tab 元信息
|
||||
const currentMeta = useMemo(() => TAB_LIST.find((t) => t.id === activeTab)!, [activeTab])
|
||||
|
||||
@@ -171,7 +191,7 @@ export default function SettingsView() {
|
||||
const diagnosticText = runtimeInfo
|
||||
? [
|
||||
`OpsXShell v${runtimeInfo.appVersion}`,
|
||||
`${runtimeInfo.os} ${runtimeInfo.arch}`,
|
||||
formatRuntimeSystem(runtimeInfo.os, runtimeInfo.arch),
|
||||
`Data: ${runtimeInfo.dataPath}`,
|
||||
`Log: ${runtimeInfo.logPath}`,
|
||||
].join('\n')
|
||||
@@ -249,7 +269,7 @@ export default function SettingsView() {
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 left-0.5 h-5 w-5 rounded-full bg-white transition-transform ${
|
||||
className={`absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white transition-transform ${
|
||||
settings.sidebarCollapsed ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
@@ -334,7 +354,7 @@ export default function SettingsView() {
|
||||
onClick={() => setSetting('terminalTheme', key as TerminalThemeId)}
|
||||
className={`rounded-xl border p-2.5 text-left text-xs transition-colors ${
|
||||
settings.terminalTheme === key
|
||||
? 'bg-accent-blue/10 ring-accent-blue/30 border-accent-blue ring-1'
|
||||
? 'border-accent-blue bg-accent-blue/10 ring-1 ring-accent-blue/30'
|
||||
: 'border-dark-600/60 hover:border-dark-500'
|
||||
}`}
|
||||
>
|
||||
@@ -389,7 +409,9 @@ export default function SettingsView() {
|
||||
const defs = getKeybindingsByCategory(category)
|
||||
return (
|
||||
<SettingCard key={category}>
|
||||
<h3 className="mb-3 text-sm font-semibold text-content-secondary">{t(`shortcuts.category.${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]
|
||||
@@ -400,13 +422,13 @@ export default function SettingsView() {
|
||||
<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'
|
||||
isRecordingThis ? 'border border-accent-blue/30 bg-accent-blue/10' : 'hover:bg-dark-800/60'
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-sm text-content-primary">{t(def.labelKey)}</span>
|
||||
{isCustom && (
|
||||
<span className="bg-accent-blue/10 ml-2 rounded px-1.5 py-0.5 text-[10px] text-accent-blue">
|
||||
<span className="ml-2 rounded bg-accent-blue/10 px-1.5 py-0.5 text-[10px] text-accent-blue">
|
||||
{t('shortcuts.custom')}
|
||||
</span>
|
||||
)}
|
||||
@@ -415,7 +437,7 @@ export default function SettingsView() {
|
||||
<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">
|
||||
<kbd className="inline-flex animate-pulse items-center rounded border border-accent-blue/40 bg-accent-blue/20 px-3 py-1 font-mono text-xs text-accent-blue">
|
||||
{recordedKeys || t('shortcuts.pressKeys')}
|
||||
</kbd>
|
||||
<button
|
||||
@@ -442,7 +464,7 @@ export default function SettingsView() {
|
||||
<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-accent-blue/30 bg-accent-blue/10 text-accent-blue'
|
||||
: 'border border-dark-600 bg-dark-700 text-content-secondary'
|
||||
} group-hover:border-accent-blue/50`}
|
||||
>
|
||||
@@ -472,15 +494,13 @@ 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">
|
||||
{t('shortcuts.setTo', { keys: recordedKeys })}
|
||||
</span>
|
||||
<span className="text-sm text-content-secondary">{t('shortcuts.setTo', { keys: recordedKeys })}</span>
|
||||
<button
|
||||
onClick={async () => {
|
||||
await keybindingState.setCustomKeybinding(recordingId, recordedKeys)
|
||||
stopRecording()
|
||||
}}
|
||||
className="hover:bg-accent-blue/80 rounded-lg bg-accent-blue px-3 py-1 text-xs text-content-primary transition-colors"
|
||||
className="rounded-lg bg-accent-blue px-3 py-1 text-xs text-content-primary transition-colors hover:bg-accent-blue/80"
|
||||
>
|
||||
{t('common:action.confirm')}
|
||||
</button>
|
||||
@@ -525,6 +545,7 @@ export default function SettingsView() {
|
||||
checked={settings.autoReconnect !== false}
|
||||
onChange={(v) => setSetting('autoReconnect', v)}
|
||||
/>
|
||||
<p className="px-3 py-2 text-[11px] leading-relaxed text-txt-faint">{t('ssh.sessionPolicyHint')}</p>
|
||||
<SettingRow label={t('ssh.timeout')} description={t('ssh.timeoutDesc')}>
|
||||
<input
|
||||
type="number"
|
||||
@@ -588,12 +609,12 @@ export default function SettingsView() {
|
||||
<div className="space-y-6">
|
||||
<SettingGroup title={t('security.credentials')}>
|
||||
<SettingRow label={t('security.sessionPassword')} description={t('security.sessionPasswordDesc')}>
|
||||
<span className="border-accent-green/25 bg-accent-green/10 rounded-md border px-2 py-1 text-xs font-medium text-accent-green">
|
||||
<span className="rounded-md border border-accent-green/25 bg-accent-green/10 px-2 py-1 text-xs font-medium text-accent-green">
|
||||
{t('security.enabled')}
|
||||
</span>
|
||||
</SettingRow>
|
||||
<SettingRow label={t('security.masterKey')} description={t('security.masterKeyDesc')}>
|
||||
<code className="text-xs text-txt-muted break-all">
|
||||
<code className="break-all text-xs text-txt-muted">
|
||||
{runtimeInfo?.credentialPath ?? '~/.opsxshell/master.key'}
|
||||
</code>
|
||||
</SettingRow>
|
||||
@@ -605,71 +626,101 @@ export default function SettingsView() {
|
||||
// ──────── 关于 ─────────
|
||||
case 'about':
|
||||
return (
|
||||
<div className="max-w-2xl space-y-5">
|
||||
<div className="max-w-xl space-y-6">
|
||||
<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="flex items-center gap-4 py-1">
|
||||
<img
|
||||
src="/app-icon.png"
|
||||
alt=""
|
||||
width={64}
|
||||
height={64}
|
||||
className="h-16 w-16 flex-shrink-0 rounded-[18px] shadow-sm"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-lg font-semibold text-content-primary">OpsXShell</h3>
|
||||
<h3 className="text-lg font-semibold tracking-tight text-content-primary">OpsXShell</h3>
|
||||
<p className="mt-0.5 text-sm text-txt-muted">{t('about.tagline')}</p>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<span className="bg-accent-blue/15 border-accent-blue/20 inline-flex items-center rounded-md border px-2.5 py-1 font-mono text-xs font-medium text-accent-blue">
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-txt-faint">
|
||||
<span className="font-mono text-content-secondary">
|
||||
{t('about.appVersion', { version: appVersion })}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyText('version', diagnosticText)}
|
||||
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs text-txt-muted transition-colors hover:bg-dark-700 hover:text-content-primary"
|
||||
title={t('about.copyVersion')}
|
||||
>
|
||||
{copiedField === 'version' ? (
|
||||
<Check className="h-3.5 w-3.5 text-accent-green" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{copiedField === 'version' ? t('common:action.copied') : t('common:action.copy')}
|
||||
</button>
|
||||
{runtimeInfo ? (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<span>{formatRuntimeSystem(runtimeInfo.os, runtimeInfo.arch)}</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingCard>
|
||||
|
||||
<SettingCard>
|
||||
<SettingSectionTitle>{t('about.runtime')}</SettingSectionTitle>
|
||||
<div className="divide-dark-700/30 mt-3 divide-y text-sm">
|
||||
<div className="flex items-center justify-between gap-4 py-2">
|
||||
<span className="text-txt-muted">{t('about.system')}</span>
|
||||
<code className="text-right text-xs text-content-secondary">
|
||||
{runtimeInfo ? `${runtimeInfo.os} ${runtimeInfo.arch}` : t('common:status.reading')}
|
||||
</code>
|
||||
<SettingGroup title={t('about.resources')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copyText('wechat', WECHAT_ID)}
|
||||
aria-label={t('about.copyWechat')}
|
||||
className="hover:bg-dark-700/35 flex w-full items-center gap-3 px-4 py-3.5 text-left transition-colors"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-content-primary">{t('about.wechat')}</div>
|
||||
<div className="mt-0.5 font-mono text-xs leading-relaxed text-txt-muted">{WECHAT_ID}</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingCard>
|
||||
{copiedField === 'wechat' ? (
|
||||
<Check className="h-3.5 w-3.5 flex-shrink-0 text-accent-green" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5 flex-shrink-0 text-txt-faint" />
|
||||
)}
|
||||
</button>
|
||||
<AboutLink label={t('about.reportBug')} description={t('about.reportBugDesc')} onClick={openBugReport} />
|
||||
<AboutLink
|
||||
label={t('about.github')}
|
||||
description={t('about.githubDesc')}
|
||||
onClick={() => openHttpUrl(GITHUB_DISCUSSIONS_URL)}
|
||||
/>
|
||||
<AboutLink
|
||||
label={t('about.releases')}
|
||||
description={t('about.releasesDesc')}
|
||||
onClick={() => openHttpUrl(GITHUB_RELEASES_URL)}
|
||||
/>
|
||||
<AboutLink
|
||||
label={t('about.licenseLink')}
|
||||
description={t('about.licenseLinkDesc')}
|
||||
onClick={() => openHttpUrl(GITHUB_LICENSE_URL)}
|
||||
/>
|
||||
</SettingGroup>
|
||||
|
||||
<SettingCard>
|
||||
<SettingSectionTitle>{t('about.localData')}</SettingSectionTitle>
|
||||
<div className="mt-3 space-y-3 text-xs">
|
||||
<div>
|
||||
<div className="mb-1 text-txt-muted">{t('about.sessionsSettings')}</div>
|
||||
<code className="block break-all text-content-secondary">{runtimeInfo?.dataPath ?? t('common:status.reading')}</code>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-txt-muted">{t('about.masterKeyPath')}</div>
|
||||
<code className="block break-all text-content-secondary">
|
||||
{runtimeInfo?.credentialPath ?? '~/.opsxshell/master.key'}
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-txt-muted">{t('about.logPath')}</div>
|
||||
<code className="block break-all text-content-secondary">
|
||||
{runtimeInfo?.logPath ?? t('common:status.reading')}
|
||||
</code>
|
||||
</div>
|
||||
<p className="leading-relaxed text-txt-faint">{t('about.privacyNote')}</p>
|
||||
</div>
|
||||
</SettingCard>
|
||||
<SettingGroup title={t('about.diagnostics')}>
|
||||
<SettingRow label={t('about.logPath')}>
|
||||
<CopyValueButton
|
||||
copied={copiedField === 'log'}
|
||||
value={runtimeInfo?.logPath}
|
||||
label={t('about.copyPath')}
|
||||
onCopy={() => runtimeInfo?.logPath && void copyText('log', runtimeInfo.logPath)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label={t('about.sessionsSettings')}>
|
||||
<CopyValueButton
|
||||
copied={copiedField === 'data'}
|
||||
value={runtimeInfo?.dataPath}
|
||||
label={t('about.copyPath')}
|
||||
onCopy={() => runtimeInfo?.dataPath && void copyText('data', runtimeInfo.dataPath)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label={t('about.copyDiagnostics')} description={t('about.copyVersion')}>
|
||||
<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"
|
||||
>
|
||||
{copiedField === 'version' ? (
|
||||
<Check className="h-3.5 w-3.5 text-accent-green" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{copiedField === 'version' ? t('common:action.copied') : t('common:action.copy')}
|
||||
</button>
|
||||
</SettingRow>
|
||||
</SettingGroup>
|
||||
|
||||
<p className="px-1 text-[11px] text-txt-faint">{t('about.license')}</p>
|
||||
</div>
|
||||
@@ -833,6 +884,65 @@ function KnownHostsSettingsCard() {
|
||||
)
|
||||
}
|
||||
|
||||
function openHttpUrl(url: string) {
|
||||
const open = window.opsxApi?.openExternal
|
||||
if (open) {
|
||||
void open(url).catch(() => {
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
})
|
||||
return
|
||||
}
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
function AboutLink({ label, description, onClick }: { label: string; description: string; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="hover:bg-dark-700/35 flex w-full items-center gap-3 px-4 py-3.5 text-left transition-colors"
|
||||
>
|
||||
<div className="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>
|
||||
<ExternalLink className="h-3.5 w-3.5 flex-shrink-0 text-txt-faint" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function CopyValueButton({
|
||||
value,
|
||||
copied,
|
||||
label,
|
||||
onCopy,
|
||||
}: {
|
||||
value?: string
|
||||
copied: boolean
|
||||
label: string
|
||||
onCopy: () => void
|
||||
}) {
|
||||
if (!value) {
|
||||
return <span className="text-xs text-txt-faint">—</span>
|
||||
}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCopy}
|
||||
title={value}
|
||||
aria-label={label}
|
||||
className="inline-flex max-w-[16rem] items-center gap-1.5 rounded-md px-1.5 py-1 text-left text-xs text-content-secondary transition-colors hover:bg-dark-700 hover:text-content-primary"
|
||||
>
|
||||
<code className="truncate">{value}</code>
|
||||
{copied ? (
|
||||
<Check className="h-3.5 w-3.5 flex-shrink-0 text-accent-green" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/** 分组卡片:多行设置 */
|
||||
function SettingGroup({ title, children }: { title?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
@@ -850,7 +960,7 @@ function SettingCard({ children, highlight = false }: { children: React.ReactNod
|
||||
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'
|
||||
highlight ? 'border border-accent-blue/25 bg-accent-blue/5' : 'bg-dark-800/50 border-dark-700/45 border'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
@@ -858,11 +968,6 @@ function SettingCard({ children, highlight = false }: { children: React.ReactNod
|
||||
)
|
||||
}
|
||||
|
||||
/** 区域小标题 */
|
||||
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,
|
||||
|
||||
Reference in new issue
Block a user