From df8846913a32204aa73e1c3f7c028344fb513a66 Mon Sep 17 00:00:00 2001 From: Chris233 Date: Thu, 30 Apr 2026 14:58:53 +0800 Subject: [PATCH] =?UTF-8?q?fix(settings):=20=E9=81=BF=E5=85=8D=E5=87=AD?= =?UTF-8?q?=E6=8D=AE=E5=8A=A0=E8=BD=BD=E5=89=8D=E8=A2=AB=E7=A9=BA=E5=80=BC?= =?UTF-8?q?=E8=A6=86=E7=9B=96=20(#82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Prevent untouched credential fields from clearing secrets Settings credential inputs can mount empty while readCredential is still pending. The field now waits for a successful read before enabling saves, tracks whether the user actually edited the value, and surfaces read failures without writing an empty credential back to storage. Constraint: Issue #34 requires preserving existing credentials when the settings page is opened or blurred without edits. Constraint: Keep secrets out of logs and UI. Rejected: Add full E2E coverage now | larger test harness work than needed for the minimal bug fix. Confidence: high Scope-risk: narrow Tested: npm install Tested: npm run build Tested: cargo check Tested: git diff --check * Keep credential fields recoverable after read errors Review feedback identified that read failures left fields disabled while the temporary status disappeared. Read errors now leave the field interactive with a persistent readError status, and the credential-field status union is named explicitly for safer helper signatures. Constraint: Preserve the issue #34 guard that only dirty loaded fields save credentials. Rejected: Broader state-machine rewrite | unnecessary for this targeted review fix. Confidence: high Scope-risk: narrow Tested: npm run build Tested: cargo check Tested: git diff --check --- openless-all/app/src/i18n/en.ts | 3 ++ openless-all/app/src/i18n/zh-CN.ts | 3 ++ openless-all/app/src/pages/Settings.tsx | 48 ++++++++++++++++--------- 3 files changed, 37 insertions(+), 17 deletions(-) diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 826e4a2f..6781dfaf 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -19,7 +19,9 @@ export const en: typeof zhCN = { show: 'Show', hide: 'Hide', saved: 'Saved', + saving: 'Saving…', copied: 'Copied', + operationFailed: 'Operation failed', add: 'Add', }, capsule: { @@ -189,6 +191,7 @@ export const en: typeof zhCN = { asrWhisper: 'OpenAI Whisper (compatible)', }, fillDefault: 'Fill default value', + readFailed: 'Read failed', }, shortcuts: { title: 'Shortcut reference', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index a8b92296..fad775b9 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -17,7 +17,9 @@ export const zhCN = { show: '显示', hide: '隐藏', saved: '已保存', + saving: '保存中', copied: '已复制', + operationFailed: '操作失败', add: '添加', }, capsule: { @@ -187,6 +189,7 @@ export const zhCN = { asrWhisper: 'OpenAI Whisper(兼容)', }, fillDefault: '填入默认值', + readFailed: '读取失败', }, shortcuts: { title: '快捷键速查', diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index 666319f6..fd71926d 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -336,6 +336,8 @@ function ProvidersSection() { ); } +type CredentialFieldStatus = 'idle' | 'saving' | 'saved' | 'readError' | 'saveError' | 'copied' | 'copyError'; + interface CredentialFieldProps { label: string; account: string; @@ -351,14 +353,16 @@ function CredentialField({ label, account, placeholder, mono, mask, defaultValue const [revealed, setRevealed] = useState(false); const [loaded, setLoaded] = useState(false); const [dirty, setDirty] = useState(false); - const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'saveError' | 'copied' | 'copyError'>('idle'); + const [status, setStatus] = useState('idle'); const debounceRef = useRef | null>(null); + const statusRef = useRef | null>(null); useEffect(() => { let cancelled = false; setLoaded(false); setDirty(false); setStatus('idle'); + setValue(''); if (debounceRef.current) { clearTimeout(debounceRef.current); debounceRef.current = null; @@ -373,7 +377,7 @@ function CredentialField({ label, account, placeholder, mono, mask, defaultValue if (cancelled) return; console.error('[settings] failed to read credential', account, error); setLoaded(true); - setStatus('saveError'); + setStatus('readError'); }); return () => { cancelled = true; @@ -383,26 +387,33 @@ function CredentialField({ label, account, placeholder, mono, mask, defaultValue useEffect(() => { return () => { if (debounceRef.current) clearTimeout(debounceRef.current); + if (statusRef.current) clearTimeout(statusRef.current); }; }, []); + const showTemporaryStatus = (next: CredentialFieldStatus) => { + setStatus(next); + if (statusRef.current) clearTimeout(statusRef.current); + statusRef.current = window.setTimeout(() => setStatus('idle'), 1600); + }; + const save = async (v: string) => { - if (!loaded) return; + if (!loaded || !dirty) return; setStatus('saving'); try { await setCredential(account, v); setDirty(false); - setStatus('saved'); + showTemporaryStatus('saved'); } catch (error) { console.error('[settings] failed to save credential', account, error); - setStatus('saveError'); + showTemporaryStatus('saveError'); } - window.setTimeout(() => setStatus('idle'), 1600); }; const handleChange = (e: React.ChangeEvent) => { const v = e.target.value; setValue(v); + if (!loaded) return; setDirty(true); if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => save(v), 300); @@ -425,21 +436,21 @@ function CredentialField({ label, account, placeholder, mono, mask, defaultValue }; const onCopy = async () => { - if (!value) return; + if (!value || !loaded) return; try { if (!navigator.clipboard?.writeText) { throw new Error('Clipboard API unavailable'); } await navigator.clipboard.writeText(value); - setStatus('copied'); + showTemporaryStatus('copied'); } catch (error) { console.error('[settings] failed to copy credential', account, error); - setStatus('copyError'); + showTemporaryStatus('copyError'); } - window.setTimeout(() => setStatus('idle'), 1600); }; const inputType = mask && !revealed ? 'password' : 'text'; + const disabled = !loaded; return ( @@ -447,13 +458,13 @@ function CredentialField({ label, account, placeholder, mono, mask, defaultValue - {defaultValue && !value && ( + {defaultValue && !value && loaded && ( @@ -463,6 +474,7 @@ function CredentialField({ label, account, placeholder, mono, mask, defaultValue onClick={() => setRevealed(r => !r)} title={revealed ? t('common.hide') : t('common.show')} style={iconBtnStyle} + disabled={disabled} > @@ -471,7 +483,7 @@ function CredentialField({ label, account, placeholder, mono, mask, defaultValue onClick={onCopy} title={t('common.copy')} style={iconBtnStyle} - disabled={!value} + disabled={!value || disabled} > @@ -484,12 +496,14 @@ function CredentialField({ label, account, placeholder, mono, mask, defaultValue }} > {status === 'saving' - ? '保存中' + ? t('common.saving') : status === 'saved' ? t('common.saved') : status === 'copied' - ? '已复制' - : '操作失败'} + ? t('common.copied') + : status === 'readError' + ? t('settings.providers.readFailed') + : t('common.operationFailed')} )}