diff --git a/.changeset/settings-provider-card-cleanup.md b/.changeset/settings-provider-card-cleanup.md new file mode 100644 index 0000000..0f2abe5 --- /dev/null +++ b/.changeset/settings-provider-card-cleanup.md @@ -0,0 +1,13 @@ +--- +'@open-codesign/desktop': patch +--- + +feat(desktop): simplify provider cards in Settings → API 服务 + +Each provider card now collapses to a single header row (label, masked key, +optional base URL) with the active badge or a compact "set active" button on +the right. The model selector is hidden on non-active providers (they don't +drive any generation) and shown as a click-to-edit chip on the active card. +Per-row actions move into a `···` overflow menu (Test connection on the +active card, Re-enter key, Delete with inline confirm), and the duplicated +dashed "Add provider" button at the bottom of the list is removed. diff --git a/apps/desktop/src/renderer/src/components/Settings.tsx b/apps/desktop/src/renderer/src/components/Settings.tsx index c86cc53..171fe24 100644 --- a/apps/desktop/src/renderer/src/components/Settings.tsx +++ b/apps/desktop/src/renderer/src/components/Settings.tsx @@ -18,14 +18,15 @@ import { Cpu, FolderOpen, Globe, + KeyRound, Loader2, + MoreHorizontal, Palette, Plus, RotateCcw, Sliders, Trash2, X, - Zap, } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; import type { AppPaths, Preferences, ProviderRow } from '../../../preload/index'; @@ -507,6 +508,127 @@ function AddProviderModal({ ); } +function ProviderOverflowMenu({ + isActive, + hasError, + onTestConnection, + onReEnterKey, + onDelete, + label, +}: { + isActive: boolean; + hasError: boolean; + onTestConnection: () => void; + onReEnterKey: () => void; + onDelete: () => void; + label: string; +}) { + const t = useT(); + const [open, setOpen] = useState(false); + const [confirmDelete, setConfirmDelete] = useState(false); + const wrapRef = useRef(null); + + useEffect(() => { + if (!open) return; + function onDocClick(e: MouseEvent) { + if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) { + setOpen(false); + setConfirmDelete(false); + } + } + document.addEventListener('mousedown', onDocClick); + return () => document.removeEventListener('mousedown', onDocClick); + }, [open]); + + function close() { + setOpen(false); + setConfirmDelete(false); + } + + const itemClass = + 'w-full flex items-center gap-2 px-2.5 py-1.5 text-left text-[var(--text-xs)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] transition-colors'; + + return ( +
+ + {open && ( +
+ {isActive && !hasError && ( + + )} + + {confirmDelete ? ( +
+ + +
+ ) : ( + + )} +
+ )} +
+ ); +} + function ProviderCard({ row, config, @@ -521,8 +643,8 @@ function ProviderCard({ onReEnterKey: (p: SupportedOnboardingProvider) => void; }) { const t = useT(); + const pushToast = useCodesignStore((s) => s.pushToast); const label = SHORTLIST[row.provider]?.label ?? row.provider; - const [confirmDelete, setConfirmDelete] = useState(false); const hasError = row.error !== undefined; const stateClass = hasError @@ -531,27 +653,50 @@ function ProviderCard({ ? 'border-[var(--color-border)] border-l-[var(--size-accent-stripe)] border-l-[var(--color-accent)] bg-[var(--color-accent-tint)]' : 'border-[var(--color-border)] bg-[var(--color-surface)]'; + async function handleTestConnection() { + if (!window.codesign) { + pushToast({ + variant: 'error', + title: t('settings.providers.toast.connectionFailed'), + description: t('settings.common.unknownError'), + }); + return; + } + try { + const res = await window.codesign.connection.testActive(); + if (res.ok) { + pushToast({ variant: 'success', title: t('settings.providers.toast.connectionOk') }); + } else { + pushToast({ + variant: 'error', + title: t('settings.providers.toast.connectionFailed'), + description: res.hint || res.message, + }); + } + } catch (err) { + pushToast({ + variant: 'error', + title: t('settings.providers.toast.connectionFailed'), + description: err instanceof Error ? err.message : t('settings.common.unknownError'), + }); + } + } + return (
-
-
+
+
{label} - {row.isActive && !hasError && ( - - {t('settings.providers.active')} - - )} - {hasError && ( + {hasError ? ( {t('settings.providers.decryptionFailed')} - )} - {!hasError && ( + ) : ( {row.maskedKey} @@ -564,7 +709,12 @@ function ProviderCard({ )}
-
+
+ {row.isActive && !hasError && ( + + {t('settings.providers.active')} + + )} {!row.isActive && !hasError && ( - -
- ) : ( - - )} + onReEnterKey(row.provider)} + onDelete={() => onDelete(row.provider)} + label={label} + />
@@ -637,50 +765,69 @@ function ActiveModelSelector({ const pushToast = useCodesignStore((s) => s.pushToast); const [primary, setPrimary] = useState(config.modelPrimary ?? sl.defaultPrimary); - const saveTimeout = useRef | null>(null); + const [editing, setEditing] = useState(false); useEffect(() => { setPrimary(config.modelPrimary ?? sl.defaultPrimary); }, [config.modelPrimary, sl.defaultPrimary]); - useEffect(() => { - return () => { - if (saveTimeout.current !== null) { - clearTimeout(saveTimeout.current); - saveTimeout.current = null; - } - }; - }, []); + // Monotonic counter to guard against overlapping save races: if a later + // save has already fired, a stale failure from an earlier save must NOT + // roll back the UI to the prior-to-earlier value. + const saveSeq = useRef(0); - async function save(p: string) { - if (!window.codesign) return; + async function save(next: string): Promise { + if (!window.codesign) { + pushToast({ + variant: 'error', + title: t('settings.providers.toast.modelSaveFailed'), + description: t('settings.common.unknownError'), + }); + return false; + } try { - const next = await window.codesign.settings.setActiveProvider({ + const updated = await window.codesign.settings.setActiveProvider({ provider, - modelPrimary: p, + modelPrimary: next, }); - setConfig(next); + setConfig(updated); + return true; } catch (err) { pushToast({ variant: 'error', title: t('settings.providers.toast.modelSaveFailed'), description: err instanceof Error ? err.message : t('settings.common.unknownError'), }); + return false; } } - function handlePrimaryChange(v: string) { + function handleChange(v: string) { + const prev = primary; + const seq = ++saveSeq.current; setPrimary(v); - if (saveTimeout.current !== null) clearTimeout(saveTimeout.current); - saveTimeout.current = setTimeout(() => void save(v), 400); + setEditing(false); + void save(v).then((ok) => { + if (!ok && seq === saveSeq.current) setPrimary(prev); + }); } return ( -
-

- {t('settings.providers.primary')} -

- +
+ + {editing ? ( + + ) : ( + + )}
); } @@ -820,14 +967,6 @@ function ModelsTab() { onReEnterKey={setReEnterProvider} /> ))} -
)}
diff --git a/packages/i18n/src/locales/en.json b/packages/i18n/src/locales/en.json index a619c69..97d7524 100644 --- a/packages/i18n/src/locales/en.json +++ b/packages/i18n/src/locales/en.json @@ -114,6 +114,10 @@ "setActive": "Set active", "reEnterKey": "Re-enter key", "confirm": "Confirm", + "delete": "Delete", + "testConnection": "Test connection", + "moreActions": "More actions", + "editModel": "Change model", "deleteAria": "Delete {{label}} provider", "primary": "Primary", "fast": "Fast", @@ -140,7 +144,9 @@ "switchedTo": "Switched to {{label}}", "switchFailed": "Switch failed", "saved": "Provider saved", - "modelSaveFailed": "Failed to save model selection" + "modelSaveFailed": "Failed to save model selection", + "connectionOk": "Connection OK", + "connectionFailed": "Connection failed" } }, "appearance": { diff --git a/packages/i18n/src/locales/zh-CN.json b/packages/i18n/src/locales/zh-CN.json index dc68bc1..b4b1ab2 100644 --- a/packages/i18n/src/locales/zh-CN.json +++ b/packages/i18n/src/locales/zh-CN.json @@ -113,6 +113,10 @@ "setActive": "设为当前", "reEnterKey": "重新输入 Key", "confirm": "确认", + "delete": "删除", + "testConnection": "测试连接", + "moreActions": "更多操作", + "editModel": "更改模型", "deleteAria": "删除 {{label}} 服务", "primary": "主力", "fast": "快速", @@ -139,7 +143,9 @@ "switchedTo": "已切换到 {{label}}", "switchFailed": "切换失败", "saved": "服务已保存", - "modelSaveFailed": "保存模型选择失败" + "modelSaveFailed": "保存模型选择失败", + "connectionOk": "连接正常", + "connectionFailed": "连接失败" } }, "appearance": {