mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-06-11 18:02:01 +00:00
Compare commits
No commits in common. "master" and "v1.8.0-beta.2" have entirely different histories.
master
...
v1.8.0-bet
63
CHANGELOG.md
63
CHANGELOG.md
@ -1,66 +1,3 @@
|
||||
# [1.8.0-beta.5](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.4...v1.8.0-beta.5) (2026-06-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **editor:** 优化历史回滚确认流程 ([48519b0](https://github.com/Tencent/tmagic-editor/commit/48519b0155a7cda8226217fa3bfd97a92410a7a6))
|
||||
* **editor:** 修复历史对比属性配置上下文缺失 ([be3a900](https://github.com/Tencent/tmagic-editor/commit/be3a900e6a132751f3b1d59c06b850c00604ee15))
|
||||
* **stage:** 复用 TargetShadow 修正闪烁高亮定位 ([171d31e](https://github.com/Tencent/tmagic-editor/commit/171d31e20797ab0e68ac8b2a4c39740e1f636634))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **design:** popover 支持点击外部关闭 ([846f05e](https://github.com/Tencent/tmagic-editor/commit/846f05e04d6d85d37611148185eae93661e9d0da))
|
||||
* **editor:** 将侧边栏激活面板状态同步至 uiService ([6ba59c0](https://github.com/Tencent/tmagic-editor/commit/6ba59c0d141947727c83bc708c9fb7fc6b71a47f))
|
||||
* **editor:** 支持历史记录持久化 ([614f12a](https://github.com/Tencent/tmagic-editor/commit/614f12adf3174a4dadac028bda27057d18831a81))
|
||||
* **editor:** 支持按历史记录 uuid 回滚 ([bddc6f3](https://github.com/Tencent/tmagic-editor/commit/bddc6f343cc97d3034c869c3fc46780759134f7c))
|
||||
* **editor:** 支持页面初始基线与 root 变更历史记录 ([4f284e8](https://github.com/Tencent/tmagic-editor/commit/4f284e8d9cf6af9af234d345c14a2bf9176e5284))
|
||||
* **editor:** 页面删除前增加确认弹窗并支持危险样式按钮 ([113af7d](https://github.com/Tencent/tmagic-editor/commit/113af7dd5104f5f49515abd66f12f5e62098f7e2))
|
||||
* **editor:** 页面历史记录点击选中对应画布节点 ([fd652b0](https://github.com/Tencent/tmagic-editor/commit/fd652b0d13a2bf87db55d03013dc2c9ff01ff45d))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **editor:** 优化节点信息查找性能 ([c4ec2c5](https://github.com/Tencent/tmagic-editor/commit/c4ec2c5c722963c95141ac2d2ddf94d952d2e47d))
|
||||
|
||||
|
||||
|
||||
# [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)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **form:** 对比模式下无 name 字段时不展示差异 ([64d35d5](https://github.com/Tencent/tmagic-editor/commit/64d35d53631698e8d94362765a1621654bd3d1f6))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **editor:** 历史记录列表展示时间并优化回滚差异弹窗 ([a9e9e65](https://github.com/Tencent/tmagic-editor/commit/a9e9e65f9c50e47b22de8eab7184cebd87632bc6))
|
||||
* **editor:** 历史记录差异对比弹窗关闭时派发 close 事件 ([42162f2](https://github.com/Tencent/tmagic-editor/commit/42162f2e4ac651ad78ff2f5291e00639a658a1ae))
|
||||
* **editor:** 历史记录面板支持自定义扩展 tab 并开放 Bucket/goto 配置 ([8612311](https://github.com/Tencent/tmagic-editor/commit/8612311db12a22adcc30188ae1ead03729fa6a7a))
|
||||
* **editor:** 对比表单支持自定义 loadConfig 加载逻辑 ([1cd69b3](https://github.com/Tencent/tmagic-editor/commit/1cd69b33fecd75fe8522d9a261e1c03e806ecf69))
|
||||
* **form:** fieldset legend 支持函数动态生成标题 ([35fc394](https://github.com/Tencent/tmagic-editor/commit/35fc39419902e14e2d5bdf98f99802f05a4b5934))
|
||||
* **form:** submitForm 支持返回 changeRecords ([12069e0](https://github.com/Tencent/tmagic-editor/commit/12069e0937589cf9b7684e4bd5ed927e15462513))
|
||||
* **stage:** 非点击画布选中组件时高亮闪烁选中区域 ([444d422](https://github.com/Tencent/tmagic-editor/commit/444d4223a943d763a33b752ffbbfa704591820ca))
|
||||
|
||||
|
||||
|
||||
# [1.8.0-beta.2](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.1...v1.8.0-beta.2) (2026-05-29)
|
||||
|
||||
|
||||
|
||||
@ -201,16 +201,7 @@ export default defineConfig({
|
||||
},
|
||||
{
|
||||
text: 'uiService',
|
||||
items: [
|
||||
{
|
||||
text: '方法',
|
||||
link: '/api/editor/uiServiceMethods.md',
|
||||
},
|
||||
{
|
||||
text: '事件',
|
||||
link: '/api/editor/uiServiceEvents.md',
|
||||
},
|
||||
],
|
||||
link: '/api/editor/uiServiceMethods.md',
|
||||
},
|
||||
{
|
||||
text: 'codeBlockService',
|
||||
|
||||
@ -1,8 +1,5 @@
|
||||
# codeBlockService方法
|
||||
|
||||
写入历史栈的方法([setCodeDslById](#setcodedslbyid)、[setCodeDslByIdSync](#setcodedslbyidsync)、[deleteCodeDslByIds](#deletecodedslbyids) 等)的 `options` 支持
|
||||
[historyDescription / historySource](./editorServiceMethods.md#历史记录相关-options),会透传到 `historyService.pushCodeBlock` 的 `historyDescription` / `source` 字段。
|
||||
|
||||
## setCodeDsl
|
||||
|
||||
- **参数:**
|
||||
@ -54,8 +51,6 @@
|
||||
- `{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}
|
||||
@ -77,8 +72,6 @@
|
||||
- `{Object}` options 可选配置
|
||||
- {`ChangeRecord`[]} changeRecords form 端变更记录,用于历史记录的精细化撤销/重做
|
||||
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false)
|
||||
- `{string}` historyDescription 见 [editorService 历史记录相关 options](./editorServiceMethods.md#历史记录相关-options)
|
||||
- `{HistoryOpSource}` historySource 见 [editorService 历史记录相关 options](./editorServiceMethods.md#历史记录相关-options)
|
||||
|
||||
- **返回:**
|
||||
- `{void}`
|
||||
@ -220,8 +213,6 @@
|
||||
- `{(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>}`
|
||||
@ -235,86 +226,6 @@
|
||||
`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
|
||||
|
||||
- **参数:**
|
||||
|
||||
@ -300,8 +300,6 @@ 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`} 添加后的数据源配置
|
||||
@ -340,8 +338,6 @@ 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}
|
||||
@ -383,8 +379,6 @@ 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}`
|
||||
@ -406,78 +400,6 @@ 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#行为扩展):** 是
|
||||
|
||||
@ -1,48 +1,5 @@
|
||||
# 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
|
||||
|
||||
- **参数:**
|
||||
@ -402,8 +359,6 @@ 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`[]>} 新增的组件或组件集合
|
||||
@ -450,8 +405,6 @@ editorService.highlight("text_123");
|
||||
- `{boolean}` doNotSelect 删除后是否不更新当前选中节点(默认 false,删除后会选中父节点或首个页面)
|
||||
- `{boolean}` doNotSwitchPage 删除后是否不切换当前页面(默认 false;删除页面 / 页面片段时为 true 会跳过自动切换到首个剩余页面)
|
||||
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false)
|
||||
- `{string}` historyDescription 见[历史记录相关 options](#历史记录相关-options)
|
||||
- `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options)
|
||||
|
||||
- **返回:**
|
||||
- `{Promise<void>}`
|
||||
@ -506,8 +459,6 @@ 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}
|
||||
@ -549,7 +500,6 @@ editorService.highlight("text_123");
|
||||
- `{boolean}` doNotSelect 排序后是否不更新当前选中节点(默认 false)
|
||||
- `{boolean}` doNotSwitchPage 排序后是否不切换当前页面(排序只发生在同一父节点内,方法内为空操作;保留以与其它 DSL 操作 API 一致)
|
||||
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false)
|
||||
- `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options)
|
||||
|
||||
- **返回:**
|
||||
- `{Promise<void>}`
|
||||
@ -618,8 +568,6 @@ 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`[]>} 添加后的组件节点配置
|
||||
@ -658,8 +606,6 @@ 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`[]>}
|
||||
@ -682,8 +628,6 @@ alignCenter可以支持一次水平居中多个组件,alignCenter是通过调
|
||||
- `{number | 'top' | 'bottom'}` offset
|
||||
- `{Object}` options 可选配置
|
||||
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false)
|
||||
- `{string}` historyDescription 见[历史记录相关 options](#历史记录相关-options)
|
||||
- `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options)
|
||||
|
||||
- **返回:**
|
||||
- `{Promise<void>}`
|
||||
@ -705,8 +649,6 @@ alignCenter可以支持一次水平居中多个组件,alignCenter是通过调
|
||||
- `{boolean}` doNotSelect 移动后是否不更新当前选中节点(默认 false)
|
||||
- `{boolean}` doNotSwitchPage 移动后是否不切换当前页面(默认 false;目标容器位于其它页面时为 true 会跳过自动选中以避免页面切换)
|
||||
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false)
|
||||
- `{string}` historyDescription 见[历史记录相关 options](#历史记录相关-options)
|
||||
- `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options)
|
||||
|
||||
- **返回:**
|
||||
- Promise<`MNode` | undefined>
|
||||
@ -723,8 +665,6 @@ alignCenter可以支持一次水平居中多个组件,alignCenter是通过调
|
||||
- `{number}` targetIndex 目标位置索引
|
||||
- `{Object}` options 可选配置
|
||||
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false)
|
||||
- `{string}` historyDescription 见[历史记录相关 options](#历史记录相关-options)
|
||||
- `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options)
|
||||
|
||||
- **返回:**
|
||||
- `{Promise<void>}`
|
||||
@ -733,115 +673,6 @@ 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#行为扩展):** 是
|
||||
@ -854,8 +685,6 @@ if (historyId) {
|
||||
|
||||
<<< @/../packages/editor/src/type.ts#HistoryOpType{ts}
|
||||
|
||||
<<< @/../packages/editor/src/type.ts#HistoryOpSource{ts}
|
||||
|
||||
<<< @/../packages/schema/src/index.ts#Id{ts}
|
||||
:::
|
||||
|
||||
@ -870,16 +699,6 @@ if (historyId) {
|
||||
- **返回:**
|
||||
- {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}
|
||||
:::
|
||||
|
||||
- **详情:**
|
||||
|
||||
恢复到下一步
|
||||
@ -893,8 +712,6 @@ if (historyId) {
|
||||
- `{number}` top
|
||||
- `{Object}` options 可选配置
|
||||
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false)
|
||||
- `{string}` historyDescription 见[历史记录相关 options](#历史记录相关-options)
|
||||
- `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options)
|
||||
|
||||
- **返回:**
|
||||
- `{Promise<void>}`
|
||||
|
||||
@ -21,8 +21,6 @@
|
||||
|
||||
<<< @/../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}
|
||||
@ -41,8 +39,6 @@
|
||||
::: 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}
|
||||
@ -63,8 +59,6 @@
|
||||
::: details 查看 DataSourceStepValue 及关联类型定义
|
||||
<<< @/../packages/editor/src/type.ts#DataSourceStepValue{ts}
|
||||
|
||||
<<< @/../packages/editor/src/type.ts#HistoryOpSource{ts}
|
||||
|
||||
<<< @/../packages/schema/src/index.ts#Id{ts}
|
||||
:::
|
||||
|
||||
@ -73,36 +67,3 @@
|
||||
- 删除触发的 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}
|
||||
:::
|
||||
|
||||
@ -43,8 +43,6 @@
|
||||
|
||||
<<< @/../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}
|
||||
@ -63,14 +61,6 @@
|
||||
`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
|
||||
@ -101,14 +91,10 @@
|
||||
- `{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}
|
||||
@ -186,14 +172,10 @@
|
||||
- `{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}
|
||||
:::
|
||||
|
||||
@ -260,122 +242,6 @@
|
||||
|
||||
指定数据源当前是否可重做。栈不存在时返回 `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
|
||||
|
||||
- **详情:**
|
||||
|
||||
@ -1220,28 +1220,6 @@ const guidesOptions = {
|
||||
</template>
|
||||
```
|
||||
|
||||
## disabledFlashTip
|
||||
|
||||
- **详情:**
|
||||
|
||||
禁用「非点击画布选中组件时的高亮闪烁提示」。
|
||||
|
||||
当组件不是通过点击画布选中(如从组件树、面包屑等外部方式选中)时,编辑器会在画布上对选中区域做一次高亮闪烁,帮助用户快速定位组件在画布中的位置。设置为 `true` 可关闭该提示。
|
||||
|
||||
注:选中页面(`magic-ui-page`)时不会触发闪烁。
|
||||
|
||||
- **默认值:** `false`
|
||||
|
||||
- **类型:** `boolean`
|
||||
|
||||
- **示例:**
|
||||
|
||||
```html
|
||||
<template>
|
||||
<m-editor :disabled-flash-tip="true"></m-editor>
|
||||
</template>
|
||||
```
|
||||
|
||||
## disabledStageOverlay
|
||||
|
||||
- **详情:**
|
||||
@ -1530,55 +1508,6 @@ const extendFormState = async (state) => {
|
||||
```
|
||||
:::
|
||||
|
||||
## historyListExtraTabs
|
||||
|
||||
- **详情:**
|
||||
|
||||
[历史记录面板](/guide/advanced/history-list.md) 的自定义扩展 tab。
|
||||
|
||||
业务方可借此在历史记录面板内置的「页面 / 数据源 / 代码块」三个 tab 之后追加自定义模块的历史 tab,例如某个自定义模块维护自己的操作历史时,可在面板中增加一个独立的 tab 来展示与回滚。
|
||||
|
||||
- **默认值:** `[]`
|
||||
|
||||
- **类型:** `HistoryListExtraTab[]`
|
||||
|
||||
::: details 查看 HistoryListExtraTab 类型定义
|
||||
<<< @/../packages/editor/src/type.ts#HistoryListExtraTab{ts}
|
||||
:::
|
||||
|
||||
- **示例:**
|
||||
|
||||
```html
|
||||
<template>
|
||||
<m-editor :menu="menu" :history-list-extra-tabs="historyListExtraTabs"></m-editor>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { markRaw } from 'vue';
|
||||
|
||||
import MyModuleHistoryTab from './MyModuleHistoryTab.vue';
|
||||
|
||||
const historyListExtraTabs = [
|
||||
{
|
||||
name: 'my-module',
|
||||
// label 支持字符串或函数,函数形式便于展示动态数量
|
||||
label: () => '我的模块',
|
||||
component: markRaw(MyModuleHistoryTab),
|
||||
// 传入内容组件的 props
|
||||
props: { foo: 'bar' },
|
||||
// 内容组件的事件监听
|
||||
listeners: {
|
||||
goto: (cursor) => console.log(cursor),
|
||||
},
|
||||
},
|
||||
];
|
||||
</script>
|
||||
```
|
||||
|
||||
::: tip
|
||||
内容组件内部可自行通过 `useServices()` 获取 `historyService` 等服务来读取与回滚自定义模块的历史。
|
||||
:::
|
||||
|
||||
## pageBarSortOptions
|
||||
|
||||
- **详情:**
|
||||
|
||||
@ -1,28 +0,0 @@
|
||||
# uiService事件
|
||||
|
||||
## state-change
|
||||
|
||||
- **详情:** UI 状态发生变化时触发,[uiService.set()](./uiServiceMethods.md#set) 在写入的新值与旧值不同时触发
|
||||
|
||||
- **事件回调函数:** `(name: keyof UiState, value: UiState[typeof name], preValue: UiState[typeof name]) => void`
|
||||
|
||||
::: details 查看 UiState 类型定义
|
||||
<<< @/../packages/editor/src/type.ts#UiState{ts}
|
||||
:::
|
||||
|
||||
- **示例:**
|
||||
|
||||
```js
|
||||
import { uiService } from '@tmagic/editor';
|
||||
|
||||
uiService.on('state-change', (name, value, preValue) => {
|
||||
console.log(`${name} 从`, preValue, '变为', value);
|
||||
});
|
||||
|
||||
uiService.set('zoom', 1.5);
|
||||
```
|
||||
|
||||
:::tip
|
||||
- 新值与旧值相同时不会触发该事件
|
||||
- 通过 `set('stageRect', value)` 修改画布尺寸时,内部会走 `setStageRect` 逻辑并可能联动更新 `zoom`,但不会触发 `state-change` 事件
|
||||
:::
|
||||
@ -13,7 +13,7 @@
|
||||
|
||||
- **详情:**
|
||||
|
||||
设置UI服务的状态。新值与旧值不同时会触发 [`state-change`](./uiServiceEvents.md#state-change) 事件
|
||||
设置UI服务的状态
|
||||
|
||||
可用的状态键:
|
||||
- `uiSelectMode`: UI选择模式
|
||||
@ -31,7 +31,6 @@
|
||||
- `showPageListButton`: 是否显示页面列表按钮
|
||||
- `hideSlideBar`: 是否隐藏侧边栏
|
||||
- `sideBarItems`: 侧边栏项目
|
||||
- `sideBarActiveTabName`: 当前激活的侧边栏面板
|
||||
- `navMenuRect`: 导航菜单尺寸
|
||||
- `frameworkRect`: 框架尺寸
|
||||
|
||||
|
||||
@ -18,7 +18,7 @@ function submitForm(options: SubmitFormOptions): Promise<any>;
|
||||
|
||||
## 参数
|
||||
|
||||
`options` 与 `MForm` 组件的 props 基本对齐,额外提供了 `native`、`returnChangeRecords`、`appContext`、`timeout` 等参数。
|
||||
`options` 与 `MForm` 组件的 props 基本对齐,额外提供了 `native`、`appContext`、`timeout` 三个参数。
|
||||
|
||||
| 名称 | 类型 | 默认值 | 说明 |
|
||||
| ---------------------- | ------------------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------- |
|
||||
@ -39,22 +39,17 @@ function submitForm(options: SubmitFormOptions): Promise<any>;
|
||||
| `preventSubmitDefault` | `boolean` | — | 是否阻止表单原生 submit |
|
||||
| `extendState` | `(state: FormState) => Record<string, any> \| Promise<Record<string, any>>` | — | 扩展 `formState` |
|
||||
| `native` | `boolean` | `false` | 透传给 `Form.submitForm`。`true` 时返回内部响应式 `values`,否则返回 `cloneDeep(toRaw(values))` |
|
||||
| `returnChangeRecords` | `boolean` | `false` | `true` 时 resolve 结果为 `{ values, changeRecords }`,携带表单变更记录;否则仅 resolve `values` |
|
||||
| `appContext` | `AppContext \| null` | `null` | 父级 Vue 应用上下文。需要继承全局组件、指令、provide 等时传入,常通过 `app._context` 或 `getCurrentInstance()?.appContext` 获取 |
|
||||
| `timeout` | `number` | `10000` | 等待表单初始化的最长时间(毫秒)。超时将以错误 reject。设为 `<= 0` 时关闭超时兜底 |
|
||||
|
||||
## 返回值
|
||||
|
||||
- `校验通过` — `Promise<any>` resolve 当前表单值(`native` 决定是否克隆);当 `returnChangeRecords` 为 `true` 时,resolve `{ values, changeRecords }`
|
||||
- `校验通过` — `Promise<any>` resolve 当前表单值(`native` 决定是否克隆)
|
||||
- `校验失败` — `Promise<any>` reject 一个 `Error`,`message` 中包含逐条字段错误信息(格式 `${text} -> ${message}`,多条用 `<br>` 分隔)
|
||||
- `初始化超时` — `Promise<any>` reject `Error('submitForm timeout after ${timeout}ms: form is not initialized.')`
|
||||
|
||||
无论成功或失败,函数都会在最后自动 `unmount` 内部 app 并移除挂载用的 DOM 容器,无需调用方手动清理。
|
||||
|
||||
::: tip 关于 changeRecords
|
||||
`changeRecords` 记录的是表单挂载后发生的字段变更(由各字段的 `change` 事件累积而来)。在 `submitForm` 这种命令式、无用户交互的场景下,通常为空数组;只有在 `extendState` 或字段联动等逻辑中触发了变更时才会有内容。`MForm` 内部的 `submitForm` 在校验通过后会清空变更记录,因此本函数会在调用前先对其做快照再返回。
|
||||
:::
|
||||
|
||||
## 基础用法
|
||||
|
||||
```ts
|
||||
@ -78,23 +73,6 @@ try {
|
||||
}
|
||||
```
|
||||
|
||||
## 同时获取变更记录(changeRecords)
|
||||
|
||||
设置 `returnChangeRecords: true` 后,resolve 的结果会从单纯的 `values` 变为 `{ values, changeRecords }`:
|
||||
|
||||
```ts
|
||||
import { submitForm } from '@tmagic/form';
|
||||
|
||||
const { values, changeRecords } = await submitForm({
|
||||
config: [{ type: 'text', name: 'username', text: '用户名' }],
|
||||
initValues: { username: 'foo' },
|
||||
returnChangeRecords: true,
|
||||
});
|
||||
|
||||
console.log(values); // { username: 'foo' }
|
||||
console.log(changeRecords); // ChangeRecord[]
|
||||
```
|
||||
|
||||
## 在组件中继承父级应用上下文
|
||||
|
||||
`MForm` 内部使用 `@tmagic/design` 的组件(背后可能是 `element-plus` 或 `tdesign`),需要宿主应用先完成相应的 `app.use(...)` 安装。在 `submitForm` 这种脱离常规组件树的命令式调用中,可通过 `appContext` 把父级应用上下文带过去:
|
||||
@ -212,7 +190,3 @@ console.log(values);
|
||||
::: details 查看 `SubmitFormOptions` 类型定义
|
||||
<<< @/../packages/form/src/submitForm.ts#SubmitFormOptions{ts}
|
||||
:::
|
||||
|
||||
::: details 查看 `SubmitFormResult` 类型定义
|
||||
<<< @/../packages/form/src/submitForm.ts#SubmitFormResult{ts}
|
||||
:::
|
||||
|
||||
@ -135,18 +135,6 @@
|
||||
}]
|
||||
}]"></demo-block>
|
||||
|
||||
`legend` 除了支持字符串,也支持函数,函数返回值作为标题展示,可根据表单数据动态生成:
|
||||
|
||||
<demo-block type="form" :config="[{
|
||||
type: 'fieldset',
|
||||
labelWidth: '100px',
|
||||
legend: (mForm, { formValue }) => `当前值:${formValue.text || '空'}`,
|
||||
items: [{
|
||||
name: 'text',
|
||||
text: '配置1',
|
||||
}]
|
||||
}]"></demo-block>
|
||||
|
||||
### panel
|
||||
|
||||
<demo-block type="form" :config="[{
|
||||
|
||||
@ -61,12 +61,6 @@ 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` 步骤上提供「查看差异」入口,点击后弹出差异对话框。对话框支持两个维度的切换:
|
||||
@ -82,47 +76,6 @@ const menu = ref({
|
||||
表单对比依赖 `@tmagic/form` 的对比模式(`isCompare` / `lastValues`)。对于 `event-select`、`code-select`、`code-select-col` 等由列表或嵌套子表单组成的复合字段,表单会逐项展示新增 / 删除 / 修改的高亮差异,并在对比模式下隐藏「添加 / 删除 / 编辑」等写操作按钮,仅保留只读展示。
|
||||
:::
|
||||
|
||||
## 扩展自定义 tab
|
||||
|
||||
内置的三个 tab 之外,业务方可以通过 Editor 的 [`historyListExtraTabs`](/api/editor/props.html#historylistextratabs) 在面板中追加自定义的历史 tab,追加在「页面 / 数据源 / 代码块」之后。适用于某个自定义模块维护自己的操作历史,需要在历史记录面板中独立展示与回滚的场景。
|
||||
|
||||
```html
|
||||
<template>
|
||||
<m-editor :menu="menu" :history-list-extra-tabs="historyListExtraTabs"></m-editor>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { markRaw } from 'vue';
|
||||
|
||||
import MyModuleHistoryTab from './MyModuleHistoryTab.vue';
|
||||
|
||||
const historyListExtraTabs = [
|
||||
{
|
||||
name: 'my-module',
|
||||
// label 支持字符串或函数,函数形式便于展示动态数量
|
||||
label: () => `我的模块 (${getMyModuleHistory().length})`,
|
||||
component: markRaw(MyModuleHistoryTab),
|
||||
props: { foo: 'bar' },
|
||||
listeners: {
|
||||
goto: (cursor) => console.log(cursor),
|
||||
},
|
||||
},
|
||||
];
|
||||
</script>
|
||||
```
|
||||
|
||||
每个扩展 tab 的字段说明:
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `name` | 是 | tab 唯一标识,作为内部 `TMagicTabs` 的 `name` |
|
||||
| `label` | 是 | tab 显示文案,支持字符串或返回字符串的函数(便于展示动态数量) |
|
||||
| `component` | 是 | tab 内容区渲染的组件 |
|
||||
| `props` | 否 | 传入内容组件的 props |
|
||||
| `listeners` | 否 | 内容组件的事件监听 |
|
||||
|
||||
> 内容组件内部可自行通过 `useServices()` 拿到 `historyService` 等服务,读取并回滚自定义模块自己维护的历史。
|
||||
|
||||
## 自定义对比判断
|
||||
|
||||
差异对话框中的「表单对比」最终透传到 `MForm`,你可以通过 Editor 顶层注入的 `extendFormState` 让对比表单拿到完整业务上下文,从而让依赖上下文的 `display` / `disabled` 等 `filterFunction` 正常工作。
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.8.0-beta.5",
|
||||
"version": "1.8.0-beta.2",
|
||||
"name": "tmagic",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.8.0-beta.5",
|
||||
"version": "1.8.0-beta.2",
|
||||
"name": "@tmagic/cli",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/index.d.ts",
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.8.0-beta.5",
|
||||
"version": "1.8.0-beta.2",
|
||||
"name": "@tmagic/core",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.8.0-beta.5",
|
||||
"version": "1.8.0-beta.2",
|
||||
"name": "@tmagic/data-source",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.8.0-beta.5",
|
||||
"version": "1.8.0-beta.2",
|
||||
"name": "@tmagic/dep",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.8.0-beta.5",
|
||||
"version": "1.8.0-beta.2",
|
||||
"name": "@tmagic/design",
|
||||
"type": "module",
|
||||
"sideEffects": [
|
||||
|
||||
@ -43,16 +43,8 @@ const props = withDefaults(defineProps<PopoverProps>(), {
|
||||
visible: undefined,
|
||||
tabindex: 0,
|
||||
destroyOnClose: false,
|
||||
closeOnClickOutside: true,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 受控模式(传入了 visible)下点击外部收起时触发,便于配合 v-model:visible。 */
|
||||
'update:visible': [_visible: boolean];
|
||||
/** 点击 popover 及其衍生浮层以外的区域时触发。 */
|
||||
clickoutside: [_event: MouseEvent];
|
||||
}>();
|
||||
|
||||
const popoverVisible = ref(false);
|
||||
|
||||
const visibleWatch = watch(
|
||||
@ -187,70 +179,6 @@ if (props.trigger === 'hover' && typeof props.visible === 'undefined') {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* popover 内部触发、却挂载到 body(在 popper 之外)的浮层:弹窗、二次确认框、tooltip、
|
||||
* 下拉 / 日期选择等。点击它们属于 popover 内部交互,不应顺带把 popover 关闭。
|
||||
*
|
||||
* 由于 @tmagic/design 通过适配器支持 element-plus、tdesign 等多套 UI 库,这里同时列出
|
||||
* 两套库的浮层 class(class 名互不冲突,未命中的选择器无副作用),避免切换适配器后失效。
|
||||
*/
|
||||
const DEFAULT_CLICK_OUTSIDE_IGNORE = [
|
||||
// @tmagic/design 自身(与适配器无关)
|
||||
'.tmagic-design-dialog',
|
||||
// element-plus
|
||||
'.el-overlay',
|
||||
'.el-message-box',
|
||||
'.el-popper',
|
||||
'.el-select-dropdown',
|
||||
'.el-picker__popper',
|
||||
'.el-dropdown__popper',
|
||||
'.el-cascader__dropdown',
|
||||
// tdesign:弹窗 / 消息确认(DialogPlugin / MessagePlugin)与各类浮层(tooltip / select / dropdown / 日期选择等均挂在 .t-popup 内)
|
||||
'.t-dialog__ctx',
|
||||
'.t-dialog',
|
||||
'.t-message',
|
||||
'.t-popup',
|
||||
].join(',');
|
||||
|
||||
const clickOutsideIgnoreSelector = computed(() =>
|
||||
[DEFAULT_CLICK_OUTSIDE_IGNORE, props.clickOutsideIgnore].filter(Boolean).join(','),
|
||||
);
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (props.disabled) return;
|
||||
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (!target) return;
|
||||
|
||||
// 点击 reference、popper 自身或衍生浮层时保持打开
|
||||
if (referenceElementRef.value?.contains(target)) return;
|
||||
if (popperElementRef.value?.contains(target)) return;
|
||||
if (target.closest(clickOutsideIgnoreSelector.value)) return;
|
||||
|
||||
emit('clickoutside', e);
|
||||
|
||||
// 非受控:直接收起;受控:通过 update:visible 通知父级(可配合 v-model:visible)
|
||||
if (typeof props.visible === 'undefined') {
|
||||
popoverVisible.value = false;
|
||||
} else {
|
||||
emit('update:visible', false);
|
||||
}
|
||||
};
|
||||
|
||||
const bindClickOutside = () => globalThis.document?.addEventListener('click', handleClickOutside);
|
||||
const unbindClickOutside = () => globalThis.document?.removeEventListener('click', handleClickOutside);
|
||||
|
||||
watch(popoverVisible, (visible) => {
|
||||
if (!props.closeOnClickOutside) return;
|
||||
|
||||
if (visible) {
|
||||
// 延后到「打开 popover 的这一次点击」冒泡结束后再监听,避免刚打开就被立即关闭
|
||||
nextTick(bindClickOutside);
|
||||
} else {
|
||||
unbindClickOutside();
|
||||
}
|
||||
});
|
||||
|
||||
const destroy = () => {
|
||||
if (!instanceRef.value) return;
|
||||
|
||||
@ -260,6 +188,5 @@ const destroy = () => {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
destroy();
|
||||
unbindClickOutside();
|
||||
});
|
||||
</script>
|
||||
|
||||
@ -258,13 +258,6 @@ export interface PopoverProps {
|
||||
popperClass?: string;
|
||||
tabindex?: number;
|
||||
destroyOnClose?: boolean;
|
||||
/** 点击 popover 及其衍生浮层以外的区域时收起,默认开启。 */
|
||||
closeOnClickOutside?: boolean;
|
||||
/**
|
||||
* 追加的「点击不关闭」选择器,会与内置的弹窗 / 确认框 / 下拉等浮层选择器合并。
|
||||
* 用于 popover 内部触发、挂载到 body 之外的浮层不应顺带关闭 popover 的场景。
|
||||
*/
|
||||
clickOutsideIgnore?: string;
|
||||
}
|
||||
|
||||
export interface RadioProps {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.8.0-beta.5",
|
||||
"version": "1.8.0-beta.2",
|
||||
"name": "@tmagic/editor",
|
||||
"type": "module",
|
||||
"sideEffects": [
|
||||
|
||||
@ -221,7 +221,6 @@ const stageOptions: StageOptions = {
|
||||
guidesOptions: props.guidesOptions,
|
||||
disabledMultiSelect: props.disabledMultiSelect,
|
||||
alwaysMultiSelect: props.alwaysMultiSelect,
|
||||
disabledFlashTip: props.disabledFlashTip,
|
||||
beforeDblclick: props.beforeDblclick,
|
||||
};
|
||||
|
||||
@ -238,13 +237,6 @@ provide('stageOptions', stageOptions);
|
||||
*/
|
||||
provide('extendFormState', props.extendFormState);
|
||||
|
||||
/**
|
||||
* 把历史记录面板的自定义扩展 tab 提供给深层的 HistoryListPanel(它挂在 NavMenu 中,
|
||||
* 以 markRaw component 形式渲染,无法直接通过 props 透传)。业务方可借此在历史记录
|
||||
* 面板内追加自定义模块的历史 tab。
|
||||
*/
|
||||
provide('historyListExtraTabs', props.historyListExtraTabs);
|
||||
|
||||
provide<EventBus>('eventBus', new EventEmitter());
|
||||
|
||||
const propsPanelMountedHandler = (e: InstanceType<typeof FormPanel>) => {
|
||||
|
||||
@ -24,13 +24,20 @@ import { type CodeBlockContent, type DataSourceSchema, HookType, type MNode } fr
|
||||
import { type FormConfig, type FormState, type FormValue, MForm } from '@tmagic/form';
|
||||
|
||||
import { useServices } from '@editor/hooks/use-services';
|
||||
import type { CompareCategory, CompareFormLoadConfig } from '@editor/type';
|
||||
import { getCodeBlockFormConfig } from '@editor/utils/code-block';
|
||||
|
||||
defineOptions({
|
||||
name: 'MEditorCompareForm',
|
||||
});
|
||||
|
||||
/**
|
||||
* 对比类型:
|
||||
* - node: 节点组件,按 `type` 从 propsService 获取属性表单配置
|
||||
* - data-source: 数据源,按 `type`(base/http/...) 从 dataSourceService 获取数据源表单配置
|
||||
* - code-block: 数据源代码块,使用内置的代码块表单配置
|
||||
*/
|
||||
export type CompareCategory = 'node' | 'data-source' | 'code-block';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 当前值(修改后的值) */
|
||||
@ -61,12 +68,6 @@ const props = withDefaults(
|
||||
* 因此在差异对比场景下也需要透传,避免出现 `formState.xxx is undefined` 的运行时错误。
|
||||
*/
|
||||
extendState?: (_state: FormState) => Record<string, any> | Promise<Record<string, any>>;
|
||||
/**
|
||||
* 自定义 FormConfig 加载逻辑。传入后将接管内置的按 `category`(node/data-source/code-block)
|
||||
* 取配置逻辑,调用方可根据业务自行返回(或异步返回)表单配置。可通过
|
||||
* `ctx.defaultLoadConfig()` 复用默认结果再做二次加工。返回的 config 直接用于对比展示。
|
||||
*/
|
||||
loadConfig?: CompareFormLoadConfig;
|
||||
}>(),
|
||||
{
|
||||
category: 'node',
|
||||
@ -152,43 +153,22 @@ 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`,方便复用与二次加工。
|
||||
*/
|
||||
const defaultLoadConfig = async (): Promise<FormConfig> => {
|
||||
const loadConfig = async () => {
|
||||
switch (props.category) {
|
||||
case 'node': {
|
||||
if (!props.type) {
|
||||
return [];
|
||||
config.value = [];
|
||||
return;
|
||||
}
|
||||
return removeStyleDisplayConfig(
|
||||
await propsService.getPropsConfig(props.type, { node: props.value as unknown as MNode }),
|
||||
);
|
||||
config.value = await propsService.getPropsConfig(props.type);
|
||||
break;
|
||||
}
|
||||
case 'data-source': {
|
||||
return dataSourceService.getFormConfig(props.type || 'base');
|
||||
config.value = dataSourceService.getFormConfig(props.type || 'base');
|
||||
break;
|
||||
}
|
||||
case 'code-block': {
|
||||
return getCodeBlockFormConfig({
|
||||
config.value = getCodeBlockFormConfig({
|
||||
paramColConfig: codeBlockService.getParamsColConfig(),
|
||||
// 通过传入 dataSourceType 间接表达"是数据源代码块"——在对比场景下 props.dataSourceType
|
||||
// 由调用方按 step 上下文显式传入,未传则视为普通代码块,「执行时机」字段隐藏。
|
||||
@ -198,28 +178,15 @@ const defaultLoadConfig = async (): Promise<FormConfig> => {
|
||||
// 对比模式只读,不需要校验/语法检查
|
||||
editable: false,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return [];
|
||||
config.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const loadConfig = async () => {
|
||||
if (props.loadConfig) {
|
||||
config.value = await props.loadConfig({
|
||||
category: props.category,
|
||||
type: props.type,
|
||||
dataSourceType: props.dataSourceType,
|
||||
defaultLoadConfig,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
config.value = await defaultLoadConfig();
|
||||
};
|
||||
|
||||
watch(
|
||||
[() => props.category, () => props.type, () => props.dataSourceType, () => props.loadConfig],
|
||||
[() => props.category, () => props.type, () => props.dataSourceType],
|
||||
() => {
|
||||
loadConfig();
|
||||
},
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
|
||||
<template v-else-if="data.type === 'button'">
|
||||
<TMagicTooltip v-if="data.tooltip" effect="dark" placement="bottom-start" :content="data.tooltip">
|
||||
<TMagicButton size="small" link :disabled="disabled" v-bind="data.buttonProps || {}">
|
||||
<TMagicButton size="small" link :disabled="disabled">
|
||||
<template #icon v-if="data.icon">
|
||||
<MIcon :icon="data.icon"></MIcon>
|
||||
</template>
|
||||
@ -21,7 +21,7 @@
|
||||
</TMagicButton>
|
||||
</TMagicTooltip>
|
||||
|
||||
<TMagicButton v-else size="small" link :disabled="disabled" :title="data.text" v-bind="data.buttonProps || {}">
|
||||
<TMagicButton v-else size="small" link :disabled="disabled" :title="data.text">
|
||||
<template #icon v-if="data.icon">
|
||||
<MIcon :icon="data.icon"></MIcon>
|
||||
</template>
|
||||
|
||||
@ -15,7 +15,6 @@ import type {
|
||||
ComponentGroup,
|
||||
CustomContentMenuFunction,
|
||||
DatasourceTypeOption,
|
||||
HistoryListExtraTab,
|
||||
IsExpandableFunction,
|
||||
MenuBarData,
|
||||
MenuButton,
|
||||
@ -87,8 +86,6 @@ export interface EditorProps {
|
||||
alwaysMultiSelect?: boolean;
|
||||
/** 禁用页面片 */
|
||||
disabledPageFragment?: boolean;
|
||||
/** 禁用「非点击画布选中组件时(如从图层树、面包屑等外部选中),对选中区域做高亮闪烁提示」,默认 false(即默认开启闪烁) */
|
||||
disabledFlashTip?: boolean;
|
||||
/** 禁用双击在浮层中单独编辑选中组件 */
|
||||
disabledStageOverlay?: boolean;
|
||||
/** 禁用属性配置面板右下角显示源码的按钮 */
|
||||
@ -128,8 +125,6 @@ export interface EditorProps {
|
||||
/** 组件树节点双击前的钩子函数,返回 false 则阻止默认的双击行为 */
|
||||
beforeLayerNodeDblclick?: (event: MouseEvent, data: TreeNodeData) => Promise<boolean | void> | boolean | void;
|
||||
extendFormState?: (state: FormState) => Record<string, any> | Promise<Record<string, any>>;
|
||||
/** 历史记录面板的自定义扩展 tab,追加在内置的页面/数据源/代码块 tab 之后 */
|
||||
historyListExtraTabs?: HistoryListExtraTab[];
|
||||
/** 页面顺序拖拽配置参数 */
|
||||
pageBarSortOptions?: PageBarSortOptions;
|
||||
/** 页面搜索函数 */
|
||||
@ -141,7 +136,6 @@ export const defaultEditorProps = {
|
||||
disabledMultiSelect: false,
|
||||
alwaysMultiSelect: false,
|
||||
disabledPageFragment: false,
|
||||
disabledFlashTip: false,
|
||||
disabledStageOverlay: false,
|
||||
containerHighlightClassName: CONTAINER_HIGHLIGHT_CLASS_NAME,
|
||||
containerHighlightDuration: 800,
|
||||
@ -151,7 +145,6 @@ export const defaultEditorProps = {
|
||||
disabledCodeBlock: false,
|
||||
componentGroupList: () => [],
|
||||
datasourceList: () => [],
|
||||
historyListExtraTabs: () => [],
|
||||
menu: () => ({ left: [], right: [] }),
|
||||
layerContentMenu: () => [],
|
||||
stageContentMenu: () => [],
|
||||
|
||||
@ -4,11 +4,11 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, reactive, watch } from 'vue';
|
||||
import serialize from 'serialize-javascript';
|
||||
|
||||
import type { CodeLinkConfig, FieldProps, MLink } from '@tmagic/form';
|
||||
|
||||
import { getEditorConfig } from '@editor/utils/config';
|
||||
import { serializeConfig } from '@editor/utils/editor';
|
||||
|
||||
defineOptions({
|
||||
name: 'MFieldsCodeLink',
|
||||
@ -47,7 +47,10 @@ watch(
|
||||
() => props.model[props.name],
|
||||
(value) => {
|
||||
modelValue.form = {
|
||||
[props.name]: serializeConfig(value),
|
||||
[props.name]: serialize(value, {
|
||||
space: 2,
|
||||
unsafe: true,
|
||||
}).replace(/"(\w+)":\s/g, '$1: '),
|
||||
};
|
||||
},
|
||||
{
|
||||
|
||||
@ -6,7 +6,7 @@ import { tMagicMessage } from '@tmagic/design';
|
||||
import type { ContainerChangeEventData } from '@tmagic/form';
|
||||
|
||||
import CodeBlockEditor from '@editor/components/CodeBlockEditor.vue';
|
||||
import type { HistoryOpSource, Services } from '@editor/type';
|
||||
import type { 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, { historySource }: { historySource?: HistoryOpSource } = {}) => {
|
||||
codeBlockService.deleteCodeDslByIds([key], { historySource });
|
||||
const deleteCode = async (key: string) => {
|
||||
codeBlockService.deleteCodeDslByIds([key]);
|
||||
};
|
||||
|
||||
const submitCodeBlockHandler = async (values: CodeBlockContent, eventData?: ContainerChangeEventData) => {
|
||||
@ -67,7 +67,6 @@ export const useCodeBlockEdit = (codeBlockService: Services['codeBlockService'])
|
||||
|
||||
await codeBlockService.setCodeDslById(codeId.value, values, {
|
||||
changeRecords: eventData?.changeRecords,
|
||||
historySource: 'props',
|
||||
});
|
||||
|
||||
codeBlockEditorRef.value?.hide();
|
||||
|
||||
@ -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, historySource: 'props' });
|
||||
dataSourceService.update(value, { changeRecords: eventData.changeRecords });
|
||||
} else {
|
||||
dataSourceService.add(value, { historySource: 'props' });
|
||||
dataSourceService.add(value);
|
||||
}
|
||||
|
||||
editDialog.value?.hide();
|
||||
|
||||
@ -48,7 +48,6 @@ export const useStage = (stageOptions: StageOptions) => {
|
||||
disabledMultiSelect: stageOptions.disabledMultiSelect,
|
||||
alwaysMultiSelect: stageOptions.alwaysMultiSelect,
|
||||
disabledRule: stageOptions.disabledRule,
|
||||
disabledFlashTip: stageOptions.disabledFlashTip,
|
||||
});
|
||||
|
||||
watch(
|
||||
@ -130,16 +129,16 @@ export const useStage = (stageOptions: StageOptions) => {
|
||||
});
|
||||
if (configs.length === 0) return;
|
||||
|
||||
editorService.update(configs, { changeRecordList, historySource: 'stage' });
|
||||
editorService.update(configs, { changeRecordList });
|
||||
});
|
||||
|
||||
stage.on('sort', (ev: SortEventData) => {
|
||||
editorService.sort(ev.src, ev.dist, { historySource: 'stage' });
|
||||
editorService.sort(ev.src, ev.dist);
|
||||
});
|
||||
|
||||
stage.on('remove', (ev: RemoveEventData) => {
|
||||
const nodes = ev.data.map(({ el }) => editorService.getNodeById(getIdFromEl()(el) || ''));
|
||||
editorService.remove(nodes.filter((node) => Boolean(node)) as MNode[], { historySource: 'stage' });
|
||||
editorService.remove(nodes.filter((node) => Boolean(node)) as MNode[]);
|
||||
});
|
||||
|
||||
stage.on('select-parent', () => {
|
||||
|
||||
@ -71,9 +71,6 @@ export { default as SplitView } from './components/SplitView.vue';
|
||||
export { default as Resizer } from './components/Resizer.vue';
|
||||
export { default as CodeBlockEditor } from './components/CodeBlockEditor.vue';
|
||||
export { default as CompareForm } from './components/CompareForm.vue';
|
||||
export { default as HistoryListBucket } from './layouts/history-list/Bucket.vue';
|
||||
export { default as HistoryListBucketTab } from './layouts/history-list/BucketTab.vue';
|
||||
export { default as HistoryDiffDialog } from './layouts/history-list/HistoryDiffDialog.vue';
|
||||
export { default as FloatingBox } from './components/FloatingBox.vue';
|
||||
export { default as Tree } from './components/Tree.vue';
|
||||
export { default as TreeNode } from './components/TreeNode.vue';
|
||||
|
||||
@ -55,7 +55,7 @@ export const initServiceState = (
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(modelValue) => {
|
||||
editorService.set('root', modelValue || null, { historySource: 'initial' });
|
||||
editorService.set('root', modelValue || null);
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
|
||||
@ -21,12 +21,12 @@ import { computed, nextTick, onBeforeUnmount, onMounted, onUnmounted, ref, useTe
|
||||
import { FullScreen } from '@element-plus/icons-vue';
|
||||
import { throttle } from 'lodash-es';
|
||||
import type * as Monaco from 'monaco-editor';
|
||||
import serialize from 'serialize-javascript';
|
||||
|
||||
import { TMagicButton } from '@tmagic/design';
|
||||
|
||||
import MIcon from '@editor/components/Icon.vue';
|
||||
import { getEditorConfig } from '@editor/utils/config';
|
||||
import { serializeConfig } from '@editor/utils/editor';
|
||||
import loadMonaco from '@editor/utils/monaco-editor';
|
||||
|
||||
defineOptions({
|
||||
@ -163,7 +163,10 @@ const toString = (v: string | any, language: string): string => {
|
||||
if (language === 'json') {
|
||||
value = JSON.stringify(v, null, 2);
|
||||
} else {
|
||||
value = serializeConfig(v);
|
||||
value = serialize(v, {
|
||||
space: 2,
|
||||
unsafe: true,
|
||||
}).replace(/"(\w+)":\s/g, '$1: ');
|
||||
}
|
||||
} else {
|
||||
value = v;
|
||||
|
||||
@ -168,7 +168,7 @@ onBeforeUnmount(() => {
|
||||
const saveCode = (value: string) => {
|
||||
try {
|
||||
const parseDSL = getEditorConfig('parseDSL');
|
||||
editorService.set('root', parseDSL(value), { historySource: 'root-code' });
|
||||
editorService.set('root', parseDSL(value));
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
@ -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, { historySource: 'toolbar' });
|
||||
node && editorService.remove(node);
|
||||
},
|
||||
});
|
||||
break;
|
||||
|
||||
@ -1,18 +1,33 @@
|
||||
<template>
|
||||
<div class="m-editor-history-list-bucket">
|
||||
<div class="m-editor-history-list-bucket-title">
|
||||
<span>{{ config.title }}</span>
|
||||
<span>{{ title }}</span>
|
||||
<code>{{ String(bucketId) }}</code>
|
||||
<span class="m-editor-history-list-bucket-count">{{ groups.length }} 组</span>
|
||||
</div>
|
||||
|
||||
<ul class="m-editor-history-list-ul">
|
||||
<GroupRow
|
||||
v-for="group in groups"
|
||||
:key="rowKey(group)"
|
||||
:group="toRow(group)"
|
||||
:expanded="isHistoryGroupExpanded(expanded, rowKey(group))"
|
||||
:goto-enabled="config.gotoEnabled"
|
||||
v-for="(group, gIdx) in groups"
|
||||
:key="`${prefix}-${bucketId}-${gIdx}`"
|
||||
:group-key="`${prefix}-${bucketId}-${gIdx}`"
|
||||
:applied="group.applied"
|
||||
:merged="group.steps.length > 1"
|
||||
:op-type="group.opType"
|
||||
:desc="describeGroup(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,
|
||||
}))
|
||||
"
|
||||
:is-current="group.isCurrent"
|
||||
:expanded="!!expanded[`${prefix}-${bucketId}-${gIdx}`]"
|
||||
@toggle="(key: string) => $emit('toggle', key)"
|
||||
@goto="(index: number) => $emit('goto', bucketId, index)"
|
||||
@diff-step="(index: number) => $emit('diff-step', bucketId, index)"
|
||||
@ -21,25 +36,17 @@
|
||||
<!--
|
||||
初始状态项:永远位于该 bucket 列表底部(同样按倒序展示,最底部 = 最早状态)。
|
||||
当 bucket 内所有 group 都未 applied 时即为当前位置。
|
||||
config.showInitial=false 时不展示(用于没有"撤销到初始状态"语义的自定义历史,如业务模块历史)。
|
||||
-->
|
||||
<InitialRow
|
||||
v-if="config.showInitial !== false"
|
||||
:is-current="isInitial"
|
||||
:goto-enabled="config.gotoEnabled"
|
||||
@goto-initial="$emit('goto-initial', bucketId)"
|
||||
/>
|
||||
<InitialRow :is-current="isInitial" @goto-initial="$emit('goto-initial', bucketId)" />
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup generic="T extends BaseStepValue = BaseStepValue">
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import type { BaseStepValue, HistoryBucketConfig } from '@editor/type';
|
||||
import type { HistoryOpType } from '@editor/type';
|
||||
|
||||
import type { HistoryBucketGroup, HistoryRowGroup } from './composables';
|
||||
import { isHistoryGroupExpanded, toRowGroup } from './composables';
|
||||
import GroupRow from './GroupRow.vue';
|
||||
import InitialRow from './InitialRow.vue';
|
||||
|
||||
@ -48,16 +55,26 @@ defineOptions({
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
/**
|
||||
* 该类历史的整体渲染配置(title / prefix / describe* / isStep* / showInitial / gotoEnabled)。
|
||||
* 由父组件按业务类型注入,组件内部按需读取,避免逐项透传多个 props。
|
||||
*/
|
||||
config: HistoryBucketConfig<T>;
|
||||
/** Bucket 标题,例如 "数据源" / "代码块",渲染在 bucket 头部。 */
|
||||
title: string;
|
||||
/** 当前 bucket 对应的目标 id(dataSource.id 或 codeBlock.id),同时用于组装子项的 key。 */
|
||||
bucketId: string | number;
|
||||
/** 子项 key 的命名空间前缀:`ds` 表示数据源,`cb` 表示代码块。与上层折叠状态 key 保持一致。 */
|
||||
prefix: 'ds' | 'cb';
|
||||
/** 当前 bucket 下的所有历史分组,按时间倒序展示(最近的操作在前)。 */
|
||||
groups: HistoryBucketGroup<T>[];
|
||||
/** 共享的折叠状态表(key -> 是否展开,缺省或 true 为展开、false 为收起),由顶层 panel 统一维护以便跨 tab 复用。 */
|
||||
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>;
|
||||
}>();
|
||||
|
||||
@ -77,15 +94,6 @@ 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>
|
||||
|
||||
@ -1,72 +0,0 @@
|
||||
<template>
|
||||
<div v-if="!buckets.length" class="m-editor-history-list-empty">暂无操作记录</div>
|
||||
<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 generic="T extends BaseStepValue = BaseStepValue">
|
||||
import { TMagicScrollbar } from '@tmagic/design';
|
||||
|
||||
import type { BaseStepValue, HistoryBucketConfig } from '@editor/type';
|
||||
|
||||
import Bucket from './Bucket.vue';
|
||||
import type { HistoryBucketGroup } from './composables';
|
||||
|
||||
defineOptions({
|
||||
name: 'MEditorHistoryListBucketTab',
|
||||
});
|
||||
|
||||
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。 */
|
||||
(_e: 'toggle', _key: string): void;
|
||||
/** 透传 Bucket 的 goto 事件,携带目标 id 与目标 step 索引。 */
|
||||
(_e: 'goto', _targetId: string | number, _index: number): void;
|
||||
/** 透传 Bucket 的 goto-initial 事件,携带目标 id(回到该目标未修改时的状态)。 */
|
||||
(_e: 'goto-initial', _targetId: string | number): void;
|
||||
/** 透传 Bucket 的 diff-step 事件,携带目标 id 与 step 索引。 */
|
||||
(_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>
|
||||
61
packages/editor/src/layouts/history-list/CodeBlockTab.vue
Normal file
61
packages/editor/src/layouts/history-list/CodeBlockTab.vue
Normal file
@ -0,0 +1,61 @@
|
||||
<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="`cb-${bucket.id}`"
|
||||
title="代码块"
|
||||
:bucket-id="bucket.id"
|
||||
prefix="cb"
|
||||
:groups="bucket.groups"
|
||||
:describe-group="describeCodeBlockGroup"
|
||||
:describe-step="describeCodeBlockStep"
|
||||
:is-step-diffable="isCodeBlockStepDiffable"
|
||||
: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>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { TMagicScrollbar } from '@tmagic/design';
|
||||
|
||||
import type { CodeBlockHistoryGroup, CodeBlockStepValue } from '@editor/type';
|
||||
|
||||
import Bucket from './Bucket.vue';
|
||||
import { describeCodeBlockGroup, describeCodeBlockStep } from './composables';
|
||||
|
||||
defineOptions({
|
||||
name: 'MEditorHistoryListCodeBlockTab',
|
||||
});
|
||||
|
||||
defineProps<{
|
||||
/**
|
||||
* 已按 codeBlock.id 聚拢成的 bucket 列表,每个 bucket 内部的 groups 已按时间倒序排好。
|
||||
* 空数组时显示空态。
|
||||
*/
|
||||
buckets: { id: string | number; groups: CodeBlockHistoryGroup[] }[];
|
||||
/** 共享的折叠状态表(key -> 是否展开),由顶层 panel 统一维护。本 tab 使用 `cb-${id}-${idx}` 作为 key。 */
|
||||
expanded: Record<string, boolean>;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
/** 透传子组件 Bucket 的 toggle 事件给上层 panel,由其更新 expanded。 */
|
||||
(_e: 'toggle', _key: string): void;
|
||||
/** 透传 Bucket 的 goto 事件,携带 codeBlock id 与目标 step 索引。 */
|
||||
(_e: 'goto', _codeBlockId: string | number, _index: number): void;
|
||||
/** 透传 Bucket 的 goto-initial 事件,携带 codeBlock id(回到该代码块未修改时的状态)。 */
|
||||
(_e: 'goto-initial', _codeBlockId: string | number): void;
|
||||
/** 透传 Bucket 的 diff-step 事件,携带 codeBlock id 与 step 索引。 */
|
||||
(_e: 'diff-step', _codeBlockId: string | number, _index: number): void;
|
||||
/** 透传 Bucket 的 revert-step 事件,携带 codeBlock id 与 step 索引(类 git revert)。 */
|
||||
(_e: 'revert-step', _codeBlockId: string | number, _index: number): void;
|
||||
}>();
|
||||
|
||||
/** 仅 update(前后 content 都存在)时可查看差异。 */
|
||||
const isCodeBlockStepDiffable = (step: CodeBlockStepValue) => Boolean(step.oldContent && step.newContent);
|
||||
</script>
|
||||
61
packages/editor/src/layouts/history-list/DataSourceTab.vue
Normal file
61
packages/editor/src/layouts/history-list/DataSourceTab.vue
Normal file
@ -0,0 +1,61 @@
|
||||
<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="`ds-${bucket.id}`"
|
||||
title="数据源"
|
||||
:bucket-id="bucket.id"
|
||||
prefix="ds"
|
||||
:groups="bucket.groups"
|
||||
:describe-group="describeDataSourceGroup"
|
||||
:describe-step="describeDataSourceStep"
|
||||
:is-step-diffable="isDataSourceStepDiffable"
|
||||
: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>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { TMagicScrollbar } from '@tmagic/design';
|
||||
|
||||
import type { DataSourceHistoryGroup, DataSourceStepValue } from '@editor/type';
|
||||
|
||||
import Bucket from './Bucket.vue';
|
||||
import { describeDataSourceGroup, describeDataSourceStep } from './composables';
|
||||
|
||||
defineOptions({
|
||||
name: 'MEditorHistoryListDataSourceTab',
|
||||
});
|
||||
|
||||
defineProps<{
|
||||
/**
|
||||
* 已按 dataSource.id 聚拢成的 bucket 列表,每个 bucket 内部的 groups 已按时间倒序排好。
|
||||
* 空数组时显示空态。
|
||||
*/
|
||||
buckets: { id: string | number; groups: DataSourceHistoryGroup[] }[];
|
||||
/** 共享的折叠状态表(key -> 是否展开),由顶层 panel 统一维护。本 tab 使用 `ds-${id}-${idx}` 作为 key。 */
|
||||
expanded: Record<string, boolean>;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
/** 透传子组件 Bucket 的 toggle 事件给上层 panel,由其更新 expanded。 */
|
||||
(_e: 'toggle', _key: string): void;
|
||||
/** 透传 Bucket 的 goto 事件,携带 dataSource id 与目标 step 索引。 */
|
||||
(_e: 'goto', _dataSourceId: string | number, _index: number): void;
|
||||
/** 透传 Bucket 的 goto-initial 事件,携带 dataSource id(回到该数据源未修改时的状态)。 */
|
||||
(_e: 'goto-initial', _dataSourceId: string | number): void;
|
||||
/** 透传 Bucket 的 diff-step 事件,携带 dataSource id 与 step 索引。 */
|
||||
(_e: 'diff-step', _dataSourceId: string | number, _index: number): void;
|
||||
/** 透传 Bucket 的 revert-step 事件,携带 dataSource id 与 step 索引(类 git revert)。 */
|
||||
(_e: 'revert-step', _dataSourceId: string | number, _index: number): void;
|
||||
}>();
|
||||
|
||||
/** 仅 update(前后 schema 都存在)时可查看差异。 */
|
||||
const isDataSourceStepDiffable = (step: DataSourceStepValue) => Boolean(step.oldSchema && step.newSchema);
|
||||
</script>
|
||||
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<li
|
||||
class="m-editor-history-list-item m-editor-history-list-group"
|
||||
:class="{ 'is-undone': !group.applied, 'is-merged': merged, 'is-current': group.isCurrent }"
|
||||
:class="{ 'is-undone': !applied, 'is-merged': merged, 'is-current': isCurrent }"
|
||||
>
|
||||
<div
|
||||
class="m-editor-history-list-group-head"
|
||||
@ -10,53 +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-${group.opType}`">{{ opLabel(group.opType) }}</span>
|
||||
<span class="m-editor-history-list-item-desc">{{ group.desc }}</span>
|
||||
|
||||
<span v-if="headSaved" class="m-editor-history-list-item-saved" title="该记录为最近一次保存的状态">已保存</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 v-if="isCurrent" class="m-editor-history-list-item-current">当前</span>
|
||||
<span
|
||||
v-if="!merged && (headRevertable || headDiffable || canHeadGoto)"
|
||||
class="m-editor-history-list-item-actions"
|
||||
v-if="!merged && headDiffable"
|
||||
class="m-editor-history-list-item-diff"
|
||||
title="查看修改差异"
|
||||
@click.stop="onDiffClick(subSteps[0].index)"
|
||||
>查看差异</span
|
||||
>
|
||||
<span
|
||||
v-if="headRevertable"
|
||||
class="m-editor-history-list-item-revert"
|
||||
title="将该步骤的修改作为一次新操作反向应用(不影响后续历史)"
|
||||
@click.stop="onRevertClick(group.subSteps[0].index)"
|
||||
>回滚</span
|
||||
>
|
||||
<span
|
||||
v-if="canHeadGoto"
|
||||
class="m-editor-history-list-item-goto"
|
||||
title="回到该记录"
|
||||
@click.stop="onGotoClick(group.subSteps[0].index)"
|
||||
>回到</span
|
||||
>
|
||||
<span
|
||||
v-if="headDiffable"
|
||||
class="m-editor-history-list-item-diff"
|
||||
title="查看修改差异"
|
||||
@click.stop="onDiffClick(group.subSteps[0].index)"
|
||||
>查看差异</span
|
||||
>
|
||||
</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>
|
||||
<span
|
||||
v-if="!merged && headRevertable"
|
||||
class="m-editor-history-list-item-revert"
|
||||
title="将该步骤的修改作为一次新操作反向应用(不影响后续历史)"
|
||||
@click.stop="onRevertClick(subSteps[0].index)"
|
||||
>回滚</span
|
||||
>
|
||||
<span v-if="merged" class="m-editor-history-list-group-toggle" :class="{ 'is-expanded': expanded }">▾</span>
|
||||
</div>
|
||||
|
||||
@ -64,46 +35,27 @@
|
||||
<li
|
||||
v-for="s in subStepsDisplay"
|
||||
:key="s.index"
|
||||
:class="{ 'is-undone': !s.applied, 'is-current': s.isCurrent, 'is-clickable': selectEnabled }"
|
||||
:title="subStepTitle(s)"
|
||||
@click="onSubStepClick(s.index)"
|
||||
:class="{ 'is-undone': !s.applied, 'is-current': s.isCurrent, 'is-clickable': !s.isCurrent }"
|
||||
:title="s.isCurrent ? '当前所在记录' : '点击跳转到该记录'"
|
||||
@click="onSubStepClick(s)"
|
||||
>
|
||||
<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="s.isCurrent" class="m-editor-history-list-item-current">当前</span>
|
||||
<span
|
||||
v-if="s.revertable || s.diffable || (gotoEnabled && !s.isCurrent)"
|
||||
class="m-editor-history-list-item-actions"
|
||||
v-if="s.diffable"
|
||||
class="m-editor-history-list-item-diff"
|
||||
title="查看修改差异"
|
||||
@click.stop="onDiffClick(s.index)"
|
||||
>查看差异</span
|
||||
>
|
||||
<span
|
||||
v-if="s.revertable"
|
||||
class="m-editor-history-list-item-revert"
|
||||
title="将该步骤的修改作为一次新操作反向应用(不影响后续历史)"
|
||||
@click.stop="onRevertClick(s.index)"
|
||||
>回滚</span
|
||||
>
|
||||
<span
|
||||
v-if="gotoEnabled && !s.isCurrent"
|
||||
class="m-editor-history-list-item-goto"
|
||||
title="回到该记录"
|
||||
@click.stop="onGotoClick(s.index)"
|
||||
>回到</span
|
||||
>
|
||||
<span
|
||||
v-if="s.diffable"
|
||||
class="m-editor-history-list-item-diff"
|
||||
title="查看修改差异"
|
||||
@click.stop="onDiffClick(s.index)"
|
||||
>查看差异</span
|
||||
>
|
||||
</span>
|
||||
<span
|
||||
v-if="sourceLabel(s.source)"
|
||||
class="m-editor-history-list-item-source"
|
||||
:title="`操作途径:${sourceLabel(s.source)}`"
|
||||
>{{ sourceLabel(s.source) }}</span
|
||||
v-if="s.revertable"
|
||||
class="m-editor-history-list-item-revert"
|
||||
title="将该步骤的修改作为一次新操作反向应用(不影响后续历史)"
|
||||
@click.stop="onRevertClick(s.index)"
|
||||
>回滚</span
|
||||
>
|
||||
<span v-if="s.time" class="m-editor-history-list-item-time" :title="s.timeTitle || s.time">{{ s.time }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
@ -112,51 +64,53 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import type { HistoryRowGroup, HistoryRowStep } from './composables';
|
||||
import { opLabel, sourceLabel } from './composables';
|
||||
import type { HistoryOpType } from '@editor/type';
|
||||
|
||||
import { opLabel } from './composables';
|
||||
|
||||
defineOptions({
|
||||
name: 'MEditorHistoryListGroupRow',
|
||||
});
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/**
|
||||
* 该组的视图模型(由 `toRowGroup` 统一派生):包含 key、应用状态、操作类型、描述、
|
||||
* 来源 / 时间等头部信息以及子步列表。原先散落的十余个扁平 props 收敛于此单一对象。
|
||||
*/
|
||||
group: HistoryRowGroup;
|
||||
/** 当前组是否处于展开状态(合并组默认展开)。仅在合并组(子步数 > 1)时生效,控制子步列表是否渲染。 */
|
||||
expanded: boolean;
|
||||
/**
|
||||
* 是否支持「跳转到该记录」(goto)。默认 true。
|
||||
* 为 false 时:单步组头部与子步条目都不再可点击跳转、也不会触发 goto 事件,
|
||||
* 仅保留合并组头部的展开 / 收起能力,以及查看差异、回滚等其它入口。
|
||||
*/
|
||||
gotoEnabled?: boolean;
|
||||
/**
|
||||
* 是否支持「点击记录选中对应节点」。默认 false(仅页面 tab 启用,数据源 / 代码块无节点概念)。
|
||||
* 为 true 时:点击单步组头部、子步条目或合并组头部都会发出 `select` 事件(携带对应 step 索引),
|
||||
* 由上层解析出节点 id 并在画布选中;合并组头部同时保留展开 / 收起能力。
|
||||
*/
|
||||
selectEnabled?: boolean;
|
||||
}>(),
|
||||
{
|
||||
gotoEnabled: true,
|
||||
selectEnabled: false,
|
||||
},
|
||||
);
|
||||
const props = defineProps<{
|
||||
/** 唯一标识当前组的 key,作为 toggle 事件的 payload 回传给上层。形如 `pg-${idx}` / `ds-${id}-${idx}` / `cb-${id}-${idx}`。 */
|
||||
groupKey: string;
|
||||
/** 该组当前是否处于已应用状态(false 表示已被 undo 撤销,UI 会显示为灰态)。 */
|
||||
applied: boolean;
|
||||
/** 是否为合并组(即组内 step 数大于 1,由多次连续操作合并而来)。决定是否展示合并标记与可展开的子步列表。 */
|
||||
merged: boolean;
|
||||
/** 操作类型:`add` / `remove` / `update`,用于决定操作徽标的颜色和文案。 */
|
||||
opType: HistoryOpType;
|
||||
/** 组的整体描述文案,由上层根据 step / group 计算后传入,例如 "修改 button · style.color"。 */
|
||||
desc: string;
|
||||
/** 组内的 step 总数,仅在 merged 为 true 时显示为 "合并 N 步"。 */
|
||||
stepCount: number;
|
||||
/** 子步列表,用于在展开状态下逐条展示每个 step 的索引、应用状态与描述文案。 */
|
||||
subSteps: {
|
||||
index: number;
|
||||
applied: boolean;
|
||||
desc: string;
|
||||
isCurrent?: boolean;
|
||||
diffable?: boolean;
|
||||
/** 是否可对该子步执行「回滚」(已应用 + 业务侧确认支持反向)。父级根据 step 与 applied 决定。 */
|
||||
revertable?: boolean;
|
||||
}[];
|
||||
/** 当前组是否处于展开状态。仅在 merged 为 true 时生效,控制子步列表是否渲染。 */
|
||||
expanded: boolean;
|
||||
/** 是否为当前所在的分组(包含栈中最近一次已应用步骤的那一组),UI 高亮展示。 */
|
||||
isCurrent?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
/**
|
||||
* 用户点击合并组头部时触发,携带 group.key;上层用其切换 expanded 状态。
|
||||
* 用户点击合并组头部时触发,携带 groupKey;上层用其切换 expanded 状态。
|
||||
* 对单步组(非合并)头部点击不会发该事件——因为单步组没有"展开"的概念。
|
||||
*/
|
||||
(_e: 'toggle', _key: string): void;
|
||||
/**
|
||||
* 用户希望跳转到该记录时触发,携带"目标 step 在所属栈中的索引"——上层据此计算目标 cursor (= index + 1)。
|
||||
* 触发场景:
|
||||
* - 单步组(非合并)头部:取该唯一 step 的 index;
|
||||
* - 单步组(merged=false)头部:取该唯一 step 的 index;
|
||||
* - 子步条目:取该子步的 index。
|
||||
* 合并组头部不再触发 goto,避免与展开/收起冲突;用户应展开后点具体子步精准跳转。
|
||||
* 当前所在的步骤(isCurrent)始终不会触发 goto。
|
||||
@ -164,7 +118,7 @@ const emit = defineEmits<{
|
||||
(_e: 'goto', _index: number): void;
|
||||
/**
|
||||
* 用户希望查看该 step 的修改差异(旧值 vs 新值)。
|
||||
* 只在 step 满足"前后值都存在"(如 update / 数据源、代码块的 update)时由 `toRowGroup` 标记 `diffable=true`。
|
||||
* 只在 step 满足"前后值都存在"(如 update / 数据源、代码块的 update)时由父级标记 `diffable=true`。
|
||||
* payload 为该 step 在所属栈中的索引,由上层根据 index 取 step 内容并展示对比。
|
||||
*/
|
||||
(_e: 'diff-step', _index: number): void;
|
||||
@ -173,115 +127,74 @@ const emit = defineEmits<{
|
||||
* payload 为该 step 在所属栈中的索引。仅在单步组头部(headRevertable)或合并组的可回滚子步上触发。
|
||||
*/
|
||||
(_e: 'revert-step', _index: number): void;
|
||||
/**
|
||||
* 用户希望「选中该记录对应的节点」。payload 为该 step 在所属栈中的索引,
|
||||
* 由上层据 index 取出 step、解析出节点 id 并在画布选中。仅在 `selectEnabled` 为 true 时触发。
|
||||
*/
|
||||
(_e: 'select', _index: number): void;
|
||||
}>();
|
||||
|
||||
/** 子步数大于 1 即为合并组:决定是否展示合并标记与可展开的子步列表。 */
|
||||
const merged = computed(() => props.group.subSteps.length > 1);
|
||||
|
||||
/** 组内 step 总数,仅在合并组时显示为 "合并 N 步"。 */
|
||||
const stepCount = computed(() => props.group.subSteps.length);
|
||||
|
||||
/**
|
||||
* 头部可点击的场景:
|
||||
* - 合并组:点击切换展开 / 收起;
|
||||
* - 开启 `selectEnabled`(页面 tab):点击选中对应节点。
|
||||
* 单步组的跳转仍由头部的「回到」按钮触发。
|
||||
*/
|
||||
const isHeadClickable = computed(() => merged.value || props.selectEnabled);
|
||||
/** 单步组:头部可点击 goto;合并组:头部可点击切换展开。当前组(isCurrent)的单步组头部不可点击。 */
|
||||
const isHeadClickable = computed(() => {
|
||||
if (props.merged) return true;
|
||||
return !props.isCurrent;
|
||||
});
|
||||
|
||||
const headTitle = computed(() => {
|
||||
if (merged.value) {
|
||||
const expandHint = props.expanded ? '点击收起子步' : '点击展开子步';
|
||||
return props.selectEnabled ? `${expandHint}(并选中该节点)` : expandHint;
|
||||
}
|
||||
if (props.selectEnabled) return '点击选中该节点';
|
||||
if (props.group.isCurrent) return '当前所在记录';
|
||||
return '';
|
||||
if (props.merged) return props.expanded ? '点击收起子步' : '点击展开子步';
|
||||
if (props.isCurrent) return '当前所在记录';
|
||||
return '点击跳转到该记录';
|
||||
});
|
||||
|
||||
/**
|
||||
* 头部点击行为:
|
||||
* - 开启 selectEnabled 时,发出 select(携带组内首步 index,上层据此选中节点);
|
||||
* - 合并组同时切换展开 / 收起。
|
||||
* 头部点击行为分流:
|
||||
* - 合并组:仅切换展开 / 收起,不触发 goto;
|
||||
* - 单步组:跳转到该唯一步骤;当前组忽略点击。
|
||||
*/
|
||||
const onHeadClick = () => {
|
||||
if (props.selectEnabled && props.group.subSteps.length) {
|
||||
emit('select', props.group.subSteps[0].index);
|
||||
}
|
||||
if (merged.value) {
|
||||
emit('toggle', props.group.key);
|
||||
if (props.merged) {
|
||||
emit('toggle', props.groupKey);
|
||||
return;
|
||||
}
|
||||
if (props.isCurrent) return;
|
||||
if (!props.subSteps.length) return;
|
||||
emit('goto', props.subSteps[0].index);
|
||||
};
|
||||
|
||||
const onGotoClick = (index: number) => {
|
||||
if (!props.gotoEnabled) return;
|
||||
emit('goto', index);
|
||||
const onSubStepClick = (s: { index: number; isCurrent?: boolean }) => {
|
||||
if (s.isCurrent) return;
|
||||
emit('goto', s.index);
|
||||
};
|
||||
|
||||
/** 点击子步行:开启 selectEnabled 时选中该子步对应的节点。 */
|
||||
const onSubStepClick = (index: number) => {
|
||||
if (!props.selectEnabled) return;
|
||||
emit('select', index);
|
||||
};
|
||||
|
||||
const subStepTitle = (s: { isCurrent?: boolean }) => {
|
||||
if (props.selectEnabled) return '点击选中该节点';
|
||||
if (s.isCurrent) return '当前所在记录';
|
||||
return '';
|
||||
};
|
||||
|
||||
/**
|
||||
* 头部是否展示「已保存」标记:
|
||||
* - 单步组:取该唯一子步的 saved;
|
||||
* - 合并组:组内任一子步为已保存即在头部提示(具体落在哪一步可展开查看)。
|
||||
*/
|
||||
const headSaved = computed(() =>
|
||||
merged.value ? props.group.subSteps.some((s) => s.saved) : Boolean(props.group.subSteps[0]?.saved),
|
||||
);
|
||||
|
||||
/** 单步组头部是否展示"查看差异"入口:要求该唯一子步本身可对比。 */
|
||||
const headDiffable = computed(() => !merged.value && Boolean(props.group.subSteps[0]?.diffable));
|
||||
const headDiffable = computed(() => !props.merged && Boolean(props.subSteps[0]?.diffable));
|
||||
|
||||
/** 单步组头部是否展示"回滚"入口:要求该唯一子步本身可回滚(已应用)。 */
|
||||
const headRevertable = computed(() => !merged.value && Boolean(props.group.subSteps[0]?.revertable));
|
||||
|
||||
/** 单步组头部是否展示"回到"入口:可跳转、非当前、且存在唯一子步。 */
|
||||
const canHeadGoto = computed(
|
||||
() => !merged.value && props.gotoEnabled && !props.group.isCurrent && props.group.subSteps.length > 0,
|
||||
);
|
||||
const headRevertable = computed(() => !props.merged && Boolean(props.subSteps[0]?.revertable));
|
||||
|
||||
/**
|
||||
* 合并组展开后的子步渲染顺序:与外层分组列表保持一致——倒序展示(最新的子步在最上方)。
|
||||
* 外层 page tab / bucket 都已对 groups 做了 reverse,子步沿用同样的视觉规则更直观。
|
||||
* 注意:仅用于渲染,原 `subSteps` 保持时间正序,`headIndexLabel` 等基于首尾索引的展示语义不变。
|
||||
*/
|
||||
const subStepsDisplay = computed<HistoryRowStep[]>(() => props.group.subSteps.slice().reverse());
|
||||
const subStepsDisplay = computed(() => props.subSteps.slice().reverse());
|
||||
|
||||
/**
|
||||
* 头部索引展示:
|
||||
* - 单步组(非合并):显示该唯一 step 的编号,如 `#5`;
|
||||
* - 单步组(merged=false):显示该唯一 step 的编号,如 `#5`;
|
||||
* - 合并组:显示组内 step 的编号范围,如 `#3-#7`(首尾相同则退化为 `#5`)。
|
||||
*
|
||||
* 这里展示的是 step.index + 1(与子步列表 `#{{ s.index + 1 }}` 保持一致),从 1 起编号更符合直觉。
|
||||
*/
|
||||
const headIndexLabel = computed(() => {
|
||||
const list = props.group.subSteps;
|
||||
const list = props.subSteps;
|
||||
if (!list.length) return '';
|
||||
const first = list[0].index + 1;
|
||||
const last = list[list.length - 1].index + 1;
|
||||
if (!merged.value || first === last) return `#${first}`;
|
||||
if (!props.merged || first === last) return `#${first}`;
|
||||
return `#${first}-#${last}`;
|
||||
});
|
||||
|
||||
const headIndexTitle = computed(() => {
|
||||
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} 条历史步骤`;
|
||||
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 onDiffClick = (index: number) => {
|
||||
|
||||
@ -1,74 +1,67 @@
|
||||
<template>
|
||||
<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>
|
||||
<Teleport to="body">
|
||||
<TMagicDialog
|
||||
v-model="visible"
|
||||
class="m-editor-history-diff-dialog"
|
||||
title="查看修改差异"
|
||||
width="900px"
|
||||
top="5vh"
|
||||
destroy-on-close
|
||||
append-to-body
|
||||
>
|
||||
<div v-if="payload" class="m-editor-history-diff-dialog-body">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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"
|
||||
height="70vh"
|
||||
/>
|
||||
|
||||
<CodeEditor
|
||||
v-else
|
||||
type="diff"
|
||||
language="json"
|
||||
:init-values="leftValue"
|
||||
:modified-values="rightValue"
|
||||
:options="codeDiffOptions"
|
||||
disabled-full-screen
|
||||
height="70vh"
|
||||
/>
|
||||
</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="isConfirm">
|
||||
<TMagicButton size="small" @click="visible = false">取消</TMagicButton>
|
||||
<TMagicButton size="small" type="primary" @click="onConfirmClick">确定回滚</TMagicButton>
|
||||
<template #footer>
|
||||
<TMagicButton size="small" @click="visible = false">关闭</TMagicButton>
|
||||
</template>
|
||||
<TMagicButton v-else size="small" @click="visible = false">关闭</TMagicButton>
|
||||
</template>
|
||||
</TMagicDialog>
|
||||
</TMagicDialog>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
@ -78,38 +71,41 @@ import { isEqual } from 'lodash-es';
|
||||
import { TMagicButton, TMagicDialog, TMagicRadioButton, TMagicRadioGroup, TMagicTag } from '@tmagic/design';
|
||||
import type { FormState } from '@tmagic/form';
|
||||
|
||||
import CompareForm from '@editor/components/CompareForm.vue';
|
||||
import CompareForm, { type CompareCategory } from '@editor/components/CompareForm.vue';
|
||||
import CodeEditor from '@editor/layouts/CodeEditor.vue';
|
||||
import type { CompareCategory, CompareFormLoadConfig, DiffDialogPayload } from '@editor/type';
|
||||
|
||||
defineOptions({
|
||||
name: 'MEditorHistoryDiffDialog',
|
||||
});
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/**
|
||||
* 来自 Editor 顶层的 `extendFormState`,用于扩展 MForm.formState。
|
||||
* 透传给 CompareForm,从而让差异对比时表单 item 中依赖业务上下文的
|
||||
* `display` / `disabled` 等 filterFunction 正常工作。
|
||||
*/
|
||||
extendState?: (_state: FormState) => Record<string, any> | Promise<Record<string, any>>;
|
||||
/**
|
||||
* 自定义 FormConfig 加载逻辑,透传给 CompareForm。传入后将接管内置的按 `category`
|
||||
* 取配置逻辑,可通过 `ctx.defaultLoadConfig()` 复用默认结果再做二次加工。
|
||||
*/
|
||||
loadConfig?: CompareFormLoadConfig;
|
||||
width?: string;
|
||||
isConfirm?: boolean;
|
||||
onConfirm?: () => void;
|
||||
selfDiffFieldTypes?: string[];
|
||||
}>(),
|
||||
{
|
||||
width: '900px',
|
||||
},
|
||||
);
|
||||
defineProps<{
|
||||
/**
|
||||
* 来自 Editor 顶层的 `extendFormState`,用于扩展 MForm.formState。
|
||||
* 透传给 CompareForm,从而让差异对比时表单 item 中依赖业务上下文的
|
||||
* `display` / `disabled` 等 filterFunction 正常工作。
|
||||
*/
|
||||
extendState?: (_state: FormState) => Record<string, any> | Promise<Record<string, any>>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
/** 差异对话框的入参 */
|
||||
export interface DiffDialogPayload {
|
||||
/** 表单类别 */
|
||||
category: CompareCategory;
|
||||
/** 节点类型 / 数据源类型 */
|
||||
type?: string;
|
||||
/** 代码块场景下的数据源类型 */
|
||||
dataSourceType?: string;
|
||||
/** 该 step 修改前的值(oldNode / oldSchema / oldContent) */
|
||||
lastValue: Record<string, any>;
|
||||
/** 该 step 修改后的值(newNode / newSchema / newContent) */
|
||||
value: Record<string, any>;
|
||||
/** 当前编辑器中实际的最新值;不传或为 null 时禁用「与当前对比」 */
|
||||
currentValue?: Record<string, any> | null;
|
||||
/** 用于标题展示的目标名称 */
|
||||
targetLabel?: string;
|
||||
/** 用于标题展示的目标 id */
|
||||
id?: string | number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 差异对比模式:
|
||||
@ -150,8 +146,6 @@ const codeDiffOptions = {
|
||||
},
|
||||
};
|
||||
|
||||
const dialogTitle = computed(() => (props.onConfirm ? '确认回滚' : '查看修改差异'));
|
||||
|
||||
const hasCurrent = computed(() => payload.value?.currentValue !== undefined && payload.value?.currentValue !== null);
|
||||
|
||||
/** 左侧(旧/参照)值 */
|
||||
@ -177,19 +171,6 @@ const isSameAsCurrent = computed(() => {
|
||||
return isEqual(payload.value.value, payload.value.currentValue);
|
||||
});
|
||||
|
||||
/** confirm() 的 resolve,仅在「等待用户确认回滚」期间存在 */
|
||||
let confirmResolve: ((_value: boolean) => void) | null = null;
|
||||
|
||||
const onConfirmClick = () => {
|
||||
props.onConfirm?.();
|
||||
|
||||
// 用户确认回滚:resolve(true),并清空以避免随后 visible=false 再 resolve(false)
|
||||
confirmResolve?.(true);
|
||||
confirmResolve = null;
|
||||
|
||||
visible.value = false;
|
||||
};
|
||||
|
||||
const targetText = computed(() => {
|
||||
if (!payload.value) return '';
|
||||
const categoryText: Record<CompareCategory, string> = {
|
||||
@ -197,8 +178,7 @@ const targetText = computed(() => {
|
||||
'data-source': '数据源',
|
||||
'code-block': '代码块',
|
||||
};
|
||||
const { category } = payload.value;
|
||||
const prefix = category ? categoryText[category] : '';
|
||||
const prefix = categoryText[payload.value.category] || '';
|
||||
const label = payload.value.targetLabel || payload.value.type || '';
|
||||
const { id } = payload.value;
|
||||
const labelWithId = id !== undefined && id !== '' ? `${label}(${id})` : label;
|
||||
@ -214,24 +194,6 @@ 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;
|
||||
};
|
||||
@ -240,19 +202,11 @@ const close = () => {
|
||||
watch(visible, (v) => {
|
||||
if (!v) {
|
||||
payload.value = null;
|
||||
// 非「确定回滚」方式关闭(取消 / Esc / 点遮罩等)时,resolve(false)
|
||||
confirmResolve?.(false);
|
||||
confirmResolve = null;
|
||||
}
|
||||
});
|
||||
|
||||
const onClose = () => {
|
||||
emit('close');
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
open,
|
||||
confirm,
|
||||
close,
|
||||
});
|
||||
</script>
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
popper-class="m-editor-history-list-popover"
|
||||
placement="bottom"
|
||||
trigger="click"
|
||||
v-model:visible="visible"
|
||||
:visible="visible"
|
||||
:width="660"
|
||||
>
|
||||
<div class="m-editor-history-list">
|
||||
@ -23,24 +23,19 @@
|
||||
<PageTab
|
||||
:list="pageGroupsDisplay"
|
||||
:expanded="expanded"
|
||||
:marker="pageMarker"
|
||||
@toggle="toggleGroup"
|
||||
@goto="onPageGoto"
|
||||
@goto-initial="onPageGotoInitial"
|
||||
@diff-step="onPageDiff"
|
||||
@revert-step="onPageRevert"
|
||||
@select="onPageSelect"
|
||||
@clear="onPageClear"
|
||||
/>
|
||||
</component>
|
||||
|
||||
<component
|
||||
v-if="!disabledDataSource"
|
||||
:is="tabPaneComponent?.component || 'el-tab-pane'"
|
||||
v-bind="tabPaneComponent?.props({ name: 'data-source', label: `数据源 (${dataSourceGroups.length})` }) || {}"
|
||||
>
|
||||
<BucketTab
|
||||
:config="dataSourceConfig"
|
||||
<DataSourceTab
|
||||
:buckets="dataSourceGroupsByTarget"
|
||||
:expanded="expanded"
|
||||
@toggle="toggleGroup"
|
||||
@ -48,17 +43,14 @@
|
||||
@goto-initial="onDataSourceGotoInitial"
|
||||
@diff-step="onDataSourceDiff"
|
||||
@revert-step="onDataSourceRevert"
|
||||
@clear="onDataSourceClear"
|
||||
/>
|
||||
</component>
|
||||
|
||||
<component
|
||||
v-if="!disabledCodeBlock"
|
||||
:is="tabPaneComponent?.component || 'el-tab-pane'"
|
||||
v-bind="tabPaneComponent?.props({ name: 'code-block', label: `代码块 (${codeBlockGroups.length})` }) || {}"
|
||||
>
|
||||
<BucketTab
|
||||
:config="codeBlockConfig"
|
||||
<CodeBlockTab
|
||||
:buckets="codeBlockGroupsByTarget"
|
||||
:expanded="expanded"
|
||||
@toggle="toggleGroup"
|
||||
@ -66,18 +58,8 @@
|
||||
@goto-initial="onCodeBlockGotoInitial"
|
||||
@diff-step="onCodeBlockDiff"
|
||||
@revert-step="onCodeBlockRevert"
|
||||
@clear="onCodeBlockClear"
|
||||
/>
|
||||
</component>
|
||||
|
||||
<component
|
||||
v-for="tab in extraTabs"
|
||||
:key="tab.name"
|
||||
:is="tabPaneComponent?.component || 'el-tab-pane'"
|
||||
v-bind="tabPaneComponent?.props({ name: tab.name, label: resolveTabLabel(tab) }) || {}"
|
||||
>
|
||||
<component :is="tab.component" v-bind="tab.props || {}" v-on="tab.listeners || {}" />
|
||||
</component>
|
||||
</TMagicTabs>
|
||||
</div>
|
||||
|
||||
@ -93,15 +75,14 @@
|
||||
</TMagicPopover>
|
||||
|
||||
<HistoryDiffDialog ref="diffDialog" :extend-state="extendFormState" />
|
||||
<HistoryDiffDialog ref="confirmDialog" :is-confirm="true" :extend-state="extendFormState" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 历史记录面板:在顶部 NavMenu 上点击图标打开 popover,分三个 tab:
|
||||
* - 页面:当前活动页面的历史栈,连续修改同一节点的多步会被合并成一组
|
||||
* - 数据源:以 dataSource.id 分桶,每条操作记录独立展示
|
||||
* - 代码块:同上,按 codeBlock.id 分桶,每条操作记录独立展示
|
||||
* - 数据源:以 dataSource.id 分组,每组内部相邻的连续 update 自动合并
|
||||
* - 代码块:同上,按 codeBlock.id 分组并合并相邻 update
|
||||
*
|
||||
* 数据通过 historyService 暴露的聚合 API 读取,UI 仅用于只读展示,
|
||||
* 同时支持点击任意一条记录跳转至该状态:
|
||||
@ -114,44 +95,21 @@
|
||||
* 此外每条 step 上提供"查看差异"入口(仅在前后值都存在的 update 步骤显示),
|
||||
* 点击后弹出 HistoryDiffDialog,使用 CompareForm 组件以表单形式展示新旧值差异。
|
||||
*
|
||||
* 各 tab 的内容拆分为独立的 SFC:页面用 PageTab,数据源 / 代码块复用通用的 BucketTab
|
||||
* (通过 title / prefix / describe* / isStepDiffable 注入差异)。
|
||||
* 各 tab 的内容拆分为独立的 SFC(PageTab / DataSourceTab / CodeBlockTab),
|
||||
* 共享的描述生成与折叠状态在 composables.ts 中维护。
|
||||
*/
|
||||
import { computed, inject, markRaw, ref, useTemplateRef, watch } from 'vue';
|
||||
import { inject, markRaw, ref, useTemplateRef } from 'vue';
|
||||
import { Clock, Close } from '@element-plus/icons-vue';
|
||||
|
||||
import {
|
||||
getDesignConfig,
|
||||
TMagicButton,
|
||||
tMagicMessage,
|
||||
tMagicMessageBox,
|
||||
TMagicPopover,
|
||||
TMagicTabs,
|
||||
TMagicTooltip,
|
||||
} from '@tmagic/design';
|
||||
import { getDesignConfig, TMagicButton, TMagicPopover, TMagicTabs, TMagicTooltip } from '@tmagic/design';
|
||||
import type { FormState } from '@tmagic/form';
|
||||
|
||||
import MIcon from '@editor/components/Icon.vue';
|
||||
import { useServices } from '@editor/hooks/use-services';
|
||||
import type {
|
||||
CodeBlockStepValue,
|
||||
DataSourceStepValue,
|
||||
DiffDialogPayload,
|
||||
HistoryBucketConfig,
|
||||
HistoryListExtraTab,
|
||||
} from '@editor/type';
|
||||
|
||||
import BucketTab from './BucketTab.vue';
|
||||
import {
|
||||
describeCodeBlockGroup,
|
||||
describeCodeBlockStep,
|
||||
describeDataSourceGroup,
|
||||
describeDataSourceStep,
|
||||
isCodeBlockStepRevertable,
|
||||
isDataSourceStepRevertable,
|
||||
useHistoryList,
|
||||
} from './composables';
|
||||
import CodeBlockTab from './CodeBlockTab.vue';
|
||||
import { useHistoryList } from './composables';
|
||||
import DataSourceTab from './DataSourceTab.vue';
|
||||
import HistoryDiffDialog from './HistoryDiffDialog.vue';
|
||||
import PageTab from './PageTab.vue';
|
||||
|
||||
@ -161,40 +119,14 @@ defineOptions({
|
||||
|
||||
const ClockIcon = markRaw(Clock);
|
||||
const CloseIcon = markRaw(Close);
|
||||
const activeTab = ref<string>('page');
|
||||
const activeTab = ref<'page' | 'data-source' | 'code-block'>('page');
|
||||
|
||||
/**
|
||||
* 面板显隐受控:reference 图标点击切换,右上角关闭按钮置为 false。
|
||||
* 点击面板以外区域的自动收起由 TMagicPopover 通过 v-model:visible 回写完成。
|
||||
*/
|
||||
/** 面板显隐受控:reference 图标点击切换,右上角关闭按钮置为 false。 */
|
||||
const visible = ref(false);
|
||||
|
||||
const tabPaneComponent = getDesignConfig('components')?.tabPane;
|
||||
|
||||
/**
|
||||
* 业务方自定义的扩展 tab,由 Editor 顶层通过 `historyListExtraTabs` 注入。
|
||||
* 追加在内置「页面 / 数据源 / 代码块」三个 tab 之后,未提供时为空数组。
|
||||
*/
|
||||
const extraTabs = inject<HistoryListExtraTab[]>('historyListExtraTabs', []);
|
||||
|
||||
/** label 支持字符串或函数,函数形式便于展示动态数量等内容。 */
|
||||
const resolveTabLabel = (tab: HistoryListExtraTab) => (typeof tab.label === 'function' ? tab.label() : tab.label);
|
||||
|
||||
const { editorService, dataSourceService, codeBlockService, historyService, propsService, stageOverlayService } =
|
||||
useServices();
|
||||
|
||||
/**
|
||||
* 数据源 / 代码块功能可被业务方通过 `disabledDataSource` / `disabledCodeBlock` 禁用,
|
||||
* 禁用后对应的历史记录 tab 不再展示。若当前激活的 tab 恰好被禁用,则回退到「页面」tab。
|
||||
*/
|
||||
const disabledDataSource = computed(() => propsService.getDisabledDataSource());
|
||||
const disabledCodeBlock = computed(() => propsService.getDisabledCodeBlock());
|
||||
|
||||
watch([disabledDataSource, disabledCodeBlock], ([dsDisabled, cbDisabled]) => {
|
||||
if ((activeTab.value === 'data-source' && dsDisabled) || (activeTab.value === 'code-block' && cbDisabled)) {
|
||||
activeTab.value = 'page';
|
||||
}
|
||||
});
|
||||
const { editorService, dataSourceService, codeBlockService, historyService } = useServices();
|
||||
|
||||
/**
|
||||
* 通过 inject 拿到 Editor 顶层注入的 `extendFormState`,转交给 HistoryDiffDialog
|
||||
@ -217,42 +149,6 @@ const {
|
||||
codeBlockGroupsByTarget,
|
||||
} = useHistoryList();
|
||||
|
||||
/**
|
||||
* 当前活动页的「加载/初始」标记记录(设置 root 时生成),透传给 PageTab 的底部初始行展示。
|
||||
* 基于 historyService 的 reactive state 派生,活动页切换或标记写入后自动刷新。
|
||||
*/
|
||||
const pageMarker = computed(() => historyService.getPageMarker());
|
||||
|
||||
/** 数据源 step 仅 update(前后 schema 都存在)时可查看差异。 */
|
||||
const isDataSourceStepDiffable = (step: DataSourceStepValue) =>
|
||||
Boolean(step.diff?.[0]?.oldSchema && step.diff?.[0]?.newSchema);
|
||||
|
||||
/** 代码块 step 仅 update(前后 content 都存在)时可查看差异。 */
|
||||
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;
|
||||
|
||||
@ -260,29 +156,6 @@ const onPageGoto = (index: number) => {
|
||||
editorService.gotoPageStep(indexToCursor(index));
|
||||
};
|
||||
|
||||
/**
|
||||
* 点击页面历史记录行:选中该记录对应的画布节点。
|
||||
* - 从目标 step 的 diff 中取节点 id(优先 newSchema,回退 oldSchema),按出现顺序找到第一个当前仍存在的节点;
|
||||
* - 与图层树点击选中一致:editorService.select + 画布 / overlay 画布 select 三者联动;
|
||||
* - 该 step 涉及的节点都已不存在(如删除记录、被撤销的新增)时给出提示,不做选中。
|
||||
*/
|
||||
const onPageSelect = async (index: number) => {
|
||||
const step = historyService.getPageStepList()[index]?.step;
|
||||
if (!step) return;
|
||||
const targetId = (step.diff ?? [])
|
||||
.map((item) => item.newSchema?.id ?? item.oldSchema?.id)
|
||||
.find((id) => id !== undefined && id !== null && editorService.getNodeById(id, false));
|
||||
if (targetId === undefined || targetId === null) {
|
||||
tMagicMessage.warning('该记录对应的节点已不存在,无法选中');
|
||||
return;
|
||||
}
|
||||
const node = editorService.getNodeById(targetId, false);
|
||||
if (!node) return;
|
||||
await editorService.select(node);
|
||||
editorService.get('stage')?.select(targetId);
|
||||
stageOverlayService.get('stage')?.select(targetId);
|
||||
};
|
||||
|
||||
const onDataSourceGoto = (id: string | number, index: number) => {
|
||||
dataSourceService.goto(id, indexToCursor(index));
|
||||
};
|
||||
@ -307,261 +180,92 @@ const onCodeBlockGotoInitial = (id: string | number) => {
|
||||
codeBlockService.goto(id, 0);
|
||||
};
|
||||
|
||||
const diffDialogRef = useTemplateRef<InstanceType<typeof HistoryDiffDialog>>('diffDialog');
|
||||
const confirmDialogRef = useTemplateRef<InstanceType<typeof HistoryDiffDialog>>('confirmDialog');
|
||||
|
||||
/**
|
||||
* 三类历史(页面 / 数据源 / 代码块)差异弹窗入参的构造差异,收敛为一份配置:
|
||||
* 仅「分组来源、当前值读取、类型 / 展示名提取」不同,定位 step、校验前后值、组装 payload 的流程共用。
|
||||
*/
|
||||
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: 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;
|
||||
};
|
||||
|
||||
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 =>
|
||||
buildDiffPayload(
|
||||
{
|
||||
category: 'data-source',
|
||||
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 =>
|
||||
buildDiffPayload(
|
||||
{
|
||||
category: 'code-block',
|
||||
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);
|
||||
if (payload) diffDialogRef.value?.open(payload);
|
||||
};
|
||||
|
||||
const onDataSourceDiff = (id: string | number, index: number) => {
|
||||
const payload = buildDataSourceDiffPayload(id, index);
|
||||
if (payload) diffDialogRef.value?.open(payload);
|
||||
};
|
||||
|
||||
const onCodeBlockDiff = (id: string | number, index: number) => {
|
||||
const payload = buildCodeBlockDiffPayload(id, index);
|
||||
if (payload) diffDialogRef.value?.open(payload);
|
||||
};
|
||||
|
||||
/**
|
||||
* 「回滚」统一入口:把目标历史步骤的修改作为一次新操作反向应用(类 git revert),
|
||||
* 「回滚」入口:把目标历史步骤的修改作为一次新操作反向应用(类 git revert),
|
||||
* 不破坏原有栈结构。各 service 内部完成反向 + 入栈,并自带描述用于面板展示。
|
||||
*
|
||||
* 交互:
|
||||
* - 可差异对比的步骤(单节点 / 单实体 update):弹出差异弹窗供用户确认,点「确定回滚」再执行;
|
||||
* - 无法对比的步骤(add / remove / 多节点更新,payload 为 null):弹出普通二次确认框,确认后执行。
|
||||
*
|
||||
* 页面 / 数据源 / 代码块三类回滚仅「差异入参构造」与「实际 revert 调用」不同,
|
||||
* 由调用方分别传入 payload 与 revert,公共的弹窗 / 确认流程在此收敛。
|
||||
*/
|
||||
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));
|
||||
editorService.revertPageStep(index);
|
||||
};
|
||||
|
||||
const onDataSourceRevert = (id: string | number, index: number) => {
|
||||
if (isDataSourceRevertTargetMissing(id, index)) {
|
||||
showRevertTargetMissing();
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
return runRevert(buildDataSourceDiffPayload(id, index)).then((result) =>
|
||||
result ? dataSourceService.revert(id, index) : null,
|
||||
);
|
||||
dataSourceService.revert(id, index);
|
||||
};
|
||||
|
||||
const onCodeBlockRevert = (id: string | number, index: number) => {
|
||||
if (isCodeBlockRevertTargetMissing(id, index)) {
|
||||
showRevertTargetMissing();
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
return runRevert(buildCodeBlockDiffPayload(id, index)).then((result) =>
|
||||
result ? codeBlockService.revert(id, index) : null,
|
||||
);
|
||||
codeBlockService.revert(id, index);
|
||||
};
|
||||
|
||||
/**
|
||||
* 「回滚」二次确认:新增 / 删除 / 多节点更新等无法做差异对比的步骤,
|
||||
* 不弹差异弹窗,改用一个普通确认框替代「确定回滚」按钮,避免点击后无任何提示直接执行。
|
||||
* 用户取消时返回 false,调用方据此中止回滚。
|
||||
*/
|
||||
const confirmRevert = (): Promise<boolean> =>
|
||||
confirmDialog(
|
||||
'确定回滚该步骤吗?回滚会将该操作作为一条新记录反向应用(新增将被删除、删除将被还原),不影响后续历史记录。',
|
||||
);
|
||||
const diffDialogRef = useTemplateRef<InstanceType<typeof HistoryDiffDialog>>('diffDialog');
|
||||
|
||||
/**
|
||||
* 通用二次确认弹窗:清空历史 / 无法差异对比的回滚等会改变状态的操作,先弹出确认框,
|
||||
* 用户点击「确定」返回 true,取消(confirm reject)时返回 false 并静默忽略。
|
||||
* 页面 step 差异:仅 update 单节点修改可对比,传入旧/新节点。
|
||||
* 节点类型 `type` 优先取 newNode.type,再回退 oldNode.type。
|
||||
* `currentValue` 取自 editorService 中该节点当前实际值,用于支持「与当前对比」。
|
||||
*/
|
||||
const confirmDialog = async (message: string): Promise<boolean> => {
|
||||
try {
|
||||
await tMagicMessageBox.confirm(message, '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
const onPageDiff = (index: number) => {
|
||||
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;
|
||||
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;
|
||||
diffDialogRef.value?.open({
|
||||
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,
|
||||
});
|
||||
return true;
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
} catch (e) {
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 把内存中(已清空对应类别后的)历史状态重新写回 IndexedDB,
|
||||
* 使本地持久化的那份与内存保持一致——即「连同本地保存的一并删除」。
|
||||
* 不支持 IndexedDB 或写入失败时静默忽略(内存清空已生效)。
|
||||
*/
|
||||
const syncIndexedDB = async () => {
|
||||
try {
|
||||
await historyService.saveToIndexedDB();
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
} catch (e) {
|
||||
// ignore: 内存清空已生效,本地同步失败不阻塞交互
|
||||
const onDataSourceDiff = (id: string | number, index: number) => {
|
||||
const groups = historyService.getDataSourceHistoryGroups();
|
||||
for (const group of groups) {
|
||||
if (group.id !== id) continue;
|
||||
const entry = group.steps.find((s) => s.index === index);
|
||||
if (!entry) continue;
|
||||
const { oldSchema, newSchema } = entry.step;
|
||||
if (!oldSchema || !newSchema) return;
|
||||
const currentSchema = dataSourceService.getDataSourceById(`${id}`);
|
||||
diffDialogRef.value?.open({
|
||||
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,
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
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();
|
||||
const onCodeBlockDiff = (id: string | number, index: number) => {
|
||||
const groups = historyService.getCodeBlockHistoryGroups();
|
||||
for (const group of groups) {
|
||||
if (group.id !== id) continue;
|
||||
const entry = group.steps.find((s) => s.index === index);
|
||||
if (!entry) continue;
|
||||
const { oldContent, newContent } = entry.step;
|
||||
if (!oldContent || !newContent) return;
|
||||
const currentContent = codeBlockService.getCodeContentById(id);
|
||||
diffDialogRef.value?.open({
|
||||
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,
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
@ -2,15 +2,13 @@
|
||||
<li
|
||||
class="m-editor-history-list-item m-editor-history-list-initial"
|
||||
:class="{ 'is-current': isCurrent, 'is-clickable': !isCurrent }"
|
||||
:title="rowTitle"
|
||||
:title="isCurrent ? '当前已回到未修改的初始状态' : '点击回到未修改的初始状态'"
|
||||
@click="onClick"
|
||||
>
|
||||
<span class="m-editor-history-list-item-index" title="历史步骤编号 #0(未修改的初始状态)">#0</span>
|
||||
<span class="m-editor-history-list-item-op op-initial">初始</span>
|
||||
<span class="m-editor-history-list-item-desc">{{ desc }}</span>
|
||||
<span v-if="gotoEnabled && !isCurrent" class="m-editor-history-list-item-actions">
|
||||
<span class="m-editor-history-list-item-goto" title="回到该记录" @click.stop="onClick">回到</span>
|
||||
</span>
|
||||
<span v-if="time" class="m-editor-history-list-item-time" :title="timeTitle">{{ time }}</span>
|
||||
<span class="m-editor-history-list-item-desc">未修改的初始状态</span>
|
||||
<span v-if="isCurrent" class="m-editor-history-list-item-current">当前</span>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
@ -19,42 +17,17 @@
|
||||
* 「初始状态」记录行:渲染于历史列表底部,作为整个栈的"零点"。
|
||||
* - 点击该行会把对应栈撤销到 cursor === 0(即没有任何已应用步骤),等同于回到所有修改之前。
|
||||
* - 当对应栈本身已处于 cursor === 0 时(isCurrent=true),用户已在初始状态,点击不再触发动作。
|
||||
* - 当上层传入 `marker`(设置 root 时为该页生成的「未修改的初始状态」标记)时,
|
||||
* 用标记的文案与时间渲染本行;标记不进入撤销/重做栈,仅作为该页基线展示。
|
||||
*
|
||||
* 该行不是真实 step,仅作为 UI 入口;上层负责把"点击"翻译为 `service.goto*(0)`。
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
|
||||
import type { StepValue } from '@editor/type';
|
||||
|
||||
import { formatHistoryFullTime, formatHistoryTime } from './composables';
|
||||
|
||||
defineOptions({
|
||||
name: 'MEditorHistoryListInitialRow',
|
||||
});
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 当前对应栈是否已经处于初始状态 (cursor === 0)。true 时用蓝条高亮并禁用点击。 */
|
||||
isCurrent: boolean;
|
||||
gotoEnabled?: boolean;
|
||||
/** 该页面的「加载/初始」基线记录(设置 root 时生成的 `opType: 'initial'` StepValue);提供时用其文案与时间展示。 */
|
||||
marker?: StepValue;
|
||||
}>(),
|
||||
{
|
||||
gotoEnabled: true,
|
||||
marker: undefined,
|
||||
},
|
||||
);
|
||||
|
||||
const desc = computed(() => props.marker?.historyDescription || '未修改的初始状态');
|
||||
const time = computed(() => formatHistoryTime(props.marker?.timestamp));
|
||||
const timeTitle = computed(() => formatHistoryFullTime(props.marker?.timestamp));
|
||||
const rowTitle = computed(() => {
|
||||
const base = props.marker?.historyDescription || '未修改的初始状态';
|
||||
return props.isCurrent ? `当前已回到${base}` : `点击回到${base}`;
|
||||
});
|
||||
const props = defineProps<{
|
||||
/** 当前对应栈是否已经处于初始状态 (cursor === 0)。true 时用蓝条高亮并禁用点击。 */
|
||||
isCurrent: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 点击非当前的初始项时触发,由上层调用对应 service 的 goto 把 cursor 移到 0。 */
|
||||
|
||||
@ -1,32 +1,40 @@
|
||||
<template>
|
||||
<div v-if="!list.length && !marker" class="m-editor-history-list-empty">暂无操作记录</div>
|
||||
<template v-else>
|
||||
<div v-if="list.length" 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="isHistoryGroupExpanded(expanded, rowKey(group))"
|
||||
:select-enabled="true"
|
||||
@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)"
|
||||
@select="(index: number) => $emit('select', index)"
|
||||
/>
|
||||
<!--
|
||||
<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, gIdx) in list"
|
||||
:key="`pg-${gIdx}`"
|
||||
:group-key="`pg-${gIdx}`"
|
||||
:applied="group.applied"
|
||||
:merged="group.steps.length > 1"
|
||||
:op-type="group.opType"
|
||||
:desc="describePageGroup(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,
|
||||
}))
|
||||
"
|
||||
:is-current="group.isCurrent"
|
||||
:expanded="!!expanded[`pg-${gIdx}`]"
|
||||
@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 时它即为当前位置。
|
||||
设置 root 时生成的「未修改的初始状态」标记(marker)会作为该行的文案与时间来源。
|
||||
-->
|
||||
<InitialRow :is-current="isInitial" :marker="marker" @goto-initial="$emit('goto-initial')" />
|
||||
</ul>
|
||||
</TMagicScrollbar>
|
||||
</template>
|
||||
<InitialRow :is-current="isInitial" @goto-initial="$emit('goto-initial')" />
|
||||
</ul>
|
||||
</TMagicScrollbar>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
@ -34,16 +42,9 @@ import { computed } from 'vue';
|
||||
|
||||
import { TMagicScrollbar } from '@tmagic/design';
|
||||
|
||||
import type { HistoryRowDescriptor, PageHistoryGroup, StepValue } from '@editor/type';
|
||||
import type { PageHistoryGroup, StepValue } from '@editor/type';
|
||||
|
||||
import type { HistoryRowGroup } from './composables';
|
||||
import {
|
||||
describePageGroup,
|
||||
describePageStep,
|
||||
isHistoryGroupExpanded,
|
||||
isPageStepRevertable,
|
||||
toRowGroup,
|
||||
} from './composables';
|
||||
import { describePageGroup, describePageStep } from './composables';
|
||||
import GroupRow from './GroupRow.vue';
|
||||
import InitialRow from './InitialRow.vue';
|
||||
|
||||
@ -54,17 +55,8 @@ defineOptions({
|
||||
const props = defineProps<{
|
||||
/** 当前活动页面的历史分组列表,已按时间倒序排好(最新一组在最前)。空数组时显示空态。 */
|
||||
list: PageHistoryGroup[];
|
||||
/**
|
||||
* 共享的折叠状态表(key -> 是否展开,缺省或 true 为展开、false 为收起),由顶层 panel 统一维护。
|
||||
* 本 tab 使用 `pg-${组内首步 index}` 作为 key——以稳定的 step 索引而非展示位置标识分组,
|
||||
* 这样历史数据更新(新增 / 撤销重做导致列表顺序变化)后,已展开的分组状态仍能正确保持。
|
||||
*/
|
||||
/** 共享的折叠状态表(key -> 是否展开),由顶层 panel 统一维护。本 tab 使用 `pg-${idx}` 作为 key。 */
|
||||
expanded: Record<string, boolean>;
|
||||
/**
|
||||
* 当前活动页的「加载/初始」基线记录(设置 root 时生成的 `opType: 'initial'` StepValue)。
|
||||
* 提供时即使没有任何操作记录也会展示底部初始行,并用其文案 / 时间渲染。
|
||||
*/
|
||||
marker?: StepValue;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
@ -78,41 +70,25 @@ defineEmits<{
|
||||
(_e: 'diff-step', _index: number): void;
|
||||
/** 用户点击"回滚"按钮,携带目标 step 在栈中的索引,类 git revert。 */
|
||||
(_e: 'revert-step', _index: number): void;
|
||||
/** 用户点击记录行希望选中对应节点,携带目标 step 在栈中的索引。 */
|
||||
(_e: 'select', _index: number): void;
|
||||
/** 用户点击"清空"按钮,请求清空当前页面的历史记录(由上层弹窗二次确认后执行)。 */
|
||||
(_e: 'clear'): void;
|
||||
}>();
|
||||
|
||||
/**
|
||||
* 当前 step 是否可查看差异:
|
||||
* - 仅 update 操作;
|
||||
* - 单节点更新(diff.length === 1),且 oldSchema / newSchema 都存在。
|
||||
* - 单节点更新(updatedItems.length === 1),且 oldNode / newNode 都存在。
|
||||
* 多节点更新难以选定单一对比目标,统一不展示差异入口。
|
||||
*/
|
||||
const isPageStepDiffable = (step: StepValue): boolean => {
|
||||
if (step.opType !== 'update') return false;
|
||||
const items = step.diff ?? [];
|
||||
const items = step.updatedItems ?? [];
|
||||
if (items.length !== 1) return false;
|
||||
return Boolean(items[0]?.oldSchema && items[0]?.newSchema);
|
||||
return Boolean(items[0]?.oldNode && items[0]?.newNode);
|
||||
};
|
||||
|
||||
/** 页面历史的描述 / 可操作性判定集合,注入给统一的 `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 时即为该状态(空列表 `every` 返回 true,
|
||||
* 即仅有 marker、无任何操作记录时也视为处于初始状态)。
|
||||
* 当 list 中所有 group 的 applied 都为 false 时即为该状态。
|
||||
* 没有任何 group 的情况由外层"暂无操作记录"分支兜底,本计算可以不考虑。
|
||||
*/
|
||||
const isInitial = computed(() => props.list.every((g) => !g.applied));
|
||||
const isInitial = computed(() => props.list.length > 0 && props.list.every((g) => !g.applied));
|
||||
</script>
|
||||
|
||||
@ -1,88 +1,16 @@
|
||||
import { computed, reactive } from 'vue';
|
||||
|
||||
import { datetimeFormatter } from '@tmagic/form';
|
||||
|
||||
import { useServices } from '@editor/hooks/use-services';
|
||||
import type {
|
||||
BaseStepValue,
|
||||
CodeBlockHistoryGroup,
|
||||
CodeBlockStepValue,
|
||||
DataSourceHistoryGroup,
|
||||
DataSourceStepValue,
|
||||
HistoryOpSource,
|
||||
HistoryOpType,
|
||||
HistoryRowDescriptor,
|
||||
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 }[];
|
||||
}
|
||||
|
||||
/** GroupRow 渲染所需的单个子步视图模型(已由 {@link toRowGroup} 预先派生,组件内部不再触碰原始 step)。 */
|
||||
export interface HistoryRowStep {
|
||||
/** 该子步在所属栈中的稳定索引。 */
|
||||
index: number;
|
||||
/** 是否已应用(false 表示已被 undo,UI 灰态)。 */
|
||||
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[];
|
||||
}
|
||||
|
||||
/** 合并组默认展开;仅当 expanded[key] === false 时为收起。 */
|
||||
export const isHistoryGroupExpanded = (expanded: Record<string, boolean>, key: string) => expanded[key] !== false;
|
||||
|
||||
/**
|
||||
* 历史记录面板共享逻辑:
|
||||
* - 暴露三类历史的聚合数据(页面 / 数据源 / 代码块);
|
||||
@ -94,14 +22,10 @@ export const isHistoryGroupExpanded = (expanded: Record<string, boolean>, key: s
|
||||
export const useHistoryList = () => {
|
||||
const { historyService } = useServices();
|
||||
|
||||
/**
|
||||
* 折叠状态:key 形如 `pg-${组内首步 index}` / `ds-${id}-${组内首步 index}` / `cb-${id}-${组内首步 index}`。
|
||||
* 用组内首步的稳定 index(而非展示位置)作为 key,确保历史数据更新后已展开的分组状态保持不变。
|
||||
* 合并组默认展开;仅当值为 `false` 时表示收起。
|
||||
*/
|
||||
/** 折叠状态:key 形如 `pg-${groupIdx}` / `ds-${id}-${groupIdx}` / `cb-${id}-${groupIdx}`。 */
|
||||
const expanded = reactive<Record<string, boolean>>({});
|
||||
const toggleGroup = (key: string) => {
|
||||
expanded[key] = expanded[key] === false;
|
||||
expanded[key] = !expanded[key];
|
||||
};
|
||||
|
||||
const pageGroups = computed(() => historyService.getPageHistoryGroups());
|
||||
@ -140,32 +64,6 @@ export const useHistoryList = () => {
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 历史面板的时间展示:
|
||||
* - 当天的记录只显示 `HH:mm:ss`;
|
||||
* - 跨天的记录显示 `MM-DD HH:mm:ss`。
|
||||
* 无时间戳(旧数据 / 未写入)时返回空串,UI 据此不渲染时间。
|
||||
*/
|
||||
export const formatHistoryTime = (timestamp?: number): string => {
|
||||
if (!timestamp) return '';
|
||||
const isToday =
|
||||
datetimeFormatter(new Date(timestamp), '', 'YYYY-MM-DD') ===
|
||||
(datetimeFormatter(new Date(), '', 'YYYY-MM-DD') as string);
|
||||
return `${
|
||||
isToday
|
||||
? datetimeFormatter(new Date(timestamp), '', 'HH:mm:ss')
|
||||
: datetimeFormatter(new Date(timestamp), '', 'MM-DD HH:mm:ss')
|
||||
}`;
|
||||
};
|
||||
|
||||
/** 完整时间(含年份与秒),用于 title 悬浮提示。无时间戳时返回空串。 */
|
||||
export const formatHistoryFullTime = (timestamp?: number): string =>
|
||||
timestamp ? `${datetimeFormatter(new Date(timestamp), '', 'YYYY-MM-DD HH:mm:ss')}` : '';
|
||||
|
||||
/** 取一组历史步骤里最后一步(最近一次)的时间戳,用于组头部展示。 */
|
||||
export const groupTimestamp = (group: { steps: { step: { timestamp?: number } }[] }): number | undefined =>
|
||||
group.steps[group.steps.length - 1]?.step.timestamp;
|
||||
|
||||
export const opLabel = (op: HistoryOpType) => {
|
||||
switch (op) {
|
||||
case 'add':
|
||||
@ -178,80 +76,7 @@ export const opLabel = (op: HistoryOpType) => {
|
||||
}
|
||||
};
|
||||
|
||||
/** 内置操作途径的中文文案;自定义来源直接回显原值,未知 / 缺省返回空串(UI 据此不渲染)。 */
|
||||
const HISTORY_SOURCE_LABELS: Record<string, string> = {
|
||||
stage: '画布',
|
||||
tree: '树面板',
|
||||
'component-panel': '组件面板',
|
||||
props: '配置面板',
|
||||
code: '源码',
|
||||
'root-code': 'DSL源码',
|
||||
'stage-contextmenu': '画布菜单',
|
||||
'tree-contextmenu': '树菜单',
|
||||
toolbar: '工具栏',
|
||||
shortcut: '快捷键',
|
||||
rollback: '回滚',
|
||||
api: '接口',
|
||||
ai: 'AI',
|
||||
initial: '初始值',
|
||||
sync: '同步',
|
||||
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 }) =>
|
||||
const nameOf = (node: { name?: string; id?: string | number; type?: string }) =>
|
||||
node?.name || node?.type || `${node?.id ?? ''}`;
|
||||
|
||||
/**
|
||||
@ -276,25 +101,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 = items.length;
|
||||
const node = items[0]?.newSchema;
|
||||
const count = step.nodes?.length ?? 0;
|
||||
const node = step.nodes?.[0];
|
||||
return `新增 ${count} 个节点${count === 1 && node ? `(${labelWithId(nameOf(node), node.id)})` : ''}`;
|
||||
}
|
||||
if (opType === 'remove') {
|
||||
const count = items.length;
|
||||
const node = items[0]?.oldSchema;
|
||||
const count = step.removedItems?.length ?? 0;
|
||||
const node = step.removedItems?.[0]?.node;
|
||||
return `删除 ${count} 个节点${count === 1 && node ? `(${labelWithId(nameOf(node), node.id)})` : ''}`;
|
||||
}
|
||||
if (!items.length) return '修改节点';
|
||||
if (items.length === 1) {
|
||||
const { newSchema, changeRecords } = items[0];
|
||||
const updated = step.updatedItems ?? [];
|
||||
if (!updated.length) return '修改节点';
|
||||
if (updated.length === 1) {
|
||||
const { newNode, changeRecords } = updated[0];
|
||||
const propPath = changeRecords?.[0]?.propPath;
|
||||
const target = labelWithId(nameOf(newSchema), newSchema?.id);
|
||||
const target = labelWithId(nameOf(newNode), newNode?.id);
|
||||
return `修改 ${target}${propPath ? ` · ${propPath}` : ''}`;
|
||||
}
|
||||
return `修改 ${items.length} 个节点`;
|
||||
return `修改 ${updated.length} 个节点`;
|
||||
};
|
||||
|
||||
/**
|
||||
@ -309,7 +134,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.diff?.[0]?.changeRecords?.forEach((r) => r.propPath && paths.add(r.propPath));
|
||||
s.step.updatedItems?.[0]?.changeRecords?.forEach((r) => r.propPath && paths.add(r.propPath));
|
||||
});
|
||||
const pathList = Array.from(paths).slice(0, 3).join(', ');
|
||||
const target = labelWithId(
|
||||
@ -321,11 +146,12 @@ export const describePageGroup = (group: PageHistoryGroup) => {
|
||||
|
||||
export const describeDataSourceStep = (step: DataSourceStepValue) => {
|
||||
if (step.historyDescription) return step.historyDescription;
|
||||
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);
|
||||
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);
|
||||
return propPath ? `修改 ${title} · ${propPath}` : `修改 ${title}`;
|
||||
};
|
||||
|
||||
@ -335,23 +161,22 @@ 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.diff?.[0]?.changeRecords?.forEach((r) => r.propPath && paths.add(r.propPath));
|
||||
s.step.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.diff?.[0]?.newSchema?.title ||
|
||||
group.steps[0].step.diff?.[0]?.oldSchema?.title;
|
||||
const rawTitle = group.steps[group.steps.length - 1].step.newSchema?.title || group.steps[0].step.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;
|
||||
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);
|
||||
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);
|
||||
return propPath ? `修改 ${title} · ${propPath}` : `修改 ${title}`;
|
||||
};
|
||||
|
||||
@ -361,47 +186,10 @@ 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.diff?.[0]?.changeRecords?.forEach((r) => r.propPath && paths.add(r.propPath));
|
||||
s.step.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.diff?.[0]?.newSchema?.name ||
|
||||
group.steps[0].step.diff?.[0]?.oldSchema?.name;
|
||||
const rawName = group.steps[group.steps.length - 1].step.newContent?.name || group.steps[0].step.oldContent?.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);
|
||||
};
|
||||
|
||||
@ -51,7 +51,6 @@
|
||||
type: 'button',
|
||||
text: '删除',
|
||||
icon: Delete,
|
||||
buttonProps: { type: 'danger' },
|
||||
handler: () => remove(item),
|
||||
}"
|
||||
></ToolButton>
|
||||
@ -73,7 +72,7 @@ import { computed, ref, useTemplateRef, watch } from 'vue';
|
||||
import { CaretBottom, Delete, DocumentCopy } from '@element-plus/icons-vue';
|
||||
|
||||
import { type Id, type MPage, type MPageFragment, NodeType } from '@tmagic/core';
|
||||
import { TMagicIcon, tMagicMessageBox, TMagicPopover } from '@tmagic/design';
|
||||
import { TMagicIcon, TMagicPopover } from '@tmagic/design';
|
||||
|
||||
import ToolButton from '@editor/components/ToolButton.vue';
|
||||
import { useServices } from '@editor/hooks/use-services';
|
||||
@ -141,8 +140,7 @@ const copy = (node: MPage | MPageFragment) => {
|
||||
});
|
||||
};
|
||||
|
||||
const remove = async (node: MPage | MPageFragment) => {
|
||||
await tMagicMessageBox.confirm('确定删除该页面吗?');
|
||||
const remove = (node: MPage | MPageFragment) => {
|
||||
editorService.remove(node);
|
||||
};
|
||||
|
||||
|
||||
@ -151,11 +151,7 @@ const submit = async (v: MNode, eventData?: ContainerChangeEventData) => {
|
||||
});
|
||||
}
|
||||
|
||||
// 区分操作途径:表单字段编辑(MForm @change)会带上 eventData(含 changeRecords);
|
||||
// 源码编辑器(CodeEditor @save → saveCode)保存时不带 eventData,据此标记为「源码编辑器」。
|
||||
const historySource = eventData ? 'props' : 'code';
|
||||
|
||||
editorService.update(newValue, { changeRecords: eventData?.changeRecords, historySource });
|
||||
editorService.update(newValue, { changeRecords: eventData?.changeRecords });
|
||||
} catch (e: any) {
|
||||
emit('submit-error', e);
|
||||
}
|
||||
|
||||
@ -94,15 +94,11 @@ let clientX: number;
|
||||
let clientY: number;
|
||||
|
||||
const appendComponent = ({ text, type, data = {} }: ComponentItem): void => {
|
||||
editorService.add(
|
||||
{
|
||||
name: text,
|
||||
type,
|
||||
...data,
|
||||
},
|
||||
undefined,
|
||||
{ historySource: 'component-panel' },
|
||||
);
|
||||
editorService.add({
|
||||
name: text,
|
||||
type,
|
||||
...data,
|
||||
});
|
||||
};
|
||||
|
||||
const dragstartHandler = ({ text, type, data = {} }: ComponentItem, e: DragEvent) => {
|
||||
|
||||
@ -239,12 +239,7 @@ const unWatchEditorContentHeight = watch(
|
||||
},
|
||||
);
|
||||
|
||||
const activeTabName = computed<string>({
|
||||
get: () => uiService.get('sideBarActiveTabName'),
|
||||
set: (value) => uiService.set('sideBarActiveTabName', value),
|
||||
});
|
||||
|
||||
uiService.set('sideBarActiveTabName', props.data?.status || '');
|
||||
const activeTabName = ref(props.data?.status);
|
||||
|
||||
const getItemConfig = (data: SideItem): SideComponent => {
|
||||
const map: Record<string, SideComponent> = {
|
||||
|
||||
@ -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}`, { historySource: 'tree' })"></Icon>
|
||||
<Icon :icon="Close" class="edit-icon" @click.stop="deleteCode(`${data.key}`)"></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, HistoryOpSource, type TreeNodeData } from '@editor/type';
|
||||
import { type CodeBlockListSlots, CodeDeleteErrorType, type TreeNodeData } from '@editor/type';
|
||||
|
||||
defineSlots<CodeBlockListSlots>();
|
||||
|
||||
@ -60,7 +60,7 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
edit: [id: string];
|
||||
remove: [id: string, { historySource?: HistoryOpSource }];
|
||||
remove: [id: string];
|
||||
'node-contextmenu': [event: MouseEvent, data: TreeNodeData];
|
||||
}>();
|
||||
|
||||
@ -142,7 +142,7 @@ const editCode = (id: string) => {
|
||||
emit('edit', id);
|
||||
};
|
||||
|
||||
const deleteCode = async (id: string, { historySource }: { historySource?: HistoryOpSource } = {}) => {
|
||||
const deleteCode = async (id: string) => {
|
||||
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, { historySource }: { historySource?: Histo
|
||||
});
|
||||
|
||||
// 无绑定关系,且不在不可删除列表中
|
||||
emit('remove', id, { historySource });
|
||||
emit('remove', id);
|
||||
} else {
|
||||
if (typeof props.customError === 'function') {
|
||||
props.customError(id, existBinds ? CodeDeleteErrorType.BIND : CodeDeleteErrorType.UNDELETEABLE);
|
||||
|
||||
@ -122,7 +122,7 @@ const {
|
||||
menuData: contentMenuData,
|
||||
contentMenuHideHandler,
|
||||
} = useContentMenu((id: string) => {
|
||||
codeBlockListRef.value?.deleteCode(id, { historySource: 'tree-contextmenu' });
|
||||
codeBlockListRef.value?.deleteCode(id);
|
||||
});
|
||||
const menuData = computed<(MenuButton | MenuComponent)[]>(() => props.customContentMenu(contentMenuData, 'code-block'));
|
||||
</script>
|
||||
|
||||
@ -41,7 +41,7 @@ export const useContentMenu = (deleteCode: (id: string) => void) => {
|
||||
|
||||
const newCodeId = await codeBlockService.getUniqueId();
|
||||
|
||||
codeBlockService.setCodeDslById(newCodeId, cloneDeep(codeBlock), { historySource: 'tree-contextmenu' });
|
||||
codeBlockService.setCodeDslById(newCodeId, cloneDeep(codeBlock));
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@ -129,7 +129,7 @@ const removeHandler = async (id: string) => {
|
||||
type: 'warning',
|
||||
});
|
||||
|
||||
dataSourceService.remove(id, { historySource: 'tree-contextmenu' });
|
||||
dataSourceService.remove(id);
|
||||
};
|
||||
|
||||
const dataSourceListRef = useTemplateRef<InstanceType<typeof DataSourceList>>('dataSourceList');
|
||||
|
||||
@ -39,7 +39,7 @@ export const useContentMenu = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
dataSourceService.add(cloneDeep(ds), { historySource: 'tree-contextmenu' });
|
||||
dataSourceService.add(cloneDeep(ds));
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@ -41,15 +41,11 @@ const createMenuItems = (group: ComponentGroup): MenuButton[] =>
|
||||
type: 'button',
|
||||
icon: component.icon,
|
||||
handler: () => {
|
||||
editorService.add(
|
||||
{
|
||||
name: component.text,
|
||||
type: component.type,
|
||||
...(component.data || {}),
|
||||
},
|
||||
undefined,
|
||||
{ historySource: 'tree-contextmenu' },
|
||||
);
|
||||
editorService.add({
|
||||
name: component.text,
|
||||
type: component.type,
|
||||
...(component.data || {}),
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
@ -61,13 +57,9 @@ const getSubMenuData = computed<MenuButton[]>(() => {
|
||||
type: 'button',
|
||||
icon: Files,
|
||||
handler: () => {
|
||||
editorService.add(
|
||||
{
|
||||
type: 'tab-pane',
|
||||
},
|
||||
undefined,
|
||||
{ historySource: 'tree-contextmenu' },
|
||||
);
|
||||
editorService.add({
|
||||
type: 'tab-pane',
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
@ -114,9 +106,9 @@ const menuData = computed<(MenuButton | MenuComponent)[]>(() =>
|
||||
items: getSubMenuData.value,
|
||||
},
|
||||
useCopyMenu(),
|
||||
usePasteMenu('tree-contextmenu'),
|
||||
useDeleteMenu('tree-contextmenu'),
|
||||
useMoveToMenu(services, 'tree-contextmenu'),
|
||||
usePasteMenu(),
|
||||
useDeleteMenu(),
|
||||
useMoveToMenu(services),
|
||||
...props.layerContentMenu,
|
||||
],
|
||||
'layer',
|
||||
|
||||
@ -25,12 +25,9 @@ const props = defineProps<{
|
||||
const { editorService } = useServices();
|
||||
|
||||
const setNodeVisible = (visible: boolean) => {
|
||||
editorService.update(
|
||||
{
|
||||
id: props.data.id,
|
||||
visible,
|
||||
},
|
||||
{ historySource: 'tree' },
|
||||
);
|
||||
editorService.update({
|
||||
id: props.data.id,
|
||||
visible,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
@ -380,7 +380,7 @@ const dropHandler = async (e: DragEvent) => {
|
||||
|
||||
config.data.inputEvent = e;
|
||||
|
||||
editorService.add(config.data, parent, { historySource: 'component-panel' });
|
||||
editorService.add(config.data, parent);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
@ -49,11 +49,11 @@ const menuData = computed<(MenuButton | MenuComponent)[]>(() =>
|
||||
display: () => canCenter.value,
|
||||
handler: () => {
|
||||
if (!nodes.value) return;
|
||||
editorService.alignCenter(nodes.value, { historySource: 'stage-contextmenu' });
|
||||
editorService.alignCenter(nodes.value);
|
||||
},
|
||||
},
|
||||
useCopyMenu(),
|
||||
usePasteMenu('stage-contextmenu', menuRef),
|
||||
usePasteMenu(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, { historySource: 'stage-contextmenu' });
|
||||
editorService.moveLayer(1);
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -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, { historySource: 'stage-contextmenu' });
|
||||
editorService.moveLayer(-1);
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -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, { historySource: 'stage-contextmenu' });
|
||||
editorService.moveLayer(LayerOffset.TOP);
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -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, { historySource: 'stage-contextmenu' });
|
||||
editorService.moveLayer(LayerOffset.BOTTOM);
|
||||
},
|
||||
},
|
||||
useMoveToMenu(services, 'stage-contextmenu'),
|
||||
useMoveToMenu(services),
|
||||
{
|
||||
type: 'divider',
|
||||
direction: 'horizontal',
|
||||
display: () => !isPage(node.value) && !isPageFragment(node.value) && !props.isMultiSelect,
|
||||
},
|
||||
useDeleteMenu('stage-contextmenu'),
|
||||
useDeleteMenu(),
|
||||
{
|
||||
type: 'divider',
|
||||
direction: 'horizontal',
|
||||
|
||||
@ -38,7 +38,6 @@ 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';
|
||||
|
||||
@ -49,6 +48,18 @@ 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,
|
||||
@ -58,17 +69,6 @@ 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,19 +120,9 @@ class CodeBlock extends BaseService {
|
||||
public async setCodeDslById(
|
||||
id: Id,
|
||||
codeConfig: Partial<CodeBlockContent>,
|
||||
{
|
||||
changeRecords,
|
||||
doNotPushHistory = false,
|
||||
historyDescription,
|
||||
historySource,
|
||||
}: HistoryOpOptionsWithChangeRecords = {},
|
||||
{ changeRecords, doNotPushHistory = false }: HistoryOpOptionsWithChangeRecords = {},
|
||||
): Promise<void> {
|
||||
this.setCodeDslByIdSync(id, codeConfig, true, {
|
||||
changeRecords,
|
||||
doNotPushHistory,
|
||||
historyDescription,
|
||||
historySource,
|
||||
});
|
||||
this.setCodeDslByIdSync(id, codeConfig, true, { changeRecords, doNotPushHistory });
|
||||
}
|
||||
|
||||
/**
|
||||
@ -151,12 +141,7 @@ class CodeBlock extends BaseService {
|
||||
id: Id,
|
||||
codeConfig: Partial<CodeBlockContent>,
|
||||
force = true,
|
||||
{
|
||||
changeRecords,
|
||||
doNotPushHistory = false,
|
||||
historyDescription,
|
||||
historySource,
|
||||
}: HistoryOpOptionsWithChangeRecords = {},
|
||||
{ changeRecords, doNotPushHistory = false, historyDescription }: HistoryOpOptionsWithChangeRecords = {},
|
||||
): void {
|
||||
const codeDsl = this.getCodeDsl();
|
||||
|
||||
@ -187,14 +172,7 @@ class CodeBlock extends BaseService {
|
||||
const newContent = cloneDeep(codeDsl[id]);
|
||||
|
||||
if (!doNotPushHistory) {
|
||||
this.lastPushedHistoryId =
|
||||
historyService.pushCodeBlock(id, {
|
||||
oldContent,
|
||||
newContent,
|
||||
changeRecords,
|
||||
historyDescription,
|
||||
source: historySource,
|
||||
})?.uuid ?? null;
|
||||
historyService.pushCodeBlock(id, { oldContent, newContent, changeRecords, historyDescription });
|
||||
}
|
||||
|
||||
this.emit('addOrUpdate', id, codeDsl[id]);
|
||||
@ -290,14 +268,12 @@ class CodeBlock extends BaseService {
|
||||
*/
|
||||
public async deleteCodeDslByIds(
|
||||
codeIds: Id[],
|
||||
{ doNotPushHistory = false, historyDescription, historySource }: HistoryOpOptions = {},
|
||||
{ doNotPushHistory = false, historyDescription }: 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;
|
||||
@ -305,62 +281,13 @@ class CodeBlock extends BaseService {
|
||||
delete currentDsl[id];
|
||||
|
||||
if (oldContent && !doNotPushHistory) {
|
||||
const uuid = historyService.pushCodeBlock(id, {
|
||||
oldContent,
|
||||
newContent: null,
|
||||
historyDescription,
|
||||
source: historySource,
|
||||
})?.uuid;
|
||||
if (uuid) this.lastDeletedHistoryIds.push(uuid);
|
||||
historyService.pushCodeBlock(id, { oldContent, newContent: null, historyDescription });
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@ -446,28 +373,10 @@ class CodeBlock extends BaseService {
|
||||
const list = historyService.getCodeBlockStepList(id);
|
||||
const entry = list[index];
|
||||
if (!entry?.applied) return null;
|
||||
// 更新类步骤(前后 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)}`;
|
||||
const description = `回滚 #${index + 1}: ${describeRevertCodeBlockStep(entry.step)}`;
|
||||
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
|
||||
@ -558,22 +467,21 @@ class CodeBlock extends BaseService {
|
||||
step: CodeBlockStepValue,
|
||||
historyDescription: string,
|
||||
): Promise<CodeBlockStepValue | null> {
|
||||
const { id } = step;
|
||||
const { oldSchema, newSchema, changeRecords } = step.diff?.[0] ?? {};
|
||||
const { id, oldContent, newContent, changeRecords } = step;
|
||||
|
||||
// 原本是新增 → revert 即删除
|
||||
if (!oldSchema && newSchema) {
|
||||
await this.deleteCodeDslByIds([id], { historyDescription, historySource: 'rollback' });
|
||||
if (oldContent === null && newContent) {
|
||||
await this.deleteCodeDslByIds([id], { historyDescription });
|
||||
return historyService.getCodeBlockStepList(id).slice(-1)[0]?.step ?? null;
|
||||
}
|
||||
|
||||
// 原本是删除 → revert 即写回
|
||||
if (oldSchema && !newSchema) {
|
||||
this.setCodeDslByIdSync(id, cloneDeep(oldSchema), true, { historyDescription, historySource: 'rollback' });
|
||||
if (oldContent && newContent === null) {
|
||||
this.setCodeDslByIdSync(id, cloneDeep(oldContent), true, { historyDescription });
|
||||
return historyService.getCodeBlockStepList(id).slice(-1)[0]?.step ?? null;
|
||||
}
|
||||
|
||||
if (!oldSchema || !newSchema) return null;
|
||||
if (!oldContent || !newContent) return null;
|
||||
|
||||
// 原本是更新 → 把 oldContent 写回;优先按 changeRecords 局部 patch
|
||||
if (changeRecords?.length) {
|
||||
@ -586,18 +494,17 @@ class CodeBlock extends BaseService {
|
||||
fallbackToFullReplace = true;
|
||||
break;
|
||||
}
|
||||
const value = cloneDeep(getValueByKeyPath(record.propPath, oldSchema));
|
||||
const value = cloneDeep(getValueByKeyPath(record.propPath, oldContent));
|
||||
setValueByKeyPath(record.propPath, value, patched);
|
||||
}
|
||||
this.setCodeDslByIdSync(id, fallbackToFullReplace ? cloneDeep(oldSchema) : patched, true, {
|
||||
this.setCodeDslByIdSync(id, fallbackToFullReplace ? cloneDeep(oldContent) : patched, true, {
|
||||
changeRecords,
|
||||
historyDescription,
|
||||
historySource: 'rollback',
|
||||
});
|
||||
return historyService.getCodeBlockStepList(id).slice(-1)[0]?.step ?? null;
|
||||
}
|
||||
|
||||
this.setCodeDslByIdSync(id, cloneDeep(oldSchema), true, { historyDescription, historySource: 'rollback' });
|
||||
this.setCodeDslByIdSync(id, cloneDeep(oldContent), true, { historyDescription });
|
||||
return historyService.getCodeBlockStepList(id).slice(-1)[0]?.step ?? null;
|
||||
}
|
||||
|
||||
@ -616,32 +523,31 @@ class CodeBlock extends BaseService {
|
||||
* @param reverse true=撤销,false=重做
|
||||
*/
|
||||
private async applyHistoryStep(step: CodeBlockStepValue, reverse: boolean): Promise<void> {
|
||||
const { id } = step;
|
||||
const { oldSchema, newSchema, changeRecords } = step.diff?.[0] ?? {};
|
||||
const { id, oldContent, newContent, changeRecords } = step;
|
||||
|
||||
// 新增 / 删除:直接 set 或 delete,不走 patch 逻辑
|
||||
if (!oldSchema && newSchema) {
|
||||
if (oldContent === null && newContent) {
|
||||
if (reverse) {
|
||||
await this.deleteCodeDslByIds([id], { doNotPushHistory: true });
|
||||
} else {
|
||||
this.setCodeDslByIdSync(id, cloneDeep(newSchema), true, { doNotPushHistory: true });
|
||||
this.setCodeDslByIdSync(id, cloneDeep(newContent), true, { doNotPushHistory: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (oldSchema && !newSchema) {
|
||||
if (oldContent && newContent === null) {
|
||||
if (reverse) {
|
||||
this.setCodeDslByIdSync(id, cloneDeep(oldSchema), true, { doNotPushHistory: true });
|
||||
this.setCodeDslByIdSync(id, cloneDeep(oldContent), true, { doNotPushHistory: true });
|
||||
} else {
|
||||
await this.deleteCodeDslByIds([id], { doNotPushHistory: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!oldSchema || !newSchema) return;
|
||||
if (!oldContent || !newContent) return;
|
||||
|
||||
// 更新场景:优先按 changeRecords 局部 patch;缺省退化为整内容替换
|
||||
const sourceForValues = reverse ? oldSchema : newSchema;
|
||||
const sourceForValues = reverse ? oldContent : newContent;
|
||||
|
||||
if (changeRecords?.length) {
|
||||
const current = this.getCodeContentById(id);
|
||||
|
||||
@ -19,7 +19,6 @@ 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';
|
||||
|
||||
@ -55,6 +54,19 @@ 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: [],
|
||||
@ -66,13 +78,6 @@ 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 })));
|
||||
}
|
||||
@ -124,10 +129,7 @@ class DataSource extends BaseService {
|
||||
* @param options.doNotPushHistory 是否不写入历史记录(默认 false)
|
||||
* @param options.historyDescription 入栈时附带的人类可读描述,用于历史面板展示
|
||||
*/
|
||||
public add(
|
||||
config: DataSourceSchema,
|
||||
{ doNotPushHistory = false, historyDescription, historySource }: HistoryOpOptions = {},
|
||||
) {
|
||||
public add(config: DataSourceSchema, { doNotPushHistory = false, historyDescription }: HistoryOpOptions = {}) {
|
||||
const newConfig = {
|
||||
...config,
|
||||
id: config.id && !this.getDataSourceById(config.id) ? config.id : this.createId(),
|
||||
@ -136,13 +138,7 @@ class DataSource extends BaseService {
|
||||
this.get('dataSources').push(newConfig);
|
||||
|
||||
if (!doNotPushHistory) {
|
||||
this.lastPushedHistoryId =
|
||||
historyService.pushDataSource(newConfig.id, {
|
||||
oldSchema: null,
|
||||
newSchema: newConfig,
|
||||
historyDescription,
|
||||
source: historySource,
|
||||
})?.uuid ?? null;
|
||||
historyService.pushDataSource(newConfig.id, { oldSchema: null, newSchema: newConfig, historyDescription });
|
||||
}
|
||||
|
||||
this.emit('add', newConfig);
|
||||
@ -160,12 +156,7 @@ class DataSource extends BaseService {
|
||||
*/
|
||||
public update(
|
||||
config: DataSourceSchema,
|
||||
{
|
||||
changeRecords = [],
|
||||
doNotPushHistory = false,
|
||||
historyDescription,
|
||||
historySource,
|
||||
}: HistoryOpOptionsWithChangeRecords = {},
|
||||
{ changeRecords = [], doNotPushHistory = false, historyDescription }: HistoryOpOptionsWithChangeRecords = {},
|
||||
) {
|
||||
const dataSources = this.get('dataSources');
|
||||
|
||||
@ -177,14 +168,12 @@ class DataSource extends BaseService {
|
||||
dataSources[index] = newConfig;
|
||||
|
||||
if (!doNotPushHistory) {
|
||||
this.lastPushedHistoryId =
|
||||
historyService.pushDataSource(newConfig.id, {
|
||||
oldSchema: oldConfig ? cloneDeep(oldConfig) : null,
|
||||
newSchema: newConfig,
|
||||
changeRecords,
|
||||
historyDescription,
|
||||
source: historySource,
|
||||
})?.uuid ?? null;
|
||||
historyService.pushDataSource(newConfig.id, {
|
||||
oldSchema: oldConfig ? cloneDeep(oldConfig) : null,
|
||||
newSchema: newConfig,
|
||||
changeRecords,
|
||||
historyDescription,
|
||||
});
|
||||
}
|
||||
|
||||
this.emit('update', newConfig, {
|
||||
@ -202,59 +191,19 @@ class DataSource extends BaseService {
|
||||
* @param options.doNotPushHistory 是否不写入历史记录(默认 false)
|
||||
* @param options.historyDescription 入栈时附带的人类可读描述,用于历史面板展示
|
||||
*/
|
||||
public remove(id: string, { doNotPushHistory = false, historyDescription, historySource }: HistoryOpOptions = {}) {
|
||||
public remove(id: string, { doNotPushHistory = false, historyDescription }: 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) {
|
||||
this.lastPushedHistoryId =
|
||||
historyService.pushDataSource(id, {
|
||||
oldSchema: cloneDeep(oldConfig),
|
||||
newSchema: null,
|
||||
historyDescription,
|
||||
source: historySource,
|
||||
})?.uuid ?? null;
|
||||
historyService.pushDataSource(id, { oldSchema: cloneDeep(oldConfig), newSchema: null, historyDescription });
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
/**
|
||||
* 撤销指定数据源的最近一次变更。
|
||||
*
|
||||
@ -329,27 +278,10 @@ class DataSource extends BaseService {
|
||||
const list = historyService.getDataSourceStepList(id);
|
||||
const entry = list[index];
|
||||
if (!entry?.applied) return null;
|
||||
// 更新类步骤(前后 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)}`;
|
||||
const description = `回滚 #${index + 1}: ${describeRevertDataSourceStep(entry.step)}`;
|
||||
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()}`;
|
||||
}
|
||||
@ -430,18 +362,17 @@ class DataSource extends BaseService {
|
||||
* 同构,差异仅在于走对应的公共 add / update / remove 而不是带 doNotPushHistory 的版本。
|
||||
*/
|
||||
private applyRevertStep(step: DataSourceStepValue, historyDescription: string): DataSourceStepValue | null {
|
||||
const { id } = step;
|
||||
const { oldSchema, newSchema, changeRecords } = step.diff?.[0] ?? {};
|
||||
const { id, oldSchema, newSchema, changeRecords } = step;
|
||||
|
||||
// 原本是新增 → revert 即删除
|
||||
if (!oldSchema && newSchema) {
|
||||
this.remove(`${id}`, { historyDescription, historySource: 'rollback' });
|
||||
if (oldSchema === null && newSchema) {
|
||||
this.remove(`${id}`, { historyDescription });
|
||||
return historyService.getDataSourceStepList(id).slice(-1)[0]?.step ?? null;
|
||||
}
|
||||
|
||||
// 原本是删除 → revert 即重新加回
|
||||
if (oldSchema && !newSchema) {
|
||||
this.add(cloneDeep(oldSchema), { historyDescription, historySource: 'rollback' });
|
||||
if (oldSchema && newSchema === null) {
|
||||
this.add(cloneDeep(oldSchema), { historyDescription });
|
||||
return historyService.getDataSourceStepList(id).slice(-1)[0]?.step ?? null;
|
||||
}
|
||||
|
||||
@ -464,12 +395,11 @@ 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, historySource: 'rollback' });
|
||||
this.update(cloneDeep(oldSchema), { historyDescription });
|
||||
return historyService.getDataSourceStepList(id).slice(-1)[0]?.step ?? null;
|
||||
}
|
||||
|
||||
@ -488,11 +418,10 @@ class DataSource extends BaseService {
|
||||
* @param reverse true=撤销,false=重做
|
||||
*/
|
||||
private applyHistoryStep(step: DataSourceStepValue, reverse: boolean): void {
|
||||
const { id } = step;
|
||||
const { oldSchema, newSchema, changeRecords } = step.diff?.[0] ?? {};
|
||||
const { id, oldSchema, newSchema, changeRecords } = step;
|
||||
|
||||
// 新增 / 删除:直接 add 或 remove,不走 patch 逻辑
|
||||
if (!oldSchema && newSchema) {
|
||||
if (oldSchema === null && newSchema) {
|
||||
if (reverse) {
|
||||
this.remove(`${id}`, { doNotPushHistory: true });
|
||||
} else {
|
||||
@ -501,7 +430,7 @@ class DataSource extends BaseService {
|
||||
return;
|
||||
}
|
||||
|
||||
if (oldSchema && !newSchema) {
|
||||
if (oldSchema && newSchema === null) {
|
||||
if (reverse) {
|
||||
this.add(cloneDeep(oldSchema), { doNotPushHistory: true });
|
||||
} else {
|
||||
|
||||
@ -17,21 +17,13 @@
|
||||
*/
|
||||
|
||||
import { reactive, toRaw } from 'vue';
|
||||
import { cloneDeep, isEmpty, isEqual, isObject, mergeWith, uniq } from 'lodash-es';
|
||||
import { cloneDeep, isObject, mergeWith, uniq } from 'lodash-es';
|
||||
|
||||
import type { Id, MApp, MContainer, MNode, MPage, MPageFragment, TargetOptions } from '@tmagic/core';
|
||||
import { NodeType } from '@tmagic/core';
|
||||
import type { ChangeRecord } from '@tmagic/form';
|
||||
import { isFixed } from '@tmagic/stage';
|
||||
import {
|
||||
getNodeInfo,
|
||||
getNodePath,
|
||||
getValueByKeyPath,
|
||||
guid,
|
||||
isPage,
|
||||
isPageFragment,
|
||||
setValueByKeyPath,
|
||||
} from '@tmagic/utils';
|
||||
import { getNodeInfo, getNodePath, getValueByKeyPath, isPage, isPageFragment, setValueByKeyPath } from '@tmagic/utils';
|
||||
|
||||
import BaseService from '@editor/services//BaseService';
|
||||
import propsService from '@editor/services//props';
|
||||
@ -44,10 +36,8 @@ import type {
|
||||
DslOpOptions,
|
||||
EditorEvents,
|
||||
EditorNodeInfo,
|
||||
HistoryOpSource,
|
||||
HistoryOpType,
|
||||
PastePosition,
|
||||
StepDiffItem,
|
||||
StepValue,
|
||||
StoreState,
|
||||
StoreStateKey,
|
||||
@ -60,7 +50,6 @@ import {
|
||||
classifyDragSources,
|
||||
collectRelatedNodes,
|
||||
COPY_STORAGE_KEY,
|
||||
describeStepForRevert,
|
||||
editorNodeMergeCustomizer,
|
||||
fixNodePosition,
|
||||
getInitPositionStyle,
|
||||
@ -78,18 +67,36 @@ import { beforePaste, getAddParent } from '@editor/utils/operator';
|
||||
type MoveItem = { node: MNode; parent: MContainer; pageForOp: { name: string; id: Id } | null };
|
||||
|
||||
/**
|
||||
* 把「变更前后节点快照」列表归一成 update 类型的 {@link StepDiffItem} 列表,供 {@link StepValue.diff} 使用。
|
||||
* `changeRecords` 来自 form 端的 propPath/value 列表,撤销/重做时只对这些 propPath 做局部更新;
|
||||
* 缺省(未传 / 空数组)才退化为整节点替换。
|
||||
* 给「回滚」生成的新 step 用的简短描述生成器。
|
||||
* 与 UI 层 `describePageStep` 同义,但避免 service 反向依赖 layouts/,故在此本地实现。
|
||||
*/
|
||||
const buildUpdateDiff = (
|
||||
items: { oldNode: MNode; newNode: MNode; changeRecords?: ChangeRecord[] }[],
|
||||
): StepDiffItem<MNode>[] =>
|
||||
items.map(({ oldNode, newNode, changeRecords }) => ({
|
||||
oldSchema: oldNode,
|
||||
newSchema: newNode,
|
||||
...(changeRecords?.length ? { changeRecords } : {}),
|
||||
}));
|
||||
const describeStepForRevert = (step: StepValue): string => {
|
||||
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})` : ''}`;
|
||||
}
|
||||
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})` : ''}`;
|
||||
}
|
||||
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 propPath = changeRecords?.[0]?.propPath;
|
||||
return propPath ? `还原 ${target} · ${propPath}` : `还原 ${target}`;
|
||||
}
|
||||
return `还原 ${items.length} 个节点的修改`;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
class Editor extends BaseService {
|
||||
public state: StoreState = reactive({
|
||||
@ -108,12 +115,6 @@ class Editor extends BaseService {
|
||||
alwaysMultiSelect: false,
|
||||
});
|
||||
private selectionBeforeOp: Id[] | null = null;
|
||||
/**
|
||||
* 最近一次 pushOpHistory 写入的历史记录 uuid。
|
||||
* 供 *AndGetHistoryId 系列方法在调用普通操作后取回本次产生的历史记录 id;
|
||||
* 普通操作不会读取它,调用前由 *AndGetHistoryId 重置为 null。
|
||||
*/
|
||||
private lastPushedHistoryId: string | null = null;
|
||||
|
||||
constructor() {
|
||||
super(
|
||||
@ -127,13 +128,8 @@ class Editor extends BaseService {
|
||||
* 设置当前指点节点配置
|
||||
* @param name 'root' | 'page' | 'parent' | 'node' | 'highlightNode' | 'nodes' | 'stage' | 'modifiedNodeIds' | 'pageLength' | 'pageFragmentLength
|
||||
* @param value MNode
|
||||
* @param options.historySource 设置 root 时,本次变更写入历史记录的「操作来源」(仅 name === 'root' 时生效)
|
||||
*/
|
||||
public set<K extends StoreStateKey, T extends StoreState[K]>(
|
||||
name: K,
|
||||
value: T,
|
||||
options: { historySource?: HistoryOpSource } = {},
|
||||
) {
|
||||
public set<K extends StoreStateKey, T extends StoreState[K]>(name: K, value: T) {
|
||||
const preValue = this.state[name];
|
||||
this.state[name] = value;
|
||||
|
||||
@ -152,25 +148,6 @@ class Editor extends BaseService {
|
||||
this.state.pageLength = getPageList(app).length || 0;
|
||||
this.state.pageFragmentLength = getPageFragmentList(app).length || 0;
|
||||
this.state.stageLoading = this.state.pageLength !== 0;
|
||||
|
||||
if (preValue && !isEmpty(preValue)) {
|
||||
// 编辑期间再次整体设置 root(源码保存 / 外部重设 DSL / root 节点更新):与上一次 root
|
||||
// 做页面级 diff,按 update / add / remove 入栈,作为正常历史记录体现整体替换。
|
||||
this.pushRootDiffHistory(preValue as MApp, app, options.historySource);
|
||||
} else {
|
||||
// 首次设置 root:仅当该页面 / 页面片尚无基线标记时,才写入「未修改的初始状态」基线。
|
||||
// 配合「先恢复历史再 set root」:若基线已随历史恢复建立(恢复后已有基线),则此处不再
|
||||
// 重复创建,set root 不额外产生记录,由恢复出的历史栈作为当前状态来源。
|
||||
// 标记不进入撤销/重做栈,仅作为该页历史列表底部的初始基线展示。
|
||||
app.items?.forEach((pageNode) => {
|
||||
if (pageNode?.id !== undefined && !historyService.getPageMarker(pageNode.id)) {
|
||||
historyService.setPageMarker(pageNode.id, {
|
||||
name: pageNode.name,
|
||||
source: options.historySource,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.state.pageLength = 0;
|
||||
this.state.pageFragmentLength = 0;
|
||||
@ -202,30 +179,7 @@ class Editor extends BaseService {
|
||||
root = toRaw(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);
|
||||
return getNodeInfo(id, root);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -452,13 +406,7 @@ class Editor extends BaseService {
|
||||
public async add(
|
||||
addNode: AddMNode | MNode[],
|
||||
parent?: MContainer | null,
|
||||
{
|
||||
doNotSelect = false,
|
||||
doNotSwitchPage = false,
|
||||
doNotPushHistory = false,
|
||||
historyDescription,
|
||||
historySource,
|
||||
}: DslOpOptions = {},
|
||||
{ doNotSelect = false, doNotSwitchPage = false, doNotPushHistory = false, historyDescription }: DslOpOptions = {},
|
||||
): Promise<MNode | MNode[]> {
|
||||
this.captureSelectionBeforeOp();
|
||||
|
||||
@ -518,21 +466,21 @@ class Editor extends BaseService {
|
||||
if (!(isPage(newNodes[0]) || isPageFragment(newNodes[0]))) {
|
||||
const pageForOp = this.getNodeInfo(newNodes[0].id, false).page;
|
||||
if (!doNotPushHistory) {
|
||||
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 },
|
||||
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 },
|
||||
historyDescription,
|
||||
source: historySource,
|
||||
});
|
||||
);
|
||||
} else {
|
||||
this.selectionBeforeOp = null;
|
||||
}
|
||||
@ -629,19 +577,13 @@ class Editor extends BaseService {
|
||||
*/
|
||||
public async remove(
|
||||
nodeOrNodeList: MNode | MNode[],
|
||||
{
|
||||
doNotSelect = false,
|
||||
doNotSwitchPage = false,
|
||||
doNotPushHistory = false,
|
||||
historyDescription,
|
||||
historySource,
|
||||
}: DslOpOptions = {},
|
||||
{ doNotSelect = false, doNotSwitchPage = false, doNotPushHistory = false, historyDescription }: DslOpOptions = {},
|
||||
): Promise<void> {
|
||||
this.captureSelectionBeforeOp();
|
||||
|
||||
const nodes = Array.isArray(nodeOrNodeList) ? nodeOrNodeList : [nodeOrNodeList];
|
||||
|
||||
const removedItems: StepDiffItem<MNode>[] = [];
|
||||
const removedItems: { node: MNode; parentId: Id; index: number }[] = [];
|
||||
let pageForOp: { name: string; id: Id } | null = null;
|
||||
if (!(isPage(nodes[0]) || isPageFragment(nodes[0]))) {
|
||||
for (const n of nodes) {
|
||||
@ -652,7 +594,7 @@ class Editor extends BaseService {
|
||||
}
|
||||
const idx = getNodeIndex(curNode.id, parent);
|
||||
removedItems.push({
|
||||
oldSchema: cloneDeep(toRaw(curNode)),
|
||||
node: cloneDeep(toRaw(curNode)),
|
||||
parentId: parent.id,
|
||||
index: typeof idx === 'number' ? idx : -1,
|
||||
});
|
||||
@ -664,12 +606,7 @@ class Editor extends BaseService {
|
||||
|
||||
if (removedItems.length > 0 && pageForOp) {
|
||||
if (!doNotPushHistory) {
|
||||
this.pushOpHistory('remove', {
|
||||
diff: removedItems,
|
||||
pageData: pageForOp,
|
||||
historyDescription,
|
||||
source: historySource,
|
||||
});
|
||||
this.pushOpHistory('remove', { removedItems }, pageForOp, historyDescription);
|
||||
} else {
|
||||
this.selectionBeforeOp = null;
|
||||
}
|
||||
@ -680,7 +617,7 @@ class Editor extends BaseService {
|
||||
|
||||
public async doUpdate(
|
||||
config: MNode,
|
||||
{ changeRecords = [], historySource }: { changeRecords?: ChangeRecord[]; historySource?: HistoryOpSource } = {},
|
||||
{ changeRecords = [] }: { changeRecords?: ChangeRecord[] } = {},
|
||||
): Promise<{ newNode: MNode; oldNode: MNode; changeRecords?: ChangeRecord[] }> {
|
||||
const root = this.get('root');
|
||||
if (!root) throw new Error('root为空');
|
||||
@ -700,7 +637,7 @@ class Editor extends BaseService {
|
||||
if (!newConfig.type) throw new Error('配置缺少type值');
|
||||
|
||||
if (newConfig.type === NodeType.ROOT) {
|
||||
this.set('root', newConfig as MApp, { historySource });
|
||||
this.set('root', newConfig as MApp);
|
||||
return {
|
||||
oldNode: node,
|
||||
newNode: newConfig,
|
||||
@ -767,12 +704,11 @@ class Editor extends BaseService {
|
||||
changeRecordList?: ChangeRecord[][];
|
||||
doNotPushHistory?: boolean;
|
||||
historyDescription?: string;
|
||||
historySource?: HistoryOpSource;
|
||||
} = {},
|
||||
): Promise<MNode | MNode[]> {
|
||||
this.captureSelectionBeforeOp();
|
||||
|
||||
const { doNotPushHistory = false, changeRecordList, changeRecords, historyDescription, historySource } = data;
|
||||
const { doNotPushHistory = false, changeRecordList, changeRecords, historyDescription } = data;
|
||||
|
||||
const nodes = Array.isArray(config) ? config : [config];
|
||||
|
||||
@ -781,7 +717,7 @@ class Editor extends BaseService {
|
||||
const updateData = await Promise.all(
|
||||
nodes.map((node, index) => {
|
||||
const recordsForNode = changeRecordList ? (changeRecordList[index] ?? []) : (changeRecords ?? []);
|
||||
return this.doUpdate(node, { changeRecords: recordsForNode, historySource });
|
||||
return this.doUpdate(node, { changeRecords: recordsForNode });
|
||||
}),
|
||||
);
|
||||
|
||||
@ -790,20 +726,20 @@ class Editor extends BaseService {
|
||||
if (curNodes.length) {
|
||||
if (!doNotPushHistory) {
|
||||
const pageForOp = this.getNodeInfo(nodes[0].id, false).page;
|
||||
this.pushOpHistory('update', {
|
||||
// 每个节点单独保留自己的 changeRecords,便于撤销/重做时按 propPath 精细化更新;
|
||||
// 没有 changeRecords 的(如内部 sort/moveLayer 等整节点替换操作)会退化为全节点替换。
|
||||
diff: buildUpdateDiff(
|
||||
updateData.map((d) => ({
|
||||
this.pushOpHistory(
|
||||
'update',
|
||||
{
|
||||
updatedItems: updateData.map((d) => ({
|
||||
oldNode: cloneDeep(d.oldNode),
|
||||
newNode: cloneDeep(d.newNode),
|
||||
newNode: cloneDeep(toRaw(d.newNode)),
|
||||
// 每个节点单独保留自己的 changeRecords,便于撤销/重做时按 propPath 精细化更新;
|
||||
// 没有 changeRecords 的(如内部 sort/moveLayer 等整节点替换操作)会退化为全节点替换。
|
||||
changeRecords: d.changeRecords?.length ? cloneDeep(d.changeRecords) : undefined,
|
||||
})),
|
||||
),
|
||||
pageData: { name: pageForOp?.name || '', id: pageForOp!.id },
|
||||
},
|
||||
{ name: pageForOp?.name || '', id: pageForOp!.id },
|
||||
historyDescription,
|
||||
source: historySource,
|
||||
});
|
||||
);
|
||||
} else {
|
||||
this.selectionBeforeOp = null;
|
||||
}
|
||||
@ -827,7 +763,7 @@ class Editor extends BaseService {
|
||||
public async sort(
|
||||
id1: Id,
|
||||
id2: Id,
|
||||
{ doNotSelect = false, doNotPushHistory = false, historySource }: DslOpOptions = {},
|
||||
{ doNotSelect = false, doNotPushHistory = false }: DslOpOptions = {},
|
||||
): Promise<void> {
|
||||
this.captureSelectionBeforeOp();
|
||||
|
||||
@ -847,7 +783,7 @@ class Editor extends BaseService {
|
||||
|
||||
parent.items.splice(index2, 0, ...parent.items.splice(index1, 1));
|
||||
|
||||
await this.update(parent, { doNotPushHistory, historySource });
|
||||
await this.update(parent, { doNotPushHistory });
|
||||
if (!doNotSelect) {
|
||||
await this.select(node);
|
||||
}
|
||||
@ -900,13 +836,7 @@ class Editor extends BaseService {
|
||||
public async paste(
|
||||
position: PastePosition = {},
|
||||
collectorOptions?: TargetOptions,
|
||||
{
|
||||
doNotSelect = false,
|
||||
doNotSwitchPage = false,
|
||||
doNotPushHistory = false,
|
||||
historyDescription,
|
||||
historySource,
|
||||
}: DslOpOptions = {},
|
||||
{ doNotSelect = false, doNotSwitchPage = false, doNotPushHistory = false }: DslOpOptions = {},
|
||||
): Promise<MNode | MNode[] | void> {
|
||||
const config: MNode[] = storageService.getItem(COPY_STORAGE_KEY);
|
||||
if (!Array.isArray(config)) return;
|
||||
@ -927,13 +857,7 @@ class Editor extends BaseService {
|
||||
propsService.replaceRelateId(config, pasteConfigs, collectorOptions);
|
||||
}
|
||||
|
||||
return this.add(pasteConfigs, parent, {
|
||||
doNotSelect,
|
||||
doNotSwitchPage,
|
||||
doNotPushHistory,
|
||||
historyDescription,
|
||||
historySource,
|
||||
});
|
||||
return this.add(pasteConfigs, parent, { doNotSelect, doNotSwitchPage, doNotPushHistory });
|
||||
}
|
||||
|
||||
public async doPaste(config: MNode[], position: PastePosition = {}): Promise<MNode[]> {
|
||||
@ -969,14 +893,14 @@ class Editor extends BaseService {
|
||||
*/
|
||||
public async alignCenter(
|
||||
config: MNode | MNode[],
|
||||
{ doNotSelect = false, doNotPushHistory = false, historyDescription, historySource }: DslOpOptions = {},
|
||||
{ doNotSelect = false, doNotPushHistory = false }: 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, historyDescription, historySource });
|
||||
const newNode = await this.update(newNodes, { doNotPushHistory });
|
||||
|
||||
if (!doNotSelect) {
|
||||
if (newNodes.length > 1) {
|
||||
@ -995,10 +919,7 @@ class Editor extends BaseService {
|
||||
* @param options 可选配置
|
||||
* @param options.doNotPushHistory 是否不写入历史记录(默认 false)
|
||||
*/
|
||||
public async moveLayer(
|
||||
offset: number | LayerOffset,
|
||||
{ doNotPushHistory = false, historyDescription, historySource }: DslOpOptions = {},
|
||||
): Promise<void> {
|
||||
public async moveLayer(offset: number | LayerOffset, { doNotPushHistory = false }: DslOpOptions = {}): Promise<void> {
|
||||
this.captureSelectionBeforeOp();
|
||||
|
||||
const root = this.get('root');
|
||||
@ -1039,13 +960,10 @@ class Editor extends BaseService {
|
||||
const pageForOp = this.getNodeInfo(node.id, false).page;
|
||||
this.pushOpHistory(
|
||||
'update',
|
||||
|
||||
{
|
||||
diff: buildUpdateDiff([{ oldNode: oldParent, newNode: cloneDeep(toRaw(parent)) }]),
|
||||
pageData: { name: pageForOp?.name || '', id: pageForOp!.id },
|
||||
historyDescription,
|
||||
source: historySource,
|
||||
updatedItems: [{ oldNode: oldParent, newNode: cloneDeep(toRaw(parent)) }],
|
||||
},
|
||||
{ name: pageForOp?.name || '', id: pageForOp!.id },
|
||||
);
|
||||
} else {
|
||||
this.selectionBeforeOp = null;
|
||||
@ -1071,13 +989,7 @@ class Editor extends BaseService {
|
||||
public async moveToContainer(
|
||||
config: MNode | MNode[],
|
||||
targetId: Id,
|
||||
{
|
||||
doNotSelect = false,
|
||||
doNotSwitchPage = false,
|
||||
doNotPushHistory = false,
|
||||
historyDescription,
|
||||
historySource,
|
||||
}: DslOpOptions = {},
|
||||
{ doNotSelect = false, doNotSwitchPage = false, doNotPushHistory = false }: DslOpOptions = {},
|
||||
): Promise<MNode | MNode[]> {
|
||||
const isBatch = Array.isArray(config);
|
||||
const configs = (isBatch ? config : [config]).filter((item) => !(isPage(item) || isPageFragment(item)));
|
||||
@ -1140,12 +1052,7 @@ 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', {
|
||||
diff: buildUpdateDiff(updatedItems),
|
||||
pageData: historyPage,
|
||||
historyDescription,
|
||||
source: historySource,
|
||||
});
|
||||
this.pushOpHistory('update', { updatedItems }, historyPage);
|
||||
} else {
|
||||
this.selectionBeforeOp = null;
|
||||
}
|
||||
@ -1157,7 +1064,7 @@ class Editor extends BaseService {
|
||||
config: MNode | MNode[],
|
||||
targetParent: MContainer,
|
||||
targetIndex: number,
|
||||
{ doNotPushHistory = false, historyDescription, historySource }: DslOpOptions = {},
|
||||
{ doNotPushHistory = false }: DslOpOptions = {},
|
||||
) {
|
||||
this.captureSelectionBeforeOp();
|
||||
|
||||
@ -1220,12 +1127,7 @@ class Editor extends BaseService {
|
||||
}
|
||||
if (!doNotPushHistory) {
|
||||
const pageForOp = this.getNodeInfo(configs[0].id, false).page;
|
||||
this.pushOpHistory('update', {
|
||||
diff: buildUpdateDiff(updatedItems),
|
||||
pageData: { name: pageForOp?.name || '', id: pageForOp!.id },
|
||||
historyDescription,
|
||||
source: historySource,
|
||||
});
|
||||
this.pushOpHistory('update', { updatedItems }, { name: pageForOp?.name || '', id: pageForOp!.id });
|
||||
} else {
|
||||
this.selectionBeforeOp = null;
|
||||
}
|
||||
@ -1233,86 +1135,6 @@ 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 被撤销的操作
|
||||
@ -1359,17 +1181,9 @@ class Editor extends BaseService {
|
||||
if (!entry?.applied) return null;
|
||||
|
||||
const { step } = entry;
|
||||
// 初始基线(index 0 的 initial step)是栈底线,不可回滚。
|
||||
if (step.opType === 'initial') return null;
|
||||
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) => {
|
||||
@ -1379,15 +1193,15 @@ class Editor extends BaseService {
|
||||
|
||||
const historyDescription = `回滚 #${index + 1}: ${describeStepForRevert(step)}`;
|
||||
// revert 走 public add/remove/update,让操作以一条普通新 step 入栈;不要切换选区与页面,避免打断用户。
|
||||
const opts = { doNotSelect: true, doNotSwitchPage: true, historyDescription, historySource: 'rollback' } as const;
|
||||
const opts = { doNotSelect: true, doNotSwitchPage: true, historyDescription } as const;
|
||||
|
||||
try {
|
||||
switch (step.opType) {
|
||||
case 'add': {
|
||||
// 原本是新增 → revert 即删除当时被加入的节点
|
||||
for (const { newSchema } of step.diff ?? []) {
|
||||
if (!newSchema) continue;
|
||||
const existing = this.getNodeById(newSchema.id, false);
|
||||
const nodes = step.nodes ?? [];
|
||||
for (const n of nodes) {
|
||||
const existing = this.getNodeById(n.id, false);
|
||||
if (existing) {
|
||||
await this.remove(existing, opts);
|
||||
}
|
||||
@ -1397,42 +1211,37 @@ class Editor extends BaseService {
|
||||
case 'remove': {
|
||||
// 原本是删除 → revert 即把节点按原父容器加回来。
|
||||
// 按原 index 升序逐个插回,先小后大避免索引漂移。
|
||||
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 items = step.removedItems ?? [];
|
||||
const sorted = [...items].sort((a, b) => a.index - b.index);
|
||||
for (const { node, parentId } of sorted) {
|
||||
const parent = this.getNodeById(parentId, false) as MContainer | null;
|
||||
if (parent) {
|
||||
await this.add([cloneDeep(oldSchema)] as MNode[], parent, opts);
|
||||
await this.add([cloneDeep(node)] as MNode[], parent, opts);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'update': {
|
||||
// 原本是更新 → revert 即把 oldSchema 的值写回;
|
||||
// 原本是更新 → revert 即把 oldNode 的值写回;
|
||||
// 优先按 changeRecords 局部 patch(仅触达 propPath 下的字段,避免冲掉同节点上其它无关变更)。
|
||||
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 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);
|
||||
}
|
||||
return patch;
|
||||
const value = cloneDeep(getValueByKeyPath(record.propPath, oldNode));
|
||||
setValueByKeyPath(record.propPath, value, patch);
|
||||
}
|
||||
return cloneDeep(oldNode);
|
||||
});
|
||||
return patch;
|
||||
}
|
||||
return cloneDeep(oldNode);
|
||||
});
|
||||
if (configs.length) {
|
||||
await this.update(configs, { historyDescription, historySource: 'rollback' });
|
||||
await this.update(configs, { historyDescription });
|
||||
}
|
||||
break;
|
||||
}
|
||||
@ -1450,20 +1259,6 @@ 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳转当前页面历史栈到指定游标位置。
|
||||
*
|
||||
@ -1490,21 +1285,14 @@ class Editor extends BaseService {
|
||||
return cursor;
|
||||
}
|
||||
|
||||
public async move(
|
||||
left: number,
|
||||
top: number,
|
||||
{ doNotPushHistory = false, historyDescription, historySource }: DslOpOptions = {},
|
||||
) {
|
||||
public async move(left: number, top: number, { doNotPushHistory = false }: 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, historyDescription, historySource },
|
||||
);
|
||||
await this.update({ id: node.id, type: node.type, style: newStyle }, { doNotPushHistory });
|
||||
}
|
||||
|
||||
public resetState() {
|
||||
@ -1560,119 +1348,25 @@ class Editor extends BaseService {
|
||||
this.selectionBeforeOp = this.get('nodes').map((n) => n.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 比较「上一次 root」与「新 root」的页面 / 页面片,按页面粒度把整体替换拆成历史记录:
|
||||
* - 新旧都存在且内容变化的页面 → 一条 `update`(整页快照替换,无 changeRecords);
|
||||
* - 仅新 root 存在的页面 → 一条 `add`;
|
||||
* - 仅旧 root 存在的页面 → 一条 `remove`。
|
||||
*
|
||||
* 每条记录落到对应页面自己的历史栈(与普通节点操作一致),并标记来源 `source`。
|
||||
* 内容未变化的页面不产生记录,避免重复设置相同 DSL 时产生噪声。
|
||||
*/
|
||||
private pushRootDiffHistory(preRoot: MApp, nextRoot: MApp, source?: HistoryOpSource): void {
|
||||
const prevPages = preRoot?.items || [];
|
||||
const nextPages = nextRoot?.items || [];
|
||||
const prevMap = new Map(prevPages.map((p) => [`${p.id}`, p]));
|
||||
const nextMap = new Map(nextPages.map((p) => [`${p.id}`, p]));
|
||||
const indexInItems = (root: MApp, id: Id) => (root.items ?? []).findIndex((item) => `${item.id}` === `${id}`);
|
||||
|
||||
nextPages.forEach((nextPage) => {
|
||||
const prevPage = prevMap.get(`${nextPage.id}`);
|
||||
if (!prevPage) {
|
||||
this.pushPageDiffStep(
|
||||
'add',
|
||||
nextPage,
|
||||
{ newSchema: cloneDeep(toRaw(nextPage)), parentId: nextRoot.id, index: indexInItems(nextRoot, nextPage.id) },
|
||||
source,
|
||||
);
|
||||
} else if (!isEqual(toRaw(prevPage), toRaw(nextPage))) {
|
||||
this.pushPageDiffStep(
|
||||
'update',
|
||||
nextPage,
|
||||
{ oldSchema: cloneDeep(toRaw(prevPage)), newSchema: cloneDeep(toRaw(nextPage)) },
|
||||
source,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
prevPages.forEach((prevPage) => {
|
||||
if (!nextMap.has(`${prevPage.id}`)) {
|
||||
this.pushPageDiffStep(
|
||||
'remove',
|
||||
prevPage,
|
||||
{ oldSchema: cloneDeep(toRaw(prevPage)), parentId: preRoot.id, index: indexInItems(preRoot, prevPage.id) },
|
||||
source,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造一条页面级「set root」历史记录(不携带选区 / modifiedNodeIds 上下文)并落到该页面自己的栈。
|
||||
*
|
||||
* 连续 set root 替换:若该页栈最新一条已是**同来源**的 set root 记录({@link StepValue.rootStep} 且 `source` 相同),
|
||||
* 则用本次记录**替换**它而非新增,避免源码反复保存 / 外部重设 DSL 时堆积多条 root 记录;
|
||||
* 来源不同则照常新增(initial 基线不是 rootStep,不在此列)。
|
||||
*/
|
||||
private pushPageDiffStep(
|
||||
opType: HistoryOpType,
|
||||
page: MPage | MPageFragment,
|
||||
diffItem: StepDiffItem<MNode>,
|
||||
source?: HistoryOpSource,
|
||||
): void {
|
||||
const step: StepValue = {
|
||||
uuid: guid(),
|
||||
data: { name: page.name || '', id: page.id },
|
||||
opType,
|
||||
selectedBefore: [],
|
||||
selectedAfter: [],
|
||||
modifiedNodeIds: new Map(),
|
||||
diff: [diffItem],
|
||||
rootStep: true,
|
||||
};
|
||||
if (source) step.source = source;
|
||||
|
||||
const top = historyService.getCurrentPageStep(page.id);
|
||||
if (top?.rootStep && top.source === source) {
|
||||
historyService.replaceCurrentPageStep(step, page.id);
|
||||
} else {
|
||||
historyService.push(step, page.id);
|
||||
}
|
||||
}
|
||||
|
||||
private pushOpHistory(
|
||||
opType: HistoryOpType,
|
||||
{
|
||||
diff,
|
||||
pageData,
|
||||
historyDescription,
|
||||
source,
|
||||
}: {
|
||||
diff: StepDiffItem<MNode>[];
|
||||
pageData: { name: string; id: Id };
|
||||
historyDescription?: string;
|
||||
source?: HistoryOpSource;
|
||||
},
|
||||
): string | null {
|
||||
extra: Partial<StepValue>,
|
||||
pageData: { name: string; id: Id },
|
||||
historyDescription?: string,
|
||||
) {
|
||||
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')),
|
||||
diff,
|
||||
...extra,
|
||||
};
|
||||
if (historyDescription) step.historyDescription = historyDescription;
|
||||
if (source) step.source = source;
|
||||
// 显式按 step.data.id 入栈:跨页操作(如 moveToContainer 从源页搬到目标页)
|
||||
// 必须落到正确的页面栈,否则会把记录错误地推到当前活动页 / 操作发起页。
|
||||
const pushed = historyService.push(step, pageData.id);
|
||||
// push 返回 null 表示当前没有可写入的页面栈(未真正入栈),此时不应返回 uuid。
|
||||
const historyId = pushed ? step.uuid : null;
|
||||
this.lastPushedHistoryId = historyId;
|
||||
historyService.push(step, pageData.id);
|
||||
this.selectionBeforeOp = null;
|
||||
return historyId;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1688,8 +1382,6 @@ class Editor extends BaseService {
|
||||
* @param reverse true = 撤销,false = 重做
|
||||
*/
|
||||
private async applyHistoryOp(step: StepValue, reverse: boolean) {
|
||||
// 初始基线 step 仅作展示,不承载任何变更,撤销/重做时无需应用(正常流程下也不会被触达)。
|
||||
if (step.opType === 'initial') return;
|
||||
const root = this.get('root');
|
||||
const stage = this.get('stage');
|
||||
if (!root) return;
|
||||
@ -1698,52 +1390,52 @@ class Editor extends BaseService {
|
||||
|
||||
switch (step.opType) {
|
||||
case 'add': {
|
||||
const items = step.diff ?? [];
|
||||
const nodes = step.nodes ?? [];
|
||||
if (reverse) {
|
||||
// 撤销 add:把当时加入的节点删除
|
||||
for (const { newSchema } of items) {
|
||||
if (!newSchema) continue;
|
||||
const existing = this.getNodeById(newSchema.id, false);
|
||||
for (const n of nodes) {
|
||||
const existing = this.getNodeById(n.id, false);
|
||||
if (existing) {
|
||||
await this.remove(existing, commonOpts);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 重做 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));
|
||||
// 重做 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),
|
||||
});
|
||||
}
|
||||
await stage?.add({
|
||||
config: cloneDeep(newSchema),
|
||||
parent: cloneDeep(parent),
|
||||
parentId: parent.id,
|
||||
root: cloneDeep(root),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'remove': {
|
||||
const items = step.diff ?? [];
|
||||
const items = step.removedItems ?? [];
|
||||
if (reverse) {
|
||||
// 撤销 remove:按原 index 升序逐个插回(先小后大避免索引漂移)
|
||||
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 sorted = [...items].sort((a, b) => a.index - b.index);
|
||||
for (const { node, parentId, index } of sorted) {
|
||||
const parent = this.getNodeById(parentId, false) as MContainer | null;
|
||||
if (parent?.items) {
|
||||
parent.items.splice(index ?? parent.items.length, 0, cloneDeep(oldSchema));
|
||||
parent.items.splice(index, 0, cloneDeep(node));
|
||||
await stage?.add({
|
||||
config: cloneDeep(oldSchema),
|
||||
config: cloneDeep(node),
|
||||
parent: cloneDeep(parent),
|
||||
parentId,
|
||||
root: cloneDeep(root),
|
||||
@ -1752,9 +1444,8 @@ class Editor extends BaseService {
|
||||
}
|
||||
} else {
|
||||
// 重做 remove:再删一次
|
||||
for (const { oldSchema } of items) {
|
||||
if (!oldSchema) continue;
|
||||
const existing = this.getNodeById(oldSchema.id, false);
|
||||
for (const { node } of items) {
|
||||
const existing = this.getNodeById(node.id, false);
|
||||
if (existing) {
|
||||
await this.remove(existing, commonOpts);
|
||||
}
|
||||
@ -1763,31 +1454,27 @@ class Editor extends BaseService {
|
||||
break;
|
||||
}
|
||||
case 'update': {
|
||||
const items = step.diff ?? [];
|
||||
const items = step.updatedItems ?? [];
|
||||
// 优先按 changeRecords 局部 patch:仅触达 propPath 下的字段,避免整节点替换冲掉同节点上其它无关变更。
|
||||
// 没有 changeRecords 的(如内部 sort/moveLayer/拖动等整节点快照场景)才退化为整节点替换。
|
||||
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 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);
|
||||
}
|
||||
return patch;
|
||||
const value = cloneDeep(getValueByKeyPath(record.propPath, sourceForValues));
|
||||
setValueByKeyPath(record.propPath, value, patch);
|
||||
}
|
||||
return cloneDeep(reverse ? oldNode : newNode);
|
||||
});
|
||||
return patch;
|
||||
}
|
||||
return cloneDeep(reverse ? oldNode : newNode);
|
||||
});
|
||||
if (configs.length) {
|
||||
await this.update(configs, { doNotPushHistory: true });
|
||||
}
|
||||
|
||||
@ -17,49 +17,183 @@
|
||||
*/
|
||||
|
||||
import { reactive } from 'vue';
|
||||
import serialize from 'serialize-javascript';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
|
||||
import type { CodeBlockContent, DataSourceSchema, Id, MPage, MPageFragment } from '@tmagic/core';
|
||||
import type { ChangeRecord } from '@tmagic/form';
|
||||
import { guid } from '@tmagic/utils';
|
||||
|
||||
import type {
|
||||
CodeBlockHistoryGroup,
|
||||
CodeBlockStepValue,
|
||||
DataSourceHistoryGroup,
|
||||
DataSourceStepValue,
|
||||
HistoryOpSource,
|
||||
HistoryPersistOptions,
|
||||
HistoryState,
|
||||
PageHistoryGroup,
|
||||
PageHistoryStepEntry,
|
||||
PersistedHistoryState,
|
||||
StepValue,
|
||||
} from '@editor/type';
|
||||
import { getEditorConfig } from '@editor/utils/config';
|
||||
import {
|
||||
createStackStep,
|
||||
deserializeStacks,
|
||||
getOrCreateStack,
|
||||
markStackSaved,
|
||||
mergePageSteps,
|
||||
mergeStackSteps,
|
||||
serializeStacks,
|
||||
undoFloor,
|
||||
} from '@editor/utils/history';
|
||||
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:
|
||||
* - 把"新增/删除"独立成组(语义上属于一次性事件,不应与 update 合并);
|
||||
* - 连续 'update' 合并到同一组,组内 steps 顺序就是发生顺序。
|
||||
*/
|
||||
private static mergeCodeBlockSteps(
|
||||
codeBlockId: Id,
|
||||
list: CodeBlockStepValue[],
|
||||
cursor: number,
|
||||
): CodeBlockHistoryGroup[] {
|
||||
const groups: CodeBlockHistoryGroup[] = [];
|
||||
let current: CodeBlockHistoryGroup | null = null;
|
||||
const currentIndex = cursor - 1;
|
||||
list.forEach((step, index) => {
|
||||
const opType = History.detectOpType(step.oldContent, step.newContent);
|
||||
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: '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,
|
||||
opType,
|
||||
steps: [{ step, index, applied, isCurrent }],
|
||||
applied,
|
||||
isCurrent,
|
||||
};
|
||||
groups.push(current);
|
||||
}
|
||||
});
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 old/new 是否为 null 推断 opType(与 push 时的约定一致)。
|
||||
*/
|
||||
private static detectOpType(oldVal: unknown, newVal: unknown): 'add' | 'remove' | 'update' {
|
||||
if (oldVal === null && newVal !== null) return 'add';
|
||||
if (oldVal !== null && newVal === null) return 'remove';
|
||||
return 'update';
|
||||
}
|
||||
|
||||
/**
|
||||
* 把页面栈拆成若干 group:
|
||||
* - 单节点的 'update' 按 targetId 与相邻同 targetId 的 update 合并到一个 group;
|
||||
* - 'add' / 'remove' 始终独立成组(语义上是结构变更,不应被收纳进单节点修改组);
|
||||
* - 多节点 'update'(如批量改属性)也独立成组(无明确单一目标,避免误合并)。
|
||||
*/
|
||||
private static mergePageSteps(pageId: Id, list: StepValue[], cursor: number): PageHistoryGroup[] {
|
||||
const groups: PageHistoryGroup[] = [];
|
||||
let current: PageHistoryGroup | null = null;
|
||||
const currentIndex = cursor - 1;
|
||||
list.forEach((step, index) => {
|
||||
const applied = index < cursor;
|
||||
const isCurrent = index === currentIndex;
|
||||
const targetId = History.detectPageTargetId(step);
|
||||
const targetName = History.detectPageTargetName(step);
|
||||
const entry: PageHistoryStepEntry = { step, index, applied, isCurrent };
|
||||
|
||||
// 仅"单节点 update"参与合并;其它情形(add/remove/多节点 update)始终独立成组。
|
||||
const mergeable = step.opType === 'update' && targetId !== undefined;
|
||||
if (mergeable && current?.opType === 'update' && current.targetId === targetId) {
|
||||
current.steps.push(entry);
|
||||
current.applied = applied;
|
||||
if (isCurrent) current.isCurrent = true;
|
||||
// 保持目标名为最近一次的(节点重命名时也能反映)
|
||||
if (targetName) current.targetName = targetName;
|
||||
} else {
|
||||
current = {
|
||||
kind: 'page',
|
||||
pageId,
|
||||
opType: step.opType,
|
||||
targetId: mergeable ? targetId : undefined,
|
||||
targetName,
|
||||
steps: [entry],
|
||||
applied,
|
||||
isCurrent,
|
||||
};
|
||||
groups.push(current);
|
||||
}
|
||||
});
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 StepValue 中的"目标节点 id"用于合并:
|
||||
* - 单节点 update:取唯一一项 updatedItems 的节点 id;
|
||||
* - 其它情形(多节点 update / add / remove):返回 undefined,表示不参与合并。
|
||||
*/
|
||||
private static detectPageTargetId(step: StepValue): Id | undefined {
|
||||
if (step.opType !== 'update') return undefined;
|
||||
const items = step.updatedItems;
|
||||
if (items?.length !== 1) return undefined;
|
||||
return items[0].newNode?.id ?? items[0].oldNode?.id;
|
||||
}
|
||||
|
||||
/** 解析 StepValue 中的目标节点可读名(用于 UI 展示)。 */
|
||||
private static detectPageTargetName(step: StepValue): string | undefined {
|
||||
if (step.opType === 'update') {
|
||||
const items = step.updatedItems;
|
||||
if (items?.length === 1) {
|
||||
const node = items[0].newNode || items[0].oldNode;
|
||||
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}`;
|
||||
}
|
||||
return step.nodes?.length ? `${step.nodes.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}`;
|
||||
}
|
||||
return step.removedItems?.length ? `${step.removedItems.length} 个节点` : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public state = reactive<HistoryState>({
|
||||
pageSteps: {},
|
||||
pageId: undefined,
|
||||
@ -111,59 +245,6 @@ class History extends BaseService {
|
||||
this.state.dataSourceState = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 为指定页面 / 页面片种入一条「初始基线」记录(如加载 DSL 时的「初始 / 加载」基线)。
|
||||
*
|
||||
* 该记录是一条 `opType: 'initial'` 的 {@link StepValue},作为页面历史栈 **index 0 的固定底线**:
|
||||
* - 它是一条真实入栈的 step(随栈一起持久化),但被钉为撤销/回滚的下限——cursor 永不低于它,
|
||||
* 因此不会被 undo / goto / revert 触达(详见 {@link undo} / {@link setCanUndoRedo});
|
||||
* - 历史面板把它过滤出分组列表(见 {@link getPageHistoryGroups}),改由底部「初始」行展示。
|
||||
*
|
||||
* 仅当目标页面栈为空时种入(保证 initial 一定位于 index 0);已存在 initial 底线时默认不重复种入,
|
||||
* 传 `force=true` 且栈为空时按新基线种入。
|
||||
*/
|
||||
public setPageMarker(
|
||||
pageId: Id,
|
||||
options: { name?: string; description?: string; source?: HistoryOpSource } = {},
|
||||
): StepValue | null {
|
||||
if (pageId === undefined || pageId === null || `${pageId}` === '') return null;
|
||||
|
||||
const existing = this.getPageMarker(pageId);
|
||||
if (existing) return existing;
|
||||
|
||||
const stack = getOrCreateStack(this.state.pageSteps, pageId);
|
||||
// initial 必须是 index 0;栈非空(已有真实记录、却无 initial,如旧数据)时不强行前插,优雅降级为无基线。
|
||||
if (stack.getLength() > 0) return null;
|
||||
|
||||
const marker: StepValue = {
|
||||
uuid: guid(),
|
||||
opType: 'initial',
|
||||
diff: [],
|
||||
data: { name: options.name || '', id: pageId },
|
||||
selectedBefore: [],
|
||||
selectedAfter: [],
|
||||
modifiedNodeIds: new Map(),
|
||||
historyDescription: options.description || '未修改的初始状态',
|
||||
timestamp: Date.now(),
|
||||
...(options.source ? { source: options.source } : {}),
|
||||
};
|
||||
stack.pushElement(marker);
|
||||
if (`${pageId}` === `${this.state.pageId}`) this.setCanUndoRedo();
|
||||
this.emit('page-marker-change', marker);
|
||||
return marker;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取指定页面(缺省当前活动页)的初始基线 step(页面栈 index 0 且 `opType: 'initial'`);
|
||||
* 不存在时返回 undefined。
|
||||
*/
|
||||
public getPageMarker(pageId?: Id): StepValue | undefined {
|
||||
const targetPageId = pageId ?? this.state.pageId;
|
||||
if (!targetPageId) return undefined;
|
||||
const first = this.state.pageSteps[targetPageId]?.getElementList()[0];
|
||||
return first?.opType === 'initial' ? first : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一条步骤推入指定页面的栈;不指定 pageId 时落到当前活动页。
|
||||
*
|
||||
@ -173,8 +254,6 @@ 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 状态没影响。
|
||||
if (pageId === undefined || `${pageId}` === `${this.state.pageId}`) {
|
||||
@ -183,27 +262,6 @@ class History extends BaseService {
|
||||
return state;
|
||||
}
|
||||
|
||||
/** 读取指定页面(缺省当前活动页)历史栈当前游标所在的 step(cursor - 1);无则返回 null。 */
|
||||
public getCurrentPageStep(pageId?: Id): StepValue | null {
|
||||
const targetPageId = pageId ?? this.state.pageId;
|
||||
if (!targetPageId) return null;
|
||||
return this.state.pageSteps[targetPageId]?.getCurrentElement() ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用 `state` 替换指定页面栈当前游标所在的 step(并丢弃其后的重做尾部),游标不变。
|
||||
* 用于「连续 set root 记录合并」等就地替换最新一条的场景;替换成功后按需刷新 / 通知。
|
||||
*/
|
||||
public replaceCurrentPageStep(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();
|
||||
if (!undoRedo.replaceCurrentElement(state)) return null;
|
||||
this.emit('change', state);
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* 推入一条代码块变更记录(与页面/节点完全无关),按 `codeBlockId` 维度独立一份 UndoRedo 栈。
|
||||
*
|
||||
@ -221,19 +279,19 @@ class History extends BaseService {
|
||||
changeRecords?: ChangeRecord[];
|
||||
/** 可选的人类可读描述(如「修改按钮颜色」),仅用于历史面板展示。 */
|
||||
historyDescription?: string;
|
||||
/** 可选的操作途径(配置面板 / 菜单 / 接口等),仅用于历史面板展示与埋点。 */
|
||||
source?: HistoryOpSource;
|
||||
},
|
||||
): CodeBlockStepValue | null {
|
||||
const step = createStackStep<CodeBlockContent, CodeBlockStepValue>(codeBlockId, {
|
||||
oldValue: payload.oldContent,
|
||||
newValue: payload.newContent,
|
||||
changeRecords: payload.changeRecords,
|
||||
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,
|
||||
historyDescription: payload.historyDescription,
|
||||
source: payload.source,
|
||||
});
|
||||
if (!step) return null;
|
||||
getOrCreateStack(this.state.codeBlockState, codeBlockId).pushElement(step);
|
||||
};
|
||||
|
||||
this.getCodeBlockUndoRedo(codeBlockId).pushElement(step);
|
||||
this.emit('code-block-history-change', codeBlockId, step);
|
||||
return step;
|
||||
}
|
||||
@ -250,19 +308,19 @@ class History extends BaseService {
|
||||
changeRecords?: ChangeRecord[];
|
||||
/** 可选的人类可读描述,仅用于历史面板展示。 */
|
||||
historyDescription?: string;
|
||||
/** 可选的操作途径(配置面板 / 菜单 / 接口等),仅用于历史面板展示与埋点。 */
|
||||
source?: HistoryOpSource;
|
||||
},
|
||||
): DataSourceStepValue | null {
|
||||
const step = createStackStep<DataSourceSchema, DataSourceStepValue>(dataSourceId, {
|
||||
oldValue: payload.oldSchema,
|
||||
newValue: payload.newSchema,
|
||||
changeRecords: payload.changeRecords,
|
||||
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,
|
||||
historyDescription: payload.historyDescription,
|
||||
source: payload.source,
|
||||
});
|
||||
if (!step) return null;
|
||||
getOrCreateStack(this.state.dataSourceState, dataSourceId).pushElement(step);
|
||||
};
|
||||
|
||||
this.getDataSourceUndoRedo(dataSourceId).pushElement(step);
|
||||
this.emit('data-source-history-change', dataSourceId, step);
|
||||
return step;
|
||||
}
|
||||
@ -326,8 +384,6 @@ class History extends BaseService {
|
||||
public undo(): StepValue | null {
|
||||
const undoRedo = this.getUndoRedo();
|
||||
if (!undoRedo) return null;
|
||||
// 不允许撤销越过初始基线(index 0 的 initial step)。
|
||||
if (undoRedo.getCursor() <= undoFloor(undoRedo)) return null;
|
||||
const state = undoRedo.undo();
|
||||
this.emit('change', state);
|
||||
return state;
|
||||
@ -347,146 +403,6 @@ class History extends BaseService {
|
||||
this.removeAllPlugins();
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空指定页面(缺省当前活动页)的历史记录栈。
|
||||
* 仅删除撤销/重做记录,不会改动当前 DSL;清空后该页将无法再撤销/重做之前的操作。
|
||||
*/
|
||||
public clearPage(pageId?: Id): void {
|
||||
const targetPageId = pageId ?? this.state.pageId;
|
||||
if (!targetPageId) return;
|
||||
// 保留该页原 initial 基线的文案 / 来源(仅清空其后的真实操作记录),无基线时清空成空栈。
|
||||
const marker = this.getPageMarker(targetPageId);
|
||||
this.state.pageSteps[targetPageId] = new UndoRedo<StepValue>();
|
||||
if (marker) {
|
||||
this.setPageMarker(targetPageId, {
|
||||
name: marker.data?.name,
|
||||
description: marker.historyDescription,
|
||||
source: marker.source,
|
||||
});
|
||||
}
|
||||
if (`${targetPageId}` === `${this.state.pageId}`) {
|
||||
this.setCanUndoRedo();
|
||||
this.emit('clear-page', 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(markStackSaved);
|
||||
Object.values(this.state.codeBlockState).forEach(markStackSaved);
|
||||
Object.values(this.state.dataSourceState).forEach(markStackSaved);
|
||||
this.emit('mark-saved', { kind: 'all' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记指定页面(缺省为当前活动页)的历史栈当前记录为已保存。
|
||||
* 仅影响该页面自己的栈,不波及代码块 / 数据源 / 其它页面。
|
||||
*/
|
||||
public markPageSaved(pageId?: Id): void {
|
||||
const targetPageId = pageId ?? this.state.pageId;
|
||||
if (!targetPageId) return;
|
||||
markStackSaved(this.state.pageSteps[targetPageId]);
|
||||
this.emit('mark-saved', { kind: 'page', id: targetPageId });
|
||||
}
|
||||
|
||||
/** 标记指定代码块的历史栈当前记录为已保存,仅影响该代码块自己的栈。 */
|
||||
public markCodeBlockSaved(codeBlockId: Id): void {
|
||||
if (!codeBlockId) return;
|
||||
markStackSaved(this.state.codeBlockState[codeBlockId]);
|
||||
this.emit('mark-saved', { kind: 'code-block', id: codeBlockId });
|
||||
}
|
||||
|
||||
/** 标记指定数据源的历史栈当前记录为已保存,仅影响该数据源自己的栈。 */
|
||||
public markDataSourceSaved(dataSourceId: Id): void {
|
||||
if (!dataSourceId) return;
|
||||
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, appId } = options;
|
||||
|
||||
const snapshot: PersistedHistoryState = {
|
||||
version: PERSIST_VERSION,
|
||||
pageId: this.state.pageId,
|
||||
pageSteps: serializeStacks(this.state.pageSteps),
|
||||
codeBlockState: serializeStacks(this.state.codeBlockState),
|
||||
dataSourceState: serializeStacks(this.state.dataSourceState),
|
||||
savedAt: Date.now(),
|
||||
};
|
||||
|
||||
// 历史记录里可能包含函数(如代码块内容 / 节点事件 / 数据源方法),IndexedDB 的结构化克隆无法写入函数,
|
||||
// 因此用 serialize-javascript 序列化成字符串后再写入(支持函数 / Map 等),读取时用 parseDSL 还原。
|
||||
await idbSet(this.resolveDbName(dbName, appId), 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, appId } = options;
|
||||
|
||||
const raw = await idbGet<string | PersistedHistoryState>(this.resolveDbName(dbName, appId), 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 = deserializeStacks(snapshot.pageSteps);
|
||||
this.state.codeBlockState = deserializeStacks(snapshot.codeBlockState);
|
||||
this.state.dataSourceState = deserializeStacks(snapshot.dataSourceState);
|
||||
// initial 基线作为页面栈 index 0 的 step 随 pageSteps 一并还原,无需单独恢复。
|
||||
this.state.pageId = snapshot.pageId;
|
||||
|
||||
this.setCanUndoRedo();
|
||||
this.emit('restore-from-indexed-db', snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取出当前活动页的历史步骤平铺列表(包含已应用 + 已撤销)。
|
||||
* 列表按时间正序,最早一步在最前面。
|
||||
@ -520,14 +436,15 @@ class History extends BaseService {
|
||||
const list = undoRedo.getElementList();
|
||||
if (!list.length) return [];
|
||||
const cursor = undoRedo.getCursor();
|
||||
// initial 基线(index 0)不作为普通操作组展示,过滤掉;其余真实 step 的 index 保持不变,
|
||||
// 以便面板 goto(index+1) / revert(index) 仍直接对应栈内位置。底部「初始」行由 getPageMarker 驱动。
|
||||
return mergePageSteps(targetPageId, list, cursor).filter((group) => group.opType !== 'initial');
|
||||
return History.mergePageSteps(targetPageId, list, cursor);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取出全部代码块的历史栈,按 codeBlockId 分桶展示。
|
||||
* 同一栈内每条操作记录独立成组,不做相邻 update 合并。
|
||||
* 取出全部代码块的历史栈,按 codeBlockId 分组。
|
||||
* 同一栈内相邻、同 opType 且作用于同一 id 的多步会被合并为一个 group:
|
||||
* - 这正是"代码块/数据源各自按 id 分栈"的天然表现,再叠加"连续修改同目标的相邻步骤合并展示"。
|
||||
* - 合并后 group 暴露子步骤数组,UI 可展开查看每一步的 changeRecords。
|
||||
* - applied 字段:组内最后一步是否处于已应用段。
|
||||
*/
|
||||
public getCodeBlockHistoryGroups(): CodeBlockHistoryGroup[] {
|
||||
const groups: CodeBlockHistoryGroup[] = [];
|
||||
@ -536,7 +453,7 @@ class History extends BaseService {
|
||||
const list = undoRedo.getElementList();
|
||||
if (!list.length) return;
|
||||
const cursor = undoRedo.getCursor();
|
||||
groups.push(...mergeStackSteps('code-block', id, list, cursor));
|
||||
groups.push(...History.mergeCodeBlockSteps(id, list, cursor));
|
||||
});
|
||||
return groups;
|
||||
}
|
||||
@ -584,42 +501,7 @@ class History extends BaseService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 按历史记录 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 分桶展示。同上,每条操作独立成组。
|
||||
* 取出全部数据源的历史栈,按 dataSourceId 分组。同上。
|
||||
*/
|
||||
public getDataSourceHistoryGroups(): DataSourceHistoryGroup[] {
|
||||
const groups: DataSourceHistoryGroup[] = [];
|
||||
@ -628,7 +510,7 @@ class History extends BaseService {
|
||||
const list = undoRedo.getElementList();
|
||||
if (!list.length) return;
|
||||
const cursor = undoRedo.getCursor();
|
||||
groups.push(...mergeStackSteps('data-source', id, list, cursor));
|
||||
groups.push(...History.mergeDataSourceSteps(id, list, cursor));
|
||||
});
|
||||
return groups;
|
||||
}
|
||||
@ -648,21 +530,30 @@ class History extends BaseService {
|
||||
return this.state.pageSteps[targetPageId];
|
||||
}
|
||||
|
||||
/**
|
||||
* 把基础 dbName 与当前 DSL(root app)的 id 拼成最终库名,实现不同应用历史隔离。
|
||||
* 取不到 app id(如尚未加载 DSL)时退回基础 dbName。
|
||||
*/
|
||||
private resolveDbName(dbName: string, appId?: Id): string {
|
||||
// 优先用显式传入的 appId(「先恢复再 set root」时 root 尚未就绪);否则回退到当前 root.id。
|
||||
const resolvedAppId = appId ?? editorService.get('root')?.id;
|
||||
return resolvedAppId ? `${dbName}-${resolvedAppId}` : dbName;
|
||||
}
|
||||
|
||||
private setCanUndoRedo(): void {
|
||||
const undoRedo = this.getUndoRedo();
|
||||
this.state.canRedo = undoRedo?.canRedo() || false;
|
||||
// 初始基线之上才可撤销:cursor 必须高于底线(有 initial 时为 1)。
|
||||
this.state.canUndo = undoRedo ? undoRedo.getCursor() > undoFloor(undoRedo) : false;
|
||||
this.state.canUndo = undoRedo?.canUndo() || false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 id 获取(或创建)指定代码块的 UndoRedo 栈。
|
||||
*/
|
||||
private getCodeBlockUndoRedo(codeBlockId: Id): UndoRedo<CodeBlockStepValue> {
|
||||
if (!this.state.codeBlockState[codeBlockId]) {
|
||||
this.state.codeBlockState[codeBlockId] = new UndoRedo<CodeBlockStepValue>();
|
||||
}
|
||||
return this.state.codeBlockState[codeBlockId];
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 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];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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, { historySource: 'shortcut' });
|
||||
editorService.remove(nodes);
|
||||
},
|
||||
[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, { historySource: 'shortcut' });
|
||||
editorService.remove(nodes);
|
||||
},
|
||||
[KeyBindingCommand.PASTE_NODE]: () => {
|
||||
const nodes = editorService.get('nodes');
|
||||
nodes && editorService.paste({ offsetX: 10, offsetY: 10 }, undefined, { historySource: 'shortcut' });
|
||||
nodes && editorService.paste({ offsetX: 10, offsetY: 10 });
|
||||
},
|
||||
[KeyBindingCommand.UNDO]: () => {
|
||||
editorService.undo();
|
||||
|
||||
@ -62,7 +62,6 @@ const state = shallowReactive<UiState>({
|
||||
showPageListButton: true,
|
||||
hideSlideBar: false,
|
||||
sideBarItems: [],
|
||||
sideBarActiveTabName: '',
|
||||
navMenuRect: {
|
||||
left: 0,
|
||||
top: 0,
|
||||
@ -105,13 +104,7 @@ class Ui extends BaseService {
|
||||
mask?.showRule(value as unknown as boolean);
|
||||
}
|
||||
|
||||
const preValue = state[name];
|
||||
|
||||
state[name] = value;
|
||||
|
||||
if (preValue !== value) {
|
||||
this.emit('state-change', name, value, preValue);
|
||||
}
|
||||
}
|
||||
|
||||
public get<K extends keyof UiState>(name: K) {
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
z-index: 2;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
@ -45,28 +45,6 @@
|
||||
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;
|
||||
@ -147,19 +125,19 @@
|
||||
&.is-merged {
|
||||
margin: 4px 0;
|
||||
padding: 4px 8px 6px;
|
||||
background-color: rgba(47, 84, 235, 0.06);
|
||||
border: 1px solid rgba(47, 84, 235, 0.18);
|
||||
border-left: 3px solid #2f54eb;
|
||||
background-color: rgba(144, 105, 219, 0.06);
|
||||
border: 1px solid rgba(144, 105, 219, 0.18);
|
||||
border-left: 3px solid #9069db;
|
||||
border-radius: 4px;
|
||||
|
||||
// 卡片本体已经有背景色,hover 状态以更深的同色提示交互
|
||||
&:hover {
|
||||
background-color: rgba(47, 84, 235, 0.1);
|
||||
background-color: rgba(144, 105, 219, 0.1);
|
||||
}
|
||||
|
||||
.m-editor-history-list-group-head {
|
||||
font-weight: 600;
|
||||
color: #1d39c4;
|
||||
color: #5b3fa5;
|
||||
}
|
||||
|
||||
// 已撤销态:整张卡片去色
|
||||
@ -191,7 +169,7 @@
|
||||
margin: 6px 0 0 6px;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
border-left: 1px dashed rgba(47, 84, 235, 0.45);
|
||||
border-left: 1px dashed rgba(144, 105, 219, 0.45);
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
@ -207,7 +185,7 @@
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(47, 84, 235, 0.1);
|
||||
background-color: rgba(144, 105, 219, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
@ -237,10 +215,6 @@
|
||||
|
||||
.m-editor-history-list-item-index {
|
||||
flex: 0 0 auto;
|
||||
// 固定最小宽度并右对齐:序号位数不一(#6 / #16)时右边界仍统一,
|
||||
// 使紧随其后的「类型」徽标在各行间对齐成整齐的一列。
|
||||
min-width: 30px;
|
||||
text-align: right;
|
||||
color: #909399;
|
||||
font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
|
||||
font-size: 11px;
|
||||
@ -248,25 +222,6 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// 合并组头部展示的是步骤区间(如 #10-#20),宽度本就不定、也无需与单步行对齐,
|
||||
// 恢复自然宽度与左对齐,避免被强制成固定列后显得突兀。
|
||||
.m-editor-history-list-group.is-merged
|
||||
> .m-editor-history-list-group-head
|
||||
> .m-editor-history-list-item-index {
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
// 操作时间:弱化展示,紧贴在描述之后、各操作按钮之前。
|
||||
.m-editor-history-list-item-time {
|
||||
flex: 0 0 auto;
|
||||
color: #a8abb2;
|
||||
font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 400; // 防止被合并组头部的粗体继承
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m-editor-history-list-item-op {
|
||||
flex: 0 0 auto;
|
||||
padding: 0 6px;
|
||||
@ -285,7 +240,7 @@
|
||||
}
|
||||
|
||||
&.op-update {
|
||||
background-color: #e6a23c;
|
||||
background-color: #409eff;
|
||||
}
|
||||
|
||||
&.op-initial {
|
||||
@ -316,34 +271,6 @@
|
||||
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;
|
||||
@ -352,29 +279,11 @@
|
||||
font-size: 10px;
|
||||
line-height: 16px;
|
||||
color: #fff;
|
||||
background-color: #2f54eb;
|
||||
background-color: #9069db;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
// 操作区:把「回滚 / 回到 / 查看差异」收敛为一个统一容器,默认隐藏,
|
||||
// 仅在指针悬停于所在行时显示。静止状态下每行最右侧固定为「时间」,
|
||||
// 各行因此能对齐成整齐的右侧列,避免按钮数量不一导致的参差错乱。
|
||||
.m-editor-history-list-item-actions {
|
||||
display: none;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.m-editor-history-list-group-head:hover > .m-editor-history-list-item-actions,
|
||||
.m-editor-history-list-substeps
|
||||
> li:hover
|
||||
> .m-editor-history-list-item-actions,
|
||||
.m-editor-history-list-initial:hover > .m-editor-history-list-item-actions {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.m-editor-history-list-item-diff {
|
||||
flex: 0 0 auto;
|
||||
padding: 0 6px;
|
||||
@ -391,39 +300,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 「回到」按钮:将历史游标移动到该 step。使用绿色色系,
|
||||
// 与红色「回滚」、蓝色「查看差异」区分,也避免与紧邻的灰色「来源」徽标混淆。
|
||||
.m-editor-history-list-item-goto {
|
||||
flex: 0 0 auto;
|
||||
padding: 0 6px;
|
||||
border-radius: 2px;
|
||||
font-size: 10px;
|
||||
line-height: 16px;
|
||||
color: #529b2e;
|
||||
background-color: rgba(103, 194, 58, 0.12);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(103, 194, 58, 0.24);
|
||||
}
|
||||
}
|
||||
|
||||
// 「回滚」按钮:类 git revert,把目标 step 反向应用一次作为新提交。
|
||||
// 使用红色色调,强调其为"破坏性/可逆操作",与「查看差异」「跳转」区分开。
|
||||
// 使用与「查看差异」不同的色调(橙黄),用来区分"可逆操作"与"只读对比"。
|
||||
.m-editor-history-list-item-revert {
|
||||
flex: 0 0 auto;
|
||||
padding: 0 6px;
|
||||
border-radius: 2px;
|
||||
font-size: 10px;
|
||||
line-height: 16px;
|
||||
color: #f56c6c;
|
||||
background-color: rgba(245, 108, 108, 0.12);
|
||||
color: #e6a23c;
|
||||
background-color: rgba(230, 162, 60, 0.12);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(245, 108, 108, 0.25);
|
||||
background-color: rgba(230, 162, 60, 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
@ -478,17 +369,6 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.m-editor-history-diff-dialog-notice {
|
||||
margin-bottom: 8px;
|
||||
padding: 8px 12px;
|
||||
background-color: #fdf6ec;
|
||||
border: 1px solid #faecd8;
|
||||
border-radius: 4px;
|
||||
color: #e6a23c;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.m-editor-history-diff-dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@ -102,6 +102,10 @@
|
||||
transition: all 0.2s ease 0s;
|
||||
padding: 5px 14px;
|
||||
|
||||
.tmagic-design-button {
|
||||
color: $font-color;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: $hover-color;
|
||||
}
|
||||
|
||||
@ -58,7 +58,7 @@
|
||||
position: absolute;
|
||||
right: 15px;
|
||||
bottom: 15px;
|
||||
z-index: 32;
|
||||
z-index: 30;
|
||||
opacity: 0.5;
|
||||
|
||||
&:hover {
|
||||
@ -70,7 +70,7 @@
|
||||
position: absolute;
|
||||
right: 15px;
|
||||
bottom: 60px;
|
||||
z-index: 31;
|
||||
z-index: 30;
|
||||
opacity: 0.5;
|
||||
|
||||
&:hover {
|
||||
@ -82,7 +82,7 @@
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 31;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.m-editor-resizer {
|
||||
|
||||
@ -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 { SerializedUndoRedo, UndoRedo } from './utils/undo-redo';
|
||||
import type { UndoRedo } from './utils/undo-redo';
|
||||
|
||||
export type EditorSlots = FrameworkSlots &
|
||||
WorkspaceSlots &
|
||||
@ -199,11 +199,6 @@ export interface StageOptions {
|
||||
*/
|
||||
alwaysMultiSelect?: boolean;
|
||||
disabledRule?: boolean;
|
||||
/**
|
||||
* 禁用「非点击画布选中组件时(如从图层树、面包屑等外部选中),对选中区域做高亮闪烁提示」,
|
||||
* 默认 false(即默认开启闪烁)
|
||||
*/
|
||||
disabledFlashTip?: boolean;
|
||||
zoom?: number;
|
||||
/** 画布双击前的钩子函数,返回 false 则阻止默认的双击行为 */
|
||||
beforeDblclick?: (event: MouseEvent) => Promise<boolean | void> | boolean | void;
|
||||
@ -312,8 +307,6 @@ export interface UiState {
|
||||
hideSlideBar: boolean;
|
||||
/** 侧边栏面板配置 */
|
||||
sideBarItems: SideComponent[];
|
||||
/** 当前激活的侧边栏面板 */
|
||||
sideBarActiveTabName: string;
|
||||
|
||||
// navMenu 的宽高
|
||||
navMenuRect: {
|
||||
@ -392,9 +385,6 @@ export interface MenuButton {
|
||||
items?: MenuButton[];
|
||||
/** 唯一标识,用于高亮 */
|
||||
id?: string | number;
|
||||
buttonProps?: {
|
||||
type?: string;
|
||||
};
|
||||
}
|
||||
// #endregion MenuButton
|
||||
|
||||
@ -485,63 +475,6 @@ export interface SideComponent extends MenuComponent {
|
||||
}
|
||||
// #endregion SideComponent
|
||||
|
||||
// #region HistoryListExtraTab
|
||||
/**
|
||||
* 历史记录面板(HistoryListPanel)的自定义扩展 tab。
|
||||
*
|
||||
* 业务方可通过 Editor 的 `historyListExtraTabs` 注入额外的历史记录 tab,
|
||||
* 例如某个自定义模块维护自己的操作历史时,可以在历史记录面板中增加一个
|
||||
* 独立的 tab 来展示与回滚。内置的「页面 / 数据源 / 代码块」三个 tab 之后
|
||||
* 会依次追加这些扩展 tab。
|
||||
*/
|
||||
export interface HistoryListExtraTab {
|
||||
/** tab 唯一标识,作为 TMagicTabs 的 name */
|
||||
name: string;
|
||||
/** tab 显示文案,支持传入函数以展示动态内容(如记录数量) */
|
||||
label: string | (() => string);
|
||||
/** tab 内容区渲染的组件(Vue 组件或字符串标签) */
|
||||
component: any;
|
||||
/** 传入内容组件的 props */
|
||||
props?: Record<string, any>;
|
||||
/** 内容组件的事件监听 */
|
||||
listeners?: Record<string, (..._args: any[]) => any>;
|
||||
}
|
||||
// #endregion HistoryListExtraTab
|
||||
|
||||
// #region CompareForm
|
||||
/**
|
||||
* 对比表单(CompareForm)的对比类型:
|
||||
* - node: 节点组件,按 `type` 从 propsService 获取属性表单配置
|
||||
* - data-source: 数据源,按 `type`(base/http/...) 从 dataSourceService 获取数据源表单配置
|
||||
* - code-block: 数据源代码块,使用内置的代码块表单配置
|
||||
*/
|
||||
export type CompareCategory = 'node' | 'data-source' | 'code-block' | string;
|
||||
|
||||
/**
|
||||
* 自定义 `loadConfig` 时回传的上下文,聚合了组件当前的对比入参,
|
||||
* 方便调用方在外部按需拼装 FormConfig。
|
||||
*/
|
||||
export interface CompareFormLoadConfigContext {
|
||||
/** 对比类型,见 CompareCategory */
|
||||
category: string;
|
||||
/** 节点 / 数据源类型 */
|
||||
type?: string;
|
||||
/** 数据源代码块场景下的数据源类型 */
|
||||
dataSourceType?: string;
|
||||
/**
|
||||
* 内置的默认 FormConfig 加载逻辑(按 `category` 从 propsService / dataSourceService /
|
||||
* 代码块工具取配置)。自定义 `loadConfig` 可调用它复用默认结果,再做二次加工。
|
||||
*/
|
||||
defaultLoadConfig: () => Promise<FormConfig>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 自定义 FormConfig 加载逻辑。传入后将接管内置的按 `category` 取配置逻辑,
|
||||
* 可通过 `ctx.defaultLoadConfig()` 复用默认结果再做二次加工。
|
||||
*/
|
||||
export type CompareFormLoadConfig = (ctx: CompareFormLoadConfigContext) => FormConfig | Promise<FormConfig>;
|
||||
// #endregion CompareForm
|
||||
|
||||
// #region SideItemKey
|
||||
export enum SideItemKey {
|
||||
COMPONENT_LIST = 'component-list',
|
||||
@ -685,172 +618,87 @@ export interface CodeParamStatement {
|
||||
}
|
||||
|
||||
// #region HistoryOpType
|
||||
/**
|
||||
* 历史记录操作类型:
|
||||
* - `add` / `remove` / `update`:普通可撤销/重做的节点变更;
|
||||
* - `initial`:页面「未修改的初始状态」基线(设置 root 时生成),作为页面栈 index 0 的固定底线 step。
|
||||
* 该 step 不可被撤销/回滚(cursor 不会低于它),仅用于历史面板底部的初始行展示。
|
||||
*/
|
||||
export type HistoryOpType = 'add' | 'remove' | 'update' | 'initial';
|
||||
export type HistoryOpType = 'add' | 'remove' | 'update';
|
||||
// #endregion HistoryOpType
|
||||
|
||||
// #region HistoryOpSource
|
||||
/**
|
||||
* 历史记录的「操作途径」——标记本次变更由哪条交互入口触发,仅用于历史面板展示 / 业务埋点,
|
||||
* 不影响 undo/redo 行为。缺省(未传)时 UI 视为「未知」。
|
||||
*
|
||||
* - `stage`:画布(拖拽 / 缩放 / 排序等舞台直接操作)
|
||||
* - `tree`:树形面板(图层 / 数据源 / 代码块等树形结构里的拖拽 / 菜单操作)
|
||||
* - `component-panel`:组件面板(左侧组件列表点击 / 拖拽新增组件)
|
||||
* - `props`:配置面板表单(属性表单字段编辑)
|
||||
* - `code`:源码编辑器(配置面板「源码」面板里直接编辑 JSON/代码后保存)
|
||||
* - `stage-contextmenu`:画布右键菜单(舞台上节点的右键上下文菜单)
|
||||
* - `tree-contextmenu`:树面板右键菜单(图层 / 数据源 / 代码块等树形列表上的右键上下文菜单)
|
||||
* - `toolbar`:工具栏菜单(顶部导航工具栏按钮)
|
||||
* - `shortcut`:键盘快捷键
|
||||
* - `rollback`:历史回滚(历史面板里对某条历史「回滚」,反向应用为一条新记录,类 git revert)
|
||||
* - `api`:代码 / 接口调用(程序化触发)
|
||||
* - `ai`:AI 生成 / 智能助手触发的变更
|
||||
* - `unknown`:未知来源
|
||||
*
|
||||
* 通过 `(string & {})` 允许业务侧扩展自定义途径字符串,同时保留内置值的自动补全。
|
||||
*/
|
||||
export type HistoryOpSource =
|
||||
| 'initial'
|
||||
| 'stage'
|
||||
| 'tree'
|
||||
| 'component-panel'
|
||||
| 'props'
|
||||
| 'code'
|
||||
| 'root-code'
|
||||
| 'stage-contextmenu'
|
||||
| 'tree-contextmenu'
|
||||
| 'toolbar'
|
||||
| 'shortcut'
|
||||
| 'rollback'
|
||||
| 'api'
|
||||
| 'ai'
|
||||
// 同步
|
||||
| 'sync'
|
||||
| '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;
|
||||
/** 在父节点 items 数组中的索引。仅页面节点有(数据源 / 代码块无需排序)。 */
|
||||
index?: number;
|
||||
/**
|
||||
* form 端 propPath/value 变更列表,仅 `opType` 为 `update` 时有;
|
||||
* 撤销/重做时若有则按 propPath 局部更新,缺省才退化为整内容替换。
|
||||
*/
|
||||
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;
|
||||
/**
|
||||
* 操作途径:标记本次变更由哪条交互入口触发,取值见 {@link HistoryOpSource}
|
||||
* (画布 / 树面板 / 组件面板 / 配置面板 / 源码编辑器 / 右键菜单 / 工具栏 / 快捷键 / 回滚 / 接口 等)。
|
||||
* 仅用于历史面板展示与业务埋点,不影响 undo/redo 行为;缺省时面板视为「未知」。
|
||||
*/
|
||||
source?: HistoryOpSource;
|
||||
/**
|
||||
* 入栈时间戳(毫秒)。入栈时自动写入(若调用方未指定),仅用于历史面板展示。
|
||||
*/
|
||||
timestamp?: number;
|
||||
/**
|
||||
* 是否为「已保存」记录:DSL 落库(如保存到后端 / 本地)时由 historyService.markSaved 标记。
|
||||
* 同一栈内任意时刻最多只有一条记录为 true;从 IndexedDB 恢复时游标会被定位到最近一条已保存记录之后。
|
||||
*/
|
||||
saved?: boolean;
|
||||
/**
|
||||
* 是否为「整体设置 root」(set root)产生的记录(由 {@link Editor.pushRootDiffHistory} 写入)。
|
||||
* 用于「连续 set root 合并」:当某页栈最新一条已是 root 记录时,下一条 set root 会替换它而非新增,
|
||||
* 避免源码反复保存 / 外部重设 DSL 时堆积多条 root 记录。
|
||||
*/
|
||||
rootStep?: boolean;
|
||||
}
|
||||
// #endregion BaseStepValue
|
||||
|
||||
// #region StepValue
|
||||
export interface StepValue extends BaseStepValue<MNode> {
|
||||
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 */
|
||||
parentId?: Id;
|
||||
/** opType 'add': 每个新增节点在父节点 items 中的索引 */
|
||||
indexMap?: Record<string, number>;
|
||||
/** opType 'remove': 被删除的节点及其位置信息 */
|
||||
removedItems?: { node: MNode; parentId: Id; index: number }[];
|
||||
/**
|
||||
* opType 'update': 变更前后的节点快照
|
||||
*
|
||||
* `changeRecords` 来自 form 端的 propPath/value 列表,撤销/重做时只对这些 propPath 做局部更新;
|
||||
* 缺省(未传 / 空数组)才退化为整节点替换。
|
||||
*/
|
||||
updatedItems?: { oldNode: MNode; newNode: MNode; changeRecords?: ChangeRecord[] }[];
|
||||
/**
|
||||
* 调用方可选传入的人类可读描述(如「调整按钮颜色」),用于历史面板展示。
|
||||
* 不影响 undo/redo 行为;缺省时面板会根据节点 / propPath 自动生成描述。
|
||||
*/
|
||||
historyDescription?: string;
|
||||
}
|
||||
// #endregion StepValue
|
||||
|
||||
// #region CodeBlockStepValue
|
||||
/**
|
||||
* 代码块历史记录条目。按 codeBlock.id 分组保存到 historyState.codeBlockState。
|
||||
* 变更内容统一由 `diff`(单项)表达,每项见 {@link StepDiffItem}:
|
||||
* - 新增(opType 'add'):仅 `newSchema`(新内容);
|
||||
* - 更新(opType 'update'):`oldSchema` + `newSchema`,并可带 `changeRecords` 做局部更新;
|
||||
* - 删除(opType 'remove'):仅 `oldSchema`(删除前内容)。
|
||||
* - 新增:oldContent = null,newContent = 新内容
|
||||
* - 更新:oldContent / newContent 都为对应内容
|
||||
* - 删除:newContent = null,oldContent = 删除前内容
|
||||
*/
|
||||
export interface CodeBlockStepValue extends BaseStepValue<CodeBlockContent> {
|
||||
export interface CodeBlockStepValue {
|
||||
/** 关联的代码块 id */
|
||||
id: Id;
|
||||
/** 变更前的代码块内容,新增时为 null */
|
||||
oldContent: CodeBlockContent | null;
|
||||
/** 变更后的代码块内容,删除时为 null */
|
||||
newContent: CodeBlockContent | null;
|
||||
/**
|
||||
* form 端 propPath/value 列表。撤销/重做时若有则按 propPath 局部更新;
|
||||
* 缺省才退化为整内容替换。新增/删除场景通常无 changeRecords。
|
||||
*/
|
||||
changeRecords?: ChangeRecord[];
|
||||
/** 调用方可选传入的人类可读描述,用于历史面板展示;不影响 undo/redo 行为。 */
|
||||
historyDescription?: string;
|
||||
}
|
||||
// #endregion CodeBlockStepValue
|
||||
|
||||
// #region DataSourceStepValue
|
||||
/**
|
||||
* 数据源历史记录条目。按 dataSource.id 分组保存到 historyState.dataSourceState。
|
||||
* 变更内容统一由 `diff`(单项)表达,每项见 {@link StepDiffItem}:
|
||||
* - 新增(opType 'add'):仅 `newSchema`(新 schema);
|
||||
* - 更新(opType 'update'):`oldSchema` + `newSchema`,并可带 `changeRecords` 做局部更新;
|
||||
* - 删除(opType 'remove'):仅 `oldSchema`(删除前 schema)。
|
||||
* - 新增:oldSchema = null,newSchema = 新 schema
|
||||
* - 更新:oldSchema / newSchema 都为对应 schema
|
||||
* - 删除:newSchema = null,oldSchema = 删除前 schema
|
||||
*/
|
||||
export interface DataSourceStepValue extends BaseStepValue<DataSourceSchema> {
|
||||
export interface DataSourceStepValue {
|
||||
/** 关联的数据源 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;
|
||||
}
|
||||
// #endregion DataSourceStepValue
|
||||
|
||||
@ -871,45 +719,6 @@ 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;
|
||||
/**
|
||||
* 显式指定用于库名隔离的 DSL app id。
|
||||
* 缺省时回退到当前 editorService 的 `root.id`;在「先恢复历史再 set root」场景下 root 尚未设置,
|
||||
* 需由调用方(如从待加载 DSL 取 id)显式传入,否则会读 / 写到未按 app 隔离的默认库。
|
||||
*/
|
||||
appId?: Id;
|
||||
}
|
||||
// #endregion HistoryPersistOptions
|
||||
|
||||
// #region HistoryListEntry
|
||||
/**
|
||||
* 历史面板用:当前页面的一条历史步骤(包含位置和是否已应用)。
|
||||
@ -1220,21 +1029,16 @@ 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)。
|
||||
@ -1242,9 +1046,7 @@ export interface HistoryOpOptions {
|
||||
export interface HistoryOpOptionsWithChangeRecords extends HistoryOpOptions {
|
||||
changeRecords?: ChangeRecord[];
|
||||
}
|
||||
// #endregion HistoryOpOptionsWithChangeRecords
|
||||
|
||||
// #region DslOpOptions
|
||||
/**
|
||||
* DSL 修改类操作的通用配置
|
||||
* - doNotSelect: 操作后是否不要自动触发选中(不调用 this.select / this.multiSelect / stage.select / stage.multiSelect)
|
||||
@ -1254,55 +1056,3 @@ export interface DslOpOptions extends HistoryOpOptions {
|
||||
doNotSelect?: boolean;
|
||||
doNotSwitchPage?: boolean;
|
||||
}
|
||||
// #endregion DslOpOptions
|
||||
|
||||
/** 差异对话框的入参 */
|
||||
export interface DiffDialogPayload {
|
||||
/** 表单类别 */
|
||||
category?: CompareCategory;
|
||||
/** 节点类型 / 数据源类型 */
|
||||
type?: string;
|
||||
/** 代码块场景下的数据源类型 */
|
||||
dataSourceType?: string;
|
||||
/** 该 step 修改前的值(oldNode / oldSchema / oldContent) */
|
||||
lastValue: Record<string, any>;
|
||||
/** 该 step 修改后的值(newNode / newSchema / newContent) */
|
||||
value: Record<string, any>;
|
||||
/** 当前编辑器中实际的最新值;不传或为 null 时禁用「与当前对比」 */
|
||||
currentValue?: Record<string, any> | null;
|
||||
/** 用于标题展示的目标名称 */
|
||||
targetLabel?: string;
|
||||
/** 用于标题展示的目标 id */
|
||||
id?: string | number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 一组「描述 + 可操作性」的判定函数集合。页面 / 数据源 / 代码块及业务自定义历史
|
||||
* 各自实现一份,作为整体注入,避免把 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;
|
||||
}
|
||||
|
||||
@ -5,16 +5,11 @@ import { cloneDeep, Id, MContainer, NodeType } from '@tmagic/core';
|
||||
import { calcValueByFontsize, isPage, isPageFragment } from '@tmagic/utils';
|
||||
|
||||
import ContentMenu from '@editor/components/ContentMenu.vue';
|
||||
import type { HistoryOpSource, MenuButton, Services } from '@editor/type';
|
||||
import type { MenuButton, Services } from '@editor/type';
|
||||
|
||||
import { COPY_STORAGE_KEY } from './editor';
|
||||
|
||||
/**
|
||||
* 共享的右键菜单项构造器(画布 ViewerMenu 与图层树 LayerMenu 共用)。
|
||||
* `historySource` 用于标记本次操作的途径,调用方按所在面板传入:
|
||||
* 画布传 `'stage-contextmenu'`,树形面板传 `'tree-contextmenu'`。
|
||||
*/
|
||||
export const useDeleteMenu = (historySource?: HistoryOpSource): MenuButton => ({
|
||||
export const useDeleteMenu = (): MenuButton => ({
|
||||
type: 'button',
|
||||
text: '删除',
|
||||
icon: Delete,
|
||||
@ -24,7 +19,7 @@ export const useDeleteMenu = (historySource?: HistoryOpSource): MenuButton => ({
|
||||
},
|
||||
handler: ({ editorService }) => {
|
||||
const nodes = editorService.get('nodes');
|
||||
nodes && editorService.remove(nodes, { historySource });
|
||||
nodes && editorService.remove(nodes);
|
||||
},
|
||||
});
|
||||
|
||||
@ -38,10 +33,7 @@ export const useCopyMenu = (): MenuButton => ({
|
||||
},
|
||||
});
|
||||
|
||||
export const usePasteMenu = (
|
||||
historySource?: HistoryOpSource,
|
||||
menu?: ShallowRef<InstanceType<typeof ContentMenu> | null>,
|
||||
): MenuButton => ({
|
||||
export const usePasteMenu = (menu?: ShallowRef<InstanceType<typeof ContentMenu> | null>): MenuButton => ({
|
||||
type: 'button',
|
||||
text: '粘贴',
|
||||
icon: markRaw(DocumentCopy),
|
||||
@ -60,14 +52,14 @@ export const usePasteMenu = (
|
||||
const initialTop =
|
||||
calcValueByFontsize(stage?.renderer?.getDocument(), (rect.top || 0) - (parentRect?.top || 0)) /
|
||||
uiService.get('zoom');
|
||||
editorService.paste({ left: initialLeft, top: initialTop }, undefined, { historySource });
|
||||
editorService.paste({ left: initialLeft, top: initialTop });
|
||||
} else {
|
||||
editorService.paste(undefined, undefined, { historySource });
|
||||
editorService.paste();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const moveTo = async (id: Id, { editorService }: Services, historySource?: HistoryOpSource) => {
|
||||
const moveTo = async (id: Id, { editorService }: Services) => {
|
||||
const nodes = editorService.get('nodes') || [];
|
||||
const parent = editorService.getNodeById(id) as MContainer;
|
||||
|
||||
@ -77,11 +69,10 @@ const moveTo = async (id: Id, { editorService }: Services, historySource?: Histo
|
||||
// 不要再走 remove + add 两步,否则会被切成两条历史(且语义也不正确)。
|
||||
await editorService.moveToContainer(cloneDeep(nodes), parent.id, {
|
||||
doNotSwitchPage: true,
|
||||
historySource,
|
||||
});
|
||||
};
|
||||
|
||||
export const useMoveToMenu = ({ editorService }: Services, historySource?: HistoryOpSource): MenuButton => {
|
||||
export const useMoveToMenu = ({ editorService }: Services): MenuButton => {
|
||||
const root = computed(() => editorService.get('root'));
|
||||
|
||||
return {
|
||||
@ -98,7 +89,7 @@ export const useMoveToMenu = ({ editorService }: Services, historySource?: Histo
|
||||
text: `${page.name}(${page.id})`,
|
||||
type: 'button',
|
||||
handler: (services: Services) => {
|
||||
moveTo(page.id, services, historySource);
|
||||
moveTo(page.id, services);
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
@ -35,7 +35,7 @@ import {
|
||||
isValueIncludeDataSource,
|
||||
} from '@tmagic/utils';
|
||||
|
||||
import type { EditorNodeInfo, StepValue } from '@editor/type';
|
||||
import type { EditorNodeInfo } from '@editor/type';
|
||||
import { LayerOffset, Layout } from '@editor/type';
|
||||
|
||||
export const COPY_STORAGE_KEY = '$MagicEditorCopyData';
|
||||
@ -684,43 +684,3 @@ export const classifyDragSources = (
|
||||
|
||||
return { sameParentIndices, crossParentConfigs, aborted: false };
|
||||
};
|
||||
|
||||
/**
|
||||
* 给「回滚」生成的新 step 用的简短描述生成器。
|
||||
* 与 UI 层 `describePageStep` 同义,但避免 service 反向依赖 layouts/,故放在此工具函数中。
|
||||
*/
|
||||
export 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 = items.length;
|
||||
const node = items[0]?.newSchema;
|
||||
const label = node?.name || node?.type || '';
|
||||
return `撤回新增 ${count} 个节点${count === 1 ? `(${withId(node, label)})` : ''}`;
|
||||
}
|
||||
case 'remove': {
|
||||
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: {
|
||||
if (items.length === 1) {
|
||||
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}`;
|
||||
}
|
||||
return `还原 ${items.length} 个节点的修改`;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -1,278 +0,0 @@
|
||||
/*
|
||||
* 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 { cloneDeep } from 'lodash-es';
|
||||
|
||||
import type { Id } from '@tmagic/core';
|
||||
import type { ChangeRecord } from '@tmagic/form';
|
||||
import { guid } from '@tmagic/utils';
|
||||
|
||||
import type {
|
||||
BaseStepValue,
|
||||
HistoryOpSource,
|
||||
HistoryOpType,
|
||||
PageHistoryGroup,
|
||||
PageHistoryStepEntry,
|
||||
StepDiffItem,
|
||||
StepValue,
|
||||
} from '@editor/type';
|
||||
|
||||
import { UndoRedo } from './undo-redo';
|
||||
|
||||
/**
|
||||
* 「回滚」生成的新 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}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据 old/new 是否为 null 推断 opType(与 push 时的约定一致)。
|
||||
*/
|
||||
export const detectStackOpType = (oldVal: unknown, newVal: unknown): 'add' | 'remove' | 'update' => {
|
||||
if (oldVal === null && newVal !== null) return 'add';
|
||||
if (oldVal !== null && newVal === null) return 'remove';
|
||||
return 'update';
|
||||
};
|
||||
|
||||
/**
|
||||
* 构造一条代码块 / 数据源「按 id 分栈」的历史记录:两者除 payload 字段命名外完全一致。
|
||||
*
|
||||
* - `add`:oldValue = null;`remove`:newValue = null;`update`:两者都有,可带 changeRecords 做局部更新。
|
||||
* - 内容会做 cloneDeep 防止后续被外部引用篡改;opType 依据 old/new 是否为 null 推断。
|
||||
* - 仅负责构造 step 并返回,入栈与事件 emit 由各公共方法(pushCodeBlock / pushDataSource)自行处理。
|
||||
* - 不直接驱动业务 service,调用方负责实际写回。
|
||||
*/
|
||||
export const 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;
|
||||
|
||||
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 = detectStackOpType(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;
|
||||
};
|
||||
|
||||
export const markStackSaved = <S extends { saved?: boolean }>(undoRedo?: UndoRedo<S>): void => {
|
||||
if (!undoRedo) return;
|
||||
undoRedo.updateElements((element) => {
|
||||
element.saved = false;
|
||||
});
|
||||
undoRedo.updateCurrentElement((element) => {
|
||||
element.saved = true;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 把单个「按 id 分栈」的历史栈(代码块 / 数据源)拆成若干 group:
|
||||
* 每条操作记录独立成组,不做相邻 update 合并(与页面历史的合并策略不同)。
|
||||
*
|
||||
* 代码块与数据源除 `kind` 外结构完全一致,统一由本方法处理;`kind` 决定返回的具体分组类型。
|
||||
*/
|
||||
export const mergeStackSteps = <S extends BaseStepValue, K extends 'code-block' | 'data-source'>(
|
||||
kind: K,
|
||||
id: Id,
|
||||
list: S[],
|
||||
cursor: number,
|
||||
): {
|
||||
kind: K;
|
||||
id: Id;
|
||||
opType: HistoryOpType;
|
||||
steps: { step: S; index: number; applied: boolean; isCurrent?: boolean }[];
|
||||
applied: boolean;
|
||||
isCurrent?: boolean;
|
||||
}[] => {
|
||||
const currentIndex = cursor - 1;
|
||||
return list.map((step, index) => {
|
||||
const applied = index < cursor;
|
||||
const isCurrent = index === currentIndex;
|
||||
return {
|
||||
kind,
|
||||
id,
|
||||
opType: step.opType,
|
||||
steps: [{ step, index, applied, isCurrent }],
|
||||
applied,
|
||||
isCurrent,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 把页面栈拆成若干 group:
|
||||
* - 单节点的 'update' 按 targetId 与相邻同 targetId 的 update 合并到一个 group;
|
||||
* - 'add' / 'remove' 始终独立成组(语义上是结构变更,不应被收纳进单节点修改组);
|
||||
* - 多节点 'update'(如批量改属性)也独立成组(无明确单一目标,避免误合并)。
|
||||
*/
|
||||
export const mergePageSteps = (pageId: Id, list: StepValue[], cursor: number): PageHistoryGroup[] => {
|
||||
const groups: PageHistoryGroup[] = [];
|
||||
let current: PageHistoryGroup | null = null;
|
||||
const currentIndex = cursor - 1;
|
||||
list.forEach((step, index) => {
|
||||
const applied = index < cursor;
|
||||
const isCurrent = index === currentIndex;
|
||||
const targetId = detectPageTargetId(step);
|
||||
const targetName = detectPageTargetName(step);
|
||||
const entry: PageHistoryStepEntry = { step, index, applied, isCurrent };
|
||||
|
||||
// 仅"单节点 update"参与合并;其它情形(add/remove/多节点 update)始终独立成组。
|
||||
const mergeable = step.opType === 'update' && targetId !== undefined;
|
||||
if (mergeable && current?.opType === 'update' && current.targetId === targetId) {
|
||||
current.steps.push(entry);
|
||||
current.applied = applied;
|
||||
if (isCurrent) current.isCurrent = true;
|
||||
// 保持目标名为最近一次的(节点重命名时也能反映)
|
||||
if (targetName) current.targetName = targetName;
|
||||
} else {
|
||||
current = {
|
||||
kind: 'page',
|
||||
pageId,
|
||||
opType: step.opType,
|
||||
targetId: mergeable ? targetId : undefined,
|
||||
targetName,
|
||||
steps: [entry],
|
||||
applied,
|
||||
isCurrent,
|
||||
};
|
||||
groups.push(current);
|
||||
}
|
||||
});
|
||||
return groups;
|
||||
};
|
||||
|
||||
/**
|
||||
* 解析 StepValue 中的"目标节点 id"用于合并:
|
||||
* - 单节点 update:取唯一一项 updatedItems 的节点 id;
|
||||
* - 其它情形(多节点 update / add / remove):返回 undefined,表示不参与合并。
|
||||
*/
|
||||
export const detectPageTargetId = (step: StepValue): Id | undefined => {
|
||||
if (step.opType !== 'update') return undefined;
|
||||
const items = step.diff;
|
||||
if (items?.length !== 1) return undefined;
|
||||
return items[0].newSchema?.id ?? items[0].oldSchema?.id;
|
||||
};
|
||||
|
||||
/** 解析 StepValue 中的目标节点可读名(用于 UI 展示)。 */
|
||||
export const detectPageTargetName = (step: StepValue): string | undefined => {
|
||||
const items = step.diff;
|
||||
if (step.opType === 'update') {
|
||||
if (items?.length === 1) {
|
||||
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 (items?.length === 1) {
|
||||
const n = items[0].newSchema;
|
||||
return (n?.name as string) || (n?.type as string) || `${n?.id}`;
|
||||
}
|
||||
return items?.length ? `${items.length} 个节点` : undefined;
|
||||
}
|
||||
if (step.opType === 'remove') {
|
||||
if (items?.length === 1) {
|
||||
const n = items[0].oldSchema;
|
||||
return (n?.name as string) || (n?.type as string) || `${n?.id}`;
|
||||
}
|
||||
return items?.length ? `${items.length} 个节点` : undefined;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** 把 `Record<Id, UndoRedo>` 整体序列化为 `Record<Id, SerializedUndoRedo>`。 */
|
||||
export const 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`)记录之后。
|
||||
*/
|
||||
export const 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 栈。
|
||||
*/
|
||||
export const getOrCreateStack = <T>(stacks: Record<Id, UndoRedo<T>>, id: Id): UndoRedo<T> => {
|
||||
if (!stacks[id]) {
|
||||
stacks[id] = new UndoRedo<T>();
|
||||
}
|
||||
return stacks[id];
|
||||
};
|
||||
|
||||
/**
|
||||
* 撤销下限:当页面栈 index 0 是 `opType: 'initial'` 的基线 step 时为 1(基线不可被撤销),否则为 0。
|
||||
* 用于把 cursor 钉在基线之上,保证 undo / canUndo / goto 都不会越过初始基线。
|
||||
*/
|
||||
export const undoFloor = (undoRedo: UndoRedo<StepValue>): number => {
|
||||
return undoRedo.getElementList()[0]?.opType === 'initial' ? 1 : 0;
|
||||
};
|
||||
@ -27,7 +27,5 @@ 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';
|
||||
|
||||
@ -1,122 +0,0 @@
|
||||
/*
|
||||
* 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();
|
||||
}
|
||||
};
|
||||
@ -18,59 +18,8 @@
|
||||
|
||||
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;
|
||||
@ -82,18 +31,6 @@ 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));
|
||||
@ -139,31 +76,6 @@ export class UndoRedo<T = any> {
|
||||
return cloneDeep(this.elementList[this.listCursor - 1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用 `element` 替换当前游标所在元素(cursor - 1),并丢弃其后的重做尾部(与 {@link pushElement} 的丢尾一致),
|
||||
* 游标位置保持不变(元素数量不增)。cursor 为 0(无已应用元素)时不做任何操作并返回 false。
|
||||
* 用于「连续同类记录合并」等就地替换最新一条的场景。
|
||||
*/
|
||||
public replaceCurrentElement(element: T): boolean {
|
||||
if (this.listCursor < 1) return false;
|
||||
this.elementList.splice(this.listCursor - 1, this.elementList.length - (this.listCursor - 1), cloneDeep(element));
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 对当前游标所在元素(cursor - 1)做就地更新;cursor 为 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 为最早一步)。
|
||||
* 仅用于历史面板等只读展示场景,不应直接修改返回值。
|
||||
|
||||
@ -1,230 +0,0 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h, nextTick, ref } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import { HookType } from '@tmagic/core';
|
||||
|
||||
import CompareForm from '@editor/components/CompareForm.vue';
|
||||
|
||||
const propsService = {
|
||||
getPropsConfig: vi.fn(async () => [
|
||||
{
|
||||
type: 'tab',
|
||||
items: [{ title: '样式', items: [{ type: 'text', name: 'color', display: false }] }],
|
||||
},
|
||||
{ type: 'text', name: 'name' },
|
||||
]),
|
||||
};
|
||||
const dataSourceService = {
|
||||
getFormConfig: vi.fn(() => [{ type: 'text', name: 'title' }]),
|
||||
};
|
||||
const codeBlockService = {
|
||||
getParamsColConfig: vi.fn(() => null),
|
||||
};
|
||||
const editorService = {
|
||||
get: vi.fn(() => ({ select: vi.fn() })),
|
||||
};
|
||||
|
||||
let capturedShowDiff: ((args: any) => boolean) | undefined;
|
||||
let capturedFormProps: Record<string, any> = {};
|
||||
|
||||
vi.mock('@editor/hooks/use-services', () => ({
|
||||
useServices: () => ({
|
||||
propsService,
|
||||
dataSourceService,
|
||||
codeBlockService,
|
||||
editorService,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/utils/code-block', () => ({
|
||||
getCodeBlockFormConfig: vi.fn(() => [{ type: 'text', name: 'content' }]),
|
||||
}));
|
||||
|
||||
vi.mock('@tmagic/form', () => ({
|
||||
MForm: defineComponent({
|
||||
name: 'MForm',
|
||||
props: ['config', 'initValues', 'lastValues', 'isCompare', 'disabled', 'labelWidth', 'extendState', 'showDiff'],
|
||||
setup(props, { expose }) {
|
||||
capturedShowDiff = props.showDiff as (args: any) => boolean;
|
||||
capturedFormProps = props as Record<string, any>;
|
||||
expose({ formState: {} });
|
||||
return () => h('div', { class: 'fake-mform' });
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
capturedShowDiff = undefined;
|
||||
capturedFormProps = {};
|
||||
});
|
||||
|
||||
describe('CompareForm.vue', () => {
|
||||
test('node 类别按 type 加载 props 配置并展示 MForm', async () => {
|
||||
const wrapper = mount(CompareForm, {
|
||||
props: {
|
||||
category: 'node',
|
||||
type: 'text',
|
||||
value: { id: 'n1', name: 'new' },
|
||||
lastValue: { id: 'n1', name: 'old' },
|
||||
},
|
||||
global: {
|
||||
provide: {
|
||||
codeOptions: { theme: 'vs-dark' },
|
||||
},
|
||||
},
|
||||
});
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
expect(propsService.getPropsConfig).toHaveBeenCalledWith('text', { node: { id: 'n1', name: 'new' } });
|
||||
expect(wrapper.find('.fake-mform').exists()).toBe(true);
|
||||
expect(capturedFormProps.initValues).toEqual({ id: 'n1', name: 'new' });
|
||||
expect(capturedFormProps.lastValues).toEqual({ id: 'n1', name: 'old' });
|
||||
});
|
||||
|
||||
test('node 类别缺少 type 时不渲染 MForm', async () => {
|
||||
const wrapper = mount(CompareForm, {
|
||||
props: {
|
||||
category: 'node',
|
||||
value: { id: 'n1' },
|
||||
},
|
||||
});
|
||||
await nextTick();
|
||||
expect(wrapper.find('.fake-mform').exists()).toBe(false);
|
||||
});
|
||||
|
||||
test('data-source 类别加载数据源表单配置', async () => {
|
||||
mount(CompareForm, {
|
||||
props: {
|
||||
category: 'data-source',
|
||||
type: 'http',
|
||||
value: { id: 'ds_1', title: 'A' },
|
||||
lastValue: { id: 'ds_1', title: 'B' },
|
||||
},
|
||||
});
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
expect(dataSourceService.getFormConfig).toHaveBeenCalledWith('http');
|
||||
});
|
||||
|
||||
test('code-block 类别会把 content 非字符串值 normalize 成字符串', async () => {
|
||||
mount(CompareForm, {
|
||||
props: {
|
||||
category: 'code-block',
|
||||
value: { id: 'cb_1', content: { toString: () => 'fn-body' } },
|
||||
lastValue: { id: 'cb_1', content: '' },
|
||||
},
|
||||
});
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
expect(capturedFormProps.initValues.content).toBe('fn-body');
|
||||
expect(capturedFormProps.lastValues.content).toBe('');
|
||||
});
|
||||
|
||||
test('传入 height 时外层容器启用内部滚动样式', () => {
|
||||
const wrapper = mount(CompareForm, {
|
||||
props: {
|
||||
category: 'node',
|
||||
type: 'text',
|
||||
value: { id: 'n1' },
|
||||
height: '400px',
|
||||
},
|
||||
});
|
||||
const style = wrapper.find('.m-editor-compare-form-wrapper').attributes('style') || '';
|
||||
expect(style).toContain('height: 400px');
|
||||
expect(style).toContain('overflow: auto');
|
||||
});
|
||||
|
||||
test('自定义 loadConfig 可接管配置加载', async () => {
|
||||
const loadConfig = vi.fn(async ({ defaultLoadConfig }) => {
|
||||
await defaultLoadConfig();
|
||||
return [{ type: 'text', name: 'custom' }];
|
||||
});
|
||||
const wrapper = mount(CompareForm, {
|
||||
props: {
|
||||
category: 'node',
|
||||
type: 'text',
|
||||
value: { id: 'n1' },
|
||||
loadConfig,
|
||||
},
|
||||
});
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
expect(loadConfig).toHaveBeenCalled();
|
||||
expect((wrapper.vm as any).config).toEqual([{ type: 'text', name: 'custom' }]);
|
||||
});
|
||||
|
||||
test('showDiff 对 code-select 的空形态视为相等', async () => {
|
||||
mount(CompareForm, {
|
||||
props: {
|
||||
category: 'node',
|
||||
type: 'text',
|
||||
value: { id: 'n1' },
|
||||
},
|
||||
});
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
expect(capturedShowDiff).toBeTypeOf('function');
|
||||
expect(
|
||||
capturedShowDiff!({
|
||||
curValue: '',
|
||||
lastValue: { hookType: HookType.CODE, hookData: [] },
|
||||
config: { type: 'code-select' },
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
capturedShowDiff!({
|
||||
curValue: { hookType: HookType.CODE, hookData: [] },
|
||||
lastValue: '',
|
||||
config: { type: 'code-select' },
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
capturedShowDiff!({
|
||||
curValue: 'a',
|
||||
lastValue: 'b',
|
||||
config: { type: 'code-select' },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('reload 暴露方法会重新加载配置', async () => {
|
||||
const wrapper = mount(CompareForm, {
|
||||
props: {
|
||||
category: 'data-source',
|
||||
type: 'base',
|
||||
value: { id: 'ds_1' },
|
||||
},
|
||||
});
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
dataSourceService.getFormConfig.mockClear();
|
||||
await (wrapper.vm as any).reload();
|
||||
expect(dataSourceService.getFormConfig).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('watchEffect 会把 stage / services 写入 MForm.formState', async () => {
|
||||
const stage = ref({ select: vi.fn() });
|
||||
editorService.get.mockReturnValue(stage.value);
|
||||
mount(CompareForm, {
|
||||
props: {
|
||||
category: 'node',
|
||||
type: 'text',
|
||||
value: { id: 'n1' },
|
||||
},
|
||||
});
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
stage.value = { select: vi.fn() };
|
||||
await nextTick();
|
||||
expect(editorService.get).toHaveBeenCalledWith('stage');
|
||||
});
|
||||
});
|
||||
@ -104,7 +104,7 @@ describe('useCodeBlockEdit', () => {
|
||||
const deleteCodeDslByIds = vi.fn();
|
||||
const hook = mountHook({ deleteCodeDslByIds });
|
||||
await hook.deleteCode('k');
|
||||
expect(deleteCodeDslByIds).toHaveBeenCalledWith(['k'], { historySource: undefined });
|
||||
expect(deleteCodeDslByIds).toHaveBeenCalledWith(['k']);
|
||||
});
|
||||
|
||||
test('submitCodeBlockHandler - 没有 codeId 时跳过', async () => {
|
||||
@ -119,14 +119,7 @@ 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,
|
||||
historySource: 'props',
|
||||
},
|
||||
);
|
||||
expect(setCodeDslById).toHaveBeenCalledWith('id1', { name: 'b' }, { changeRecords: undefined });
|
||||
expect(hideMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@ -136,13 +129,6 @@ 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,
|
||||
historySource: 'props',
|
||||
},
|
||||
);
|
||||
expect(setCodeDslById).toHaveBeenCalledWith('id1', { name: 'b' }, { changeRecords: records });
|
||||
});
|
||||
});
|
||||
|
||||
@ -116,18 +116,6 @@ describe('useStage', () => {
|
||||
expect(stageInstance.mask.setGuides).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('disabledFlashTip 透传给 StageCore', () => {
|
||||
useStage({ disabledFlashTip: true } as any);
|
||||
const opts = StageCoreCtor.mock.calls[0][0];
|
||||
expect(opts.disabledFlashTip).toBe(true);
|
||||
});
|
||||
|
||||
test('默认不开启 disabledFlashTip(透传 undefined)', () => {
|
||||
useStage({} as any);
|
||||
const opts = StageCoreCtor.mock.calls[0][0];
|
||||
expect(opts.disabledFlashTip).toBeUndefined();
|
||||
});
|
||||
|
||||
test('canSelect: 无 stageOptions.canSelect 时返回 true', () => {
|
||||
useStage({} as any);
|
||||
const opts = StageCoreCtor.mock.calls[0][0];
|
||||
@ -235,7 +223,7 @@ describe('useStage', () => {
|
||||
test('sort 事件', () => {
|
||||
useStage({} as any);
|
||||
stageInstance.handlers.sort[0]({ src: 'a', dist: 'b' });
|
||||
expect(editorService.sort).toHaveBeenCalledWith('a', 'b', { historySource: 'stage' });
|
||||
expect(editorService.sort).toHaveBeenCalledWith('a', 'b');
|
||||
});
|
||||
|
||||
test('remove 事件', () => {
|
||||
|
||||
@ -179,7 +179,7 @@ describe('initServiceState', () => {
|
||||
test('modelValue 变化设置 editor root', () => {
|
||||
const props = { modelValue: { id: 'a' } } as any;
|
||||
mount(Wrap(props, services));
|
||||
expect(services.editorService.set).toHaveBeenCalledWith('root', { id: 'a' }, { historySource: 'initial' });
|
||||
expect(services.editorService.set).toHaveBeenCalledWith('root', { id: 'a' });
|
||||
});
|
||||
|
||||
test('disabledMultiSelect/alwaysMultiSelect 设置', () => {
|
||||
|
||||
@ -145,7 +145,7 @@ describe('Framework', () => {
|
||||
});
|
||||
const wrapper = mount(Framework, { props: { disabledPageFragment: false } as any });
|
||||
await wrapper.find('.fake-code-editor').trigger('click');
|
||||
expect(editorService.set).toHaveBeenCalledWith('root', { id: 'x' }, { historySource: 'root-code' });
|
||||
expect(editorService.set).toHaveBeenCalledWith('root', { id: 'x' });
|
||||
});
|
||||
|
||||
test('SplitView change 写入 uiService 和 storage', async () => {
|
||||
|
||||
@ -7,9 +7,8 @@ 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/type';
|
||||
|
||||
const buildGroup = (opType: 'add' | 'remove' | 'update', stepCount: number, applied = true): any => ({
|
||||
const buildGroup = (opType: 'add' | 'remove' | 'update', stepCount: number, applied = true) => ({
|
||||
applied,
|
||||
opType,
|
||||
steps: Array.from({ length: stepCount }, (_, i) => ({
|
||||
@ -19,22 +18,16 @@ 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: {
|
||||
config: buildConfig(),
|
||||
title: '数据源',
|
||||
bucketId: 'ds_1',
|
||||
prefix: 'ds',
|
||||
groups: [buildGroup('update', 1), buildGroup('add', 1)],
|
||||
describeGroup: () => 'desc',
|
||||
describeStep: () => 'sub-desc',
|
||||
expanded: {},
|
||||
},
|
||||
});
|
||||
@ -51,9 +44,12 @@ describe('Bucket.vue', () => {
|
||||
|
||||
const wrapper = mount(Bucket, {
|
||||
props: {
|
||||
config: buildConfig({ title: '代码块', prefix: 'cb', describeGroup, describeStep }),
|
||||
title: '代码块',
|
||||
bucketId: 'code_1',
|
||||
prefix: 'cb',
|
||||
groups,
|
||||
describeGroup,
|
||||
describeStep,
|
||||
expanded: { 'cb-code_1-0': true },
|
||||
},
|
||||
});
|
||||
@ -77,9 +73,12 @@ describe('Bucket.vue', () => {
|
||||
test('合并组头部点击 → toggle 事件被透传到 Bucket', async () => {
|
||||
const wrapper = mount(Bucket, {
|
||||
props: {
|
||||
config: buildConfig({ title: '代码块', prefix: 'cb', describeGroup: () => 'g', describeStep: () => 's' }),
|
||||
title: '代码块',
|
||||
bucketId: 'code_1',
|
||||
prefix: 'cb',
|
||||
groups: [buildGroup('update', 2)],
|
||||
describeGroup: () => 'g',
|
||||
describeStep: () => 's',
|
||||
expanded: {},
|
||||
},
|
||||
});
|
||||
@ -91,34 +90,40 @@ describe('Bucket.vue', () => {
|
||||
expect(wrapper.emitted('goto')).toBeFalsy();
|
||||
});
|
||||
|
||||
test('单步组「回到」按钮点击 → goto 事件被透传到 Bucket,并附带 bucketId', async () => {
|
||||
test('单步组头部点击 → goto 事件被透传到 Bucket,并附带 bucketId', async () => {
|
||||
const wrapper = mount(Bucket, {
|
||||
props: {
|
||||
config: buildConfig({ title: '代码块', prefix: 'cb', describeGroup: () => 'g', describeStep: () => 's' }),
|
||||
title: '代码块',
|
||||
bucketId: 'code_1',
|
||||
prefix: 'cb',
|
||||
groups: [buildGroup('update', 1)],
|
||||
describeGroup: () => 'g',
|
||||
describeStep: () => 's',
|
||||
expanded: {},
|
||||
},
|
||||
});
|
||||
await wrapper.find('.m-editor-history-list-item-goto').trigger('click');
|
||||
await wrapper.find('.m-editor-history-list-group-head').trigger('click');
|
||||
const events = wrapper.emitted('goto');
|
||||
expect(events).toBeTruthy();
|
||||
expect(events![0]).toEqual(['code_1', 0]);
|
||||
});
|
||||
|
||||
test('合并组展开后点击子步「回到」按钮 → goto 透传,附带子步 index', async () => {
|
||||
test('合并组展开后点击子步 → goto 透传,附带子步 index', async () => {
|
||||
const wrapper = mount(Bucket, {
|
||||
props: {
|
||||
config: buildConfig({ title: '代码块', prefix: 'cb', describeGroup: () => 'g', describeStep: () => 's' }),
|
||||
title: '代码块',
|
||||
bucketId: 'code_1',
|
||||
prefix: 'cb',
|
||||
groups: [buildGroup('update', 2)],
|
||||
describeGroup: () => 'g',
|
||||
describeStep: () => 's',
|
||||
expanded: { 'cb-code_1-0': true },
|
||||
},
|
||||
});
|
||||
const subItems = wrapper.findAll('.m-editor-history-list-substeps li');
|
||||
expect(subItems).toHaveLength(2);
|
||||
// 子步倒序渲染:subItems[0] 对应 index=1
|
||||
await subItems[0].find('.m-editor-history-list-item-goto').trigger('click');
|
||||
await subItems[0].trigger('click');
|
||||
const events = wrapper.emitted('goto');
|
||||
expect(events).toBeTruthy();
|
||||
expect(events![0]).toEqual(['code_1', 1]);
|
||||
@ -127,9 +132,12 @@ describe('Bucket.vue', () => {
|
||||
test('groupKey 命名空间使用 prefix + bucketId + 索引', () => {
|
||||
const wrapper = mount(Bucket, {
|
||||
props: {
|
||||
config: buildConfig({ describeGroup: () => 'g', describeStep: () => 's' }),
|
||||
title: '数据源',
|
||||
bucketId: 42,
|
||||
prefix: 'ds',
|
||||
groups: [buildGroup('update', 2), buildGroup('add', 1)],
|
||||
describeGroup: () => 'g',
|
||||
describeStep: () => 's',
|
||||
// 给第二组打开展开状态
|
||||
expanded: { 'ds-42-1': true },
|
||||
},
|
||||
@ -137,16 +145,19 @@ describe('Bucket.vue', () => {
|
||||
// 第二组只有 1 步,不应渲染 substeps(即使 expanded 为 true)
|
||||
const rows = wrapper.findAll('.m-editor-history-list-group');
|
||||
expect(rows[1].find('.m-editor-history-list-substeps').exists()).toBe(false);
|
||||
// 第一组为合并组,默认展开
|
||||
expect(rows[0].find('.m-editor-history-list-substeps').exists()).toBe(true);
|
||||
// 第一组未展开,也不应有 substeps
|
||||
expect(rows[0].find('.m-editor-history-list-substeps').exists()).toBe(false);
|
||||
});
|
||||
|
||||
test('groups 非空时底部追加初始项;点击透传 goto-initial 携带 bucketId', async () => {
|
||||
const wrapper = mount(Bucket, {
|
||||
props: {
|
||||
config: buildConfig({ describeGroup: () => 'g', describeStep: () => 's' }),
|
||||
title: '数据源',
|
||||
bucketId: 'ds_1',
|
||||
prefix: 'ds',
|
||||
groups: [buildGroup('add', 1)],
|
||||
describeGroup: () => 'g',
|
||||
describeStep: () => 's',
|
||||
expanded: {},
|
||||
},
|
||||
});
|
||||
@ -155,7 +166,7 @@ describe('Bucket.vue', () => {
|
||||
// 已有 applied 组,初始项不应为当前
|
||||
expect(initial.classes()).not.toContain('is-current');
|
||||
|
||||
await initial.find('.m-editor-history-list-item-goto').trigger('click');
|
||||
await initial.trigger('click');
|
||||
const events = wrapper.emitted('goto-initial');
|
||||
expect(events).toBeTruthy();
|
||||
expect(events![0]).toEqual(['ds_1']);
|
||||
@ -164,9 +175,12 @@ describe('Bucket.vue', () => {
|
||||
test('该 bucket 全部组都已撤销时初始项标记为当前', () => {
|
||||
const wrapper = mount(Bucket, {
|
||||
props: {
|
||||
config: buildConfig({ title: '代码块', prefix: 'cb', describeGroup: () => 'g', describeStep: () => 's' }),
|
||||
title: '代码块',
|
||||
bucketId: 'cb_1',
|
||||
prefix: 'cb',
|
||||
groups: [buildGroup('add', 1, false), buildGroup('update', 2, false)],
|
||||
describeGroup: () => 'g',
|
||||
describeStep: () => 's',
|
||||
expanded: {},
|
||||
},
|
||||
});
|
||||
|
||||
@ -1,72 +0,0 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import BucketTab from '@editor/layouts/history-list/BucketTab.vue';
|
||||
import type { HistoryBucketConfig } from '@editor/type';
|
||||
|
||||
const buildConfig = (): HistoryBucketConfig<any> => ({
|
||||
title: '数据源',
|
||||
prefix: 'ds',
|
||||
describeGroup: () => 'desc',
|
||||
describeStep: () => 'sub-desc',
|
||||
});
|
||||
|
||||
const buildGroup = () => ({
|
||||
applied: true,
|
||||
opType: 'update' as const,
|
||||
steps: [{ index: 0, applied: true, step: { mark: 's-0' } }],
|
||||
});
|
||||
|
||||
describe('BucketTab.vue', () => {
|
||||
test('buckets 为空时显示空态', () => {
|
||||
const wrapper = mount(BucketTab, {
|
||||
props: {
|
||||
config: buildConfig(),
|
||||
buckets: [],
|
||||
expanded: {},
|
||||
},
|
||||
});
|
||||
expect(wrapper.find('.m-editor-history-list-empty').text()).toBe('暂无操作记录');
|
||||
});
|
||||
|
||||
test('buckets 非空时渲染 toolbar 与 Bucket 列表', () => {
|
||||
const wrapper = mount(BucketTab, {
|
||||
props: {
|
||||
config: buildConfig(),
|
||||
buckets: [{ id: 'ds_1', groups: [buildGroup()] }],
|
||||
expanded: {},
|
||||
},
|
||||
});
|
||||
expect(wrapper.find('.m-editor-history-list-toolbar').exists()).toBe(true);
|
||||
expect(wrapper.find('.m-editor-history-list-bucket-title').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test('点击清空按钮触发 clear 事件', async () => {
|
||||
const wrapper = mount(BucketTab, {
|
||||
props: {
|
||||
config: buildConfig(),
|
||||
buckets: [{ id: 'ds_1', groups: [buildGroup()] }],
|
||||
expanded: {},
|
||||
},
|
||||
});
|
||||
await wrapper.find('.m-editor-history-list-clear').trigger('click');
|
||||
expect(wrapper.emitted('clear')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('透传 Bucket 子组件事件', async () => {
|
||||
const wrapper = mount(BucketTab, {
|
||||
props: {
|
||||
config: buildConfig(),
|
||||
buckets: [{ id: 'ds_1', groups: [buildGroup()] }],
|
||||
expanded: {},
|
||||
},
|
||||
});
|
||||
await wrapper.find('.m-editor-history-list-item-goto').trigger('click');
|
||||
expect(wrapper.emitted('goto')?.[0]).toEqual(['ds_1', 0]);
|
||||
});
|
||||
});
|
||||
@ -7,9 +7,8 @@ import { describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import BucketTab from '@editor/layouts/history-list/BucketTab.vue';
|
||||
import { describeCodeBlockGroup, describeCodeBlockStep } from '@editor/layouts/history-list/composables';
|
||||
import type { CodeBlockHistoryGroup, CodeBlockStepValue } from '@editor/type';
|
||||
import CodeBlockTab from '@editor/layouts/history-list/CodeBlockTab.vue';
|
||||
import type { CodeBlockHistoryGroup } from '@editor/type';
|
||||
|
||||
vi.mock('@tmagic/design', () => ({
|
||||
TMagicScrollbar: defineComponent({
|
||||
@ -21,51 +20,22 @@ 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 && s.newContent !== undefined ? { newSchema: s.newContent } : {}),
|
||||
...(s.oldContent !== null && s.oldContent !== undefined ? { oldSchema: s.oldContent } : {}),
|
||||
...(s.changeRecords ? { changeRecords: s.changeRecords } : {}),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const buildGroup = (
|
||||
id: string,
|
||||
opType: 'add' | 'remove' | 'update',
|
||||
steps: any[],
|
||||
applied = true,
|
||||
startIndex = 0,
|
||||
): CodeBlockHistoryGroup => ({
|
||||
kind: 'code-block',
|
||||
id,
|
||||
opType,
|
||||
applied,
|
||||
steps: steps.map((s, i) => ({ step: toDiffStep(s, opType) as any, index: startIndex + i, applied })),
|
||||
steps: steps.map((s, i) => ({ step: s, index: i, applied })),
|
||||
});
|
||||
|
||||
/** 代码块 tab 复用通用 BucketTab,固定注入代码块的 config(title/prefix/describe/isStepDiffable)。 */
|
||||
const mountCodeBlockTab = (props: { buckets: any[]; expanded: Record<string, boolean> }) =>
|
||||
mount(BucketTab, {
|
||||
props: {
|
||||
config: {
|
||||
title: '代码块',
|
||||
prefix: 'cb',
|
||||
describeGroup: describeCodeBlockGroup,
|
||||
describeStep: describeCodeBlockStep,
|
||||
isStepDiffable: (step: CodeBlockStepValue) => Boolean(step.diff?.[0]?.oldSchema && step.diff?.[0]?.newSchema),
|
||||
},
|
||||
...props,
|
||||
},
|
||||
});
|
||||
|
||||
describe('CodeBlockTab.vue', () => {
|
||||
test('buckets 为空时显示空态', () => {
|
||||
const wrapper = mountCodeBlockTab({ buckets: [], expanded: {} });
|
||||
const wrapper = mount(CodeBlockTab, { props: { buckets: [], expanded: {} } });
|
||||
expect(wrapper.find('.m-editor-history-list-empty').exists()).toBe(true);
|
||||
});
|
||||
|
||||
@ -78,7 +48,7 @@ describe('CodeBlockTab.vue', () => {
|
||||
],
|
||||
},
|
||||
];
|
||||
const wrapper = mountCodeBlockTab({ buckets, expanded: {} });
|
||||
const wrapper = mount(CodeBlockTab, { props: { buckets, expanded: {} } });
|
||||
expect(wrapper.find('.m-editor-history-list-bucket-title').text()).toContain('代码块');
|
||||
expect(wrapper.find('.m-editor-history-list-bucket-title code').text()).toBe('code_1');
|
||||
|
||||
@ -105,35 +75,29 @@ describe('CodeBlockTab.vue', () => {
|
||||
changeRecords: [{ propPath: 'b' }],
|
||||
},
|
||||
]),
|
||||
buildGroup(
|
||||
'code_1',
|
||||
'update',
|
||||
[
|
||||
{
|
||||
id: 'code_1',
|
||||
oldContent: { id: 'code_1', name: 'fn' },
|
||||
newContent: { id: 'code_1', name: 'fn' },
|
||||
changeRecords: [{ propPath: 'c' }],
|
||||
},
|
||||
{
|
||||
id: 'code_1',
|
||||
oldContent: { id: 'code_1', name: 'fn' },
|
||||
newContent: { id: 'code_1', name: 'fn' },
|
||||
changeRecords: [{ propPath: 'd' }],
|
||||
},
|
||||
],
|
||||
true,
|
||||
2,
|
||||
),
|
||||
buildGroup('code_1', 'update', [
|
||||
{
|
||||
id: 'code_1',
|
||||
oldContent: { id: 'code_1', name: 'fn' },
|
||||
newContent: { id: 'code_1', name: 'fn' },
|
||||
changeRecords: [{ propPath: 'c' }],
|
||||
},
|
||||
{
|
||||
id: 'code_1',
|
||||
oldContent: { id: 'code_1', name: 'fn' },
|
||||
newContent: { id: 'code_1', name: 'fn' },
|
||||
changeRecords: [{ propPath: 'd' }],
|
||||
},
|
||||
]),
|
||||
],
|
||||
},
|
||||
];
|
||||
const wrapper = mountCodeBlockTab({ buckets, expanded: {} });
|
||||
const wrapper = mount(CodeBlockTab, { props: { buckets, expanded: {} } });
|
||||
const heads = wrapper.findAll('.m-editor-history-list-group-head');
|
||||
await heads[0].trigger('click');
|
||||
expect(wrapper.emitted('toggle')![0]).toEqual(['cb-code_1-0']);
|
||||
await heads[1].trigger('click');
|
||||
expect(wrapper.emitted('toggle')![1]).toEqual(['cb-code_1-2']);
|
||||
expect(wrapper.emitted('toggle')![1]).toEqual(['cb-code_1-1']);
|
||||
});
|
||||
|
||||
test('goto 透传:携带 codeBlock id 与最后一步 index', async () => {
|
||||
@ -145,8 +109,8 @@ describe('CodeBlockTab.vue', () => {
|
||||
],
|
||||
},
|
||||
];
|
||||
const wrapper = mountCodeBlockTab({ buckets, expanded: {} });
|
||||
await wrapper.find('.m-editor-history-list-item-goto').trigger('click');
|
||||
const wrapper = mount(CodeBlockTab, { props: { buckets, expanded: {} } });
|
||||
await wrapper.find('.m-editor-history-list-group-head').trigger('click');
|
||||
const events = wrapper.emitted('goto');
|
||||
expect(events).toBeTruthy();
|
||||
expect(events![0]).toEqual(['code_1', 0]);
|
||||
@ -174,7 +138,9 @@ describe('CodeBlockTab.vue', () => {
|
||||
],
|
||||
},
|
||||
];
|
||||
const wrapper = mountCodeBlockTab({ buckets, expanded: { 'cb-code_1-0': true } });
|
||||
const wrapper = mount(CodeBlockTab, {
|
||||
props: { buckets, expanded: { 'cb-code_1-0': true } },
|
||||
});
|
||||
const items = wrapper.findAll('.m-editor-history-list-substeps li');
|
||||
expect(items).toHaveLength(2);
|
||||
// 子步倒序渲染(最新在上):params 在前,content 在后
|
||||
|
||||
@ -7,9 +7,8 @@ import { describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import BucketTab from '@editor/layouts/history-list/BucketTab.vue';
|
||||
import { describeDataSourceGroup, describeDataSourceStep } from '@editor/layouts/history-list/composables';
|
||||
import type { DataSourceHistoryGroup, DataSourceStepValue } from '@editor/type';
|
||||
import DataSourceTab from '@editor/layouts/history-list/DataSourceTab.vue';
|
||||
import type { DataSourceHistoryGroup } from '@editor/type';
|
||||
|
||||
vi.mock('@tmagic/design', () => ({
|
||||
TMagicScrollbar: defineComponent({
|
||||
@ -21,51 +20,22 @@ 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 && s.newSchema !== undefined ? { newSchema: s.newSchema } : {}),
|
||||
...(s.oldSchema !== null && s.oldSchema !== undefined ? { oldSchema: s.oldSchema } : {}),
|
||||
...(s.changeRecords ? { changeRecords: s.changeRecords } : {}),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const buildGroup = (
|
||||
id: string,
|
||||
opType: 'add' | 'remove' | 'update',
|
||||
steps: any[],
|
||||
applied = true,
|
||||
startIndex = 0,
|
||||
): DataSourceHistoryGroup => ({
|
||||
kind: 'data-source',
|
||||
id,
|
||||
opType,
|
||||
applied,
|
||||
steps: steps.map((s, i) => ({ step: toDiffStep(s, opType) as any, index: startIndex + i, applied })),
|
||||
steps: steps.map((s, i) => ({ step: s, index: i, applied })),
|
||||
});
|
||||
|
||||
/** 数据源 tab 复用通用 BucketTab,固定注入数据源的 config(title/prefix/describe/isStepDiffable)。 */
|
||||
const mountDataSourceTab = (props: { buckets: any[]; expanded: Record<string, boolean> }) =>
|
||||
mount(BucketTab, {
|
||||
props: {
|
||||
config: {
|
||||
title: '数据源',
|
||||
prefix: 'ds',
|
||||
describeGroup: describeDataSourceGroup,
|
||||
describeStep: describeDataSourceStep,
|
||||
isStepDiffable: (step: DataSourceStepValue) => Boolean(step.diff?.[0]?.oldSchema && step.diff?.[0]?.newSchema),
|
||||
},
|
||||
...props,
|
||||
},
|
||||
});
|
||||
|
||||
describe('DataSourceTab.vue', () => {
|
||||
test('buckets 为空时显示空态', () => {
|
||||
const wrapper = mountDataSourceTab({ buckets: [], expanded: {} });
|
||||
const wrapper = mount(DataSourceTab, { props: { buckets: [], expanded: {} } });
|
||||
expect(wrapper.find('.m-editor-history-list-empty').exists()).toBe(true);
|
||||
});
|
||||
|
||||
@ -82,7 +52,7 @@ describe('DataSourceTab.vue', () => {
|
||||
],
|
||||
},
|
||||
];
|
||||
const wrapper = mountDataSourceTab({ buckets, expanded: {} });
|
||||
const wrapper = mount(DataSourceTab, { props: { buckets, expanded: {} } });
|
||||
const titles = wrapper.findAll('.m-editor-history-list-bucket-title');
|
||||
expect(titles).toHaveLength(2);
|
||||
expect(titles[0].text()).toContain('数据源');
|
||||
@ -116,33 +86,27 @@ describe('DataSourceTab.vue', () => {
|
||||
changeRecords: [{ propPath: 'b' }],
|
||||
},
|
||||
]),
|
||||
buildGroup(
|
||||
'ds_1',
|
||||
'update',
|
||||
[
|
||||
{
|
||||
id: 'ds_1',
|
||||
oldSchema: { id: 'ds_1', title: 'A' },
|
||||
newSchema: { id: 'ds_1', title: 'A2' },
|
||||
changeRecords: [{ propPath: 'c' }],
|
||||
},
|
||||
{
|
||||
id: 'ds_1',
|
||||
oldSchema: { id: 'ds_1', title: 'A2' },
|
||||
newSchema: { id: 'ds_1', title: 'A3' },
|
||||
changeRecords: [{ propPath: 'd' }],
|
||||
},
|
||||
],
|
||||
true,
|
||||
2,
|
||||
),
|
||||
buildGroup('ds_1', 'update', [
|
||||
{
|
||||
id: 'ds_1',
|
||||
oldSchema: { id: 'ds_1', title: 'A' },
|
||||
newSchema: { id: 'ds_1', title: 'A2' },
|
||||
changeRecords: [{ propPath: 'c' }],
|
||||
},
|
||||
{
|
||||
id: 'ds_1',
|
||||
oldSchema: { id: 'ds_1', title: 'A2' },
|
||||
newSchema: { id: 'ds_1', title: 'A3' },
|
||||
changeRecords: [{ propPath: 'd' }],
|
||||
},
|
||||
]),
|
||||
],
|
||||
},
|
||||
];
|
||||
const wrapper = mountDataSourceTab({ buckets, expanded: {} });
|
||||
const wrapper = mount(DataSourceTab, { props: { buckets, expanded: {} } });
|
||||
const heads = wrapper.findAll('.m-editor-history-list-group-head');
|
||||
await heads[1].trigger('click');
|
||||
expect(wrapper.emitted('toggle')![0]).toEqual(['ds-ds_1-2']);
|
||||
expect(wrapper.emitted('toggle')![0]).toEqual(['ds-ds_1-1']);
|
||||
});
|
||||
|
||||
test('goto 透传:携带 dataSource id 与最后一步 index', async () => {
|
||||
@ -152,8 +116,8 @@ describe('DataSourceTab.vue', () => {
|
||||
groups: [buildGroup('ds_1', 'add', [{ id: 'ds_1', oldSchema: null, newSchema: { id: 'ds_1', title: 'A' } }])],
|
||||
},
|
||||
];
|
||||
const wrapper = mountDataSourceTab({ buckets, expanded: {} });
|
||||
await wrapper.find('.m-editor-history-list-item-goto').trigger('click');
|
||||
const wrapper = mount(DataSourceTab, { props: { buckets, expanded: {} } });
|
||||
await wrapper.find('.m-editor-history-list-group-head').trigger('click');
|
||||
const events = wrapper.emitted('goto');
|
||||
expect(events).toBeTruthy();
|
||||
expect(events![0]).toEqual(['ds_1', 0]);
|
||||
@ -181,7 +145,9 @@ describe('DataSourceTab.vue', () => {
|
||||
],
|
||||
},
|
||||
];
|
||||
const wrapper = mountDataSourceTab({ buckets, expanded: { 'ds-ds_1-0': true } });
|
||||
const wrapper = mount(DataSourceTab, {
|
||||
props: { buckets, expanded: { 'ds-ds_1-0': true } },
|
||||
});
|
||||
expect(wrapper.findAll('.m-editor-history-list-substeps li')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
@ -6,30 +6,22 @@
|
||||
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';
|
||||
|
||||
/** 构造 GroupRow 的视图模型(merged / stepCount 由 subSteps 长度派生)。 */
|
||||
const makeGroup = (overrides: Partial<HistoryRowGroup> = {}): HistoryRowGroup => ({
|
||||
key: 'pg-0',
|
||||
const baseProps = {
|
||||
groupKey: 'pg-0',
|
||||
applied: true,
|
||||
isCurrent: false,
|
||||
opType: 'update',
|
||||
merged: false,
|
||||
opType: 'update' as const,
|
||||
desc: '修改 按钮',
|
||||
subSteps: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/** 构造单个子步,缺省值贴近真实派生结果。 */
|
||||
const makeStep = (overrides: Partial<HistoryRowStep> & Pick<HistoryRowStep, 'index'>): HistoryRowStep => ({
|
||||
applied: true,
|
||||
desc: '',
|
||||
...overrides,
|
||||
});
|
||||
stepCount: 1,
|
||||
subSteps: [] as { index: number; applied: boolean; desc: string }[],
|
||||
expanded: false,
|
||||
};
|
||||
|
||||
describe('GroupRow.vue', () => {
|
||||
test('渲染描述与操作类型徽标(update→修改)', () => {
|
||||
const wrapper = mount(GroupRow, { props: { group: makeGroup(), expanded: false } });
|
||||
const wrapper = mount(GroupRow, { props: baseProps });
|
||||
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('修改');
|
||||
@ -37,86 +29,44 @@ describe('GroupRow.vue', () => {
|
||||
});
|
||||
|
||||
test('add / remove 操作徽标使用对应类名与文案', () => {
|
||||
const w1 = mount(GroupRow, { props: { group: makeGroup({ opType: 'add' }), expanded: false } });
|
||||
const w1 = mount(GroupRow, { props: { ...baseProps, opType: 'add' } });
|
||||
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: { group: makeGroup({ opType: 'remove' }), expanded: false } });
|
||||
const w2 = mount(GroupRow, { props: { ...baseProps, opType: 'remove' } });
|
||||
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: { group: makeGroup({ applied: false }), expanded: false } });
|
||||
const wrapper = mount(GroupRow, { props: { ...baseProps, applied: false } });
|
||||
expect(wrapper.find('.m-editor-history-list-group').classes()).toContain('is-undone');
|
||||
});
|
||||
|
||||
test('merged(子步数>1)时显示「合并 N 步」并附 is-merged 类名', () => {
|
||||
test('merged=true 时显示「合并 N 步」并附 is-merged 类名', () => {
|
||||
const wrapper = mount(GroupRow, {
|
||||
props: {
|
||||
group: makeGroup({
|
||||
subSteps: [makeStep({ index: 0 }), makeStep({ index: 1 }), makeStep({ index: 2 })],
|
||||
}),
|
||||
expanded: false,
|
||||
},
|
||||
props: { ...baseProps, merged: true, stepCount: 3 },
|
||||
});
|
||||
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: { group: makeGroup(), expanded: false } });
|
||||
const wrapper = mount(GroupRow, { props: baseProps });
|
||||
expect(wrapper.find('.m-editor-history-list-item-merge').exists()).toBe(false);
|
||||
});
|
||||
|
||||
test('传入 time 时头部渲染时间,title 取 timeTitle', () => {
|
||||
const wrapper = mount(GroupRow, {
|
||||
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);
|
||||
expect(time.text()).toBe('12:00:00');
|
||||
expect(time.attributes('title')).toBe('2026-06-03 12:00:00');
|
||||
});
|
||||
|
||||
test('未传 time 时头部不渲染时间元素', () => {
|
||||
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: { group: makeGroup({ time: '08:30:00' }), expanded: false } });
|
||||
expect(wrapper.find('.m-editor-history-list-item-time').attributes('title')).toBe('08:30:00');
|
||||
});
|
||||
|
||||
test('展开的子步各自渲染自己的时间', () => {
|
||||
test('merged=true 且 expanded=true 时渲染子步列表', () => {
|
||||
const wrapper = mount(GroupRow, {
|
||||
props: {
|
||||
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,
|
||||
},
|
||||
});
|
||||
const items = wrapper.findAll('.m-editor-history-list-substeps li');
|
||||
// 子步倒序渲染:index=1 在前
|
||||
expect(items[0].find('.m-editor-history-list-item-time').text()).toBe('10:01:00');
|
||||
expect(items[1].find('.m-editor-history-list-item-time').text()).toBe('10:00:00');
|
||||
});
|
||||
|
||||
test('merged 且 expanded=true 时渲染子步列表', () => {
|
||||
const wrapper = mount(GroupRow, {
|
||||
props: {
|
||||
group: makeGroup({
|
||||
subSteps: [
|
||||
makeStep({ index: 0, applied: true, desc: '修改 颜色' }),
|
||||
makeStep({ index: 1, applied: false, desc: '修改 字号' }),
|
||||
],
|
||||
}),
|
||||
...baseProps,
|
||||
merged: true,
|
||||
stepCount: 2,
|
||||
expanded: true,
|
||||
subSteps: [
|
||||
{ index: 0, applied: true, desc: '修改 颜色' },
|
||||
{ index: 1, applied: false, desc: '修改 字号' },
|
||||
],
|
||||
},
|
||||
});
|
||||
const items = wrapper.findAll('.m-editor-history-list-substeps li');
|
||||
@ -130,23 +80,21 @@ describe('GroupRow.vue', () => {
|
||||
expect(items[1].text()).toContain('修改 颜色');
|
||||
});
|
||||
|
||||
test('merged 但 expanded=false 时不渲染子步列表', () => {
|
||||
test('merged=true 但 expanded=false 时不渲染子步列表', () => {
|
||||
const wrapper = mount(GroupRow, {
|
||||
props: {
|
||||
group: makeGroup({ subSteps: [makeStep({ index: 0, desc: 'x' }), makeStep({ index: 1, desc: 'y' })] }),
|
||||
...baseProps,
|
||||
merged: true,
|
||||
stepCount: 2,
|
||||
expanded: false,
|
||||
subSteps: [{ index: 0, applied: true, desc: 'x' }],
|
||||
},
|
||||
});
|
||||
expect(wrapper.find('.m-editor-history-list-substeps').exists()).toBe(false);
|
||||
});
|
||||
|
||||
test('点击合并组头部触发 toggle 事件并携带 group.key', async () => {
|
||||
const wrapper = mount(GroupRow, {
|
||||
props: {
|
||||
group: makeGroup({ subSteps: [makeStep({ index: 0 }), makeStep({ index: 1 })] }),
|
||||
expanded: false,
|
||||
},
|
||||
});
|
||||
test('点击合并组头部触发 toggle 事件并携带 groupKey', async () => {
|
||||
const wrapper = mount(GroupRow, { props: { ...baseProps, merged: true, stepCount: 2 } });
|
||||
await wrapper.find('.m-editor-history-list-group-head').trigger('click');
|
||||
const events = wrapper.emitted('toggle');
|
||||
expect(events).toBeTruthy();
|
||||
@ -155,81 +103,28 @@ describe('GroupRow.vue', () => {
|
||||
expect(wrapper.emitted('goto')).toBeFalsy();
|
||||
});
|
||||
|
||||
test('点击单步组(非合并)的「回到」按钮触发 goto,携带该唯一 step 的 index', async () => {
|
||||
test('点击单步组(非合并)头部触发 goto,携带该唯一 step 的 index', async () => {
|
||||
const wrapper = mount(GroupRow, {
|
||||
props: {
|
||||
group: makeGroup({ subSteps: [makeStep({ index: 7, applied: true, desc: 'a' })] }),
|
||||
expanded: false,
|
||||
...baseProps,
|
||||
merged: false,
|
||||
subSteps: [{ index: 7, applied: true, desc: 'a' }],
|
||||
},
|
||||
});
|
||||
// 点击头部本身不再触发 goto(整行不可点击)
|
||||
await wrapper.find('.m-editor-history-list-group-head').trigger('click');
|
||||
expect(wrapper.emitted('goto')).toBeFalsy();
|
||||
// 仅点击「回到」按钮才触发 goto
|
||||
await wrapper.find('.m-editor-history-list-item-goto').trigger('click');
|
||||
expect(wrapper.emitted('goto')).toBeTruthy();
|
||||
expect(wrapper.emitted('goto')![0]).toEqual([7]);
|
||||
// 单步组没有展开概念,不应触发 toggle
|
||||
expect(wrapper.emitted('toggle')).toBeFalsy();
|
||||
});
|
||||
|
||||
test('selectEnabled 时点击单步组头部触发 select,携带该 step 的 index', async () => {
|
||||
const wrapper = mount(GroupRow, {
|
||||
props: {
|
||||
group: makeGroup({ subSteps: [makeStep({ index: 5, applied: true, desc: 'a' })] }),
|
||||
expanded: false,
|
||||
selectEnabled: true,
|
||||
},
|
||||
});
|
||||
await wrapper.find('.m-editor-history-list-group-head').trigger('click');
|
||||
expect(wrapper.emitted('select')).toBeTruthy();
|
||||
expect(wrapper.emitted('select')![0]).toEqual([5]);
|
||||
});
|
||||
|
||||
test('selectEnabled 时点击合并组头部同时触发 select 与 toggle', async () => {
|
||||
const wrapper = mount(GroupRow, {
|
||||
props: {
|
||||
group: makeGroup({ subSteps: [makeStep({ index: 3, desc: 'a' }), makeStep({ index: 4, desc: 'b' })] }),
|
||||
expanded: false,
|
||||
selectEnabled: true,
|
||||
},
|
||||
});
|
||||
await wrapper.find('.m-editor-history-list-group-head').trigger('click');
|
||||
// 合并组头部点击:选中组内首步对应节点,同时切换展开
|
||||
expect(wrapper.emitted('select')![0]).toEqual([3]);
|
||||
expect(wrapper.emitted('toggle')![0]).toEqual(['pg-0']);
|
||||
});
|
||||
|
||||
test('selectEnabled 时点击子步行触发 select,携带该子步 index', async () => {
|
||||
const wrapper = mount(GroupRow, {
|
||||
props: {
|
||||
group: makeGroup({ subSteps: [makeStep({ index: 0, desc: 'a' }), makeStep({ index: 1, desc: 'b' })] }),
|
||||
expanded: true,
|
||||
selectEnabled: true,
|
||||
},
|
||||
});
|
||||
const subItems = wrapper.findAll('.m-editor-history-list-substeps li');
|
||||
// 子步倒序渲染:subItems[0] 为 index=1
|
||||
await subItems[0].trigger('click');
|
||||
expect(wrapper.emitted('select')![0]).toEqual([1]);
|
||||
});
|
||||
|
||||
test('未开启 selectEnabled(默认)时点击单步组头部不触发 select', async () => {
|
||||
const wrapper = mount(GroupRow, {
|
||||
props: {
|
||||
group: makeGroup({ subSteps: [makeStep({ index: 5, applied: true, desc: 'a' })] }),
|
||||
expanded: false,
|
||||
},
|
||||
});
|
||||
await wrapper.find('.m-editor-history-list-group-head').trigger('click');
|
||||
expect(wrapper.emitted('select')).toBeFalsy();
|
||||
});
|
||||
|
||||
test('当前单步组(isCurrent=true)点击头部不触发 goto', async () => {
|
||||
const wrapper = mount(GroupRow, {
|
||||
props: {
|
||||
group: makeGroup({ isCurrent: true, subSteps: [makeStep({ index: 0, desc: 'x' })] }),
|
||||
expanded: false,
|
||||
...baseProps,
|
||||
merged: false,
|
||||
isCurrent: true,
|
||||
subSteps: [{ index: 0, applied: true, desc: 'x' }],
|
||||
},
|
||||
});
|
||||
await wrapper.find('.m-editor-history-list-group-head').trigger('click');
|
||||
@ -239,11 +134,14 @@ describe('GroupRow.vue', () => {
|
||||
test('当前合并组(isCurrent=true)点击头部仍能 toggle', async () => {
|
||||
const wrapper = mount(GroupRow, {
|
||||
props: {
|
||||
group: makeGroup({
|
||||
isCurrent: true,
|
||||
subSteps: [makeStep({ index: 0, desc: 'a' }), makeStep({ index: 1, desc: 'b', isCurrent: true })],
|
||||
}),
|
||||
expanded: false,
|
||||
...baseProps,
|
||||
merged: true,
|
||||
stepCount: 2,
|
||||
isCurrent: true,
|
||||
subSteps: [
|
||||
{ index: 0, applied: true, desc: 'a' },
|
||||
{ index: 1, applied: true, desc: 'b', isCurrent: true },
|
||||
],
|
||||
},
|
||||
});
|
||||
await wrapper.find('.m-editor-history-list-group-head').trigger('click');
|
||||
@ -251,26 +149,24 @@ describe('GroupRow.vue', () => {
|
||||
expect(wrapper.emitted('goto')).toBeFalsy();
|
||||
});
|
||||
|
||||
test('点击子步「回到」按钮触发 goto 携带该子步 index;当前子步无回到按钮', async () => {
|
||||
test('点击子步触发 goto 携带该子步 index;当前子步点击无效', async () => {
|
||||
const wrapper = mount(GroupRow, {
|
||||
props: {
|
||||
group: makeGroup({
|
||||
subSteps: [
|
||||
makeStep({ index: 0, applied: true, desc: 'a', isCurrent: true }),
|
||||
makeStep({ index: 1, applied: false, desc: 'b' }),
|
||||
],
|
||||
}),
|
||||
...baseProps,
|
||||
merged: true,
|
||||
stepCount: 2,
|
||||
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(当前,无跳转按钮)
|
||||
// 子步倒序渲染:subItems[0] 为 index=1(非当前,可点击),subItems[1] 为 index=0(当前)
|
||||
const subItems = wrapper.findAll('.m-editor-history-list-substeps li');
|
||||
expect(subItems[1].find('.m-editor-history-list-item-goto').exists()).toBe(false);
|
||||
// 点击子步行本身不再触发 goto
|
||||
await subItems[0].trigger('click');
|
||||
await subItems[1].trigger('click');
|
||||
expect(wrapper.emitted('goto')).toBeFalsy();
|
||||
// 仅点击「跳转」按钮才触发 goto
|
||||
await subItems[0].find('.m-editor-history-list-item-goto').trigger('click');
|
||||
await subItems[0].trigger('click');
|
||||
expect(wrapper.emitted('goto')![0]).toEqual([1]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -13,7 +13,7 @@ vi.mock('@tmagic/design', () => ({
|
||||
// 受控对话框:modelValue 为真时才渲染 body / footer 插槽
|
||||
TMagicDialog: defineComponent({
|
||||
name: 'TMagicDialog',
|
||||
props: ['modelValue', 'title'],
|
||||
props: ['modelValue'],
|
||||
setup(props, { slots }) {
|
||||
return () =>
|
||||
props.modelValue ? h('div', { class: 'fake-dialog' }, [slots.default?.(), slots.footer?.()]) : null;
|
||||
@ -207,34 +207,6 @@ describe('HistoryDiffDialog.vue', () => {
|
||||
expect(form.props('lastValue')).toEqual({ text: 'old' });
|
||||
});
|
||||
|
||||
test('无 onConfirm 时标题为「查看修改差异」', async () => {
|
||||
const wrapper = factory();
|
||||
(wrapper.vm as any).open(basePayload());
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.findComponent({ name: 'TMagicDialog' }).props('title')).toBe('查看修改差异');
|
||||
});
|
||||
|
||||
test('有 onConfirm 时标题为「确认回滚」并展示回滚说明', async () => {
|
||||
const wrapper = mount(HistoryDiffDialog, {
|
||||
global: { stubs: { teleport: true } },
|
||||
props: { onConfirm: vi.fn() },
|
||||
});
|
||||
(wrapper.vm as any).open(basePayload());
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.findComponent({ name: 'TMagicDialog' }).props('title')).toBe('确认回滚');
|
||||
expect(wrapper.find('.m-editor-history-diff-dialog-notice').text()).toBe('仅回滚有差异的字段');
|
||||
});
|
||||
|
||||
test('无 onConfirm 时不展示回滚说明', async () => {
|
||||
const wrapper = factory();
|
||||
(wrapper.vm as any).open(basePayload());
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find('.m-editor-history-diff-dialog-notice').exists()).toBe(false);
|
||||
});
|
||||
|
||||
test('close() 隐藏对话框并清空 payload', async () => {
|
||||
const wrapper = factory();
|
||||
(wrapper.vm as any).open(basePayload());
|
||||
|
||||
@ -9,51 +9,16 @@ import { mount } from '@vue/test-utils';
|
||||
|
||||
import historyService from '@editor/services/history';
|
||||
|
||||
const { diffDialogOpen, confirmDialogConfirm } = vi.hoisted(() => ({
|
||||
diffDialogOpen: vi.fn(),
|
||||
confirmDialogConfirm: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
const stageSelect = vi.fn();
|
||||
const overlayStageSelect = vi.fn();
|
||||
const editorService = {
|
||||
gotoPageStep: vi.fn(async () => 0),
|
||||
revertPageStep: vi.fn(async () => null),
|
||||
getNodeById: vi.fn((id: string | number) => ({ id })),
|
||||
select: vi.fn(async () => {}),
|
||||
get: vi.fn(() => ({ select: stageSelect })),
|
||||
};
|
||||
const stageOverlayService = { get: vi.fn(() => ({ select: overlayStageSelect })) };
|
||||
const dataSourceService = {
|
||||
goto: vi.fn(() => 0),
|
||||
revert: vi.fn(async () => null),
|
||||
getDataSourceById: vi.fn((id: string) => ({ id, title: 'DS' })),
|
||||
};
|
||||
const codeBlockService = {
|
||||
goto: vi.fn(async () => 0),
|
||||
revert: vi.fn(async () => null),
|
||||
getCodeContentById: vi.fn((id: string | number) => ({ id, name: 'CB' })),
|
||||
};
|
||||
const propsService = {
|
||||
getDisabledDataSource: vi.fn(() => false),
|
||||
getDisabledCodeBlock: vi.fn(() => false),
|
||||
};
|
||||
const editorService = { gotoPageStep: vi.fn(async () => 0) };
|
||||
const dataSourceService = { goto: vi.fn(() => 0) };
|
||||
const codeBlockService = { goto: vi.fn(async () => 0) };
|
||||
|
||||
vi.mock('@editor/hooks/use-services', () => ({
|
||||
useServices: () => ({
|
||||
historyService,
|
||||
editorService,
|
||||
dataSourceService,
|
||||
codeBlockService,
|
||||
propsService,
|
||||
stageOverlayService,
|
||||
}),
|
||||
useServices: () => ({ historyService, editorService, dataSourceService, codeBlockService }),
|
||||
}));
|
||||
|
||||
vi.mock('@tmagic/design', () => ({
|
||||
getDesignConfig: vi.fn(() => undefined),
|
||||
tMagicMessage: { warning: vi.fn(), error: vi.fn(), success: vi.fn() },
|
||||
tMagicMessageBox: { confirm: vi.fn(async () => undefined) },
|
||||
TMagicButton: defineComponent({
|
||||
name: 'FakeButton',
|
||||
setup(_p, { slots }) {
|
||||
@ -105,7 +70,7 @@ vi.mock('@editor/layouts/history-list/HistoryDiffDialog.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'FakeHistoryDiffDialog',
|
||||
setup(_p, { expose }) {
|
||||
expose({ open: diffDialogOpen, close: vi.fn(), confirm: confirmDialogConfirm });
|
||||
expose({ open: vi.fn(), close: vi.fn() });
|
||||
return () => h('div', { class: 'fake-history-diff-dialog' });
|
||||
},
|
||||
}),
|
||||
@ -114,8 +79,6 @@ vi.mock('@editor/layouts/history-list/HistoryDiffDialog.vue', () => ({
|
||||
afterEach(() => {
|
||||
historyService.reset();
|
||||
vi.clearAllMocks();
|
||||
propsService.getDisabledDataSource.mockReturnValue(false);
|
||||
propsService.getDisabledCodeBlock.mockReturnValue(false);
|
||||
});
|
||||
|
||||
const factory = async () => {
|
||||
@ -138,7 +101,7 @@ describe('HistoryListPanel.vue', () => {
|
||||
historyService.changePage({ id: 'p1' } as any);
|
||||
historyService.push({
|
||||
opType: 'add',
|
||||
diff: [{ newSchema: { id: 'n1', name: 'A' } }],
|
||||
nodes: [{ id: 'n1', name: 'A' }],
|
||||
modifiedNodeIds: new Map(),
|
||||
} as any);
|
||||
historyService.pushDataSource('ds_1', {
|
||||
@ -169,10 +132,10 @@ describe('HistoryListPanel.vue', () => {
|
||||
const mkUpdate = (path: string) => ({
|
||||
opType: 'update',
|
||||
modifiedNodeIds: new Map(),
|
||||
diff: [
|
||||
updatedItems: [
|
||||
{
|
||||
newSchema: { id: 'btn', name: '按钮' },
|
||||
oldSchema: { id: 'btn', name: '按钮' },
|
||||
newNode: { id: 'btn', name: '按钮' },
|
||||
oldNode: { id: 'btn', name: '按钮' },
|
||||
changeRecords: [{ propPath: path }],
|
||||
},
|
||||
],
|
||||
@ -185,29 +148,29 @@ describe('HistoryListPanel.vue', () => {
|
||||
|
||||
const head = wrapper.find('.m-editor-history-list-group-head');
|
||||
expect(head.exists()).toBe(true);
|
||||
// 默认展开
|
||||
// 默认未展开
|
||||
expect(wrapper.find('.m-editor-history-list-substeps').exists()).toBe(false);
|
||||
// 点击展开
|
||||
await head.trigger('click');
|
||||
expect(wrapper.find('.m-editor-history-list-substeps').exists()).toBe(true);
|
||||
expect(wrapper.findAll('.m-editor-history-list-substeps li')).toHaveLength(2);
|
||||
// 合并组头部点击不应触发 goto
|
||||
expect(editorService.gotoPageStep).not.toHaveBeenCalled();
|
||||
// 点击收起
|
||||
await head.trigger('click');
|
||||
expect(wrapper.find('.m-editor-history-list-substeps').exists()).toBe(false);
|
||||
// 再点击展开
|
||||
// 再点击折叠
|
||||
await wrapper.find('.m-editor-history-list-group-head').trigger('click');
|
||||
expect(wrapper.find('.m-editor-history-list-substeps').exists()).toBe(true);
|
||||
expect(wrapper.find('.m-editor-history-list-substeps').exists()).toBe(false);
|
||||
});
|
||||
|
||||
test('点击页面 group 头部调用 editorService.gotoPageStep', async () => {
|
||||
historyService.changePage({ id: 'p1' } as any);
|
||||
historyService.push({
|
||||
opType: 'add',
|
||||
diff: [{ newSchema: { id: 'n1', name: 'A' } }],
|
||||
nodes: [{ id: 'n1', name: 'A' }],
|
||||
modifiedNodeIds: new Map(),
|
||||
} as any);
|
||||
historyService.push({
|
||||
opType: 'add',
|
||||
diff: [{ newSchema: { id: 'n2', name: 'B' } }],
|
||||
nodes: [{ id: 'n2', name: 'B' }],
|
||||
modifiedNodeIds: new Map(),
|
||||
} as any);
|
||||
|
||||
@ -220,59 +183,15 @@ describe('HistoryListPanel.vue', () => {
|
||||
const heads = wrapper.findAll('.m-editor-history-list-group-head');
|
||||
expect(heads.length).toBeGreaterThanOrEqual(2);
|
||||
// 第二行(pg-1)对应原始 step.index = 0;cursor 应为 0+1 = 1
|
||||
await heads[1].find('.m-editor-history-list-item-goto').trigger('click');
|
||||
await heads[1].trigger('click');
|
||||
expect(editorService.gotoPageStep).toHaveBeenCalledTimes(1);
|
||||
expect(editorService.gotoPageStep).toHaveBeenCalledWith(1);
|
||||
|
||||
// 当前组没有「回到」按钮,点击头部不触发 goto
|
||||
// 当前组点击不触发 goto
|
||||
await head.trigger('click');
|
||||
expect(editorService.gotoPageStep).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('点击页面 group 头部选中对应节点(editorService.select + 画布 select 联动)', async () => {
|
||||
historyService.changePage({ id: 'p1' } as any);
|
||||
historyService.push({
|
||||
opType: 'add',
|
||||
diff: [{ newSchema: { id: 'n1', name: 'A' } }],
|
||||
modifiedNodeIds: new Map(),
|
||||
} as any);
|
||||
|
||||
const wrapper = await factory();
|
||||
await nextTick();
|
||||
|
||||
const head = wrapper.find('.m-editor-history-list-group-head');
|
||||
await head.trigger('click');
|
||||
await nextTick();
|
||||
|
||||
expect(editorService.getNodeById).toHaveBeenCalledWith('n1', false);
|
||||
expect(editorService.select).toHaveBeenCalledWith({ id: 'n1' });
|
||||
expect(stageSelect).toHaveBeenCalledWith('n1');
|
||||
expect(overlayStageSelect).toHaveBeenCalledWith('n1');
|
||||
// 选中不应触发跳转
|
||||
expect(editorService.gotoPageStep).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('点击页面记录时节点已不存在则提示且不选中', async () => {
|
||||
historyService.changePage({ id: 'p1' } as any);
|
||||
historyService.push({
|
||||
opType: 'remove',
|
||||
diff: [{ oldSchema: { id: 'gone', name: 'G' } }],
|
||||
modifiedNodeIds: new Map(),
|
||||
} as any);
|
||||
editorService.getNodeById.mockReturnValueOnce(null);
|
||||
|
||||
const { tMagicMessage } = await import('@tmagic/design');
|
||||
const wrapper = await factory();
|
||||
await nextTick();
|
||||
|
||||
await wrapper.find('.m-editor-history-list-group-head').trigger('click');
|
||||
await nextTick();
|
||||
|
||||
expect(tMagicMessage.warning).toHaveBeenCalled();
|
||||
expect(editorService.select).not.toHaveBeenCalled();
|
||||
expect(stageSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('点击数据源组头部调用 dataSourceService.goto(id, cursor)', async () => {
|
||||
historyService.pushDataSource('ds_1', {
|
||||
oldSchema: null,
|
||||
@ -290,7 +209,7 @@ describe('HistoryListPanel.vue', () => {
|
||||
// 找到数据源 tab 那一组
|
||||
const dsHead = heads.find((h) => h.text().includes('创建 DS'));
|
||||
expect(dsHead).toBeTruthy();
|
||||
await dsHead!.find('.m-editor-history-list-item-goto').trigger('click');
|
||||
await dsHead!.trigger('click');
|
||||
expect(dataSourceService.goto).toHaveBeenCalledWith('ds_1', 1);
|
||||
});
|
||||
|
||||
@ -309,7 +228,7 @@ describe('HistoryListPanel.vue', () => {
|
||||
const heads = wrapper.findAll('.m-editor-history-list-group-head');
|
||||
const cbHead = heads.find((h) => h.text().includes('创建 CB'));
|
||||
expect(cbHead).toBeTruthy();
|
||||
await cbHead!.find('.m-editor-history-list-item-goto').trigger('click');
|
||||
await cbHead!.trigger('click');
|
||||
expect(codeBlockService.goto).toHaveBeenCalledWith('code_1', 1);
|
||||
});
|
||||
|
||||
@ -317,7 +236,7 @@ describe('HistoryListPanel.vue', () => {
|
||||
historyService.changePage({ id: 'p1' } as any);
|
||||
historyService.push({
|
||||
opType: 'add',
|
||||
diff: [{ newSchema: { id: 'n1', name: 'A' } }],
|
||||
nodes: [{ id: 'n1', name: 'A' }],
|
||||
modifiedNodeIds: new Map(),
|
||||
} as any);
|
||||
|
||||
@ -328,148 +247,10 @@ describe('HistoryListPanel.vue', () => {
|
||||
const initials = wrapper.findAll('.m-editor-history-list-initial');
|
||||
expect(initials.length).toBeGreaterThanOrEqual(1);
|
||||
// 第一项(页面 tab)应为页面 tab 的初始项;page tab 在三个 tab 中最先渲染
|
||||
await initials[0].find('.m-editor-history-list-item-goto').trigger('click');
|
||||
await initials[0].trigger('click');
|
||||
expect(editorService.gotoPageStep).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
test('注入 historyListExtraTabs 时追加渲染自定义 tab 内容组件', async () => {
|
||||
const { default: historyListPanel } = await import('@editor/layouts/history-list/HistoryListPanel.vue');
|
||||
const customTab = defineComponent({
|
||||
name: 'CustomHistoryTab',
|
||||
props: ['title'],
|
||||
setup(p) {
|
||||
return () => h('div', { class: 'custom-history-tab' }, p.title);
|
||||
},
|
||||
});
|
||||
|
||||
const wrapper = mount(historyListPanel, {
|
||||
attachTo: document.body,
|
||||
global: {
|
||||
provide: {
|
||||
historyListExtraTabs: [
|
||||
{
|
||||
name: 'custom-module',
|
||||
label: () => '自定义模块 (1)',
|
||||
component: customTab,
|
||||
props: { title: 'hello-custom' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
const custom = wrapper.find('.custom-history-tab');
|
||||
expect(custom.exists()).toBe(true);
|
||||
expect(custom.text()).toBe('hello-custom');
|
||||
});
|
||||
|
||||
test('disabledDataSource / disabledCodeBlock 为 true 时不渲染对应 tab', async () => {
|
||||
propsService.getDisabledDataSource.mockReturnValue(true);
|
||||
propsService.getDisabledCodeBlock.mockReturnValue(true);
|
||||
|
||||
const wrapper = await factory();
|
||||
await nextTick();
|
||||
|
||||
const empties = wrapper.findAll('.m-editor-history-list-empty');
|
||||
expect(empties).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('点击页面 update 记录的「查看差异」打开 diff 弹窗', async () => {
|
||||
historyService.changePage({ id: 'p1' } as any);
|
||||
historyService.push({
|
||||
opType: 'update',
|
||||
modifiedNodeIds: new Map(),
|
||||
diff: [
|
||||
{
|
||||
newSchema: { id: 'btn', name: '新按钮', type: 'button' },
|
||||
oldSchema: { id: 'btn', name: '旧按钮', type: 'button' },
|
||||
changeRecords: [{ propPath: 'name' }],
|
||||
},
|
||||
],
|
||||
} as any);
|
||||
|
||||
const wrapper = await factory();
|
||||
await nextTick();
|
||||
|
||||
await wrapper.find('.m-editor-history-list-item-diff').trigger('click');
|
||||
expect(diffDialogOpen).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
category: 'node',
|
||||
targetLabel: '新按钮',
|
||||
value: expect.objectContaining({ name: '新按钮' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('点击页面 update 记录的「回滚」在确认后调用 revertPageStep', async () => {
|
||||
historyService.changePage({ id: 'p1' } as any);
|
||||
historyService.push({
|
||||
opType: 'update',
|
||||
modifiedNodeIds: new Map(),
|
||||
diff: [
|
||||
{
|
||||
newSchema: { id: 'btn', name: '新按钮', type: 'button' },
|
||||
oldSchema: { id: 'btn', name: '旧按钮', type: 'button' },
|
||||
changeRecords: [{ propPath: 'name' }],
|
||||
},
|
||||
],
|
||||
} as any);
|
||||
|
||||
const wrapper = await factory();
|
||||
await nextTick();
|
||||
|
||||
await wrapper.find('.m-editor-history-list-item-revert').trigger('click');
|
||||
await nextTick();
|
||||
expect(confirmDialogConfirm).toHaveBeenCalled();
|
||||
expect(editorService.revertPageStep).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
test('回滚目标节点已删除时提示错误且不执行 revert', async () => {
|
||||
historyService.changePage({ id: 'p1' } as any);
|
||||
historyService.push({
|
||||
opType: 'update',
|
||||
modifiedNodeIds: new Map(),
|
||||
diff: [
|
||||
{
|
||||
newSchema: { id: 'gone', name: '按钮', type: 'button' },
|
||||
oldSchema: { id: 'gone', name: '按钮', type: 'button' },
|
||||
changeRecords: [{ propPath: 'name' }],
|
||||
},
|
||||
],
|
||||
} as any);
|
||||
editorService.getNodeById.mockReturnValueOnce(null);
|
||||
|
||||
const { tMagicMessage } = await import('@tmagic/design');
|
||||
const wrapper = await factory();
|
||||
await nextTick();
|
||||
|
||||
await wrapper.find('.m-editor-history-list-item-revert').trigger('click');
|
||||
await nextTick();
|
||||
expect(tMagicMessage.error).toHaveBeenCalledWith('回滚失败:该记录对应的数据已被删除');
|
||||
expect(editorService.revertPageStep).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('确认清空页面历史后调用 historyService.clearPage', async () => {
|
||||
historyService.changePage({ id: 'p1' } as any);
|
||||
historyService.push({
|
||||
opType: 'add',
|
||||
diff: [{ newSchema: { id: 'n1', name: 'A' } }],
|
||||
modifiedNodeIds: new Map(),
|
||||
} as any);
|
||||
const saveSpy = vi.spyOn(historyService, 'saveToIndexedDB').mockResolvedValue(undefined);
|
||||
|
||||
const wrapper = await factory();
|
||||
await nextTick();
|
||||
|
||||
await wrapper.find('.m-editor-history-list-clear').trigger('click');
|
||||
await nextTick();
|
||||
|
||||
expect(historyService.getPageHistoryGroups()).toHaveLength(0);
|
||||
expect(saveSpy).toHaveBeenCalled();
|
||||
saveSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('点击数据源/代码块初始项调用对应 service.goto(id, 0)', async () => {
|
||||
historyService.pushDataSource('ds_x', {
|
||||
oldSchema: null,
|
||||
@ -490,63 +271,10 @@ describe('HistoryListPanel.vue', () => {
|
||||
|
||||
// 顺序:tab 渲染顺序是 page → data-source → code-block
|
||||
// 因此 initials[0] 属于 ds_x,initials[1] 属于 code_x
|
||||
await initials[0].find('.m-editor-history-list-item-goto').trigger('click');
|
||||
await initials[0].trigger('click');
|
||||
expect(dataSourceService.goto).toHaveBeenCalledWith('ds_x', 0);
|
||||
|
||||
await initials[1].find('.m-editor-history-list-item-goto').trigger('click');
|
||||
await initials[1].trigger('click');
|
||||
expect(codeBlockService.goto).toHaveBeenCalledWith('code_x', 0);
|
||||
});
|
||||
|
||||
test('点击数据源 update 记录的「查看差异」与「回滚」', async () => {
|
||||
historyService.pushDataSource('ds_1', {
|
||||
oldSchema: null,
|
||||
newSchema: { id: 'ds_1', title: 'DS' } as any,
|
||||
});
|
||||
historyService.pushDataSource('ds_1', {
|
||||
oldSchema: { id: 'ds_1', title: '旧 DS' } as any,
|
||||
newSchema: { id: 'ds_1', title: '新 DS' } as any,
|
||||
changeRecords: [{ propPath: 'title' }],
|
||||
});
|
||||
|
||||
const wrapper = await factory();
|
||||
await nextTick();
|
||||
|
||||
const diffBtn = wrapper.find('.m-editor-history-list-item-diff');
|
||||
await diffBtn.trigger('click');
|
||||
expect(diffDialogOpen).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ category: 'data-source', targetLabel: '新 DS' }),
|
||||
);
|
||||
|
||||
await wrapper.find('.m-editor-history-list-item-revert').trigger('click');
|
||||
await nextTick();
|
||||
expect(confirmDialogConfirm).toHaveBeenCalled();
|
||||
expect(dataSourceService.revert).toHaveBeenCalledWith('ds_1', 1);
|
||||
});
|
||||
|
||||
test('确认清空数据源/代码块历史后调用 clearDataSource / clearCodeBlock', async () => {
|
||||
historyService.pushDataSource('ds_1', {
|
||||
oldSchema: null,
|
||||
newSchema: { id: 'ds_1', title: 'DS' } as any,
|
||||
});
|
||||
historyService.pushCodeBlock('code_1', {
|
||||
oldContent: null,
|
||||
newContent: { id: 'code_1', name: 'CB' } as any,
|
||||
});
|
||||
const saveSpy = vi.spyOn(historyService, 'saveToIndexedDB').mockResolvedValue(undefined);
|
||||
|
||||
const wrapper = await factory();
|
||||
await nextTick();
|
||||
|
||||
const clears = wrapper.findAll('.m-editor-history-list-clear');
|
||||
expect(clears.length).toBeGreaterThanOrEqual(2);
|
||||
await clears[0].trigger('click');
|
||||
await nextTick();
|
||||
await clears[1].trigger('click');
|
||||
await nextTick();
|
||||
|
||||
expect(historyService.getDataSourceHistoryGroups()).toHaveLength(0);
|
||||
expect(historyService.getCodeBlockHistoryGroups()).toHaveLength(0);
|
||||
expect(saveSpy).toHaveBeenCalled();
|
||||
saveSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@ -17,15 +17,15 @@ describe('InitialRow.vue', () => {
|
||||
expect(wrapper.find('.m-editor-history-list-item-desc').text()).toBe('未修改的初始状态');
|
||||
});
|
||||
|
||||
test('isCurrent=true 时附 is-current 类名且不展示「回到」按钮', () => {
|
||||
test('isCurrent=true 时附 is-current 类名并显示「当前」徽标', () => {
|
||||
const wrapper = mount(InitialRow, { props: { isCurrent: true } });
|
||||
expect(wrapper.find('.m-editor-history-list-initial').classes()).toContain('is-current');
|
||||
expect(wrapper.find('.m-editor-history-list-item-goto').exists()).toBe(false);
|
||||
expect(wrapper.find('.m-editor-history-list-item-current').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test('非当前时点击「回到」按钮触发 goto-initial 事件', async () => {
|
||||
test('非当前时点击触发 goto-initial 事件', async () => {
|
||||
const wrapper = mount(InitialRow, { props: { isCurrent: false } });
|
||||
await wrapper.find('.m-editor-history-list-item-goto').trigger('click');
|
||||
await wrapper.find('.m-editor-history-list-initial').trigger('click');
|
||||
expect(wrapper.emitted('goto-initial')).toBeTruthy();
|
||||
expect(wrapper.emitted('goto-initial')).toHaveLength(1);
|
||||
});
|
||||
|
||||
@ -26,7 +26,6 @@ const buildPageGroup = (
|
||||
applied = true,
|
||||
targetName?: string,
|
||||
targetId?: string,
|
||||
startIndex = 0,
|
||||
): PageHistoryGroup => ({
|
||||
kind: 'page',
|
||||
pageId: 'p1',
|
||||
@ -34,7 +33,7 @@ const buildPageGroup = (
|
||||
applied,
|
||||
targetId,
|
||||
targetName,
|
||||
steps: steps.map((s, i) => ({ step: s, index: startIndex + i, applied })),
|
||||
steps: steps.map((s, i) => ({ step: s, index: i, applied })),
|
||||
});
|
||||
|
||||
describe('PageTab.vue', () => {
|
||||
@ -46,16 +45,16 @@ describe('PageTab.vue', () => {
|
||||
|
||||
test('list 非空:每个 group 渲染一行', () => {
|
||||
const list = [
|
||||
buildPageGroup('add', [{ opType: 'add', diff: [{ newSchema: { id: 'n1', name: 'A' } }] }]),
|
||||
buildPageGroup('add', [{ opType: 'add', nodes: [{ id: 'n1', name: 'A' }] }]),
|
||||
buildPageGroup(
|
||||
'update',
|
||||
[
|
||||
{
|
||||
opType: 'update',
|
||||
diff: [
|
||||
updatedItems: [
|
||||
{
|
||||
newSchema: { id: 'btn', name: '按钮' },
|
||||
oldSchema: { id: 'btn' },
|
||||
newNode: { id: 'btn', name: '按钮' },
|
||||
oldNode: { id: 'btn' },
|
||||
changeRecords: [{ propPath: 'style.color' }],
|
||||
},
|
||||
],
|
||||
@ -77,43 +76,26 @@ describe('PageTab.vue', () => {
|
||||
expect(rows[1].find('.m-editor-history-list-item-desc').text()).toBe('修改 按钮 (id: btn) · style.color');
|
||||
});
|
||||
|
||||
test('step 含 timestamp 时渲染时间元素', () => {
|
||||
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);
|
||||
// 当天记录展示 HH:mm:ss
|
||||
expect(time.text()).toMatch(/^\d{2}:\d{2}:\d{2}$/);
|
||||
});
|
||||
|
||||
test('step 无 timestamp 时不渲染时间元素', () => {
|
||||
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);
|
||||
});
|
||||
|
||||
test('expanded 控制合并组的展开状态(key=pg-${idx})', async () => {
|
||||
const mergedGroup = buildPageGroup(
|
||||
'update',
|
||||
[
|
||||
{
|
||||
opType: 'update',
|
||||
diff: [
|
||||
updatedItems: [
|
||||
{
|
||||
newSchema: { id: 'btn', name: '按钮' },
|
||||
oldSchema: { id: 'btn' },
|
||||
newNode: { id: 'btn', name: '按钮' },
|
||||
oldNode: { id: 'btn' },
|
||||
changeRecords: [{ propPath: 'a' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
opType: 'update',
|
||||
diff: [
|
||||
updatedItems: [
|
||||
{
|
||||
newSchema: { id: 'btn', name: '按钮' },
|
||||
oldSchema: { id: 'btn' },
|
||||
newNode: { id: 'btn', name: '按钮' },
|
||||
oldNode: { id: 'btn' },
|
||||
changeRecords: [{ propPath: 'b' }],
|
||||
},
|
||||
],
|
||||
@ -124,11 +106,11 @@ describe('PageTab.vue', () => {
|
||||
'btn',
|
||||
);
|
||||
|
||||
const wrapper = mount(PageTab, { props: { list: [mergedGroup], expanded: {} } });
|
||||
const wrapper = mount(PageTab, { props: { list: [mergedGroup], expanded: { 'pg-0': true } } });
|
||||
expect(wrapper.find('.m-editor-history-list-substeps').exists()).toBe(true);
|
||||
expect(wrapper.findAll('.m-editor-history-list-substeps li')).toHaveLength(2);
|
||||
|
||||
await wrapper.setProps({ list: [mergedGroup], expanded: { 'pg-0': false } });
|
||||
await wrapper.setProps({ list: [mergedGroup], expanded: {} });
|
||||
expect(wrapper.find('.m-editor-history-list-substeps').exists()).toBe(false);
|
||||
});
|
||||
|
||||
@ -140,11 +122,11 @@ describe('PageTab.vue', () => {
|
||||
[
|
||||
{
|
||||
opType: 'update',
|
||||
diff: [{ newSchema: { id: 'btn' }, oldSchema: { id: 'btn' }, changeRecords: [{ propPath: 'a' }] }],
|
||||
updatedItems: [{ newNode: { id: 'btn' }, oldNode: { id: 'btn' }, changeRecords: [{ propPath: 'a' }] }],
|
||||
},
|
||||
{
|
||||
opType: 'update',
|
||||
diff: [{ newSchema: { id: 'btn' }, oldSchema: { id: 'btn' }, changeRecords: [{ propPath: 'b' }] }],
|
||||
updatedItems: [{ newNode: { id: 'btn' }, oldNode: { id: 'btn' }, changeRecords: [{ propPath: 'b' }] }],
|
||||
},
|
||||
],
|
||||
true,
|
||||
@ -156,17 +138,16 @@ describe('PageTab.vue', () => {
|
||||
[
|
||||
{
|
||||
opType: 'update',
|
||||
diff: [{ newSchema: { id: 'btn2' }, oldSchema: { id: 'btn2' }, changeRecords: [{ propPath: 'a' }] }],
|
||||
updatedItems: [{ newNode: { id: 'btn2' }, oldNode: { id: 'btn2' }, changeRecords: [{ propPath: 'a' }] }],
|
||||
},
|
||||
{
|
||||
opType: 'update',
|
||||
diff: [{ newSchema: { id: 'btn2' }, oldSchema: { id: 'btn2' }, changeRecords: [{ propPath: 'b' }] }],
|
||||
updatedItems: [{ newNode: { id: 'btn2' }, oldNode: { id: 'btn2' }, changeRecords: [{ propPath: 'b' }] }],
|
||||
},
|
||||
],
|
||||
true,
|
||||
'按钮2',
|
||||
'btn2',
|
||||
2,
|
||||
),
|
||||
];
|
||||
const wrapper = mount(PageTab, { props: { list, expanded: {} } });
|
||||
@ -174,22 +155,22 @@ describe('PageTab.vue', () => {
|
||||
await heads[1].trigger('click');
|
||||
const events = wrapper.emitted('toggle');
|
||||
expect(events).toBeTruthy();
|
||||
expect(events![0]).toEqual(['pg-2']);
|
||||
expect(events![0]).toEqual(['pg-1']);
|
||||
// 合并组头部不应触发 goto
|
||||
expect(wrapper.emitted('goto')).toBeFalsy();
|
||||
});
|
||||
|
||||
test('点击单步组「回到」按钮透传 goto 事件,携带该 step 的 index', async () => {
|
||||
const list = [buildPageGroup('add', [{ opType: 'add', diff: [{ newSchema: { id: 'n1', name: 'A' } }] }])];
|
||||
test('点击单步组头部透传 goto 事件,携带该 step 的 index', async () => {
|
||||
const list = [buildPageGroup('add', [{ opType: 'add', nodes: [{ id: 'n1', name: 'A' }] }])];
|
||||
const wrapper = mount(PageTab, { props: { list, expanded: {} } });
|
||||
await wrapper.find('.m-editor-history-list-item-goto').trigger('click');
|
||||
await wrapper.find('.m-editor-history-list-group-head').trigger('click');
|
||||
expect(wrapper.emitted('goto')).toBeTruthy();
|
||||
expect(wrapper.emitted('goto')![0]).toEqual([0]);
|
||||
expect(wrapper.emitted('toggle')).toBeFalsy();
|
||||
});
|
||||
|
||||
test('已撤销组(applied=false)附 is-undone 类名', () => {
|
||||
const list = [buildPageGroup('add', [{ opType: 'add', diff: [{ newSchema: { id: 'n1', name: 'A' } }] }], false)];
|
||||
const list = [buildPageGroup('add', [{ opType: 'add', nodes: [{ id: 'n1', name: 'A' }] }], false)];
|
||||
const wrapper = mount(PageTab, { props: { list, expanded: {} } });
|
||||
expect(wrapper.find('.m-editor-history-list-group').classes()).toContain('is-undone');
|
||||
});
|
||||
@ -200,13 +181,13 @@ describe('PageTab.vue', () => {
|
||||
expect(empty.find('.m-editor-history-list-initial').exists()).toBe(false);
|
||||
|
||||
// 非空 list:底部应有一条初始项
|
||||
const list = [buildPageGroup('add', [{ opType: 'add', diff: [{ newSchema: { id: 'n1', name: 'A' } }] }])];
|
||||
const list = [buildPageGroup('add', [{ opType: 'add', nodes: [{ 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', diff: [{ newSchema: { id: 'n1', name: 'A' } }] }], false)];
|
||||
const list = [buildPageGroup('add', [{ opType: 'add', nodes: [{ 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');
|
||||
@ -214,18 +195,18 @@ describe('PageTab.vue', () => {
|
||||
|
||||
test('存在已 applied 的 group 时初始项不为当前', () => {
|
||||
const list = [
|
||||
buildPageGroup('add', [{ opType: 'add', diff: [{ newSchema: { id: 'n1', name: 'A' } }] }], true),
|
||||
buildPageGroup('add', [{ opType: 'add', diff: [{ newSchema: { id: 'n2', name: 'B' } }] }], false),
|
||||
buildPageGroup('add', [{ opType: 'add', nodes: [{ id: 'n1', name: 'A' }] }], true),
|
||||
buildPageGroup('add', [{ opType: 'add', nodes: [{ id: 'n2', name: 'B' }] }], false),
|
||||
];
|
||||
const wrapper = mount(PageTab, { props: { list, expanded: {} } });
|
||||
const initial = wrapper.find('.m-editor-history-list-initial');
|
||||
expect(initial.classes()).not.toContain('is-current');
|
||||
});
|
||||
|
||||
test('点击非当前初始项的「回到」按钮透传 goto-initial 事件', async () => {
|
||||
const list = [buildPageGroup('add', [{ opType: 'add', diff: [{ newSchema: { id: 'n1', name: 'A' } }] }], true)];
|
||||
test('点击非当前的初始项透传 goto-initial 事件', async () => {
|
||||
const list = [buildPageGroup('add', [{ opType: 'add', nodes: [{ 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');
|
||||
await wrapper.find('.m-editor-history-list-initial').trigger('click');
|
||||
expect(wrapper.emitted('goto-initial')).toBeTruthy();
|
||||
expect(wrapper.emitted('goto-initial')).toHaveLength(1);
|
||||
});
|
||||
|
||||
@ -14,12 +14,6 @@ import {
|
||||
describeDataSourceStep,
|
||||
describePageGroup,
|
||||
describePageStep,
|
||||
formatHistoryFullTime,
|
||||
formatHistoryTime,
|
||||
groupTimestamp,
|
||||
isCodeBlockStepRevertable,
|
||||
isDataSourceStepRevertable,
|
||||
isPageStepRevertable,
|
||||
opLabel,
|
||||
useHistoryList,
|
||||
} from '@editor/layouts/history-list/composables';
|
||||
@ -56,50 +50,6 @@ describe('opLabel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatHistoryFullTime', () => {
|
||||
test('无时间戳返回空串', () => {
|
||||
expect(formatHistoryFullTime()).toBe('');
|
||||
expect(formatHistoryFullTime(0)).toBe('');
|
||||
});
|
||||
|
||||
test('格式化为北京时间的完整 YYYY-MM-DD HH:mm:ss(不随本地时区漂移)', () => {
|
||||
// 2026-01-02 03:04:05 UTC → 北京时间 (UTC+8) 2026-01-02 11:04:05
|
||||
const ts = Date.UTC(2026, 0, 2, 3, 4, 5);
|
||||
expect(formatHistoryFullTime(ts)).toBe('2026-01-02 11:04:05');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatHistoryTime', () => {
|
||||
test('无时间戳返回空串', () => {
|
||||
expect(formatHistoryTime()).toBe('');
|
||||
expect(formatHistoryTime(0)).toBe('');
|
||||
});
|
||||
|
||||
test('当天记录只显示 HH:mm:ss', () => {
|
||||
expect(formatHistoryTime(Date.now())).toMatch(/^\d{2}:\d{2}:\d{2}$/);
|
||||
});
|
||||
|
||||
test('跨天记录显示 MM-DD HH:mm:ss', () => {
|
||||
// 取一个明显不是今天的旧时间戳
|
||||
const ts = Date.UTC(2020, 5, 15, 1, 2, 3);
|
||||
expect(formatHistoryTime(ts)).toMatch(/^\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupTimestamp', () => {
|
||||
test('取组内最后一步的时间戳', () => {
|
||||
const group = {
|
||||
steps: [{ step: { timestamp: 100 } }, { step: { timestamp: 200 } }, { step: { timestamp: 300 } }],
|
||||
};
|
||||
expect(groupTimestamp(group)).toBe(300);
|
||||
});
|
||||
|
||||
test('末步无时间戳时返回 undefined', () => {
|
||||
expect(groupTimestamp({ steps: [{ step: {} }] })).toBeUndefined();
|
||||
expect(groupTimestamp({ steps: [] })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('describePageStep', () => {
|
||||
test('显式 historyDescription 优先于自动生成', () => {
|
||||
const step = { opType: 'update', historyDescription: '调整按钮颜色' } as unknown as StepValue;
|
||||
@ -109,7 +59,7 @@ describe('describePageStep', () => {
|
||||
test('add 单个节点:含名称与 id', () => {
|
||||
const step = {
|
||||
opType: 'add',
|
||||
diff: [{ newSchema: { id: 'btn_1', type: 'button', name: '主按钮' } }],
|
||||
nodes: [{ id: 'btn_1', type: 'button', name: '主按钮' }],
|
||||
} as unknown as StepValue;
|
||||
expect(describePageStep(step)).toBe('新增 1 个节点(主按钮 (id: btn_1))');
|
||||
});
|
||||
@ -117,7 +67,7 @@ describe('describePageStep', () => {
|
||||
test('add 节点无 name 但有 type:使用 type 作为名称', () => {
|
||||
const step = {
|
||||
opType: 'add',
|
||||
diff: [{ newSchema: { id: 'n1', type: 'text' } }],
|
||||
nodes: [{ id: 'n1', type: 'text' }],
|
||||
} as unknown as StepValue;
|
||||
expect(describePageStep(step)).toBe('新增 1 个节点(text (id: n1))');
|
||||
});
|
||||
@ -125,7 +75,7 @@ describe('describePageStep', () => {
|
||||
test('add 节点 name 与 id 相同:仅显示 id', () => {
|
||||
const step = {
|
||||
opType: 'add',
|
||||
diff: [{ newSchema: { id: 'n1', name: 'n1' } }],
|
||||
nodes: [{ id: 'n1', name: 'n1' }],
|
||||
} as unknown as StepValue;
|
||||
expect(describePageStep(step)).toBe('新增 1 个节点(n1)');
|
||||
});
|
||||
@ -133,7 +83,7 @@ describe('describePageStep', () => {
|
||||
test('add 多个节点:仅给出数量', () => {
|
||||
const step = {
|
||||
opType: 'add',
|
||||
diff: [{ newSchema: { id: 'a' } }, { newSchema: { id: 'b' } }],
|
||||
nodes: [{ id: 'a' }, { id: 'b' }],
|
||||
} as unknown as StepValue;
|
||||
expect(describePageStep(step)).toBe('新增 2 个节点');
|
||||
});
|
||||
@ -146,7 +96,7 @@ describe('describePageStep', () => {
|
||||
test('remove 单个节点:含名称与 id', () => {
|
||||
const step = {
|
||||
opType: 'remove',
|
||||
diff: [{ oldSchema: { id: 'btn_1', name: '主按钮' } }],
|
||||
removedItems: [{ node: { id: 'btn_1', name: '主按钮' } }],
|
||||
} as unknown as StepValue;
|
||||
expect(describePageStep(step)).toBe('删除 1 个节点(主按钮 (id: btn_1))');
|
||||
});
|
||||
@ -154,7 +104,7 @@ describe('describePageStep', () => {
|
||||
test('remove 多个节点', () => {
|
||||
const step = {
|
||||
opType: 'remove',
|
||||
diff: [{ oldSchema: { id: 'a' } }, { oldSchema: { id: 'b' } }],
|
||||
removedItems: [{ node: { id: 'a' } }, { node: { id: 'b' } }],
|
||||
} as unknown as StepValue;
|
||||
expect(describePageStep(step)).toBe('删除 2 个节点');
|
||||
});
|
||||
@ -162,10 +112,10 @@ describe('describePageStep', () => {
|
||||
test('update 单节点:附 propPath 与 id', () => {
|
||||
const step = {
|
||||
opType: 'update',
|
||||
diff: [
|
||||
updatedItems: [
|
||||
{
|
||||
newSchema: { id: 'btn_1', name: '按钮' },
|
||||
oldSchema: { id: 'btn_1', name: '按钮' },
|
||||
newNode: { id: 'btn_1', name: '按钮' },
|
||||
oldNode: { id: 'btn_1', name: '按钮' },
|
||||
changeRecords: [{ propPath: 'style.color' }],
|
||||
},
|
||||
],
|
||||
@ -176,7 +126,7 @@ describe('describePageStep', () => {
|
||||
test('update 单节点无 propPath:仅展示节点', () => {
|
||||
const step = {
|
||||
opType: 'update',
|
||||
diff: [{ newSchema: { id: 'btn_1', name: '按钮' }, oldSchema: { id: 'btn_1' } }],
|
||||
updatedItems: [{ newNode: { id: 'btn_1', name: '按钮' }, oldNode: { id: 'btn_1' } }],
|
||||
} as unknown as StepValue;
|
||||
expect(describePageStep(step)).toBe('修改 按钮 (id: btn_1)');
|
||||
});
|
||||
@ -184,15 +134,15 @@ describe('describePageStep', () => {
|
||||
test('update 多节点:返回数量', () => {
|
||||
const step = {
|
||||
opType: 'update',
|
||||
diff: [
|
||||
{ newSchema: { id: 'a' }, oldSchema: { id: 'a' } },
|
||||
{ newSchema: { id: 'b' }, oldSchema: { id: 'b' } },
|
||||
updatedItems: [
|
||||
{ newNode: { id: 'a' }, oldNode: { id: 'a' } },
|
||||
{ newNode: { id: 'b' }, oldNode: { id: 'b' } },
|
||||
],
|
||||
} as unknown as StepValue;
|
||||
expect(describePageStep(step)).toBe('修改 2 个节点');
|
||||
});
|
||||
|
||||
test('update diff 缺省:兜底为「修改节点」', () => {
|
||||
test('update updatedItems 缺省:兜底为「修改节点」', () => {
|
||||
const step = { opType: 'update' } as unknown as StepValue;
|
||||
expect(describePageStep(step)).toBe('修改节点');
|
||||
});
|
||||
@ -219,7 +169,7 @@ describe('describePageGroup', () => {
|
||||
test('单步 group 复用 describePageStep', () => {
|
||||
const step = {
|
||||
opType: 'update',
|
||||
diff: [{ newSchema: { id: 'a', name: 'A' }, oldSchema: { id: 'a' } }],
|
||||
updatedItems: [{ newNode: { id: 'a', name: 'A' }, oldNode: { id: 'a' } }],
|
||||
} as unknown as StepValue;
|
||||
const group: PageHistoryGroup = {
|
||||
kind: 'page',
|
||||
@ -237,10 +187,10 @@ describe('describePageGroup', () => {
|
||||
const mkStep = (path: string) =>
|
||||
({
|
||||
opType: 'update',
|
||||
diff: [
|
||||
updatedItems: [
|
||||
{
|
||||
newSchema: { id: 'btn_1', name: '按钮' },
|
||||
oldSchema: { id: 'btn_1', name: '按钮' },
|
||||
newNode: { id: 'btn_1', name: '按钮' },
|
||||
oldNode: { id: 'btn_1', name: '按钮' },
|
||||
changeRecords: [{ propPath: path }],
|
||||
},
|
||||
],
|
||||
@ -262,10 +212,10 @@ describe('describePageGroup', () => {
|
||||
const mkStep = (path: string) =>
|
||||
({
|
||||
opType: 'update',
|
||||
diff: [
|
||||
updatedItems: [
|
||||
{
|
||||
newSchema: { id: 'btn_1', name: '按钮' },
|
||||
oldSchema: { id: 'btn_1' },
|
||||
newNode: { id: 'btn_1', name: '按钮' },
|
||||
oldNode: { id: 'btn_1' },
|
||||
changeRecords: [{ propPath: path }],
|
||||
},
|
||||
],
|
||||
@ -294,7 +244,7 @@ describe('describePageGroup', () => {
|
||||
const mkStep = () =>
|
||||
({
|
||||
opType: 'update',
|
||||
diff: [{ newSchema: { id: 'btn_1', name: '按钮' }, oldSchema: { id: 'btn_1' } }],
|
||||
updatedItems: [{ newNode: { id: 'btn_1', name: '按钮' }, oldNode: { id: 'btn_1' } }],
|
||||
}) as unknown as StepValue;
|
||||
|
||||
const group: PageHistoryGroup = {
|
||||
@ -317,8 +267,8 @@ describe('describePageGroup', () => {
|
||||
targetId: 'btn_1',
|
||||
applied: true,
|
||||
steps: [
|
||||
buildPageEntry({ opType: 'update', diff: [] } as any, 0),
|
||||
buildPageEntry({ opType: 'update', diff: [] } as any, 1),
|
||||
buildPageEntry({ opType: 'update', updatedItems: [] } as any, 0),
|
||||
buildPageEntry({ opType: 'update', updatedItems: [] } as any, 1),
|
||||
],
|
||||
};
|
||||
// targetName 为 undefined,labelWithId 看 label === id 时只展示 id
|
||||
@ -328,72 +278,61 @@ describe('describePageGroup', () => {
|
||||
|
||||
describe('describeDataSourceStep', () => {
|
||||
test('historyDescription 优先', () => {
|
||||
const step = {
|
||||
const step: DataSourceStepValue = {
|
||||
id: 'ds_1',
|
||||
opType: 'update',
|
||||
diff: [{}],
|
||||
oldSchema: null,
|
||||
newSchema: null,
|
||||
historyDescription: '自定义',
|
||||
} as unknown as DataSourceStepValue;
|
||||
};
|
||||
expect(describeDataSourceStep(step)).toBe('自定义');
|
||||
});
|
||||
|
||||
test('新增(oldSchema=null):展示 title 与 id', () => {
|
||||
const step = {
|
||||
const step: DataSourceStepValue = {
|
||||
id: 'ds_1',
|
||||
opType: 'add',
|
||||
diff: [{ newSchema: { id: 'ds_1', title: '用户列表' } }],
|
||||
} as unknown as DataSourceStepValue;
|
||||
oldSchema: null,
|
||||
newSchema: { id: 'ds_1', title: '用户列表' } as any,
|
||||
};
|
||||
expect(describeDataSourceStep(step)).toBe('创建 用户列表 (id: ds_1)');
|
||||
});
|
||||
|
||||
test('删除(newSchema=null):展示 title 与 id', () => {
|
||||
const step = {
|
||||
const step: DataSourceStepValue = {
|
||||
id: 'ds_1',
|
||||
opType: 'remove',
|
||||
diff: [{ oldSchema: { id: 'ds_1', title: '用户列表' } }],
|
||||
} as unknown as DataSourceStepValue;
|
||||
oldSchema: { id: 'ds_1', title: '用户列表' } as any,
|
||||
newSchema: null,
|
||||
};
|
||||
expect(describeDataSourceStep(step)).toBe('删除 用户列表 (id: ds_1)');
|
||||
});
|
||||
|
||||
test('修改:展示 propPath', () => {
|
||||
const step = {
|
||||
const step: DataSourceStepValue = {
|
||||
id: 'ds_1',
|
||||
opType: 'update',
|
||||
diff: [
|
||||
{
|
||||
oldSchema: { id: 'ds_1', title: '用户列表' },
|
||||
newSchema: { id: 'ds_1', title: '用户列表' },
|
||||
changeRecords: [{ propPath: 'fields.0.name' }],
|
||||
},
|
||||
],
|
||||
} as unknown as DataSourceStepValue;
|
||||
oldSchema: { id: 'ds_1', title: '用户列表' } as any,
|
||||
newSchema: { id: 'ds_1', title: '用户列表' } as any,
|
||||
changeRecords: [{ propPath: 'fields.0.name' } as any],
|
||||
};
|
||||
expect(describeDataSourceStep(step)).toBe('修改 用户列表 (id: ds_1) · fields.0.name');
|
||||
});
|
||||
|
||||
test('修改无 title 时仅展示 id', () => {
|
||||
const step = {
|
||||
const step: DataSourceStepValue = {
|
||||
id: 'ds_1',
|
||||
opType: 'update',
|
||||
diff: [{ oldSchema: { id: 'ds_1' }, newSchema: { id: 'ds_1' } }],
|
||||
} as unknown as DataSourceStepValue;
|
||||
oldSchema: { id: 'ds_1' } as any,
|
||||
newSchema: { id: 'ds_1' } as any,
|
||||
};
|
||||
expect(describeDataSourceStep(step)).toBe('修改 ds_1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('describeDataSourceGroup', () => {
|
||||
test('多步组:聚合 propPath 与目标 id', () => {
|
||||
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 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 group: DataSourceHistoryGroup = {
|
||||
kind: 'data-source',
|
||||
id: 'ds_1',
|
||||
@ -415,11 +354,7 @@ describe('describeDataSourceGroup', () => {
|
||||
applied: true,
|
||||
steps: [
|
||||
{
|
||||
step: {
|
||||
id: 'ds_1',
|
||||
opType: 'add',
|
||||
diff: [{ newSchema: { id: 'ds_1', title: 'T' } }],
|
||||
} as unknown as DataSourceStepValue,
|
||||
step: { id: 'ds_1', oldSchema: null, newSchema: { id: 'ds_1', title: 'T' } as any },
|
||||
index: 0,
|
||||
applied: true,
|
||||
},
|
||||
@ -438,10 +373,10 @@ describe('describeDataSourceGroup', () => {
|
||||
{
|
||||
step: {
|
||||
id: 'ds_1',
|
||||
opType: 'update',
|
||||
diff: [{}],
|
||||
oldSchema: null,
|
||||
newSchema: null,
|
||||
historyDescription: '我的描述',
|
||||
} as unknown as DataSourceStepValue,
|
||||
},
|
||||
index: 0,
|
||||
applied: true,
|
||||
},
|
||||
@ -453,63 +388,52 @@ describe('describeDataSourceGroup', () => {
|
||||
|
||||
describe('describeCodeBlockStep', () => {
|
||||
test('新增', () => {
|
||||
const step = {
|
||||
const step: CodeBlockStepValue = {
|
||||
id: 'code_1',
|
||||
opType: 'add',
|
||||
diff: [{ newSchema: { id: 'code_1', name: 'onClick' } }],
|
||||
} as unknown as CodeBlockStepValue;
|
||||
oldContent: null,
|
||||
newContent: { id: 'code_1', name: 'onClick' } as any,
|
||||
};
|
||||
expect(describeCodeBlockStep(step)).toBe('创建 onClick (id: code_1)');
|
||||
});
|
||||
|
||||
test('删除', () => {
|
||||
const step = {
|
||||
const step: CodeBlockStepValue = {
|
||||
id: 'code_1',
|
||||
opType: 'remove',
|
||||
diff: [{ oldSchema: { id: 'code_1', name: 'onClick' } }],
|
||||
} as unknown as CodeBlockStepValue;
|
||||
oldContent: { id: 'code_1', name: 'onClick' } as any,
|
||||
newContent: null,
|
||||
};
|
||||
expect(describeCodeBlockStep(step)).toBe('删除 onClick (id: code_1)');
|
||||
});
|
||||
|
||||
test('修改 + propPath', () => {
|
||||
const step = {
|
||||
const step: CodeBlockStepValue = {
|
||||
id: 'code_1',
|
||||
opType: 'update',
|
||||
diff: [
|
||||
{
|
||||
oldSchema: { id: 'code_1', name: 'onClick' },
|
||||
newSchema: { id: 'code_1', name: 'onClick' },
|
||||
changeRecords: [{ propPath: 'content' }],
|
||||
},
|
||||
],
|
||||
} as unknown as CodeBlockStepValue;
|
||||
oldContent: { id: 'code_1', name: 'onClick' } as any,
|
||||
newContent: { id: 'code_1', name: 'onClick' } as any,
|
||||
changeRecords: [{ propPath: 'content' } as any],
|
||||
};
|
||||
expect(describeCodeBlockStep(step)).toBe('修改 onClick (id: code_1) · content');
|
||||
});
|
||||
|
||||
test('historyDescription 优先', () => {
|
||||
const step = {
|
||||
const step: CodeBlockStepValue = {
|
||||
id: 'code_1',
|
||||
opType: 'update',
|
||||
diff: [{}],
|
||||
oldContent: null,
|
||||
newContent: null,
|
||||
historyDescription: '自定义说明',
|
||||
} as unknown as CodeBlockStepValue;
|
||||
};
|
||||
expect(describeCodeBlockStep(step)).toBe('自定义说明');
|
||||
});
|
||||
});
|
||||
|
||||
describe('describeCodeBlockGroup', () => {
|
||||
test('多步组:聚合 propPath', () => {
|
||||
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 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 group: CodeBlockHistoryGroup = {
|
||||
kind: 'code-block',
|
||||
id: 'code_1',
|
||||
@ -531,11 +455,7 @@ describe('describeCodeBlockGroup', () => {
|
||||
applied: false,
|
||||
steps: [
|
||||
{
|
||||
step: {
|
||||
id: 'code_1',
|
||||
opType: 'remove',
|
||||
diff: [{ oldSchema: { id: 'code_1', name: 'fn' } }],
|
||||
} as unknown as CodeBlockStepValue,
|
||||
step: { id: 'code_1', oldContent: { id: 'code_1', name: 'fn' } as any, newContent: null },
|
||||
index: 0,
|
||||
applied: false,
|
||||
},
|
||||
@ -565,11 +485,9 @@ describe('useHistoryList', () => {
|
||||
return { api, wrapper };
|
||||
};
|
||||
|
||||
test('toggleGroup 切换 expanded[key](默认展开)', () => {
|
||||
test('toggleGroup 切换 expanded[key]', () => {
|
||||
const { api } = mountWithHost();
|
||||
expect(api.expanded.foo).toBeUndefined();
|
||||
api.toggleGroup('foo');
|
||||
expect(api.expanded.foo).toBe(false);
|
||||
expect(api.expanded.foo).toBeFalsy();
|
||||
api.toggleGroup('foo');
|
||||
expect(api.expanded.foo).toBe(true);
|
||||
api.toggleGroup('foo');
|
||||
@ -582,12 +500,12 @@ describe('useHistoryList', () => {
|
||||
historyService.changePage({ id: 'p1' } as any);
|
||||
historyService.push({
|
||||
opType: 'add',
|
||||
diff: [{ newSchema: { id: 'n1', name: 'A' } }],
|
||||
nodes: [{ id: 'n1', name: 'A' }],
|
||||
modifiedNodeIds: new Map(),
|
||||
} as any);
|
||||
historyService.push({
|
||||
opType: 'remove',
|
||||
diff: [{ oldSchema: { id: 'n2', name: 'B' } }],
|
||||
removedItems: [{ node: { id: 'n2', name: 'B' } }],
|
||||
modifiedNodeIds: new Map(),
|
||||
} as any);
|
||||
|
||||
@ -642,80 +560,3 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -9,8 +9,6 @@ import { mount } from '@vue/test-utils';
|
||||
|
||||
import PageBar from '@editor/layouts/page-bar/PageBar.vue';
|
||||
|
||||
const { messageBoxConfirm } = vi.hoisted(() => ({ messageBoxConfirm: vi.fn(async () => undefined) }));
|
||||
|
||||
const editorState = {
|
||||
page: ref<any>({ id: 'p1' }),
|
||||
root: ref<any>({
|
||||
@ -122,7 +120,6 @@ vi.mock('@tmagic/design', () => ({
|
||||
return () => h('div', { class: 'fake-popover' }, [slots.reference?.(), slots.default?.()]);
|
||||
},
|
||||
}),
|
||||
tMagicMessageBox: { confirm: messageBoxConfirm },
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
@ -174,7 +171,6 @@ describe('PageBar.vue', () => {
|
||||
const wrapper = factory();
|
||||
const removeBtn = wrapper.findAll('.remove')[0];
|
||||
await removeBtn.trigger('click');
|
||||
expect(messageBoxConfirm).toHaveBeenCalledWith('确定删除该页面吗?');
|
||||
expect(editorService.remove).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@ -97,9 +97,7 @@ 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' }, undefined, {
|
||||
historySource: 'component-panel',
|
||||
});
|
||||
expect(editorService.add).toHaveBeenCalledWith({ name: '按钮', type: 'button' });
|
||||
});
|
||||
|
||||
test('搜索过滤组件', async () => {
|
||||
|
||||
@ -4,24 +4,15 @@
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h, nextTick, reactive } from 'vue';
|
||||
import { defineComponent, h, nextTick } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import Sidebar from '@editor/layouts/sidebar/Sidebar.vue';
|
||||
|
||||
const depService = {
|
||||
get: vi.fn((name: string) => {
|
||||
if (name === 'collecting') return false;
|
||||
if (name === 'taskLength') return 0;
|
||||
return false;
|
||||
}),
|
||||
};
|
||||
const uiState: Record<string, any> = reactive({});
|
||||
const depService = { get: vi.fn(() => false) };
|
||||
const uiService = {
|
||||
get: vi.fn((name: string) => (name === 'sideBarActiveTabName' ? uiState.sideBarActiveTabName : { left: 200 })),
|
||||
set: vi.fn((name: string, value: any) => {
|
||||
uiState[name] = value;
|
||||
}),
|
||||
get: vi.fn(() => ({ left: 200 })),
|
||||
set: vi.fn(),
|
||||
};
|
||||
const propsService = {
|
||||
getDisabledDataSource: vi.fn(() => false),
|
||||
@ -100,10 +91,7 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
propsService.getDisabledDataSource.mockReturnValue(false);
|
||||
propsService.getDisabledCodeBlock.mockReturnValue(false);
|
||||
Object.keys(uiState).forEach((key) => delete uiState[key]);
|
||||
uiService.get.mockImplementation((name: string) =>
|
||||
name === 'sideBarActiveTabName' ? uiState.sideBarActiveTabName : { left: 200 },
|
||||
);
|
||||
uiService.get.mockReturnValue({ left: 200 });
|
||||
});
|
||||
|
||||
const baseProps = (extra: any = {}) => ({
|
||||
@ -183,44 +171,4 @@ describe('Sidebar', () => {
|
||||
await items[0].trigger('dragstart');
|
||||
expect(dragstartHandler).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('dragendHandler 触发', async () => {
|
||||
const wrapper = mount(Sidebar, { props: baseProps() as any });
|
||||
const items = wrapper.findAll('.m-editor-sidebar-header-item');
|
||||
await items[0].trigger('dragend');
|
||||
expect(dragendHandler).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('自定义 sidebar item 可渲染 slots 组件', async () => {
|
||||
const customPanel = stub('CustomPanel');
|
||||
const wrapper = mount(Sidebar, {
|
||||
props: baseProps({
|
||||
data: {
|
||||
type: 'tabs',
|
||||
status: '自定义',
|
||||
items: [
|
||||
{
|
||||
$key: 'custom',
|
||||
text: '自定义',
|
||||
component: customPanel,
|
||||
slots: { componentList: stub('SlotComponentList') },
|
||||
},
|
||||
],
|
||||
},
|
||||
}) as any,
|
||||
});
|
||||
await wrapper.find('.m-editor-sidebar-header-item').trigger('click');
|
||||
expect(wrapper.find('.CustomPanel').exists()).toBe(true);
|
||||
});
|
||||
|
||||
test('dep collecting 时展示 tips 区域', () => {
|
||||
depService.get.mockImplementation((name: string) => {
|
||||
if (name === 'collecting') return true;
|
||||
if (name === 'taskLength') return 3;
|
||||
return false;
|
||||
});
|
||||
const wrapper = mount(Sidebar, { props: baseProps() as any });
|
||||
expect(wrapper.find('.m-editor-sidebar-tips').exists()).toBe(true);
|
||||
expect(wrapper.find('.m-editor-sidebar-tips').text()).toContain('剩余任务:3');
|
||||
});
|
||||
});
|
||||
|
||||
@ -70,13 +70,7 @@ describe('code-block useContentMenu', () => {
|
||||
setCodeDslById: vi.fn(),
|
||||
};
|
||||
await (result.menuData[1] as any).handler({ codeBlockService });
|
||||
expect(codeBlockService.setCodeDslById).toHaveBeenCalledWith(
|
||||
'newId',
|
||||
{ name: 'a' },
|
||||
{
|
||||
historySource: 'tree-contextmenu',
|
||||
},
|
||||
);
|
||||
expect(codeBlockService.setCodeDslById).toHaveBeenCalledWith('newId', { name: 'a' });
|
||||
});
|
||||
|
||||
test('复制按钮: 未选中时不触发', async () => {
|
||||
|
||||
@ -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', { historySource: 'tree-contextmenu' });
|
||||
expect(dataSourceService.remove).toHaveBeenCalledWith('d1');
|
||||
await wrapper.find('.ctx-btn').trigger('click');
|
||||
expect(nodeContentMenuHandler).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@ -67,7 +67,7 @@ describe('data-source useContentMenu', () => {
|
||||
add: vi.fn(),
|
||||
};
|
||||
(result.menuData[1] as any).handler({ dataSourceService });
|
||||
expect(dataSourceService.add).toHaveBeenCalledWith({ name: 'a' }, { historySource: 'tree-contextmenu' });
|
||||
expect(dataSourceService.add).toHaveBeenCalledWith({ name: 'a' });
|
||||
});
|
||||
|
||||
test('复制按钮: 未选中时不触发', () => {
|
||||
|
||||
@ -117,9 +117,7 @@ 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' }, undefined, {
|
||||
historySource: 'tree-contextmenu',
|
||||
});
|
||||
expect(editorService.add).toHaveBeenCalledWith({ type: 'tab-pane' });
|
||||
});
|
||||
|
||||
test('node.items 时根据组件列表生成子菜单 (含分隔)', () => {
|
||||
@ -153,8 +151,6 @@ 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' }, undefined, {
|
||||
historySource: 'tree-contextmenu',
|
||||
});
|
||||
expect(editorService.add).toHaveBeenCalledWith({ name: 'btn', type: 'button' });
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 }, { historySource: 'tree' });
|
||||
expect(editorService.update).toHaveBeenCalledWith({ id: 'n1', visible: false });
|
||||
});
|
||||
|
||||
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 }, { historySource: 'tree' });
|
||||
expect(editorService.update).toHaveBeenCalledWith({ id: 'n2', visible: true });
|
||||
});
|
||||
});
|
||||
|
||||
@ -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, { historySource: 'stage-contextmenu' });
|
||||
expect(editorService.moveLayer).toHaveBeenCalledWith(1);
|
||||
menuData.find((m: any) => m.text === '下移一层').handler();
|
||||
expect(editorService.moveLayer).toHaveBeenCalledWith(-1, { historySource: 'stage-contextmenu' });
|
||||
expect(editorService.moveLayer).toHaveBeenCalledWith(-1);
|
||||
menuData.find((m: any) => m.text === '置顶').handler();
|
||||
menuData.find((m: any) => m.text === '置底').handler();
|
||||
expect(editorService.moveLayer).toHaveBeenCalledTimes(4);
|
||||
|
||||
@ -83,24 +83,5 @@ describe('plugin install', () => {
|
||||
editorPlugin.install(app);
|
||||
expect(app.config.globalProperties.$TMAGIC_EDITOR).toBeDefined();
|
||||
expect(typeof app.config.globalProperties.$TMAGIC_EDITOR.parseDSL).toBe('function');
|
||||
expect(typeof app.config.globalProperties.$TMAGIC_EDITOR.customCreateMonacoEditor).toBe('function');
|
||||
expect(typeof app.config.globalProperties.$TMAGIC_EDITOR.customCreateMonacoDiffEditor).toBe('function');
|
||||
});
|
||||
|
||||
test('customCreateMonacoEditor / customCreateMonacoDiffEditor 会调用 monaco API', () => {
|
||||
const { app } = buildApp();
|
||||
editorPlugin.install(app);
|
||||
const { customCreateMonacoEditor, customCreateMonacoDiffEditor } = app.config.globalProperties.$TMAGIC_EDITOR;
|
||||
const monaco = {
|
||||
editor: {
|
||||
create: vi.fn(() => 'editor'),
|
||||
createDiffEditor: vi.fn(() => 'diff-editor'),
|
||||
},
|
||||
};
|
||||
const el = document.createElement('div');
|
||||
expect(customCreateMonacoEditor(monaco, el, { theme: 'vs' })).toBe('editor');
|
||||
expect(customCreateMonacoDiffEditor(monaco, el, { readOnly: true })).toBe('diff-editor');
|
||||
expect(monaco.editor.create).toHaveBeenCalledWith(el, { theme: 'vs' });
|
||||
expect(monaco.editor.createDiffEditor).toHaveBeenCalledWith(el, { readOnly: true });
|
||||
});
|
||||
});
|
||||
|
||||
@ -176,8 +176,8 @@ describe('CodeBlockService - 历史记录接入', () => {
|
||||
|
||||
expect(historyService.canUndoCodeBlock('new_code')).toBe(true);
|
||||
const step = historyService.undoCodeBlock('new_code');
|
||||
expect(step?.diff?.[0]?.oldSchema).toBeUndefined();
|
||||
expect(step?.diff?.[0]?.newSchema).toEqual(expect.objectContaining({ name: 'A' }));
|
||||
expect(step?.oldContent).toBeNull();
|
||||
expect(step?.newContent).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?.diff?.[0]?.oldSchema).toEqual({ name: 'A' });
|
||||
expect(step?.diff?.[0]?.newSchema).toEqual(expect.objectContaining({ name: 'A2' }));
|
||||
expect(step?.oldContent).toEqual({ name: 'A' });
|
||||
expect(step?.newContent).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?.diff?.[0]?.oldSchema).toEqual({ name: 'A' });
|
||||
expect(step?.diff?.[0]?.newSchema).toBeUndefined();
|
||||
expect(step?.oldContent).toEqual({ name: 'A' });
|
||||
expect(step?.newContent).toBeNull();
|
||||
});
|
||||
|
||||
test('deleteCodeDslByIds - 删除不存在的 id 不入历史', async () => {
|
||||
@ -218,7 +218,7 @@ describe('CodeBlockService - 历史记录接入', () => {
|
||||
});
|
||||
|
||||
const step = historyService.undoCodeBlock('a');
|
||||
expect(step?.diff?.[0]?.changeRecords).toEqual([{ propPath: 'name', value: 'A2' }]);
|
||||
expect(step?.changeRecords).toEqual([{ propPath: 'name', value: 'A2' }]);
|
||||
});
|
||||
|
||||
test('setCodeDslByIdSync - 不传 changeRecords 时 step.changeRecords 为 undefined', async () => {
|
||||
@ -227,99 +227,7 @@ describe('CodeBlockService - 历史记录接入', () => {
|
||||
codeBlockService.setCodeDslByIdSync('a', { name: 'A2' } as any);
|
||||
|
||||
const step = historyService.undoCodeBlock('a');
|
||||
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('setCodeDslByIdAndGetHistoryId(async)返回本次写入历史记录的 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();
|
||||
expect(step?.changeRecords).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user