From 614f12adf3174a4dadac028bda27057d18831a81 Mon Sep 17 00:00:00 2001 From: roymondchen Date: Mon, 8 Jun 2026 17:04:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(editor):=20=E6=94=AF=E6=8C=81=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E8=AE=B0=E5=BD=95=E6=8C=81=E4=B9=85=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- docs/api/editor/historyServiceEvents.md | 33 +++ docs/api/editor/historyServiceMethods.md | 116 +++++++++ .../src/layouts/history-list/Bucket.vue | 21 +- .../src/layouts/history-list/BucketTab.vue | 62 +++-- .../src/layouts/history-list/GroupRow.vue | 14 + .../layouts/history-list/HistoryListPanel.vue | 68 ++++- .../src/layouts/history-list/PageTab.vue | 84 +++--- .../src/layouts/history-list/composables.ts | 16 ++ packages/editor/src/services/history.ts | 191 ++++++++++++++ .../editor/src/theme/history-list-panel.scss | 36 +++ packages/editor/src/type.ts | 111 ++++---- packages/editor/src/utils/index.ts | 1 + packages/editor/src/utils/indexed-db.ts | 122 +++++++++ packages/editor/src/utils/undo-redo.ts | 77 ++++++ .../unit/services/history-persist.spec.ts | 240 ++++++++++++++++++ .../editor/tests/unit/utils/undo-redo.spec.ts | 117 +++++++++ playground/src/pages/Editor.vue | 45 +++- 17 files changed, 1233 insertions(+), 121 deletions(-) create mode 100644 packages/editor/src/utils/indexed-db.ts create mode 100644 packages/editor/tests/unit/services/history-persist.spec.ts diff --git a/docs/api/editor/historyServiceEvents.md b/docs/api/editor/historyServiceEvents.md index 7a19adf6..c5f992dd 100644 --- a/docs/api/editor/historyServiceEvents.md +++ b/docs/api/editor/historyServiceEvents.md @@ -73,3 +73,36 @@ - 删除触发的 step 中 `newSchema` 为 `null` - `undo` / `redo` 返回 `null`(边界状态)时不会触发该事件 ::: + +## mark-saved + +- **详情:** 调用 `markSaved` / `markPageSaved` / `markCodeBlockSaved` / `markDataSourceSaved` 标记「已保存」记录时触发 + +- **事件回调函数:** `(payload: { kind: 'all' | 'page' | 'code-block' | 'data-source'; id?: Id }) => void` + + ::: tip + - `markSaved` 触发时 `kind` 为 `all`,无 `id` + - 细粒度方法触发时 `kind` 对应类别,`id` 为目标页面 / 代码块 / 数据源 id + ::: + +## save-to-indexed-db + +- **详情:** `saveToIndexedDB` 把历史记录写入本地 IndexedDB 成功时触发 + +- **事件回调函数:** `(snapshot: PersistedHistoryState) => void` + + ::: details 查看 PersistedHistoryState 类型定义 + <<< @/../packages/editor/src/type.ts#PersistedHistoryState{ts} + + <<< @/../packages/editor/src/utils/undo-redo.ts#SerializedUndoRedo{ts} + ::: + +## restore-from-indexed-db + +- **详情:** `restoreFromIndexedDB` 从本地 IndexedDB 读取并重建历史记录成功时触发(找不到记录时不触发) + +- **事件回调函数:** `(snapshot: PersistedHistoryState) => void` + + ::: details 查看 PersistedHistoryState 类型定义 + <<< @/../packages/editor/src/type.ts#PersistedHistoryState{ts} + ::: diff --git a/docs/api/editor/historyServiceMethods.md b/docs/api/editor/historyServiceMethods.md index a419e2e5..73c47076 100644 --- a/docs/api/editor/historyServiceMethods.md +++ b/docs/api/editor/historyServiceMethods.md @@ -260,6 +260,122 @@ 指定数据源当前是否可重做。栈不存在时返回 `false`。 +## markSaved + +- **详情:** + + 标记「整份 DSL 已保存」:把页面 / 代码块 / 数据源所有栈当前游标所在的记录都标记为已保存(`saved = true`)。 + + 同一栈内任意时刻最多保留一条已保存记录(标记前会清除该栈内全部旧标记);某个栈处于「全部已撤销」(cursor 为 0)时不会留下已保存记录,从 IndexedDB 恢复时其游标会回到 0。 + + 通常在 DSL 整体落库(保存到后端 / 本地)成功后调用,配合 [`restoreFromIndexedDB`](#restorefromindexeddb) 把游标恢复到此处。仅保存了其中一类时请改用更细粒度的 `markPageSaved` / `markCodeBlockSaved` / `markDataSourceSaved`。 + + 调用后会触发 `mark-saved` 事件(`{ kind: 'all' }`)。 + +## markPageSaved + +- **参数:** + - `{Id} pageId` 可选;缺省为当前活动页 + +- **详情:** + + 标记指定页面(缺省当前活动页)历史栈的当前记录为已保存,仅影响该页面自己的栈。触发 `mark-saved` 事件(`{ kind: 'page', id }`)。 + +## markCodeBlockSaved + +- **参数:** + - `{Id} codeBlockId` + +- **详情:** + + 标记指定代码块历史栈的当前记录为已保存,仅影响该代码块自己的栈。触发 `mark-saved` 事件(`{ kind: 'code-block', id }`)。 + +## markDataSourceSaved + +- **参数:** + - `{Id} dataSourceId` + +- **详情:** + + 标记指定数据源历史栈的当前记录为已保存,仅影响该数据源自己的栈。触发 `mark-saved` 事件(`{ kind: 'data-source', id }`)。 + +## clearPage + +- **参数:** + - `{Id} pageId` 可选;缺省为当前活动页 + +- **详情:** + + 清空指定页面(缺省当前活动页)的历史记录栈。仅删除撤销/重做记录,不会改动当前 DSL;清空后该页将无法再撤销/重做之前的操作。清空当前活动页时会同步刷新 `canUndo` / `canRedo` 并触发 `change` 事件。 + +## clearCodeBlock + +- **参数:** + - `{Id} codeBlockId` 可选;缺省清空全部代码块 + +- **详情:** + + 清空代码块历史记录栈:传入 `codeBlockId` 仅清空该代码块,缺省清空全部代码块。仅删除撤销/重做记录,不会改动代码块本身。 + +## clearDataSource + +- **参数:** + - `{Id} dataSourceId` 可选;缺省清空全部数据源 + +- **详情:** + + 清空数据源历史记录栈:传入 `dataSourceId` 仅清空该数据源,缺省清空全部数据源。仅删除撤销/重做记录,不会改动数据源本身。 + +## saveToIndexedDB + +- **参数:** + - `{HistoryPersistOptions} options` 可选 + + ::: details 查看 HistoryPersistOptions / PersistedHistoryState 类型定义 + <<< @/../packages/editor/src/type.ts#HistoryPersistOptions{ts} + + <<< @/../packages/editor/src/type.ts#PersistedHistoryState{ts} + + <<< @/../packages/editor/src/utils/undo-redo.ts#SerializedUndoRedo{ts} + ::: + +- **返回:** + - `{Promise}` 写入成功的快照对象 + +- **详情:** + + 把当前内存中的全部历史栈(页面 / 代码块 / 数据源)连同各自游标、容量序列化后写入本地 IndexedDB。 + + - 最终库名为 `${dbName}-${当前 DSL app id}`,按应用隔离; + - `key` 用于在同一 store 下区分不同记录,缺省为 `default`; + - 历史记录里可能包含函数(代码块内容 / 节点事件等),内部使用 `serialize-javascript` 序列化为字符串后写入,恢复时再用 `parseDSL` 还原,因此可安全持久化函数 / `Map` 等; + - 不支持 IndexedDB 的环境(如 SSR)会 reject。 + + 写入成功后触发 `save-to-indexed-db` 事件。 + + ::: warning + `beforeunload` / `pagehide` 阶段浏览器不会等待异步 IndexedDB 事务提交,单纯依赖卸载时写入可能丢失最近一次编辑。建议在历史变更时(防抖)即调用本方法持久化,确保刷新后能完整恢复。 + ::: + +## restoreFromIndexedDB + +- **参数:** + - `{HistoryPersistOptions} options` 可选 + +- **返回:** + - `{Promise}` 找不到记录时返回 `null` + +- **详情:** + + 从本地 IndexedDB 读取此前保存的历史快照并重建全部撤销/重做栈。 + + - 每个栈都会按 `listMaxSize` 裁剪并还原游标; + - 若某个栈存在已保存记录(见 `markSaved`),其游标会被定位到「最近一条已保存记录」之后,使恢复后的状态与落库的 DSL 对齐; + - 会整体覆盖当前内存中的历史状态,并把活动页恢复为快照中的 `pageId`; + - 找不到对应记录时返回 `null` 且不改动当前状态;不支持 IndexedDB 的环境会 reject。 + + 成功后触发 `restore-from-indexed-db` 与 `change` 事件。 + ## destroy - **详情:** diff --git a/packages/editor/src/layouts/history-list/Bucket.vue b/packages/editor/src/layouts/history-list/Bucket.vue index 4ce7b286..2dcd7125 100644 --- a/packages/editor/src/layouts/history-list/Bucket.vue +++ b/packages/editor/src/layouts/history-list/Bucket.vue @@ -20,10 +20,11 @@ :time-title="formatHistoryFullTime(groupTimestamp(group))" :step-count="group.steps.length" :sub-steps=" - group.steps.map((s: any) => ({ + group.steps.map((s) => ({ index: s.index, applied: s.applied, isCurrent: s.isCurrent, + saved: s.step.saved, desc: describeStep(s.step), diffable: isStepDiffable ? isStepDiffable(s.step) : false, revertable: s.applied && (isStepRevertable ? isStepRevertable(s.step) : true), @@ -55,11 +56,12 @@ - diff --git a/packages/editor/src/layouts/history-list/GroupRow.vue b/packages/editor/src/layouts/history-list/GroupRow.vue index ffcfaacd..4a7c7024 100644 --- a/packages/editor/src/layouts/history-list/GroupRow.vue +++ b/packages/editor/src/layouts/history-list/GroupRow.vue @@ -13,6 +13,8 @@ {{ opLabel(opType) }} {{ desc }} + 已保存 + #{{ s.index + 1 }} {{ s.desc }} + 已保存 { return ''; }; +/** + * 头部是否展示「已保存」标记: + * - 单步组:取该唯一子步的 saved; + * - 合并组:组内任一子步为已保存即在头部提示(具体落在哪一步可展开查看)。 + */ +const headSaved = computed(() => + props.merged ? props.subSteps.some((s) => s.saved) : Boolean(props.subSteps[0]?.saved), +); + /** 单步组头部是否展示"查看差异"入口:要求该唯一子步本身可对比。 */ const headDiffable = computed(() => !props.merged && Boolean(props.subSteps[0]?.diffable)); diff --git a/packages/editor/src/layouts/history-list/HistoryListPanel.vue b/packages/editor/src/layouts/history-list/HistoryListPanel.vue index b3a7c93e..d0fcbf77 100644 --- a/packages/editor/src/layouts/history-list/HistoryListPanel.vue +++ b/packages/editor/src/layouts/history-list/HistoryListPanel.vue @@ -28,6 +28,7 @@ @goto-initial="onPageGotoInitial" @diff-step="onPageDiff" @revert-step="onPageRevert" + @clear="onPageClear" /> @@ -50,6 +51,7 @@ @goto-initial="onDataSourceGotoInitial" @diff-step="onDataSourceDiff" @revert-step="onDataSourceRevert" + @clear="onDataSourceClear" /> @@ -72,6 +74,7 @@ @goto-initial="onCodeBlockGotoInitial" @diff-step="onCodeBlockDiff" @revert-step="onCodeBlockRevert" + @clear="onCodeBlockClear" /> @@ -130,7 +133,14 @@ import { computed, inject, markRaw, ref, shallowRef, useTemplateRef, watch } from 'vue'; import { Clock, Close } from '@element-plus/icons-vue'; -import { getDesignConfig, TMagicButton, TMagicPopover, TMagicTabs, TMagicTooltip } from '@tmagic/design'; +import { + getDesignConfig, + TMagicButton, + tMagicMessageBox, + TMagicPopover, + TMagicTabs, + TMagicTooltip, +} from '@tmagic/design'; import type { FormState } from '@tmagic/form'; import MIcon from '@editor/components/Icon.vue'; @@ -381,4 +391,60 @@ const onCodeBlockRevert = (id: string | number, index: number) => { const onDiffDialogClose = () => { onConfirmRevert.value = undefined; }; + +/** + * 「清空历史记录」入口:先弹出二次确认,确认后清空对应类别的历史栈。 + * 仅删除撤销/重做记录,不会改动当前 DSL / 数据源 / 代码块本身。 + * 用户取消(confirm reject)时静默忽略。 + */ +const confirmClear = async (message: string): Promise => { + try { + await tMagicMessageBox.confirm(message, '提示', { + confirmButtonText: '确定', + cancelButtonText: '取消', + type: 'warning', + }); + return true; + // eslint-disable-next-line no-unused-vars + } catch (e) { + return false; + } +}; + +/** + * 把内存中(已清空对应类别后的)历史状态重新写回 IndexedDB, + * 使本地持久化的那份与内存保持一致——即「连同本地保存的一并删除」。 + * 不支持 IndexedDB 或写入失败时静默忽略(内存清空已生效)。 + */ +const syncIndexedDB = async () => { + try { + await historyService.saveToIndexedDB(); + // eslint-disable-next-line no-unused-vars + } catch (e) { + // ignore: 内存清空已生效,本地同步失败不阻塞交互 + } +}; + +const onPageClear = async () => { + if ( + await confirmClear('确定清空当前页面的历史记录吗?清空后将无法撤销/重做之前的操作,本地保存的记录也会一并删除。') + ) { + historyService.clearPage(); + await syncIndexedDB(); + } +}; + +const onDataSourceClear = async () => { + if (await confirmClear('确定清空数据源的历史记录吗?清空后将无法撤销/重做之前的操作,本地保存的记录也会一并删除。')) { + historyService.clearDataSource(); + await syncIndexedDB(); + } +}; + +const onCodeBlockClear = async () => { + if (await confirmClear('确定清空代码块的历史记录吗?清空后将无法撤销/重做之前的操作,本地保存的记录也会一并删除。')) { + historyService.clearCodeBlock(); + await syncIndexedDB(); + } +}; diff --git a/packages/editor/src/layouts/history-list/PageTab.vue b/packages/editor/src/layouts/history-list/PageTab.vue index 09562357..13bb982d 100644 --- a/packages/editor/src/layouts/history-list/PageTab.vue +++ b/packages/editor/src/layouts/history-list/PageTab.vue @@ -1,46 +1,52 @@