diff --git a/client/src/locales/data-de.json b/client/src/locales/data-de.json index 985bb9e..d3f13fa 100644 --- a/client/src/locales/data-de.json +++ b/client/src/locales/data-de.json @@ -270,6 +270,23 @@ "closeDrawer": "Schließen", "toastBatchApplied": "Stapelbearbeitung angewendet", "toastBatchParseError": "Ungültiges Stapelbearbeitungsformat", + "undo": "Rückgängig", + "toastUndo": "Rückgängig: {{detail}}", + "toastUndoEmpty": "Rückgängig gemacht", + "undoBandParam": "{{mode}} Band {{band}} {{param}} {{from}} → {{to}}", + "undoSimpleParam": "{{param}} {{from}} → {{to}}", + "undoMultiple": "{{count}} Änderungen ({{summary}})", + "undoMoreSuffix": " (+{{count}} weitere)", + "undoDetailSep": "; ", + "undoParamFreq": "FREQ", + "undoParamGain": "GAIN", + "undoParamQ": "Q", + "undoParamType": "Filter", + "undoParamPreamp": "Preamp", + "undoParamAuto": "AUTO", + "undoParamAbMode": "A/B", + "undoAutoOn": "EIN", + "undoAutoOff": "AUS", "toastBrandsFail": "Laden der Marken fehlgeschlagen", "toastModelsFail": "Laden der Modelle fehlgeschlagen", "toastCurveFail": "Laden der Kurve fehlgeschlagen", diff --git a/client/src/locales/data-en.json b/client/src/locales/data-en.json index 5360642..9bc24bb 100644 --- a/client/src/locales/data-en.json +++ b/client/src/locales/data-en.json @@ -270,6 +270,23 @@ "closeDrawer": "Close", "toastBatchApplied": "Batch edit applied", "toastBatchParseError": "Invalid batch edit format", + "undo": "Undo", + "toastUndo": "Undone: {{detail}}", + "toastUndoEmpty": "Undone", + "undoBandParam": "{{mode}} Band {{band}} {{param}} {{from}} → {{to}}", + "undoSimpleParam": "{{param}} {{from}} → {{to}}", + "undoMultiple": "{{count}} changes ({{summary}})", + "undoMoreSuffix": " (+{{count}} more)", + "undoDetailSep": "; ", + "undoParamFreq": "FREQ", + "undoParamGain": "GAIN", + "undoParamQ": "Q", + "undoParamType": "Filter", + "undoParamPreamp": "Preamp", + "undoParamAuto": "AUTO", + "undoParamAbMode": "A/B", + "undoAutoOn": "ON", + "undoAutoOff": "OFF", "toastBrandsFail": "Failed to load brands", "toastModelsFail": "Failed to load models", "toastCurveFail": "Failed to load curve", diff --git a/client/src/locales/data-es.json b/client/src/locales/data-es.json index 4db571c..9c94c39 100644 --- a/client/src/locales/data-es.json +++ b/client/src/locales/data-es.json @@ -270,6 +270,23 @@ "closeDrawer": "Cerrar", "toastBatchApplied": "Edición por lotes aplicada", "toastBatchParseError": "Formato de edición por lotes inválido", + "undo": "Deshacer", + "toastUndo": "Deshecho: {{detail}}", + "toastUndoEmpty": "Deshecho", + "undoBandParam": "{{mode}} Banda {{band}} {{param}} {{from}} → {{to}}", + "undoSimpleParam": "{{param}} {{from}} → {{to}}", + "undoMultiple": "{{count}} cambios ({{summary}})", + "undoMoreSuffix": " (+{{count}} más)", + "undoDetailSep": "; ", + "undoParamFreq": "FREQ", + "undoParamGain": "GAIN", + "undoParamQ": "Q", + "undoParamType": "Filtro", + "undoParamPreamp": "Preamp", + "undoParamAuto": "AUTO", + "undoParamAbMode": "A/B", + "undoAutoOn": "ON", + "undoAutoOff": "OFF", "toastBrandsFail": "Error al cargar las marcas", "toastModelsFail": "Error al cargar los modelos", "toastCurveFail": "Error al cargar la curva", diff --git a/client/src/locales/data-fr.json b/client/src/locales/data-fr.json index b61559d..e5241f5 100644 --- a/client/src/locales/data-fr.json +++ b/client/src/locales/data-fr.json @@ -270,6 +270,23 @@ "closeDrawer": "Fermer", "toastBatchApplied": "Édition par lot appliquée", "toastBatchParseError": "Format d'édition par lot invalide", + "undo": "Annuler", + "toastUndo": "Annulé : {{detail}}", + "toastUndoEmpty": "Annulé", + "undoBandParam": "{{mode}} Bande {{band}} {{param}} {{from}} → {{to}}", + "undoSimpleParam": "{{param}} {{from}} → {{to}}", + "undoMultiple": "{{count}} modifications ({{summary}})", + "undoMoreSuffix": " (+{{count}})", + "undoDetailSep": " ; ", + "undoParamFreq": "FREQ", + "undoParamGain": "GAIN", + "undoParamQ": "Q", + "undoParamType": "Filtre", + "undoParamPreamp": "Preamp", + "undoParamAuto": "AUTO", + "undoParamAbMode": "A/B", + "undoAutoOn": "ON", + "undoAutoOff": "OFF", "toastBrandsFail": "Échec du chargement des marques", "toastModelsFail": "Échec du chargement des modèles", "toastCurveFail": "Échec du chargement de la courbe", diff --git a/client/src/locales/data-zh-HK.json b/client/src/locales/data-zh-HK.json index e2a1a98..abc3cf0 100644 --- a/client/src/locales/data-zh-HK.json +++ b/client/src/locales/data-zh-HK.json @@ -263,6 +263,23 @@ "closeDrawer": "關閉", "toastBatchApplied": "批次編輯已套用", "toastBatchParseError": "批次編輯格式錯誤", + "undo": "復原", + "toastUndo": "已復原:{{detail}}", + "toastUndoEmpty": "已復原", + "undoBandParam": "{{mode}} 頻段{{band}} {{param}} {{from}} → {{to}}", + "undoSimpleParam": "{{param}} {{from}} → {{to}}", + "undoMultiple": "{{count}} 項變更({{summary}})", + "undoMoreSuffix": "(另有 {{count}} 項)", + "undoDetailSep": ";", + "undoParamFreq": "頻率", + "undoParamGain": "增益", + "undoParamQ": "Q", + "undoParamType": "濾波器", + "undoParamPreamp": "總增益", + "undoParamAuto": "AUTO", + "undoParamAbMode": "A/B", + "undoAutoOn": "開", + "undoAutoOff": "關", "toastBrandsFail": "取得品牌列表失敗", "toastModelsFail": "取得型號列表失敗", "toastCurveFail": "取得曲線失敗", diff --git a/client/src/locales/data-zh.json b/client/src/locales/data-zh.json index 4f699f9..d31fdcd 100644 --- a/client/src/locales/data-zh.json +++ b/client/src/locales/data-zh.json @@ -263,6 +263,23 @@ "closeDrawer": "关闭", "toastBatchApplied": "批量编辑已应用", "toastBatchParseError": "批量编辑格式错误", + "undo": "撤销", + "toastUndo": "已撤销:{{detail}}", + "toastUndoEmpty": "已撤销", + "undoBandParam": "{{mode}} 频段{{band}} {{param}} {{from}} → {{to}}", + "undoSimpleParam": "{{param}} {{from}} → {{to}}", + "undoMultiple": "{{count}} 项变更({{summary}})", + "undoMoreSuffix": "(另有 {{count}} 项)", + "undoDetailSep": ";", + "undoParamFreq": "频率", + "undoParamGain": "增益", + "undoParamQ": "Q", + "undoParamType": "滤波器", + "undoParamPreamp": "总增益", + "undoParamAuto": "AUTO", + "undoParamAbMode": "A/B", + "undoAutoOn": "开", + "undoAutoOff": "关", "toastBrandsFail": "获取品牌列表失败", "toastModelsFail": "获取型号列表失败", "toastCurveFail": "获取曲线失败", diff --git a/client/src/pages/EQPage.tsx b/client/src/pages/EQPage.tsx index e733307..ebbe6fe 100644 --- a/client/src/pages/EQPage.tsx +++ b/client/src/pages/EQPage.tsx @@ -87,7 +87,13 @@ import { ShareDialog } from "./eq/components/ShareDialog"; import { BrandDrawer } from "./eq/components/BrandDrawer"; import { PeqOverwriteConfirmDialog } from "./eq/components/PeqOverwriteConfirmDialog"; import { PeqPresetManageDialog } from "./eq/components/PeqPresetManageDialog"; +import { PeqUndoButton } from "./eq/components/PeqUndoButton"; import { useRawCurve } from "./eq/hooks/useRawCurve"; +import { + usePeqUndoHistory, + type PeqUndoSnapshot, +} from "./eq/hooks/usePeqUndoHistory"; +import { formatPeqUndoToast } from "./eq/peqUndoDiff"; import { isPeqPresetNameTooLong } from "./eq/peqPresetName"; /* ── Default bands imported from eq/constants ── */ @@ -199,6 +205,14 @@ export default function EQPage() { setRawCurveLoading, loadRawCurveForPeq, } = useRawCurve(); + const { + canUndo, + clear: clearPeqUndo, + captureBefore, + beginGesture, + commitGesture, + undo: popPeqUndo, + } = usePeqUndoHistory(); const allowPeqRemoteSyncRef = useRef(false); const peqHydrationPendingRef = useRef(true); const lastPeqCatalogSyncKeyRef = useRef(""); @@ -261,6 +275,32 @@ export default function EQPage() { const preampValue = Number(currentPeq?.preamp ?? 0); const autoPreOn = (currentPeq?.autoPre ?? 0) === 1; + const undoSnapshotSourceRef = useRef({ + abMode, + bandsByMode, + preamp: preampValue, + autoPre: autoPreOn ? 1 : 0, + }); + undoSnapshotSourceRef.current = { + abMode, + bandsByMode, + preamp: preampValue, + autoPre: autoPreOn ? 1 : 0, + }; + + const takeUndoSnapshot = useCallback((): PeqUndoSnapshot => { + const src = undoSnapshotSourceRef.current; + return { + abMode: src.abMode, + bandsByMode: { + A: cloneBands(src.bandsByMode.A), + B: cloneBands(src.bandsByMode.B), + }, + preamp: src.preamp, + autoPre: src.autoPre, + }; + }, []); + const guardPeqPresetCapacity = () => { if (peqItems.length < PEQ_PRESET_MAX) return true; toast.error( @@ -436,6 +476,7 @@ export default function EQPage() { const handleSaveBatchEdit = () => { try { const parsed = parseBatchEditText(batchEditText); + captureBefore(takeUndoSnapshot()); let updatedPeq: | { name: string; autoPre?: number; preamp?: number; canDel?: number } | undefined; @@ -1062,6 +1103,17 @@ export default function EQPage() { ].join("\u0001"); }, [headphoneIdx, peqItems]); + // 切换预设或离开页面时清空撤销栈 + useEffect(() => { + clearPeqUndo(); + }, [headphoneSwitchKey, clearPeqUndo]); + + useEffect(() => { + return () => { + clearPeqUndo(); + }; + }, [clearPeqUndo]); + // 切换耳机型号后,从 peqItems 加载该型号的 filters(暂停一次上报避免错写) useEffect(() => { const peq = peqItems[headphoneIdx]; @@ -1241,6 +1293,7 @@ export default function EQPage() { const handleCopyAndSwitchTo = useCallback( (to: "A" | "B") => { const from = abMode; + captureBefore(takeUndoSnapshot()); const copied = cloneBands(bandsByMode[from]); copyModeParams(from, to); skipBandsSyncFromAbToggleRef.current = true; @@ -1250,10 +1303,12 @@ export default function EQPage() { [ abMode, bandsByMode, + captureBefore, copyModeParams, headphoneIdx, peqItems, schedulePeqSync, + takeUndoSnapshot, ] ); @@ -1310,6 +1365,8 @@ export default function EQPage() { return; } + captureBefore(takeUndoSnapshot()); + const bBands = cloneBands(bandsByMode.B); const filters = bBands.map(bandToPeqFilter); const payload: PeqChangePayload = { @@ -1355,11 +1412,13 @@ export default function EQPage() { } }, [ bandsByMode.B, + captureBefore, eqUi.toastApplyBFail, eqUi.toastApplyBSuccess, headphoneIdx, peqItems, rememberPeqPresetCache, + takeUndoSnapshot, upgradePeqChange, ]); @@ -1385,17 +1444,22 @@ export default function EQPage() { }); }, []); - const beginBandSliderDrag = useCallback((kind: BandParamKind) => { - bandSliderDragRef.current = kind; - }, []); + const beginBandSliderDrag = useCallback( + (kind: BandParamKind) => { + bandSliderDragRef.current = kind; + beginGesture(takeUndoSnapshot()); + }, + [beginGesture, takeUndoSnapshot] + ); const endBandSliderDrag = useCallback( (kind: BandParamKind) => { if (bandSliderDragRef.current !== kind) return; bandSliderDragRef.current = null; clearBandSliderPreviewField(kind); + commitGesture(takeUndoSnapshot()); }, - [clearBandSliderPreviewField] + [clearBandSliderPreviewField, commitGesture, takeUndoSnapshot] ); const handleBandSliderChange = useCallback( @@ -1485,6 +1549,7 @@ export default function EQPage() { toast.error(parsed.message); return; } + captureBefore(takeUndoSnapshot()); bandSliderDragRef.current = null; if (bandParamDialog === "freq") updateBand(selectedBand, { freq: parsed.value }); @@ -1514,6 +1579,54 @@ export default function EQPage() { schedulePeqSync(updatedPeq, bands, abMode, 0, "byMode", headphoneIdx); }; + const beginPreampSliderDrag = () => { + beginGesture(takeUndoSnapshot()); + }; + + const endPreampSliderDrag = () => { + commitGesture(takeUndoSnapshot()); + }; + + const handleAutoPreChange = (v: boolean) => { + captureBefore(takeUndoSnapshot()); + updateCurrentPeqMeta({ autoPre: v ? 1 : 0 }); + }; + + const handleUndo = () => { + const current = takeUndoSnapshot(); + const snap = popPeqUndo(); + if (!snap) return; + const toastMsg = formatPeqUndoToast(current, snap, eqUi); + setAbMode(snap.abMode); + setBandsByMode({ + A: cloneBands( + snap.bandsByMode.A as unknown as typeof DEFAULT_BANDS + ), + B: cloneBands( + snap.bandsByMode.B as unknown as typeof DEFAULT_BANDS + ), + }); + setPeqItems(prev => + prev.map((item, i) => { + if (i !== headphoneIdx) return item; + const filters = snap.bandsByMode.A.map(bandToPeqFilter); + const merged = { + ...item, + preamp: snap.preamp, + autoPre: snap.autoPre, + filters, + }; + rememberPeqPresetCache(item.name, { + filters, + preamp: snap.preamp, + autoPre: snap.autoPre, + }); + return merged; + }) + ); + toast.success(toastMsg); + }; + return (