Compare commits

..

12 Commits

Author SHA1 Message Date
roymondchen
c4ec2c5c72 perf(editor): 优化节点信息查找性能 2026-06-09 14:23:43 +08:00
roymondchen
48519b0155 fix(editor): 优化历史回滚确认流程 2026-06-09 11:05:26 +08:00
roymondchen
a965dfb06e refactor(editor): 优化历史记录列表复用 2026-06-08 20:09:10 +08:00
roymondchen
614f12adf3 feat(editor): 支持历史记录持久化
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 17:04:39 +08:00
roymondchen
bddc6f343c feat(editor): 支持按历史记录 uuid 回滚 2026-06-05 19:25:50 +08:00
roymondchen
be3a900e6a fix(editor): 修复历史对比属性配置上下文缺失 2026-06-05 17:27:20 +08:00
roymondchen
bc555ebdc0 chore: update lockfile v1.8.0-beta.4 2026-06-04 17:15:03 +08:00
roymondchen
b7d1cea7c1 chore: release v1.8.0-beta.4 2026-06-04 17:13:59 +08:00
roymondchen
3bd0eecb42 fix(editor): 修复合并历史记录信息展示
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 17:10:54 +08:00
roymondchen
cd19dec790 fix(editor): 修复历史对比样式配置显示
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 16:59:08 +08:00
roymondchen
10b70c36bb fix(editor): 禁止缺少变更记录的历史回滚 2026-06-04 16:48:24 +08:00
roymondchen
27b2c2c685 feat(editor): 历史记录支持操作来源 2026-06-04 16:08:52 +08:00
89 changed files with 4249 additions and 1271 deletions

View File

