From 27b2c2c68598264e97a1e1ecc34121829851c85e Mon Sep 17 00:00:00 2001 From: roymondchen Date: Thu, 4 Jun 2026 16:08:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(editor):=20=E5=8E=86=E5=8F=B2=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E6=94=AF=E6=8C=81=E6=93=8D=E4=BD=9C=E6=9D=A5=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api/editor/codeBlockServiceMethods.md | 9 ++ docs/api/editor/dataSourceServiceMethods.md | 6 + docs/api/editor/editorServiceMethods.md | 51 +++++++ docs/api/editor/historyServiceEvents.md | 6 + docs/api/editor/historyServiceMethods.md | 12 ++ .../editor/src/hooks/use-code-block-edit.ts | 7 +- .../editor/src/hooks/use-data-source-edit.ts | 4 +- packages/editor/src/hooks/use-stage.ts | 6 +- packages/editor/src/layouts/NavMenu.vue | 2 +- .../src/layouts/history-list/Bucket.vue | 4 +- .../src/layouts/history-list/GroupRow.vue | 21 ++- .../src/layouts/history-list/PageTab.vue | 3 + .../src/layouts/history-list/composables.ts | 27 ++++ .../src/layouts/props-panel/PropsPanel.vue | 6 +- .../layouts/sidebar/ComponentListPanel.vue | 14 +- .../sidebar/code-block/CodeBlockList.vue | 10 +- .../sidebar/code-block/CodeBlockListPanel.vue | 2 +- .../sidebar/code-block/useContentMenu.ts | 2 +- .../data-source/DataSourceListPanel.vue | 2 +- .../sidebar/data-source/useContentMenu.ts | 2 +- .../src/layouts/sidebar/layer/LayerMenu.vue | 30 ++-- .../layouts/sidebar/layer/LayerNodeTool.vue | 11 +- .../src/layouts/workspace/viewer/Stage.vue | 2 +- .../layouts/workspace/viewer/ViewerMenu.vue | 16 +- packages/editor/src/services/codeBlock.ts | 40 +++-- packages/editor/src/services/dataSource.ts | 36 ++++- packages/editor/src/services/editor.ts | 139 +++++++++++++----- packages/editor/src/services/history.ts | 7 + packages/editor/src/services/keybinding.ts | 6 +- .../editor/src/theme/history-list-panel.scss | 14 ++ packages/editor/src/type.ts | 58 +++++++- packages/editor/src/utils/content-menu.ts | 27 ++-- .../unit/hooks/use-code-block-edit.spec.ts | 20 ++- .../editor/tests/unit/hooks/use-stage.spec.ts | 2 +- .../sidebar/ComponentListPanel.spec.ts | 4 +- .../sidebar/code-block/useContentMenu.spec.ts | 8 +- .../data-source/DataSourceListPanel.spec.ts | 2 +- .../data-source/useContentMenu.spec.ts | 2 +- .../layouts/sidebar/layer/LayerMenu.spec.ts | 8 +- .../sidebar/layer/LayerNodeTool.spec.ts | 4 +- .../workspace/viewer/ViewerMenu.spec.ts | 4 +- .../unit/utils/content-menu-utils.spec.ts | 8 +- 42 files changed, 513 insertions(+), 131 deletions(-) diff --git a/docs/api/editor/codeBlockServiceMethods.md b/docs/api/editor/codeBlockServiceMethods.md index 9db0b6ba..d2e64dac 100644 --- a/docs/api/editor/codeBlockServiceMethods.md +++ b/docs/api/editor/codeBlockServiceMethods.md @@ -1,5 +1,8 @@ # codeBlockService方法 +写入历史栈的方法([setCodeDslById](#setcodedslbyid)、[setCodeDslByIdSync](#setcodedslbyidsync)、[deleteCodeDslByIds](#deletecodedslbyids) 等)的 `options` 支持 +[historyDescription / historySource](./editorServiceMethods.md#历史记录相关-options),会透传到 `historyService.pushCodeBlock` 的 `historyDescription` / `source` 字段。 + ## setCodeDsl - **参数:** @@ -51,6 +54,8 @@ - `{Object}` options 可选配置 - {`ChangeRecord`[]} changeRecords form 端变更记录,用于历史记录的精细化撤销/重做 - `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false) + - `{string}` historyDescription 见 [editorService 历史记录相关 options](./editorServiceMethods.md#历史记录相关-options) + - `{HistoryOpSource}` historySource 见 [editorService 历史记录相关 options](./editorServiceMethods.md#历史记录相关-options) ::: details 查看 ChangeRecord 类型定义 <<< @/../packages/form-schema/src/base.ts#ChangeRecord{ts} @@ -72,6 +77,8 @@ - `{Object}` options 可选配置 - {`ChangeRecord`[]} changeRecords form 端变更记录,用于历史记录的精细化撤销/重做 - `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false) + - `{string}` historyDescription 见 [editorService 历史记录相关 options](./editorServiceMethods.md#历史记录相关-options) + - `{HistoryOpSource}` historySource 见 [editorService 历史记录相关 options](./editorServiceMethods.md#历史记录相关-options) - **返回:** - `{void}` @@ -213,6 +220,8 @@ - `{(string | number)[]}` codeIds 需要删除的代码块id数组 - `{Object}` options 可选配置 - `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false) + - `{string}` historyDescription 见 [editorService 历史记录相关 options](./editorServiceMethods.md#历史记录相关-options) + - `{HistoryOpSource}` historySource 见 [editorService 历史记录相关 options](./editorServiceMethods.md#历史记录相关-options) - **返回:** - `{Promise}` diff --git a/docs/api/editor/dataSourceServiceMethods.md b/docs/api/editor/dataSourceServiceMethods.md index 146a9a36..ae57e392 100644 --- a/docs/api/editor/dataSourceServiceMethods.md +++ b/docs/api/editor/dataSourceServiceMethods.md @@ -300,6 +300,8 @@ dataSourceService.setFormMethod("http", [ - {`DataSourceSchema`} config 数据源配置 - `{Object}` options 可选配置 - `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false) + - `{string}` historyDescription 见 [editorService 历史记录相关 options](./editorServiceMethods.md#历史记录相关-options) + - `{HistoryOpSource}` historySource 见 [editorService 历史记录相关 options](./editorServiceMethods.md#历史记录相关-options) - **返回:** - {`DataSourceSchema`} 添加后的数据源配置 @@ -338,6 +340,8 @@ console.log(newDs.id); // 自动生成的id - `{Object}` options 可选配置 - {`ChangeRecord`[]} changeRecords 变更记录 - `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false) + - `{string}` historyDescription 见 [editorService 历史记录相关 options](./editorServiceMethods.md#历史记录相关-options) + - `{HistoryOpSource}` historySource 见 [editorService 历史记录相关 options](./editorServiceMethods.md#历史记录相关-options) ::: details 查看 ChangeRecord 类型定义 <<< @/../packages/form-schema/src/base.ts#ChangeRecord{ts} @@ -379,6 +383,8 @@ console.log(updatedDs); - `{string}` id 数据源id - `{Object}` options 可选配置 - `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false) + - `{string}` historyDescription 见 [editorService 历史记录相关 options](./editorServiceMethods.md#历史记录相关-options) + - `{HistoryOpSource}` historySource 见 [editorService 历史记录相关 options](./editorServiceMethods.md#历史记录相关-options) - **返回:** - `{void}` diff --git a/docs/api/editor/editorServiceMethods.md b/docs/api/editor/editorServiceMethods.md index b0dc783a..76527b0b 100644 --- a/docs/api/editor/editorServiceMethods.md +++ b/docs/api/editor/editorServiceMethods.md @@ -1,5 +1,25 @@ # editorService方法 +## 历史记录相关 options + +下列 DSL 操作方法([add](#add)、[remove](#remove)、[update](#update) 等)的 `options` / `data` 参数,以及 +[codeBlockService](./codeBlockServiceMethods.md) / [dataSourceService](./dataSourceServiceMethods.md) +的 `options`,在 `doNotPushHistory` 之外还可传入: + +- `{string}` **historyDescription**:入栈时附带的人类可读描述,用于历史面板展示;不影响 undo/redo 行为,缺省时面板会自动生成描述 +- `{HistoryOpSource}` **historySource**:操作途径,用于历史面板展示与埋点;不影响 undo/redo 行为,缺省时面板视为「未知」 + +编辑器内置交互(画布、树面板、配置面板、右键菜单、快捷键等)会自动传入对应的 `historySource`; +业务侧程序化调用时建议显式传入(如 `api`),便于历史面板区分来源。 + +::: details 查看 HistoryOpOptions / DslOpOptions / HistoryOpSource 类型定义 +<<< @/../packages/editor/src/type.ts#HistoryOpOptions{ts} + +<<< @/../packages/editor/src/type.ts#DslOpOptions{ts} + +<<< @/../packages/editor/src/type.ts#HistoryOpSource{ts} +::: + ## get - **参数:** @@ -359,6 +379,8 @@ editorService.highlight("text_123"); - `{boolean}` doNotSelect 添加后是否不更新当前选中节点(默认 false,添加后会选中新增的节点) - `{boolean}` doNotSwitchPage 添加后是否不切换当前页面(默认 false;新增页面 / 跨页新增时为 true 会跳过会引发页面切换的选中操作) - `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false) + - `{string}` historyDescription 见[历史记录相关 options](#历史记录相关-options) + - `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options) - **返回:** - {Promise<`MNode` | `MNode`[]>} 新增的组件或组件集合 @@ -405,6 +427,8 @@ editorService.highlight("text_123"); - `{boolean}` doNotSelect 删除后是否不更新当前选中节点(默认 false,删除后会选中父节点或首个页面) - `{boolean}` doNotSwitchPage 删除后是否不切换当前页面(默认 false;删除页面 / 页面片段时为 true 会跳过自动切换到首个剩余页面) - `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false) + - `{string}` historyDescription 见[历史记录相关 options](#历史记录相关-options) + - `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options) - **返回:** - `{Promise}` @@ -459,6 +483,8 @@ editorService.highlight("text_123"); - {`ChangeRecord`[]} changeRecords 单节点 form 端变更记录(多节点场景下被忽略,使用 `changeRecordList`) - {`ChangeRecord`[][]} changeRecordList 多节点 form 端变更记录列表,按 `config` 数组同序对应每个节点;优先级高于 `changeRecords` - `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false) + - `{string}` historyDescription 见[历史记录相关 options](#历史记录相关-options) + - `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options) ::: details 查看 ChangeRecord 类型定义 <<< @/../packages/form-schema/src/base.ts#ChangeRecord{ts} @@ -500,6 +526,7 @@ editorService.highlight("text_123"); - `{boolean}` doNotSelect 排序后是否不更新当前选中节点(默认 false) - `{boolean}` doNotSwitchPage 排序后是否不切换当前页面(排序只发生在同一父节点内,方法内为空操作;保留以与其它 DSL 操作 API 一致) - `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false) + - `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options) - **返回:** - `{Promise}` @@ -568,6 +595,8 @@ editorService.highlight("text_123"); - `{boolean}` doNotSelect 粘贴后是否不更新当前选中节点(默认 false) - `{boolean}` doNotSwitchPage 粘贴后是否不切换当前页面(默认 false;跨页粘贴时为 true 会跳过页面切换) - `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false) + - `{string}` historyDescription 见[历史记录相关 options](#历史记录相关-options) + - `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options) - **返回:** - {Promise<`MNode` | `MNode`[]>} 添加后的组件节点配置 @@ -606,6 +635,8 @@ editorService.highlight("text_123"); - `{boolean}` doNotSelect 居中后是否不更新当前选中节点(默认 false) - `{boolean}` doNotSwitchPage 居中后是否不切换当前页面(居中只更新节点 style,方法内为空操作;保留以与其它 DSL 操作 API 一致) - `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false) + - `{string}` historyDescription 见[历史记录相关 options](#历史记录相关-options) + - `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options) - **返回:** - {Promise<`MNode` | `MNode`[]>} @@ -628,6 +659,8 @@ alignCenter可以支持一次水平居中多个组件,alignCenter是通过调 - `{number | 'top' | 'bottom'}` offset - `{Object}` options 可选配置 - `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false) + - `{string}` historyDescription 见[历史记录相关 options](#历史记录相关-options) + - `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options) - **返回:** - `{Promise}` @@ -649,6 +682,8 @@ alignCenter可以支持一次水平居中多个组件,alignCenter是通过调 - `{boolean}` doNotSelect 移动后是否不更新当前选中节点(默认 false) - `{boolean}` doNotSwitchPage 移动后是否不切换当前页面(默认 false;目标容器位于其它页面时为 true 会跳过自动选中以避免页面切换) - `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false) + - `{string}` historyDescription 见[历史记录相关 options](#历史记录相关-options) + - `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options) - **返回:** - Promise<`MNode` | undefined> @@ -665,6 +700,8 @@ alignCenter可以支持一次水平居中多个组件,alignCenter是通过调 - `{number}` targetIndex 目标位置索引 - `{Object}` options 可选配置 - `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false) + - `{string}` historyDescription 见[历史记录相关 options](#历史记录相关-options) + - `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options) - **返回:** - `{Promise}` @@ -685,6 +722,8 @@ alignCenter可以支持一次水平居中多个组件,alignCenter是通过调 <<< @/../packages/editor/src/type.ts#HistoryOpType{ts} + <<< @/../packages/editor/src/type.ts#HistoryOpSource{ts} + <<< @/../packages/schema/src/index.ts#Id{ts} ::: @@ -699,6 +738,16 @@ alignCenter可以支持一次水平居中多个组件,alignCenter是通过调 - **返回:** - {Promise<`StepValue` | null>} + ::: details 查看 StepValue 及关联类型定义 + <<< @/../packages/editor/src/type.ts#StepValue{ts} + + <<< @/../packages/editor/src/type.ts#HistoryOpType{ts} + + <<< @/../packages/editor/src/type.ts#HistoryOpSource{ts} + + <<< @/../packages/schema/src/index.ts#Id{ts} + ::: + - **详情:** 恢复到下一步 @@ -712,6 +761,8 @@ alignCenter可以支持一次水平居中多个组件,alignCenter是通过调 - `{number}` top - `{Object}` options 可选配置 - `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false) + - `{string}` historyDescription 见[历史记录相关 options](#历史记录相关-options) + - `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options) - **返回:** - `{Promise}` diff --git a/docs/api/editor/historyServiceEvents.md b/docs/api/editor/historyServiceEvents.md index 87ce79ee..7a19adf6 100644 --- a/docs/api/editor/historyServiceEvents.md +++ b/docs/api/editor/historyServiceEvents.md @@ -21,6 +21,8 @@ <<< @/../packages/editor/src/type.ts#HistoryOpType{ts} + <<< @/../packages/editor/src/type.ts#HistoryOpSource{ts} + <<< @/../packages/schema/src/index.ts#Id{ts} <<< @/../packages/schema/src/index.ts#MNode{ts} @@ -39,6 +41,8 @@ ::: details 查看 CodeBlockStepValue 及关联类型定义 <<< @/../packages/editor/src/type.ts#CodeBlockStepValue{ts} + <<< @/../packages/editor/src/type.ts#HistoryOpSource{ts} + <<< @/../packages/schema/src/index.ts#CodeBlockContent{ts} <<< @/../packages/schema/src/index.ts#Id{ts} @@ -59,6 +63,8 @@ ::: details 查看 DataSourceStepValue 及关联类型定义 <<< @/../packages/editor/src/type.ts#DataSourceStepValue{ts} + <<< @/../packages/editor/src/type.ts#HistoryOpSource{ts} + <<< @/../packages/schema/src/index.ts#Id{ts} ::: diff --git a/docs/api/editor/historyServiceMethods.md b/docs/api/editor/historyServiceMethods.md index e8dd55e0..38fdce2b 100644 --- a/docs/api/editor/historyServiceMethods.md +++ b/docs/api/editor/historyServiceMethods.md @@ -43,6 +43,8 @@ <<< @/../packages/editor/src/type.ts#HistoryOpType{ts} + <<< @/../packages/editor/src/type.ts#HistoryOpSource{ts} + <<< @/../packages/schema/src/index.ts#Id{ts} <<< @/../packages/schema/src/index.ts#MNode{ts} @@ -61,6 +63,8 @@ `opType: 'update'` 的每个 `updatedItems[i]` 上可携带 `changeRecords`,用于撤销 / 重做时仅按 `propPath` 局部更新对应字段,避免整节点替换冲掉同节点上的其它无关变更;不带 `changeRecords` 时退化为整节点替换(如 `sort` / `moveLayer` / 拖动等纯快照场景)。 + + `StepValue` 上的 `historyDescription` / `source` 仅用于历史面板展示与埋点,不影响 undo/redo 行为。 ::: ## undo @@ -91,10 +95,14 @@ - `{CodeBlockContent | null} oldContent` 变更前的代码块内容;新增时为 `null` - `{CodeBlockContent | null} newContent` 变更后的代码块内容;删除时为 `null` - `{ChangeRecord[]} changeRecords` 可选;form 端 propPath/value 变更列表,撤销/重做时若有则按 propPath 局部更新;缺省(或空数组)才退化为整内容替换 + - `{string}` historyDescription 可选;人类可读描述,用于历史面板展示;不影响 undo/redo 行为 + - `{HistoryOpSource}` source 可选;操作途径,用于历史面板展示与埋点;不影响 undo/redo 行为 ::: details 查看 CodeBlockStepValue 及关联类型定义 <<< @/../packages/editor/src/type.ts#CodeBlockStepValue{ts} + <<< @/../packages/editor/src/type.ts#HistoryOpSource{ts} + <<< @/../packages/schema/src/index.ts#CodeBlockContent{ts} <<< @/../packages/form-schema/src/base.ts#ChangeRecord{ts} @@ -172,10 +180,14 @@ - `{DataSourceSchema | null} oldSchema` 变更前的数据源 schema;新增时为 `null` - `{DataSourceSchema | null} newSchema` 变更后的数据源 schema;删除时为 `null` - `{ChangeRecord[]} changeRecords` 可选;form 端 propPath/value 变更列表,撤销/重做时若有则按 propPath 局部更新;缺省(或空数组)才退化为整 schema 替换 + - `{string}` historyDescription 可选;人类可读描述,用于历史面板展示;不影响 undo/redo 行为 + - `{HistoryOpSource}` source 可选;操作途径,用于历史面板展示与埋点;不影响 undo/redo 行为 ::: details 查看 DataSourceStepValue 及关联类型定义 <<< @/../packages/editor/src/type.ts#DataSourceStepValue{ts} + <<< @/../packages/editor/src/type.ts#HistoryOpSource{ts} + <<< @/../packages/form-schema/src/base.ts#ChangeRecord{ts} ::: diff --git a/packages/editor/src/hooks/use-code-block-edit.ts b/packages/editor/src/hooks/use-code-block-edit.ts index 2db58764..8c3ab77d 100644 --- a/packages/editor/src/hooks/use-code-block-edit.ts +++ b/packages/editor/src/hooks/use-code-block-edit.ts @@ -6,7 +6,7 @@ import { tMagicMessage } from '@tmagic/design'; import type { ContainerChangeEventData } from '@tmagic/form'; import CodeBlockEditor from '@editor/components/CodeBlockEditor.vue'; -import type { Services } from '@editor/type'; +import type { HistoryOpSource, Services } from '@editor/type'; export const useCodeBlockEdit = (codeBlockService: Services['codeBlockService']) => { const codeConfig = ref & { content: string }>(); @@ -58,8 +58,8 @@ export const useCodeBlockEdit = (codeBlockService: Services['codeBlockService']) }; // 删除代码块 - const deleteCode = async (key: string) => { - codeBlockService.deleteCodeDslByIds([key]); + const deleteCode = async (key: string, { historySource }: { historySource?: HistoryOpSource } = {}) => { + codeBlockService.deleteCodeDslByIds([key], { historySource }); }; const submitCodeBlockHandler = async (values: CodeBlockContent, eventData?: ContainerChangeEventData) => { @@ -67,6 +67,7 @@ export const useCodeBlockEdit = (codeBlockService: Services['codeBlockService']) await codeBlockService.setCodeDslById(codeId.value, values, { changeRecords: eventData?.changeRecords, + historySource: 'props', }); codeBlockEditorRef.value?.hide(); diff --git a/packages/editor/src/hooks/use-data-source-edit.ts b/packages/editor/src/hooks/use-data-source-edit.ts index 96954cf8..c39369a9 100644 --- a/packages/editor/src/hooks/use-data-source-edit.ts +++ b/packages/editor/src/hooks/use-data-source-edit.ts @@ -27,9 +27,9 @@ export const useDataSourceEdit = (dataSourceService: Services['dataSourceService const submitDataSourceHandler = (value: DataSourceSchema, eventData: ContainerChangeEventData) => { if (value.id) { - dataSourceService.update(value, { changeRecords: eventData.changeRecords }); + dataSourceService.update(value, { changeRecords: eventData.changeRecords, historySource: 'props' }); } else { - dataSourceService.add(value); + dataSourceService.add(value, { historySource: 'props' }); } editDialog.value?.hide(); diff --git a/packages/editor/src/hooks/use-stage.ts b/packages/editor/src/hooks/use-stage.ts index 1b6cd268..cf4af955 100644 --- a/packages/editor/src/hooks/use-stage.ts +++ b/packages/editor/src/hooks/use-stage.ts @@ -130,16 +130,16 @@ export const useStage = (stageOptions: StageOptions) => { }); if (configs.length === 0) return; - editorService.update(configs, { changeRecordList }); + editorService.update(configs, { changeRecordList, historySource: 'stage' }); }); stage.on('sort', (ev: SortEventData) => { - editorService.sort(ev.src, ev.dist); + editorService.sort(ev.src, ev.dist, { historySource: 'stage' }); }); stage.on('remove', (ev: RemoveEventData) => { const nodes = ev.data.map(({ el }) => editorService.getNodeById(getIdFromEl()(el) || '')); - editorService.remove(nodes.filter((node) => Boolean(node)) as MNode[]); + editorService.remove(nodes.filter((node) => Boolean(node)) as MNode[], { historySource: 'stage' }); }); stage.on('select-parent', () => { diff --git a/packages/editor/src/layouts/NavMenu.vue b/packages/editor/src/layouts/NavMenu.vue index c7369a6a..cac122a5 100644 --- a/packages/editor/src/layouts/NavMenu.vue +++ b/packages/editor/src/layouts/NavMenu.vue @@ -80,7 +80,7 @@ const getConfig = (item: MenuItem): (MenuButton | MenuComponent)[] => { disabled: () => editorService.get('node')?.type === NodeType.PAGE, handler: () => { const node = editorService.get('node'); - node && editorService.remove(node); + node && editorService.remove(node, { historySource: 'toolbar' }); }, }); break; diff --git a/packages/editor/src/layouts/history-list/Bucket.vue b/packages/editor/src/layouts/history-list/Bucket.vue index 1062587f..0f543b55 100644 --- a/packages/editor/src/layouts/history-list/Bucket.vue +++ b/packages/editor/src/layouts/history-list/Bucket.vue @@ -15,6 +15,7 @@ :merged="group.steps.length > 1" :op-type="group.opType" :desc="describeGroup(group)" + :source="groupSource(group)" :time="formatHistoryTime(groupTimestamp(group))" :time-title="formatHistoryFullTime(groupTimestamp(group))" :step-count="group.steps.length" @@ -26,6 +27,7 @@ desc: describeStep(s.step), diffable: isStepDiffable ? isStepDiffable(s.step) : false, revertable: s.applied, + source: s.step.source, time: formatHistoryTime(s.step.timestamp), timeTitle: formatHistoryFullTime(s.step.timestamp), })) @@ -58,7 +60,7 @@ import { computed } from 'vue'; import type { HistoryOpType } from '@editor/type'; -import { formatHistoryFullTime, formatHistoryTime, groupTimestamp } from './composables'; +import { formatHistoryFullTime, formatHistoryTime, groupSource, groupTimestamp } from './composables'; import GroupRow from './GroupRow.vue'; import InitialRow from './InitialRow.vue'; diff --git a/packages/editor/src/layouts/history-list/GroupRow.vue b/packages/editor/src/layouts/history-list/GroupRow.vue index 0275f6d1..80decd10 100644 --- a/packages/editor/src/layouts/history-list/GroupRow.vue +++ b/packages/editor/src/layouts/history-list/GroupRow.vue @@ -13,6 +13,13 @@ {{ opLabel(opType) }} {{ desc }} + {{ sourceLabel(source) }} + {{ time }} 合并 {{ stepCount }} 步 @@ -50,6 +57,12 @@ > #{{ s.index + 1 }} {{ s.desc }} + {{ sourceLabel(s.source) }} {{ s.time }} import { computed } from 'vue'; -import type { HistoryOpType } from '@editor/type'; +import type { HistoryOpSource, HistoryOpType } from '@editor/type'; -import { opLabel } from './composables'; +import { opLabel, sourceLabel } from './composables'; defineOptions({ name: 'MEditorHistoryListGroupRow', @@ -100,6 +113,8 @@ const props = withDefaults( opType: HistoryOpType; /** 组的整体描述文案,由上层根据 step / group 计算后传入,例如 "修改 button · style.color"。 */ desc: string; + /** 组的操作途径(一般取组内最近一步),用于头部展示「画布 / 树面板 / 配置面板…」标签。 */ + source?: HistoryOpSource; /** 组头部展示的时间文案(一般为组内最近一步的时间),为空时不渲染。 */ time?: string; /** 组头部时间的 title 悬浮提示(完整时间),缺省时回退为 time。 */ @@ -115,6 +130,8 @@ const props = withDefaults( diffable?: boolean; /** 是否可对该子步执行「回滚」(已应用 + 业务侧确认支持反向)。父级根据 step 与 applied 决定。 */ revertable?: boolean; + /** 该子步的操作途径,用于展示「画布 / 树面板 / 配置面板…」标签。 */ + source?: HistoryOpSource; /** 该子步的时间文案,为空时不渲染。 */ time?: string; /** 该子步时间的 title 悬浮提示(完整时间),缺省时回退为 time。 */ diff --git a/packages/editor/src/layouts/history-list/PageTab.vue b/packages/editor/src/layouts/history-list/PageTab.vue index 32cb429c..575c27e3 100644 --- a/packages/editor/src/layouts/history-list/PageTab.vue +++ b/packages/editor/src/layouts/history-list/PageTab.vue @@ -10,6 +10,7 @@ :merged="group.steps.length > 1" :op-type="group.opType" :desc="describePageGroup(group)" + :source="groupSource(group)" :time="formatHistoryTime(groupTimestamp(group))" :time-title="formatHistoryFullTime(groupTimestamp(group))" :step-count="group.steps.length" @@ -21,6 +22,7 @@ desc: describePageStep(s.step), diffable: isPageStepDiffable(s.step), revertable: s.applied, + source: s.step.source, time: formatHistoryTime(s.step.timestamp), timeTitle: formatHistoryFullTime(s.step.timestamp), })) @@ -53,6 +55,7 @@ import { describePageStep, formatHistoryFullTime, formatHistoryTime, + groupSource, groupTimestamp, } from './composables'; import GroupRow from './GroupRow.vue'; diff --git a/packages/editor/src/layouts/history-list/composables.ts b/packages/editor/src/layouts/history-list/composables.ts index 17e258ab..94a77984 100644 --- a/packages/editor/src/layouts/history-list/composables.ts +++ b/packages/editor/src/layouts/history-list/composables.ts @@ -8,6 +8,7 @@ import type { CodeBlockStepValue, DataSourceHistoryGroup, DataSourceStepValue, + HistoryOpSource, HistoryOpType, PageHistoryGroup, StepValue, @@ -107,6 +108,32 @@ export const opLabel = (op: HistoryOpType) => { } }; +/** 内置操作途径的中文文案;自定义来源直接回显原值,未知 / 缺省返回空串(UI 据此不渲染)。 */ +const HISTORY_SOURCE_LABELS: Record = { + stage: '画布', + tree: '树面板', + 'component-panel': '组件面板', + props: '配置面板', + code: '源码', + 'stage-contextmenu': '画布菜单', + 'tree-contextmenu': '树菜单', + toolbar: '工具栏', + shortcut: '快捷键', + rollback: '回滚', + api: '接口', + ai: 'AI', + unknown: '未知', +}; + +/** 操作途径文案:用于历史面板展示「画布 / 树面板 / 配置面板…」标签。 */ +export const sourceLabel = (source: HistoryOpSource = 'unknown'): string => { + return HISTORY_SOURCE_LABELS[source] ?? `${source}`; +}; + +/** 取一组历史步骤里最后一步(最近一次)的操作途径,用于组头部展示。 */ +export const groupSource = (group: { steps: { step: { source?: HistoryOpSource } }[] }): HistoryOpSource | undefined => + group.steps[group.steps.length - 1]?.step.source; + const nameOf = (node: { name?: string; id?: string | number; type?: string }) => node?.name || node?.type || `${node?.id ?? ''}`; diff --git a/packages/editor/src/layouts/props-panel/PropsPanel.vue b/packages/editor/src/layouts/props-panel/PropsPanel.vue index f9802ec7..197f7170 100644 --- a/packages/editor/src/layouts/props-panel/PropsPanel.vue +++ b/packages/editor/src/layouts/props-panel/PropsPanel.vue @@ -151,7 +151,11 @@ const submit = async (v: MNode, eventData?: ContainerChangeEventData) => { }); } - editorService.update(newValue, { changeRecords: eventData?.changeRecords }); + // 区分操作途径:表单字段编辑(MForm @change)会带上 eventData(含 changeRecords); + // 源码编辑器(CodeEditor @save → saveCode)保存时不带 eventData,据此标记为「源码编辑器」。 + const historySource = eventData ? 'props' : 'code'; + + editorService.update(newValue, { changeRecords: eventData?.changeRecords, historySource }); } catch (e: any) { emit('submit-error', e); } diff --git a/packages/editor/src/layouts/sidebar/ComponentListPanel.vue b/packages/editor/src/layouts/sidebar/ComponentListPanel.vue index a121b0bb..f5e3e1b3 100644 --- a/packages/editor/src/layouts/sidebar/ComponentListPanel.vue +++ b/packages/editor/src/layouts/sidebar/ComponentListPanel.vue @@ -94,11 +94,15 @@ let clientX: number; let clientY: number; const appendComponent = ({ text, type, data = {} }: ComponentItem): void => { - editorService.add({ - name: text, - type, - ...data, - }); + editorService.add( + { + name: text, + type, + ...data, + }, + undefined, + { historySource: 'component-panel' }, + ); }; const dragstartHandler = ({ text, type, data = {} }: ComponentItem, e: DragEvent) => { diff --git a/packages/editor/src/layouts/sidebar/code-block/CodeBlockList.vue b/packages/editor/src/layouts/sidebar/code-block/CodeBlockList.vue index 8f9376eb..57724c66 100644 --- a/packages/editor/src/layouts/sidebar/code-block/CodeBlockList.vue +++ b/packages/editor/src/layouts/sidebar/code-block/CodeBlockList.vue @@ -24,7 +24,7 @@ - + @@ -44,7 +44,7 @@ import Tree from '@editor/components/Tree.vue'; import { useFilter } from '@editor/hooks/use-filter'; import { useNodeStatus } from '@editor/hooks/use-node-status'; import { useServices } from '@editor/hooks/use-services'; -import { type CodeBlockListSlots, CodeDeleteErrorType, type TreeNodeData } from '@editor/type'; +import { type CodeBlockListSlots, CodeDeleteErrorType, HistoryOpSource, type TreeNodeData } from '@editor/type'; defineSlots(); @@ -60,7 +60,7 @@ const props = defineProps<{ const emit = defineEmits<{ edit: [id: string]; - remove: [id: string]; + remove: [id: string, { historySource?: HistoryOpSource }]; 'node-contextmenu': [event: MouseEvent, data: TreeNodeData]; }>(); @@ -142,7 +142,7 @@ const editCode = (id: string) => { emit('edit', id); }; -const deleteCode = async (id: string) => { +const deleteCode = async (id: string, { historySource }: { historySource?: HistoryOpSource } = {}) => { const currentCode = codeList.value.find((codeItem) => codeItem.id === id); const existBinds = Boolean(currentCode?.items?.length); const undeleteableList = codeBlockService.getUndeletableList() || []; @@ -154,7 +154,7 @@ const deleteCode = async (id: string) => { }); // 无绑定关系,且不在不可删除列表中 - emit('remove', id); + emit('remove', id, { historySource }); } else { if (typeof props.customError === 'function') { props.customError(id, existBinds ? CodeDeleteErrorType.BIND : CodeDeleteErrorType.UNDELETEABLE); diff --git a/packages/editor/src/layouts/sidebar/code-block/CodeBlockListPanel.vue b/packages/editor/src/layouts/sidebar/code-block/CodeBlockListPanel.vue index 428ccec7..47f83419 100644 --- a/packages/editor/src/layouts/sidebar/code-block/CodeBlockListPanel.vue +++ b/packages/editor/src/layouts/sidebar/code-block/CodeBlockListPanel.vue @@ -122,7 +122,7 @@ const { menuData: contentMenuData, contentMenuHideHandler, } = useContentMenu((id: string) => { - codeBlockListRef.value?.deleteCode(id); + codeBlockListRef.value?.deleteCode(id, { historySource: 'tree-contextmenu' }); }); const menuData = computed<(MenuButton | MenuComponent)[]>(() => props.customContentMenu(contentMenuData, 'code-block')); diff --git a/packages/editor/src/layouts/sidebar/code-block/useContentMenu.ts b/packages/editor/src/layouts/sidebar/code-block/useContentMenu.ts index 296053f9..47808b3a 100644 --- a/packages/editor/src/layouts/sidebar/code-block/useContentMenu.ts +++ b/packages/editor/src/layouts/sidebar/code-block/useContentMenu.ts @@ -41,7 +41,7 @@ export const useContentMenu = (deleteCode: (id: string) => void) => { const newCodeId = await codeBlockService.getUniqueId(); - codeBlockService.setCodeDslById(newCodeId, cloneDeep(codeBlock)); + codeBlockService.setCodeDslById(newCodeId, cloneDeep(codeBlock), { historySource: 'tree-contextmenu' }); }, }, { diff --git a/packages/editor/src/layouts/sidebar/data-source/DataSourceListPanel.vue b/packages/editor/src/layouts/sidebar/data-source/DataSourceListPanel.vue index 18c095fe..e1e348a1 100644 --- a/packages/editor/src/layouts/sidebar/data-source/DataSourceListPanel.vue +++ b/packages/editor/src/layouts/sidebar/data-source/DataSourceListPanel.vue @@ -129,7 +129,7 @@ const removeHandler = async (id: string) => { type: 'warning', }); - dataSourceService.remove(id); + dataSourceService.remove(id, { historySource: 'tree-contextmenu' }); }; const dataSourceListRef = useTemplateRef>('dataSourceList'); diff --git a/packages/editor/src/layouts/sidebar/data-source/useContentMenu.ts b/packages/editor/src/layouts/sidebar/data-source/useContentMenu.ts index 0e6581ac..2a2e704b 100644 --- a/packages/editor/src/layouts/sidebar/data-source/useContentMenu.ts +++ b/packages/editor/src/layouts/sidebar/data-source/useContentMenu.ts @@ -39,7 +39,7 @@ export const useContentMenu = () => { return; } - dataSourceService.add(cloneDeep(ds)); + dataSourceService.add(cloneDeep(ds), { historySource: 'tree-contextmenu' }); }, }, { diff --git a/packages/editor/src/layouts/sidebar/layer/LayerMenu.vue b/packages/editor/src/layouts/sidebar/layer/LayerMenu.vue index 667ade7a..950ac81d 100644 --- a/packages/editor/src/layouts/sidebar/layer/LayerMenu.vue +++ b/packages/editor/src/layouts/sidebar/layer/LayerMenu.vue @@ -41,11 +41,15 @@ const createMenuItems = (group: ComponentGroup): MenuButton[] => type: 'button', icon: component.icon, handler: () => { - editorService.add({ - name: component.text, - type: component.type, - ...(component.data || {}), - }); + editorService.add( + { + name: component.text, + type: component.type, + ...(component.data || {}), + }, + undefined, + { historySource: 'tree-contextmenu' }, + ); }, })); @@ -57,9 +61,13 @@ const getSubMenuData = computed(() => { type: 'button', icon: Files, handler: () => { - editorService.add({ - type: 'tab-pane', - }); + editorService.add( + { + type: 'tab-pane', + }, + undefined, + { historySource: 'tree-contextmenu' }, + ); }, }, ]; @@ -106,9 +114,9 @@ const menuData = computed<(MenuButton | MenuComponent)[]>(() => items: getSubMenuData.value, }, useCopyMenu(), - usePasteMenu(), - useDeleteMenu(), - useMoveToMenu(services), + usePasteMenu('tree-contextmenu'), + useDeleteMenu('tree-contextmenu'), + useMoveToMenu(services, 'tree-contextmenu'), ...props.layerContentMenu, ], 'layer', diff --git a/packages/editor/src/layouts/sidebar/layer/LayerNodeTool.vue b/packages/editor/src/layouts/sidebar/layer/LayerNodeTool.vue index e0658404..13d99b09 100644 --- a/packages/editor/src/layouts/sidebar/layer/LayerNodeTool.vue +++ b/packages/editor/src/layouts/sidebar/layer/LayerNodeTool.vue @@ -25,9 +25,12 @@ const props = defineProps<{ const { editorService } = useServices(); const setNodeVisible = (visible: boolean) => { - editorService.update({ - id: props.data.id, - visible, - }); + editorService.update( + { + id: props.data.id, + visible, + }, + { historySource: 'tree' }, + ); }; diff --git a/packages/editor/src/layouts/workspace/viewer/Stage.vue b/packages/editor/src/layouts/workspace/viewer/Stage.vue index 4461afe1..0d241acc 100644 --- a/packages/editor/src/layouts/workspace/viewer/Stage.vue +++ b/packages/editor/src/layouts/workspace/viewer/Stage.vue @@ -380,7 +380,7 @@ const dropHandler = async (e: DragEvent) => { config.data.inputEvent = e; - editorService.add(config.data, parent); + editorService.add(config.data, parent, { historySource: 'component-panel' }); } }; diff --git a/packages/editor/src/layouts/workspace/viewer/ViewerMenu.vue b/packages/editor/src/layouts/workspace/viewer/ViewerMenu.vue index c1f15520..95fa0c03 100644 --- a/packages/editor/src/layouts/workspace/viewer/ViewerMenu.vue +++ b/packages/editor/src/layouts/workspace/viewer/ViewerMenu.vue @@ -49,11 +49,11 @@ const menuData = computed<(MenuButton | MenuComponent)[]>(() => display: () => canCenter.value, handler: () => { if (!nodes.value) return; - editorService.alignCenter(nodes.value); + editorService.alignCenter(nodes.value, { historySource: 'stage-contextmenu' }); }, }, useCopyMenu(), - usePasteMenu(menuRef), + usePasteMenu('stage-contextmenu', menuRef), { type: 'divider', direction: 'horizontal', @@ -68,7 +68,7 @@ const menuData = computed<(MenuButton | MenuComponent)[]>(() => icon: markRaw(Top), display: () => !isPage(node.value) && !isPageFragment(node.value) && !props.isMultiSelect, handler: () => { - editorService.moveLayer(1); + editorService.moveLayer(1, { historySource: 'stage-contextmenu' }); }, }, { @@ -77,7 +77,7 @@ const menuData = computed<(MenuButton | MenuComponent)[]>(() => icon: markRaw(Bottom), display: () => !isPage(node.value) && !isPageFragment(node.value) && !props.isMultiSelect, handler: () => { - editorService.moveLayer(-1); + editorService.moveLayer(-1, { historySource: 'stage-contextmenu' }); }, }, { @@ -86,7 +86,7 @@ const menuData = computed<(MenuButton | MenuComponent)[]>(() => icon: markRaw(Top), display: () => !isPage(node.value) && !isPageFragment(node.value) && !props.isMultiSelect, handler: () => { - editorService.moveLayer(LayerOffset.TOP); + editorService.moveLayer(LayerOffset.TOP, { historySource: 'stage-contextmenu' }); }, }, { @@ -95,16 +95,16 @@ const menuData = computed<(MenuButton | MenuComponent)[]>(() => icon: markRaw(Bottom), display: () => !isPage(node.value) && !isPageFragment(node.value) && !props.isMultiSelect, handler: () => { - editorService.moveLayer(LayerOffset.BOTTOM); + editorService.moveLayer(LayerOffset.BOTTOM, { historySource: 'stage-contextmenu' }); }, }, - useMoveToMenu(services), + useMoveToMenu(services, 'stage-contextmenu'), { type: 'divider', direction: 'horizontal', display: () => !isPage(node.value) && !isPageFragment(node.value) && !props.isMultiSelect, }, - useDeleteMenu(), + useDeleteMenu('stage-contextmenu'), { type: 'divider', direction: 'horizontal', diff --git a/packages/editor/src/services/codeBlock.ts b/packages/editor/src/services/codeBlock.ts index a07b0451..021ac793 100644 --- a/packages/editor/src/services/codeBlock.ts +++ b/packages/editor/src/services/codeBlock.ts @@ -120,9 +120,19 @@ class CodeBlock extends BaseService { public async setCodeDslById( id: Id, codeConfig: Partial, - { changeRecords, doNotPushHistory = false }: HistoryOpOptionsWithChangeRecords = {}, + { + changeRecords, + doNotPushHistory = false, + historyDescription, + historySource, + }: HistoryOpOptionsWithChangeRecords = {}, ): Promise { - this.setCodeDslByIdSync(id, codeConfig, true, { changeRecords, doNotPushHistory }); + this.setCodeDslByIdSync(id, codeConfig, true, { + changeRecords, + doNotPushHistory, + historyDescription, + historySource, + }); } /** @@ -141,7 +151,12 @@ class CodeBlock extends BaseService { id: Id, codeConfig: Partial, force = true, - { changeRecords, doNotPushHistory = false, historyDescription }: HistoryOpOptionsWithChangeRecords = {}, + { + changeRecords, + doNotPushHistory = false, + historyDescription, + historySource, + }: HistoryOpOptionsWithChangeRecords = {}, ): void { const codeDsl = this.getCodeDsl(); @@ -172,7 +187,13 @@ class CodeBlock extends BaseService { const newContent = cloneDeep(codeDsl[id]); if (!doNotPushHistory) { - historyService.pushCodeBlock(id, { oldContent, newContent, changeRecords, historyDescription }); + historyService.pushCodeBlock(id, { + oldContent, + newContent, + changeRecords, + historyDescription, + source: historySource, + }); } this.emit('addOrUpdate', id, codeDsl[id]); @@ -268,7 +289,7 @@ class CodeBlock extends BaseService { */ public async deleteCodeDslByIds( codeIds: Id[], - { doNotPushHistory = false, historyDescription }: HistoryOpOptions = {}, + { doNotPushHistory = false, historyDescription, historySource }: HistoryOpOptions = {}, ): Promise { const currentDsl = await this.getCodeDsl(); @@ -281,7 +302,7 @@ class CodeBlock extends BaseService { delete currentDsl[id]; if (oldContent && !doNotPushHistory) { - historyService.pushCodeBlock(id, { oldContent, newContent: null, historyDescription }); + historyService.pushCodeBlock(id, { oldContent, newContent: null, historyDescription, source: historySource }); } this.emit('remove', id); @@ -471,13 +492,13 @@ class CodeBlock extends BaseService { // 原本是新增 → revert 即删除 if (oldContent === null && newContent) { - await this.deleteCodeDslByIds([id], { historyDescription }); + await this.deleteCodeDslByIds([id], { historyDescription, historySource: 'rollback' }); return historyService.getCodeBlockStepList(id).slice(-1)[0]?.step ?? null; } // 原本是删除 → revert 即写回 if (oldContent && newContent === null) { - this.setCodeDslByIdSync(id, cloneDeep(oldContent), true, { historyDescription }); + this.setCodeDslByIdSync(id, cloneDeep(oldContent), true, { historyDescription, historySource: 'rollback' }); return historyService.getCodeBlockStepList(id).slice(-1)[0]?.step ?? null; } @@ -500,11 +521,12 @@ class CodeBlock extends BaseService { this.setCodeDslByIdSync(id, fallbackToFullReplace ? cloneDeep(oldContent) : patched, true, { changeRecords, historyDescription, + historySource: 'rollback', }); return historyService.getCodeBlockStepList(id).slice(-1)[0]?.step ?? null; } - this.setCodeDslByIdSync(id, cloneDeep(oldContent), true, { historyDescription }); + this.setCodeDslByIdSync(id, cloneDeep(oldContent), true, { historyDescription, historySource: 'rollback' }); return historyService.getCodeBlockStepList(id).slice(-1)[0]?.step ?? null; } diff --git a/packages/editor/src/services/dataSource.ts b/packages/editor/src/services/dataSource.ts index 1af8ac1c..05f5dc43 100644 --- a/packages/editor/src/services/dataSource.ts +++ b/packages/editor/src/services/dataSource.ts @@ -129,7 +129,10 @@ class DataSource extends BaseService { * @param options.doNotPushHistory 是否不写入历史记录(默认 false) * @param options.historyDescription 入栈时附带的人类可读描述,用于历史面板展示 */ - public add(config: DataSourceSchema, { doNotPushHistory = false, historyDescription }: HistoryOpOptions = {}) { + public add( + config: DataSourceSchema, + { doNotPushHistory = false, historyDescription, historySource }: HistoryOpOptions = {}, + ) { const newConfig = { ...config, id: config.id && !this.getDataSourceById(config.id) ? config.id : this.createId(), @@ -138,7 +141,12 @@ class DataSource extends BaseService { this.get('dataSources').push(newConfig); if (!doNotPushHistory) { - historyService.pushDataSource(newConfig.id, { oldSchema: null, newSchema: newConfig, historyDescription }); + historyService.pushDataSource(newConfig.id, { + oldSchema: null, + newSchema: newConfig, + historyDescription, + source: historySource, + }); } this.emit('add', newConfig); @@ -156,7 +164,12 @@ class DataSource extends BaseService { */ public update( config: DataSourceSchema, - { changeRecords = [], doNotPushHistory = false, historyDescription }: HistoryOpOptionsWithChangeRecords = {}, + { + changeRecords = [], + doNotPushHistory = false, + historyDescription, + historySource, + }: HistoryOpOptionsWithChangeRecords = {}, ) { const dataSources = this.get('dataSources'); @@ -173,6 +186,7 @@ class DataSource extends BaseService { newSchema: newConfig, changeRecords, historyDescription, + source: historySource, }); } @@ -191,14 +205,19 @@ class DataSource extends BaseService { * @param options.doNotPushHistory 是否不写入历史记录(默认 false) * @param options.historyDescription 入栈时附带的人类可读描述,用于历史面板展示 */ - public remove(id: string, { doNotPushHistory = false, historyDescription }: HistoryOpOptions = {}) { + public remove(id: string, { doNotPushHistory = false, historyDescription, historySource }: HistoryOpOptions = {}) { const dataSources = this.get('dataSources'); const index = dataSources.findIndex((ds) => ds.id === id); const oldConfig = index !== -1 ? dataSources[index] : null; dataSources.splice(index, 1); if (oldConfig && !doNotPushHistory) { - historyService.pushDataSource(id, { oldSchema: cloneDeep(oldConfig), newSchema: null, historyDescription }); + historyService.pushDataSource(id, { + oldSchema: cloneDeep(oldConfig), + newSchema: null, + historyDescription, + source: historySource, + }); } this.emit('remove', id); @@ -366,13 +385,13 @@ class DataSource extends BaseService { // 原本是新增 → revert 即删除 if (oldSchema === null && newSchema) { - this.remove(`${id}`, { historyDescription }); + this.remove(`${id}`, { historyDescription, historySource: 'rollback' }); return historyService.getDataSourceStepList(id).slice(-1)[0]?.step ?? null; } // 原本是删除 → revert 即重新加回 if (oldSchema && newSchema === null) { - this.add(cloneDeep(oldSchema), { historyDescription }); + this.add(cloneDeep(oldSchema), { historyDescription, historySource: 'rollback' }); return historyService.getDataSourceStepList(id).slice(-1)[0]?.step ?? null; } @@ -395,11 +414,12 @@ class DataSource extends BaseService { this.update(fallbackToFullReplace ? cloneDeep(oldSchema) : patched, { changeRecords, historyDescription, + historySource: 'rollback', }); return historyService.getDataSourceStepList(id).slice(-1)[0]?.step ?? null; } - this.update(cloneDeep(oldSchema), { historyDescription }); + this.update(cloneDeep(oldSchema), { historyDescription, historySource: 'rollback' }); return historyService.getDataSourceStepList(id).slice(-1)[0]?.step ?? null; } diff --git a/packages/editor/src/services/editor.ts b/packages/editor/src/services/editor.ts index 48394681..47fad822 100644 --- a/packages/editor/src/services/editor.ts +++ b/packages/editor/src/services/editor.ts @@ -36,6 +36,7 @@ import type { DslOpOptions, EditorEvents, EditorNodeInfo, + HistoryOpSource, HistoryOpType, PastePosition, StepValue, @@ -406,7 +407,13 @@ class Editor extends BaseService { public async add( addNode: AddMNode | MNode[], parent?: MContainer | null, - { doNotSelect = false, doNotSwitchPage = false, doNotPushHistory = false, historyDescription }: DslOpOptions = {}, + { + doNotSelect = false, + doNotSwitchPage = false, + doNotPushHistory = false, + historyDescription, + historySource, + }: DslOpOptions = {}, ): Promise { this.captureSelectionBeforeOp(); @@ -466,9 +473,8 @@ class Editor extends BaseService { if (!(isPage(newNodes[0]) || isPageFragment(newNodes[0]))) { const pageForOp = this.getNodeInfo(newNodes[0].id, false).page; if (!doNotPushHistory) { - this.pushOpHistory( - 'add', - { + this.pushOpHistory('add', { + extra: { nodes: newNodes.map((n) => cloneDeep(toRaw(n))), parentId: (this.getParentById(newNodes[0].id, false) ?? this.get('root'))!.id, indexMap: Object.fromEntries( @@ -478,9 +484,10 @@ class Editor extends BaseService { }), ), }, - { name: pageForOp?.name || '', id: pageForOp!.id }, + pageData: { name: pageForOp?.name || '', id: pageForOp!.id }, historyDescription, - ); + source: historySource, + }); } else { this.selectionBeforeOp = null; } @@ -577,7 +584,13 @@ class Editor extends BaseService { */ public async remove( nodeOrNodeList: MNode | MNode[], - { doNotSelect = false, doNotSwitchPage = false, doNotPushHistory = false, historyDescription }: DslOpOptions = {}, + { + doNotSelect = false, + doNotSwitchPage = false, + doNotPushHistory = false, + historyDescription, + historySource, + }: DslOpOptions = {}, ): Promise { this.captureSelectionBeforeOp(); @@ -606,7 +619,12 @@ class Editor extends BaseService { if (removedItems.length > 0 && pageForOp) { if (!doNotPushHistory) { - this.pushOpHistory('remove', { removedItems }, pageForOp, historyDescription); + this.pushOpHistory('remove', { + extra: { removedItems }, + pageData: pageForOp, + historyDescription, + source: historySource, + }); } else { this.selectionBeforeOp = null; } @@ -704,11 +722,12 @@ class Editor extends BaseService { changeRecordList?: ChangeRecord[][]; doNotPushHistory?: boolean; historyDescription?: string; + historySource?: HistoryOpSource; } = {}, ): Promise { this.captureSelectionBeforeOp(); - const { doNotPushHistory = false, changeRecordList, changeRecords, historyDescription } = data; + const { doNotPushHistory = false, changeRecordList, changeRecords, historyDescription, historySource } = data; const nodes = Array.isArray(config) ? config : [config]; @@ -726,9 +745,8 @@ class Editor extends BaseService { if (curNodes.length) { if (!doNotPushHistory) { const pageForOp = this.getNodeInfo(nodes[0].id, false).page; - this.pushOpHistory( - 'update', - { + this.pushOpHistory('update', { + extra: { updatedItems: updateData.map((d) => ({ oldNode: cloneDeep(d.oldNode), newNode: cloneDeep(toRaw(d.newNode)), @@ -737,9 +755,10 @@ class Editor extends BaseService { changeRecords: d.changeRecords?.length ? cloneDeep(d.changeRecords) : undefined, })), }, - { name: pageForOp?.name || '', id: pageForOp!.id }, + pageData: { name: pageForOp?.name || '', id: pageForOp!.id }, historyDescription, - ); + source: historySource, + }); } else { this.selectionBeforeOp = null; } @@ -763,7 +782,7 @@ class Editor extends BaseService { public async sort( id1: Id, id2: Id, - { doNotSelect = false, doNotPushHistory = false }: DslOpOptions = {}, + { doNotSelect = false, doNotPushHistory = false, historySource }: DslOpOptions = {}, ): Promise { this.captureSelectionBeforeOp(); @@ -783,7 +802,7 @@ class Editor extends BaseService { parent.items.splice(index2, 0, ...parent.items.splice(index1, 1)); - await this.update(parent, { doNotPushHistory }); + await this.update(parent, { doNotPushHistory, historySource }); if (!doNotSelect) { await this.select(node); } @@ -836,7 +855,13 @@ class Editor extends BaseService { public async paste( position: PastePosition = {}, collectorOptions?: TargetOptions, - { doNotSelect = false, doNotSwitchPage = false, doNotPushHistory = false }: DslOpOptions = {}, + { + doNotSelect = false, + doNotSwitchPage = false, + doNotPushHistory = false, + historyDescription, + historySource, + }: DslOpOptions = {}, ): Promise { const config: MNode[] = storageService.getItem(COPY_STORAGE_KEY); if (!Array.isArray(config)) return; @@ -857,7 +882,13 @@ class Editor extends BaseService { propsService.replaceRelateId(config, pasteConfigs, collectorOptions); } - return this.add(pasteConfigs, parent, { doNotSelect, doNotSwitchPage, doNotPushHistory }); + return this.add(pasteConfigs, parent, { + doNotSelect, + doNotSwitchPage, + doNotPushHistory, + historyDescription, + historySource, + }); } public async doPaste(config: MNode[], position: PastePosition = {}): Promise { @@ -893,14 +924,14 @@ class Editor extends BaseService { */ public async alignCenter( config: MNode | MNode[], - { doNotSelect = false, doNotPushHistory = false }: DslOpOptions = {}, + { doNotSelect = false, doNotPushHistory = false, historyDescription, historySource }: DslOpOptions = {}, ): Promise { const nodes = Array.isArray(config) ? config : [config]; const stage = this.get('stage'); const newNodes = await Promise.all(nodes.map((node) => this.doAlignCenter(node))); - const newNode = await this.update(newNodes, { doNotPushHistory }); + const newNode = await this.update(newNodes, { doNotPushHistory, historyDescription, historySource }); if (!doNotSelect) { if (newNodes.length > 1) { @@ -919,7 +950,10 @@ class Editor extends BaseService { * @param options 可选配置 * @param options.doNotPushHistory 是否不写入历史记录(默认 false) */ - public async moveLayer(offset: number | LayerOffset, { doNotPushHistory = false }: DslOpOptions = {}): Promise { + public async moveLayer( + offset: number | LayerOffset, + { doNotPushHistory = false, historyDescription, historySource }: DslOpOptions = {}, + ): Promise { this.captureSelectionBeforeOp(); const root = this.get('root'); @@ -960,10 +994,15 @@ class Editor extends BaseService { const pageForOp = this.getNodeInfo(node.id, false).page; this.pushOpHistory( 'update', + { - updatedItems: [{ oldNode: oldParent, newNode: cloneDeep(toRaw(parent)) }], + extra: { + updatedItems: [{ oldNode: oldParent, newNode: cloneDeep(toRaw(parent)) }], + }, + pageData: { name: pageForOp?.name || '', id: pageForOp!.id }, + historyDescription, + source: historySource, }, - { name: pageForOp?.name || '', id: pageForOp!.id }, ); } else { this.selectionBeforeOp = null; @@ -989,7 +1028,13 @@ class Editor extends BaseService { public async moveToContainer( config: MNode | MNode[], targetId: Id, - { doNotSelect = false, doNotSwitchPage = false, doNotPushHistory = false }: DslOpOptions = {}, + { + doNotSelect = false, + doNotSwitchPage = false, + doNotPushHistory = false, + historyDescription, + historySource, + }: DslOpOptions = {}, ): Promise { const isBatch = Array.isArray(config); const configs = (isBatch ? config : [config]).filter((item) => !(isPage(item) || isPageFragment(item))); @@ -1052,7 +1097,12 @@ class Editor extends BaseService { newNode: cloneDeep(toRaw(this.getNodeById(id, false))) as MNode, })); const historyPage = moves[0].pageForOp ?? { name: '', id: target.id }; - this.pushOpHistory('update', { updatedItems }, historyPage); + this.pushOpHistory('update', { + extra: { updatedItems }, + pageData: historyPage, + historyDescription, + source: historySource, + }); } else { this.selectionBeforeOp = null; } @@ -1064,7 +1114,7 @@ class Editor extends BaseService { config: MNode | MNode[], targetParent: MContainer, targetIndex: number, - { doNotPushHistory = false }: DslOpOptions = {}, + { doNotPushHistory = false, historyDescription, historySource }: DslOpOptions = {}, ) { this.captureSelectionBeforeOp(); @@ -1127,7 +1177,12 @@ class Editor extends BaseService { } if (!doNotPushHistory) { const pageForOp = this.getNodeInfo(configs[0].id, false).page; - this.pushOpHistory('update', { updatedItems }, { name: pageForOp?.name || '', id: pageForOp!.id }); + this.pushOpHistory('update', { + extra: { updatedItems }, + pageData: { name: pageForOp?.name || '', id: pageForOp!.id }, + historyDescription, + source: historySource, + }); } else { this.selectionBeforeOp = null; } @@ -1193,7 +1248,7 @@ class Editor extends BaseService { const historyDescription = `回滚 #${index + 1}: ${describeStepForRevert(step)}`; // revert 走 public add/remove/update,让操作以一条普通新 step 入栈;不要切换选区与页面,避免打断用户。 - const opts = { doNotSelect: true, doNotSwitchPage: true, historyDescription } as const; + const opts = { doNotSelect: true, doNotSwitchPage: true, historyDescription, historySource: 'rollback' } as const; try { switch (step.opType) { @@ -1241,7 +1296,7 @@ class Editor extends BaseService { return cloneDeep(oldNode); }); if (configs.length) { - await this.update(configs, { historyDescription }); + await this.update(configs, { historyDescription, historySource: 'rollback' }); } break; } @@ -1285,14 +1340,21 @@ class Editor extends BaseService { return cursor; } - public async move(left: number, top: number, { doNotPushHistory = false }: DslOpOptions = {}) { + public async move( + left: number, + top: number, + { doNotPushHistory = false, historyDescription, historySource }: DslOpOptions = {}, + ) { const node = toRaw(this.get('node')); if (!node || isPage(node)) return; const newStyle = calcMoveStyle(node.style || {}, left, top); if (!newStyle) return; - await this.update({ id: node.id, type: node.type, style: newStyle }, { doNotPushHistory }); + await this.update( + { id: node.id, type: node.type, style: newStyle }, + { doNotPushHistory, historyDescription, historySource }, + ); } public resetState() { @@ -1350,9 +1412,17 @@ class Editor extends BaseService { private pushOpHistory( opType: HistoryOpType, - extra: Partial, - pageData: { name: string; id: Id }, - historyDescription?: string, + { + extra, + pageData, + historyDescription, + source, + }: { + extra: Partial; + pageData: { name: string; id: Id }; + historyDescription?: string; + source?: HistoryOpSource; + }, ) { const step: StepValue = { data: pageData, @@ -1363,6 +1433,7 @@ class Editor extends BaseService { ...extra, }; if (historyDescription) step.historyDescription = historyDescription; + if (source) step.source = source; // 显式按 step.data.id 入栈:跨页操作(如 moveToContainer 从源页搬到目标页) // 必须落到正确的页面栈,否则会把记录错误地推到当前活动页 / 操作发起页。 historyService.push(step, pageData.id); diff --git a/packages/editor/src/services/history.ts b/packages/editor/src/services/history.ts index 57d55a0e..906d7dda 100644 --- a/packages/editor/src/services/history.ts +++ b/packages/editor/src/services/history.ts @@ -27,6 +27,7 @@ import type { CodeBlockStepValue, DataSourceHistoryGroup, DataSourceStepValue, + HistoryOpSource, HistoryState, PageHistoryGroup, PageHistoryStepEntry, @@ -280,6 +281,8 @@ class History extends BaseService { changeRecords?: ChangeRecord[]; /** 可选的人类可读描述(如「修改按钮颜色」),仅用于历史面板展示。 */ historyDescription?: string; + /** 可选的操作途径(配置面板 / 菜单 / 接口等),仅用于历史面板展示与埋点。 */ + source?: HistoryOpSource; }, ): CodeBlockStepValue | null { if (!codeBlockId) return null; @@ -290,6 +293,7 @@ class History extends BaseService { newContent: payload.newContent ? cloneDeep(payload.newContent) : null, changeRecords: payload.changeRecords?.length ? cloneDeep(payload.changeRecords) : undefined, historyDescription: payload.historyDescription, + source: payload.source, timestamp: Date.now(), }; @@ -310,6 +314,8 @@ class History extends BaseService { changeRecords?: ChangeRecord[]; /** 可选的人类可读描述,仅用于历史面板展示。 */ historyDescription?: string; + /** 可选的操作途径(配置面板 / 菜单 / 接口等),仅用于历史面板展示与埋点。 */ + source?: HistoryOpSource; }, ): DataSourceStepValue | null { if (!dataSourceId) return null; @@ -320,6 +326,7 @@ class History extends BaseService { newSchema: payload.newSchema ? cloneDeep(payload.newSchema) : null, changeRecords: payload.changeRecords?.length ? cloneDeep(payload.changeRecords) : undefined, historyDescription: payload.historyDescription, + source: payload.source, timestamp: Date.now(), }; diff --git a/packages/editor/src/services/keybinding.ts b/packages/editor/src/services/keybinding.ts index 58aea835..0c2b4433 100644 --- a/packages/editor/src/services/keybinding.ts +++ b/packages/editor/src/services/keybinding.ts @@ -20,7 +20,7 @@ class Keybinding extends BaseService { const nodes = editorService.get('nodes'); if (!nodes || isPage(nodes[0]) || isPageFragment(nodes[0])) return; - editorService.remove(nodes); + editorService.remove(nodes, { historySource: 'shortcut' }); }, [KeyBindingCommand.COPY_NODE]: () => { const nodes = editorService.get('nodes'); @@ -31,11 +31,11 @@ class Keybinding extends BaseService { if (!nodes || isPage(nodes[0]) || isPageFragment(nodes[0])) return; editorService.copy(nodes); - editorService.remove(nodes); + editorService.remove(nodes, { historySource: 'shortcut' }); }, [KeyBindingCommand.PASTE_NODE]: () => { const nodes = editorService.get('nodes'); - nodes && editorService.paste({ offsetX: 10, offsetY: 10 }); + nodes && editorService.paste({ offsetX: 10, offsetY: 10 }, undefined, { historySource: 'shortcut' }); }, [KeyBindingCommand.UNDO]: () => { editorService.undo(); diff --git a/packages/editor/src/theme/history-list-panel.scss b/packages/editor/src/theme/history-list-panel.scss index 27763e76..946b564d 100644 --- a/packages/editor/src/theme/history-list-panel.scss +++ b/packages/editor/src/theme/history-list-panel.scss @@ -281,6 +281,20 @@ white-space: nowrap; } + // 「操作途径」徽标:浅灰描边胶囊,弱化展示来源(画布 / 图层 / 配置面板…),不抢占描述焦点。 + .m-editor-history-list-item-source { + flex: 0 0 auto; + padding: 0 6px; + border: 1px solid #dcdfe6; + border-radius: 8px; + font-size: 10px; + line-height: 14px; + color: #909399; + background-color: #f4f4f5; + white-space: nowrap; + font-weight: 400; // 防止被合并组头部的粗体继承 + } + // 「合并 N 步」徽标:紫色实心胶囊,与合并组卡片色系一致,醒目区分单步条目。 .m-editor-history-list-item-merge { flex: 0 0 auto; diff --git a/packages/editor/src/type.ts b/packages/editor/src/type.ts index d02c49f8..7ec2fcb7 100644 --- a/packages/editor/src/type.ts +++ b/packages/editor/src/type.ts @@ -510,7 +510,7 @@ export interface HistoryListExtraTab { * - data-source: 数据源,按 `type`(base/http/...) 从 dataSourceService 获取数据源表单配置 * - code-block: 数据源代码块,使用内置的代码块表单配置 */ -export type CompareCategory = 'node' | 'data-source' | 'code-block'; +export type CompareCategory = 'node' | 'data-source' | 'code-block' | string; /** * 自定义 `loadConfig` 时回传的上下文,聚合了组件当前的对比入参, @@ -683,6 +683,44 @@ export interface CodeParamStatement { export type HistoryOpType = 'add' | 'remove' | 'update'; // #endregion HistoryOpType +// #region HistoryOpSource +/** + * 历史记录的「操作途径」——标记本次变更由哪条交互入口触发,仅用于历史面板展示 / 业务埋点, + * 不影响 undo/redo 行为。缺省(未传)时 UI 视为「未知」。 + * + * - `stage`:画布(拖拽 / 缩放 / 排序等舞台直接操作) + * - `tree`:树形面板(图层 / 数据源 / 代码块等树形结构里的拖拽 / 菜单操作) + * - `component-panel`:组件面板(左侧组件列表点击 / 拖拽新增组件) + * - `props`:配置面板表单(属性表单字段编辑) + * - `code`:源码编辑器(配置面板「源码」面板里直接编辑 JSON/代码后保存) + * - `stage-contextmenu`:画布右键菜单(舞台上节点的右键上下文菜单) + * - `tree-contextmenu`:树面板右键菜单(图层 / 数据源 / 代码块等树形列表上的右键上下文菜单) + * - `toolbar`:工具栏菜单(顶部导航工具栏按钮) + * - `shortcut`:键盘快捷键 + * - `rollback`:历史回滚(历史面板里对某条历史「回滚」,反向应用为一条新记录,类 git revert) + * - `api`:代码 / 接口调用(程序化触发) + * - `ai`:AI 生成 / 智能助手触发的变更 + * - `unknown`:未知来源 + * + * 通过 `(string & {})` 允许业务侧扩展自定义途径字符串,同时保留内置值的自动补全。 + */ +export type HistoryOpSource = + | 'stage' + | 'tree' + | 'component-panel' + | 'props' + | 'code' + | 'stage-contextmenu' + | 'tree-contextmenu' + | 'toolbar' + | 'shortcut' + | 'rollback' + | 'api' + | 'ai' + | 'unknown' + | (string & {}); +// #endregion HistoryOpSource + // #region StepValue export interface StepValue { /** 页面信息 */ @@ -713,6 +751,12 @@ export interface StepValue { * 不影响 undo/redo 行为;缺省时面板会根据节点 / propPath 自动生成描述。 */ historyDescription?: string; + /** + * 操作途径:标记本次变更由哪条交互入口触发,取值见 {@link HistoryOpSource} + * (画布 / 树面板 / 组件面板 / 配置面板 / 源码编辑器 / 右键菜单 / 工具栏 / 快捷键 / 回滚 / 接口 等)。 + * 仅用于历史面板展示与业务埋点,不影响 undo/redo 行为;缺省时面板视为「未知」。 + */ + source?: HistoryOpSource; /** * 入栈时间戳(毫秒)。在 historyService.push 时自动写入(若调用方未指定),仅用于历史面板展示。 */ @@ -741,6 +785,8 @@ export interface CodeBlockStepValue { changeRecords?: ChangeRecord[]; /** 调用方可选传入的人类可读描述,用于历史面板展示;不影响 undo/redo 行为。 */ historyDescription?: string; + /** 操作途径:标记本次变更由哪条交互入口触发,取值见 {@link HistoryOpSource};仅用于历史面板展示与埋点,不影响 undo/redo 行为。 */ + source?: HistoryOpSource; /** 入栈时间戳(毫秒),入栈时自动写入,仅用于历史面板展示。 */ timestamp?: number; } @@ -767,6 +813,8 @@ export interface DataSourceStepValue { changeRecords?: ChangeRecord[]; /** 调用方可选传入的人类可读描述,用于历史面板展示;不影响 undo/redo 行为。 */ historyDescription?: string; + /** 操作途径:标记本次变更由哪条交互入口触发,取值见 {@link HistoryOpSource};仅用于历史面板展示与埋点,不影响 undo/redo 行为。 */ + source?: HistoryOpSource; /** 入栈时间戳(毫秒),入栈时自动写入,仅用于历史面板展示。 */ timestamp?: number; } @@ -1099,16 +1147,21 @@ export const canUsePluginMethods = { export type AsyncMethodName = Writable<(typeof canUsePluginMethods)['async']>; +// #region HistoryOpOptions /** * 历史记录写入相关的通用配置(codeBlock / dataSource / editor 共用) * - doNotPushHistory: 操作完成后是否不要将本次操作压入历史栈(撤销/重做记录),默认 false * - historyDescription: 入栈时附带的人类可读描述,用于历史面板展示;不影响 undo/redo 行为,缺省时面板会自动生成描述 + * - historySource: 操作途径,取值见 {@link HistoryOpSource}(画布 / 树面板 / 组件面板 / 配置面板 / 源码编辑器 / 右键菜单 / 工具栏 / 快捷键 / 回滚 / 接口 等),用于历史面板展示与埋点;不影响 undo/redo 行为 */ export interface HistoryOpOptions { doNotPushHistory?: boolean; historyDescription?: string; + historySource?: HistoryOpSource; } +// #endregion HistoryOpOptions +// #region HistoryOpOptionsWithChangeRecords /** * 在 HistoryOpOptions 基础上携带 form 端 propPath/value 变更记录, * 用于历史记录的精细化撤销/重做(按 propPath 局部 patch)。 @@ -1116,7 +1169,9 @@ export interface HistoryOpOptions { export interface HistoryOpOptionsWithChangeRecords extends HistoryOpOptions { changeRecords?: ChangeRecord[]; } +// #endregion HistoryOpOptionsWithChangeRecords +// #region DslOpOptions /** * DSL 修改类操作的通用配置 * - doNotSelect: 操作后是否不要自动触发选中(不调用 this.select / this.multiSelect / stage.select / stage.multiSelect) @@ -1126,6 +1181,7 @@ export interface DslOpOptions extends HistoryOpOptions { doNotSelect?: boolean; doNotSwitchPage?: boolean; } +// #endregion DslOpOptions /** 差异对话框的入参 */ export interface DiffDialogPayload { diff --git a/packages/editor/src/utils/content-menu.ts b/packages/editor/src/utils/content-menu.ts index 12b36d41..3bfa2312 100644 --- a/packages/editor/src/utils/content-menu.ts +++ b/packages/editor/src/utils/content-menu.ts @@ -5,11 +5,16 @@ import { cloneDeep, Id, MContainer, NodeType } from '@tmagic/core'; import { calcValueByFontsize, isPage, isPageFragment } from '@tmagic/utils'; import ContentMenu from '@editor/components/ContentMenu.vue'; -import type { MenuButton, Services } from '@editor/type'; +import type { HistoryOpSource, MenuButton, Services } from '@editor/type'; import { COPY_STORAGE_KEY } from './editor'; -export const useDeleteMenu = (): MenuButton => ({ +/** + * 共享的右键菜单项构造器(画布 ViewerMenu 与图层树 LayerMenu 共用)。 + * `historySource` 用于标记本次操作的途径,调用方按所在面板传入: + * 画布传 `'stage-contextmenu'`,树形面板传 `'tree-contextmenu'`。 + */ +export const useDeleteMenu = (historySource?: HistoryOpSource): MenuButton => ({ type: 'button', text: '删除', icon: Delete, @@ -19,7 +24,7 @@ export const useDeleteMenu = (): MenuButton => ({ }, handler: ({ editorService }) => { const nodes = editorService.get('nodes'); - nodes && editorService.remove(nodes); + nodes && editorService.remove(nodes, { historySource }); }, }); @@ -33,7 +38,10 @@ export const useCopyMenu = (): MenuButton => ({ }, }); -export const usePasteMenu = (menu?: ShallowRef | null>): MenuButton => ({ +export const usePasteMenu = ( + historySource?: HistoryOpSource, + menu?: ShallowRef | null>, +): MenuButton => ({ type: 'button', text: '粘贴', icon: markRaw(DocumentCopy), @@ -52,14 +60,14 @@ export const usePasteMenu = (menu?: ShallowRef const initialTop = calcValueByFontsize(stage?.renderer?.getDocument(), (rect.top || 0) - (parentRect?.top || 0)) / uiService.get('zoom'); - editorService.paste({ left: initialLeft, top: initialTop }); + editorService.paste({ left: initialLeft, top: initialTop }, undefined, { historySource }); } else { - editorService.paste(); + editorService.paste(undefined, undefined, { historySource }); } }, }); -const moveTo = async (id: Id, { editorService }: Services) => { +const moveTo = async (id: Id, { editorService }: Services, historySource?: HistoryOpSource) => { const nodes = editorService.get('nodes') || []; const parent = editorService.getNodeById(id) as MContainer; @@ -69,10 +77,11 @@ const moveTo = async (id: Id, { editorService }: Services) => { // 不要再走 remove + add 两步,否则会被切成两条历史(且语义也不正确)。 await editorService.moveToContainer(cloneDeep(nodes), parent.id, { doNotSwitchPage: true, + historySource, }); }; -export const useMoveToMenu = ({ editorService }: Services): MenuButton => { +export const useMoveToMenu = ({ editorService }: Services, historySource?: HistoryOpSource): MenuButton => { const root = computed(() => editorService.get('root')); return { @@ -89,7 +98,7 @@ export const useMoveToMenu = ({ editorService }: Services): MenuButton => { text: `${page.name}(${page.id})`, type: 'button', handler: (services: Services) => { - moveTo(page.id, services); + moveTo(page.id, services, historySource); }, })), }; diff --git a/packages/editor/tests/unit/hooks/use-code-block-edit.spec.ts b/packages/editor/tests/unit/hooks/use-code-block-edit.spec.ts index 3b09c6ec..eaa97877 100644 --- a/packages/editor/tests/unit/hooks/use-code-block-edit.spec.ts +++ b/packages/editor/tests/unit/hooks/use-code-block-edit.spec.ts @@ -104,7 +104,7 @@ describe('useCodeBlockEdit', () => { const deleteCodeDslByIds = vi.fn(); const hook = mountHook({ deleteCodeDslByIds }); await hook.deleteCode('k'); - expect(deleteCodeDslByIds).toHaveBeenCalledWith(['k']); + expect(deleteCodeDslByIds).toHaveBeenCalledWith(['k'], { historySource: undefined }); }); test('submitCodeBlockHandler - 没有 codeId 时跳过', async () => { @@ -119,7 +119,14 @@ describe('useCodeBlockEdit', () => { const hook = mountHook({ setCodeDslById }); hook.codeId.value = 'id1'; await hook.submitCodeBlockHandler({ name: 'b' } as any); - expect(setCodeDslById).toHaveBeenCalledWith('id1', { name: 'b' }, { changeRecords: undefined }); + expect(setCodeDslById).toHaveBeenCalledWith( + 'id1', + { name: 'b' }, + { + changeRecords: undefined, + historySource: 'props', + }, + ); expect(hideMock).toHaveBeenCalled(); }); @@ -129,6 +136,13 @@ describe('useCodeBlockEdit', () => { hook.codeId.value = 'id1'; const records = [{ propPath: 'name', value: 'b' }]; await hook.submitCodeBlockHandler({ name: 'b' } as any, { changeRecords: records } as any); - expect(setCodeDslById).toHaveBeenCalledWith('id1', { name: 'b' }, { changeRecords: records }); + expect(setCodeDslById).toHaveBeenCalledWith( + 'id1', + { name: 'b' }, + { + changeRecords: records, + historySource: 'props', + }, + ); }); }); diff --git a/packages/editor/tests/unit/hooks/use-stage.spec.ts b/packages/editor/tests/unit/hooks/use-stage.spec.ts index 4064304b..b3eb28d4 100644 --- a/packages/editor/tests/unit/hooks/use-stage.spec.ts +++ b/packages/editor/tests/unit/hooks/use-stage.spec.ts @@ -235,7 +235,7 @@ describe('useStage', () => { test('sort 事件', () => { useStage({} as any); stageInstance.handlers.sort[0]({ src: 'a', dist: 'b' }); - expect(editorService.sort).toHaveBeenCalledWith('a', 'b'); + expect(editorService.sort).toHaveBeenCalledWith('a', 'b', { historySource: 'stage' }); }); test('remove 事件', () => { diff --git a/packages/editor/tests/unit/layouts/sidebar/ComponentListPanel.spec.ts b/packages/editor/tests/unit/layouts/sidebar/ComponentListPanel.spec.ts index 2471dc49..bd907e3d 100644 --- a/packages/editor/tests/unit/layouts/sidebar/ComponentListPanel.spec.ts +++ b/packages/editor/tests/unit/layouts/sidebar/ComponentListPanel.spec.ts @@ -97,7 +97,9 @@ describe('ComponentListPanel', () => { test('点击 component-item 调用 editorService.add', async () => { const wrapper = mount(ComponentListPanel); await wrapper.find('.component-item').trigger('click'); - expect(editorService.add).toHaveBeenCalledWith({ name: '按钮', type: 'button' }); + expect(editorService.add).toHaveBeenCalledWith({ name: '按钮', type: 'button' }, undefined, { + historySource: 'component-panel', + }); }); test('搜索过滤组件', async () => { diff --git a/packages/editor/tests/unit/layouts/sidebar/code-block/useContentMenu.spec.ts b/packages/editor/tests/unit/layouts/sidebar/code-block/useContentMenu.spec.ts index 478f8c51..c153fca1 100644 --- a/packages/editor/tests/unit/layouts/sidebar/code-block/useContentMenu.spec.ts +++ b/packages/editor/tests/unit/layouts/sidebar/code-block/useContentMenu.spec.ts @@ -70,7 +70,13 @@ describe('code-block useContentMenu', () => { setCodeDslById: vi.fn(), }; await (result.menuData[1] as any).handler({ codeBlockService }); - expect(codeBlockService.setCodeDslById).toHaveBeenCalledWith('newId', { name: 'a' }); + expect(codeBlockService.setCodeDslById).toHaveBeenCalledWith( + 'newId', + { name: 'a' }, + { + historySource: 'tree-contextmenu', + }, + ); }); test('复制按钮: 未选中时不触发', async () => { diff --git a/packages/editor/tests/unit/layouts/sidebar/data-source/DataSourceListPanel.spec.ts b/packages/editor/tests/unit/layouts/sidebar/data-source/DataSourceListPanel.spec.ts index aa17978f..335065fa 100644 --- a/packages/editor/tests/unit/layouts/sidebar/data-source/DataSourceListPanel.spec.ts +++ b/packages/editor/tests/unit/layouts/sidebar/data-source/DataSourceListPanel.spec.ts @@ -187,7 +187,7 @@ describe('DataSourceListPanel', () => { await wrapper.find('.remove-btn').trigger('click'); expect(messageBoxConfirm).toHaveBeenCalled(); await new Promise((r) => setTimeout(r, 0)); - expect(dataSourceService.remove).toHaveBeenCalledWith('d1'); + expect(dataSourceService.remove).toHaveBeenCalledWith('d1', { historySource: 'tree-contextmenu' }); await wrapper.find('.ctx-btn').trigger('click'); expect(nodeContentMenuHandler).toHaveBeenCalled(); }); diff --git a/packages/editor/tests/unit/layouts/sidebar/data-source/useContentMenu.spec.ts b/packages/editor/tests/unit/layouts/sidebar/data-source/useContentMenu.spec.ts index d1d29c9d..45a09443 100644 --- a/packages/editor/tests/unit/layouts/sidebar/data-source/useContentMenu.spec.ts +++ b/packages/editor/tests/unit/layouts/sidebar/data-source/useContentMenu.spec.ts @@ -67,7 +67,7 @@ describe('data-source useContentMenu', () => { add: vi.fn(), }; (result.menuData[1] as any).handler({ dataSourceService }); - expect(dataSourceService.add).toHaveBeenCalledWith({ name: 'a' }); + expect(dataSourceService.add).toHaveBeenCalledWith({ name: 'a' }, { historySource: 'tree-contextmenu' }); }); test('复制按钮: 未选中时不触发', () => { diff --git a/packages/editor/tests/unit/layouts/sidebar/layer/LayerMenu.spec.ts b/packages/editor/tests/unit/layouts/sidebar/layer/LayerMenu.spec.ts index f62312b4..8b4f7978 100644 --- a/packages/editor/tests/unit/layouts/sidebar/layer/LayerMenu.spec.ts +++ b/packages/editor/tests/unit/layouts/sidebar/layer/LayerMenu.spec.ts @@ -117,7 +117,9 @@ describe('LayerMenu', () => { const addItem = arg.find((m: any) => m.text === '新增'); expect(addItem.items[0].text).toBe('标签页'); addItem.items[0].handler(); - expect(editorService.add).toHaveBeenCalledWith({ type: 'tab-pane' }); + expect(editorService.add).toHaveBeenCalledWith({ type: 'tab-pane' }, undefined, { + historySource: 'tree-contextmenu', + }); }); test('node.items 时根据组件列表生成子菜单 (含分隔)', () => { @@ -151,6 +153,8 @@ describe('LayerMenu', () => { const arg = customContentMenu.mock.calls[0][0]; const addItem = arg.find((m: any) => m.text === '新增'); addItem.items[0].handler(); - expect(editorService.add).toHaveBeenCalledWith({ name: 'btn', type: 'button' }); + expect(editorService.add).toHaveBeenCalledWith({ name: 'btn', type: 'button' }, undefined, { + historySource: 'tree-contextmenu', + }); }); }); diff --git a/packages/editor/tests/unit/layouts/sidebar/layer/LayerNodeTool.spec.ts b/packages/editor/tests/unit/layouts/sidebar/layer/LayerNodeTool.spec.ts index 16273841..5bd27096 100644 --- a/packages/editor/tests/unit/layouts/sidebar/layer/LayerNodeTool.spec.ts +++ b/packages/editor/tests/unit/layouts/sidebar/layer/LayerNodeTool.spec.ts @@ -39,7 +39,7 @@ describe('LayerNodeTool', () => { props: { data: { id: 'n1', type: 'text', visible: true } as any }, }); await wrapper.find('button').trigger('click'); - expect(editorService.update).toHaveBeenCalledWith({ id: 'n1', visible: false }); + expect(editorService.update).toHaveBeenCalledWith({ id: 'n1', visible: false }, { historySource: 'tree' }); }); test('点击按钮切换 visible 状态 (false -> true)', async () => { @@ -48,6 +48,6 @@ describe('LayerNodeTool', () => { props: { data: { id: 'n2', type: 'text', visible: false } as any }, }); await wrapper.find('button').trigger('click'); - expect(editorService.update).toHaveBeenCalledWith({ id: 'n2', visible: true }); + expect(editorService.update).toHaveBeenCalledWith({ id: 'n2', visible: true }, { historySource: 'tree' }); }); }); diff --git a/packages/editor/tests/unit/layouts/workspace/viewer/ViewerMenu.spec.ts b/packages/editor/tests/unit/layouts/workspace/viewer/ViewerMenu.spec.ts index b3f5b8c6..98415b79 100644 --- a/packages/editor/tests/unit/layouts/workspace/viewer/ViewerMenu.spec.ts +++ b/packages/editor/tests/unit/layouts/workspace/viewer/ViewerMenu.spec.ts @@ -126,9 +126,9 @@ describe('ViewerMenu.vue', () => { }); const menuData = wrapper.findComponent({ name: 'FakeContentMenu' }).props('menuData') as any[]; menuData.find((m: any) => m.text === '上移一层').handler(); - expect(editorService.moveLayer).toHaveBeenCalledWith(1); + expect(editorService.moveLayer).toHaveBeenCalledWith(1, { historySource: 'stage-contextmenu' }); menuData.find((m: any) => m.text === '下移一层').handler(); - expect(editorService.moveLayer).toHaveBeenCalledWith(-1); + expect(editorService.moveLayer).toHaveBeenCalledWith(-1, { historySource: 'stage-contextmenu' }); menuData.find((m: any) => m.text === '置顶').handler(); menuData.find((m: any) => m.text === '置底').handler(); expect(editorService.moveLayer).toHaveBeenCalledTimes(4); diff --git a/packages/editor/tests/unit/utils/content-menu-utils.spec.ts b/packages/editor/tests/unit/utils/content-menu-utils.spec.ts index 49e59814..c6954255 100644 --- a/packages/editor/tests/unit/utils/content-menu-utils.spec.ts +++ b/packages/editor/tests/unit/utils/content-menu-utils.spec.ts @@ -114,9 +114,13 @@ describe('content-menu utils', () => { toJSON: () => ({}), }); const menu = ref({ $el: menuEl }); - const m = usePasteMenu(menu); + const m = usePasteMenu('stage-contextmenu', menu); (m as any).handler({ editorService, uiService: { get: () => 2 } }); - expect(paste).toHaveBeenCalledWith(expect.objectContaining({ left: expect.any(Number), top: expect.any(Number) })); + expect(paste).toHaveBeenCalledWith( + expect.objectContaining({ left: expect.any(Number), top: expect.any(Number) }), + undefined, + { historySource: 'stage-contextmenu' }, + ); }); test('useMoveToMenu - display 行为校验', () => {