chore: 保存工作副本——客户端授权新契约(双端点/验签/令牌/周检)与桌面会话可靠性改进

This commit is contained in:
Logan.Li committed 2026-09-14 22:10:22 +08:00
1 parent f71eef0eba
commit ae6093c9b3
163 files changed
+4911 -2494

No files matched your search

+97 -84
View File
@@ -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>
}
+178 -73
View File
@@ -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,