@ -1,3 +1,19 @@
# [1.8.0-beta.4](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.3...v1.8.0-beta.4) (2026-06-04)
### Bug Fixes
* **editor:** 修复历史对比样式配置显示 ([cd19dec](https://github.com/Tencent/tmagic-editor/commit/cd19dec7907cac5cff775f1cbde24cb3f384e87b))
* **editor:** 修复合并历史记录信息展示 ([3bd0eec](https://github.com/Tencent/tmagic-editor/commit/3bd0eecb42d06f06f50cc4736ecc31cc07cc1886))
* **editor:** 禁止缺少变更记录的历史回滚 ([10b70c3](https://github.com/Tencent/tmagic-editor/commit/10b70c36bbace6af48bf6fa63f2df0704c6861af))
### Features
* **editor:** 历史记录支持操作来源 ([27b2c2c](https://github.com/Tencent/tmagic-editor/commit/27b2c2c68598264e97a1e1ecc34121829851c85e))
# [1.8.0-beta.3](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.2...v1.8.0-beta.3) (2026-06-04)

View File

@ -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<void>}`
@ -226,6 +235,86 @@
`newContent=null` 的删除记录;不存在的 id 不会入历史。传入 `doNotPushHistory: true` 也可显式跳过写入历史栈。
:::
## setCodeDslByIdAndGetHistoryId
- **参数:** 同 [setCodeDslById](#setcodedslbyid)
- **返回:**
- {`Promise<string | null>`} 本次写入历史记录的 uuid未写入历史`doNotPushHistory: true` 等)时返回 `null`
- **详情:**
与 [setCodeDslById](#setcodedslbyid) 行为完全一致,仅把返回值换成本次写入历史记录的 `uuid`,可用于精确引用 / 定位该条历史记录。
参见 [editorService 历史记录 uuid 与 \*AndGetHistoryId](./editorServiceMethods.md#历史记录-uuid-与-andgethistoryid)。
- **示例:**
```js
import { codeBlockService } from "@tmagic/editor";
const historyId = await codeBlockService.setCodeDslByIdAndGetHistoryId("code_1234", {
name: "代码块1",
content: "() => {}",
});
console.log(historyId); // 本次变更对应的历史记录 uuid或 null
```
## setCodeDslByIdSyncAndGetHistoryId
- **参数:** 同 [setCodeDslByIdSync](#setcodedslbyidsync)
- **返回:**
- {`string | null`} 本次写入历史记录的 uuid未写入历史`doNotPushHistory: true`、或 `force=false` 跳过等)时返回 `null`
- **详情:**
与 [setCodeDslByIdSync](#setcodedslbyidsync) 行为完全一致(同步),仅把返回值换成本次写入历史记录的 `uuid`
## deleteCodeDslByIdsAndGetHistoryId
- **参数:** 同 [deleteCodeDslByIds](#deletecodedslbyids)
- **返回:**
- {`Promise<string[]>`} 本次写入的全部历史记录 uuid按删除顺序未写入任何历史时返回空数组 `[]`
- **详情:**
与 [deleteCodeDslByIds](#deletecodedslbyids) 行为完全一致。由于一次可删除多个代码块、会产生多条历史记录,因此返回的是 uuid 数组(每条删除记录一个 uuid不存在的 id 不会入历史,也不会出现在返回数组中。
- **示例:**
```js
import { codeBlockService } from "@tmagic/editor";
const historyIds = await codeBlockService.deleteCodeDslByIdsAndGetHistoryId(["code_1", "code_2"]);
console.log(historyIds); // ['xxxx', 'yyyy'],或 []
```
## revertById
- **参数:**
- `{string}` uuid 目标历史记录的 uuid通常由 [setCodeDslByIdAndGetHistoryId](#setcodedslbyidandgethistoryid) 等方法返回)
- **返回:**
- {`Promise<CodeBlockStepValue | null>`} 反向应用后产生的新 step找不到对应 uuid / 该步未应用时返回 `null`
- **详情:**
通过历史记录 uuid「回滚」某条代码块历史步骤类 git revert 语义),语义同按 `(id, index)` 回滚,
仅无需调用方再传 `codeBlockId``index`:内部会按 uuid 在全部代码块栈中定位对应步骤后再回滚。
参见 [editorService 历史记录 uuid 与 \*AndGetHistoryId](./editorServiceMethods.md#历史记录-uuid-与-andgethistoryid)。
- **示例:**
```js
import { codeBlockService } from "@tmagic/editor";
const historyId = await codeBlockService.setCodeDslByIdAndGetHistoryId("code_1234", { name: "代码块1" });
if (historyId) {
await codeBlockService.revertById(historyId);
}
```
## undo
- **参数:**

View File

@ -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}`
@ -400,6 +406,78 @@ import { dataSourceService } from "@tmagic/editor";
dataSourceService.remove("ds_123");
```
## addAndGetHistoryId
- **参数:** 同 [add](#add)
- **返回:**
- {`string` | null} 本次写入历史记录的 uuid未写入历史`doNotPushHistory: true` 等)时返回 `null`
- **详情:**
与 [add](#add) 行为完全一致,仅把返回值换成本次写入历史记录的 `uuid`,可用于精确引用 / 定位该条历史记录。
参见 [editorService 历史记录 uuid 与 \*AndGetHistoryId](./editorServiceMethods.md#历史记录-uuid-与-andgethistoryid)。
- **示例:**
```js
import { dataSourceService } from "@tmagic/editor";
const historyId = dataSourceService.addAndGetHistoryId({
type: "http",
title: "用户信息",
url: "/api/user",
});
console.log(historyId); // 本次新增对应的历史记录 uuid或 null
```
## updateAndGetHistoryId
- **参数:** 同 [update](#update)
- **返回:**
- {`string` | null} 本次写入历史记录的 uuid未写入历史时返回 `null`
- **详情:**
与 [update](#update) 行为完全一致,仅把返回值换成本次写入历史记录的 `uuid`
## removeAndGetHistoryId
- **参数:** 同 [remove](#remove)
- **返回:**
- {`string` | null} 本次写入历史记录的 uuid删除的 id 不存在或未写入历史时返回 `null`
- **详情:**
与 [remove](#remove) 行为完全一致,仅把返回值换成本次写入历史记录的 `uuid`
## revertById
- **参数:**
- `{string}` uuid 目标历史记录的 uuid通常由 [addAndGetHistoryId](#addandgethistoryid) 等方法返回)
- **返回:**
- {`DataSourceStepValue` | null} 反向应用后产生的新 step找不到对应 uuid / 该步未应用时返回 `null`
- **详情:**
通过历史记录 uuid「回滚」某条数据源历史步骤类 git revert 语义),语义同按 `(id, index)` 回滚,
仅无需调用方再传 `dataSourceId``index`:内部会按 uuid 在全部数据源栈中定位对应步骤后再回滚。
参见 [editorService 历史记录 uuid 与 \*AndGetHistoryId](./editorServiceMethods.md#历史记录-uuid-与-andgethistoryid)。
- **示例:**
```js
import { dataSourceService } from "@tmagic/editor";
const historyId = dataSourceService.addAndGetHistoryId({ type: "http", title: "用户信息" });
if (historyId) {
dataSourceService.revertById(historyId);
}
```
## createId
- **[扩展支持](../../guide/editor-expand#行为扩展)** 是

View File

@ -1,5 +1,48 @@
# 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`),便于历史面板区分来源。
## 历史记录 uuid 与 \*AndGetHistoryId
每条历史记录入栈时都会自动生成一个唯一标识 `uuid`(见 [StepValue](#undo)),可用于精确引用 / 定位某一条历史记录(如埋点、回滚、跨端同步等)。
DSL 操作方法(`add` / `remove` / `update` 等)默认返回操作结果(节点 / 节点集合 / void不会返回 `uuid`。若需要拿到本次写入历史记录的 `uuid`,可改用对应的 `*AndGetHistoryId` 方法:它们与原方法行为完全一致,仅把返回值换成本次写入历史记录的 `uuid``string`)。当本次操作未写入历史(`doNotPushHistory: true`、无实际变更或提前返回)时返回 `null`
| 原方法 | 取 uuid 的方法 | 返回值 |
| --- | --- | --- |
| [add](#add) | [addAndGetHistoryId](#addandgethistoryid) | `Promise<string \| null>` |
| [remove](#remove) | [removeAndGetHistoryId](#removeandgethistoryid) | `Promise<string \| null>` |
| [update](#update) | [updateAndGetHistoryId](#updateandgethistoryid) | `Promise<string \| null>` |
| [moveLayer](#movelayer) | [moveLayerAndGetHistoryId](#movelayerandgethistoryid) | `Promise<string \| null>` |
| [moveToContainer](#movetocontainer) | [moveToContainerAndGetHistoryId](#movetocontainerandgethistoryid) | `Promise<string \| null>` |
| [dragTo](#dragto) | [dragToAndGetHistoryId](#dragtoandgethistoryid) | `Promise<string \| null>` |
[dataSourceService](./dataSourceServiceMethods.md) / [codeBlockService](./codeBlockServiceMethods.md) 也提供了同名约定的 `*AndGetHistoryId` 方法。
拿到 `uuid` 后,可在需要时按 uuid「回滚」对应的历史记录类 git revert 语义,详见[历史记录面板](../../guide/advanced/history-list.md))。相比按 index 回滚uuid 不会随栈内步骤增删而变化,更适合业务侧持有引用后再回滚:
- 页面:[editorService.revertPageStepById(uuid)](#revertpagestepbyid)
- 数据源:[dataSourceService.revertById(uuid)](./dataSourceServiceMethods.md#revertbyid)
- 代码块:[codeBlockService.revertById(uuid)](./codeBlockServiceMethods.md#revertbyid)
::: 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 +402,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 +450,8 @@ editorService.highlight("text_123");
- `{boolean}` doNotSelect 删除后是否不更新当前选中节点(默认 false删除后会选中父节点或首个页面
- `{boolean}` doNotSwitchPage 删除后是否不切换当前页面(默认 false删除页面 / 页面片段时为 true 会跳过自动切换到首个剩余页面)
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false
- `{string}` historyDescription 见[历史记录相关 options](#历史记录相关-options)
- `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options)
- **返回:**
- `{Promise<void>}`
@ -459,6 +506,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 +549,7 @@ editorService.highlight("text_123");
- `{boolean}` doNotSelect 排序后是否不更新当前选中节点(默认 false
- `{boolean}` doNotSwitchPage 排序后是否不切换当前页面(排序只发生在同一父节点内,方法内为空操作;保留以与其它 DSL 操作 API 一致)
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false
- `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options)
- **返回:**
- `{Promise<void>}`
@ -568,6 +618,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 +658,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 +682,8 @@ alignCenter可以支持一次水平居中多个组件alignCenter是通过调
- `{number | 'top' | 'bottom'}` offset
- `{Object}` options 可选配置
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false
- `{string}` historyDescription 见[历史记录相关 options](#历史记录相关-options)
- `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options)
- **返回:**
- `{Promise<void>}`
@ -649,6 +705,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 +723,8 @@ alignCenter可以支持一次水平居中多个组件alignCenter是通过调
- `{number}` targetIndex 目标位置索引
- `{Object}` options 可选配置
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false
- `{string}` historyDescription 见[历史记录相关 options](#历史记录相关-options)
- `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options)
- **返回:**
- `{Promise<void>}`
@ -673,6 +733,115 @@ alignCenter可以支持一次水平居中多个组件alignCenter是通过调
将节点(支持多选)拖拽到目标容器的指定位置,会自动处理跨容器布局切换并记录历史
## addAndGetHistoryId
- **参数:** 同 [add](#add)
- **返回:**
- {Promise<`string` | null>} 本次写入历史记录的 [uuid](#历史记录-uuid-与-andgethistoryid);未写入历史时返回 `null`
- **详情:**
与 [add](#add) 行为完全一致,仅把返回值换成本次写入历史记录的 `uuid`,见[历史记录 uuid 与 \*AndGetHistoryId](#历史记录-uuid-与-andgethistoryid)
- **示例:**
```js
import { editorService } from "@tmagic/editor";
const historyId = await editorService.addAndGetHistoryId(
{ type: "text", text: "hello" },
parent,
{ historySource: "api" },
);
console.log(historyId); // 本次新增对应的历史记录 uuid或 null
```
## removeAndGetHistoryId
- **参数:** 同 [remove](#remove)
- **返回:**
- {Promise<`string` | null>} 本次写入历史记录的 [uuid](#历史记录-uuid-与-andgethistoryid);未写入历史时返回 `null`
- **详情:**
与 [remove](#remove) 行为完全一致,仅把返回值换成本次写入历史记录的 `uuid`
## updateAndGetHistoryId
- **参数:** 同 [update](#update)
- **返回:**
- {Promise<`string` | null>} 本次写入历史记录的 [uuid](#历史记录-uuid-与-andgethistoryid);未写入历史时返回 `null`
- **详情:**
与 [update](#update) 行为完全一致,仅把返回值换成本次写入历史记录的 `uuid`
## moveLayerAndGetHistoryId
- **参数:** 同 [moveLayer](#movelayer)
- **返回:**
- {Promise<`string` | null>} 本次写入历史记录的 [uuid](#历史记录-uuid-与-andgethistoryid);未写入历史时返回 `null`
- **详情:**
与 [moveLayer](#movelayer) 行为完全一致,仅把返回值换成本次写入历史记录的 `uuid`
## moveToContainerAndGetHistoryId
- **参数:** 同 [moveToContainer](#movetocontainer)
- **返回:**
- {Promise<`string` | null>} 本次写入历史记录的 [uuid](#历史记录-uuid-与-andgethistoryid);未写入历史时返回 `null`
- **详情:**
与 [moveToContainer](#movetocontainer) 行为完全一致,仅把返回值换成本次写入历史记录的 `uuid`
## dragToAndGetHistoryId
- **参数:** 同 [dragTo](#dragto)
- **返回:**
- {Promise<`string` | null>} 本次写入历史记录的 [uuid](#历史记录-uuid-与-andgethistoryid);未写入历史时返回 `null`
- **详情:**
与 [dragTo](#dragto) 行为完全一致,仅把返回值换成本次写入历史记录的 `uuid`
## revertPageStepById
- **参数:**
- `{string}` uuid 目标历史记录的 [uuid](#历史记录-uuid-与-andgethistoryid)(通常由 `*AndGetHistoryId` 方法返回)
- **返回:**
- {Promise<`StepValue` | null>} 反向应用后产生的新 step找不到对应 uuid / 该步未应用 / 反向失败时返回 `null`
- **详情:**
通过历史记录 uuid「回滚」当前页面的某条历史步骤类 git revert 语义):不移动游标、不丢弃任何步骤,而是把目标 step 的修改**反向应用为一条全新的步骤**压入栈顶。语义与按 index 回滚一致,仅入参从 index 改为 uuid更适合业务侧持有引用后再回滚。
::: tip
`opType: 'update'` 的步骤必须携带 `changeRecords` 才支持回滚(否则只能整节点替换,会冲掉后续无关变更);未应用(已被撤销)的步骤无法回滚。
:::
- **示例:**
```js
import { editorService } from "@tmagic/editor";
// 执行操作时拿到本次历史记录 uuid
const historyId = await editorService.addAndGetHistoryId({ type: "text", text: "hello" });
// 之后任意时机按 uuid 回滚该步骤
if (historyId) {
await editorService.revertPageStepById(historyId);
}
```
## undo
- **[扩展支持](../../guide/editor-expand#行为扩展)** 是
@ -685,6 +854,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 +870,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 +893,8 @@ alignCenter可以支持一次水平居中多个组件alignCenter是通过调
- `{number}` top
- `{Object}` options 可选配置
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false
- `{string}` historyDescription 见[历史记录相关 options](#历史记录相关-options)
- `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options)
- **返回:**
- `{Promise<void>}`

View File

@ -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}
:::
@ -67,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}
:::

View File

@ -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,14 @@
`opType: 'update'` 的每个 `updatedItems[i]` 上可携带 `changeRecords`,用于撤销 / 重做时仅按
`propPath` 局部更新对应字段,避免整节点替换冲掉同节点上的其它无关变更;不带
`changeRecords` 时退化为整节点替换(如 `sort` / `moveLayer` / 拖动等纯快照场景)。
`StepValue` 上的 `historyDescription` / `source` 仅用于历史面板展示与埋点,不影响 undo/redo 行为。
入栈时会为每条记录自动生成唯一标识 `uuid`(调用方未指定时),可用于精确引用 / 定位某一条历史记录。
若需要在执行 DSL 操作后拿到本次写入记录的 `uuid`,可使用 editorService / dataSourceService /
codeBlockService 提供的 `*AndGetHistoryId` 方法,参见
[editorService 历史记录 uuid 与 \*AndGetHistoryId](./editorServiceMethods.md#历史记录-uuid-与-andgethistoryid)。
`pushCodeBlock` / `pushDataSource` 同样会自动写入 `uuid`
:::
## undo
@ -91,10 +101,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 +186,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}
:::
@ -242,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<PersistedHistoryState>}` 写入成功的快照对象
- **详情:**
把当前内存中的全部历史栈(页面 / 代码块 / 数据源)连同各自游标、容量序列化后写入本地 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<PersistedHistoryState | null>}` 找不到记录时返回 `null`
- **详情:**
从本地 IndexedDB 读取此前保存的历史快照并重建全部撤销/重做栈。
- 每个栈都会按 `listMaxSize` 裁剪并还原游标;
- 若某个栈存在已保存记录(见 `markSaved`),其游标会被定位到「最近一条已保存记录」之后,使恢复后的状态与落库的 DSL 对齐;
- 会整体覆盖当前内存中的历史状态,并把活动页恢复为快照中的 `pageId`
- 找不到对应记录时返回 `null` 且不改动当前状态;不支持 IndexedDB 的环境会 reject。
成功后触发 `restore-from-indexed-db``change` 事件。
## destroy
- **详情:**

View File

@ -61,6 +61,12 @@ const menu = ref({
- 数据源:`dataSourceService.revert(id, index)`
- 代码块:`codeBlockService.revert(id, index)`
如果业务侧在执行操作时已通过 `*AndGetHistoryId` 拿到了该条记录的 [uuid](/api/editor/editorServiceMethods.md#历史记录-uuid-与-andgethistoryid),也可以直接按 uuid 回滚(无需再关心 index / id且 uuid 不会随栈内步骤增删而变化):
- 页面:`editorService.revertPageStepById(uuid)`
- 数据源:`dataSourceService.revertById(uuid)`
- 代码块:`codeBlockService.revertById(uuid)`
### 4. 差异对比
在前后值都存在的 `update` 步骤上提供「查看差异」入口,点击后弹出差异对话框。对话框支持两个维度的切换:

View File

@ -1,5 +1,5 @@
{
"version": "1.8.0-beta.3",
"version": "1.8.0-beta.4",
"name": "tmagic",
"private": true,
"type": "module",

View File

@ -1,5 +1,5 @@
{
"version": "1.8.0-beta.3",
"version": "1.8.0-beta.4",
"name": "@tmagic/cli",
"main": "lib/index.js",
"types": "lib/index.d.ts",

View File

@ -1,5 +1,5 @@
{
"version": "1.8.0-beta.3",
"version": "1.8.0-beta.4",
"name": "@tmagic/core",
"type": "module",
"sideEffects": false,

View File

@ -1,5 +1,5 @@
{
"version": "1.8.0-beta.3",
"version": "1.8.0-beta.4",
"name": "@tmagic/data-source",
"type": "module",
"sideEffects": false,

View File

@ -1,5 +1,5 @@
{
"version": "1.8.0-beta.3",
"version": "1.8.0-beta.4",
"name": "@tmagic/dep",
"type": "module",
"sideEffects": false,

View File

@ -1,5 +1,5 @@
{
"version": "1.8.0-beta.3",
"version": "1.8.0-beta.4",
"name": "@tmagic/design",
"type": "module",
"sideEffects": [

View File

@ -1,5 +1,5 @@
{
"version": "1.8.0-beta.3",
"version": "1.8.0-beta.4",
"name": "@tmagic/editor",
"type": "module",
"sideEffects": [

View File

@ -152,6 +152,24 @@ const showDiff = ({ curValue, lastValue, config }: { curValue: any; lastValue: a
return !isEqual(curValue, lastValue);
};
const removeStyleDisplayConfig = (formConfig: FormConfig): FormConfig =>
formConfig.map((item) => {
if (!('type' in item)) return item;
if (item?.type !== 'tab' || !Array.isArray(item.items)) return item;
return {
...item,
items: item.items.map((tabPane) => {
if (tabPane?.title !== '样式' || !Array.isArray(tabPane.items)) return tabPane;
return {
...tabPane,
display: true,
};
}),
};
});
/**
* 内置的默认 FormConfig 加载逻辑 `category` 从对应 service / 工具取配置
* 作为 ctx.defaultLoadConfig 透传给自定义 `loadConfig`方便复用与二次加工
@ -162,7 +180,9 @@ const defaultLoadConfig = async (): Promise<FormConfig> => {
if (!props.type) {
return [];
}
return await propsService.getPropsConfig(props.type);
return removeStyleDisplayConfig(
await propsService.getPropsConfig(props.type, { node: props.value as unknown as MNode }),
);
}
case 'data-source': {
return dataSourceService.getFormConfig(props.type || 'base');

View File

@ -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<Omit<CodeBlockContent, 'content'> & { 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();

View File

@ -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();

View File

@ -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', () => {

View File

@ -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;

View File

@ -1,7 +1,7 @@
<template>
<div class="m-editor-history-list-bucket">
<div class="m-editor-history-list-bucket-title">
<span>{{ title }}</span>
<span>{{ config.title }}</span>
<code>{{ String(bucketId) }}</code>
<span class="m-editor-history-list-bucket-count">{{ groups.length }} </span>
</div>
@ -9,30 +9,10 @@
<ul class="m-editor-history-list-ul">
<GroupRow
v-for="group in groups"
:key="`${prefix}-${bucketId}-${group.steps[0]?.index}`"
:group-key="`${prefix}-${bucketId}-${group.steps[0]?.index}`"
:applied="group.applied"
:merged="group.steps.length > 1"
:op-type="group.opType"
:desc="describeGroup(group)"
:time="formatHistoryTime(groupTimestamp(group))"
:time-title="formatHistoryFullTime(groupTimestamp(group))"
:step-count="group.steps.length"
:sub-steps="
group.steps.map((s: any) => ({
index: s.index,
applied: s.applied,
isCurrent: s.isCurrent,
desc: describeStep(s.step),
diffable: isStepDiffable ? isStepDiffable(s.step) : false,
revertable: s.applied,
time: formatHistoryTime(s.step.timestamp),
timeTitle: formatHistoryFullTime(s.step.timestamp),
}))
"
:is-current="group.isCurrent"
:expanded="!!expanded[`${prefix}-${bucketId}-${group.steps[0]?.index}`]"
:goto-enabled="gotoEnabled"
:key="rowKey(group)"
:group="toRow(group)"
:expanded="!!expanded[rowKey(group)]"
:goto-enabled="config.gotoEnabled"
@toggle="(key: string) => $emit('toggle', key)"
@goto="(index: number) => $emit('goto', bucketId, index)"
@diff-step="(index: number) => $emit('diff-step', bucketId, index)"
@ -41,24 +21,25 @@
<!--
初始状态项永远位于该 bucket 列表底部同样按倒序展示最底部 = 最早状态
bucket 内所有 group 都未 applied 时即为当前位置
showInitial=false 时不展示用于没有"撤销到初始状态"语义的自定义历史如业务模块历史
config.showInitial=false 时不展示用于没有"撤销到初始状态"语义的自定义历史如业务模块历史
-->
<InitialRow
v-if="showInitial !== false"
v-if="config.showInitial !== false"
:is-current="isInitial"
:goto-enabled="gotoEnabled"
:goto-enabled="config.gotoEnabled"
@goto-initial="$emit('goto-initial', bucketId)"
/>
</ul>
</div>
</template>
<script lang="ts" setup>
<script lang="ts" setup generic="T extends BaseStepValue = BaseStepValue">
import { computed } from 'vue';
import type { HistoryOpType } from '@editor/type';
import type { BaseStepValue } from '@editor/type';
import { formatHistoryFullTime, formatHistoryTime, groupTimestamp } from './composables';
import type { HistoryBucketConfig, HistoryBucketGroup, HistoryRowGroup } from './composables';
import { toRowGroup } from './composables';
import GroupRow from './GroupRow.vue';
import InitialRow from './InitialRow.vue';
@ -66,42 +47,19 @@ defineOptions({
name: 'MEditorHistoryListBucket',
});
const props = withDefaults(
defineProps<{
/** Bucket 标题,例如 "数据源" / "代码块",渲染在 bucket 头部。 */
title: string;
/** 当前 bucket 对应的目标 iddataSource.id 或 codeBlock.id同时用于组装子项的 key。 */
bucketId: string | number;
/**
* 子项 key 的命名空间前缀内置 `ds` 表示数据源`cb` 表示代码块
* 业务方复用 Bucket 时可传入自定义前缀 `mod`与上层折叠状态 key 保持一致
*/
prefix: string;
/** 是否展示底部「回到初始状态」入口,默认 true。无 undo cursor 语义的自定义历史可传 false 关闭。 */
showInitial?: boolean;
/** 当前 bucket 下的所有历史分组,按时间倒序展示(最近的操作在前)。 */
groups: {
applied: boolean;
isCurrent?: boolean;
opType: HistoryOpType;
steps: { index: number; applied: boolean; isCurrent?: boolean; step: any }[];
}[];
/** 组级描述文案生成器,接收一个 group返回展示文本。由父组件按业务类型注入。 */
describeGroup: (_group: any) => string;
/** 单步描述文案生成器,接收一个 step返回展示文本。用于合并组展开后的子步列表。 */
describeStep: (_step: any) => string;
/** 判断某个 step 是否可查看差异(前后值都存在)。由父组件按业务类型注入;不传则一律不展示差异入口。 */
isStepDiffable?: (_step: any) => boolean;
/** 共享的折叠状态表key -> 是否展开),由顶层 panel 统一维护以便跨 tab 复用。 */
expanded: Record<string, boolean>;
/** 是否支持「跳转到该记录」(goto)。默认 true。 */
gotoEnabled?: boolean;
}>(),
{
showInitial: true,
gotoEnabled: true,
},
);
const props = defineProps<{
/**
* 该类历史的整体渲染配置title / prefix / describe* / isStep* / showInitial / gotoEnabled
* 由父组件按业务类型注入组件内部按需读取避免逐项透传多个 props
*/
config: HistoryBucketConfig<T>;
/** 当前 bucket 对应的目标 iddataSource.id 或 codeBlock.id同时用于组装子项的 key。 */
bucketId: string | number;
/** 当前 bucket 下的所有历史分组,按时间倒序展示(最近的操作在前)。 */
groups: HistoryBucketGroup<T>[];
/** 共享的折叠状态表key -> 是否展开),由顶层 panel 统一维护以便跨 tab 复用。 */
expanded: Record<string, boolean>;
}>();
defineEmits<{
/** 透传子组件 GroupRow 的 toggle由上层 panel 更新 expanded。 */
@ -119,6 +77,15 @@ defineEmits<{
(_e: 'revert-step', _bucketId: string | number, _index: number): void;
}>();
/**
* 子项 / 折叠状态 key`${prefix}-${bucketId}-${组内首步 index}`
* 以稳定的 step 索引而非展示位置标识分组历史数据更新后已展开的分组状态仍能正确保持
*/
const rowKey = (group: HistoryBucketGroup<T>) => `${props.config.prefix}-${props.bucketId}-${group.steps[0]?.index}`;
/** 把原始分组派生为 GroupRow 直接消费的视图模型。 */
const toRow = (group: HistoryBucketGroup<T>): HistoryRowGroup => toRowGroup(group, rowKey(group), props.config);
/** 该 bucket 是否处于初始状态(栈 cursor=0等价于全部 group 都未 applied。 */
const isInitial = computed(() => props.groups.length > 0 && props.groups.every((g) => !g.applied));
</script>

View File

@ -1,66 +1,59 @@
<template>
<div v-if="!buckets.length" class="m-editor-history-list-empty">暂无操作记录</div>
<TMagicScrollbar v-else max-height="360px">
<Bucket
v-for="bucket in buckets"
:key="`${prefix}-${bucket.id}`"
:title="title"
:bucket-id="bucket.id"
:prefix="prefix"
:groups="bucket.groups"
:describe-group="describeGroup"
:describe-step="describeStep"
:is-step-diffable="isStepDiffable"
:expanded="expanded"
:goto-enabled="gotoEnabled"
@toggle="(key: string) => $emit('toggle', key)"
@goto="(id: string | number, index: number) => $emit('goto', id, index)"
@goto-initial="(id: string | number) => $emit('goto-initial', id)"
@diff-step="(id: string | number, index: number) => $emit('diff-step', id, index)"
@revert-step="(id: string | number, index: number) => $emit('revert-step', id, index)"
/>
</TMagicScrollbar>
<template v-else>
<div class="m-editor-history-list-toolbar">
<span class="m-editor-history-list-clear" :title="`清空${config.title}的历史记录`" @click="$emit('clear')"
>清空</span
>
</div>
<TMagicScrollbar max-height="360px">
<Bucket
v-for="bucket in buckets"
:key="`${config.prefix}-${bucket.id}`"
:config="config"
:bucket-id="bucket.id"
:groups="bucket.groups"
:expanded="expanded"
@toggle="(key: string) => $emit('toggle', key)"
@goto="(id: string | number, index: number) => $emit('goto', id, index)"
@goto-initial="(id: string | number) => $emit('goto-initial', id)"
@diff-step="(id: string | number, index: number) => $emit('diff-step', id, index)"
@revert-step="(id: string | number, index: number) => $emit('revert-step', id, index)"
/>
</TMagicScrollbar>
</template>
</template>
<script lang="ts" setup>
<script lang="ts" setup generic="T extends BaseStepValue = BaseStepValue">
import { TMagicScrollbar } from '@tmagic/design';
import type { BaseStepValue } from '@editor/type';
import Bucket from './Bucket.vue';
import type { HistoryBucketConfig, HistoryBucketGroup } from './composables';
defineOptions({
name: 'MEditorHistoryListBucketTab',
});
withDefaults(
defineProps<{
/** bucket 头部展示的标题,例如 "数据源" / "代码块"。 */
title: string;
/** 子项 key 的命名空间前缀(`ds` 数据源 / `cb` 代码块),与上层折叠状态 key 保持一致。 */
prefix: string;
/**
* 已按目标 id 聚拢成的 bucket 列表每个 bucket 内部的 groups 已按时间倒序排好
* 空数组时显示空态
*/
buckets: { id: string | number; groups: any[] }[];
/** 组级描述文案生成器,由父组件按业务类型注入。 */
describeGroup: (_group: any) => string;
/** 单步描述文案生成器,由父组件按业务类型注入。 */
describeStep: (_step: any) => string;
/** 判断某个 step 是否可查看差异(前后值都存在)。由父组件按业务类型注入。 */
isStepDiffable: (_step: any) => boolean;
/**
* 共享的折叠状态表key -> 是否展开由顶层 panel 统一维护
* tab 使用 `${prefix}-${id}-${组内首步 index}` 作为 key以稳定的 step 索引而非展示位置标识分组
* 这样历史数据更新后已展开的分组状态仍能正确保持
*/
expanded: Record<string, boolean>;
/** 是否支持「跳转到该记录」(goto),透传给 Bucket。默认 true。 */
gotoEnabled?: boolean;
}>(),
{
gotoEnabled: true,
},
);
defineProps<{
/**
* 该类历史的整体渲染配置title / prefix / describe* / isStep* / showInitial / gotoEnabled
* 由父组件按业务类型注入并整体透传给 Bucket避免逐项透传多个 props
*/
config: HistoryBucketConfig<T>;
/**
* 已按目标 id 聚拢成的 bucket 列表每个 bucket 内部的 groups 已按时间倒序排好
* 空数组时显示空态
*/
buckets: { id: string | number; groups: HistoryBucketGroup<T>[] }[];
/**
* 共享的折叠状态表key -> 是否展开由顶层 panel 统一维护
* key 形如 `${prefix}-${id}-${组内首步 index}`以稳定的 step 索引而非展示位置标识分组
* 这样历史数据更新后已展开的分组状态仍能正确保持
*/
expanded: Record<string, boolean>;
}>();
defineEmits<{
/** 透传子组件 Bucket 的 toggle 事件给上层 panel由其更新 expanded。 */
@ -73,5 +66,7 @@ defineEmits<{
(_e: 'diff-step', _targetId: string | number, _index: number): void;
/** 透传 Bucket 的 revert-step 事件,携带目标 id 与 step 索引(类 git revert。 */
(_e: 'revert-step', _targetId: string | number, _index: number): void;
/** 用户点击"清空"按钮,请求清空该类(数据源 / 代码块)的全部历史记录(由上层弹窗二次确认后执行)。 */
(_e: 'clear'): void;
}>();
</script>

View File

@ -1,7 +1,7 @@
<template>
<li
class="m-editor-history-list-item m-editor-history-list-group"
:class="{ 'is-undone': !applied, 'is-merged': merged, 'is-current': isCurrent }"
:class="{ 'is-undone': !group.applied, 'is-merged': merged, 'is-current': group.isCurrent }"
>
<div
class="m-editor-history-list-group-head"
@ -10,10 +10,24 @@
@click="onHeadClick"
>
<span class="m-editor-history-list-item-index" :title="headIndexTitle">{{ headIndexLabel }}</span>
<span class="m-editor-history-list-item-op" :class="`op-${opType}`">{{ opLabel(opType) }}</span>
<span class="m-editor-history-list-item-desc">{{ desc }}</span>
<span class="m-editor-history-list-item-op" :class="`op-${group.opType}`">{{ opLabel(group.opType) }}</span>
<span class="m-editor-history-list-item-desc">{{ group.desc }}</span>
<span v-if="time" class="m-editor-history-list-item-time" :title="timeTitle || time">{{ time }}</span>
<span v-if="headSaved" class="m-editor-history-list-item-saved" title="该记录为最近一次保存的状态">已保存</span>
<span
v-if="!merged && sourceLabel(group.source)"
class="m-editor-history-list-item-source"
:title="`操作途径:${sourceLabel(group.source)}`"
>{{ sourceLabel(group.source) }}</span
>
<span
v-if="!merged && group.time"
class="m-editor-history-list-item-time"
:title="group.timeTitle || group.time"
>{{ group.time }}</span
>
<span v-if="merged" class="m-editor-history-list-item-merge">合并 {{ stepCount }} </span>
@ -21,21 +35,21 @@
v-if="!merged && headRevertable"
class="m-editor-history-list-item-revert"
title="将该步骤的修改作为一次新操作反向应用(不影响后续历史)"
@click.stop="onRevertClick(subSteps[0].index)"
@click.stop="onRevertClick(group.subSteps[0].index)"
>回滚</span
>
<span
v-if="!merged && gotoEnabled && !isCurrent && subSteps.length"
v-if="!merged && gotoEnabled && !group.isCurrent && group.subSteps.length"
class="m-editor-history-list-item-goto"
title="回到该记录"
@click.stop="onGotoClick(subSteps[0].index)"
@click.stop="onGotoClick(group.subSteps[0].index)"
>回到</span
>
<span
v-if="!merged && headDiffable"
class="m-editor-history-list-item-diff"
title="查看修改差异"
@click.stop="onDiffClick(subSteps[0].index)"
@click.stop="onDiffClick(group.subSteps[0].index)"
>查看差异</span
>
<span v-if="merged" class="m-editor-history-list-group-toggle" :class="{ 'is-expanded': expanded }"></span>
@ -50,6 +64,13 @@
>
<span class="m-editor-history-list-item-index">#{{ s.index + 1 }}</span>
<span class="m-editor-history-list-substep-desc">{{ s.desc }}</span>
<span v-if="s.saved" class="m-editor-history-list-item-saved" title="该记录为最近一次保存的状态">已保存</span>
<span
v-if="sourceLabel(s.source)"
class="m-editor-history-list-item-source"
:title="`操作途径:${sourceLabel(s.source)}`"
>{{ sourceLabel(s.source) }}</span
>
<span v-if="s.time" class="m-editor-history-list-item-time" :title="s.timeTitle || s.time">{{ s.time }}</span>
<span
v-if="s.revertable"
@ -80,9 +101,8 @@
<script lang="ts" setup>
import { computed } from 'vue';
import type { HistoryOpType } from '@editor/type';
import { opLabel } from './composables';
import type { HistoryRowGroup, HistoryRowStep } from './composables';
import { opLabel, sourceLabel } from './composables';
defineOptions({
name: 'MEditorHistoryListGroupRow',
@ -90,40 +110,13 @@ defineOptions({
const props = withDefaults(
defineProps<{
/** 唯一标识当前组的 key作为 toggle 事件的 payload 回传给上层。形如 `pg-${首步 index}` / `ds-${id}-${首步 index}` / `cb-${id}-${首步 index}`,以稳定的 step 索引标识分组。 */
groupKey: string;
/** 该组当前是否处于已应用状态false 表示已被 undo 撤销UI 会显示为灰态)。 */
applied: boolean;
/** 是否为合并组(即组内 step 数大于 1由多次连续操作合并而来。决定是否展示合并标记与可展开的子步列表。 */
merged: boolean;
/** 操作类型:`add` / `remove` / `update`,用于决定操作徽标的颜色和文案。 */
opType: HistoryOpType;
/** 组的整体描述文案,由上层根据 step / group 计算后传入,例如 "修改 button · style.color"。 */
desc: string;
/** 组头部展示的时间文案(一般为组内最近一步的时间),为空时不渲染。 */
time?: string;
/** 组头部时间的 title 悬浮提示(完整时间),缺省时回退为 time。 */
timeTitle?: string;
/** 组内的 step 总数,仅在 merged 为 true 时显示为 "合并 N 步"。 */
stepCount: number;
/** 子步列表,用于在展开状态下逐条展示每个 step 的索引、应用状态与描述文案。 */
subSteps: {
index: number;
applied: boolean;
desc: string;
isCurrent?: boolean;
diffable?: boolean;
/** 是否可对该子步执行「回滚」(已应用 + 业务侧确认支持反向)。父级根据 step 与 applied 决定。 */
revertable?: boolean;
/** 该子步的时间文案,为空时不渲染。 */
time?: string;
/** 该子步时间的 title 悬浮提示(完整时间),缺省时回退为 time。 */
timeTitle?: string;
}[];
/** 当前组是否处于展开状态。仅在 merged 为 true 时生效,控制子步列表是否渲染。 */
/**
* 该组的视图模型 `toRowGroup` 统一派生包含 key应用状态操作类型描述
* 来源 / 时间等头部信息以及子步列表原先散落的十余个扁平 props 收敛于此单一对象
*/
group: HistoryRowGroup;
/** 当前组是否处于展开状态。仅在合并组(子步数 > 1时生效控制子步列表是否渲染。 */
expanded: boolean;
/** 是否为当前所在的分组包含栈中最近一次已应用步骤的那一组UI 高亮展示。 */
isCurrent?: boolean;
/**
* 是否支持跳转到该记录(goto)默认 true
* false 单步组头部与子步条目都不再可点击跳转也不会触发 goto 事件
@ -132,21 +125,20 @@ const props = withDefaults(
gotoEnabled?: boolean;
}>(),
{
isCurrent: false,
gotoEnabled: true,
},
);
const emit = defineEmits<{
/**
* 用户点击合并组头部时触发携带 groupKey上层用其切换 expanded 状态
* 用户点击合并组头部时触发携带 group.key上层用其切换 expanded 状态
* 对单步组非合并头部点击不会发该事件因为单步组没有"展开"的概念
*/
(_e: 'toggle', _key: string): void;
/**
* 用户希望跳转到该记录时触发携带"目标 step 在所属栈中的索引"上层据此计算目标 cursor (= index + 1)
* 触发场景
* - 单步组merged=false头部取该唯一 step index
* - 单步组非合并头部取该唯一 step index
* - 子步条目取该子步的 index
* 合并组头部不再触发 goto避免与展开/收起冲突用户应展开后点具体子步精准跳转
* 当前所在的步骤isCurrent始终不会触发 goto
@ -154,7 +146,7 @@ const emit = defineEmits<{
(_e: 'goto', _index: number): void;
/**
* 用户希望查看该 step 的修改差异旧值 vs 新值
* 只在 step 满足"前后值都存在" update / 数据源代码块的 update时由父级标记 `diffable=true`
* 只在 step 满足"前后值都存在" update / 数据源代码块的 update时由 `toRowGroup` 标记 `diffable=true`
* payload 为该 step 在所属栈中的索引由上层根据 index step 内容并展示对比
*/
(_e: 'diff-step', _index: number): void;
@ -165,15 +157,21 @@ const emit = defineEmits<{
(_e: 'revert-step', _index: number): void;
}>();
/** 子步数大于 1 即为合并组:决定是否展示合并标记与可展开的子步列表。 */
const merged = computed(() => props.group.subSteps.length > 1);
/** 组内 step 总数,仅在合并组时显示为 "合并 N 步"。 */
const stepCount = computed(() => props.group.subSteps.length);
/**
* 仅合并组头部可点击切换展开 / 收起
* 单步组的跳转改由头部的回退按钮触发整行不再可点击
* 单步组的跳转改由头部的按钮触发整行不再可点击
*/
const isHeadClickable = computed(() => props.merged);
const isHeadClickable = computed(() => merged.value);
const headTitle = computed(() => {
if (props.merged) return props.expanded ? '点击收起子步' : '点击展开子步';
if (props.isCurrent) return '当前所在记录';
if (merged.value) return props.expanded ? '点击收起子步' : '点击展开子步';
if (props.group.isCurrent) return '当前所在记录';
return '';
});
@ -181,8 +179,8 @@ const headTitle = computed(() => {
* 头部点击行为仅合并组切换展开 / 收起单步组不再响应整行点击
*/
const onHeadClick = () => {
if (props.merged) {
emit('toggle', props.groupKey);
if (merged.value) {
emit('toggle', props.group.key);
}
};
@ -196,40 +194,48 @@ const subStepTitle = (s: { isCurrent?: boolean }) => {
return '';
};
/**
* 头部是否展示已保存标记
* - 单步组取该唯一子步的 saved
* - 合并组组内任一子步为已保存即在头部提示具体落在哪一步可展开查看
*/
const headSaved = computed(() =>
merged.value ? props.group.subSteps.some((s) => s.saved) : Boolean(props.group.subSteps[0]?.saved),
);
/** 单步组头部是否展示"查看差异"入口:要求该唯一子步本身可对比。 */
const headDiffable = computed(() => !props.merged && Boolean(props.subSteps[0]?.diffable));
const headDiffable = computed(() => !merged.value && Boolean(props.group.subSteps[0]?.diffable));
/** 单步组头部是否展示"回滚"入口:要求该唯一子步本身可回滚(已应用)。 */
const headRevertable = computed(() => !props.merged && Boolean(props.subSteps[0]?.revertable));
const headRevertable = computed(() => !merged.value && Boolean(props.group.subSteps[0]?.revertable));
/**
* 合并组展开后的子步渲染顺序与外层分组列表保持一致倒序展示最新的子步在最上方
* 外层 page tab / bucket 都已对 groups 做了 reverse子步沿用同样的视觉规则更直观
* 注意仅用于渲染 `subSteps` 保持时间正序`headIndexLabel` 等基于首尾索引的展示语义不变
*/
const subStepsDisplay = computed(() => props.subSteps.slice().reverse());
const subStepsDisplay = computed<HistoryRowStep[]>(() => props.group.subSteps.slice().reverse());
/**
* 头部索引展示
* - 单步组merged=false显示该唯一 step 的编号 `#5`
* - 单步组非合并显示该唯一 step 的编号 `#5`
* - 合并组显示组内 step 的编号范围 `#3-#7`首尾相同则退化为 `#5`
*
* 这里展示的是 step.index + 1与子步列表 `#{{ s.index + 1 }}` 保持一致 1 起编号更符合直觉
*/
const headIndexLabel = computed(() => {
const list = props.subSteps;
const list = props.group.subSteps;
if (!list.length) return '';
const first = list[0].index + 1;
const last = list[list.length - 1].index + 1;
if (!props.merged || first === last) return `#${first}`;
if (!merged.value || first === last) return `#${first}`;
return `#${first}-#${last}`;
});
const headIndexTitle = computed(() => {
if (!props.merged) return `历史步骤编号 #${props.subSteps[0]?.index + 1}`;
return `合并了第 ${props.subSteps[0]?.index + 1} 至第 ${
props.subSteps[props.subSteps.length - 1]?.index + 1
} ${props.subSteps.length} 条历史步骤`;
const list = props.group.subSteps;
if (!merged.value) return `历史步骤编号 #${list[0]?.index + 1}`;
return `合并了第 ${list[0]?.index + 1} 至第 ${list[list.length - 1]?.index + 1}${list.length} 条历史步骤`;
});
const onDiffClick = (index: number) => {

View File

@ -1,76 +1,74 @@
<template>
<Teleport to="body">
<TMagicDialog
v-model="visible"
class="m-editor-history-diff-dialog"
:title="dialogTitle"
top="5vh"
destroy-on-close
append-to-body
:width="width"
@close="onClose"
>
<div v-if="payload" class="m-editor-history-diff-dialog-body">
<div v-if="onConfirm" class="m-editor-history-diff-dialog-notice">仅回滚有差异的字段</div>
<TMagicDialog
v-model="visible"
class="m-editor-history-diff-dialog"
:title="dialogTitle"
top="5vh"
destroy-on-close
append-to-body
:width="width"
@close="onClose"
>
<div v-if="payload && visible" class="m-editor-history-diff-dialog-body">
<div v-if="onConfirm" class="m-editor-history-diff-dialog-notice">仅回滚有差异的字段</div>
<div class="m-editor-history-diff-dialog-header">
<span class="m-editor-history-diff-dialog-target">{{ targetText }}</span>
<div class="m-editor-history-diff-dialog-controls">
<TMagicRadioGroup v-model="viewMode" size="small" class="m-editor-history-diff-dialog-view">
<TMagicRadioButton value="form">表单对比</TMagicRadioButton>
<TMagicRadioButton value="code">源码对比</TMagicRadioButton>
</TMagicRadioGroup>
<div class="m-editor-history-diff-dialog-header">
<span class="m-editor-history-diff-dialog-target">{{ targetText }}</span>
<div class="m-editor-history-diff-dialog-controls">
<TMagicRadioGroup v-model="viewMode" size="small" class="m-editor-history-diff-dialog-view">
<TMagicRadioButton value="form">表单对比</TMagicRadioButton>
<TMagicRadioButton value="code">源码对比</TMagicRadioButton>
</TMagicRadioGroup>
<TMagicRadioGroup v-model="mode" size="small" class="m-editor-history-diff-dialog-mode">
<TMagicRadioButton value="before">与修改前对比</TMagicRadioButton>
<TMagicRadioButton value="current" :disabled="!hasCurrent">与当前对比</TMagicRadioButton>
</TMagicRadioGroup>
</div>
<TMagicRadioGroup v-model="mode" size="small" class="m-editor-history-diff-dialog-mode">
<TMagicRadioButton value="before">与修改前对比</TMagicRadioButton>
<TMagicRadioButton value="current" :disabled="!hasCurrent">与当前对比</TMagicRadioButton>
</TMagicRadioGroup>
</div>
<div class="m-editor-history-diff-dialog-legend">
<TMagicTag size="small" type="danger">{{ leftLabel }}</TMagicTag>
<span class="m-editor-history-diff-dialog-arrow"></span>
<TMagicTag size="small" type="success">{{ rightLabel }}</TMagicTag>
<span v-if="mode === 'current' && isSameAsCurrent" class="m-editor-history-diff-dialog-tip">
当前值与该步修改后一致无差异
</span>
</div>
<CompareForm
v-if="viewMode === 'form'"
:category="payload.category"
:type="payload.type"
:data-source-type="payload.dataSourceType"
:value="rightValue"
:last-value="leftValue"
:extend-state="extendState"
:load-config="loadConfig"
:self-diff-field-types="selfDiffFieldTypes"
height="70vh"
/>
<CodeEditor
v-else
type="diff"
language="json"
:init-values="leftValue"
:modified-values="rightValue"
:options="codeDiffOptions"
disabled-full-screen
height="70vh"
/>
</div>
<template #footer>
<template v-if="onConfirm">
<TMagicButton size="small" @click="visible = false">取消</TMagicButton>
<TMagicButton size="small" type="primary" @click="onConfirmClick">确定回滚</TMagicButton>
</template>
<TMagicButton v-else size="small" @click="visible = false">关闭</TMagicButton>
<div class="m-editor-history-diff-dialog-legend">
<TMagicTag size="small" type="danger">{{ leftLabel }}</TMagicTag>
<span class="m-editor-history-diff-dialog-arrow"></span>
<TMagicTag size="small" type="success">{{ rightLabel }}</TMagicTag>
<span v-if="mode === 'current' && isSameAsCurrent" class="m-editor-history-diff-dialog-tip">
当前值与该步修改后一致无差异
</span>
</div>
<CompareForm
v-if="viewMode === 'form'"
:category="payload.category"
:type="payload.type"
:data-source-type="payload.dataSourceType"
:value="rightValue"
:last-value="leftValue"
:extend-state="extendState"
:load-config="loadConfig"
:self-diff-field-types="selfDiffFieldTypes"
height="70vh"
/>
<CodeEditor
v-else
type="diff"
language="json"
:init-values="leftValue"
:modified-values="rightValue"
:options="codeDiffOptions"
disabled-full-screen
height="70vh"
/>
</div>
<template #footer>
<template v-if="isConfirm">
<TMagicButton size="small" @click="visible = false">取消</TMagicButton>
<TMagicButton size="small" type="primary" @click="onConfirmClick">确定回滚</TMagicButton>
</template>
</TMagicDialog>
</Teleport>
<TMagicButton v-else size="small" @click="visible = false">关闭</TMagicButton>
</template>
</TMagicDialog>
</template>
<script lang="ts" setup>
@ -102,6 +100,7 @@ const props = withDefaults(
*/
loadConfig?: CompareFormLoadConfig;
width?: string;
isConfirm?: boolean;
onConfirm?: () => void;
selfDiffFieldTypes?: string[];
}>(),
@ -178,10 +177,15 @@ const isSameAsCurrent = computed(() => {
return isEqual(payload.value.value, payload.value.currentValue);
});
const onConfirmClick = () => {
const cb = props.onConfirm;
/** confirm() 的 resolve仅在「等待用户确认回滚」期间存在 */
let confirmResolve: ((_value: boolean) => void) | null = null;
cb?.();
const onConfirmClick = () => {
props.onConfirm?.();
// resolve(true) visible=false resolve(false)
confirmResolve?.(true);
confirmResolve = null;
visible.value = false;
};
@ -193,7 +197,8 @@ const targetText = computed(() => {
'data-source': '数据源',
'code-block': '代码块',
};
const prefix = categoryText[payload.value.category] || '';
const { category } = payload.value;
const prefix = category ? categoryText[category] : '';
const label = payload.value.targetLabel || payload.value.type || '';
const { id } = payload.value;
const labelWithId = id !== undefined && id !== '' ? `${label}${id}` : label;
@ -209,6 +214,24 @@ const open = (p: DiffDialogPayload) => {
visible.value = true;
};
/**
* Promise 形式打开确认回滚弹窗
* - 用户点击确定回滚 resolve(true)
* - 取消 / 关闭 / Esc 等其他方式关闭弹窗时 resolve(false)
*
* 同一时刻只允许一个待确认流程重复调用会先 resolve(false) 掉上一个
*/
const confirm = (p: DiffDialogPayload): Promise<boolean> => {
// Promise
confirmResolve?.(false);
confirmResolve = null;
return new Promise<boolean>((resolve) => {
confirmResolve = resolve;
open(p);
});
};
const close = () => {
visible.value = false;
};
@ -217,6 +240,9 @@ const close = () => {
watch(visible, (v) => {
if (!v) {
payload.value = null;
// / Esc / resolve(false)
confirmResolve?.(false);
confirmResolve = null;
}
});
@ -226,6 +252,7 @@ const onClose = () => {
defineExpose({
open,
confirm,
close,
});
</script>

View File

@ -28,6 +28,7 @@
@goto-initial="onPageGotoInitial"
@diff-step="onPageDiff"
@revert-step="onPageRevert"
@clear="onPageClear"
/>
</component>
@ -37,18 +38,15 @@
v-bind="tabPaneComponent?.props({ name: 'data-source', label: `数据源 (${dataSourceGroups.length})` }) || {}"
>
<BucketTab
title="数据源"
prefix="ds"
:config="dataSourceConfig"
:buckets="dataSourceGroupsByTarget"
:expanded="expanded"
:describe-group="describeDataSourceGroup"
:describe-step="describeDataSourceStep"
:is-step-diffable="isDataSourceStepDiffable"
@toggle="toggleGroup"
@goto="onDataSourceGoto"
@goto-initial="onDataSourceGotoInitial"
@diff-step="onDataSourceDiff"
@revert-step="onDataSourceRevert"
@clear="onDataSourceClear"
/>
</component>
@ -58,18 +56,15 @@
v-bind="tabPaneComponent?.props({ name: 'code-block', label: `代码块 (${codeBlockGroups.length})` }) || {}"
>
<BucketTab
title="代码块"
prefix="cb"
:config="codeBlockConfig"
:buckets="codeBlockGroupsByTarget"
:expanded="expanded"
:describe-group="describeCodeBlockGroup"
:describe-step="describeCodeBlockStep"
:is-step-diffable="isCodeBlockStepDiffable"
@toggle="toggleGroup"
@goto="onCodeBlockGoto"
@goto-initial="onCodeBlockGotoInitial"
@diff-step="onCodeBlockDiff"
@revert-step="onCodeBlockRevert"
@clear="onCodeBlockClear"
/>
</component>
@ -95,12 +90,8 @@
</template>
</TMagicPopover>
<HistoryDiffDialog
ref="diffDialog"
:extend-state="extendFormState"
:on-confirm="onConfirmRevert"
@close="onDiffDialogClose"
/>
<HistoryDiffDialog ref="diffDialog" :extend-state="extendFormState" />
<HistoryDiffDialog ref="confirmDialog" :is-confirm="true" :extend-state="extendFormState" />
</template>
<script lang="ts" setup>
@ -125,10 +116,18 @@
* 通过 title / prefix / describe* / isStepDiffable
* 共享的描述生成与折叠状态在 composables.ts 中维护
*/
import { computed, inject, markRaw, ref, shallowRef, useTemplateRef, watch } from 'vue';
import { computed, inject, markRaw, ref, useTemplateRef, watch } from 'vue';
import { Clock, Close } from '@element-plus/icons-vue';
import { getDesignConfig, TMagicButton, TMagicPopover, TMagicTabs, TMagicTooltip } from '@tmagic/design';
import {
getDesignConfig,
TMagicButton,
tMagicMessage,
tMagicMessageBox,
TMagicPopover,
TMagicTabs,
TMagicTooltip,
} from '@tmagic/design';
import type { FormState } from '@tmagic/form';
import MIcon from '@editor/components/Icon.vue';
@ -136,11 +135,14 @@ import { useServices } from '@editor/hooks/use-services';
import type { CodeBlockStepValue, DataSourceStepValue, DiffDialogPayload, HistoryListExtraTab } from '@editor/type';
import BucketTab from './BucketTab.vue';
import type { HistoryBucketConfig } from './composables';
import {
describeCodeBlockGroup,
describeCodeBlockStep,
describeDataSourceGroup,
describeDataSourceStep,
isCodeBlockStepRevertable,
isDataSourceStepRevertable,
useHistoryList,
} from './composables';
import HistoryDiffDialog from './HistoryDiffDialog.vue';
@ -205,10 +207,34 @@ const {
} = useHistoryList();
/** 数据源 step 仅 update前后 schema 都存在)时可查看差异。 */
const isDataSourceStepDiffable = (step: DataSourceStepValue) => Boolean(step.oldSchema && step.newSchema);
const isDataSourceStepDiffable = (step: DataSourceStepValue) =>
Boolean(step.diff?.[0]?.oldSchema && step.diff?.[0]?.newSchema);
/** 代码块 step 仅 update前后 content 都存在)时可查看差异。 */
const isCodeBlockStepDiffable = (step: CodeBlockStepValue) => Boolean(step.oldContent && step.newContent);
const isCodeBlockStepDiffable = (step: CodeBlockStepValue) =>
Boolean(step.diff?.[0]?.oldSchema && step.diff?.[0]?.newSchema);
/**
* 数据源 / 代码块两类 bucket 历史的整体渲染配置 title / prefix 与各自的描述
* 可差异可回滚判定收敛为单一对象整体注入 BucketTab组件内部按需读取
*/
const dataSourceConfig: HistoryBucketConfig<DataSourceStepValue> = {
title: '数据源',
prefix: 'ds',
describeGroup: describeDataSourceGroup,
describeStep: describeDataSourceStep,
isStepDiffable: isDataSourceStepDiffable,
isStepRevertable: isDataSourceStepRevertable,
};
const codeBlockConfig: HistoryBucketConfig<CodeBlockStepValue> = {
title: '代码块',
prefix: 'cb',
describeGroup: describeCodeBlockGroup,
describeStep: describeCodeBlockStep,
isStepDiffable: isCodeBlockStepDiffable,
isStepRevertable: isCodeBlockStepRevertable,
};
/** 把"目标 step 索引"翻译成"目标 cursor"(已应用步骤数量)。 */
const indexToCursor = (index: number) => index + 1;
@ -242,83 +268,90 @@ const onCodeBlockGotoInitial = (id: string | number) => {
};
const diffDialogRef = useTemplateRef<InstanceType<typeof HistoryDiffDialog>>('diffDialog');
const confirmDialogRef = useTemplateRef<InstanceType<typeof HistoryDiffDialog>>('confirmDialog');
/**
* 构造页面 step 的差异弹窗入参 update 单节点修改可对比传入旧/新节点
* 节点类型 `type` 优先取 newNode.type再回退 oldNode.type
* `currentValue` 取自 editorService 中该节点当前实际值用于支持与当前对比
* 无可对比内容如多节点 / add / remove时返回 null
* 三类历史页面 / 数据源 / 代码块差异弹窗入参的构造差异收敛为一份配置
* 分组来源当前值读取类型 / 展示名提取不同定位 step校验前后值组装 payload 的流程共用
*/
const buildPageDiffPayload = (index: number): DiffDialogPayload | null => {
const groups = historyService.getPageHistoryGroups();
for (const group of groups) {
const entry = group.steps.find((s) => s.index === index);
if (!entry) continue;
const item = entry.step.updatedItems?.[0];
if (!item?.oldNode || !item?.newNode) return null;
const type = (item.newNode.type as string) || (item.oldNode.type as string) || '';
const nodeId = item.newNode.id ?? item.oldNode.id;
const currentNode = nodeId !== undefined ? editorService.getNodeById(nodeId) : null;
interface DiffPayloadSource {
/** 表单类别:节点 / 数据源 / 代码块。 */
category: DiffDialogPayload['category'];
/** 该类别按时间正序的历史分组列表(含已撤销)。 */
groups: () => { id?: string | number; steps: { index: number; step: { diff?: any[] } }[] }[];
/** 读取目标当前实际值,用于「与当前对比」;不存在时返回空即禁用对比。 */
getCurrent: (_id: string | number) => Record<string, any> | null | undefined;
/** 由新/旧快照提取展示名(含各自的兜底,如节点回退 type、数据源 / 代码块回退 id。 */
resolveLabel: (_newSchema: Record<string, any>, _oldSchema: Record<string, any>, _id: string | number) => string;
/** 由新/旧快照提取类型;代码块无 type 字段则不传。 */
resolveType?: (_newSchema: Record<string, any>, _oldSchema: Record<string, any>) => string;
}
/**
* 构造差异弹窗入参 update前后值都存在可对比
* - 页面 id在全部分组中按 index 定位 step目标 id 取自快照
* - 数据源 / 代码块 id先匹配分组 id 再按 index 定位
* 无可对比内容多节点 / add / remove或定位不到时返回 null
*/
const buildDiffPayload = (source: DiffPayloadSource, index: number, id?: string | number): DiffDialogPayload | null => {
for (const group of source.groups()) {
if (id !== undefined && group.id !== id) continue;
const step = group.steps.find((s) => s.index === index)?.step;
if (!step) continue;
const oldSchema = step.diff?.[0]?.oldSchema as Record<string, any> | undefined;
const newSchema = step.diff?.[0]?.newSchema as Record<string, any> | undefined;
if (!oldSchema || !newSchema) return null;
const targetId = id ?? newSchema.id ?? oldSchema.id;
const type = source.resolveType?.(newSchema, oldSchema);
return {
category: 'node',
type,
lastValue: item.oldNode as Record<string, any>,
value: item.newNode as Record<string, any>,
currentValue: (currentNode as Record<string, any>) || null,
targetLabel: (item.newNode.name as string) || (item.oldNode.name as string) || type,
id: nodeId,
category: source.category,
...(type !== undefined ? { type } : {}),
lastValue: oldSchema,
value: newSchema,
currentValue: (targetId !== undefined ? source.getCurrent(targetId) : null) || null,
targetLabel: source.resolveLabel(newSchema, oldSchema, targetId),
id: targetId,
};
}
return null;
};
/**
* 在指定分组列表中按 id / index 查找命中的 step命中后交由 build 构造差异弹窗入参
* 用于统一数据源代码块两类历史的查找逻辑
*/
const findGroupStep = <G extends { id: string | number; steps: { index: number; step: any }[] }>(
groups: G[],
id: string | number,
index: number,
build: (_step: G['steps'][number]['step']) => DiffDialogPayload | null,
): DiffDialogPayload | null => {
for (const group of groups) {
if (group.id !== id) continue;
const entry = group.steps.find((s) => s.index === index);
if (!entry) continue;
return build(entry.step);
}
return null;
};
const buildPageDiffPayload = (index: number): DiffDialogPayload | null =>
buildDiffPayload(
{
category: 'node',
groups: () => historyService.getPageHistoryGroups(),
getCurrent: (id) => editorService.getNodeById(id) as Record<string, any> | null,
resolveType: (n, o) => n.type || o.type || '',
resolveLabel: (n, o) => n.name || o.name || n.type || o.type || '',
},
index,
);
const buildDataSourceDiffPayload = (id: string | number, index: number): DiffDialogPayload | null =>
findGroupStep(historyService.getDataSourceHistoryGroups(), id, index, ({ oldSchema, newSchema }) => {
if (!oldSchema || !newSchema) return null;
const currentSchema = dataSourceService.getDataSourceById(`${id}`);
return {
buildDiffPayload(
{
category: 'data-source',
type: newSchema.type || oldSchema.type || 'base',
lastValue: oldSchema as Record<string, any>,
value: newSchema as Record<string, any>,
currentValue: (currentSchema as Record<string, any>) || null,
targetLabel: newSchema.title || oldSchema.title || `${id}`,
id,
};
});
groups: () => historyService.getDataSourceHistoryGroups(),
getCurrent: (id) => dataSourceService.getDataSourceById(`${id}`) as Record<string, any> | null,
resolveType: (n, o) => n.type || o.type || 'base',
resolveLabel: (n, o, id) => n.title || o.title || `${id}`,
},
index,
id,
);
const buildCodeBlockDiffPayload = (id: string | number, index: number): DiffDialogPayload | null =>
findGroupStep(historyService.getCodeBlockHistoryGroups(), id, index, ({ oldContent, newContent }) => {
if (!oldContent || !newContent) return null;
const currentContent = codeBlockService.getCodeContentById(id);
return {
buildDiffPayload(
{
category: 'code-block',
lastValue: oldContent as Record<string, any>,
value: newContent as Record<string, any>,
currentValue: (currentContent as Record<string, any>) || null,
targetLabel: newContent.name || oldContent.name || `${id}`,
id,
};
});
groups: () => historyService.getCodeBlockHistoryGroups(),
getCurrent: (id) => codeBlockService.getCodeContentById(id) as Record<string, any> | null,
resolveLabel: (n, o, id) => n.name || o.name || `${id}`,
},
index,
id,
);
const onPageDiff = (index: number) => {
const payload = buildPageDiffPayload(index);
@ -335,46 +368,160 @@ const onCodeBlockDiff = (id: string | number, index: number) => {
if (payload) diffDialogRef.value?.open(payload);
};
const onConfirmRevert = shallowRef();
/**
* 回滚入口把目标历史步骤的修改作为一次新操作反向应用 git revert
* 回滚统一入口把目标历史步骤的修改作为一次新操作反向应用 git revert
* 不破坏原有栈结构 service 内部完成反向 + 入栈并自带描述用于面板展示
*
* 交互先弹出该步骤的差异弹窗供用户确认点击确定回滚后再真正执行回滚
* 对没有可对比内容的步骤 add / remove / 多节点更新则直接回滚
* 交互
* - 可差异对比的步骤单节点 / 单实体 update弹出差异弹窗供用户确认确定回滚再执行
* - 无法对比的步骤add / remove / 多节点更新payload null弹出普通二次确认框确认后执行
*
* 页面 / 数据源 / 代码块三类回滚仅差异入参构造实际 revert 调用不同
* 由调用方分别传入 payload revert公共的弹窗 / 确认流程在此收敛
*/
const onPageRevert = (index: number) => {
const payload = buildPageDiffPayload(index);
onConfirmRevert.value = () => editorService.revertPageStep(index);
if (payload) {
diffDialogRef.value?.open({ ...payload });
} else {
onConfirmRevert.value();
const runRevert = (payload: DiffDialogPayload | null): Promise<boolean> => {
if (payload && confirmDialogRef.value) {
return confirmDialogRef.value.confirm(payload);
}
return confirmRevert();
};
/**
* 回滚前置校验若该历史步骤回滚所依赖的目标数据已被删除则无法回滚
* - update把旧值写回被修改的目标必须仍存在
* - 页面 remove还原被删节点被删节点的原父容器必须仍存在否则无处插回
* add回滚即删除即使目标已不在也已达成删除目的不视为失败
*
* 命中时弹出回滚失败提示并返回 true调用方据此中止本次回滚
*/
const isPageRevertTargetMissing = (index: number): boolean => {
const step = historyService.getPageStepList()[index]?.step;
if (!step) return false;
if (step.opType === 'update') {
return (step.diff ?? []).some((item) => {
const id = item.newSchema?.id ?? item.oldSchema?.id;
return id !== undefined && !editorService.getNodeById(id, false);
});
}
if (step.opType === 'remove') {
return (step.diff ?? []).some(
(item) => item.parentId !== undefined && !editorService.getNodeById(item.parentId, false),
);
}
return false;
};
/** 数据源 update 步骤回滚时,对应数据源必须仍存在(已删除则无处写回旧值)。 */
const isDataSourceRevertTargetMissing = (id: string | number, index: number): boolean => {
const step = historyService.getDataSourceStepList(id)[index]?.step;
return Boolean(step && step.opType === 'update' && !dataSourceService.getDataSourceById(`${id}`));
};
/** 代码块 update 步骤回滚时,对应代码块必须仍存在(已删除则无处写回旧值)。 */
const isCodeBlockRevertTargetMissing = (id: string | number, index: number): boolean => {
const step = historyService.getCodeBlockStepList(id)[index]?.step;
return Boolean(step && step.opType === 'update' && !codeBlockService.getCodeContentById(id));
};
/** 目标数据已被删除、无法回滚时的统一提示。 */
const showRevertTargetMissing = () => {
tMagicMessage.error('回滚失败:该记录对应的数据已被删除');
};
const onPageRevert = (index: number) => {
if (isPageRevertTargetMissing(index)) {
showRevertTargetMissing();
return Promise.resolve(null);
}
return runRevert(buildPageDiffPayload(index)).then((result) => (result ? editorService.revertPageStep(index) : null));
};
const onDataSourceRevert = (id: string | number, index: number) => {
const payload = buildDataSourceDiffPayload(id, index);
onConfirmRevert.value = () => dataSourceService.revert(id, index);
if (payload) {
diffDialogRef.value?.open({ ...payload });
} else {
onConfirmRevert.value();
if (isDataSourceRevertTargetMissing(id, index)) {
showRevertTargetMissing();
return Promise.resolve(null);
}
return runRevert(buildDataSourceDiffPayload(id, index)).then((result) =>
result ? dataSourceService.revert(id, index) : null,
);
};
const onCodeBlockRevert = (id: string | number, index: number) => {
const payload = buildCodeBlockDiffPayload(id, index);
onConfirmRevert.value = () => codeBlockService.revert(id, index);
if (payload) {
diffDialogRef.value?.open({ ...payload });
} else {
onConfirmRevert.value();
if (isCodeBlockRevertTargetMissing(id, index)) {
showRevertTargetMissing();
return Promise.resolve(null);
}
return runRevert(buildCodeBlockDiffPayload(id, index)).then((result) =>
result ? codeBlockService.revert(id, index) : null,
);
};
/**
* 回滚二次确认新增 / 删除 / 多节点更新等无法做差异对比的步骤
* 不弹差异弹窗改用一个普通确认框替代确定回滚按钮避免点击后无任何提示直接执行
* 用户取消时返回 false调用方据此中止回滚
*/
const confirmRevert = (): Promise<boolean> =>
confirmDialog(
'确定回滚该步骤吗?回滚会将该操作作为一条新记录反向应用(新增将被删除、删除将被还原),不影响后续历史记录。',
);
/**
* 通用二次确认弹窗清空历史 / 无法差异对比的回滚等会改变状态的操作先弹出确认框
* 用户点击确定返回 true取消confirm reject时返回 false 并静默忽略
*/
const confirmDialog = async (message: string): Promise<boolean> => {
try {
await tMagicMessageBox.confirm(message, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
});
return true;
// eslint-disable-next-line no-unused-vars
} catch (e) {
return false;
}
};
const onDiffDialogClose = () => {
onConfirmRevert.value = undefined;
/**
* 把内存中已清空对应类别后的历史状态重新写回 IndexedDB
* 使本地持久化的那份与内存保持一致连同本地保存的一并删除
* 不支持 IndexedDB 或写入失败时静默忽略内存清空已生效
*/
const syncIndexedDB = async () => {
try {
await historyService.saveToIndexedDB();
// eslint-disable-next-line no-unused-vars
} catch (e) {
// ignore:
}
};
const onPageClear = async () => {
if (
await confirmDialog('确定清空当前页面的历史记录吗?清空后将无法撤销/重做之前的操作,本地保存的记录也会一并删除。')
) {
historyService.clearPage();
await syncIndexedDB();
}
};
const onDataSourceClear = async () => {
if (
await confirmDialog('确定清空数据源的历史记录吗?清空后将无法撤销/重做之前的操作,本地保存的记录也会一并删除。')
) {
historyService.clearDataSource();
await syncIndexedDB();
}
};
const onCodeBlockClear = async () => {
if (
await confirmDialog('确定清空代码块的历史记录吗?清空后将无法撤销/重做之前的操作,本地保存的记录也会一并删除。')
) {
historyService.clearCodeBlock();
await syncIndexedDB();
}
};
</script>

View File

@ -1,44 +1,29 @@
<template>
<div v-if="!list.length" class="m-editor-history-list-empty">暂无操作记录</div>
<TMagicScrollbar v-else max-height="360px">
<ul class="m-editor-history-list-ul">
<GroupRow
v-for="group in list"
:key="`pg-${group.steps[0]?.index}`"
:group-key="`pg-${group.steps[0]?.index}`"
:applied="group.applied"
:merged="group.steps.length > 1"
:op-type="group.opType"
:desc="describePageGroup(group)"
:time="formatHistoryTime(groupTimestamp(group))"
:time-title="formatHistoryFullTime(groupTimestamp(group))"
:step-count="group.steps.length"
:sub-steps="
group.steps.map((s) => ({
index: s.index,
applied: s.applied,
isCurrent: s.isCurrent,
desc: describePageStep(s.step),
diffable: isPageStepDiffable(s.step),
revertable: s.applied,
time: formatHistoryTime(s.step.timestamp),
timeTitle: formatHistoryFullTime(s.step.timestamp),
}))
"
:is-current="group.isCurrent"
:expanded="!!expanded[`pg-${group.steps[0]?.index}`]"
@toggle="(key: string) => $emit('toggle', key)"
@goto="(index: number) => $emit('goto', index)"
@diff-step="(index: number) => $emit('diff-step', index)"
@revert-step="(index: number) => $emit('revert-step', index)"
/>
<!--
<template v-else>
<div class="m-editor-history-list-toolbar">
<span class="m-editor-history-list-clear" title="清空当前页面的历史记录" @click="$emit('clear')">清空</span>
</div>
<TMagicScrollbar max-height="360px">
<ul class="m-editor-history-list-ul">
<GroupRow
v-for="group in list"
:key="rowKey(group)"
:group="toRow(group)"
:expanded="!!expanded[rowKey(group)]"
@toggle="(key: string) => $emit('toggle', key)"
@goto="(index: number) => $emit('goto', index)"
@diff-step="(index: number) => $emit('diff-step', index)"
@revert-step="(index: number) => $emit('revert-step', index)"
/>
<!--
初始状态项永远位于列表底部页面 tab 倒序展示最底部=最早
作为"未修改"零点当所有 group 都未 applied 时它即为当前位置
-->
<InitialRow :is-current="isInitial" @goto-initial="$emit('goto-initial')" />
</ul>
</TMagicScrollbar>
<InitialRow :is-current="isInitial" @goto-initial="$emit('goto-initial')" />
</ul>
</TMagicScrollbar>
</template>
</template>
<script lang="ts" setup>
@ -48,13 +33,8 @@ import { TMagicScrollbar } from '@tmagic/design';
import type { PageHistoryGroup, StepValue } from '@editor/type';
import {
describePageGroup,
describePageStep,
formatHistoryFullTime,
formatHistoryTime,
groupTimestamp,
} from './composables';
import type { HistoryRowDescriptor, HistoryRowGroup } from './composables';
import { describePageGroup, describePageStep, isPageStepRevertable, toRowGroup } from './composables';
import GroupRow from './GroupRow.vue';
import InitialRow from './InitialRow.vue';
@ -84,21 +64,35 @@ defineEmits<{
(_e: 'diff-step', _index: number): void;
/** 用户点击"回滚"按钮,携带目标 step 在栈中的索引,类 git revert。 */
(_e: 'revert-step', _index: number): void;
/** 用户点击"清空"按钮,请求清空当前页面的历史记录(由上层弹窗二次确认后执行)。 */
(_e: 'clear'): void;
}>();
/**
* 当前 step 是否可查看差异
* - update 操作
* - 单节点更新updatedItems.length === 1 oldNode / newNode 都存在
* - 单节点更新diff.length === 1 oldSchema / newSchema 都存在
* 多节点更新难以选定单一对比目标统一不展示差异入口
*/
const isPageStepDiffable = (step: StepValue): boolean => {
if (step.opType !== 'update') return false;
const items = step.updatedItems ?? [];
const items = step.diff ?? [];
if (items.length !== 1) return false;
return Boolean(items[0]?.oldNode && items[0]?.newNode);
return Boolean(items[0]?.oldSchema && items[0]?.newSchema);
};
/** 页面历史的描述 / 可操作性判定集合,注入给统一的 `toRowGroup`。 */
const descriptor: HistoryRowDescriptor<StepValue> = {
describeGroup: describePageGroup,
describeStep: describePageStep,
isStepDiffable: isPageStepDiffable,
isStepRevertable: isPageStepRevertable,
};
const rowKey = (group: PageHistoryGroup) => `pg-${group.steps[0]?.index}`;
const toRow = (group: PageHistoryGroup): HistoryRowGroup => toRowGroup(group, rowKey(group), descriptor);
/**
* 是否处于"初始状态"即对应页面历史栈 cursor===0
* list 中所有 group applied 都为 false 时即为该状态

View File

@ -4,15 +4,112 @@ import { datetimeFormatter } from '@tmagic/form';
import { useServices } from '@editor/hooks/use-services';
import type {
BaseStepValue,
CodeBlockHistoryGroup,
CodeBlockStepValue,
DataSourceHistoryGroup,
DataSourceStepValue,
HistoryOpSource,
HistoryOpType,
PageHistoryGroup,
StepValue,
} from '@editor/type';
/**
* bucket /
* Bucket / BucketTab step T {@link BaseStepValue}
*/
export interface HistoryBucketGroup<T extends BaseStepValue = BaseStepValue> {
/** 组内最后一步是否已应用 */
applied: boolean;
/** 是否为当前所在的分组 */
isCurrent?: boolean;
/** 该分组的操作类型 */
opType: HistoryOpType;
/** 组内所有步骤 */
steps: { index: number; applied: boolean; isCurrent?: boolean; step: T }[];
}
/**
* + / /
* describe* / isStep* props
*/
export interface HistoryRowDescriptor<T extends BaseStepValue = BaseStepValue> {
/** 组级描述文案生成器,接收一个 group返回展示文本。 */
describeGroup: (_group: any) => string;
/** 单步描述文案生成器,接收一个 step返回展示文本合并组展开后的子步列表用。 */
describeStep: (_step: T) => string;
/** 判断某个 step 是否可查看差异(前后值都存在)。不传则一律不展示差异入口。 */
isStepDiffable?: (_step: T) => boolean;
/** 判断某个 step 是否支持回滚(如更新需带 changeRecords。不传则已应用即可回滚。 */
isStepRevertable?: (_step: T) => boolean;
}
/**
* bucket / /
* Bucket / BucketTab title / prefix / describe* / isStep* / showInitial / gotoEnabled
* prop
*/
export interface HistoryBucketConfig<T extends BaseStepValue = BaseStepValue> extends HistoryRowDescriptor<T> {
/** bucket 头部标题,例如 "数据源" / "代码块"。 */
title: string;
/** 子项 key 的命名空间前缀(`ds` 数据源 / `cb` 代码块 / 业务自定义如 `mod`)。 */
prefix: string;
/** 是否展示底部「回到初始状态」入口,默认 true。无 undo cursor 语义的自定义历史可传 false。 */
showInitial?: boolean;
/** 是否支持「跳转到该记录」(goto),默认 true。 */
gotoEnabled?: boolean;
}
/** GroupRow 渲染所需的单个子步视图模型(已由 {@link toRowGroup} 预先派生,组件内部不再触碰原始 step。 */
export interface HistoryRowStep {
/** 该子步在所属栈中的稳定索引。 */
index: number;
/** 是否已应用false 表示已被 undoUI 灰态)。 */
applied: boolean;
/** 是否为当前所在步骤。 */
isCurrent?: boolean;
/** 是否为最近一次保存的记录。 */
saved?: boolean;
/** 子步描述文案。 */
desc: string;
/** 是否可查看差异。 */
diffable?: boolean;
/** 是否可回滚。 */
revertable?: boolean;
/** 操作途径。 */
source?: HistoryOpSource;
/** 时间文案。 */
time?: string;
/** 时间的完整 title 提示。 */
timeTitle?: string;
}
/**
* GroupRow {@link toRowGroup}
* GroupRow props header
*/
export interface HistoryRowGroup {
/** 分组的稳定 key作为 toggle 事件 payload 与折叠状态的索引。 */
key: string;
/** 组内最后一步是否已应用。 */
applied: boolean;
/** 是否为当前所在分组。 */
isCurrent: boolean;
/** 操作类型,用于徽标颜色与文案。 */
opType: HistoryOpType;
/** 组整体描述文案。 */
desc: string;
/** 组的操作途径(取组内最近一步)。 */
source?: HistoryOpSource;
/** 组头部时间文案(取组内最近一步)。 */
time?: string;
/** 组头部时间的完整 title 提示。 */
timeTitle?: string;
/** 子步列表时间正序其长度即合并步数length > 1 即为合并组。 */
subSteps: HistoryRowStep[];
}
/**
*
* - / /
@ -107,7 +204,77 @@ export const opLabel = (op: HistoryOpType) => {
}
};
const nameOf = (node: { name?: string; id?: string | number; type?: string }) =>
/** 内置操作途径的中文文案;自定义来源直接回显原值,未知 / 缺省返回空串UI 据此不渲染)。 */
const HISTORY_SOURCE_LABELS: Record<string, string> = {
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;
/** {@link toRowGroup} 接受的最小分组结构PageHistoryGroup 与 HistoryBucketGroup 均满足。 */
interface RowGroupInput<T extends BaseStepValue = BaseStepValue> {
applied: boolean;
isCurrent?: boolean;
opType: HistoryOpType;
steps: { index: number; applied: boolean; isCurrent?: boolean; step: T }[];
}
/**
* / bucket GroupRow {@link HistoryRowGroup}
* PageTab / Bucket sub-steps
*
*/
export const toRowGroup = <T extends BaseStepValue = BaseStepValue>(
group: RowGroupInput<T>,
key: string,
descriptor: HistoryRowDescriptor<T>,
): HistoryRowGroup => {
const { describeGroup, describeStep, isStepDiffable, isStepRevertable } = descriptor;
const timestamp = groupTimestamp(group);
return {
key,
applied: group.applied,
isCurrent: Boolean(group.isCurrent),
opType: group.opType,
desc: describeGroup(group),
source: groupSource(group),
time: formatHistoryTime(timestamp),
timeTitle: formatHistoryFullTime(timestamp),
subSteps: 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),
source: s.step.source,
time: formatHistoryTime(s.step.timestamp),
timeTitle: formatHistoryFullTime(s.step.timestamp),
})),
};
};
const nameOf = (node?: { name?: string; id?: string | number; type?: string }) =>
node?.name || node?.type || `${node?.id ?? ''}`;
/**
@ -132,25 +299,25 @@ const pickLastDescription = (descs: (string | undefined)[]): string | undefined
export const describePageStep = (step: StepValue) => {
if (step.historyDescription) return step.historyDescription;
const { opType } = step;
const items = step.diff ?? [];
if (opType === 'add') {
const count = step.nodes?.length ?? 0;
const node = step.nodes?.[0];
const count = items.length;
const node = items[0]?.newSchema;
return `新增 ${count} 个节点${count === 1 && node ? `${labelWithId(nameOf(node), node.id)}` : ''}`;
}
if (opType === 'remove') {
const count = step.removedItems?.length ?? 0;
const node = step.removedItems?.[0]?.node;
const count = items.length;
const node = items[0]?.oldSchema;
return `删除 ${count} 个节点${count === 1 && node ? `${labelWithId(nameOf(node), node.id)}` : ''}`;
}
const updated = step.updatedItems ?? [];
if (!updated.length) return '修改节点';
if (updated.length === 1) {
const { newNode, changeRecords } = updated[0];
if (!items.length) return '修改节点';
if (items.length === 1) {
const { newSchema, changeRecords } = items[0];
const propPath = changeRecords?.[0]?.propPath;
const target = labelWithId(nameOf(newNode), newNode?.id);
const target = labelWithId(nameOf(newSchema), newSchema?.id);
return `修改 ${target}${propPath ? ` · ${propPath}` : ''}`;
}
return `修改 ${updated.length} 个节点`;
return `修改 ${items.length} 个节点`;
};
/**
@ -165,7 +332,7 @@ export const describePageGroup = (group: PageHistoryGroup) => {
if (group.steps.length === 1) return describePageStep(group.steps[0].step);
const paths = new Set<string>();
group.steps.forEach((s) => {
s.step.updatedItems?.[0]?.changeRecords?.forEach((r) => r.propPath && paths.add(r.propPath));
s.step.diff?.[0]?.changeRecords?.forEach((r) => r.propPath && paths.add(r.propPath));
});
const pathList = Array.from(paths).slice(0, 3).join(', ');
const target = labelWithId(
@ -177,12 +344,11 @@ export const describePageGroup = (group: PageHistoryGroup) => {
export const describeDataSourceStep = (step: DataSourceStepValue) => {
if (step.historyDescription) return step.historyDescription;
if (step.oldSchema === null && step.newSchema)
return `创建 ${labelWithId(step.newSchema.title, step.newSchema.id ?? step.id)}`;
if (step.newSchema === null && step.oldSchema)
return `删除 ${labelWithId(step.oldSchema.title, step.oldSchema.id ?? step.id)}`;
const propPath = step.changeRecords?.[0]?.propPath;
const title = labelWithId(step.newSchema?.title || step.oldSchema?.title, step.id);
const { oldSchema: oldSchema, newSchema: newSchema, changeRecords } = step.diff?.[0] ?? {};
if (!oldSchema && newSchema) return `创建 ${labelWithId(newSchema.title, newSchema.id ?? step.id)}`;
if (!newSchema && oldSchema) return `删除 ${labelWithId(oldSchema.title, oldSchema.id ?? step.id)}`;
const propPath = changeRecords?.[0]?.propPath;
const title = labelWithId(newSchema?.title || oldSchema?.title, step.id);
return propPath ? `修改 ${title} · ${propPath}` : `修改 ${title}`;
};
@ -192,22 +358,23 @@ export const describeDataSourceGroup = (group: DataSourceHistoryGroup) => {
if (group.steps.length === 1) return describeDataSourceStep(group.steps[0].step);
const paths = new Set<string>();
group.steps.forEach((s) => {
s.step.changeRecords?.forEach((r) => r.propPath && paths.add(r.propPath));
s.step.diff?.[0]?.changeRecords?.forEach((r) => r.propPath && paths.add(r.propPath));
});
const pathList = Array.from(paths).slice(0, 3).join(', ');
const rawTitle = group.steps[group.steps.length - 1].step.newSchema?.title || group.steps[0].step.oldSchema?.title;
const rawTitle =
group.steps[group.steps.length - 1].step.diff?.[0]?.newSchema?.title ||
group.steps[0].step.diff?.[0]?.oldSchema?.title;
const target = labelWithId(rawTitle, group.id);
return pathList ? `修改 ${target} · ${pathList}${paths.size > 3 ? '…' : ''}` : `修改 ${target}`;
};
export const describeCodeBlockStep = (step: CodeBlockStepValue) => {
if (step.historyDescription) return step.historyDescription;
if (step.oldContent === null && step.newContent)
return `创建 ${labelWithId(step.newContent.name, step.newContent.id ?? step.id)}`;
if (step.newContent === null && step.oldContent)
return `删除 ${labelWithId(step.oldContent.name, step.oldContent.id ?? step.id)}`;
const propPath = step.changeRecords?.[0]?.propPath;
const title = labelWithId(step.newContent?.name || step.oldContent?.name, step.id);
const { oldSchema: oldContent, newSchema: newContent, changeRecords } = step.diff?.[0] ?? {};
if (!oldContent && newContent) return `创建 ${labelWithId(newContent.name, newContent.id ?? step.id)}`;
if (!newContent && oldContent) return `删除 ${labelWithId(oldContent.name, oldContent.id ?? step.id)}`;
const propPath = changeRecords?.[0]?.propPath;
const title = labelWithId(newContent?.name || oldContent?.name, step.id);
return propPath ? `修改 ${title} · ${propPath}` : `修改 ${title}`;
};
@ -217,10 +384,47 @@ export const describeCodeBlockGroup = (group: CodeBlockHistoryGroup) => {
if (group.steps.length === 1) return describeCodeBlockStep(group.steps[0].step);
const paths = new Set<string>();
group.steps.forEach((s) => {
s.step.changeRecords?.forEach((r) => r.propPath && paths.add(r.propPath));
s.step.diff?.[0]?.changeRecords?.forEach((r) => r.propPath && paths.add(r.propPath));
});
const pathList = Array.from(paths).slice(0, 3).join(', ');
const rawName = group.steps[group.steps.length - 1].step.newContent?.name || group.steps[0].step.oldContent?.name;
const rawName =
group.steps[group.steps.length - 1].step.diff?.[0]?.newSchema?.name ||
group.steps[0].step.diff?.[0]?.oldSchema?.name;
const target = labelWithId(rawName, group.id);
return pathList ? `修改 ${target} · ${pathList}${paths.size > 3 ? '…' : ''}` : `修改 ${target}`;
};
/**
* step git revert
* - / changeRecords /
* - changeRecords propPath patch
* changeRecords
*/
export const isPageStepRevertable = (step: StepValue): boolean => {
if (step.opType !== 'update') return true;
const items = step.diff ?? [];
if (!items.length) return false;
return items.every((item) => Boolean(item.changeRecords?.length));
};
/**
* step
* - oldSchema/ newSchema changeRecords
* - schema changeRecords patch
*/
export const isDataSourceStepRevertable = (step: DataSourceStepValue): boolean => {
const item = step.diff?.[0];
if (!item?.oldSchema || !item?.newSchema) return true;
return Boolean(item.changeRecords?.length);
};
/**
* step
* - oldSchema/ newSchema changeRecords
* - content changeRecords patch
*/
export const isCodeBlockStepRevertable = (step: CodeBlockStepValue): boolean => {
const item = step.diff?.[0];
if (!item?.oldSchema || !item?.newSchema) return true;
return Boolean(item.changeRecords?.length);
};

View File

@ -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);
}

View File

@ -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) => {

View File

@ -24,7 +24,7 @@
<Icon :icon="editable ? Edit : View" class="edit-icon" @click.stop="editCode(`${data.key}`)"></Icon>
</TMagicTooltip>
<TMagicTooltip v-if="data.type === 'code' && editable" effect="dark" content="删除" placement="bottom">
<Icon :icon="Close" class="edit-icon" @click.stop="deleteCode(`${data.key}`)"></Icon>
<Icon :icon="Close" class="edit-icon" @click.stop="deleteCode(`${data.key}`, { historySource: 'tree' })"></Icon>
</TMagicTooltip>
<slot name="code-block-panel-tool" :id="data.key" :data="data"></slot>
</template>
@ -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<CodeBlockListSlots>();
@ -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);

View File

@ -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'));
</script>

View File

@ -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' });
},
},
{

View File

@ -129,7 +129,7 @@ const removeHandler = async (id: string) => {
type: 'warning',
});
dataSourceService.remove(id);
dataSourceService.remove(id, { historySource: 'tree-contextmenu' });
};
const dataSourceListRef = useTemplateRef<InstanceType<typeof DataSourceList>>('dataSourceList');

View File

@ -39,7 +39,7 @@ export const useContentMenu = () => {
return;
}
dataSourceService.add(cloneDeep(ds));
dataSourceService.add(cloneDeep(ds), { historySource: 'tree-contextmenu' });
},
},
{

View File

@ -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<MenuButton[]>(() => {
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',

View File

@ -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' },
);
};
</script>

View File

@ -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' });
}
};
</script>

View File

@ -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',

View File

@ -38,6 +38,7 @@ import type {
import { CODE_DRAFT_STORAGE_KEY } from '@editor/type';
import { getEditorConfig } from '@editor/utils/config';
import { COPY_CODE_STORAGE_KEY } from '@editor/utils/editor';
import { describeRevertStep } from '@editor/utils/history';
import BaseService from './BaseService';
@ -48,18 +49,6 @@ const canUsePluginMethods = {
type AsyncMethodName = Writable<(typeof canUsePluginMethods)['async']>;
/**
* step service 使
*/
const describeRevertCodeBlockStep = (step: CodeBlockStepValue): string => {
const { oldContent, newContent, changeRecords, id } = step;
if (oldContent === null && newContent) return `撤回新增 ${newContent.name || newContent.id || id}`;
if (oldContent && newContent === null) return `还原已删除的 ${oldContent.name || oldContent.id || id}`;
const name = newContent?.name || oldContent?.name || `${id}`;
const propPath = changeRecords?.[0]?.propPath;
return propPath ? `还原 ${name} · ${propPath}` : `还原 ${name}`;
};
class CodeBlock extends BaseService {
private state = reactive<CodeState>({
codeDsl: null,
@ -69,6 +58,17 @@ class CodeBlock extends BaseService {
paramsColConfig: undefined,
});
/**
* uuid /
* setCodeDslById(Sync)AndGetHistoryId
*/
private lastPushedHistoryId: string | null = null;
/**
* deleteCodeDslByIds uuid
* deleteCodeDslByIds deleteCodeDslByIdsAndGetHistoryId
*/
private lastDeletedHistoryIds: string[] = [];
constructor() {
super([
...canUsePluginMethods.async.map((methodName) => ({ name: methodName, isAsync: true })),
@ -120,9 +120,19 @@ class CodeBlock extends BaseService {
public async setCodeDslById(
id: Id,
codeConfig: Partial<CodeBlockContent>,
{ changeRecords, doNotPushHistory = false }: HistoryOpOptionsWithChangeRecords = {},
{
changeRecords,
doNotPushHistory = false,
historyDescription,
historySource,
}: HistoryOpOptionsWithChangeRecords = {},
): Promise<void> {
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<CodeBlockContent>,
force = true,
{ changeRecords, doNotPushHistory = false, historyDescription }: HistoryOpOptionsWithChangeRecords = {},
{
changeRecords,
doNotPushHistory = false,
historyDescription,
historySource,
}: HistoryOpOptionsWithChangeRecords = {},
): void {
const codeDsl = this.getCodeDsl();
@ -172,7 +187,14 @@ class CodeBlock extends BaseService {
const newContent = cloneDeep(codeDsl[id]);
if (!doNotPushHistory) {
historyService.pushCodeBlock(id, { oldContent, newContent, changeRecords, historyDescription });
this.lastPushedHistoryId =
historyService.pushCodeBlock(id, {
oldContent,
newContent,
changeRecords,
historyDescription,
source: historySource,
})?.uuid ?? null;
}
this.emit('addOrUpdate', id, codeDsl[id]);
@ -268,12 +290,14 @@ class CodeBlock extends BaseService {
*/
public async deleteCodeDslByIds(
codeIds: Id[],
{ doNotPushHistory = false, historyDescription }: HistoryOpOptions = {},
{ doNotPushHistory = false, historyDescription, historySource }: HistoryOpOptions = {},
): Promise<void> {
const currentDsl = await this.getCodeDsl();
if (!currentDsl) return;
this.lastDeletedHistoryIds = [];
codeIds.forEach((id) => {
// 历史记录:删除前快照内容;不存在的 id 直接跳过历史推入
const oldContent: CodeBlockContent | null = currentDsl[id] ? cloneDeep(currentDsl[id]) : null;
@ -281,13 +305,62 @@ class CodeBlock extends BaseService {
delete currentDsl[id];
if (oldContent && !doNotPushHistory) {
historyService.pushCodeBlock(id, { oldContent, newContent: null, historyDescription });
const uuid = historyService.pushCodeBlock(id, {
oldContent,
newContent: null,
historyDescription,
source: historySource,
})?.uuid;
if (uuid) this.lastDeletedHistoryIds.push(uuid);
}
this.emit('remove', id);
});
}
// #region AndGetHistoryId
/**
* *AndGetHistoryId
* uuid{@link CodeBlockStepValue.uuid}
* / revert
*
* doNotPushHistory true null
*/
/** 等价于 {@link setCodeDslById},但返回本次写入历史记录的 uuid未入栈时返回 null。 */
public async setCodeDslByIdAndGetHistoryId(
id: Id,
codeConfig: Partial<CodeBlockContent>,
options: HistoryOpOptionsWithChangeRecords = {},
): Promise<string | null> {
this.lastPushedHistoryId = null;
await this.setCodeDslById(id, codeConfig, options);
return this.lastPushedHistoryId;
}
/** 等价于 {@link setCodeDslByIdSync},但返回本次写入历史记录的 uuid未入栈时返回 null。 */
public setCodeDslByIdSyncAndGetHistoryId(
id: Id,
codeConfig: Partial<CodeBlockContent>,
force = true,
options: HistoryOpOptionsWithChangeRecords = {},
): string | null {
this.lastPushedHistoryId = null;
this.setCodeDslByIdSync(id, codeConfig, force, options);
return this.lastPushedHistoryId;
}
/**
* {@link deleteCodeDslByIds} uuid
*
*/
public async deleteCodeDslByIdsAndGetHistoryId(codeIds: Id[], options: HistoryOpOptions = {}): Promise<string[]> {
this.lastDeletedHistoryIds = [];
await this.deleteCodeDslByIds(codeIds, options);
return [...this.lastDeletedHistoryIds];
}
// #endregion AndGetHistoryId
public setParamsColConfig(config: TableColumnConfig): void {
this.state.paramsColConfig = config;
}
@ -373,10 +446,28 @@ class CodeBlock extends BaseService {
const list = historyService.getCodeBlockStepList(id);
const entry = list[index];
if (!entry?.applied) return null;
const description = `回滚 #${index + 1}: ${describeRevertCodeBlockStep(entry.step)}`;
// 更新类步骤(前后 content 都存在)必须带 changeRecords 才支持回滚,否则只能整内容替换,会冲掉后续无关变更。
const { oldSchema, newSchema, changeRecords } = entry.step.diff?.[0] ?? {};
if (oldSchema && newSchema && !changeRecords?.length) return null;
const description = `回滚 #${index + 1}: ${describeRevertStep<CodeBlockContent>(entry.step.id, entry.step.diff?.[0], (s) => s.name)}`;
return await this.applyRevertStep(entry.step, description);
}
/**
* uuid {@link revert}
* codeBlockId index uuid{@link CodeBlockStepValue.uuid}
*
*
* @param uuid uuid {@link setCodeDslByIdAndGetHistoryId}
* @returns step uuid / null
*/
public async revertById(uuid: string): Promise<CodeBlockStepValue | null> {
const location = historyService.findCodeBlockStepLocationByUuid(uuid);
if (!location) return null;
return await this.revert(location.id, location.index);
}
/**
* id
* @returns {Id} id
@ -467,21 +558,22 @@ class CodeBlock extends BaseService {
step: CodeBlockStepValue,
historyDescription: string,
): Promise<CodeBlockStepValue | null> {
const { id, oldContent, newContent, changeRecords } = step;
const { id } = step;
const { oldSchema, newSchema, changeRecords } = step.diff?.[0] ?? {};
// 原本是新增 → revert 即删除
if (oldContent === null && newContent) {
await this.deleteCodeDslByIds([id], { historyDescription });
if (!oldSchema && newSchema) {
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 });
if (oldSchema && !newSchema) {
this.setCodeDslByIdSync(id, cloneDeep(oldSchema), true, { historyDescription, historySource: 'rollback' });
return historyService.getCodeBlockStepList(id).slice(-1)[0]?.step ?? null;
}
if (!oldContent || !newContent) return null;
if (!oldSchema || !newSchema) return null;
// 原本是更新 → 把 oldContent 写回;优先按 changeRecords 局部 patch
if (changeRecords?.length) {
@ -494,17 +586,18 @@ class CodeBlock extends BaseService {
fallbackToFullReplace = true;
break;
}
const value = cloneDeep(getValueByKeyPath(record.propPath, oldContent));
const value = cloneDeep(getValueByKeyPath(record.propPath, oldSchema));
setValueByKeyPath(record.propPath, value, patched);
}
this.setCodeDslByIdSync(id, fallbackToFullReplace ? cloneDeep(oldContent) : patched, true, {
this.setCodeDslByIdSync(id, fallbackToFullReplace ? cloneDeep(oldSchema) : 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(oldSchema), true, { historyDescription, historySource: 'rollback' });
return historyService.getCodeBlockStepList(id).slice(-1)[0]?.step ?? null;
}
@ -523,31 +616,32 @@ class CodeBlock extends BaseService {
* @param reverse true=false=
*/
private async applyHistoryStep(step: CodeBlockStepValue, reverse: boolean): Promise<void> {
const { id, oldContent, newContent, changeRecords } = step;
const { id } = step;
const { oldSchema, newSchema, changeRecords } = step.diff?.[0] ?? {};
// 新增 / 删除:直接 set 或 delete不走 patch 逻辑
if (oldContent === null && newContent) {
if (!oldSchema && newSchema) {
if (reverse) {
await this.deleteCodeDslByIds([id], { doNotPushHistory: true });
} else {
this.setCodeDslByIdSync(id, cloneDeep(newContent), true, { doNotPushHistory: true });
this.setCodeDslByIdSync(id, cloneDeep(newSchema), true, { doNotPushHistory: true });
}
return;
}
if (oldContent && newContent === null) {
if (oldSchema && !newSchema) {
if (reverse) {
this.setCodeDslByIdSync(id, cloneDeep(oldContent), true, { doNotPushHistory: true });
this.setCodeDslByIdSync(id, cloneDeep(oldSchema), true, { doNotPushHistory: true });
} else {
await this.deleteCodeDslByIds([id], { doNotPushHistory: true });
}
return;
}
if (!oldContent || !newContent) return;
if (!oldSchema || !newSchema) return;
// 更新场景:优先按 changeRecords 局部 patch缺省退化为整内容替换
const sourceForValues = reverse ? oldContent : newContent;
const sourceForValues = reverse ? oldSchema : newSchema;
if (changeRecords?.length) {
const current = this.getCodeContentById(id);

View File

@ -19,6 +19,7 @@ import type {
} from '@editor/type';
import { getFormConfig, getFormValue } from '@editor/utils/data-source';
import { COPY_DS_STORAGE_KEY } from '@editor/utils/editor';
import { describeRevertStep } from '@editor/utils/history';
import BaseService from './BaseService';
@ -54,19 +55,6 @@ const canUsePluginMethods = {
type SyncMethodName = Writable<(typeof canUsePluginMethods)['sync']>;
/**
* step
* service 使 UI composables
*/
const describeRevertDataSourceStep = (step: DataSourceStepValue): string => {
const { oldSchema, newSchema, changeRecords, id } = step;
if (oldSchema === null && newSchema) return `撤回新增 ${newSchema.title || newSchema.id || id}`;
if (oldSchema && newSchema === null) return `还原已删除的 ${oldSchema.title || oldSchema.id || id}`;
const title = newSchema?.title || oldSchema?.title || `${id}`;
const propPath = changeRecords?.[0]?.propPath;
return propPath ? `还原 ${title} · ${propPath}` : `还原 ${title}`;
};
class DataSource extends BaseService {
private state = reactive<State>({
datasourceTypeList: [],
@ -78,6 +66,13 @@ class DataSource extends BaseService {
methods: {},
});
/**
* uuid
* *AndGetHistoryId add / update / remove id
* *AndGetHistoryId null
*/
private lastPushedHistoryId: string | null = null;
constructor() {
super(canUsePluginMethods.sync.map((methodName) => ({ name: methodName, isAsync: false })));
}
@ -129,7 +124,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 +136,13 @@ class DataSource extends BaseService {
this.get('dataSources').push(newConfig);
if (!doNotPushHistory) {
historyService.pushDataSource(newConfig.id, { oldSchema: null, newSchema: newConfig, historyDescription });
this.lastPushedHistoryId =
historyService.pushDataSource(newConfig.id, {
oldSchema: null,
newSchema: newConfig,
historyDescription,
source: historySource,
})?.uuid ?? null;
}
this.emit('add', newConfig);
@ -156,7 +160,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');
@ -168,12 +177,14 @@ class DataSource extends BaseService {
dataSources[index] = newConfig;
if (!doNotPushHistory) {
historyService.pushDataSource(newConfig.id, {
oldSchema: oldConfig ? cloneDeep(oldConfig) : null,
newSchema: newConfig,
changeRecords,
historyDescription,
});
this.lastPushedHistoryId =
historyService.pushDataSource(newConfig.id, {
oldSchema: oldConfig ? cloneDeep(oldConfig) : null,
newSchema: newConfig,
changeRecords,
historyDescription,
source: historySource,
})?.uuid ?? null;
}
this.emit('update', newConfig, {
@ -191,19 +202,59 @@ 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 });
this.lastPushedHistoryId =
historyService.pushDataSource(id, {
oldSchema: cloneDeep(oldConfig),
newSchema: null,
historyDescription,
source: historySource,
})?.uuid ?? null;
}
this.emit('remove', id);
}
// #region AndGetHistoryId
/**
* *AndGetHistoryId add / update / remove
* uuid{@link DataSourceStepValue.uuid}
* / revert
*
* doNotPushHistory true null
*/
/** 等价于 {@link add},但返回本次写入历史记录的 uuid未入栈时返回 null。 */
public addAndGetHistoryId(config: DataSourceSchema, options: HistoryOpOptions = {}): string | null {
this.lastPushedHistoryId = null;
this.add(config, options);
return this.lastPushedHistoryId;
}
/** 等价于 {@link update},但返回本次写入历史记录的 uuid未入栈时返回 null。 */
public updateAndGetHistoryId(
config: DataSourceSchema,
options: HistoryOpOptionsWithChangeRecords = {},
): string | null {
this.lastPushedHistoryId = null;
this.update(config, options);
return this.lastPushedHistoryId;
}
/** 等价于 {@link remove},但返回本次写入历史记录的 uuid未入栈时返回 null。 */
public removeAndGetHistoryId(id: string, options: HistoryOpOptions = {}): string | null {
this.lastPushedHistoryId = null;
this.remove(id, options);
return this.lastPushedHistoryId;
}
// #endregion AndGetHistoryId
/**
*
*
@ -278,10 +329,27 @@ class DataSource extends BaseService {
const list = historyService.getDataSourceStepList(id);
const entry = list[index];
if (!entry?.applied) return null;
const description = `回滚 #${index + 1}: ${describeRevertDataSourceStep(entry.step)}`;
// 更新类步骤(前后 schema 都存在)必须带 changeRecords 才支持回滚,否则只能整 schema 替换,会冲掉后续无关变更。
const { oldSchema, newSchema, changeRecords } = entry.step.diff?.[0] ?? {};
if (oldSchema && newSchema && !changeRecords?.length) return null;
const description = `回滚 #${index + 1}: ${describeRevertStep<DataSourceSchema>(entry.step.id, entry.step.diff?.[0], (s) => s.title)}`;
return this.applyRevertStep(entry.step, description);
}
/**
* uuid {@link revert}
* dataSourceId index uuid{@link DataSourceStepValue.uuid}
*
*
* @param uuid uuid {@link addAndGetHistoryId}
* @returns step uuid / null
*/
public revertById(uuid: string): DataSourceStepValue | null {
const location = historyService.findDataSourceStepLocationByUuid(uuid);
if (!location) return null;
return this.revert(location.id, location.index);
}
public createId(): string {
return `ds_${guid()}`;
}
@ -362,17 +430,18 @@ class DataSource extends BaseService {
* add / update / remove doNotPushHistory
*/
private applyRevertStep(step: DataSourceStepValue, historyDescription: string): DataSourceStepValue | null {
const { id, oldSchema, newSchema, changeRecords } = step;
const { id } = step;
const { oldSchema, newSchema, changeRecords } = step.diff?.[0] ?? {};
// 原本是新增 → revert 即删除
if (oldSchema === null && newSchema) {
this.remove(`${id}`, { historyDescription });
if (!oldSchema && newSchema) {
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 });
if (oldSchema && !newSchema) {
this.add(cloneDeep(oldSchema), { historyDescription, historySource: 'rollback' });
return historyService.getDataSourceStepList(id).slice(-1)[0]?.step ?? null;
}
@ -395,11 +464,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;
}
@ -418,10 +488,11 @@ class DataSource extends BaseService {
* @param reverse true=false=
*/
private applyHistoryStep(step: DataSourceStepValue, reverse: boolean): void {
const { id, oldSchema, newSchema, changeRecords } = step;
const { id } = step;
const { oldSchema, newSchema, changeRecords } = step.diff?.[0] ?? {};
// 新增 / 删除:直接 add 或 remove不走 patch 逻辑
if (oldSchema === null && newSchema) {
if (!oldSchema && newSchema) {
if (reverse) {
this.remove(`${id}`, { doNotPushHistory: true });
} else {
@ -430,7 +501,7 @@ class DataSource extends BaseService {
return;
}
if (oldSchema && newSchema === null) {
if (oldSchema && !newSchema) {
if (reverse) {
this.add(cloneDeep(oldSchema), { doNotPushHistory: true });
} else {

View File

@ -23,7 +23,15 @@ import type { Id, MApp, MContainer, MNode, MPage, MPageFragment, TargetOptions }
import { NodeType } from '@tmagic/core';
import type { ChangeRecord } from '@tmagic/form';
import { isFixed } from '@tmagic/stage';
import { getNodeInfo, getNodePath, getValueByKeyPath, isPage, isPageFragment, setValueByKeyPath } from '@tmagic/utils';
import {
getNodeInfo,
getNodePath,
getValueByKeyPath,
guid,
isPage,
isPageFragment,
setValueByKeyPath,
} from '@tmagic/utils';
import BaseService from '@editor/services//BaseService';
import propsService from '@editor/services//props';
@ -36,8 +44,10 @@ import type {
DslOpOptions,
EditorEvents,
EditorNodeInfo,
HistoryOpSource,
HistoryOpType,
PastePosition,
StepDiffItem,
StepValue,
StoreState,
StoreStateKey,
@ -71,25 +81,33 @@ type MoveItem = { node: MNode; parent: MContainer; pageForOp: { name: string; id
* UI `describePageStep` service layouts/
*/
const describeStepForRevert = (step: StepValue): string => {
const items = step.diff ?? [];
// 在可读名后拼接组件 id便于在历史面板中精确定位被回滚的组件id 缺失时退化为仅展示名称。
const withId = (node: MNode | undefined, label: string): string => {
const id = node?.id;
if (id === undefined || id === null || `${id}` === '') return label;
return label ? `${label}id: ${id}` : `id: ${id}`;
};
switch (step.opType) {
case 'add': {
const count = step.nodes?.length ?? 0;
const node = step.nodes?.[0];
const label = node?.name || node?.type || (node?.id !== undefined ? `${node.id}` : '');
return `撤回新增 ${count} 个节点${count === 1 && label ? `${label}` : ''}`;
const count = items.length;
const node = items[0]?.newSchema;
const label = node?.name || node?.type || '';
return `撤回新增 ${count} 个节点${count === 1 ? `${withId(node, label)}` : ''}`;
}
case 'remove': {
const count = step.removedItems?.length ?? 0;
const node = step.removedItems?.[0]?.node;
const label = node?.name || node?.type || (node?.id !== undefined ? `${node.id}` : '');
return `还原已删除的 ${count} 个节点${count === 1 && label ? `${label}` : ''}`;
const count = items.length;
const node = items[0]?.oldSchema;
const label = node?.name || node?.type || '';
return `还原已删除的 ${count} 个节点${count === 1 ? `${withId(node, label)}` : ''}`;
}
case 'update':
default: {
const items = step.updatedItems ?? [];
if (items.length === 1) {
const { newNode, oldNode, changeRecords } = items[0];
const target = newNode?.name || newNode?.type || oldNode?.name || oldNode?.type || `${newNode?.id ?? ''}`;
const { newSchema, oldSchema, changeRecords } = items[0];
const node = newSchema || oldSchema;
const label = newSchema?.name || newSchema?.type || oldSchema?.name || oldSchema?.type || '';
const target = withId(node, label);
const propPath = changeRecords?.[0]?.propPath;
return propPath ? `还原 ${target} · ${propPath}` : `还原 ${target}`;
}
@ -98,6 +116,20 @@ const describeStepForRevert = (step: StepValue): string => {
}
};
/**
* update {@link StepDiffItem} {@link StepValue.diff} 使
* `changeRecords` form propPath/value / propPath
* / 退
*/
const buildUpdateDiff = (
items: { oldNode: MNode; newNode: MNode; changeRecords?: ChangeRecord[] }[],
): StepDiffItem<MNode>[] =>
items.map(({ oldNode, newNode, changeRecords }) => ({
oldSchema: oldNode,
newSchema: newNode,
...(changeRecords?.length ? { changeRecords } : {}),
}));
class Editor extends BaseService {
public state: StoreState = reactive({
root: null,
@ -115,6 +147,12 @@ class Editor extends BaseService {
alwaysMultiSelect: false,
});
private selectionBeforeOp: Id[] | null = null;
/**
* pushOpHistory uuid
* *AndGetHistoryId id
* *AndGetHistoryId null
*/
private lastPushedHistoryId: string | null = null;
constructor() {
super(
@ -179,7 +217,30 @@ class Editor extends BaseService {
root = toRaw(root);
}
return getNodeInfo(id, root);
if (!root) {
return { node: null, parent: null, page: null };
}
if (id === root.id) {
return { node: root, parent: null, page: null };
}
// 大多数查找的目标都在当前页面内,优先在当前页面子树中查找以避免对整棵树做全量遍历。
// 注意:不能直接使用 state.page它可能与当前 root 不同步(指向已脱离的旧页面对象),
// 因此仅借用其 id再从当前 root 中取回真正的页面对象(页面均为 root 的直接子节点,数量很少)。
const pageIdStr = `${this.get('page')?.id || ''}`;
const currentPageNode = root.items?.find((item) => `${item.id}` === pageIdStr);
if (currentPageNode && `${id}` !== pageIdStr) {
// util 仅读取 root.id 与 root.items按容器结构传入当前页面是安全的
const info = getNodeInfo(id, currentPageNode);
if (info.node) {
return info;
}
}
// 回退:在完整 root 上查找;当前页面已搜索过,用 skip 跳过其子树避免重复遍历,
// 同时保留真实的 parent / page 引用id 命中当前页面节点本身时会在跳过子树前先匹配到)
return getNodeInfo(id, root, currentPageNode);
}
/**
@ -406,7 +467,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<MNode | MNode[]> {
this.captureSelectionBeforeOp();
@ -466,21 +533,21 @@ 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',
{
nodes: newNodes.map((n) => cloneDeep(toRaw(n))),
parentId: (this.getParentById(newNodes[0].id, false) ?? this.get('root'))!.id,
indexMap: Object.fromEntries(
newNodes.map((n) => {
const p = this.getParentById(n.id, false) as MContainer;
return [n.id, p ? getNodeIndex(n.id, p) : -1];
}),
),
},
{ name: pageForOp?.name || '', id: pageForOp!.id },
const parentId = (this.getParentById(newNodes[0].id, false) ?? this.get('root'))!.id;
this.pushOpHistory('add', {
diff: newNodes.map((n) => {
const p = this.getParentById(n.id, false) as MContainer;
const idx = p ? getNodeIndex(n.id, p) : -1;
return {
newSchema: cloneDeep(toRaw(n)),
parentId,
index: typeof idx === 'number' ? idx : -1,
};
}),
pageData: { name: pageForOp?.name || '', id: pageForOp!.id },
historyDescription,
);
source: historySource,
});
} else {
this.selectionBeforeOp = null;
}
@ -577,13 +644,19 @@ 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<void> {
this.captureSelectionBeforeOp();
const nodes = Array.isArray(nodeOrNodeList) ? nodeOrNodeList : [nodeOrNodeList];
const removedItems: { node: MNode; parentId: Id; index: number }[] = [];
const removedItems: StepDiffItem<MNode>[] = [];
let pageForOp: { name: string; id: Id } | null = null;
if (!(isPage(nodes[0]) || isPageFragment(nodes[0]))) {
for (const n of nodes) {
@ -594,7 +667,7 @@ class Editor extends BaseService {
}
const idx = getNodeIndex(curNode.id, parent);
removedItems.push({
node: cloneDeep(toRaw(curNode)),
oldSchema: cloneDeep(toRaw(curNode)),
parentId: parent.id,
index: typeof idx === 'number' ? idx : -1,
});
@ -606,7 +679,12 @@ class Editor extends BaseService {
if (removedItems.length > 0 && pageForOp) {
if (!doNotPushHistory) {
this.pushOpHistory('remove', { removedItems }, pageForOp, historyDescription);
this.pushOpHistory('remove', {
diff: removedItems,
pageData: pageForOp,
historyDescription,
source: historySource,
});
} else {
this.selectionBeforeOp = null;
}
@ -704,11 +782,12 @@ class Editor extends BaseService {
changeRecordList?: ChangeRecord[][];
doNotPushHistory?: boolean;
historyDescription?: string;
historySource?: HistoryOpSource;
} = {},
): Promise<MNode | MNode[]> {
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,20 +805,20 @@ class Editor extends BaseService {
if (curNodes.length) {
if (!doNotPushHistory) {
const pageForOp = this.getNodeInfo(nodes[0].id, false).page;
this.pushOpHistory(
'update',
{
updatedItems: updateData.map((d) => ({
this.pushOpHistory('update', {
// 每个节点单独保留自己的 changeRecords便于撤销/重做时按 propPath 精细化更新;
// 没有 changeRecords 的(如内部 sort/moveLayer 等整节点替换操作)会退化为全节点替换。
diff: buildUpdateDiff(
updateData.map((d) => ({
oldNode: cloneDeep(d.oldNode),
newNode: cloneDeep(toRaw(d.newNode)),
// 每个节点单独保留自己的 changeRecords便于撤销/重做时按 propPath 精细化更新;
// 没有 changeRecords 的(如内部 sort/moveLayer 等整节点替换操作)会退化为全节点替换。
newNode: cloneDeep(d.newNode),
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 +842,7 @@ class Editor extends BaseService {
public async sort(
id1: Id,
id2: Id,
{ doNotSelect = false, doNotPushHistory = false }: DslOpOptions = {},
{ doNotSelect = false, doNotPushHistory = false, historySource }: DslOpOptions = {},
): Promise<void> {
this.captureSelectionBeforeOp();
@ -783,7 +862,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 +915,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<MNode | MNode[] | void> {
const config: MNode[] = storageService.getItem(COPY_STORAGE_KEY);
if (!Array.isArray(config)) return;
@ -857,7 +942,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<MNode[]> {
@ -893,14 +984,14 @@ class Editor extends BaseService {
*/
public async alignCenter(
config: MNode | MNode[],
{ doNotSelect = false, doNotPushHistory = false }: DslOpOptions = {},
{ doNotSelect = false, doNotPushHistory = false, historyDescription, historySource }: DslOpOptions = {},
): Promise<MNode | MNode[]> {
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 +1010,10 @@ class Editor extends BaseService {
* @param options
* @param options.doNotPushHistory false
*/
public async moveLayer(offset: number | LayerOffset, { doNotPushHistory = false }: DslOpOptions = {}): Promise<void> {
public async moveLayer(
offset: number | LayerOffset,
{ doNotPushHistory = false, historyDescription, historySource }: DslOpOptions = {},
): Promise<void> {
this.captureSelectionBeforeOp();
const root = this.get('root');
@ -960,10 +1054,13 @@ class Editor extends BaseService {
const pageForOp = this.getNodeInfo(node.id, false).page;
this.pushOpHistory(
'update',
{
updatedItems: [{ oldNode: oldParent, newNode: cloneDeep(toRaw(parent)) }],
diff: buildUpdateDiff([{ 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 +1086,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<MNode | MNode[]> {
const isBatch = Array.isArray(config);
const configs = (isBatch ? config : [config]).filter((item) => !(isPage(item) || isPageFragment(item)));
@ -1052,7 +1155,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', {
diff: buildUpdateDiff(updatedItems),
pageData: historyPage,
historyDescription,
source: historySource,
});
} else {
this.selectionBeforeOp = null;
}
@ -1064,7 +1172,7 @@ class Editor extends BaseService {
config: MNode | MNode[],
targetParent: MContainer,
targetIndex: number,
{ doNotPushHistory = false }: DslOpOptions = {},
{ doNotPushHistory = false, historyDescription, historySource }: DslOpOptions = {},
) {
this.captureSelectionBeforeOp();
@ -1127,7 +1235,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', {
diff: buildUpdateDiff(updatedItems),
pageData: { name: pageForOp?.name || '', id: pageForOp!.id },
historyDescription,
source: historySource,
});
} else {
this.selectionBeforeOp = null;
}
@ -1135,6 +1248,86 @@ class Editor extends BaseService {
this.emit('drag-to', { targetIndex, configs, targetParent });
}
// #region AndGetHistoryId
/**
* *AndGetHistoryId add / remove / update ...
* uuid{@link StepValue.uuid}
* / / revert
*
* doNotPushHistory true / null
*/
/** 等价于 {@link add},但返回本次写入历史记录的 uuid未入栈时返回 null。 */
public async addAndGetHistoryId(
addNode: AddMNode | MNode[],
parent?: MContainer | null,
options: DslOpOptions = {},
): Promise<string | null> {
this.lastPushedHistoryId = null;
await this.add(addNode, parent, options);
return this.lastPushedHistoryId;
}
/** 等价于 {@link remove},但返回本次写入历史记录的 uuid未入栈时返回 null。 */
public async removeAndGetHistoryId(
nodeOrNodeList: MNode | MNode[],
options: DslOpOptions = {},
): Promise<string | null> {
this.lastPushedHistoryId = null;
await this.remove(nodeOrNodeList, options);
return this.lastPushedHistoryId;
}
/** 等价于 {@link update},但返回本次写入历史记录的 uuid未入栈时返回 null。 */
public async updateAndGetHistoryId(
config: MNode | MNode[],
data: {
changeRecords?: ChangeRecord[];
changeRecordList?: ChangeRecord[][];
doNotPushHistory?: boolean;
historyDescription?: string;
historySource?: HistoryOpSource;
} = {},
): Promise<string | null> {
this.lastPushedHistoryId = null;
await this.update(config, data);
return this.lastPushedHistoryId;
}
/** 等价于 {@link moveLayer},但返回本次写入历史记录的 uuid未入栈时返回 null。 */
public async moveLayerAndGetHistoryId(
offset: number | LayerOffset,
options: DslOpOptions = {},
): Promise<string | null> {
this.lastPushedHistoryId = null;
await this.moveLayer(offset, options);
return this.lastPushedHistoryId;
}
/** 等价于 {@link moveToContainer},但返回本次写入历史记录的 uuid未入栈时返回 null。 */
public async moveToContainerAndGetHistoryId(
config: MNode | MNode[],
targetId: Id,
options: DslOpOptions = {},
): Promise<string | null> {
this.lastPushedHistoryId = null;
await this.moveToContainer(config, targetId, options);
return this.lastPushedHistoryId;
}
/** 等价于 {@link dragTo},但返回本次写入历史记录的 uuid未入栈时返回 null。 */
public async dragToAndGetHistoryId(
config: MNode | MNode[],
targetParent: MContainer,
targetIndex: number,
options: DslOpOptions = {},
): Promise<string | null> {
this.lastPushedHistoryId = null;
await this.dragTo(config, targetParent, targetIndex, options);
return this.lastPushedHistoryId;
}
// #endregion AndGetHistoryId
/**
*
* @returns
@ -1184,6 +1377,12 @@ class Editor extends BaseService {
const root = this.get('root');
if (!root) return null;
// 更新类步骤必须带 changeRecords 才支持回滚:缺失时只能整节点替换,会冲掉后续无关变更,故不支持。
if (step.opType === 'update') {
const items = step.diff ?? [];
if (!items.length || !items.every((item) => item.changeRecords?.length)) return null;
}
// 反向应用产生的新 step 由内部 pushOpHistory 触发 history `change` 事件,监听一次以拿到引用。
let revertedStep: StepValue | null = null;
const captureRevert = (s: StepValue) => {
@ -1193,15 +1392,15 @@ 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) {
case 'add': {
// 原本是新增 → revert 即删除当时被加入的节点
const nodes = step.nodes ?? [];
for (const n of nodes) {
const existing = this.getNodeById(n.id, false);
for (const { newSchema } of step.diff ?? []) {
if (!newSchema) continue;
const existing = this.getNodeById(newSchema.id, false);
if (existing) {
await this.remove(existing, opts);
}
@ -1211,37 +1410,42 @@ class Editor extends BaseService {
case 'remove': {
// 原本是删除 → revert 即把节点按原父容器加回来。
// 按原 index 升序逐个插回,先小后大避免索引漂移。
const items = step.removedItems ?? [];
const sorted = [...items].sort((a, b) => a.index - b.index);
for (const { node, parentId } of sorted) {
const items = step.diff ?? [];
const sorted = [...items].sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
for (const { oldSchema, parentId } of sorted) {
if (!oldSchema || parentId === undefined) continue;
const parent = this.getNodeById(parentId, false) as MContainer | null;
if (parent) {
await this.add([cloneDeep(node)] as MNode[], parent, opts);
await this.add([cloneDeep(oldSchema)] as MNode[], parent, opts);
}
}
break;
}
case 'update': {
// 原本是更新 → revert 即把 oldNode 的值写回;
// 原本是更新 → revert 即把 oldSchema 的值写回;
// 优先按 changeRecords 局部 patch仅触达 propPath 下的字段,避免冲掉同节点上其它无关变更)。
const items = step.updatedItems ?? [];
const configs = items.map(({ oldNode, newNode, changeRecords }) => {
if (changeRecords?.length) {
const patch: MNode = { id: newNode.id, type: newNode.type };
for (const record of changeRecords) {
if (!record.propPath) {
// 没有 propPath 视为整节点替换
return cloneDeep(oldNode);
const items = step.diff ?? [];
const configs = items
.filter((item) => item.oldSchema && item.newSchema)
.map(({ oldSchema, newSchema, changeRecords }) => {
const oldNode = oldSchema!;
const newNode = newSchema!;
if (changeRecords?.length) {
const patch: MNode = { id: newNode.id, type: newNode.type };
for (const record of changeRecords) {
if (!record.propPath) {
// 没有 propPath 视为整节点替换
return cloneDeep(oldNode);
}
const value = cloneDeep(getValueByKeyPath(record.propPath, oldNode));
setValueByKeyPath(record.propPath, value, patch);
}
const value = cloneDeep(getValueByKeyPath(record.propPath, oldNode));
setValueByKeyPath(record.propPath, value, patch);
return patch;
}
return patch;
}
return cloneDeep(oldNode);
});
return cloneDeep(oldNode);
});
if (configs.length) {
await this.update(configs, { historyDescription });
await this.update(configs, { historyDescription, historySource: 'rollback' });
}
break;
}
@ -1259,6 +1463,20 @@ class Editor extends BaseService {
return revertedStep;
}
/**
* uuid {@link revertPageStep}
* index uuid{@link StepValue.uuid}uuid
*
*
* @param uuid uuid *AndGetHistoryId
* @returns step uuid / / null
*/
public async revertPageStepById(uuid: string): Promise<StepValue | null> {
const index = historyService.getPageStepIndexByUuid(uuid);
if (index < 0) return null;
return this.revertPageStep(index);
}
/**
*
*
@ -1285,14 +1503,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,23 +1575,37 @@ class Editor extends BaseService {
private pushOpHistory(
opType: HistoryOpType,
extra: Partial<StepValue>,
pageData: { name: string; id: Id },
historyDescription?: string,
) {
{
diff,
pageData,
historyDescription,
source,
}: {
diff: StepDiffItem<MNode>[];
pageData: { name: string; id: Id };
historyDescription?: string;
source?: HistoryOpSource;
},
): string | null {
const step: StepValue = {
uuid: guid(),
data: pageData,
opType,
selectedBefore: this.selectionBeforeOp ?? [],
selectedAfter: this.get('nodes').map((n) => n.id),
modifiedNodeIds: new Map(this.get('modifiedNodeIds')),
...extra,
diff,
};
if (historyDescription) step.historyDescription = historyDescription;
if (source) step.source = source;
// 显式按 step.data.id 入栈:跨页操作(如 moveToContainer 从源页搬到目标页)
// 必须落到正确的页面栈,否则会把记录错误地推到当前活动页 / 操作发起页。
historyService.push(step, pageData.id);
const pushed = historyService.push(step, pageData.id);
// push 返回 null 表示当前没有可写入的页面栈(未真正入栈),此时不应返回 uuid。
const historyId = pushed ? step.uuid : null;
this.lastPushedHistoryId = historyId;
this.selectionBeforeOp = null;
return historyId;
}
/**
@ -1390,52 +1629,52 @@ class Editor extends BaseService {
switch (step.opType) {
case 'add': {
const nodes = step.nodes ?? [];
const items = step.diff ?? [];
if (reverse) {
// 撤销 add把当时加入的节点删除
for (const n of nodes) {
const existing = this.getNodeById(n.id, false);
for (const { newSchema } of items) {
if (!newSchema) continue;
const existing = this.getNodeById(newSchema.id, false);
if (existing) {
await this.remove(existing, commonOpts);
}
}
} else {
// 重做 add按记录的 indexMap 把节点重新插回父容器
const parent = this.getNodeById(step.parentId!, false) as MContainer | null;
if (parent) {
// 按目标 index 升序逐个插入,先小后大避免索引漂移
const sorted = [...nodes].sort((a, b) => (step.indexMap?.[a.id] ?? 0) - (step.indexMap?.[b.id] ?? 0));
for (const n of sorted) {
const idx = step.indexMap?.[n.id];
if (parent.items) {
if (typeof idx === 'number' && idx >= 0 && idx < parent.items.length) {
parent.items.splice(idx, 0, cloneDeep(n));
} else {
parent.items.push(cloneDeep(n));
}
await stage?.add({
config: cloneDeep(n),
parent: cloneDeep(parent),
parentId: parent.id,
root: cloneDeep(root),
});
// 重做 add按记录的 parentId / index 把节点重新插回父容器。
// 按目标 index 升序逐个插入,先小后大避免索引漂移
const sorted = [...items].sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
for (const { newSchema, parentId, index } of sorted) {
if (!newSchema || parentId === undefined) continue;
const parent = this.getNodeById(parentId, false) as MContainer | null;
if (parent?.items) {
if (typeof index === 'number' && index >= 0 && index < parent.items.length) {
parent.items.splice(index, 0, cloneDeep(newSchema));
} else {
parent.items.push(cloneDeep(newSchema));
}
await stage?.add({
config: cloneDeep(newSchema),
parent: cloneDeep(parent),
parentId: parent.id,
root: cloneDeep(root),
});
}
}
}
break;
}
case 'remove': {
const items = step.removedItems ?? [];
const items = step.diff ?? [];
if (reverse) {
// 撤销 remove按原 index 升序逐个插回(先小后大避免索引漂移)
const sorted = [...items].sort((a, b) => a.index - b.index);
for (const { node, parentId, index } of sorted) {
const sorted = [...items].sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
for (const { oldSchema, parentId, index } of sorted) {
if (!oldSchema || parentId === undefined) continue;
const parent = this.getNodeById(parentId, false) as MContainer | null;
if (parent?.items) {
parent.items.splice(index, 0, cloneDeep(node));
parent.items.splice(index ?? parent.items.length, 0, cloneDeep(oldSchema));
await stage?.add({
config: cloneDeep(node),
config: cloneDeep(oldSchema),
parent: cloneDeep(parent),
parentId,
root: cloneDeep(root),
@ -1444,8 +1683,9 @@ class Editor extends BaseService {
}
} else {
// 重做 remove再删一次
for (const { node } of items) {
const existing = this.getNodeById(node.id, false);
for (const { oldSchema } of items) {
if (!oldSchema) continue;
const existing = this.getNodeById(oldSchema.id, false);
if (existing) {
await this.remove(existing, commonOpts);
}
@ -1454,27 +1694,31 @@ class Editor extends BaseService {
break;
}
case 'update': {
const items = step.updatedItems ?? [];
const items = step.diff ?? [];
// 优先按 changeRecords 局部 patch仅触达 propPath 下的字段,避免整节点替换冲掉同节点上其它无关变更。
// 没有 changeRecords 的(如内部 sort/moveLayer/拖动等整节点快照场景)才退化为整节点替换。
const configs = items.map(({ oldNode, newNode, changeRecords }) => {
if (changeRecords?.length) {
const sourceForValues = reverse ? oldNode : newNode;
// 仅保留 id / type 作为最小骨架,再按 propPath 写入需要回滚/重做的字段;
// 后续 update -> mergeWith 会与现有节点深合并patch 中未涉及的字段不会被改动。
const patch: MNode = { id: newNode.id, type: newNode.type };
for (const record of changeRecords) {
if (!record.propPath) {
// 没有 propPath 视为整节点替换
return cloneDeep(sourceForValues);
const configs = items
.filter((item) => item.oldSchema && item.newSchema)
.map(({ oldSchema, newSchema, changeRecords }) => {
const oldNode = oldSchema!;
const newNode = newSchema!;
if (changeRecords?.length) {
const sourceForValues = reverse ? oldNode : newNode;
// 仅保留 id / type 作为最小骨架,再按 propPath 写入需要回滚/重做的字段;
// 后续 update -> mergeWith 会与现有节点深合并patch 中未涉及的字段不会被改动。
const patch: MNode = { id: newNode.id, type: newNode.type };
for (const record of changeRecords) {
if (!record.propPath) {
// 没有 propPath 视为整节点替换
return cloneDeep(sourceForValues);
}
const value = cloneDeep(getValueByKeyPath(record.propPath, sourceForValues));
setValueByKeyPath(record.propPath, value, patch);
}
const value = cloneDeep(getValueByKeyPath(record.propPath, sourceForValues));
setValueByKeyPath(record.propPath, value, patch);
return patch;
}
return patch;
}
return cloneDeep(reverse ? oldNode : newNode);
});
return cloneDeep(reverse ? oldNode : newNode);
});
if (configs.length) {
await this.update(configs, { doNotPushHistory: true });
}

View File

@ -18,40 +18,74 @@
import { reactive } from 'vue';
import { cloneDeep } from 'lodash-es';
import serialize from 'serialize-javascript';
import type { CodeBlockContent, DataSourceSchema, Id, MPage, MPageFragment } from '@tmagic/core';
import type { ChangeRecord } from '@tmagic/form';
import { guid } from '@tmagic/utils';
import type {
BaseStepValue,
CodeBlockHistoryGroup,
CodeBlockStepValue,
DataSourceHistoryGroup,
DataSourceStepValue,
HistoryOpSource,
HistoryOpType,
HistoryPersistOptions,
HistoryState,
PageHistoryGroup,
PageHistoryStepEntry,
PersistedHistoryState,
StepValue,
} from '@editor/type';
import { getEditorConfig } from '@editor/utils/config';
import { idbGet, idbSet } from '@editor/utils/indexed-db';
import { UndoRedo } from '@editor/utils/undo-redo';
import BaseService from './BaseService';
import editorService from './editor';
/** 历史记录持久化快照的默认存储位置与结构版本。 */
const DEFAULT_DB_NAME = 'tmagic-editor';
const DEFAULT_STORE_NAME = 'history';
const DEFAULT_KEY: IDBValidKey = 'default';
const PERSIST_VERSION = 1;
class History extends BaseService {
/**
* group
* id / group
* - "新增/删除" update
* - 'update' steps
*
* `kind` `kind`
*/
private static mergeCodeBlockSteps(
codeBlockId: Id,
list: CodeBlockStepValue[],
private static mergeStackSteps<S extends BaseStepValue, K extends 'code-block' | 'data-source'>(
kind: K,
id: Id,
list: S[],
cursor: number,
): CodeBlockHistoryGroup[] {
const groups: CodeBlockHistoryGroup[] = [];
let current: CodeBlockHistoryGroup | null = null;
): {
kind: K;
id: Id;
opType: HistoryOpType;
steps: { step: S; index: number; applied: boolean; isCurrent?: boolean }[];
applied: boolean;
isCurrent?: boolean;
}[] {
type Group = {
kind: K;
id: Id;
opType: HistoryOpType;
steps: { step: S; index: number; applied: boolean; isCurrent?: boolean }[];
applied: boolean;
isCurrent?: boolean;
};
const groups: Group[] = [];
let current: Group | null = null;
const currentIndex = cursor - 1;
list.forEach((step, index) => {
const opType = History.detectOpType(step.oldContent, step.newContent);
const { opType } = step;
const applied = index < cursor;
const isCurrent = index === currentIndex;
if (opType === 'update' && current?.opType === 'update') {
@ -60,39 +94,8 @@ class History extends BaseService {
if (isCurrent) current.isCurrent = true;
} else {
current = {
kind: 'code-block',
id: codeBlockId,
opType,
steps: [{ step, index, applied, isCurrent }],
applied,
isCurrent,
};
groups.push(current);
}
});
return groups;
}
private static mergeDataSourceSteps(
dataSourceId: Id,
list: DataSourceStepValue[],
cursor: number,
): DataSourceHistoryGroup[] {
const groups: DataSourceHistoryGroup[] = [];
let current: DataSourceHistoryGroup | null = null;
const currentIndex = cursor - 1;
list.forEach((step, index) => {
const opType = History.detectOpType(step.oldSchema, step.newSchema);
const applied = index < cursor;
const isCurrent = index === currentIndex;
if (opType === 'update' && current?.opType === 'update') {
current.steps.push({ step, index, applied, isCurrent });
current.applied = applied;
if (isCurrent) current.isCurrent = true;
} else {
current = {
kind: 'data-source',
id: dataSourceId,
kind,
id,
opType,
steps: [{ step, index, applied, isCurrent }],
applied,
@ -162,38 +165,87 @@ class History extends BaseService {
*/
private static detectPageTargetId(step: StepValue): Id | undefined {
if (step.opType !== 'update') return undefined;
const items = step.updatedItems;
const items = step.diff;
if (items?.length !== 1) return undefined;
return items[0].newNode?.id ?? items[0].oldNode?.id;
return items[0].newSchema?.id ?? items[0].oldSchema?.id;
}
/** 解析 StepValue 中的目标节点可读名(用于 UI 展示)。 */
private static detectPageTargetName(step: StepValue): string | undefined {
const items = step.diff;
if (step.opType === 'update') {
const items = step.updatedItems;
if (items?.length === 1) {
const node = items[0].newNode || items[0].oldNode;
const node = items[0].newSchema || items[0].oldSchema;
return (node?.name as string) || (node?.type as string) || (node?.id !== undefined ? `${node.id}` : undefined);
}
return items?.length ? `${items.length} 个节点` : undefined;
}
if (step.opType === 'add') {
if (step.nodes?.length === 1) {
const n = step.nodes[0];
return (n.name as string) || (n.type as string) || `${n.id}`;
if (items?.length === 1) {
const n = items[0].newSchema;
return (n?.name as string) || (n?.type as string) || `${n?.id}`;
}
return step.nodes?.length ? `${step.nodes.length} 个节点` : undefined;
return items?.length ? `${items.length} 个节点` : undefined;
}
if (step.opType === 'remove') {
if (step.removedItems?.length === 1) {
const n = step.removedItems[0].node;
return (n.name as string) || (n.type as string) || `${n.id}`;
if (items?.length === 1) {
const n = items[0].oldSchema;
return (n?.name as string) || (n?.type as string) || `${n?.id}`;
}
return step.removedItems?.length ? `${step.removedItems.length} 个节点` : undefined;
return items?.length ? `${items.length} 个节点` : undefined;
}
return undefined;
}
/**
* `saved`
* cursor 0 0
*/
private static markStackSaved<S extends { saved?: boolean }>(undoRedo?: UndoRedo<S>): void {
if (!undoRedo) return;
undoRedo.updateElements((element) => {
element.saved = false;
});
undoRedo.updateCurrentElement((element) => {
element.saved = true;
});
}
/** 把 `Record<Id, UndoRedo>` 整体序列化为 `Record<Id, SerializedUndoRedo>`。 */
private static serializeStacks<T>(stacks: Record<Id, UndoRedo<T>>) {
const result: Record<Id, ReturnType<UndoRedo<T>['serialize']>> = {};
Object.entries(stacks).forEach(([id, undoRedo]) => {
if (undoRedo) result[id] = undoRedo.serialize();
});
return result;
}
/**
* `Record<Id, SerializedUndoRedo>` `Record<Id, UndoRedo>`
* `saved === true`
*/
private static deserializeStacks<T extends { saved?: boolean }>(
stacks: Record<Id, ReturnType<UndoRedo<T>['serialize']>> = {},
): Record<Id, UndoRedo<T>> {
const result: Record<Id, UndoRedo<T>> = {};
Object.entries(stacks).forEach(([id, serialized]) => {
if (serialized) {
result[id] = UndoRedo.fromSerialized<T>(serialized, { isSavedStep: (element) => element.saved === true });
}
});
return result;
}
/**
* id id / UndoRedo
*/
private static getOrCreateStack<T>(stacks: Record<Id, UndoRedo<T>>, id: Id): UndoRedo<T> {
if (!stacks[id]) {
stacks[id] = new UndoRedo<T>();
}
return stacks[id];
}
public state = reactive<HistoryState>({
pageSteps: {},
pageId: undefined,
@ -254,6 +306,7 @@ class History extends BaseService {
public push(state: StepValue, pageId?: Id): StepValue | null {
const undoRedo = this.getUndoRedo(pageId);
if (!undoRedo) return null;
if (state.uuid === undefined) state.uuid = guid();
if (state.timestamp === undefined) state.timestamp = Date.now();
undoRedo.pushElement(state);
// 仅当推入的是当前活动页时才需要刷新 canUndo/canRedo —— 其它页栈对当前 UI 状态没影响。
@ -280,20 +333,19 @@ class History extends BaseService {
changeRecords?: ChangeRecord[];
/** 可选的人类可读描述(如「修改按钮颜色」),仅用于历史面板展示。 */
historyDescription?: string;
/** 可选的操作途径(配置面板 / 菜单 / 接口等),仅用于历史面板展示与埋点。 */
source?: HistoryOpSource;
},
): CodeBlockStepValue | null {
if (!codeBlockId) return null;
const step: CodeBlockStepValue = {
id: codeBlockId,
oldContent: payload.oldContent ? cloneDeep(payload.oldContent) : null,
newContent: payload.newContent ? cloneDeep(payload.newContent) : null,
changeRecords: payload.changeRecords?.length ? cloneDeep(payload.changeRecords) : undefined,
const step = this.createStackStep<CodeBlockContent, CodeBlockStepValue>(codeBlockId, {
oldValue: payload.oldContent,
newValue: payload.newContent,
changeRecords: payload.changeRecords,
historyDescription: payload.historyDescription,
timestamp: Date.now(),
};
this.getCodeBlockUndoRedo(codeBlockId).pushElement(step);
source: payload.source,
});
if (!step) return null;
History.getOrCreateStack(this.state.codeBlockState, codeBlockId).pushElement(step);
this.emit('code-block-history-change', codeBlockId, step);
return step;
}
@ -310,20 +362,19 @@ class History extends BaseService {
changeRecords?: ChangeRecord[];
/** 可选的人类可读描述,仅用于历史面板展示。 */
historyDescription?: string;
/** 可选的操作途径(配置面板 / 菜单 / 接口等),仅用于历史面板展示与埋点。 */
source?: HistoryOpSource;
},
): DataSourceStepValue | null {
if (!dataSourceId) return null;
const step: DataSourceStepValue = {
id: dataSourceId,
oldSchema: payload.oldSchema ? cloneDeep(payload.oldSchema) : null,
newSchema: payload.newSchema ? cloneDeep(payload.newSchema) : null,
changeRecords: payload.changeRecords?.length ? cloneDeep(payload.changeRecords) : undefined,
const step = this.createStackStep<DataSourceSchema, DataSourceStepValue>(dataSourceId, {
oldValue: payload.oldSchema,
newValue: payload.newSchema,
changeRecords: payload.changeRecords,
historyDescription: payload.historyDescription,
timestamp: Date.now(),
};
this.getDataSourceUndoRedo(dataSourceId).pushElement(step);
source: payload.source,
});
if (!step) return null;
History.getOrCreateStack(this.state.dataSourceState, dataSourceId).pushElement(step);
this.emit('data-source-history-change', dataSourceId, step);
return step;
}
@ -406,6 +457,137 @@ class History extends BaseService {
this.removeAllPlugins();
}
/**
*
* / DSL/
*/
public clearPage(pageId?: Id): void {
const targetPageId = pageId ?? this.state.pageId;
if (!targetPageId) return;
this.state.pageSteps[targetPageId] = new UndoRedo<StepValue>();
if (`${targetPageId}` === `${this.state.pageId}`) {
this.setCanUndoRedo();
this.emit('change', null);
}
}
/**
* `dataSourceId`
* /
*/
public clearDataSource(dataSourceId?: Id): void {
if (dataSourceId !== undefined) {
delete this.state.dataSourceState[dataSourceId];
} else {
this.state.dataSourceState = {};
}
}
/**
* `codeBlockId`
* /
*/
public clearCodeBlock(codeBlockId?: Id): void {
if (codeBlockId !== undefined) {
delete this.state.codeBlockState[codeBlockId];
} else {
this.state.codeBlockState = {};
}
}
/**
* DSL / / `saved`
*
* {@link markPageSaved} / {@link markCodeBlockSaved} / {@link markDataSourceSaved}
*/
public markSaved(): void {
Object.values(this.state.pageSteps).forEach(History.markStackSaved);
Object.values(this.state.codeBlockState).forEach(History.markStackSaved);
Object.values(this.state.dataSourceState).forEach(History.markStackSaved);
this.emit('mark-saved', { kind: 'all' });
}
/**
*
* / /
*/
public markPageSaved(pageId?: Id): void {
const targetPageId = pageId ?? this.state.pageId;
if (!targetPageId) return;
History.markStackSaved(this.state.pageSteps[targetPageId]);
this.emit('mark-saved', { kind: 'page', id: targetPageId });
}
/** 标记指定代码块的历史栈当前记录为已保存,仅影响该代码块自己的栈。 */
public markCodeBlockSaved(codeBlockId: Id): void {
if (!codeBlockId) return;
History.markStackSaved(this.state.codeBlockState[codeBlockId]);
this.emit('mark-saved', { kind: 'code-block', id: codeBlockId });
}
/** 标记指定数据源的历史栈当前记录为已保存,仅影响该数据源自己的栈。 */
public markDataSourceSaved(dataSourceId: Id): void {
if (!dataSourceId) return;
History.markStackSaved(this.state.dataSourceState[dataSourceId]);
this.emit('mark-saved', { kind: 'data-source', id: dataSourceId });
}
/**
* / / IndexedDB
*
* - UndoRedo undo/redo
* - `key` / store `default`
* - 便 savedAt
* - IndexedDB SSR reject
*/
public async saveToIndexedDB(options: HistoryPersistOptions = {}): Promise<PersistedHistoryState> {
const { dbName = DEFAULT_DB_NAME, storeName = DEFAULT_STORE_NAME, key = DEFAULT_KEY } = options;
const snapshot: PersistedHistoryState = {
version: PERSIST_VERSION,
pageId: this.state.pageId,
pageSteps: History.serializeStacks(this.state.pageSteps),
codeBlockState: History.serializeStacks(this.state.codeBlockState),
dataSourceState: History.serializeStacks(this.state.dataSourceState),
savedAt: Date.now(),
};
// 历史记录里可能包含函数(如代码块内容 / 节点事件 / 数据源方法IndexedDB 的结构化克隆无法写入函数,
// 因此用 serialize-javascript 序列化成字符串后再写入(支持函数 / Map 等),读取时用 parseDSL 还原。
await idbSet(this.resolveDbName(dbName), storeName, key, serialize(snapshot));
this.emit('save-to-indexed-db', snapshot);
return snapshot;
}
/**
* IndexedDB /
*
* - {@link UndoRedo.fromSerialized} undo/redo
* - pageId
* - null
* - IndexedDB SSR reject
*/
public async restoreFromIndexedDB(options: HistoryPersistOptions = {}): Promise<PersistedHistoryState | null> {
const { dbName = DEFAULT_DB_NAME, storeName = DEFAULT_STORE_NAME, key = DEFAULT_KEY } = options;
const raw = await idbGet<string | PersistedHistoryState>(this.resolveDbName(dbName), storeName, key);
if (!raw) return null;
// 新版以序列化字符串存储(含函数),用 parseDSL 还原;兼容历史上以对象形式存入的旧数据。
const snapshot = (typeof raw === 'string' ? getEditorConfig('parseDSL')(`(${raw})`) : raw) as PersistedHistoryState;
if (!snapshot) return null;
this.state.pageSteps = History.deserializeStacks(snapshot.pageSteps);
this.state.codeBlockState = History.deserializeStacks(snapshot.codeBlockState);
this.state.dataSourceState = History.deserializeStacks(snapshot.dataSourceState);
this.state.pageId = snapshot.pageId;
this.setCanUndoRedo();
this.emit('restore-from-indexed-db', snapshot);
this.emit('change', null);
return snapshot;
}
/**
* +
*
@ -456,7 +638,7 @@ class History extends BaseService {
const list = undoRedo.getElementList();
if (!list.length) return;
const cursor = undoRedo.getCursor();
groups.push(...History.mergeCodeBlockSteps(id, list, cursor));
groups.push(...History.mergeStackSteps('code-block', id, list, cursor));
});
return groups;
}
@ -503,6 +685,41 @@ class History extends BaseService {
return list.map((step, index) => ({ step, index, applied: index < cursor }));
}
/**
* uuid
* -1 uuid uuid index 使
*/
public getPageStepIndexByUuid(uuid: string, pageId?: Id): number {
if (!uuid) return -1;
return this.getPageStepList(pageId).findIndex((entry) => entry.step.uuid === uuid);
}
/**
* uuid codeBlockId
* null
*/
public findCodeBlockStepLocationByUuid(uuid: string): { id: Id; index: number } | null {
if (!uuid) return null;
for (const id of Object.keys(this.state.codeBlockState)) {
const index = this.getCodeBlockStepList(id).findIndex((entry) => entry.step.uuid === uuid);
if (index >= 0) return { id, index };
}
return null;
}
/**
* uuid dataSourceId
* null
*/
public findDataSourceStepLocationByUuid(uuid: string): { id: Id; index: number } | null {
if (!uuid) return null;
for (const id of Object.keys(this.state.dataSourceState)) {
const index = this.getDataSourceStepList(id).findIndex((entry) => entry.step.uuid === uuid);
if (index >= 0) return { id, index };
}
return null;
}
/**
* dataSourceId
*/
@ -513,7 +730,7 @@ class History extends BaseService {
const list = undoRedo.getElementList();
if (!list.length) return;
const cursor = undoRedo.getCursor();
groups.push(...History.mergeDataSourceSteps(id, list, cursor));
groups.push(...History.mergeStackSteps('data-source', id, list, cursor));
});
return groups;
}
@ -533,6 +750,15 @@ class History extends BaseService {
return this.state.pageSteps[targetPageId];
}
/**
* dbName DSLroot app id
* app id DSL退 dbName
*/
private resolveDbName(dbName: string): string {
const appId = editorService.get('root')?.id;
return appId ? `${dbName}-${appId}` : dbName;
}
private setCanUndoRedo(): void {
const undoRedo = this.getUndoRedo();
this.state.canRedo = undoRedo?.canRedo() || false;
@ -540,23 +766,47 @@ class History extends BaseService {
}
/**
* id UndoRedo
* / id payload
*
* - `add`oldValue = null`remove`newValue = null`update` changeRecords
* - cloneDeep opType old/new null
* - step emit pushCodeBlock / pushDataSource
* - service
*/
private getCodeBlockUndoRedo(codeBlockId: Id): UndoRedo<CodeBlockStepValue> {
if (!this.state.codeBlockState[codeBlockId]) {
this.state.codeBlockState[codeBlockId] = new UndoRedo<CodeBlockStepValue>();
}
return this.state.codeBlockState[codeBlockId];
}
private createStackStep<T, S extends BaseStepValue<T> & { id: Id }>(
id: Id,
payload: {
oldValue: T | null;
newValue: T | null;
changeRecords?: ChangeRecord[];
historyDescription?: string;
source?: HistoryOpSource;
},
): S | null {
if (!id) return null;
/**
* id UndoRedo
*/
private getDataSourceUndoRedo(dataSourceId: Id): UndoRedo<DataSourceStepValue> {
if (!this.state.dataSourceState[dataSourceId]) {
this.state.dataSourceState[dataSourceId] = new UndoRedo<DataSourceStepValue>();
}
return this.state.dataSourceState[dataSourceId];
const oldSchema = payload.oldValue ? cloneDeep(payload.oldValue) : null;
const newSchema = payload.newValue ? cloneDeep(payload.newValue) : null;
const changeRecords = payload.changeRecords?.length ? cloneDeep(payload.changeRecords) : undefined;
const opType = History.detectOpType(payload.oldValue, payload.newValue);
const step: BaseStepValue<T> & { id: Id } = {
uuid: guid(),
id,
opType,
diff: [
{
...(newSchema !== null ? { newSchema } : {}),
...(oldSchema !== null ? { oldSchema } : {}),
...(opType === 'update' && changeRecords ? { changeRecords } : {}),
},
],
historyDescription: payload.historyDescription,
source: payload.source,
timestamp: Date.now(),
};
return step as S;
}
}

View File

@ -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();

View File

@ -13,7 +13,7 @@
position: absolute;
top: 4px;
right: 4px;
z-index: 1;
z-index: 2;
display: flex;
align-items: center;
height: 40px;
@ -45,6 +45,28 @@
list-style: none;
}
// 历史列表工具条放置清空等列表级操作右对齐
.m-editor-history-list-toolbar {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 0 4px 4px;
}
// 清空按钮红色文字按钮强调破坏性操作点击后会二次确认
.m-editor-history-list-clear {
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
color: #f56c6c;
cursor: pointer;
user-select: none;
&:hover {
background-color: rgba(245, 108, 108, 0.12);
}
}
.m-editor-history-list-item {
display: flex;
align-items: center;
@ -281,6 +303,34 @@
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; // 防止被合并组头部的粗体继承
}
// 已保存徽标绿色实心胶囊标记最近一次保存对应的历史记录 historyService.markSaved 对应
.m-editor-history-list-item-saved {
flex: 0 0 auto;
padding: 0 6px;
border-radius: 8px;
font-size: 10px;
line-height: 16px;
color: #fff;
background-color: #67c23a;
white-space: nowrap;
font-weight: 500;
letter-spacing: 0.2px;
}
// 合并 N 徽标紫色实心胶囊与合并组卡片色系一致醒目区分单步条目
.m-editor-history-list-item-merge {
flex: 0 0 auto;

View File

@ -58,7 +58,7 @@
position: absolute;
right: 15px;
bottom: 15px;
z-index: 30;
z-index: 32;
opacity: 0.5;
&:hover {
@ -70,7 +70,7 @@
position: absolute;
right: 15px;
bottom: 60px;
z-index: 30;
z-index: 31;
opacity: 0.5;
&:hover {
@ -82,7 +82,7 @@
position: absolute;
left: 0;
top: 0;
z-index: 10;
z-index: 31;
}
.m-editor-resizer {

View File

@ -56,7 +56,7 @@ import type { PropsService } from './services/props';
import type { StageOverlayService } from './services/stageOverlay';
import type { StorageService } from './services/storage';
import type { UiService } from './services/ui';
import type { UndoRedo } from './utils/undo-redo';
import type { SerializedUndoRedo, UndoRedo } from './utils/undo-redo';
export type EditorSlots = FrameworkSlots &
WorkspaceSlots &
@ -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,92 +683,153 @@ export interface CodeParamStatement {
export type HistoryOpType = 'add' | 'remove' | 'update';
// #endregion HistoryOpType
// #region StepValue
export interface StepValue {
/** 页面信息 */
data: { name: string; id: Id };
opType: HistoryOpType;
/** 操作前选中的节点 ID用于撤销后恢复选择状态 */
selectedBefore: Id[];
/** 操作后选中的节点 ID用于重做后恢复选择状态 */
selectedAfter: Id[];
modifiedNodeIds: Map<Id, Id>;
/** opType 'add': 新增的节点 */
nodes?: MNode[];
/** opType 'add': 父节点 ID */
// #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 StepDiffItem
/**
* diff / /
* {@link StepValue} / {@link CodeBlockStepValue} / {@link DataSourceStepValue} `diff`
*
* `opType`
* - `add` `newSchema` `parentId` / `index`
* - `remove` `oldSchema` `parentId` / `index`
* - `update``oldSchema` + `newSchema` `changeRecords`
*
* `T` `MNode` `CodeBlockContent` `DataSourceSchema`
*/
export interface StepDiffItem<T = unknown> {
/** 变更后的内容快照。`opType` 为 `add` / `update` 时有,`remove` 时无。 */
newSchema?: T;
/** 变更前的内容快照。`opType` 为 `remove` / `update` 时有,`add` 时无。 */
oldSchema?: T;
/** 父节点 id。仅页面节点有数据源 / 代码块没有父节点)。 */
parentId?: Id;
/** opType 'add': 每个新增节点在父节点 items 中的索引 */
indexMap?: Record<string, number>;
/** opType 'remove': 被删除的节点及其位置信息 */
removedItems?: { node: MNode; parentId: Id; index: number }[];
/** 在父节点 items 数组中的索引。仅页面节点有(数据源 / 代码块无需排序)。 */
index?: number;
/**
* opType 'update':
*
* `changeRecords` form propPath/value / propPath
* / 退
* form propPath/value `opType` `update`
* / propPath 退
*/
updatedItems?: { oldNode: MNode; newNode: MNode; changeRecords?: ChangeRecord[] }[];
changeRecords?: ChangeRecord[];
}
// #endregion StepDiffItem
// #region BaseStepValue
/**
* {@link StepValue} / {@link CodeBlockStepValue} / {@link DataSourceStepValue}
*
* `T` `diff` `MNode` / `CodeBlockContent` / `DataSourceSchema`
*/
export interface BaseStepValue<T = unknown> {
/**
* uuid
* / revert
* `id` / / id
*/
uuid: string;
/** 操作类型:新增 / 删除 / 更新(三类历史记录统一携带)。 */
opType: HistoryOpType;
/**
* diff {@link StepDiffItem}
* add/remove update /
*/
diff: StepDiffItem<T>[];
/**
*
* undo/redo / propPath
*/
historyDescription?: string;
/**
* historyService.push
* {@link HistoryOpSource}
* / / / / / / / / /
* undo/redo
*/
source?: HistoryOpSource;
/**
*
*/
timestamp?: number;
/**
* DSL / historyService.markSaved
* true IndexedDB
*/
saved?: boolean;
}
// #endregion BaseStepValue
// #region StepValue
export interface StepValue extends BaseStepValue<MNode> {
/** 页面信息 */
data: { name: string; id: Id };
/** 操作前选中的节点 ID用于撤销后恢复选择状态 */
selectedBefore: Id[];
/** 操作后选中的节点 ID用于重做后恢复选择状态 */
selectedAfter: Id[];
modifiedNodeIds: Map<Id, Id>;
}
// #endregion StepValue
// #region CodeBlockStepValue
/**
* codeBlock.id historyState.codeBlockState
* - oldContent = nullnewContent =
* - oldContent / newContent
* - newContent = nulloldContent =
* `diff` {@link StepDiffItem}
* - opType 'add' `newSchema`
* - opType 'update'`oldSchema` + `newSchema` `changeRecords`
* - opType 'remove' `oldSchema`
*/
export interface CodeBlockStepValue {
export interface CodeBlockStepValue extends BaseStepValue<CodeBlockContent> {
/** 关联的代码块 id */
id: Id;
/** 变更前的代码块内容,新增时为 null */
oldContent: CodeBlockContent | null;
/** 变更后的代码块内容,删除时为 null */
newContent: CodeBlockContent | null;
/**
* form propPath/value / propPath
* 退/ changeRecords
*/
changeRecords?: ChangeRecord[];
/** 调用方可选传入的人类可读描述,用于历史面板展示;不影响 undo/redo 行为。 */
historyDescription?: string;
/** 入栈时间戳(毫秒),入栈时自动写入,仅用于历史面板展示。 */
timestamp?: number;
}
// #endregion CodeBlockStepValue
// #region DataSourceStepValue
/**
* dataSource.id historyState.dataSourceState
* - oldSchema = nullnewSchema = schema
* - oldSchema / newSchema schema
* - newSchema = nulloldSchema = schema
* `diff` {@link StepDiffItem}
* - opType 'add' `newSchema` schema
* - opType 'update'`oldSchema` + `newSchema` `changeRecords`
* - opType 'remove' `oldSchema` schema
*/
export interface DataSourceStepValue {
export interface DataSourceStepValue extends BaseStepValue<DataSourceSchema> {
/** 关联的数据源 id */
id: Id;
/** 变更前的数据源 schema新增时为 null */
oldSchema: DataSourceSchema | null;
/** 变更后的数据源 schema删除时为 null */
newSchema: DataSourceSchema | null;
/**
* form propPath/value / propPath
* 退 schema / changeRecords
*/
changeRecords?: ChangeRecord[];
/** 调用方可选传入的人类可读描述,用于历史面板展示;不影响 undo/redo 行为。 */
historyDescription?: string;
/** 入栈时间戳(毫秒),入栈时自动写入,仅用于历史面板展示。 */
timestamp?: number;
}
// #endregion DataSourceStepValue
@ -789,6 +850,39 @@ export interface HistoryState {
dataSourceState: Record<Id, UndoRedo<DataSourceStepValue>>;
}
// #region PersistedHistoryState
/**
* historyService.saveToIndexedDB IndexedDB
* historyService.restoreFromIndexedDB UndoRedo
*/
export interface PersistedHistoryState {
/** 快照结构版本号,便于后续兼容升级。 */
version: number;
/** 保存时的活动页 id。 */
pageId?: Id;
/** 各页面历史栈的序列化快照,按 pageId 分组。 */
pageSteps: Record<Id, SerializedUndoRedo<StepValue>>;
/** 各代码块历史栈的序列化快照,按 codeBlockId 分组。 */
codeBlockState: Record<Id, SerializedUndoRedo<CodeBlockStepValue>>;
/** 各数据源历史栈的序列化快照,按 dataSourceId 分组。 */
dataSourceState: Record<Id, SerializedUndoRedo<DataSourceStepValue>>;
/** 保存时间戳(毫秒)。 */
savedAt: number;
}
// #endregion PersistedHistoryState
// #region HistoryPersistOptions
/** historyService 持久化相关 API 的可选配置。 */
export interface HistoryPersistOptions {
/** IndexedDB 数据库名,默认 `tmagic-editor`(最终库名会拼上当前 DSL app id。 */
dbName?: string;
/** objectStore 名,默认 `history`。 */
storeName?: string;
/** 记录 key用于区分不同活动页 / 项目,默认 `default`。 */
key?: IDBValidKey;
}
// #endregion HistoryPersistOptions
// #region HistoryListEntry
/**
*
@ -1099,16 +1193,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 +1215,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,11 +1227,12 @@ export interface DslOpOptions extends HistoryOpOptions {
doNotSelect?: boolean;
doNotSwitchPage?: boolean;
}
// #endregion DslOpOptions
/** 差异对话框的入参 */
export interface DiffDialogPayload {
/** 表单类别 */
category: CompareCategory;
category?: CompareCategory;
/** 节点类型 / 数据源类型 */
type?: string;
/** 代码块场景下的数据源类型 */

View File

@ -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<InstanceType<typeof ContentMenu> | null>): MenuButton => ({
export const usePasteMenu = (
historySource?: HistoryOpSource,
menu?: ShallowRef<InstanceType<typeof ContentMenu> | null>,
): MenuButton => ({
type: 'button',
text: '粘贴',
icon: markRaw(DocumentCopy),
@ -52,14 +60,14 @@ export const usePasteMenu = (menu?: ShallowRef<InstanceType<typeof ContentMenu>
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);
},
})),
};

View File

@ -0,0 +1,43 @@
/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2025 Tencent. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Id } from '@tmagic/core';
import type { StepDiffItem } from '@editor/type';
/**
* step /
* `name` `title`
* `getLabel`
*
* @param id / id
* @param diff diff
* @param getLabel
*/
export const describeRevertStep = <T extends object>(
id: Id,
{ oldSchema, newSchema, changeRecords }: StepDiffItem<T> = {},
getLabel: (schema: T) => string | undefined,
): string => {
const labelOf = (schema: T) => getLabel(schema) || (schema as { id?: Id }).id;
if (!oldSchema && newSchema) return `撤回新增 ${labelOf(newSchema) || id}`;
if (oldSchema && !newSchema) return `还原已删除的 ${labelOf(oldSchema) || id}`;
const label = (newSchema && getLabel(newSchema)) || (oldSchema && getLabel(oldSchema)) || `${id}`;
const propPath = changeRecords?.[0]?.propPath;
return propPath ? `还原 ${label} · ${propPath}` : `还原 ${label}`;
};

View File

@ -27,5 +27,7 @@ export * from './dep/idle-task';
export * from './scroll-viewer';
export * from './tree';
export * from './undo-redo';
export * from './indexed-db';
export * from './history';
export * from './const';
export { default as loadMonaco } from './monaco-editor';

View File

@ -0,0 +1,122 @@
/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2025 Tencent. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* IndexedDB Promise KV
* IndexedDB SSR / reject
*/
/** 是否处于支持 IndexedDB 的环境。 */
export const isIndexedDBSupported = (): boolean => typeof indexedDB !== 'undefined' && indexedDB !== null;
/**
* objectStore
*
* objectStore `onupgradeneeded`
* store storeName
*/
export const openIndexedDB = (dbName: string, storeName: string): Promise<IDBDatabase> =>
new Promise((resolve, reject) => {
if (!isIndexedDBSupported()) {
reject(new Error('当前环境不支持 IndexedDB'));
return;
}
const request = indexedDB.open(dbName);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(storeName)) {
db.createObjectStore(storeName);
}
};
request.onerror = () => reject(request.error);
request.onsuccess = () => {
const db = request.result;
if (db.objectStoreNames.contains(storeName)) {
resolve(db);
return;
}
// store 不存在:以更高版本重开,在 onupgradeneeded 中创建。
const nextVersion = db.version + 1;
db.close();
const upgradeRequest = indexedDB.open(dbName, nextVersion);
upgradeRequest.onupgradeneeded = () => {
const upgradeDb = upgradeRequest.result;
if (!upgradeDb.objectStoreNames.contains(storeName)) {
upgradeDb.createObjectStore(storeName);
}
};
upgradeRequest.onerror = () => reject(upgradeRequest.error);
upgradeRequest.onsuccess = () => resolve(upgradeRequest.result);
};
});
/** 写入覆盖一条记录。value 通过结构化克隆存储,支持 Map / Set 等结构。 */
export const idbSet = async (dbName: string, storeName: string, key: IDBValidKey, value: unknown): Promise<void> => {
const db = await openIndexedDB(dbName, storeName);
try {
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(storeName, 'readwrite');
tx.objectStore(storeName).put(value, key);
tx.oncomplete = () => resolve();
tx.onabort = () => reject(tx.error);
tx.onerror = () => reject(tx.error);
});
} finally {
db.close();
}
};
/** 读取一条记录,不存在时返回 undefined。 */
export const idbGet = async <T = unknown>(
dbName: string,
storeName: string,
key: IDBValidKey,
): Promise<T | undefined> => {
const db = await openIndexedDB(dbName, storeName);
try {
return await new Promise<T | undefined>((resolve, reject) => {
const tx = db.transaction(storeName, 'readonly');
const request = tx.objectStore(storeName).get(key);
request.onsuccess = () => resolve(request.result as T | undefined);
request.onerror = () => reject(request.error);
});
} finally {
db.close();
}
};
/** 删除一条记录。 */
export const idbDelete = async (dbName: string, storeName: string, key: IDBValidKey): Promise<void> => {
const db = await openIndexedDB(dbName, storeName);
try {
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(storeName, 'readwrite');
tx.objectStore(storeName).delete(key);
tx.oncomplete = () => resolve();
tx.onabort = () => reject(tx.error);
tx.onerror = () => reject(tx.error);
});
} finally {
db.close();
}
};

View File

@ -18,8 +18,59 @@
import { cloneDeep } from 'lodash-es';
// #region SerializedUndoRedo
/**
* UndoRedo IndexedDB
*/
export interface SerializedUndoRedo<T = any> {
/** 栈内全部元素(按时间正序,索引 0 为最早一步)。 */
elementList: T[];
/** 游标位置(已应用步骤数量)。 */
listCursor: number;
/** 栈容量上限。 */
listMaxSize: number;
}
// #endregion SerializedUndoRedo
// #region UndoRedo
export class UndoRedo<T = any> {
/**
* {@link UndoRedo.serialize} UndoRedo
* [0, length]
*
* @param options.isSavedStep
* 退
*/
public static fromSerialized<T = any>(
data: SerializedUndoRedo<T>,
options: { isSavedStep?: (element: T) => boolean } = {},
): UndoRedo<T> {
const undoRedo = new UndoRedo<T>(data.listMaxSize);
const list = Array.isArray(data.elementList) ? data.elementList.map((item) => cloneDeep(item)) : [];
let cursor = Number.isFinite(data.listCursor) ? data.listCursor : list.length;
// 本地数据同样遵循容量上限:超出时裁掉最旧的记录(与 pushElement 的 shift 行为一致),并同步回退游标。
const overflow = list.length - undoRedo.listMaxSize;
if (overflow > 0) {
list.splice(0, overflow);
cursor -= overflow;
}
// 若指定了「已保存」谓词,则把游标移动到最近一条已保存记录之后;在裁剪后的 list 上查找以保证索引正确。
if (options.isSavedStep) {
for (let i = list.length - 1; i >= 0; i--) {
if (options.isSavedStep(list[i])) {
cursor = i + 1;
break;
}
}
}
undoRedo.elementList = list;
undoRedo.listCursor = Math.max(0, Math.min(cursor, list.length));
return undoRedo;
}
private elementList: T[];
private listCursor: number;
private listMaxSize: number;
@ -31,6 +82,18 @@ export class UndoRedo<T = any> {
this.listMaxSize = listMaxSize > minListMaxSize ? listMaxSize : minListMaxSize;
}
/**
*
* {@link UndoRedo.fromSerialized} /
*/
public serialize(): SerializedUndoRedo<T> {
return {
elementList: this.elementList.map((item) => cloneDeep(item)),
listCursor: this.listCursor,
listMaxSize: this.listMaxSize,
};
}
public pushElement(element: T): void {
// 新元素进来时,把游标之外的元素全部丢弃,并把新元素放进来
this.elementList.splice(this.listCursor, this.elementList.length - this.listCursor, cloneDeep(element));
@ -76,6 +139,20 @@ export class UndoRedo<T = any> {
return cloneDeep(this.elementList[this.listCursor - 1]);
}
/**
* cursor - 1cursor 0
*
*/
public updateCurrentElement(updater: (element: T) => void): void {
if (this.listCursor < 1) return;
updater(this.elementList[this.listCursor - 1]);
}
/** 对栈内全部元素做就地更新。用于批量清理元数据(如清空所有元素的已保存标记)。 */
public updateElements(updater: (element: T, index: number) => void): void {
this.elementList.forEach(updater);
}
/**
* 0
*

View File

@ -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',
},
);
});
});

View File

@ -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 事件', () => {

View File

@ -7,8 +7,9 @@ import { describe, expect, test } from 'vitest';
import { mount } from '@vue/test-utils';
import Bucket from '@editor/layouts/history-list/Bucket.vue';
import type { HistoryBucketConfig } from '@editor/layouts/history-list/composables';
const buildGroup = (opType: 'add' | 'remove' | 'update', stepCount: number, applied = true) => ({
const buildGroup = (opType: 'add' | 'remove' | 'update', stepCount: number, applied = true): any => ({
applied,
opType,
steps: Array.from({ length: stepCount }, (_, i) => ({
@ -18,16 +19,22 @@ const buildGroup = (opType: 'add' | 'remove' | 'update', stepCount: number, appl
})),
});
/** 把 title/prefix/describe* 收敛成单一 config贴近真实调用方式。 */
const buildConfig = (overrides: Partial<HistoryBucketConfig<any>> = {}): HistoryBucketConfig<any> => ({
title: '数据源',
prefix: 'ds',
describeGroup: () => 'desc',
describeStep: () => 'sub-desc',
...overrides,
});
describe('Bucket.vue', () => {
test('渲染 bucket 头部信息与组数', () => {
const wrapper = mount(Bucket, {
props: {
title: '数据源',
config: buildConfig(),
bucketId: 'ds_1',
prefix: 'ds',
groups: [buildGroup('update', 1), buildGroup('add', 1)],
describeGroup: () => 'desc',
describeStep: () => 'sub-desc',
expanded: {},
},
});
@ -44,12 +51,9 @@ describe('Bucket.vue', () => {
const wrapper = mount(Bucket, {
props: {
title: '代码块',
config: buildConfig({ title: '代码块', prefix: 'cb', describeGroup, describeStep }),
bucketId: 'code_1',
prefix: 'cb',
groups,
describeGroup,
describeStep,
expanded: { 'cb-code_1-0': true },
},
});
@ -73,12 +77,9 @@ describe('Bucket.vue', () => {
test('合并组头部点击 → toggle 事件被透传到 Bucket', async () => {
const wrapper = mount(Bucket, {
props: {
title: '代码块',
config: buildConfig({ title: '代码块', prefix: 'cb', describeGroup: () => 'g', describeStep: () => 's' }),
bucketId: 'code_1',
prefix: 'cb',
groups: [buildGroup('update', 2)],
describeGroup: () => 'g',
describeStep: () => 's',
expanded: {},
},
});
@ -93,12 +94,9 @@ describe('Bucket.vue', () => {
test('单步组「回到」按钮点击 → goto 事件被透传到 Bucket并附带 bucketId', async () => {
const wrapper = mount(Bucket, {
props: {
title: '代码块',
config: buildConfig({ title: '代码块', prefix: 'cb', describeGroup: () => 'g', describeStep: () => 's' }),
bucketId: 'code_1',
prefix: 'cb',
groups: [buildGroup('update', 1)],
describeGroup: () => 'g',
describeStep: () => 's',
expanded: {},
},
});
@ -111,12 +109,9 @@ describe('Bucket.vue', () => {
test('合并组展开后点击子步「回到」按钮 → goto 透传,附带子步 index', async () => {
const wrapper = mount(Bucket, {
props: {
title: '代码块',
config: buildConfig({ title: '代码块', prefix: 'cb', describeGroup: () => 'g', describeStep: () => 's' }),
bucketId: 'code_1',
prefix: 'cb',
groups: [buildGroup('update', 2)],
describeGroup: () => 'g',
describeStep: () => 's',
expanded: { 'cb-code_1-0': true },
},
});
@ -132,12 +127,9 @@ describe('Bucket.vue', () => {
test('groupKey 命名空间使用 prefix + bucketId + 索引', () => {
const wrapper = mount(Bucket, {
props: {
title: '数据源',
config: buildConfig({ describeGroup: () => 'g', describeStep: () => 's' }),
bucketId: 42,
prefix: 'ds',
groups: [buildGroup('update', 2), buildGroup('add', 1)],
describeGroup: () => 'g',
describeStep: () => 's',
// 给第二组打开展开状态
expanded: { 'ds-42-1': true },
},
@ -152,12 +144,9 @@ describe('Bucket.vue', () => {
test('groups 非空时底部追加初始项;点击透传 goto-initial 携带 bucketId', async () => {
const wrapper = mount(Bucket, {
props: {
title: '数据源',
config: buildConfig({ describeGroup: () => 'g', describeStep: () => 's' }),
bucketId: 'ds_1',
prefix: 'ds',
groups: [buildGroup('add', 1)],
describeGroup: () => 'g',
describeStep: () => 's',
expanded: {},
},
});
@ -175,12 +164,9 @@ describe('Bucket.vue', () => {
test('该 bucket 全部组都已撤销时初始项标记为当前', () => {
const wrapper = mount(Bucket, {
props: {
title: '代码块',
config: buildConfig({ title: '代码块', prefix: 'cb', describeGroup: () => 'g', describeStep: () => 's' }),
bucketId: 'cb_1',
prefix: 'cb',
groups: [buildGroup('add', 1, false), buildGroup('update', 2, false)],
describeGroup: () => 'g',
describeStep: () => 's',
expanded: {},
},
});

View File

@ -21,6 +21,19 @@ vi.mock('@tmagic/design', () => ({
}),
}));
/** 把以 oldContent/newContent/changeRecords 描述的 fixture 归一成统一 diff 形态的 step。 */
const toDiffStep = (s: any, opType: 'add' | 'remove' | 'update') => ({
id: s.id,
opType,
diff: [
{
...(s.newContent != null ? { newSchema: s.newContent } : {}),
...(s.oldContent != null ? { oldSchema: s.oldContent } : {}),
...(s.changeRecords ? { changeRecords: s.changeRecords } : {}),
},
],
});
const buildGroup = (
id: string,
opType: 'add' | 'remove' | 'update',
@ -32,18 +45,20 @@ const buildGroup = (
id,
opType,
applied,
steps: steps.map((s, i) => ({ step: s, index: startIndex + i, applied })),
steps: steps.map((s, i) => ({ step: toDiffStep(s, opType) as any, index: startIndex + i, applied })),
});
/** 代码块 tab 复用通用 BucketTab固定注入代码块的 title/prefix/describe/isStepDiffable。 */
/** 代码块 tab 复用通用 BucketTab固定注入代码块的 configtitle/prefix/describe/isStepDiffable。 */
const mountCodeBlockTab = (props: { buckets: any[]; expanded: Record<string, boolean> }) =>
mount(BucketTab, {
props: {
title: '代码块',
prefix: 'cb',
describeGroup: describeCodeBlockGroup,
describeStep: describeCodeBlockStep,
isStepDiffable: (step: CodeBlockStepValue) => Boolean(step.oldContent && step.newContent),
config: {
title: '代码块',
prefix: 'cb',
describeGroup: describeCodeBlockGroup,
describeStep: describeCodeBlockStep,
isStepDiffable: (step: CodeBlockStepValue) => Boolean(step.diff?.[0]?.oldSchema && step.diff?.[0]?.newSchema),
},
...props,
},
});

View File

@ -21,6 +21,19 @@ vi.mock('@tmagic/design', () => ({
}),
}));
/** 把以 oldSchema/newSchema/changeRecords 描述的 fixture 归一成统一 diff 形态的 step。 */
const toDiffStep = (s: any, opType: 'add' | 'remove' | 'update') => ({
id: s.id,
opType,
diff: [
{
...(s.newSchema != null ? { newSchema: s.newSchema } : {}),
...(s.oldSchema != null ? { oldSchema: s.oldSchema } : {}),
...(s.changeRecords ? { changeRecords: s.changeRecords } : {}),
},
],
});
const buildGroup = (
id: string,
opType: 'add' | 'remove' | 'update',
@ -32,18 +45,20 @@ const buildGroup = (
id,
opType,
applied,
steps: steps.map((s, i) => ({ step: s, index: startIndex + i, applied })),
steps: steps.map((s, i) => ({ step: toDiffStep(s, opType) as any, index: startIndex + i, applied })),
});
/** 数据源 tab 复用通用 BucketTab固定注入数据源的 title/prefix/describe/isStepDiffable。 */
/** 数据源 tab 复用通用 BucketTab固定注入数据源的 configtitle/prefix/describe/isStepDiffable。 */
const mountDataSourceTab = (props: { buckets: any[]; expanded: Record<string, boolean> }) =>
mount(BucketTab, {
props: {
title: '数据源',
prefix: 'ds',
describeGroup: describeDataSourceGroup,
describeStep: describeDataSourceStep,
isStepDiffable: (step: DataSourceStepValue) => Boolean(step.oldSchema && step.newSchema),
config: {
title: '数据源',
prefix: 'ds',
describeGroup: describeDataSourceGroup,
describeStep: describeDataSourceStep,
isStepDiffable: (step: DataSourceStepValue) => Boolean(step.diff?.[0]?.oldSchema && step.diff?.[0]?.newSchema),
},
...props,
},
});

View File

@ -6,22 +6,30 @@
import { describe, expect, test } from 'vitest';
import { mount } from '@vue/test-utils';
import type { HistoryRowGroup, HistoryRowStep } from '@editor/layouts/history-list/composables';
import GroupRow from '@editor/layouts/history-list/GroupRow.vue';
const baseProps = {
groupKey: 'pg-0',
/** 构造 GroupRow 的视图模型merged / stepCount 由 subSteps 长度派生)。 */
const makeGroup = (overrides: Partial<HistoryRowGroup> = {}): HistoryRowGroup => ({
key: 'pg-0',
applied: true,
merged: false,
opType: 'update' as const,
isCurrent: false,
opType: 'update',
desc: '修改 按钮',
stepCount: 1,
subSteps: [] as { index: number; applied: boolean; desc: string }[],
expanded: false,
};
subSteps: [],
...overrides,
});
/** 构造单个子步,缺省值贴近真实派生结果。 */
const makeStep = (overrides: Partial<HistoryRowStep> & Pick<HistoryRowStep, 'index'>): HistoryRowStep => ({
applied: true,
desc: '',
...overrides,
});
describe('GroupRow.vue', () => {
test('渲染描述与操作类型徽标update→修改', () => {
const wrapper = mount(GroupRow, { props: baseProps });
const wrapper = mount(GroupRow, { props: { group: makeGroup(), expanded: false } });
expect(wrapper.find('.m-editor-history-list-item-desc').text()).toBe('修改 按钮');
const op = wrapper.find('.m-editor-history-list-item-op');
expect(op.text()).toBe('修改');
@ -29,36 +37,41 @@ describe('GroupRow.vue', () => {
});
test('add / remove 操作徽标使用对应类名与文案', () => {
const w1 = mount(GroupRow, { props: { ...baseProps, opType: 'add' } });
const w1 = mount(GroupRow, { props: { group: makeGroup({ opType: 'add' }), expanded: false } });
expect(w1.find('.m-editor-history-list-item-op').text()).toBe('新增');
expect(w1.find('.m-editor-history-list-item-op').classes()).toContain('op-add');
const w2 = mount(GroupRow, { props: { ...baseProps, opType: 'remove' } });
const w2 = mount(GroupRow, { props: { group: makeGroup({ opType: 'remove' }), expanded: false } });
expect(w2.find('.m-editor-history-list-item-op').text()).toBe('删除');
expect(w2.find('.m-editor-history-list-item-op').classes()).toContain('op-remove');
});
test('applied=false 时附加 is-undone 类名', () => {
const wrapper = mount(GroupRow, { props: { ...baseProps, applied: false } });
const wrapper = mount(GroupRow, { props: { group: makeGroup({ applied: false }), expanded: false } });
expect(wrapper.find('.m-editor-history-list-group').classes()).toContain('is-undone');
});
test('merged=true 时显示「合并 N 步」并附 is-merged 类名', () => {
test('merged(子步数>1时显示「合并 N 步」并附 is-merged 类名', () => {
const wrapper = mount(GroupRow, {
props: { ...baseProps, merged: true, stepCount: 3 },
props: {
group: makeGroup({
subSteps: [makeStep({ index: 0 }), makeStep({ index: 1 }), makeStep({ index: 2 })],
}),
expanded: false,
},
});
expect(wrapper.find('.m-editor-history-list-group').classes()).toContain('is-merged');
expect(wrapper.find('.m-editor-history-list-item-merge').text()).toBe('合并 3 步');
});
test('未合并时不渲染合并标记', () => {
const wrapper = mount(GroupRow, { props: baseProps });
const wrapper = mount(GroupRow, { props: { group: makeGroup(), expanded: false } });
expect(wrapper.find('.m-editor-history-list-item-merge').exists()).toBe(false);
});
test('传入 time 时头部渲染时间title 取 timeTitle', () => {
const wrapper = mount(GroupRow, {
props: { ...baseProps, time: '12:00:00', timeTitle: '2026-06-03 12:00:00' },
props: { group: makeGroup({ time: '12:00:00', timeTitle: '2026-06-03 12:00:00' }), expanded: false },
});
const time = wrapper.find('.m-editor-history-list-item-time');
expect(time.exists()).toBe(true);
@ -67,26 +80,25 @@ describe('GroupRow.vue', () => {
});
test('未传 time 时头部不渲染时间元素', () => {
const wrapper = mount(GroupRow, { props: baseProps });
const wrapper = mount(GroupRow, { props: { group: makeGroup(), expanded: false } });
expect(wrapper.find('.m-editor-history-list-item-time').exists()).toBe(false);
});
test('timeTitle 缺省时 title 回退为 time 本身', () => {
const wrapper = mount(GroupRow, { props: { ...baseProps, time: '08:30:00' } });
const wrapper = mount(GroupRow, { props: { group: makeGroup({ time: '08:30:00' }), expanded: false } });
expect(wrapper.find('.m-editor-history-list-item-time').attributes('title')).toBe('08:30:00');
});
test('展开的子步各自渲染自己的时间', () => {
const wrapper = mount(GroupRow, {
props: {
...baseProps,
merged: true,
stepCount: 2,
group: makeGroup({
subSteps: [
makeStep({ index: 0, desc: '修改 颜色', time: '10:00:00', timeTitle: '2026-06-03 10:00:00' }),
makeStep({ index: 1, desc: '修改 字号', time: '10:01:00', timeTitle: '2026-06-03 10:01:00' }),
],
}),
expanded: true,
subSteps: [
{ index: 0, applied: true, desc: '修改 颜色', time: '10:00:00', timeTitle: '2026-06-03 10:00:00' },
{ index: 1, applied: true, desc: '修改 字号', time: '10:01:00', timeTitle: '2026-06-03 10:01:00' },
],
},
});
const items = wrapper.findAll('.m-editor-history-list-substeps li');
@ -95,17 +107,16 @@ describe('GroupRow.vue', () => {
expect(items[1].find('.m-editor-history-list-item-time').text()).toBe('10:00:00');
});
test('merged=true 且 expanded=true 时渲染子步列表', () => {
test('merged 且 expanded=true 时渲染子步列表', () => {
const wrapper = mount(GroupRow, {
props: {
...baseProps,
merged: true,
stepCount: 2,
group: makeGroup({
subSteps: [
makeStep({ index: 0, applied: true, desc: '修改 颜色' }),
makeStep({ index: 1, applied: false, desc: '修改 字号' }),
],
}),
expanded: true,
subSteps: [
{ index: 0, applied: true, desc: '修改 颜色' },
{ index: 1, applied: false, desc: '修改 字号' },
],
},
});
const items = wrapper.findAll('.m-editor-history-list-substeps li');
@ -119,21 +130,23 @@ describe('GroupRow.vue', () => {
expect(items[1].text()).toContain('修改 颜色');
});
test('merged=true 但 expanded=false 时不渲染子步列表', () => {
test('merged 但 expanded=false 时不渲染子步列表', () => {
const wrapper = mount(GroupRow, {
props: {
...baseProps,
merged: true,
stepCount: 2,
group: makeGroup({ subSteps: [makeStep({ index: 0, desc: 'x' }), makeStep({ index: 1, desc: 'y' })] }),
expanded: false,
subSteps: [{ index: 0, applied: true, desc: 'x' }],
},
});
expect(wrapper.find('.m-editor-history-list-substeps').exists()).toBe(false);
});
test('点击合并组头部触发 toggle 事件并携带 groupKey', async () => {
const wrapper = mount(GroupRow, { props: { ...baseProps, merged: true, stepCount: 2 } });
test('点击合并组头部触发 toggle 事件并携带 group.key', async () => {
const wrapper = mount(GroupRow, {
props: {
group: makeGroup({ subSteps: [makeStep({ index: 0 }), makeStep({ index: 1 })] }),
expanded: false,
},
});
await wrapper.find('.m-editor-history-list-group-head').trigger('click');
const events = wrapper.emitted('toggle');
expect(events).toBeTruthy();
@ -145,9 +158,8 @@ describe('GroupRow.vue', () => {
test('点击单步组(非合并)的「回到」按钮触发 goto携带该唯一 step 的 index', async () => {
const wrapper = mount(GroupRow, {
props: {
...baseProps,
merged: false,
subSteps: [{ index: 7, applied: true, desc: 'a' }],
group: makeGroup({ subSteps: [makeStep({ index: 7, applied: true, desc: 'a' })] }),
expanded: false,
},
});
// 点击头部本身不再触发 goto整行不可点击
@ -164,10 +176,8 @@ describe('GroupRow.vue', () => {
test('当前单步组isCurrent=true点击头部不触发 goto', async () => {
const wrapper = mount(GroupRow, {
props: {
...baseProps,
merged: false,
isCurrent: true,
subSteps: [{ index: 0, applied: true, desc: 'x' }],
group: makeGroup({ isCurrent: true, subSteps: [makeStep({ index: 0, desc: 'x' })] }),
expanded: false,
},
});
await wrapper.find('.m-editor-history-list-group-head').trigger('click');
@ -177,14 +187,11 @@ describe('GroupRow.vue', () => {
test('当前合并组isCurrent=true点击头部仍能 toggle', async () => {
const wrapper = mount(GroupRow, {
props: {
...baseProps,
merged: true,
stepCount: 2,
isCurrent: true,
subSteps: [
{ index: 0, applied: true, desc: 'a' },
{ index: 1, applied: true, desc: 'b', isCurrent: true },
],
group: makeGroup({
isCurrent: true,
subSteps: [makeStep({ index: 0, desc: 'a' }), makeStep({ index: 1, desc: 'b', isCurrent: true })],
}),
expanded: false,
},
});
await wrapper.find('.m-editor-history-list-group-head').trigger('click');
@ -192,17 +199,16 @@ describe('GroupRow.vue', () => {
expect(wrapper.emitted('goto')).toBeFalsy();
});
test('点击子步「回退」按钮触发 goto 携带该子步 index当前子步无回退按钮', async () => {
test('点击子步「回到」按钮触发 goto 携带该子步 index当前子步无回到按钮', async () => {
const wrapper = mount(GroupRow, {
props: {
...baseProps,
merged: true,
stepCount: 2,
group: makeGroup({
subSteps: [
makeStep({ index: 0, applied: true, desc: 'a', isCurrent: true }),
makeStep({ index: 1, applied: false, desc: 'b' }),
],
}),
expanded: true,
subSteps: [
{ index: 0, applied: true, desc: 'a', isCurrent: true },
{ index: 1, applied: false, desc: 'b' },
],
},
});
// 子步倒序渲染subItems[0] 为 index=1非当前含跳转按钮subItems[1] 为 index=0当前无跳转按钮

View File

@ -105,7 +105,7 @@ describe('HistoryListPanel.vue', () => {
historyService.changePage({ id: 'p1' } as any);
historyService.push({
opType: 'add',
nodes: [{ id: 'n1', name: 'A' }],
diff: [{ newSchema: { id: 'n1', name: 'A' } }],
modifiedNodeIds: new Map(),
} as any);
historyService.pushDataSource('ds_1', {
@ -136,10 +136,10 @@ describe('HistoryListPanel.vue', () => {
const mkUpdate = (path: string) => ({
opType: 'update',
modifiedNodeIds: new Map(),
updatedItems: [
diff: [
{
newNode: { id: 'btn', name: '按钮' },
oldNode: { id: 'btn', name: '按钮' },
newSchema: { id: 'btn', name: '按钮' },
oldSchema: { id: 'btn', name: '按钮' },
changeRecords: [{ propPath: path }],
},
],
@ -169,12 +169,12 @@ describe('HistoryListPanel.vue', () => {
historyService.changePage({ id: 'p1' } as any);
historyService.push({
opType: 'add',
nodes: [{ id: 'n1', name: 'A' }],
diff: [{ newSchema: { id: 'n1', name: 'A' } }],
modifiedNodeIds: new Map(),
} as any);
historyService.push({
opType: 'add',
nodes: [{ id: 'n2', name: 'B' }],
diff: [{ newSchema: { id: 'n2', name: 'B' } }],
modifiedNodeIds: new Map(),
} as any);
@ -240,7 +240,7 @@ describe('HistoryListPanel.vue', () => {
historyService.changePage({ id: 'p1' } as any);
historyService.push({
opType: 'add',
nodes: [{ id: 'n1', name: 'A' }],
diff: [{ newSchema: { id: 'n1', name: 'A' } }],
modifiedNodeIds: new Map(),
} as any);

View File

@ -46,16 +46,16 @@ describe('PageTab.vue', () => {
test('list 非空:每个 group 渲染一行', () => {
const list = [
buildPageGroup('add', [{ opType: 'add', nodes: [{ id: 'n1', name: 'A' }] }]),
buildPageGroup('add', [{ opType: 'add', diff: [{ newSchema: { id: 'n1', name: 'A' } }] }]),
buildPageGroup(
'update',
[
{
opType: 'update',
updatedItems: [
diff: [
{
newNode: { id: 'btn', name: '按钮' },
oldNode: { id: 'btn' },
newSchema: { id: 'btn', name: '按钮' },
oldSchema: { id: 'btn' },
changeRecords: [{ propPath: 'style.color' }],
},
],
@ -78,7 +78,9 @@ describe('PageTab.vue', () => {
});
test('step 含 timestamp 时渲染时间元素', () => {
const list = [buildPageGroup('add', [{ opType: 'add', nodes: [{ id: 'n1', name: 'A' }], timestamp: Date.now() }])];
const list = [
buildPageGroup('add', [{ opType: 'add', diff: [{ newSchema: { id: 'n1', name: 'A' } }], timestamp: Date.now() }]),
];
const wrapper = mount(PageTab, { props: { list, expanded: {} } });
const time = wrapper.find('.m-editor-history-list-item-time');
expect(time.exists()).toBe(true);
@ -87,7 +89,7 @@ describe('PageTab.vue', () => {
});
test('step 无 timestamp 时不渲染时间元素', () => {
const list = [buildPageGroup('add', [{ opType: 'add', nodes: [{ id: 'n1', name: 'A' }] }])];
const list = [buildPageGroup('add', [{ opType: 'add', diff: [{ newSchema: { id: 'n1', name: 'A' } }] }])];
const wrapper = mount(PageTab, { props: { list, expanded: {} } });
expect(wrapper.find('.m-editor-history-list-item-time').exists()).toBe(false);
});
@ -98,20 +100,20 @@ describe('PageTab.vue', () => {
[
{
opType: 'update',
updatedItems: [
diff: [
{
newNode: { id: 'btn', name: '按钮' },
oldNode: { id: 'btn' },
newSchema: { id: 'btn', name: '按钮' },
oldSchema: { id: 'btn' },
changeRecords: [{ propPath: 'a' }],
},
],
},
{
opType: 'update',
updatedItems: [
diff: [
{
newNode: { id: 'btn', name: '按钮' },
oldNode: { id: 'btn' },
newSchema: { id: 'btn', name: '按钮' },
oldSchema: { id: 'btn' },
changeRecords: [{ propPath: 'b' }],
},
],
@ -138,11 +140,11 @@ describe('PageTab.vue', () => {
[
{
opType: 'update',
updatedItems: [{ newNode: { id: 'btn' }, oldNode: { id: 'btn' }, changeRecords: [{ propPath: 'a' }] }],
diff: [{ newSchema: { id: 'btn' }, oldSchema: { id: 'btn' }, changeRecords: [{ propPath: 'a' }] }],
},
{
opType: 'update',
updatedItems: [{ newNode: { id: 'btn' }, oldNode: { id: 'btn' }, changeRecords: [{ propPath: 'b' }] }],
diff: [{ newSchema: { id: 'btn' }, oldSchema: { id: 'btn' }, changeRecords: [{ propPath: 'b' }] }],
},
],
true,
@ -154,11 +156,11 @@ describe('PageTab.vue', () => {
[
{
opType: 'update',
updatedItems: [{ newNode: { id: 'btn2' }, oldNode: { id: 'btn2' }, changeRecords: [{ propPath: 'a' }] }],
diff: [{ newSchema: { id: 'btn2' }, oldSchema: { id: 'btn2' }, changeRecords: [{ propPath: 'a' }] }],
},
{
opType: 'update',
updatedItems: [{ newNode: { id: 'btn2' }, oldNode: { id: 'btn2' }, changeRecords: [{ propPath: 'b' }] }],
diff: [{ newSchema: { id: 'btn2' }, oldSchema: { id: 'btn2' }, changeRecords: [{ propPath: 'b' }] }],
},
],
true,
@ -178,7 +180,7 @@ describe('PageTab.vue', () => {
});
test('点击单步组「回到」按钮透传 goto 事件,携带该 step 的 index', async () => {
const list = [buildPageGroup('add', [{ opType: 'add', nodes: [{ id: 'n1', name: 'A' }] }])];
const list = [buildPageGroup('add', [{ opType: 'add', diff: [{ newSchema: { id: 'n1', name: 'A' } }] }])];
const wrapper = mount(PageTab, { props: { list, expanded: {} } });
await wrapper.find('.m-editor-history-list-item-goto').trigger('click');
expect(wrapper.emitted('goto')).toBeTruthy();
@ -187,7 +189,7 @@ describe('PageTab.vue', () => {
});
test('已撤销组applied=false附 is-undone 类名', () => {
const list = [buildPageGroup('add', [{ opType: 'add', nodes: [{ id: 'n1', name: 'A' }] }], false)];
const list = [buildPageGroup('add', [{ opType: 'add', diff: [{ newSchema: { id: 'n1', name: 'A' } }] }], false)];
const wrapper = mount(PageTab, { props: { list, expanded: {} } });
expect(wrapper.find('.m-editor-history-list-group').classes()).toContain('is-undone');
});
@ -198,13 +200,13 @@ describe('PageTab.vue', () => {
expect(empty.find('.m-editor-history-list-initial').exists()).toBe(false);
// 非空 list底部应有一条初始项
const list = [buildPageGroup('add', [{ opType: 'add', nodes: [{ id: 'n1', name: 'A' }] }])];
const list = [buildPageGroup('add', [{ opType: 'add', diff: [{ newSchema: { id: 'n1', name: 'A' } }] }])];
const wrapper = mount(PageTab, { props: { list, expanded: {} } });
expect(wrapper.find('.m-editor-history-list-initial').exists()).toBe(true);
});
test('全部 group 都未 applied 时初始项标记为当前', () => {
const list = [buildPageGroup('add', [{ opType: 'add', nodes: [{ id: 'n1', name: 'A' }] }], false)];
const list = [buildPageGroup('add', [{ opType: 'add', diff: [{ newSchema: { id: 'n1', name: 'A' } }] }], false)];
const wrapper = mount(PageTab, { props: { list, expanded: {} } });
const initial = wrapper.find('.m-editor-history-list-initial');
expect(initial.classes()).toContain('is-current');
@ -212,8 +214,8 @@ describe('PageTab.vue', () => {
test('存在已 applied 的 group 时初始项不为当前', () => {
const list = [
buildPageGroup('add', [{ opType: 'add', nodes: [{ id: 'n1', name: 'A' }] }], true),
buildPageGroup('add', [{ opType: 'add', nodes: [{ id: 'n2', name: 'B' }] }], false),
buildPageGroup('add', [{ opType: 'add', diff: [{ newSchema: { id: 'n1', name: 'A' } }] }], true),
buildPageGroup('add', [{ opType: 'add', diff: [{ newSchema: { id: 'n2', name: 'B' } }] }], false),
];
const wrapper = mount(PageTab, { props: { list, expanded: {} } });
const initial = wrapper.find('.m-editor-history-list-initial');
@ -221,7 +223,7 @@ describe('PageTab.vue', () => {
});
test('点击非当前初始项的「回到」按钮透传 goto-initial 事件', async () => {
const list = [buildPageGroup('add', [{ opType: 'add', nodes: [{ id: 'n1', name: 'A' }] }], true)];
const list = [buildPageGroup('add', [{ opType: 'add', diff: [{ newSchema: { id: 'n1', name: 'A' } }] }], true)];
const wrapper = mount(PageTab, { props: { list, expanded: {} } });
await wrapper.find('.m-editor-history-list-initial .m-editor-history-list-item-goto').trigger('click');
expect(wrapper.emitted('goto-initial')).toBeTruthy();

View File

@ -17,6 +17,9 @@ import {
formatHistoryFullTime,
formatHistoryTime,
groupTimestamp,
isCodeBlockStepRevertable,
isDataSourceStepRevertable,
isPageStepRevertable,
opLabel,
useHistoryList,
} from '@editor/layouts/history-list/composables';
@ -106,7 +109,7 @@ describe('describePageStep', () => {
test('add 单个节点:含名称与 id', () => {
const step = {
opType: 'add',
nodes: [{ id: 'btn_1', type: 'button', name: '主按钮' }],
diff: [{ newSchema: { id: 'btn_1', type: 'button', name: '主按钮' } }],
} as unknown as StepValue;
expect(describePageStep(step)).toBe('新增 1 个节点(主按钮 (id: btn_1)');
});
@ -114,7 +117,7 @@ describe('describePageStep', () => {
test('add 节点无 name 但有 type使用 type 作为名称', () => {
const step = {
opType: 'add',
nodes: [{ id: 'n1', type: 'text' }],
diff: [{ newSchema: { id: 'n1', type: 'text' } }],
} as unknown as StepValue;
expect(describePageStep(step)).toBe('新增 1 个节点text (id: n1)');
});
@ -122,7 +125,7 @@ describe('describePageStep', () => {
test('add 节点 name 与 id 相同:仅显示 id', () => {
const step = {
opType: 'add',
nodes: [{ id: 'n1', name: 'n1' }],
diff: [{ newSchema: { id: 'n1', name: 'n1' } }],
} as unknown as StepValue;
expect(describePageStep(step)).toBe('新增 1 个节点n1');
});
@ -130,7 +133,7 @@ describe('describePageStep', () => {
test('add 多个节点:仅给出数量', () => {
const step = {
opType: 'add',
nodes: [{ id: 'a' }, { id: 'b' }],
diff: [{ newSchema: { id: 'a' } }, { newSchema: { id: 'b' } }],
} as unknown as StepValue;
expect(describePageStep(step)).toBe('新增 2 个节点');
});
@ -143,7 +146,7 @@ describe('describePageStep', () => {
test('remove 单个节点:含名称与 id', () => {
const step = {
opType: 'remove',
removedItems: [{ node: { id: 'btn_1', name: '主按钮' } }],
diff: [{ oldSchema: { id: 'btn_1', name: '主按钮' } }],
} as unknown as StepValue;
expect(describePageStep(step)).toBe('删除 1 个节点(主按钮 (id: btn_1)');
});
@ -151,7 +154,7 @@ describe('describePageStep', () => {
test('remove 多个节点', () => {
const step = {
opType: 'remove',
removedItems: [{ node: { id: 'a' } }, { node: { id: 'b' } }],
diff: [{ oldSchema: { id: 'a' } }, { oldSchema: { id: 'b' } }],
} as unknown as StepValue;
expect(describePageStep(step)).toBe('删除 2 个节点');
});
@ -159,10 +162,10 @@ describe('describePageStep', () => {
test('update 单节点:附 propPath 与 id', () => {
const step = {
opType: 'update',
updatedItems: [
diff: [
{
newNode: { id: 'btn_1', name: '按钮' },
oldNode: { id: 'btn_1', name: '按钮' },
newSchema: { id: 'btn_1', name: '按钮' },
oldSchema: { id: 'btn_1', name: '按钮' },
changeRecords: [{ propPath: 'style.color' }],
},
],
@ -173,7 +176,7 @@ describe('describePageStep', () => {
test('update 单节点无 propPath仅展示节点', () => {
const step = {
opType: 'update',
updatedItems: [{ newNode: { id: 'btn_1', name: '按钮' }, oldNode: { id: 'btn_1' } }],
diff: [{ newSchema: { id: 'btn_1', name: '按钮' }, oldSchema: { id: 'btn_1' } }],
} as unknown as StepValue;
expect(describePageStep(step)).toBe('修改 按钮 (id: btn_1)');
});
@ -181,15 +184,15 @@ describe('describePageStep', () => {
test('update 多节点:返回数量', () => {
const step = {
opType: 'update',
updatedItems: [
{ newNode: { id: 'a' }, oldNode: { id: 'a' } },
{ newNode: { id: 'b' }, oldNode: { id: 'b' } },
diff: [
{ newSchema: { id: 'a' }, oldSchema: { id: 'a' } },
{ newSchema: { id: 'b' }, oldSchema: { id: 'b' } },
],
} as unknown as StepValue;
expect(describePageStep(step)).toBe('修改 2 个节点');
});
test('update updatedItems 缺省:兜底为「修改节点」', () => {
test('update diff 缺省:兜底为「修改节点」', () => {
const step = { opType: 'update' } as unknown as StepValue;
expect(describePageStep(step)).toBe('修改节点');
});
@ -216,7 +219,7 @@ describe('describePageGroup', () => {
test('单步 group 复用 describePageStep', () => {
const step = {
opType: 'update',
updatedItems: [{ newNode: { id: 'a', name: 'A' }, oldNode: { id: 'a' } }],
diff: [{ newSchema: { id: 'a', name: 'A' }, oldSchema: { id: 'a' } }],
} as unknown as StepValue;
const group: PageHistoryGroup = {
kind: 'page',
@ -234,10 +237,10 @@ describe('describePageGroup', () => {
const mkStep = (path: string) =>
({
opType: 'update',
updatedItems: [
diff: [
{
newNode: { id: 'btn_1', name: '按钮' },
oldNode: { id: 'btn_1', name: '按钮' },
newSchema: { id: 'btn_1', name: '按钮' },
oldSchema: { id: 'btn_1', name: '按钮' },
changeRecords: [{ propPath: path }],
},
],
@ -259,10 +262,10 @@ describe('describePageGroup', () => {
const mkStep = (path: string) =>
({
opType: 'update',
updatedItems: [
diff: [
{
newNode: { id: 'btn_1', name: '按钮' },
oldNode: { id: 'btn_1' },
newSchema: { id: 'btn_1', name: '按钮' },
oldSchema: { id: 'btn_1' },
changeRecords: [{ propPath: path }],
},
],
@ -291,7 +294,7 @@ describe('describePageGroup', () => {
const mkStep = () =>
({
opType: 'update',
updatedItems: [{ newNode: { id: 'btn_1', name: '按钮' }, oldNode: { id: 'btn_1' } }],
diff: [{ newSchema: { id: 'btn_1', name: '按钮' }, oldSchema: { id: 'btn_1' } }],
}) as unknown as StepValue;
const group: PageHistoryGroup = {
@ -314,8 +317,8 @@ describe('describePageGroup', () => {
targetId: 'btn_1',
applied: true,
steps: [
buildPageEntry({ opType: 'update', updatedItems: [] } as any, 0),
buildPageEntry({ opType: 'update', updatedItems: [] } as any, 1),
buildPageEntry({ opType: 'update', diff: [] } as any, 0),
buildPageEntry({ opType: 'update', diff: [] } as any, 1),
],
};
// targetName 为 undefinedlabelWithId 看 label === id 时只展示 id
@ -325,61 +328,72 @@ describe('describePageGroup', () => {
describe('describeDataSourceStep', () => {
test('historyDescription 优先', () => {
const step: DataSourceStepValue = {
const step = {
id: 'ds_1',
oldSchema: null,
newSchema: null,
opType: 'update',
diff: [{}],
historyDescription: '自定义',
};
} as unknown as DataSourceStepValue;
expect(describeDataSourceStep(step)).toBe('自定义');
});
test('新增oldSchema=null展示 title 与 id', () => {
const step: DataSourceStepValue = {
const step = {
id: 'ds_1',
oldSchema: null,
newSchema: { id: 'ds_1', title: '用户列表' } as any,
};
opType: 'add',
diff: [{ newSchema: { id: 'ds_1', title: '用户列表' } }],
} as unknown as DataSourceStepValue;
expect(describeDataSourceStep(step)).toBe('创建 用户列表 (id: ds_1)');
});
test('删除newSchema=null展示 title 与 id', () => {
const step: DataSourceStepValue = {
const step = {
id: 'ds_1',
oldSchema: { id: 'ds_1', title: '用户列表' } as any,
newSchema: null,
};
opType: 'remove',
diff: [{ oldSchema: { id: 'ds_1', title: '用户列表' } }],
} as unknown as DataSourceStepValue;
expect(describeDataSourceStep(step)).toBe('删除 用户列表 (id: ds_1)');
});
test('修改:展示 propPath', () => {
const step: DataSourceStepValue = {
const step = {
id: 'ds_1',
oldSchema: { id: 'ds_1', title: '用户列表' } as any,
newSchema: { id: 'ds_1', title: '用户列表' } as any,
changeRecords: [{ propPath: 'fields.0.name' } as any],
};
opType: 'update',
diff: [
{
oldSchema: { id: 'ds_1', title: '用户列表' },
newSchema: { id: 'ds_1', title: '用户列表' },
changeRecords: [{ propPath: 'fields.0.name' }],
},
],
} as unknown as DataSourceStepValue;
expect(describeDataSourceStep(step)).toBe('修改 用户列表 (id: ds_1) · fields.0.name');
});
test('修改无 title 时仅展示 id', () => {
const step: DataSourceStepValue = {
const step = {
id: 'ds_1',
oldSchema: { id: 'ds_1' } as any,
newSchema: { id: 'ds_1' } as any,
};
opType: 'update',
diff: [{ oldSchema: { id: 'ds_1' }, newSchema: { id: 'ds_1' } }],
} as unknown as DataSourceStepValue;
expect(describeDataSourceStep(step)).toBe('修改 ds_1');
});
});
describe('describeDataSourceGroup', () => {
test('多步组:聚合 propPath 与目标 id', () => {
const mkStep = (path: string): DataSourceStepValue => ({
id: 'ds_1',
oldSchema: { id: 'ds_1', title: 'T' } as any,
newSchema: { id: 'ds_1', title: 'T' } as any,
changeRecords: [{ propPath: path } as any],
});
const mkStep = (path: string) =>
({
id: 'ds_1',
opType: 'update',
diff: [
{
oldSchema: { id: 'ds_1', title: 'T' },
newSchema: { id: 'ds_1', title: 'T' },
changeRecords: [{ propPath: path }],
},
],
}) as unknown as DataSourceStepValue;
const group: DataSourceHistoryGroup = {
kind: 'data-source',
id: 'ds_1',
@ -401,7 +415,11 @@ describe('describeDataSourceGroup', () => {
applied: true,
steps: [
{
step: { id: 'ds_1', oldSchema: null, newSchema: { id: 'ds_1', title: 'T' } as any },
step: {
id: 'ds_1',
opType: 'add',
diff: [{ newSchema: { id: 'ds_1', title: 'T' } }],
} as unknown as DataSourceStepValue,
index: 0,
applied: true,
},
@ -420,10 +438,10 @@ describe('describeDataSourceGroup', () => {
{
step: {
id: 'ds_1',
oldSchema: null,
newSchema: null,
opType: 'update',
diff: [{}],
historyDescription: '我的描述',
},
} as unknown as DataSourceStepValue,
index: 0,
applied: true,
},
@ -435,52 +453,63 @@ describe('describeDataSourceGroup', () => {
describe('describeCodeBlockStep', () => {
test('新增', () => {
const step: CodeBlockStepValue = {
const step = {
id: 'code_1',
oldContent: null,
newContent: { id: 'code_1', name: 'onClick' } as any,
};
opType: 'add',
diff: [{ newSchema: { id: 'code_1', name: 'onClick' } }],
} as unknown as CodeBlockStepValue;
expect(describeCodeBlockStep(step)).toBe('创建 onClick (id: code_1)');
});
test('删除', () => {
const step: CodeBlockStepValue = {
const step = {
id: 'code_1',
oldContent: { id: 'code_1', name: 'onClick' } as any,
newContent: null,
};
opType: 'remove',
diff: [{ oldSchema: { id: 'code_1', name: 'onClick' } }],
} as unknown as CodeBlockStepValue;
expect(describeCodeBlockStep(step)).toBe('删除 onClick (id: code_1)');
});
test('修改 + propPath', () => {
const step: CodeBlockStepValue = {
const step = {
id: 'code_1',
oldContent: { id: 'code_1', name: 'onClick' } as any,
newContent: { id: 'code_1', name: 'onClick' } as any,
changeRecords: [{ propPath: 'content' } as any],
};
opType: 'update',
diff: [
{
oldSchema: { id: 'code_1', name: 'onClick' },
newSchema: { id: 'code_1', name: 'onClick' },
changeRecords: [{ propPath: 'content' }],
},
],
} as unknown as CodeBlockStepValue;
expect(describeCodeBlockStep(step)).toBe('修改 onClick (id: code_1) · content');
});
test('historyDescription 优先', () => {
const step: CodeBlockStepValue = {
const step = {
id: 'code_1',
oldContent: null,
newContent: null,
opType: 'update',
diff: [{}],
historyDescription: '自定义说明',
};
} as unknown as CodeBlockStepValue;
expect(describeCodeBlockStep(step)).toBe('自定义说明');
});
});
describe('describeCodeBlockGroup', () => {
test('多步组:聚合 propPath', () => {
const mkStep = (path: string): CodeBlockStepValue => ({
id: 'code_1',
oldContent: { id: 'code_1', name: 'fn' } as any,
newContent: { id: 'code_1', name: 'fn' } as any,
changeRecords: [{ propPath: path } as any],
});
const mkStep = (path: string) =>
({
id: 'code_1',
opType: 'update',
diff: [
{
oldSchema: { id: 'code_1', name: 'fn' },
newSchema: { id: 'code_1', name: 'fn' },
changeRecords: [{ propPath: path }],
},
],
}) as unknown as CodeBlockStepValue;
const group: CodeBlockHistoryGroup = {
kind: 'code-block',
id: 'code_1',
@ -502,7 +531,11 @@ describe('describeCodeBlockGroup', () => {
applied: false,
steps: [
{
step: { id: 'code_1', oldContent: { id: 'code_1', name: 'fn' } as any, newContent: null },
step: {
id: 'code_1',
opType: 'remove',
diff: [{ oldSchema: { id: 'code_1', name: 'fn' } }],
} as unknown as CodeBlockStepValue,
index: 0,
applied: false,
},
@ -547,12 +580,12 @@ describe('useHistoryList', () => {
historyService.changePage({ id: 'p1' } as any);
historyService.push({
opType: 'add',
nodes: [{ id: 'n1', name: 'A' }],
diff: [{ newSchema: { id: 'n1', name: 'A' } }],
modifiedNodeIds: new Map(),
} as any);
historyService.push({
opType: 'remove',
removedItems: [{ node: { id: 'n2', name: 'B' } }],
diff: [{ oldSchema: { id: 'n2', name: 'B' } }],
modifiedNodeIds: new Map(),
} as any);
@ -607,3 +640,80 @@ describe('useHistoryList', () => {
expect(buckets.map((b) => b.id).sort()).toEqual(['code_1', 'code_2']);
});
});
describe('isPageStepRevertable', () => {
test('add / remove 始终可回滚', () => {
expect(isPageStepRevertable({ opType: 'add', diff: [{ newSchema: { id: 'n1' } }] } as any)).toBe(true);
expect(isPageStepRevertable({ opType: 'remove', diff: [{ oldSchema: { id: 'n1' } }] } as any)).toBe(true);
});
test('update 每项都有 changeRecords 才可回滚', () => {
expect(
isPageStepRevertable({
opType: 'update',
diff: [{ oldSchema: { id: 'n1' }, newSchema: { id: 'n1' }, changeRecords: [{ propPath: 'style.color' }] }],
} as any),
).toBe(true);
});
test('update 缺少 changeRecords 不可回滚', () => {
expect(
isPageStepRevertable({
opType: 'update',
diff: [{ oldSchema: { id: 'n1' }, newSchema: { id: 'n1' } }],
} as any),
).toBe(false);
});
test('update 多项中任一缺少 changeRecords 不可回滚', () => {
expect(
isPageStepRevertable({
opType: 'update',
diff: [
{ oldSchema: { id: 'n1' }, newSchema: { id: 'n1' }, changeRecords: [{ propPath: 'a' }] },
{ oldSchema: { id: 'n2' }, newSchema: { id: 'n2' } },
],
} as any),
).toBe(false);
});
test('update 无 diff 不可回滚', () => {
expect(isPageStepRevertable({ opType: 'update' } as any)).toBe(false);
});
});
describe('isDataSourceStepRevertable', () => {
test('新增 / 删除 始终可回滚', () => {
expect(isDataSourceStepRevertable({ diff: [{ newSchema: { id: 'ds_1' } }] } as any)).toBe(true);
expect(isDataSourceStepRevertable({ diff: [{ oldSchema: { id: 'ds_1' } }] } as any)).toBe(true);
});
test('更新有 changeRecords 才可回滚', () => {
expect(
isDataSourceStepRevertable({
diff: [{ oldSchema: { id: 'ds_1' }, newSchema: { id: 'ds_1' }, changeRecords: [{ propPath: 'title' }] }],
} as any),
).toBe(true);
expect(
isDataSourceStepRevertable({ diff: [{ oldSchema: { id: 'ds_1' }, newSchema: { id: 'ds_1' } }] } as any),
).toBe(false);
});
});
describe('isCodeBlockStepRevertable', () => {
test('新增 / 删除 始终可回滚', () => {
expect(isCodeBlockStepRevertable({ diff: [{ newSchema: { id: 'code_1' } }] } as any)).toBe(true);
expect(isCodeBlockStepRevertable({ diff: [{ oldSchema: { id: 'code_1' } }] } as any)).toBe(true);
});
test('更新有 changeRecords 才可回滚', () => {
expect(
isCodeBlockStepRevertable({
diff: [{ oldSchema: { id: 'code_1' }, newSchema: { id: 'code_1' }, changeRecords: [{ propPath: 'content' }] }],
} as any),
).toBe(true);
expect(
isCodeBlockStepRevertable({ diff: [{ oldSchema: { id: 'code_1' }, newSchema: { id: 'code_1' } }] } as any),
).toBe(false);
});
});

View File

@ -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 () => {

View File

@ -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 () => {

View File

@ -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();
});

View File

@ -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('复制按钮: 未选中时不触发', () => {

View File

@ -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',
});
});
});

View File

@ -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' });
});
});

View File

@ -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);

View File

@ -176,8 +176,8 @@ describe('CodeBlockService - 历史记录接入', () => {
expect(historyService.canUndoCodeBlock('new_code')).toBe(true);
const step = historyService.undoCodeBlock('new_code');
expect(step?.oldContent).toBeNull();
expect(step?.newContent).toEqual(expect.objectContaining({ name: 'A' }));
expect(step?.diff?.[0]?.oldSchema).toBeUndefined();
expect(step?.diff?.[0]?.newSchema).toEqual(expect.objectContaining({ name: 'A' }));
});
test('setCodeDslByIdSync - 更新时入历史oldContent / newContent 都非空)', async () => {
@ -185,8 +185,8 @@ describe('CodeBlockService - 历史记录接入', () => {
codeBlockService.setCodeDslByIdSync('a', { name: 'A2' } as any);
const step = historyService.undoCodeBlock('a');
expect(step?.oldContent).toEqual({ name: 'A' });
expect(step?.newContent).toEqual(expect.objectContaining({ name: 'A2' }));
expect(step?.diff?.[0]?.oldSchema).toEqual({ name: 'A' });
expect(step?.diff?.[0]?.newSchema).toEqual(expect.objectContaining({ name: 'A2' }));
});
test('setCodeDslByIdSync - force=false 已存在时不入历史', async () => {
@ -200,8 +200,8 @@ describe('CodeBlockService - 历史记录接入', () => {
await codeBlockService.deleteCodeDslByIds(['a']);
const step = historyService.undoCodeBlock('a');
expect(step?.oldContent).toEqual({ name: 'A' });
expect(step?.newContent).toBeNull();
expect(step?.diff?.[0]?.oldSchema).toEqual({ name: 'A' });
expect(step?.diff?.[0]?.newSchema).toBeUndefined();
});
test('deleteCodeDslByIds - 删除不存在的 id 不入历史', async () => {
@ -218,7 +218,7 @@ describe('CodeBlockService - 历史记录接入', () => {
});
const step = historyService.undoCodeBlock('a');
expect(step?.changeRecords).toEqual([{ propPath: 'name', value: 'A2' }]);
expect(step?.diff?.[0]?.changeRecords).toEqual([{ propPath: 'name', value: 'A2' }]);
});
test('setCodeDslByIdSync - 不传 changeRecords 时 step.changeRecords 为 undefined', async () => {
@ -227,7 +227,99 @@ describe('CodeBlockService - 历史记录接入', () => {
codeBlockService.setCodeDslByIdSync('a', { name: 'A2' } as any);
const step = historyService.undoCodeBlock('a');
expect(step?.changeRecords).toBeUndefined();
expect(step?.diff?.[0]?.changeRecords).toBeUndefined();
});
});
describe('CodeBlockService - *AndGetHistoryId', () => {
const lastStepUuid = (id: string) => {
const list = historyService.getCodeBlockStepList(id);
return list[list.length - 1]?.step.uuid;
};
test('setCodeDslByIdSyncAndGetHistoryId 返回本次写入历史记录的 uuid', async () => {
await codeBlockService.setCodeDsl({} as any);
const historyId = codeBlockService.setCodeDslByIdSyncAndGetHistoryId('a', { name: 'A' } as any);
expect(typeof historyId).toBe('string');
expect(historyId).toBe(lastStepUuid('a'));
// 与默认行为一致:内容仍被写入
expect(codeBlockService.getCodeContentById('a')?.name).toBe('A');
});
test('setCodeDslByIdSyncAndGetHistoryId - force=false 已存在时返回 null', async () => {
await codeBlockService.setCodeDsl({ a: { name: 'A' } } as any);
const historyId = codeBlockService.setCodeDslByIdSyncAndGetHistoryId('a', { name: 'NEW' } as any, false);
expect(historyId).toBeNull();
});
test('setCodeDslByIdSyncAndGetHistoryId - doNotPushHistory 时返回 null', async () => {
await codeBlockService.setCodeDsl({} as any);
const historyId = codeBlockService.setCodeDslByIdSyncAndGetHistoryId('a', { name: 'A' } as any, true, {
doNotPushHistory: true,
});
expect(historyId).toBeNull();
});
test('setCodeDslByIdAndGetHistoryIdasync返回本次写入历史记录的 uuid', async () => {
await codeBlockService.setCodeDsl({} as any);
const historyId = await codeBlockService.setCodeDslByIdAndGetHistoryId('a', { name: 'A' } as any);
expect(typeof historyId).toBe('string');
expect(historyId).toBe(lastStepUuid('a'));
});
test('deleteCodeDslByIdsAndGetHistoryId 返回每条删除记录的 uuid 数组', async () => {
await codeBlockService.setCodeDsl({ a: { name: 'A' }, b: { name: 'B' } } as any);
const historyIds = await codeBlockService.deleteCodeDslByIdsAndGetHistoryId(['a', 'b']);
expect(Array.isArray(historyIds)).toBe(true);
expect(historyIds).toHaveLength(2);
expect(historyIds[0]).toBe(lastStepUuid('a'));
expect(historyIds[1]).toBe(lastStepUuid('b'));
});
test('deleteCodeDslByIdsAndGetHistoryId - 不存在的 id 不计入返回数组', async () => {
await codeBlockService.setCodeDsl({ a: { name: 'A' } } as any);
const historyIds = await codeBlockService.deleteCodeDslByIdsAndGetHistoryId(['a', 'ghost']);
expect(historyIds).toHaveLength(1);
expect(historyIds[0]).toBe(lastStepUuid('a'));
});
test('deleteCodeDslByIdsAndGetHistoryId - 全部不存在时返回空数组', async () => {
await codeBlockService.setCodeDsl({} as any);
const historyIds = await codeBlockService.deleteCodeDslByIdsAndGetHistoryId(['ghost']);
expect(historyIds).toEqual([]);
});
});
describe('CodeBlockService - revertById', () => {
test('通过 uuid 回滚新增(删除代码块内容)', async () => {
await codeBlockService.setCodeDsl({} as any);
const uuid = codeBlockService.setCodeDslByIdSyncAndGetHistoryId('a', { name: 'A' } as any);
expect(typeof uuid).toBe('string');
expect(codeBlockService.getCodeContentById('a')?.name).toBe('A');
const reverted = await codeBlockService.revertById(uuid!);
expect(reverted).not.toBeNull();
expect(codeBlockService.getCodeContentById('a')).toBeNull();
});
test('按 uuid 能定位到对应 (id, index)', async () => {
await codeBlockService.setCodeDsl({} as any);
const uuid = codeBlockService.setCodeDslByIdSyncAndGetHistoryId('a', { name: 'A' } as any);
const location = historyService.findCodeBlockStepLocationByUuid(uuid!);
expect(location).toEqual({ id: 'a', index: 0 });
});
test('找不到 uuid 时返回 null', async () => {
await codeBlockService.setCodeDsl({} as any);
codeBlockService.setCodeDslByIdSyncAndGetHistoryId('a', { name: 'A' } as any);
await expect(codeBlockService.revertById('not-exist')).resolves.toBeNull();
await expect(codeBlockService.revertById('')).resolves.toBeNull();
});
});

View File

@ -135,8 +135,8 @@ describe('DataSource service - 历史记录接入', () => {
const ds = dataSource.add({ title: 'a', type: 'base' } as any);
expect(historyService.canUndoDataSource(ds.id!)).toBe(true);
const step = historyService.undoDataSource(ds.id!);
expect(step?.oldSchema).toBeNull();
expect(step?.newSchema?.title).toBe('a');
expect(step?.diff?.[0]?.oldSchema).toBeUndefined();
expect(step?.diff?.[0]?.newSchema?.title).toBe('a');
});
test('update - 入历史oldSchema 是旧值newSchema 是新值', () => {
@ -146,8 +146,8 @@ describe('DataSource service - 历史记录接入', () => {
dataSource.update({ ...created, title: 'b' } as any);
const step = historyService.undoDataSource(created.id!);
expect(step?.oldSchema?.title).toBe('a');
expect(step?.newSchema?.title).toBe('b');
expect(step?.diff?.[0]?.oldSchema?.title).toBe('a');
expect(step?.diff?.[0]?.newSchema?.title).toBe('b');
});
test('remove - 入历史newSchema=null', () => {
@ -156,8 +156,8 @@ describe('DataSource service - 历史记录接入', () => {
dataSource.remove(created.id!);
const step = historyService.undoDataSource(created.id!);
expect(step?.oldSchema?.title).toBe('a');
expect(step?.newSchema).toBeNull();
expect(step?.diff?.[0]?.oldSchema?.title).toBe('a');
expect(step?.diff?.[0]?.newSchema).toBeUndefined();
});
test('remove - 不存在的 id 不入历史', () => {
@ -174,7 +174,7 @@ describe('DataSource service - 历史记录接入', () => {
});
const step = historyService.undoDataSource(created.id!);
expect(step?.changeRecords).toEqual([{ propPath: 'title', value: 'b' }]);
expect(step?.diff?.[0]?.changeRecords).toEqual([{ propPath: 'title', value: 'b' }]);
});
test('update - 不传 changeRecords 时 step.changeRecords 为 undefined', () => {
@ -183,7 +183,80 @@ describe('DataSource service - 历史记录接入', () => {
dataSource.update({ ...created, title: 'b' } as any);
const step = historyService.undoDataSource(created.id!);
expect(step?.changeRecords).toBeUndefined();
expect(step?.diff?.[0]?.changeRecords).toBeUndefined();
});
});
describe('DataSource service - *AndGetHistoryId', () => {
const lastStepUuid = (id: string) => {
const list = historyService.getDataSourceStepList(id);
return list[list.length - 1]?.step.uuid;
};
test('addAndGetHistoryId 返回本次写入历史记录的 uuid', () => {
const ds = dataSource.add({ id: 'temp', title: 'a', type: 'base' } as any);
historyService.reset();
const historyId = dataSource.addAndGetHistoryId({ id: 'ds_new', title: 'a', type: 'base' } as any);
expect(typeof historyId).toBe('string');
expect(historyId).toBe(lastStepUuid('ds_new'));
// 与默认 add 行为一致:仍会写入数据源
expect(dataSource.getDataSourceById('ds_new')).toBeDefined();
expect(ds).toBeDefined();
});
test('addAndGetHistoryId 传 doNotPushHistory 时返回 null', () => {
const historyId = dataSource.addAndGetHistoryId({ id: 'ds_x', title: 'a', type: 'base' } as any, {
doNotPushHistory: true,
});
expect(historyId).toBeNull();
});
test('updateAndGetHistoryId 返回本次写入历史记录的 uuid', () => {
const created = dataSource.add({ title: 'a', type: 'base' } as any);
historyService.reset();
const historyId = dataSource.updateAndGetHistoryId({ ...created, title: 'b' } as any);
expect(typeof historyId).toBe('string');
expect(historyId).toBe(lastStepUuid(created.id!));
});
test('removeAndGetHistoryId 返回本次写入历史记录的 uuid不存在的 id 返回 null', () => {
const created = dataSource.add({ title: 'a', type: 'base' } as any);
historyService.reset();
const historyId = dataSource.removeAndGetHistoryId(created.id!);
expect(typeof historyId).toBe('string');
expect(historyId).toBe(lastStepUuid(created.id!));
expect(dataSource.removeAndGetHistoryId('ghost')).toBeNull();
});
});
describe('DataSource service - revertById', () => {
test('通过 uuid 回滚 add移除数据源', () => {
const created = dataSource.add({ title: 'a', type: 'base' } as any);
const uuid = historyService.getDataSourceStepList(created.id!).slice(-1)[0]?.step.uuid;
expect(typeof uuid).toBe('string');
expect(dataSource.getDataSourceById(created.id!)).toBeDefined();
const reverted = dataSource.revertById(uuid!);
expect(reverted).not.toBeNull();
expect(dataSource.getDataSourceById(created.id!)).toBeUndefined();
});
test('通过 uuid 回滚等价于按 (id, index) 回滚', () => {
const created = dataSource.add({ title: 'a', type: 'base' } as any);
const uuid = historyService.getDataSourceStepList(created.id!).slice(-1)[0]?.step.uuid;
const location = historyService.findDataSourceStepLocationByUuid(uuid!);
expect(location).toEqual({ id: created.id, index: 0 });
});
test('找不到 uuid 时返回 null', () => {
dataSource.add({ title: 'a', type: 'base' } as any);
expect(dataSource.revertById('not-exist')).toBeNull();
expect(dataSource.revertById('')).toBeNull();
});
});

View File

@ -190,6 +190,93 @@ describe('getParentById', () => {
});
});
describe('getNodeInfo 当前页面优先 / 跨页面回退', () => {
// 两个页面page2 内含一个容器及其子节点,用于覆盖「优先当前页面、回退跳过当前页面」逻辑
const PAGE2_ID = 20;
const NODE_IN_PAGE2 = 21;
const CONTAINER_IN_PAGE2 = 22;
const CHILD_IN_PAGE2 = 23;
const multiPageRoot: MApp = {
id: NodeId.ROOT_ID,
type: NodeType.ROOT,
items: [
cloneDeep(root.items[0]),
{
id: PAGE2_ID,
type: NodeType.PAGE,
layout: 'absolute',
style: { width: 375 },
items: [
{ id: NODE_IN_PAGE2, type: 'text', style: {} },
{
id: CONTAINER_IN_PAGE2,
type: 'container',
style: {},
items: [{ id: CHILD_IN_PAGE2, type: 'text', style: {} }],
},
],
},
],
};
beforeAll(async () => {
editorService.set('root', cloneDeep(multiPageRoot));
// 当前停留在 page1
await editorService.select(NodeId.PAGE_ID);
});
test('id 为 root.id 时返回 root 自身parent / page 为 null', () => {
const info = editorService.getNodeInfo(NodeId.ROOT_ID);
expect(info.node?.id).toBe(NodeId.ROOT_ID);
expect(info.parent).toBeNull();
expect(info.page).toBeNull();
});
test('当前页面节点本身node 为页面、parent 为 root、page 为页面自身', () => {
const info = editorService.getNodeInfo(NodeId.PAGE_ID);
expect(info.node?.id).toBe(NodeId.PAGE_ID);
expect(info.parent?.id).toBe(NodeId.ROOT_ID);
expect(info.page?.id).toBe(NodeId.PAGE_ID);
});
test('命中当前页面内的节点(快速路径)', () => {
const info = editorService.getNodeInfo(NodeId.NODE_ID);
expect(info.node?.id).toBe(NodeId.NODE_ID);
expect(info.parent?.id).toBe(NodeId.PAGE_ID);
expect(info.page?.id).toBe(NodeId.PAGE_ID);
});
test('命中非当前页面内的深层节点回退跳过当前页面parent / page 正确', () => {
const info = editorService.getNodeInfo(CHILD_IN_PAGE2);
expect(info.node?.id).toBe(CHILD_IN_PAGE2);
expect(info.parent?.id).toBe(CONTAINER_IN_PAGE2);
expect(info.page?.id).toBe(PAGE2_ID);
});
test('非当前页面的页面节点parent 为真实 root同一引用可安全 mutate', () => {
const info = editorService.getNodeInfo(PAGE2_ID, false);
expect(info.node?.id).toBe(PAGE2_ID);
expect(info.page?.id).toBe(PAGE2_ID);
// parent 必须是真实 root 引用,而非临时副本,否则对页面增删/排序会改不到真实树
expect(info.parent).toBe(editorService.get('root'));
});
test('不存在的节点返回空 info', () => {
const info = editorService.getNodeInfo(NodeId.ERROR_NODE_ID);
expect(info.node).toBeNull();
expect(info.page).toBeNull();
});
test('未选中任何页面时仍能跨页面查找到节点', () => {
editorService.set('root', cloneDeep(multiPageRoot));
editorService.set('page', null);
const info = editorService.getNodeInfo(CHILD_IN_PAGE2);
expect(info.node?.id).toBe(CHILD_IN_PAGE2);
expect(info.parent?.id).toBe(CONTAINER_IN_PAGE2);
expect(info.page?.id).toBe(PAGE2_ID);
});
});
describe('isOnDifferentPage', () => {
test('当前未选中任何页面时返回 false', () => {
editorService.set('root', cloneDeep(root));
@ -711,3 +798,99 @@ describe('undo redo', () => {
expect(editorService.getNodeById(NodeId.NODE_ID)?.style?.width).toBe(270);
});
});
describe('*AndGetHistoryId', () => {
const lastStepUuid = () => {
const list = historyService.getPageStepList();
return list[list.length - 1]?.step.uuid;
};
test('addAndGetHistoryId 返回本次写入历史记录的 uuid且与栈顶 step 一致', async () => {
editorService.set('root', cloneDeep(root));
historyService.reset();
await editorService.select(NodeId.PAGE_ID);
const historyId = await editorService.addAndGetHistoryId({ type: 'text' });
expect(typeof historyId).toBe('string');
expect(historyId).toBeTruthy();
expect(historyId).toBe(lastStepUuid());
});
test('addAndGetHistoryId 传 doNotPushHistory 时返回 null', async () => {
editorService.set('root', cloneDeep(root));
historyService.reset();
await editorService.select(NodeId.PAGE_ID);
const historyId = await editorService.addAndGetHistoryId({ type: 'text' }, null, { doNotPushHistory: true });
expect(historyId).toBeNull();
});
test('updateAndGetHistoryId 返回本次写入历史记录的 uuid', async () => {
editorService.set('root', cloneDeep(root));
historyService.reset();
await editorService.select(NodeId.PAGE_ID);
const historyId = await editorService.updateAndGetHistoryId({ id: NodeId.NODE_ID, type: 'text', text: 'x' });
expect(typeof historyId).toBe('string');
expect(historyId).toBe(lastStepUuid());
});
test('removeAndGetHistoryId 返回本次写入历史记录的 uuid', async () => {
editorService.set('root', cloneDeep(root));
historyService.reset();
await editorService.select(NodeId.PAGE_ID);
const historyId = await editorService.removeAndGetHistoryId({ id: NodeId.NODE_ID, type: 'text' });
expect(typeof historyId).toBe('string');
expect(historyId).toBe(lastStepUuid());
});
test('moveLayerAndGetHistoryId 返回本次写入历史记录的 uuid', async () => {
editorService.set('root', cloneDeep(root));
historyService.reset();
await editorService.select(NodeId.NODE_ID);
const historyId = await editorService.moveLayerAndGetHistoryId(1);
expect(typeof historyId).toBe('string');
expect(historyId).toBe(lastStepUuid());
});
});
describe('revertPageStepById', () => {
test('通过 uuid 回滚 add 步骤(删除被新增节点)', async () => {
editorService.set('root', cloneDeep(root));
historyService.reset();
await editorService.select(NodeId.PAGE_ID);
const uuid = await editorService.addAndGetHistoryId({ type: 'text' });
expect(typeof uuid).toBe('string');
const addedStep = historyService.getPageStepList().find((e) => e.step.uuid === uuid)!.step;
const addedId = addedStep.diff[0].newSchema!.id;
expect(editorService.getNodeById(addedId)).toBeTruthy();
const reverted = await editorService.revertPageStepById(uuid!);
expect(reverted).not.toBeNull();
// 回滚git revert 语义)会把被新增的节点删掉
expect(editorService.getNodeById(addedId)).toBeNull();
});
test('与按 index 回滚结果一致', async () => {
editorService.set('root', cloneDeep(root));
historyService.reset();
await editorService.select(NodeId.PAGE_ID);
const uuid = await editorService.addAndGetHistoryId({ type: 'text' });
const index = historyService.getPageStepIndexByUuid(uuid!);
expect(index).toBeGreaterThanOrEqual(0);
});
test('找不到 uuid 时返回 null', async () => {
editorService.set('root', cloneDeep(root));
historyService.reset();
await editorService.select(NodeId.PAGE_ID);
expect(await editorService.revertPageStepById('not-exist')).toBeNull();
expect(await editorService.revertPageStepById('')).toBeNull();
});
});

View File

@ -0,0 +1,240 @@
/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2025 Tencent. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest';
import history from '@editor/services/history';
import { setEditorConfig } from '@editor/utils/config';
import * as indexedDb from '@editor/utils/indexed-db';
// 用内存实现 mock 掉 IndexedDB 读写工具,避免依赖真实 IndexedDBhappy-dom 不提供)。
vi.mock('@editor/utils/indexed-db', () => {
const store = new Map<string, any>();
const k = (db: string, s: string, key: any) => `${db}__${s}__${String(key)}`;
return {
isIndexedDBSupported: () => true,
openIndexedDB: vi.fn(),
idbSet: vi.fn(async (db: string, s: string, key: any, value: any) => {
store.set(k(db, s, key), value);
}),
idbGet: vi.fn(async (db: string, s: string, key: any) => store.get(k(db, s, key))),
idbDelete: vi.fn(async (db: string, s: string, key: any) => {
store.delete(k(db, s, key));
}),
__store: store,
};
});
beforeAll(() => {
// restoreFromIndexedDB 通过 parseDSL 还原序列化字符串(默认实现即 eval
// eslint-disable-next-line no-eval
setEditorConfig({ parseDSL: (dsl: string) => eval(dsl) } as any);
});
beforeEach(() => {
(indexedDb as any).__store.clear();
});
afterEach(() => {
history.reset();
});
const pageStep = (id = 'p1') => ({ data: { id, name: '' }, modifiedNodeIds: new Map() }) as any;
describe('history service - markSaved', () => {
test('markSaved 标记页面 / 代码块 / 数据源各栈的当前记录', () => {
history.changePage({ id: 'p1' } as any);
history.push(pageStep());
history.pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
history.pushDataSource('ds_1', { oldSchema: null, newSchema: { id: 'ds_1' } as any });
history.markSaved();
expect((history.state.pageSteps as any).p1.getCurrentElement().saved).toBe(true);
expect((history.state.codeBlockState as any).code_1.getCurrentElement().saved).toBe(true);
expect((history.state.dataSourceState as any).ds_1.getCurrentElement().saved).toBe(true);
});
test('markSaved 派发 mark-saved 事件并带 kind=all', () => {
const fn = vi.fn();
history.on('mark-saved', fn);
history.markSaved();
expect(fn).toHaveBeenCalledWith({ kind: 'all' });
history.off('mark-saved', fn);
});
test('同一栈最多保留一条 saved再次标记会清除旧标记', () => {
history.changePage({ id: 'p1' } as any);
history.push(pageStep());
history.markPageSaved();
history.push(pageStep());
history.markPageSaved();
const list = (history.state.pageSteps as any).p1.getElementList();
expect(list.filter((s: any) => s.saved)).toHaveLength(1);
// 最新一条才是 saved
expect(list[list.length - 1].saved).toBe(true);
expect(list[0].saved).toBeFalsy();
});
test('markPageSaved / markCodeBlockSaved / markDataSourceSaved 仅影响对应栈', () => {
history.changePage({ id: 'p1' } as any);
history.push(pageStep());
history.pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
history.markCodeBlockSaved('code_1');
expect((history.state.codeBlockState as any).code_1.getCurrentElement().saved).toBe(true);
// 页面栈未被标记
expect((history.state.pageSteps as any).p1.getCurrentElement().saved).toBeFalsy();
});
});
describe('history service - clear', () => {
test('clearPage 清空当前页面历史并复位 canUndo/canRedo', () => {
history.changePage({ id: 'p1' } as any);
history.push(pageStep());
history.push(pageStep());
expect(history.state.canUndo).toBe(true);
history.clearPage();
expect((history.state.pageSteps as any).p1.getLength()).toBe(0);
expect(history.state.canUndo).toBe(false);
expect(history.state.canRedo).toBe(false);
});
test('clearCodeBlock 传 id 清单个,缺省清全部', () => {
history.pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
history.pushCodeBlock('code_2', { oldContent: null, newContent: { name: 'B' } as any });
history.clearCodeBlock('code_1');
expect((history.state.codeBlockState as any).code_1).toBeUndefined();
expect((history.state.codeBlockState as any).code_2).toBeDefined();
history.clearCodeBlock();
expect(Object.keys(history.state.codeBlockState)).toHaveLength(0);
});
test('clearDataSource 传 id 清单个,缺省清全部', () => {
history.pushDataSource('ds_1', { oldSchema: null, newSchema: { id: 'ds_1' } as any });
history.pushDataSource('ds_2', { oldSchema: null, newSchema: { id: 'ds_2' } as any });
history.clearDataSource('ds_1');
expect((history.state.dataSourceState as any).ds_1).toBeUndefined();
expect((history.state.dataSourceState as any).ds_2).toBeDefined();
history.clearDataSource();
expect(Object.keys(history.state.dataSourceState)).toHaveLength(0);
});
});
describe('history service - IndexedDB 持久化', () => {
test('saveToIndexedDB 以序列化字符串写入并返回快照对象', async () => {
history.changePage({ id: 'p1' } as any);
history.push(pageStep());
const snapshot = await history.saveToIndexedDB();
expect(snapshot.version).toBe(1);
expect(snapshot.pageId).toBe('p1');
// 实际写入 IndexedDB 的是字符串serialize-javascript 结果)
expect(indexedDb.idbSet).toHaveBeenCalled();
const written = (indexedDb.idbSet as any).mock.calls[0][3];
expect(typeof written).toBe('string');
});
test('restoreFromIndexedDB 还原页面 / 代码块 / 数据源全部栈与游标', async () => {
history.changePage({ id: 'p1' } as any);
history.push(pageStep());
history.push(pageStep());
history.undo(); // page cursor = 1
history.pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
history.pushDataSource('ds_1', { oldSchema: null, newSchema: { id: 'ds_1' } as any });
await history.saveToIndexedDB();
history.reset();
expect(Object.keys(history.state.pageSteps)).toHaveLength(0);
const restored = await history.restoreFromIndexedDB();
expect(restored).not.toBeNull();
expect(history.state.pageId).toBe('p1');
expect(history.getPageCursor('p1')).toBe(1);
expect((history.state.codeBlockState as any).code_1).toBeDefined();
expect((history.state.dataSourceState as any).ds_1).toBeDefined();
});
test('restoreFromIndexedDB 把游标恢复到最近一个已保存记录', async () => {
history.changePage({ id: 'p1' } as any);
history.push(pageStep());
history.push(pageStep());
history.markPageSaved(); // 标记 index 1cursor=2
history.push(pageStep()); // cursor=3saved 仍在 index 1
await history.saveToIndexedDB();
history.reset();
await history.restoreFromIndexedDB();
// 恢复后游标定位到已保存记录之后index 1 -> cursor 2
expect(history.getPageCursor('p1')).toBe(2);
});
test('restoreFromIndexedDB 能还原内容中的函数serialize + parseDSL 往返)', async () => {
history.pushCodeBlock('code_1', {
oldContent: null,
newContent: {
name: 'A',
code() {
return 42;
},
} as any,
});
await history.saveToIndexedDB();
history.reset();
await history.restoreFromIndexedDB();
const current = (history.state.codeBlockState as any).code_1.getCurrentElement();
expect(typeof current.diff[0].newSchema.code).toBe('function');
expect(current.diff[0].newSchema.code()).toBe(42);
});
test('restoreFromIndexedDB 找不到记录时返回 null 且不改动当前状态', async () => {
history.changePage({ id: 'p1' } as any);
history.push(pageStep());
const restored = await history.restoreFromIndexedDB();
expect(restored).toBeNull();
// 当前状态保持不变
expect((history.state.pageSteps as any).p1.getLength()).toBe(1);
});
test('saveToIndexedDB 派发 save-to-indexed-db、restoreFromIndexedDB 派发 restore-from-indexed-db', async () => {
const saveFn = vi.fn();
const restoreFn = vi.fn();
history.on('save-to-indexed-db', saveFn);
history.on('restore-from-indexed-db', restoreFn);
history.changePage({ id: 'p1' } as any);
history.push(pageStep());
await history.saveToIndexedDB();
await history.restoreFromIndexedDB();
expect(saveFn).toHaveBeenCalledTimes(1);
expect(restoreFn).toHaveBeenCalledTimes(1);
history.off('save-to-indexed-db', saveFn);
history.off('restore-from-indexed-db', restoreFn);
});
});

View File

@ -103,6 +103,32 @@ describe('history service', () => {
} as any);
expect(step?.timestamp).toBe(123456);
});
test('push 未带 uuid 时自动生成 uuid', () => {
history.changePage({ id: 'p1' } as any);
const step = history.push({ data: { id: 'p1', name: '' }, modifiedNodeIds: new Map() } as any);
expect(typeof step?.uuid).toBe('string');
expect(step?.uuid).toBeTruthy();
});
test('push 已带 uuid 时保留调用方指定的值', () => {
history.changePage({ id: 'p1' } as any);
const step = history.push({
uuid: 'my-uuid',
data: { id: 'p1', name: '' },
modifiedNodeIds: new Map(),
} as any);
expect(step?.uuid).toBe('my-uuid');
});
test('push 为每条记录生成不同的 uuid', () => {
history.changePage({ id: 'p1' } as any);
const s1 = history.push({ data: { id: 'p1', name: '' }, modifiedNodeIds: new Map() } as any);
const s2 = history.push({ data: { id: 'p1', name: '' }, modifiedNodeIds: new Map() } as any);
expect(s1?.uuid).toBeTruthy();
expect(s2?.uuid).toBeTruthy();
expect(s1?.uuid).not.toBe(s2?.uuid);
});
});
describe('history service - codeBlock', () => {
@ -117,8 +143,8 @@ describe('history service - codeBlock', () => {
expect(step).not.toBeNull();
expect(step?.id).toBe('code_1');
expect(step?.oldContent).toBeNull();
expect(step?.newContent).toEqual({ name: 'A', content: 'x' });
expect(step?.diff?.[0]?.oldSchema).toBeUndefined();
expect(step?.diff?.[0]?.newSchema).toEqual({ name: 'A', content: 'x' });
expect((history.state.codeBlockState as any).code_1).toBeDefined();
expect(history.canUndoCodeBlock('code_1')).toBe(true);
expect(fn).toHaveBeenCalledWith('code_1', expect.objectContaining({ id: 'code_1' }));
@ -138,6 +164,12 @@ describe('history service - codeBlock', () => {
expect(step?.timestamp).toBeLessThanOrEqual(after);
});
test('pushCodeBlock 自动生成 uuid', () => {
const step = history.pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
expect(typeof step?.uuid).toBe('string');
expect(step?.uuid).toBeTruthy();
});
test('undoCodeBlock / redoCodeBlock 走对应 id 的 UndoRedo 栈', () => {
history.pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
history.pushCodeBlock('code_1', {
@ -147,11 +179,11 @@ describe('history service - codeBlock', () => {
expect(history.canUndoCodeBlock('code_1')).toBe(true);
const undone = history.undoCodeBlock('code_1');
expect(undone?.newContent).toEqual({ name: 'B' });
expect(undone?.diff?.[0]?.newSchema).toEqual({ name: 'B' });
expect(history.canRedoCodeBlock('code_1')).toBe(true);
const redone = history.redoCodeBlock('code_1');
expect(redone?.newContent).toEqual({ name: 'B' });
expect(redone?.diff?.[0]?.newSchema).toEqual({ name: 'B' });
});
test('undoCodeBlock 对不存在 id 返回 null', () => {
@ -197,8 +229,8 @@ describe('history service - dataSource', () => {
expect(step).not.toBeNull();
expect(step?.id).toBe('ds_1');
expect(step?.oldSchema).toBeNull();
expect(step?.newSchema?.title).toBe('A');
expect(step?.diff?.[0]?.oldSchema).toBeUndefined();
expect(step?.diff?.[0]?.newSchema?.title).toBe('A');
expect((history.state.dataSourceState as any).ds_1).toBeDefined();
expect(history.canUndoDataSource('ds_1')).toBe(true);
expect(fn).toHaveBeenCalledWith('ds_1', expect.objectContaining({ id: 'ds_1' }));
@ -218,6 +250,12 @@ describe('history service - dataSource', () => {
expect(step?.timestamp).toBeLessThanOrEqual(after);
});
test('pushDataSource 自动生成 uuid', () => {
const step = history.pushDataSource('ds_1', { oldSchema: null, newSchema: { id: 'ds_1' } as any });
expect(typeof step?.uuid).toBe('string');
expect(step?.uuid).toBeTruthy();
});
test('undoDataSource / redoDataSource 走对应 id 的 UndoRedo 栈', () => {
history.pushDataSource('ds_1', {
oldSchema: null,
@ -229,10 +267,10 @@ describe('history service - dataSource', () => {
});
const undone = history.undoDataSource('ds_1');
expect(undone?.newSchema?.title).toBe('B');
expect(undone?.diff?.[0]?.newSchema?.title).toBe('B');
const redone = history.redoDataSource('ds_1');
expect(redone?.newSchema?.title).toBe('B');
expect(redone?.diff?.[0]?.newSchema?.title).toBe('B');
});
test('undoDataSource 对不存在 id 返回 null', () => {

View File

@ -114,9 +114,13 @@ describe('content-menu utils', () => {
toJSON: () => ({}),
});
const menu = ref<any>({ $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 行为校验', () => {

View File

@ -161,3 +161,120 @@ describe('list max size', () => {
expect(small.canUndo()).toBe(false);
});
});
describe('updateCurrentElement / updateElements', () => {
test('updateCurrentElement 就地更新当前游标元素', () => {
const undoRedo = new UndoRedo();
undoRedo.pushElement({ a: 1 });
undoRedo.pushElement({ a: 2 });
undoRedo.updateCurrentElement((el: any) => {
el.saved = true;
});
expect(undoRedo.getCurrentElement()).toEqual({ a: 2, saved: true });
// 撤销后当前元素是更早的那条,不应被标记
expect(undoRedo.undo()).toEqual({ a: 2, saved: true });
expect(undoRedo.getCurrentElement()).toEqual({ a: 1 });
});
test('updateCurrentElement 在 cursor 为 0 时不做任何操作', () => {
const undoRedo = new UndoRedo();
undoRedo.pushElement({ a: 1 });
undoRedo.undo();
let called = false;
undoRedo.updateCurrentElement(() => {
called = true;
});
expect(called).toBe(false);
});
test('updateElements 遍历就地更新全部元素', () => {
const undoRedo = new UndoRedo();
undoRedo.pushElement({ a: 1, saved: true });
undoRedo.pushElement({ a: 2 });
undoRedo.updateElements((el: any) => {
el.saved = false;
});
const list = undoRedo.getElementList() as any[];
expect(list.every((el) => el.saved === false)).toBe(true);
});
});
describe('serialize / fromSerialized', () => {
test('serialize 导出快照并 fromSerialized 完整还原(含游标)', () => {
const undoRedo = new UndoRedo(50);
undoRedo.pushElement({ a: 1 });
undoRedo.pushElement({ a: 2 });
undoRedo.pushElement({ a: 3 });
undoRedo.undo(); // cursor = 2
const data = undoRedo.serialize();
expect(data.elementList).toHaveLength(3);
expect(data.listCursor).toBe(2);
expect(data.listMaxSize).toBe(50);
const restored = UndoRedo.fromSerialized(data);
expect(restored.getCursor()).toBe(2);
expect(restored.getLength()).toBe(3);
expect(restored.getCurrentElement()).toEqual({ a: 2 });
expect(restored.canRedo()).toBe(true);
expect(restored.redo()).toEqual({ a: 3 });
});
test('serialize 为深克隆,修改原栈不影响快照', () => {
const undoRedo = new UndoRedo();
const el: any = { a: 1 };
undoRedo.pushElement(el);
const data = undoRedo.serialize();
undoRedo.updateCurrentElement((cur: any) => {
cur.a = 999;
});
expect((data.elementList[0] as any).a).toBe(1);
});
test('fromSerialized 超出 listMaxSize 时裁掉最旧记录并回退游标', () => {
const restored = UndoRedo.fromSerialized({
elementList: [{ a: 1 }, { a: 2 }, { a: 3 }, { a: 4 }],
listCursor: 4,
listMaxSize: 2,
});
expect(restored.getLength()).toBe(2);
// 保留最近两条cursor 同步回退到 2
expect(restored.getElementList()).toEqual([{ a: 3 }, { a: 4 }]);
expect(restored.getCursor()).toBe(2);
});
test('fromSerialized 游标越界时夹紧到 [0, length]', () => {
const restored = UndoRedo.fromSerialized({
elementList: [{ a: 1 }, { a: 2 }],
listCursor: 99,
listMaxSize: 100,
});
expect(restored.getCursor()).toBe(2);
});
test('fromSerialized isSavedStep 把游标定位到最近一条已保存记录之后', () => {
const restored = UndoRedo.fromSerialized<{ a: number; saved?: boolean }>(
{
elementList: [{ a: 1 }, { a: 2, saved: true }, { a: 3 }, { a: 4 }],
listCursor: 4,
listMaxSize: 100,
},
{ isSavedStep: (el) => el.saved === true },
);
// 最近一条已保存记录在 index 1游标应为 2
expect(restored.getCursor()).toBe(2);
expect(restored.getCurrentElement()).toEqual({ a: 2, saved: true });
});
test('fromSerialized isSavedStep 无匹配时退回原游标', () => {
const restored = UndoRedo.fromSerialized<{ a: number; saved?: boolean }>(
{
elementList: [{ a: 1 }, { a: 2 }],
listCursor: 1,
listMaxSize: 100,
},
{ isSavedStep: (el) => el.saved === true },
);
expect(restored.getCursor()).toBe(1);
});
});

View File

@ -1,5 +1,5 @@
{
"version": "1.8.0-beta.3",
"version": "1.8.0-beta.4",
"name": "@tmagic/element-plus-adapter",
"type": "module",
"sideEffects": false,

View File

@ -1,5 +1,5 @@
{
"version": "1.8.0-beta.3",
"version": "1.8.0-beta.4",
"name": "@tmagic/form-schema",
"type": "module",
"sideEffects": false,

View File

@ -1,5 +1,5 @@
{
"version": "1.8.0-beta.3",
"version": "1.8.0-beta.4",
"name": "@tmagic/form",
"type": "module",
"sideEffects": [

View File

@ -1,5 +1,5 @@
{
"version": "1.8.0-beta.3",
"version": "1.8.0-beta.4",
"name": "@tmagic/schema",
"type": "module",
"sideEffects": false,

View File

@ -1,5 +1,5 @@
{
"version": "1.8.0-beta.3",
"version": "1.8.0-beta.4",
"name": "@tmagic/stage",
"type": "module",
"sideEffects": false,

View File

@ -1,5 +1,5 @@
{
"version": "1.8.0-beta.3",
"version": "1.8.0-beta.4",
"name": "@tmagic/table",
"type": "module",
"sideEffects": [

View File

@ -1,5 +1,5 @@
{
"version": "1.8.0-beta.3",
"version": "1.8.0-beta.4",
"name": "@tmagic/tdesign-vue-next-adapter",
"type": "module",
"sideEffects": false,

View File

@ -1,5 +1,5 @@
{
"version": "1.8.0-beta.3",
"version": "1.8.0-beta.4",
"name": "@tmagic/utils",
"type": "module",
"sideEffects": false,

View File

@ -23,7 +23,6 @@ import type {
DataSchema,
DataSourceDeps,
Id,
MApp,
MComponent,
MContainer,
MNode,
@ -80,10 +79,12 @@ export const emptyFn = (): any => undefined;
* @param {Array} data
* @return {Array} data中的子孙路径
*/
export const getNodePath = (id: Id, data: MNode[] = []): MNode[] => {
export const getNodePath = (id: Id, data: MNode[] = [], skip?: MNode | null): MNode[] => {
const path: MNode[] = [];
// 目标 id 字符串只计算一次,避免在递归遍历中对每个节点重复拼接
const targetId = `${id}`;
const get = function (id: number | string, data: MNode[]): MNode | null {
const get = function (data: MNode[]): MNode | null {
if (!Array.isArray(data)) {
return null;
}
@ -92,12 +93,13 @@ export const getNodePath = (id: Id, data: MNode[] = []): MNode[] => {
const item = data[i];
path.push(item);
if (`${item.id}` === `${id}`) {
if (`${item.id}` === targetId) {
return item;
}
if (item.items) {
const node = get(id, item.items);
// skip 用于跳过已经搜索过的子树(如已优先搜索过的当前页面),避免重复遍历
if (item.items && item !== skip) {
const node = get(item.items);
if (node) {
return node;
}
@ -109,12 +111,12 @@ export const getNodePath = (id: Id, data: MNode[] = []): MNode[] => {
return null;
};
get(id, data);
get(data);
return path;
};
export const getNodeInfo = (id: Id, root: Pick<MApp, 'id' | 'items'> | null) => {
export const getNodeInfo = (id: Id, root: { id: Id; items?: MNode[] } | null, skip?: MNode | null) => {
const info: EditorNodeInfo = {
node: null,
parent: null,
@ -128,7 +130,7 @@ export const getNodeInfo = (id: Id, root: Pick<MApp, 'id' | 'items'> | null) =>
return info;
}
const path = getNodePath(id, root.items);
const path = getNodePath(id, root.items, skip);
if (!path.length) return info;
@ -137,12 +139,12 @@ export const getNodeInfo = (id: Id, root: Pick<MApp, 'id' | 'items'> | null) =>
info.node = path[path.length - 1] as MComponent;
info.parent = path[path.length - 2] as MContainer;
path.forEach((item) => {
for (const item of path) {
if (isPage(item) || isPageFragment(item)) {
info.page = item as MPage | MPageFragment;
return;
break;
}
});
}
return info;
};

View File

@ -248,6 +248,27 @@ describe('getNodeInfo', () => {
const info = getNodeInfo('foo', null);
expect(info.node).toBeNull();
});
test('skip 跳过指定页面子树后查找不到其内部节点', () => {
const multiRoot = {
id: 'app',
items: [
{ id: 'page_1', type: NodeType.PAGE, items: [{ id: 'btn_1', type: 'button' }] },
{ id: 'page_2', type: NodeType.PAGE, items: [{ id: 'btn_2', type: 'button' }] },
],
} as any;
// 跳过 page_1 后page_1 内的节点查不到
const skipped = getNodeInfo('btn_1', multiRoot, multiRoot.items[0]);
expect(skipped.node).toBeNull();
// 其它页面节点不受影响parent / page 仍为真实引用
const info = getNodeInfo('btn_2', multiRoot, multiRoot.items[0]);
expect(info.node?.id).toBe('btn_2');
expect(info.parent?.id).toBe('page_2');
expect(info.page?.id).toBe('page_2');
expect(info.parent).toBe(multiRoot.items[1]);
});
});
describe('setValueByKeyPath / getKeys', () => {

View File

@ -201,6 +201,22 @@ describe('getNodePath', () => {
expect(path).toHaveLength(0);
expect(path2).toHaveLength(0);
});
test('skip 跳过指定子树后查找不到其内部节点', () => {
// 跳过 id 为 2 的容器其子树22 / 222不再被遍历
const skipped = util.getNodePath(222, root, root[1]);
expect(skipped).toHaveLength(0);
// 其它子树不受影响
const path = util.getNodePath(111, root, root[1]);
expect(path).toHaveLength(3);
});
test('skip 不影响对被跳过节点自身的匹配', () => {
// skip 仅阻止递归进入其 items节点自身仍可被匹配到
const path = util.getNodePath(2, root, root[1]);
expect(path).toHaveLength(1);
expect(path[0].id).toBe(2);
});
});
describe('filterXSS', () => {

View File

@ -1,6 +1,6 @@
{
"name": "tmagic-playground",
"version": "1.8.0-beta.3",
"version": "1.8.0-beta.4",
"type": "module",
"private": true,
"scripts": {
@ -12,11 +12,11 @@
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@tmagic/core": "1.8.0-beta.3",
"@tmagic/design": "1.8.0-beta.3",
"@tmagic/editor": "1.8.0-beta.3",
"@tmagic/element-plus-adapter": "1.8.0-beta.3",
"@tmagic/tdesign-vue-next-adapter": "1.8.0-beta.3",
"@tmagic/core": "1.8.0-beta.4",
"@tmagic/design": "1.8.0-beta.4",
"@tmagic/editor": "1.8.0-beta.4",
"@tmagic/element-plus-adapter": "1.8.0-beta.4",
"@tmagic/tdesign-vue-next-adapter": "1.8.0-beta.4",
"@tmagic/tmagic-form-runtime": "1.1.3",
"element-plus": "^2.11.8",
"lodash-es": "^4.17.21",

View File

@ -47,12 +47,14 @@
</template>
<script lang="ts" setup>
import { computed, onBeforeUnmount, ref, shallowRef, toRaw } from 'vue';
import { computed, onBeforeUnmount, onMounted, ref, shallowRef, toRaw } from 'vue';
import { debounce } from 'lodash-es';
import type { MApp, MContainer, MNode } from '@tmagic/core';
import type { DatasourceTypeOption } from '@tmagic/editor';
import {
editorService,
historyService,
propsService,
serializeConfig,
TMagicDialog,
@ -96,6 +98,8 @@ const { moveableOptions } = useEditorMoveableOptions(editor);
const save = () => {
localStorage.setItem('magicDSL', serializeConfig(toRaw(value.value)));
editor.value?.editorService.resetModifiedNodeId();
// IndexedDB
historyService.markSaved();
};
const { menu, deviceGroup, iframe, previewVisible } = useEditorMenu(value, save);
@ -133,7 +137,46 @@ propsService.usePlugin({
beforeFillConfig: (config) => [config, '100px'],
});
// IndexedDB DSL app id
// beforeunload / pagehide await IndexedDB
const persistHistory = () => {
historyService.saveToIndexedDB().catch((error) => console.error('持久化历史记录失败', error));
};
// / / IndexedDB
// beforeunload/pagehide
// /
const schedulePersist = debounce(persistHistory, 500);
// IndexedDB undo/redo
const restoreHistory = async () => {
try {
const snapshot = await historyService.restoreFromIndexedDB();
if (!snapshot) return;
const page = editorService.get('page');
if (page) historyService.changePage(page);
} catch (error) {
console.error('恢复历史记录失败', error);
}
};
onMounted(() => {
restoreHistory();
historyService.on('change', schedulePersist);
historyService.on('code-block-history-change', schedulePersist);
historyService.on('data-source-history-change', schedulePersist);
window.addEventListener('beforeunload', persistHistory);
window.addEventListener('pagehide', persistHistory);
});
onBeforeUnmount(() => {
schedulePersist.cancel();
persistHistory();
historyService.off('change', schedulePersist);
historyService.off('code-block-history-change', schedulePersist);
historyService.off('data-source-history-change', schedulePersist);
window.removeEventListener('beforeunload', persistHistory);
window.removeEventListener('pagehide', persistHistory);
editorService.removeAllPlugins();
});

228
pnpm-lock.yaml generated
View File

@ -557,23 +557,23 @@ importers:
specifier: ^2.3.2
version: 2.3.2(vue@3.5.34(typescript@6.0.3))
'@tmagic/core':
specifier: 1.8.0-beta.3
version: 1.8.0-beta.3(typescript@6.0.3)
specifier: 1.8.0-beta.4
version: 1.8.0-beta.4(typescript@6.0.3)
'@tmagic/design':
specifier: 1.8.0-beta.3
version: 1.8.0-beta.3(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3))
specifier: 1.8.0-beta.4
version: 1.8.0-beta.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3))
'@tmagic/editor':
specifier: 1.8.0-beta.3
version: 1.8.0-beta.3(@tmagic/core@1.8.0-beta.3(typescript@6.0.3))(@tmagic/form-schema@1.8.0-beta.3(typescript@6.0.3))(@tmagic/schema@1.8.0-beta.3(typescript@6.0.3))(monaco-editor@0.55.1)(type-fest@5.2.0)(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3))
specifier: 1.8.0-beta.4
version: 1.8.0-beta.4(@tmagic/core@1.8.0-beta.4(typescript@6.0.3))(@tmagic/form-schema@1.8.0-beta.4(typescript@6.0.3))(@tmagic/schema@1.8.0-beta.4(typescript@6.0.3))(monaco-editor@0.55.1)(type-fest@5.2.0)(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3))
'@tmagic/element-plus-adapter':
specifier: 1.8.0-beta.3
version: 1.8.0-beta.3(@tmagic/design@1.8.0-beta.3(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)))(element-plus@2.11.8(@vue/composition-api@1.7.2(vue@3.5.34(typescript@6.0.3)))(vue@3.5.34(typescript@6.0.3)))(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3))
specifier: 1.8.0-beta.4
version: 1.8.0-beta.4(@tmagic/design@1.8.0-beta.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)))(element-plus@2.11.8(@vue/composition-api@1.7.2(vue@3.5.34(typescript@6.0.3)))(vue@3.5.34(typescript@6.0.3)))(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3))
'@tmagic/tdesign-vue-next-adapter':
specifier: 1.8.0-beta.3
version: 1.8.0-beta.3(@tmagic/design@1.8.0-beta.3(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)))(tdesign-vue-next@1.17.3(vue@3.5.34(typescript@6.0.3)))(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3))
specifier: 1.8.0-beta.4
version: 1.8.0-beta.4(@tmagic/design@1.8.0-beta.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)))(tdesign-vue-next@1.17.3(vue@3.5.34(typescript@6.0.3)))(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3))
'@tmagic/tmagic-form-runtime':
specifier: 1.1.3
version: 1.1.3(@tmagic/core@1.8.0-beta.3(typescript@6.0.3))(@tmagic/editor@1.8.0-beta.3(@tmagic/core@1.8.0-beta.3(typescript@6.0.3))(@tmagic/form-schema@1.8.0-beta.3(typescript@6.0.3))(@tmagic/schema@1.8.0-beta.3(typescript@6.0.3))(monaco-editor@0.55.1)(type-fest@5.2.0)(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)))(element-plus@2.11.8(@vue/composition-api@1.7.2(vue@3.5.34(typescript@6.0.3)))(vue@3.5.34(typescript@6.0.3)))(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3))
version: 1.1.3(@tmagic/core@1.8.0-beta.4(typescript@6.0.3))(@tmagic/editor@1.8.0-beta.4(@tmagic/core@1.8.0-beta.4(typescript@6.0.3))(@tmagic/form-schema@1.8.0-beta.4(typescript@6.0.3))(@tmagic/schema@1.8.0-beta.4(typescript@6.0.3))(monaco-editor@0.55.1)(type-fest@5.2.0)(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)))(element-plus@2.11.8(@vue/composition-api@1.7.2(vue@3.5.34(typescript@6.0.3)))(vue@3.5.34(typescript@6.0.3)))(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3))
element-plus:
specifier: ^2.11.8
version: 2.11.8(@vue/composition-api@1.7.2(vue@3.5.34(typescript@6.0.3)))(vue@3.5.34(typescript@6.0.3))
@ -913,14 +913,14 @@ importers:
runtime/react:
dependencies:
'@tmagic/core':
specifier: 1.8.0-beta.3
version: 1.8.0-beta.3(typescript@6.0.3)
specifier: 1.8.0-beta.4
version: 1.8.0-beta.4(typescript@6.0.3)
'@tmagic/react-runtime-help':
specifier: 0.2.2
version: 0.2.2(@tmagic/core@1.8.0-beta.3(typescript@6.0.3))(@tmagic/stage@1.8.0-beta.3(@tmagic/core@1.8.0-beta.3(typescript@6.0.3))(typescript@6.0.3))(lodash-es@4.17.21)(react@18.3.1)(typescript@6.0.3)
version: 0.2.2(@tmagic/core@1.8.0-beta.4(typescript@6.0.3))(@tmagic/stage@1.8.0-beta.4(@tmagic/core@1.8.0-beta.4(typescript@6.0.3))(typescript@6.0.3))(lodash-es@4.17.21)(react@18.3.1)(typescript@6.0.3)
'@tmagic/stage':
specifier: 1.8.0-beta.3
version: 1.8.0-beta.3(@tmagic/core@1.8.0-beta.3(typescript@6.0.3))(typescript@6.0.3)
specifier: 1.8.0-beta.4
version: 1.8.0-beta.4(@tmagic/core@1.8.0-beta.4(typescript@6.0.3))(typescript@6.0.3)
axios:
specifier: ^1.13.2
version: 1.13.2
@ -935,8 +935,8 @@ importers:
version: 18.3.1(react@18.3.1)
devDependencies:
'@tmagic/cli':
specifier: 1.8.0-beta.3
version: 1.8.0-beta.3(typescript@6.0.3)
specifier: 1.8.0-beta.4
version: 1.8.0-beta.4(typescript@6.0.3)
'@types/fs-extra':
specifier: ^11.0.4
version: 11.0.4
@ -1011,14 +1011,14 @@ importers:
runtime/vue:
dependencies:
'@tmagic/core':
specifier: 1.8.0-beta.3
version: 1.8.0-beta.3(typescript@6.0.3)
specifier: 1.8.0-beta.4
version: 1.8.0-beta.4(typescript@6.0.3)
'@tmagic/stage':
specifier: 1.8.0-beta.3
version: 1.8.0-beta.3(@tmagic/core@1.8.0-beta.3(typescript@6.0.3))(typescript@6.0.3)
specifier: 1.8.0-beta.4
version: 1.8.0-beta.4(@tmagic/core@1.8.0-beta.4(typescript@6.0.3))(typescript@6.0.3)
'@tmagic/vue-runtime-help':
specifier: ^2.0.1
version: 2.0.2(@tmagic/core@1.8.0-beta.3(typescript@6.0.3))(@tmagic/stage@1.8.0-beta.3(@tmagic/core@1.8.0-beta.3(typescript@6.0.3))(typescript@6.0.3))(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3))
version: 2.0.2(@tmagic/core@1.8.0-beta.4(typescript@6.0.3))(@tmagic/stage@1.8.0-beta.4(@tmagic/core@1.8.0-beta.4(typescript@6.0.3))(typescript@6.0.3))(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3))
axios:
specifier: ^1.13.2
version: 1.13.2
@ -1027,8 +1027,8 @@ importers:
version: 3.5.34(typescript@6.0.3)
devDependencies:
'@tmagic/cli':
specifier: 1.8.0-beta.3
version: 1.8.0-beta.3(typescript@6.0.3)
specifier: 1.8.0-beta.4
version: 1.8.0-beta.4(typescript@6.0.3)
'@types/fs-extra':
specifier: ^11.0.4
version: 11.0.4
@ -2912,8 +2912,8 @@ packages:
'@sxzz/popperjs-es@2.11.7':
resolution: {integrity: sha512-Ccy0NlLkzr0Ex2FKvh2X+OyERHXJ88XJ1MXtsI9y9fGexlaXaVTPzBCRBwIxFkORuOb+uBqeu+RqnpgYTEZRUQ==}
'@tmagic/cli@1.8.0-beta.3':
resolution: {integrity: sha512-cLQ5qrXq3iubQXeOow9FsiukYCAG+woZAQG7l9w9FIQUAnedkUyDzAAlIH72vBNAItC9ZpWesI4I6Ncmg9g9OA==}
'@tmagic/cli@1.8.0-beta.4':
resolution: {integrity: sha512-lpuG/LbpdgtwWkugFXaybP9AgqJEH3qE1PBZaEQnNZQSrma+r9vC2BvR/oMzBh2/WLkI6dMHvS20YauwnrjYOQ==}
engines: {node: '>=18'}
hasBin: true
peerDependencies:
@ -2940,8 +2940,8 @@ packages:
typescript:
optional: true
'@tmagic/core@1.8.0-beta.3':
resolution: {integrity: sha512-V+H4upUCZ92xCuTLQMgU6QEMr4dnE8CHO7ScBG0bf/rCLRIIMI1M3kXhmyZErU8D+FwdN5FyRZvcsKv775uguA==}
'@tmagic/core@1.8.0-beta.4':
resolution: {integrity: sha512-DWx0YQdwtcu/mxyGLPi8OVSAknzP4mpraP0n2so4yghrxfw4rpgrzqoBPC2VZdSDhkUemxJ+MqZE/YNKzKBqlw==}
engines: {node: '>=18'}
peerDependencies:
typescript: ^6.0.3
@ -2969,11 +2969,11 @@ packages:
typescript:
optional: true
'@tmagic/data-source@1.8.0-beta.3':
resolution: {integrity: sha512-Dzrt9jWggSaBk46DHC0qUBcOR023WYNtOTx0SopynabNGUo4Ku8BQRsSpeZXY/Vtg7d8621XqGIiC+039JUJrA==}
'@tmagic/data-source@1.8.0-beta.4':
resolution: {integrity: sha512-GhMPhuCvJDw0DQvbvmiZJyvgNMAb9zWvRhBgUzTRiM4rkANmQM72KPTNT0zWCbGISrZ7iR0ltWxwr80DWrxOTg==}
engines: {node: '>=18'}
peerDependencies:
'@tmagic/core': 1.8.0-beta.3
'@tmagic/core': 1.8.0-beta.4
typescript: ^6.0.3
peerDependenciesMeta:
typescript:
@ -3001,12 +3001,12 @@ packages:
typescript:
optional: true
'@tmagic/dep@1.8.0-beta.3':
resolution: {integrity: sha512-48ZpvczcC6Tsi+YGE7rhDKwYswhuywHx9HCgN5Bl2Ow3076il/zaDxTYsc0ZIR5HkE6aIxGNgH9nqzMSiOcacQ==}
'@tmagic/dep@1.8.0-beta.4':
resolution: {integrity: sha512-znTuExAWttbuTJW05q5dPJrrLM9mWBVWrfz2CVcZwB5jJETKW+bLLDReozgH3SufyQuH4cpLJyRW2AGt59iNxw==}
engines: {node: '>=18'}
peerDependencies:
'@tmagic/schema': 1.8.0-beta.3
'@tmagic/utils': 1.8.0-beta.3
'@tmagic/schema': 1.8.0-beta.4
'@tmagic/utils': 1.8.0-beta.4
typescript: ^6.0.3
peerDependenciesMeta:
typescript:
@ -3022,8 +3022,8 @@ packages:
typescript:
optional: true
'@tmagic/design@1.8.0-beta.3':
resolution: {integrity: sha512-eWjzWSS4AdGpK1Rz0sOVjs/APFmkA+ouFE3S4JOa43BmND+Ol1vQrMa0HDm5kCOTeZL7XLacA9wJEzH6LAjRqw==}
'@tmagic/design@1.8.0-beta.4':
resolution: {integrity: sha512-ATSuHWynVkH2OkS59O1dsu7xO6lNsZAiuUyJqfDYUHW7c5APrid5mDadlRZiTTPLbMauXWfgyNCK3X7kgCj59Q==}
engines: {node: '>=18'}
peerDependencies:
typescript: ^6.0.3
@ -3044,11 +3044,11 @@ packages:
typescript:
optional: true
'@tmagic/editor@1.8.0-beta.3':
resolution: {integrity: sha512-VZpaVkWxlGL6OL0IWIDTGUIpoC7Vnh9H55H2qEf85ktcLMAhtx5Qzal+mixcvfI8Kh93q/HYZorHgmoTp3DAsA==}
'@tmagic/editor@1.8.0-beta.4':
resolution: {integrity: sha512-n1xOKm53mnsh3qFWoaL6zZfB9MyGllXK14e7wzeuAfxaMgRINde5RiNE7JGBPBdUUrmyYs2yAcDcZIhGx/zKbA==}
engines: {node: '>=18'}
peerDependencies:
'@tmagic/core': 1.8.0-beta.3
'@tmagic/core': 1.8.0-beta.4
monaco-editor: '^0.55.1 '
type-fest: ^5.2.0
typescript: ^6.0.3
@ -3059,11 +3059,11 @@ packages:
typescript:
optional: true
'@tmagic/element-plus-adapter@1.8.0-beta.3':
resolution: {integrity: sha512-aUY8PGGiQ1XUlzR5sR6DJc2mqVVH6PLGF6YtP+7oihhqVEG7EyFzwgQvVesV5sUxTuJm7CQaZKfnyg2gBceOWQ==}
'@tmagic/element-plus-adapter@1.8.0-beta.4':
resolution: {integrity: sha512-2qBsqDix6PuIQdG28bUyunkbtcpuGiMmSl1vFGNkuObzj27x98GbSBk6ONEwAB/OvMG5TVHWZDK/5FVIUAAmZg==}
engines: {node: '>=18'}
peerDependencies:
'@tmagic/design': 1.8.0-beta.3
'@tmagic/design': 1.8.0-beta.4
element-plus: '>=2.9.0'
typescript: ^6.0.3
vue: ^3.5.34
@ -3080,8 +3080,8 @@ packages:
typescript:
optional: true
'@tmagic/form-schema@1.8.0-beta.3':
resolution: {integrity: sha512-YT8b3Td+6IYrpM2YQO/srmHxobEcS+fHzKBq8t54P4EO2gg6XOIA3yQcGwwV9m0814Wtg2YMpbXxuhcJYyAxnQ==}
'@tmagic/form-schema@1.8.0-beta.4':
resolution: {integrity: sha512-IPeONO+CZAplOf4PqCb7P6VuhrqLfvAtqpVPgGTKoy3aK9y+bl58e51Wf8tcqSyHosIIgnC7dUv0ygMIRWVMjw==}
engines: {node: '>=18'}
peerDependencies:
typescript: ^6.0.3
@ -3102,13 +3102,13 @@ packages:
typescript:
optional: true
'@tmagic/form@1.8.0-beta.3':
resolution: {integrity: sha512-/i4Sgm5Gaq9AUvKq3/F3b88GNvmLc2Q1yadKBjbBWDVispqeLljzScKrsNPhXZfmJmUSX2BnClaI9Aoyae5Lsg==}
'@tmagic/form@1.8.0-beta.4':
resolution: {integrity: sha512-wWOZJFECrTtYFFJPAPspL9qkWrDWqRPZSQlG0zTokDjYIsU2l0CIH9tOumzlKoMHc2WyainF/R3aLx/y5Dd3RQ==}
engines: {node: '>=18'}
peerDependencies:
'@tmagic/design': 1.8.0-beta.3
'@tmagic/form-schema': 1.8.0-beta.3
'@tmagic/utils': 1.8.0-beta.3
'@tmagic/design': 1.8.0-beta.4
'@tmagic/form-schema': 1.8.0-beta.4
'@tmagic/utils': 1.8.0-beta.4
typescript: ^6.0.3
vue: ^3.5.34
peerDependenciesMeta:
@ -3150,8 +3150,8 @@ packages:
typescript:
optional: true
'@tmagic/schema@1.8.0-beta.3':
resolution: {integrity: sha512-eoeGyAqGQ9SX+PFNlYZ1YfWmNQyOkAYLDGEvumWYmXWT0gs7WDmcWt3AaDvQqpCrpaCMAfICWCehR6wSkY0o3g==}
'@tmagic/schema@1.8.0-beta.4':
resolution: {integrity: sha512-yt7TPnrfl7Ensv4YNLB/01cMdUxzse7B6V3st4Ftw1j0QkFYsquaPg3Us3CoSad0NjNIttOfewYVtq9NKEsOXQ==}
engines: {node: '>=18'}
peerDependencies:
typescript: ^6.0.3
@ -3179,11 +3179,11 @@ packages:
typescript:
optional: true
'@tmagic/stage@1.8.0-beta.3':
resolution: {integrity: sha512-UKPFxxmQUvzy3I0bytlw3LezUKUez/ZNNDLuuvLiWCAHyZblwiOTtCfx4WE3pcONpI+qLIPhZP2+A6DQQ99xKQ==}
'@tmagic/stage@1.8.0-beta.4':
resolution: {integrity: sha512-E/qZLtA9XWSgWHM+mxQh3qluqDZhQ8v+d1oL2d/l4drgA9Hlsj74KqbYIHbG5n9oiqeydt3gD6yNnfp4RoTiPA==}
engines: {node: '>=18'}
peerDependencies:
'@tmagic/core': 1.8.0-beta.3
'@tmagic/core': 1.8.0-beta.4
typescript: ^6.0.3
peerDependenciesMeta:
typescript:
@ -3201,24 +3201,24 @@ packages:
typescript:
optional: true
'@tmagic/table@1.8.0-beta.3':
resolution: {integrity: sha512-CvUKRWnrWu/t/85cQz2NPeMzszvGWBg0PYvedqRWPT+K3oNOokVGCxa3cA+MxKt72ijs2qW+wOwoEDgj0068qA==}
'@tmagic/table@1.8.0-beta.4':
resolution: {integrity: sha512-eOPZNMlh7IkVsDMAPBA5uJnC0t7mYqcTM7fTPVq6SrqK43s7zAhy0PnrvzqLBv4Vi3P8aDdfZTiPdhv9akPxVA==}
engines: {node: '>=18'}
peerDependencies:
'@tmagic/design': 1.8.0-beta.3
'@tmagic/form': 1.8.0-beta.3
'@tmagic/utils': 1.8.0-beta.3
'@tmagic/design': 1.8.0-beta.4
'@tmagic/form': 1.8.0-beta.4
'@tmagic/utils': 1.8.0-beta.4
typescript: ^6.0.3
vue: ^3.5.34
peerDependenciesMeta:
typescript:
optional: true
'@tmagic/tdesign-vue-next-adapter@1.8.0-beta.3':
resolution: {integrity: sha512-OHeP+wBpMZvYCBDhzlQCYEGlsaH9m0SmnymAyOCJ3wP2JjSv79hXNd+fBeUQA0C8bAU0eI4HUP9GCCn4zKEy2w==}
'@tmagic/tdesign-vue-next-adapter@1.8.0-beta.4':
resolution: {integrity: sha512-5Dz54TOXmsb73bX8SzHSez+LJRpY8vC0C7X7mcQl257Amwnuk7LoY42XxP+fEnAy9VWDUpGo/OXpLhPbh4R0ag==}
engines: {node: '>=18'}
peerDependencies:
'@tmagic/design': 1.8.0-beta.3
'@tmagic/design': 1.8.0-beta.4
tdesign-vue-next: ^1.17.1
typescript: ^6.0.3
vue: ^3.5.34
@ -3261,11 +3261,11 @@ packages:
typescript:
optional: true
'@tmagic/utils@1.8.0-beta.3':
resolution: {integrity: sha512-XVH5Lu5AXbJ5W8lLiw3Dr3WeZ09OSbfqboyqF/W20/iBbfhEb2eOhDPJeleiRxZCzbqnzTJRf75dl9w8UgK1gQ==}
'@tmagic/utils@1.8.0-beta.4':
resolution: {integrity: sha512-LPLlZdnbDeo5ylMWIWzpbuTqsKFSi+N21Sq4IS8mlsW9OR+PmH960ghQJZfIehaDkBt9HjCsu1OYNOUK6K1/mg==}
engines: {node: '>=18'}
peerDependencies:
'@tmagic/schema': 1.8.0-beta.3
'@tmagic/schema': 1.8.0-beta.4
typescript: ^6.0.3
peerDependenciesMeta:
typescript:
@ -8699,7 +8699,7 @@ snapshots:
'@sxzz/popperjs-es@2.11.7': {}
'@tmagic/cli@1.8.0-beta.3(typescript@6.0.3)':
'@tmagic/cli@1.8.0-beta.4(typescript@6.0.3)':
dependencies:
cac: 6.7.14
chokidar: 3.6.0
@ -8734,12 +8734,12 @@ snapshots:
optionalDependencies:
typescript: 6.0.3
'@tmagic/core@1.8.0-beta.3(typescript@6.0.3)':
'@tmagic/core@1.8.0-beta.4(typescript@6.0.3)':
dependencies:
'@tmagic/data-source': 1.8.0-beta.3(@tmagic/core@1.8.0-beta.3(typescript@6.0.3))(typescript@6.0.3)
'@tmagic/dep': 1.8.0-beta.3(@tmagic/schema@1.8.0-beta.3(typescript@6.0.3))(@tmagic/utils@1.8.0-beta.3(@tmagic/schema@1.8.0-beta.3(typescript@6.0.3))(typescript@6.0.3))(typescript@6.0.3)
'@tmagic/schema': 1.8.0-beta.3(typescript@6.0.3)
'@tmagic/utils': 1.8.0-beta.3(@tmagic/schema@1.8.0-beta.3(typescript@6.0.3))(typescript@6.0.3)
'@tmagic/data-source': 1.8.0-beta.4(@tmagic/core@1.8.0-beta.4(typescript@6.0.3))(typescript@6.0.3)
'@tmagic/dep': 1.8.0-beta.4(@tmagic/schema@1.8.0-beta.4(typescript@6.0.3))(@tmagic/utils@1.8.0-beta.4(@tmagic/schema@1.8.0-beta.4(typescript@6.0.3))(typescript@6.0.3))(typescript@6.0.3)
'@tmagic/schema': 1.8.0-beta.4(typescript@6.0.3)
'@tmagic/utils': 1.8.0-beta.4(@tmagic/schema@1.8.0-beta.4(typescript@6.0.3))(typescript@6.0.3)
events: 3.3.0
lodash-es: 4.17.21
optionalDependencies:
@ -8763,9 +8763,9 @@ snapshots:
optionalDependencies:
typescript: 6.0.3
'@tmagic/data-source@1.8.0-beta.3(@tmagic/core@1.8.0-beta.3(typescript@6.0.3))(typescript@6.0.3)':
'@tmagic/data-source@1.8.0-beta.4(@tmagic/core@1.8.0-beta.4(typescript@6.0.3))(typescript@6.0.3)':
dependencies:
'@tmagic/core': 1.8.0-beta.3(typescript@6.0.3)
'@tmagic/core': 1.8.0-beta.4(typescript@6.0.3)
deep-state-observer: 5.5.14
events: 3.3.0
lodash-es: 4.17.21
@ -8786,10 +8786,10 @@ snapshots:
optionalDependencies:
typescript: 6.0.3
'@tmagic/dep@1.8.0-beta.3(@tmagic/schema@1.8.0-beta.3(typescript@6.0.3))(@tmagic/utils@1.8.0-beta.3(@tmagic/schema@1.8.0-beta.3(typescript@6.0.3))(typescript@6.0.3))(typescript@6.0.3)':
'@tmagic/dep@1.8.0-beta.4(@tmagic/schema@1.8.0-beta.4(typescript@6.0.3))(@tmagic/utils@1.8.0-beta.4(@tmagic/schema@1.8.0-beta.4(typescript@6.0.3))(typescript@6.0.3))(typescript@6.0.3)':
dependencies:
'@tmagic/schema': 1.8.0-beta.3(typescript@6.0.3)
'@tmagic/utils': 1.8.0-beta.3(@tmagic/schema@1.8.0-beta.3(typescript@6.0.3))(typescript@6.0.3)
'@tmagic/schema': 1.8.0-beta.4(typescript@6.0.3)
'@tmagic/utils': 1.8.0-beta.4(@tmagic/schema@1.8.0-beta.4(typescript@6.0.3))(typescript@6.0.3)
optionalDependencies:
typescript: 6.0.3
@ -8800,7 +8800,7 @@ snapshots:
optionalDependencies:
typescript: 6.0.3
'@tmagic/design@1.8.0-beta.3(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3))':
'@tmagic/design@1.8.0-beta.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3))':
dependencies:
'@popperjs/core': 2.11.8
vue: 3.5.34(typescript@6.0.3)
@ -8834,15 +8834,15 @@ snapshots:
- '@tmagic/form-schema'
- '@tmagic/schema'
'@tmagic/editor@1.8.0-beta.3(@tmagic/core@1.8.0-beta.3(typescript@6.0.3))(@tmagic/form-schema@1.8.0-beta.3(typescript@6.0.3))(@tmagic/schema@1.8.0-beta.3(typescript@6.0.3))(monaco-editor@0.55.1)(type-fest@5.2.0)(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3))':
'@tmagic/editor@1.8.0-beta.4(@tmagic/core@1.8.0-beta.4(typescript@6.0.3))(@tmagic/form-schema@1.8.0-beta.4(typescript@6.0.3))(@tmagic/schema@1.8.0-beta.4(typescript@6.0.3))(monaco-editor@0.55.1)(type-fest@5.2.0)(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3))':
dependencies:
'@element-plus/icons-vue': 2.3.2(vue@3.5.34(typescript@6.0.3))
'@tmagic/core': 1.8.0-beta.3(typescript@6.0.3)
'@tmagic/design': 1.8.0-beta.3(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3))
'@tmagic/form': 1.8.0-beta.3(@tmagic/design@1.8.0-beta.3(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)))(@tmagic/form-schema@1.8.0-beta.3(typescript@6.0.3))(@tmagic/utils@1.8.0-beta.3(@tmagic/schema@1.8.0-beta.3(typescript@6.0.3))(typescript@6.0.3))(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3))
'@tmagic/stage': 1.8.0-beta.3(@tmagic/core@1.8.0-beta.3(typescript@6.0.3))(typescript@6.0.3)
'@tmagic/table': 1.8.0-beta.3(@tmagic/design@1.8.0-beta.3(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)))(@tmagic/form@1.8.0-beta.3(@tmagic/design@1.8.0-beta.3(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)))(@tmagic/form-schema@1.8.0-beta.3(typescript@6.0.3))(@tmagic/utils@1.8.0-beta.3(@tmagic/schema@1.8.0-beta.3(typescript@6.0.3))(typescript@6.0.3))(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)))(@tmagic/utils@1.8.0-beta.3(@tmagic/schema@1.8.0-beta.3(typescript@6.0.3))(typescript@6.0.3))(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3))
'@tmagic/utils': 1.8.0-beta.3(@tmagic/schema@1.8.0-beta.3(typescript@6.0.3))(typescript@6.0.3)
'@tmagic/core': 1.8.0-beta.4(typescript@6.0.3)
'@tmagic/design': 1.8.0-beta.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3))
'@tmagic/form': 1.8.0-beta.4(@tmagic/design@1.8.0-beta.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)))(@tmagic/form-schema@1.8.0-beta.4(typescript@6.0.3))(@tmagic/utils@1.8.0-beta.4(@tmagic/schema@1.8.0-beta.4(typescript@6.0.3))(typescript@6.0.3))(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3))
'@tmagic/stage': 1.8.0-beta.4(@tmagic/core@1.8.0-beta.4(typescript@6.0.3))(typescript@6.0.3)
'@tmagic/table': 1.8.0-beta.4(@tmagic/design@1.8.0-beta.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)))(@tmagic/form@1.8.0-beta.4(@tmagic/design@1.8.0-beta.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)))(@tmagic/form-schema@1.8.0-beta.4(typescript@6.0.3))(@tmagic/utils@1.8.0-beta.4(@tmagic/schema@1.8.0-beta.4(typescript@6.0.3))(typescript@6.0.3))(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)))(@tmagic/utils@1.8.0-beta.4(@tmagic/schema@1.8.0-beta.4(typescript@6.0.3))(typescript@6.0.3))(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3))
'@tmagic/utils': 1.8.0-beta.4(@tmagic/schema@1.8.0-beta.4(typescript@6.0.3))(typescript@6.0.3)
buffer: 6.0.3
deep-object-diff: 1.1.9
emmet-monaco-es: 5.7.0(monaco-editor@0.55.1)
@ -8862,9 +8862,9 @@ snapshots:
- '@tmagic/form-schema'
- '@tmagic/schema'
'@tmagic/element-plus-adapter@1.8.0-beta.3(@tmagic/design@1.8.0-beta.3(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)))(element-plus@2.11.8(@vue/composition-api@1.7.2(vue@3.5.34(typescript@6.0.3)))(vue@3.5.34(typescript@6.0.3)))(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3))':
'@tmagic/element-plus-adapter@1.8.0-beta.4(@tmagic/design@1.8.0-beta.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)))(element-plus@2.11.8(@vue/composition-api@1.7.2(vue@3.5.34(typescript@6.0.3)))(vue@3.5.34(typescript@6.0.3)))(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3))':
dependencies:
'@tmagic/design': 1.8.0-beta.3(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3))
'@tmagic/design': 1.8.0-beta.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3))
element-plus: 2.11.8(@vue/composition-api@1.7.2(vue@3.5.34(typescript@6.0.3)))(vue@3.5.34(typescript@6.0.3))
vue: 3.5.34(typescript@6.0.3)
optionalDependencies:
@ -8876,9 +8876,9 @@ snapshots:
optionalDependencies:
typescript: 6.0.3
'@tmagic/form-schema@1.8.0-beta.3(typescript@6.0.3)':
'@tmagic/form-schema@1.8.0-beta.4(typescript@6.0.3)':
dependencies:
'@tmagic/schema': 1.8.0-beta.3(typescript@6.0.3)
'@tmagic/schema': 1.8.0-beta.4(typescript@6.0.3)
optionalDependencies:
typescript: 6.0.3
@ -8896,13 +8896,13 @@ snapshots:
optionalDependencies:
typescript: 6.0.3
'@tmagic/form@1.8.0-beta.3(@tmagic/design@1.8.0-beta.3(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)))(@tmagic/form-schema@1.8.0-beta.3(typescript@6.0.3))(@tmagic/utils@1.8.0-beta.3(@tmagic/schema@1.8.0-beta.3(typescript@6.0.3))(typescript@6.0.3))(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3))':
'@tmagic/form@1.8.0-beta.4(@tmagic/design@1.8.0-beta.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)))(@tmagic/form-schema@1.8.0-beta.4(typescript@6.0.3))(@tmagic/utils@1.8.0-beta.4(@tmagic/schema@1.8.0-beta.4(typescript@6.0.3))(typescript@6.0.3))(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3))':
dependencies:
'@element-plus/icons-vue': 2.3.2(vue@3.5.34(typescript@6.0.3))
'@popperjs/core': 2.11.8
'@tmagic/design': 1.8.0-beta.3(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3))
'@tmagic/form-schema': 1.8.0-beta.3(typescript@6.0.3)
'@tmagic/utils': 1.8.0-beta.3(@tmagic/schema@1.8.0-beta.3(typescript@6.0.3))(typescript@6.0.3)
'@tmagic/design': 1.8.0-beta.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3))
'@tmagic/form-schema': 1.8.0-beta.4(typescript@6.0.3)
'@tmagic/utils': 1.8.0-beta.4(@tmagic/schema@1.8.0-beta.4(typescript@6.0.3))(typescript@6.0.3)
dayjs: 1.11.19
lodash-es: 4.17.21
sortablejs: 1.15.6
@ -8910,13 +8910,13 @@ snapshots:
optionalDependencies:
typescript: 6.0.3
'@tmagic/react-runtime-help@0.2.2(@tmagic/core@1.8.0-beta.3(typescript@6.0.3))(@tmagic/stage@1.8.0-beta.3(@tmagic/core@1.8.0-beta.3(typescript@6.0.3))(typescript@6.0.3))(lodash-es@4.17.21)(react@18.3.1)(typescript@6.0.3)':
'@tmagic/react-runtime-help@0.2.2(@tmagic/core@1.8.0-beta.4(typescript@6.0.3))(@tmagic/stage@1.8.0-beta.4(@tmagic/core@1.8.0-beta.4(typescript@6.0.3))(typescript@6.0.3))(lodash-es@4.17.21)(react@18.3.1)(typescript@6.0.3)':
dependencies:
lodash-es: 4.17.21
react: 18.3.1
optionalDependencies:
'@tmagic/core': 1.8.0-beta.3(typescript@6.0.3)
'@tmagic/stage': 1.8.0-beta.3(@tmagic/core@1.8.0-beta.3(typescript@6.0.3))(typescript@6.0.3)
'@tmagic/core': 1.8.0-beta.4(typescript@6.0.3)
'@tmagic/stage': 1.8.0-beta.4(@tmagic/core@1.8.0-beta.4(typescript@6.0.3))(typescript@6.0.3)
typescript: 6.0.3
'@tmagic/schema@1.7.0(typescript@6.0.3)':
@ -8927,7 +8927,7 @@ snapshots:
optionalDependencies:
typescript: 6.0.3
'@tmagic/schema@1.8.0-beta.3(typescript@6.0.3)':
'@tmagic/schema@1.8.0-beta.4(typescript@6.0.3)':
optionalDependencies:
typescript: 6.0.3
@ -8957,10 +8957,10 @@ snapshots:
optionalDependencies:
typescript: 6.0.3
'@tmagic/stage@1.8.0-beta.3(@tmagic/core@1.8.0-beta.3(typescript@6.0.3))(typescript@6.0.3)':
'@tmagic/stage@1.8.0-beta.4(@tmagic/core@1.8.0-beta.4(typescript@6.0.3))(typescript@6.0.3)':
dependencies:
'@scena/guides': 0.29.2
'@tmagic/core': 1.8.0-beta.3(typescript@6.0.3)
'@tmagic/core': 1.8.0-beta.4(typescript@6.0.3)
'@zumer/snapdom': 2.8.0
events: 3.3.0
keycon: 1.4.0
@ -8980,31 +8980,31 @@ snapshots:
optionalDependencies:
typescript: 6.0.3
'@tmagic/table@1.8.0-beta.3(@tmagic/design@1.8.0-beta.3(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)))(@tmagic/form@1.8.0-beta.3(@tmagic/design@1.8.0-beta.3(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)))(@tmagic/form-schema@1.8.0-beta.3(typescript@6.0.3))(@tmagic/utils@1.8.0-beta.3(@tmagic/schema@1.8.0-beta.3(typescript@6.0.3))(typescript@6.0.3))(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)))(@tmagic/utils@1.8.0-beta.3(@tmagic/schema@1.8.0-beta.3(typescript@6.0.3))(typescript@6.0.3))(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3))':
'@tmagic/table@1.8.0-beta.4(@tmagic/design@1.8.0-beta.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)))(@tmagic/form@1.8.0-beta.4(@tmagic/design@1.8.0-beta.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)))(@tmagic/form-schema@1.8.0-beta.4(typescript@6.0.3))(@tmagic/utils@1.8.0-beta.4(@tmagic/schema@1.8.0-beta.4(typescript@6.0.3))(typescript@6.0.3))(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)))(@tmagic/utils@1.8.0-beta.4(@tmagic/schema@1.8.0-beta.4(typescript@6.0.3))(typescript@6.0.3))(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3))':
dependencies:
'@tmagic/design': 1.8.0-beta.3(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3))
'@tmagic/form': 1.8.0-beta.3(@tmagic/design@1.8.0-beta.3(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)))(@tmagic/form-schema@1.8.0-beta.3(typescript@6.0.3))(@tmagic/utils@1.8.0-beta.3(@tmagic/schema@1.8.0-beta.3(typescript@6.0.3))(typescript@6.0.3))(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3))
'@tmagic/utils': 1.8.0-beta.3(@tmagic/schema@1.8.0-beta.3(typescript@6.0.3))(typescript@6.0.3)
'@tmagic/design': 1.8.0-beta.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3))
'@tmagic/form': 1.8.0-beta.4(@tmagic/design@1.8.0-beta.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)))(@tmagic/form-schema@1.8.0-beta.4(typescript@6.0.3))(@tmagic/utils@1.8.0-beta.4(@tmagic/schema@1.8.0-beta.4(typescript@6.0.3))(typescript@6.0.3))(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3))
'@tmagic/utils': 1.8.0-beta.4(@tmagic/schema@1.8.0-beta.4(typescript@6.0.3))(typescript@6.0.3)
lodash-es: 4.17.21
vue: 3.5.34(typescript@6.0.3)
optionalDependencies:
typescript: 6.0.3
'@tmagic/tdesign-vue-next-adapter@1.8.0-beta.3(@tmagic/design@1.8.0-beta.3(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)))(tdesign-vue-next@1.17.3(vue@3.5.34(typescript@6.0.3)))(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3))':
'@tmagic/tdesign-vue-next-adapter@1.8.0-beta.4(@tmagic/design@1.8.0-beta.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)))(tdesign-vue-next@1.17.3(vue@3.5.34(typescript@6.0.3)))(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3))':
dependencies:
'@tmagic/design': 1.8.0-beta.3(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3))
'@tmagic/design': 1.8.0-beta.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3))
tdesign-vue-next: 1.17.3(vue@3.5.34(typescript@6.0.3))
vue: 3.5.34(typescript@6.0.3)
optionalDependencies:
typescript: 6.0.3
'@tmagic/tmagic-form-runtime@1.1.3(@tmagic/core@1.8.0-beta.3(typescript@6.0.3))(@tmagic/editor@1.8.0-beta.3(@tmagic/core@1.8.0-beta.3(typescript@6.0.3))(@tmagic/form-schema@1.8.0-beta.3(typescript@6.0.3))(@tmagic/schema@1.8.0-beta.3(typescript@6.0.3))(monaco-editor@0.55.1)(type-fest@5.2.0)(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)))(element-plus@2.11.8(@vue/composition-api@1.7.2(vue@3.5.34(typescript@6.0.3)))(vue@3.5.34(typescript@6.0.3)))(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3))':
'@tmagic/tmagic-form-runtime@1.1.3(@tmagic/core@1.8.0-beta.4(typescript@6.0.3))(@tmagic/editor@1.8.0-beta.4(@tmagic/core@1.8.0-beta.4(typescript@6.0.3))(@tmagic/form-schema@1.8.0-beta.4(typescript@6.0.3))(@tmagic/schema@1.8.0-beta.4(typescript@6.0.3))(monaco-editor@0.55.1)(type-fest@5.2.0)(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)))(element-plus@2.11.8(@vue/composition-api@1.7.2(vue@3.5.34(typescript@6.0.3)))(vue@3.5.34(typescript@6.0.3)))(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3))':
dependencies:
'@tmagic/editor': 1.8.0-beta.3(@tmagic/core@1.8.0-beta.3(typescript@6.0.3))(@tmagic/form-schema@1.8.0-beta.3(typescript@6.0.3))(@tmagic/schema@1.8.0-beta.3(typescript@6.0.3))(monaco-editor@0.55.1)(type-fest@5.2.0)(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3))
'@tmagic/editor': 1.8.0-beta.4(@tmagic/core@1.8.0-beta.4(typescript@6.0.3))(@tmagic/form-schema@1.8.0-beta.4(typescript@6.0.3))(@tmagic/schema@1.8.0-beta.4(typescript@6.0.3))(monaco-editor@0.55.1)(type-fest@5.2.0)(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3))
element-plus: 2.11.8(@vue/composition-api@1.7.2(vue@3.5.34(typescript@6.0.3)))(vue@3.5.34(typescript@6.0.3))
vue: 3.5.34(typescript@6.0.3)
optionalDependencies:
'@tmagic/core': 1.8.0-beta.3(typescript@6.0.3)
'@tmagic/core': 1.8.0-beta.4(typescript@6.0.3)
typescript: 6.0.3
'@tmagic/utils@1.7.0(@tmagic/schema@1.7.0(typescript@6.0.3))(typescript@6.0.3)':
@ -9021,19 +9021,19 @@ snapshots:
optionalDependencies:
typescript: 6.0.3
'@tmagic/utils@1.8.0-beta.3(@tmagic/schema@1.8.0-beta.3(typescript@6.0.3))(typescript@6.0.3)':
'@tmagic/utils@1.8.0-beta.4(@tmagic/schema@1.8.0-beta.4(typescript@6.0.3))(typescript@6.0.3)':
dependencies:
'@tmagic/schema': 1.8.0-beta.3(typescript@6.0.3)
'@tmagic/schema': 1.8.0-beta.4(typescript@6.0.3)
lodash-es: 4.17.21
optionalDependencies:
typescript: 6.0.3
'@tmagic/vue-runtime-help@2.0.2(@tmagic/core@1.8.0-beta.3(typescript@6.0.3))(@tmagic/stage@1.8.0-beta.3(@tmagic/core@1.8.0-beta.3(typescript@6.0.3))(typescript@6.0.3))(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3))':
'@tmagic/vue-runtime-help@2.0.2(@tmagic/core@1.8.0-beta.4(typescript@6.0.3))(@tmagic/stage@1.8.0-beta.4(@tmagic/core@1.8.0-beta.4(typescript@6.0.3))(typescript@6.0.3))(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3))':
dependencies:
vue: 3.5.34(typescript@6.0.3)
optionalDependencies:
'@tmagic/core': 1.8.0-beta.3(typescript@6.0.3)
'@tmagic/stage': 1.8.0-beta.3(@tmagic/core@1.8.0-beta.3(typescript@6.0.3))(typescript@6.0.3)
'@tmagic/core': 1.8.0-beta.4(typescript@6.0.3)
'@tmagic/stage': 1.8.0-beta.4(@tmagic/core@1.8.0-beta.4(typescript@6.0.3))(typescript@6.0.3)
typescript: 6.0.3
'@tybys/wasm-util@0.10.1':

View File

@ -1,6 +1,6 @@
{
"name": "runtime-react",
"version": "1.8.0-beta.3",
"version": "1.8.0-beta.4",
"type": "module",
"private": true,
"engines": {
@ -16,16 +16,16 @@
"build:playground": "node scripts/build.mjs --type=playground"
},
"dependencies": {
"@tmagic/core": "1.8.0-beta.3",
"@tmagic/core": "1.8.0-beta.4",
"@tmagic/react-runtime-help": "0.2.2",
"@tmagic/stage": "1.8.0-beta.3",
"@tmagic/stage": "1.8.0-beta.4",
"axios": "^1.13.2",
"qrcode": "^1.5.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@tmagic/cli": "1.8.0-beta.3",
"@tmagic/cli": "1.8.0-beta.4",
"@types/fs-extra": "^11.0.4",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",

View File

@ -1,6 +1,6 @@
{
"name": "runtime-vue",
"version": "1.8.0-beta.3",
"version": "1.8.0-beta.4",
"type": "module",
"private": true,
"engines": {
@ -16,14 +16,14 @@
"build:playground": "node scripts/build.mjs --type=playground"
},
"dependencies": {
"@tmagic/core": "1.8.0-beta.3",
"@tmagic/stage": "1.8.0-beta.3",
"@tmagic/core": "1.8.0-beta.4",
"@tmagic/stage": "1.8.0-beta.4",
"@tmagic/vue-runtime-help": "^2.0.1",
"axios": "^1.13.2",
"vue": "catalog:"
},
"devDependencies": {
"@tmagic/cli": "1.8.0-beta.3",
"@tmagic/cli": "1.8.0-beta.4",
"@types/fs-extra": "^11.0.4",
"@types/node": "^24.0.10",
"@vitejs/plugin-legacy": "^8.0.1",