Merge pull request #331 from H-Chris233/fix/issue-313-style-save-feedback
修复 Style 页保存失败后的状态回滚
This commit is contained in:
8 files changed
+240
-8
No files matched your search
@@ -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." },
|
||||
|
||||
@@ -188,6 +188,7 @@ export const ja: typeof zhCN = {
|
||||
masterToggle: '全体有効化',
|
||||
currentDefault: '現在のデフォルト',
|
||||
ariaSetDefault: 'デフォルトに設定',
|
||||
saveFailed: '保存に失敗しました: {{error}}',
|
||||
modes: {
|
||||
raw: { name: '原文', desc: '句読点と必要な区切りのみ補い、書き換えや拡張はしません。', sample: '元の話し言葉を保持。「えー」「あの」などの口癖は除去しますが、文の組み替えはしません。' },
|
||||
light: { name: '軽い整文', desc: '口癖の除去、句読点の補完、自然な送信可能テキストへの整理。', sample: '原稿読み上げのようにならないよう、語気と表現の癖を残しつつ、文章をなめらかにします。' },
|
||||
|
||||
@@ -188,6 +188,7 @@ export const ko: typeof zhCN = {
|
||||
masterToggle: '전체 활성화',
|
||||
currentDefault: '현재 기본',
|
||||
ariaSetDefault: '기본으로 설정',
|
||||
saveFailed: '저장 실패: {{error}}',
|
||||
modes: {
|
||||
raw: { name: '원문', desc: '구두점과 필요한 문장 구분만 보충하고 다시 쓰거나 확장하지 않습니다.', sample: '원래 구어체 유지. "음", "그게" 같은 입버릇은 제거하지만 문장을 재구성하지 않습니다.' },
|
||||
light: { name: '가벼운 정리', desc: '입버릇 제거, 구두점 보충, 자연스럽게 보낼 수 있는 텍스트로 정리합니다.', sample: '원고를 읽는 듯한 느낌이 들지 않도록 어조와 표현 습관은 남기되, 문장이 매끄럽게 흐르도록 합니다.' },
|
||||
|
||||
@@ -184,6 +184,7 @@ export const zhCN = {
|
||||
masterToggle: '整体启用',
|
||||
currentDefault: '当前默认',
|
||||
ariaSetDefault: '设为默认',
|
||||
saveFailed: '保存失败:{{error}}',
|
||||
modes: {
|
||||
raw: { name: '原文', desc: '只补标点和必要分句,不改写不扩写。', sample: '保留原始口语;嗯、那个等口癖会被去除,但不会重组语句。' },
|
||||
light: { name: '轻度润色', desc: '去口癖、补标点,整理为可发送的自然文字。', sample: '让转写听起来不像念稿——保留语气和表达习惯,但行文流畅。' },
|
||||
|
||||
@@ -186,6 +186,7 @@ export const zhTW: typeof zhCN = {
|
||||
masterToggle: '整體啓用',
|
||||
currentDefault: '當前默認',
|
||||
ariaSetDefault: '設爲默認',
|
||||
saveFailed: '保存失敗:{{error}}',
|
||||
modes: {
|
||||
raw: { name: '原文', desc: '只補標點和必要分句,不改寫不擴寫。', sample: '保留原始口語;嗯、那個等口癖會被去除,但不會重組語句。' },
|
||||
light: { name: '輕度潤色', desc: '去口癖、補標點,整理爲可發送的自然文字。', sample: '讓轉寫聽起來不像念稿——保留語氣和表達習慣,但行文流暢。' },
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in new issue
Block a user