Merge pull request #331 from H-Chris233/fix/issue-313-style-save-feedback

修复 Style 页保存失败后的状态回滚
This commit is contained in:
Chris233 authored and GitHub committed 2026-05-07 22:57:22 +08:00
commit fc24d37fdd
8 files changed
+240 -8

No files matched your search

+1
View File
@@ -186,6 +186,7 @@ export const en: typeof zhCN = {
masterToggle: 'Master switch',
currentDefault: 'Current default',
ariaSetDefault: 'Set as default',
saveFailed: 'Save failed: {{error}}',
modes: {
raw: { name: 'Raw', desc: 'Only adds punctuation and natural breaks — no rewriting or expansion.', sample: "Keeps spoken cadence; fillers like 'um' or 'you know' get dropped, but sentences stay intact." },
light: { name: 'Light polish', desc: 'Drops fillers, adds punctuation, and produces sendable natural prose.', sample: "Makes the transcript flow well without sounding scripted — your tone and habits remain." },
+1
View File
@@ -188,6 +188,7 @@ export const ja: typeof zhCN = {
masterToggle: '全体有効化',
currentDefault: '現在のデフォルト',
ariaSetDefault: 'デフォルトに設定',
saveFailed: '保存に失敗しました: {{error}}',
modes: {
raw: { name: '原文', desc: '句読点と必要な区切りのみ補い、書き換えや拡張はしません。', sample: '元の話し言葉を保持。「えー」「あの」などの口癖は除去しますが、文の組み替えはしません。' },
light: { name: '軽い整文', desc: '口癖の除去、句読点の補完、自然な送信可能テキストへの整理。', sample: '原稿読み上げのようにならないよう、語気と表現の癖を残しつつ、文章をなめらかにします。' },
+1
View File
@@ -188,6 +188,7 @@ export const ko: typeof zhCN = {
masterToggle: '전체 활성화',
currentDefault: '현재 기본',
ariaSetDefault: '기본으로 설정',
saveFailed: '저장 실패: {{error}}',
modes: {
raw: { name: '원문', desc: '구두점과 필요한 문장 구분만 보충하고 다시 쓰거나 확장하지 않습니다.', sample: '원래 구어체 유지. "음", "그게" 같은 입버릇은 제거하지만 문장을 재구성하지 않습니다.' },
light: { name: '가벼운 정리', desc: '입버릇 제거, 구두점 보충, 자연스럽게 보낼 수 있는 텍스트로 정리합니다.', sample: '원고를 읽는 듯한 느낌이 들지 않도록 어조와 표현 습관은 남기되, 문장이 매끄럽게 흐르도록 합니다.' },
+1
View File
@@ -184,6 +184,7 @@ export const zhCN = {
masterToggle: '整体启用',
currentDefault: '当前默认',
ariaSetDefault: '设为默认',
saveFailed: '保存失败:{{error}}',
modes: {
raw: { name: '原文', desc: '只补标点和必要分句,不改写不扩写。', sample: '保留原始口语;嗯、那个等口癖会被去除,但不会重组语句。' },
light: { name: '轻度润色', desc: '去口癖、补标点,整理为可发送的自然文字。', sample: '让转写听起来不像念稿——保留语气和表达习惯,但行文流畅。' },
+1
View File
@@ -186,6 +186,7 @@ export const zhTW: typeof zhCN = {
masterToggle: '整體啓用',
currentDefault: '當前默認',
ariaSetDefault: '設爲默認',
saveFailed: '保存失敗:{{error}}',
modes: {
raw: { name: '原文', desc: '只補標點和必要分句,不改寫不擴寫。', sample: '保留原始口語;嗯、那個等口癖會被去除,但不會重組語句。' },
light: { name: '輕度潤色', desc: '去口癖、補標點,整理爲可發送的自然文字。', sample: '讓轉寫聽起來不像念稿——保留語氣和表達習慣,但行文流暢。' },
+103
View File
@@ -0,0 +1,103 @@
import {
persistStylePreferenceChange,
rollbackStyleEnabledChange,
rollbackWholeStylePreferences,
} from './stylePrefs';
import type { UserPreferences } from './types';
function assert(condition: boolean, message: string) {
if (!condition) throw new Error(message);
}
const previousPrefs: UserPreferences = {
hotkey: { trigger: 'rightOption', mode: 'toggle' },
dictationHotkey: { primary: 'RightOption', modifiers: [] },
defaultMode: 'light',
enabledModes: ['raw', 'light', 'structured'],
launchAtLogin: false,
showCapsule: true,
muteDuringRecording: false,
microphoneDeviceName: '',
activeAsrProvider: 'volcengine',
activeLlmProvider: 'ark',
restoreClipboardAfterPaste: true,
allowNonTsfInsertionFallback: true,
workingLanguages: ['简体中文'],
translationTargetLanguage: '',
chineseScriptPreference: 'auto',
outputLanguagePreference: 'auto',
qaHotkey: null,
qaSaveHistory: false,
customComboHotkey: null,
translationHotkey: { primary: 'Shift', modifiers: [] },
switchStyleHotkey: { primary: 'S', modifiers: ['alt'] },
openAppHotkey: { primary: 'O', modifiers: ['alt'] },
localAsrActiveModel: '',
localAsrMirror: 'huggingface',
localAsrKeepLoadedSecs: 300,
foundryLocalAsrModel: '',
foundryLocalAsrLanguageHint: '',
foundryLocalAsrKeepLoadedSecs: 300,
};
const nextPrefs: UserPreferences = {
...previousPrefs,
enabledModes: [],
};
const states: UserPreferences[] = [];
const errors: string[] = [];
let firstCurrentPrefs: UserPreferences | null = previousPrefs;
const saved = await persistStylePreferenceChange(
nextPrefs,
async () => {
throw 'disk full';
},
update => {
firstCurrentPrefs = typeof update === 'function' ? update(firstCurrentPrefs) : update;
if (firstCurrentPrefs) states.push(firstCurrentPrefs);
},
message => errors.push(message),
rollbackWholeStylePreferences(previousPrefs, nextPrefs),
);
assert(saved === false, 'setSettings reject should report save failure');
assert(states.length === 2, `expected optimistic state then rollback, got ${states.length} updates`);
assert(states[0] === nextPrefs, 'first state update should be the optimistic next prefs');
assert(
states[1].enabledModes.join(',') === previousPrefs.enabledModes.join(','),
'second state update should roll back enabled modes to previous prefs',
);
assert(errors[0] === 'disk full', `expected backend error message, got ${errors[0]}`);
let currentPrefs: UserPreferences | null = previousPrefs;
const disableLightPrefs: UserPreferences = {
...previousPrefs,
enabledModes: ['raw', 'structured'],
};
const disableStructuredAfterLightPrefs: UserPreferences = {
...previousPrefs,
enabledModes: ['raw'],
};
const overlapSaved = await persistStylePreferenceChange(
disableLightPrefs,
async () => {
currentPrefs = disableStructuredAfterLightPrefs;
throw 'slow failure';
},
update => {
currentPrefs = typeof update === 'function' ? update(currentPrefs) : update;
},
() => undefined,
rollbackStyleEnabledChange('light', previousPrefs, disableLightPrefs),
);
assert(overlapSaved === false, 'overlapped style save should still report failure');
assert(
currentPrefs?.enabledModes.includes('light') === true,
'failed light toggle should roll back only the light mode',
);
assert(
currentPrefs?.enabledModes.includes('structured') === false,
'failed light toggle should preserve newer structured edit',
);
+70
View File
@@ -0,0 +1,70 @@
import type { PolishMode, UserPreferences } from './types';
type StylePrefsState = UserPreferences | null;
type StylePrefsSetter = (prefs: StylePrefsState | ((current: StylePrefsState) => StylePrefsState)) => void;
type StylePrefsRollback = (current: StylePrefsState) => StylePrefsState;
export function styleSaveErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
if (typeof error === 'string') return error;
return String(error);
}
export async function persistStylePreferenceChange(
nextPrefs: UserPreferences,
persist: () => Promise<void>,
setPrefs: StylePrefsSetter,
onFailure: (message: string) => void,
rollback: StylePrefsRollback,
): Promise<boolean> {
setPrefs(nextPrefs);
try {
await persist();
return true;
} catch (error) {
setPrefs(current => rollback(current));
onFailure(styleSaveErrorMessage(error));
return false;
}
}
export function rollbackDefaultModeChange(
previousPrefs: UserPreferences,
nextPrefs: UserPreferences,
): StylePrefsRollback {
return current => {
if (!current || current.defaultMode !== nextPrefs.defaultMode) return current;
return { ...current, defaultMode: previousPrefs.defaultMode };
};
}
export function rollbackStyleEnabledChange(
mode: PolishMode,
previousPrefs: UserPreferences,
nextPrefs: UserPreferences,
): StylePrefsRollback {
const previousEnabled = previousPrefs.enabledModes.includes(mode);
const nextEnabled = nextPrefs.enabledModes.includes(mode);
return current => {
if (!current || current.enabledModes.includes(mode) !== nextEnabled) return current;
const enabledModes = previousEnabled
? Array.from(new Set([...current.enabledModes, mode]))
: current.enabledModes.filter(m => m !== mode);
return { ...current, enabledModes };
};
}
export function rollbackWholeStylePreferences(
previousPrefs: UserPreferences,
nextPrefs: UserPreferences,
): StylePrefsRollback {
return current => {
if (!current || !sameModes(current.enabledModes, nextPrefs.enabledModes)) return current;
return { ...current, enabledModes: previousPrefs.enabledModes };
};
}
function sameModes(left: PolishMode[], right: PolishMode[]): boolean {
if (left.length !== right.length) return false;
return left.every((mode, index) => mode === right[index]);
}
+62 -8
View File
@@ -5,6 +5,12 @@ import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { getSettings, setDefaultPolishMode, setStyleEnabled, setSettings } from '../lib/ipc';
import type { PolishMode, UserPreferences } from '../lib/types';
import {
persistStylePreferenceChange,
rollbackDefaultModeChange,
rollbackStyleEnabledChange,
rollbackWholeStylePreferences,
} from '../lib/stylePrefs';
import { PageHeader, Pill } from './_atoms';
interface StyleDef {
@@ -15,6 +21,7 @@ interface StyleDef {
}
const STYLE_IDS: PolishMode[] = ['raw', 'light', 'structured', 'formal'];
type StyleSaveErrorTarget = PolishMode | 'master';
export function Style() {
const { t } = useTranslation();
@@ -25,15 +32,27 @@ export function Style() {
sample: t(`style.modes.${id}.sample`),
}));
const [prefs, setPrefs] = useState<UserPreferences | null>(null);
const [saveError, setSaveError] = useState<{ target: StyleSaveErrorTarget; message: string } | null>(null);
useEffect(() => {
getSettings().then(setPrefs);
}, []);
const showSaveError = (target: StyleSaveErrorTarget, error: string) => {
setSaveError({ target, message: t('style.saveFailed', { error }) });
};
const onPickDefault = async (mode: PolishMode) => {
if (!prefs) return;
setPrefs({ ...prefs, defaultMode: mode });
await setDefaultPolishMode(mode);
const next = { ...prefs, defaultMode: mode };
const saved = await persistStylePreferenceChange(
next,
() => setDefaultPolishMode(mode),
setPrefs,
error => showSaveError(mode, error),
rollbackDefaultModeChange(prefs, next),
);
if (saved) setSaveError(null);
};
const onToggleEnabled = async (mode: PolishMode) => {
@@ -42,8 +61,15 @@ export function Style() {
const nextEnabled = enabled
? [...prefs.enabledModes, mode]
: prefs.enabledModes.filter(m => m !== mode);
setPrefs({ ...prefs, enabledModes: nextEnabled });
await setStyleEnabled(mode, enabled);
const next = { ...prefs, enabledModes: nextEnabled };
const saved = await persistStylePreferenceChange(
next,
() => setStyleEnabled(mode, enabled),
setPrefs,
error => showSaveError(mode, error),
rollbackStyleEnabledChange(mode, prefs, next),
);
if (saved) setSaveError(null);
};
if (!prefs) {
@@ -63,12 +89,24 @@ export function Style() {
if (masterEnabled) {
// 全部关闭 → 留 raw 和当前 default 兜底
const next = { ...prefs, enabledModes: [] as PolishMode[] };
setPrefs(next);
await setSettings(next);
const saved = await persistStylePreferenceChange(
next,
() => setSettings(next),
setPrefs,
error => showSaveError('master', error),
rollbackWholeStylePreferences(prefs, next),
);
if (saved) setSaveError(null);
} else {
const next = { ...prefs, enabledModes: ['raw', 'light', 'structured', 'formal'] as PolishMode[] };
setPrefs(next);
await setSettings(next);
const saved = await persistStylePreferenceChange(
next,
() => setSettings(next),
setPrefs,
error => showSaveError('master', error),
rollbackWholeStylePreferences(prefs, next),
);
if (saved) setSaveError(null);
}
};
@@ -97,6 +135,14 @@ export function Style() {
}}
/>
</button>
{saveError?.target === 'master' && (
<span
role="alert"
style={{ fontSize: 11.5, color: 'var(--ol-red, #ef4444)', maxWidth: 220, lineHeight: 1.45 }}
>
{saveError.message}
</span>
)}
</div>
}
/>
@@ -176,6 +222,14 @@ export function Style() {
>
{s.sample}
</div>
{saveError?.target === s.id && (
<div
role="alert"
style={{ marginTop: 10, fontSize: 11.5, color: 'var(--ol-red, #ef4444)', lineHeight: 1.45 }}
>
{saveError.message}
</div>
)}
</div>
);
})}