mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-06-09 17:02:01 +00:00
Compare commits
42 Commits
v1.8.0-bet
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
614f12adf3 | ||
|
|
bddc6f343c | ||
|
|
be3a900e6a | ||
|
|
bc555ebdc0 | ||
|
|
b7d1cea7c1 | ||
|
|
3bd0eecb42 | ||
|
|
cd19dec790 | ||
|
|
10b70c36bb | ||
|
|
27b2c2c685 | ||
|
|
a8a9cf372d | ||
|
|
6253d7ed23 | ||
|
|
444d4223a9 | ||
|
|
a9e9e65f9c | ||
|
|
42162f2e4a | ||
|
|
7a161cab00 | ||
|
|
1cd69b33fe | ||
|
|
12069e0937 | ||
|
|
1b66ab1b88 | ||
|
|
64d35d5363 | ||
|
|
35fc394199 | ||
|
|
8612311db1 | ||
|
|
818b41f07f | ||
|
|
9b34124805 | ||
|
|
7a61a35664 | ||
|
|
025cca365c | ||
|
|
a3333e2b4e | ||
|
|
cbc4b25072 | ||
|
|
b02aa75ddc | ||
|
|
f0c66427b8 | ||
|
|
c854dfa8bf | ||
|
|
59f4e0edac | ||
|
|
0f8abf7298 | ||
|
|
62a2ee6693 | ||
|
|
0446202ba6 | ||
|
|
285434ef3e | ||
|
|
8dae67769c | ||
|
|
09558fa027 | ||
|
|
4c855ba50b | ||
|
|
e2c065f90d | ||
|
|
a341c7d73e | ||
|
|
de94a75803 | ||
|
|
d01a28ce76 |
70
CHANGELOG.md
70
CHANGELOG.md
@ -1,3 +1,73 @@
|
|||||||
|
# [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)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **editor:** 修复移动到菜单导致节点引用异常的问题 ([d01a28c](https://github.com/Tencent/tmagic-editor/commit/d01a28ce76203765f333548b30b4ec2954e68d4c))
|
||||||
|
* **editor:** 多选时对多个节点的操作合并入同一条历史记录 ([a341c7d](https://github.com/Tencent/tmagic-editor/commit/a341c7d73e78f0727c1adffce767b6806d356beb))
|
||||||
|
* **editor:** 显式标注 CompareForm 的 defineExpose 类型以修复 DTS 构建报错 ([7a61a35](https://github.com/Tencent/tmagic-editor/commit/7a61a356649838531f4f51c45e2e76ab84474107))
|
||||||
|
* 对比模式下关闭 tab-pane 的 lazy,确保差异数能正确统计 ([0f8abf7](https://github.com/Tencent/tmagic-editor/commit/0f8abf729854f5bfc3fbad98153a77e947ead246))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **editor:** vs-code 字段对比模式改用 monaco diff 编辑器 ([c854dfa](https://github.com/Tencent/tmagic-editor/commit/c854dfa8bf80bd501534b98c72fa1b2802076cac))
|
||||||
|
* **editor:** 代码块与数据源支持按 id 独立的历史记录 ([e2c065f](https://github.com/Tencent/tmagic-editor/commit/e2c065f90d12d1234edd3620430262857a014ee9))
|
||||||
|
* **editor:** 写操作支持 doNotPushHistory 选项以跳过历史记录 ([4c855ba](https://github.com/Tencent/tmagic-editor/commit/4c855ba50b69a2e0ab73f944171c4d5561d5a06a))
|
||||||
|
* **editor:** 历史记录接入 changeRecords,undo/redo 按 propPath 局部更新 ([09558fa](https://github.com/Tencent/tmagic-editor/commit/09558fa0273af0b7d25b4338a8ea56810b09bb1c))
|
||||||
|
* **editor:** 历史记录面板支持单步回滚(类 git revert) ([b02aa75](https://github.com/Tencent/tmagic-editor/commit/b02aa75ddc2b37a024a8966ddad96cf8d85317bb))
|
||||||
|
* **editor:** 历史记录面板支持差异对比 ([59f4e0e](https://github.com/Tencent/tmagic-editor/commit/59f4e0edac47e986a83a3f9b7862cf92650b7fee))
|
||||||
|
* **editor:** 历史记录面板支持点击跳转与回到初始状态 ([62a2ee6](https://github.com/Tencent/tmagic-editor/commit/62a2ee66931caed51f86bf170c3bce96c7e40dea))
|
||||||
|
* **editor:** 字段对比模式逐项展示差异并补充历史记录面板文档 ([cbc4b25](https://github.com/Tencent/tmagic-editor/commit/cbc4b25072542d98f19707a11b87be0295157216))
|
||||||
|
* **editor:** 数据源与代码块 service 支持 undo/redo ([8dae677](https://github.com/Tencent/tmagic-editor/commit/8dae67769c32dbf65413d47ac56ca46e65eaeecf))
|
||||||
|
* **editor:** 新增 hideSidebar 配置支持隐藏左侧面板 ([a3333e2](https://github.com/Tencent/tmagic-editor/commit/a3333e2b4e0f05b2f83c9dc539466ebd31c04250))
|
||||||
|
* **editor:** 新增历史记录列表面板 ([0446202](https://github.com/Tencent/tmagic-editor/commit/0446202ba6aaf0c99265b367343c7a4d1a8201e9))
|
||||||
|
* form 新增 showDiff prop 支持自定义对比判断 ([f0c6642](https://github.com/Tencent/tmagic-editor/commit/f0c66427b8e011252110a11c90a109f5f58d3101))
|
||||||
|
* **form:** 支持自定义 label slot ([285434e](https://github.com/Tencent/tmagic-editor/commit/285434ef3effd94c51d3ed10198842f6e689046a))
|
||||||
|
|
||||||
|
|
||||||
|
### Performance Improvements
|
||||||
|
|
||||||
|
* **dep:** 依赖收集改为单次遍历批量处理多 target ([025cca3](https://github.com/Tencent/tmagic-editor/commit/025cca365c87d755abfc047786ac9a75758019f5))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# [1.8.0-beta.1](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.0...v1.8.0-beta.1) (2026-05-27)
|
# [1.8.0-beta.1](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.0...v1.8.0-beta.1) (2026-05-27)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -102,6 +102,10 @@ export default defineConfig({
|
|||||||
text: '数据源',
|
text: '数据源',
|
||||||
link: '/guide/advanced/data-source.md'
|
link: '/guide/advanced/data-source.md'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
text: '历史记录面板',
|
||||||
|
link: '/guide/advanced/history-list.md',
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
text: '@tmagic/form',
|
text: '@tmagic/form',
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
# codeBlockService方法
|
# codeBlockService方法
|
||||||
|
|
||||||
|
写入历史栈的方法([setCodeDslById](#setcodedslbyid)、[setCodeDslByIdSync](#setcodedslbyidsync)、[deleteCodeDslByIds](#deletecodedslbyids) 等)的 `options` 支持
|
||||||
|
[historyDescription / historySource](./editorServiceMethods.md#历史记录相关-options),会透传到 `historyService.pushCodeBlock` 的 `historyDescription` / `source` 字段。
|
||||||
|
|
||||||
## setCodeDsl
|
## setCodeDsl
|
||||||
|
|
||||||
- **参数:**
|
- **参数:**
|
||||||
@ -48,6 +51,15 @@
|
|||||||
- **参数:**
|
- **参数:**
|
||||||
- `{string | number}` id 代码块id
|
- `{string | number}` id 代码块id
|
||||||
- {Partial<`CodeBlockContent`>} codeConfig 代码块内容配置信息
|
- {Partial<`CodeBlockContent`>} codeConfig 代码块内容配置信息
|
||||||
|
- `{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}
|
||||||
|
:::
|
||||||
|
|
||||||
- **返回:**
|
- **返回:**
|
||||||
- `{Promise<void>}`
|
- `{Promise<void>}`
|
||||||
@ -62,6 +74,11 @@
|
|||||||
- `{string | number}` id 代码块id
|
- `{string | number}` id 代码块id
|
||||||
- {Partial<`CodeBlockContent`>} codeConfig 代码块内容配置信息
|
- {Partial<`CodeBlockContent`>} codeConfig 代码块内容配置信息
|
||||||
- `{boolean}` force 是否强制写入,默认 `true`;为 `false` 时若同 id 已存在则跳过
|
- `{boolean}` force 是否强制写入,默认 `true`;为 `false` 时若同 id 已存在则跳过
|
||||||
|
- `{Object}` options 可选配置
|
||||||
|
- {`ChangeRecord`[]} changeRecords form 端变更记录,用于历史记录的精细化撤销/重做
|
||||||
|
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false)
|
||||||
|
- `{string}` historyDescription 见 [editorService 历史记录相关 options](./editorServiceMethods.md#历史记录相关-options)
|
||||||
|
- `{HistoryOpSource}` historySource 见 [editorService 历史记录相关 options](./editorServiceMethods.md#历史记录相关-options)
|
||||||
|
|
||||||
- **返回:**
|
- **返回:**
|
||||||
- `{void}`
|
- `{void}`
|
||||||
@ -70,6 +87,13 @@
|
|||||||
|
|
||||||
同步版本的 [setCodeDslById](#setcodedslbyid),并会触发 `addOrUpdate` 事件
|
同步版本的 [setCodeDslById](#setcodedslbyid),并会触发 `addOrUpdate` 事件
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
写入成功时(`force=false` 且同 id 已存在的跳过场景除外)会自动调用 `historyService.pushCodeBlock`
|
||||||
|
把本次变更入历史栈,参见 [historyService.pushCodeBlock](./historyServiceMethods.md#pushcodeblock)。
|
||||||
|
传入的 `changeRecords` 会一同写进 step,撤销/重做时调用方可据此按 `propPath` 局部回放。
|
||||||
|
传入 `doNotPushHistory: true` 可跳过写入历史栈,常用于批量导入、外部同步等非用户操作场景。
|
||||||
|
:::
|
||||||
|
|
||||||
## getCodeDslByIds
|
## getCodeDslByIds
|
||||||
|
|
||||||
- **参数:**
|
- **参数:**
|
||||||
@ -194,6 +218,10 @@
|
|||||||
|
|
||||||
- **参数:**
|
- **参数:**
|
||||||
- `{(string | number)[]}` codeIds 需要删除的代码块id数组
|
- `{(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>}`
|
- `{Promise<void>}`
|
||||||
@ -202,6 +230,157 @@
|
|||||||
|
|
||||||
在dsl数据源中删除指定id的代码块,每删除一个会触发一次 `remove` 事件
|
在dsl数据源中删除指定id的代码块,每删除一个会触发一次 `remove` 事件
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
对每个实际存在并被删除的代码块,会自动调用 `historyService.pushCodeBlock` 入栈一条
|
||||||
|
`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
|
||||||
|
|
||||||
|
- **参数:**
|
||||||
|
- `{Id}` id 代码块id
|
||||||
|
|
||||||
|
- **返回:**
|
||||||
|
- `{Promise<CodeBlockStepValue | null>}` 撤销的 step;栈不存在或已无可撤销时返回 `null`
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
撤销指定代码块的最近一次变更。内部根据 [historyService](./historyServiceMethods.md) 取出 step 后,
|
||||||
|
复用 [setCodeDslByIdSync](#setcodedslbyidsync) / [deleteCodeDslByIds](#deletecodedslbyids) 写回,
|
||||||
|
并自动带上 `doNotPushHistory: true`,确保不会再次入栈。
|
||||||
|
|
||||||
|
写回会触发对应的 `addOrUpdate` / `remove` 事件,编辑器内部据此重新维护
|
||||||
|
`DepTargetType.CODE_BLOCK` 的 dep target,无需调用方额外处理。
|
||||||
|
|
||||||
|
对于带有 `changeRecords` 的更新 step,会按 `propPath` 局部 patch 当前代码块内容;缺省才退化为整内容替换,
|
||||||
|
避免冲掉同代码块上的其它无关变更。
|
||||||
|
|
||||||
|
- **示例:**
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { codeBlockService } from "@tmagic/editor";
|
||||||
|
|
||||||
|
if (codeBlockService.canUndo("code_1234")) {
|
||||||
|
await codeBlockService.undo("code_1234");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## redo
|
||||||
|
|
||||||
|
- **参数:**
|
||||||
|
- `{Id}` id 代码块id
|
||||||
|
|
||||||
|
- **返回:**
|
||||||
|
- `{Promise<CodeBlockStepValue | null>}` 重做的 step;栈不存在或已无可重做时返回 `null`
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
重做指定代码块的下一次变更。其它行为同 [undo](#undo)。
|
||||||
|
|
||||||
|
## canUndo
|
||||||
|
|
||||||
|
- **参数:**
|
||||||
|
- `{Id}` id 代码块id
|
||||||
|
|
||||||
|
- **返回:**
|
||||||
|
- `{boolean}`
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
当前指定代码块是否可撤销,等价于 `historyService.canUndoCodeBlock(id)`。
|
||||||
|
|
||||||
|
## canRedo
|
||||||
|
|
||||||
|
- **参数:**
|
||||||
|
- `{Id}` id 代码块id
|
||||||
|
|
||||||
|
- **返回:**
|
||||||
|
- `{boolean}`
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
当前指定代码块是否可重做,等价于 `historyService.canRedoCodeBlock(id)`。
|
||||||
|
|
||||||
## setParamsColConfig
|
## setParamsColConfig
|
||||||
|
|
||||||
- **参数:**
|
- **参数:**
|
||||||
@ -288,15 +467,11 @@
|
|||||||
|
|
||||||
销毁 codeBlockService,重置状态并移除所有事件监听和插件
|
销毁 codeBlockService,重置状态并移除所有事件监听和插件
|
||||||
|
|
||||||
## use
|
|
||||||
|
|
||||||
使用中间件的方式扩展方法,上述方法中标记有`扩展支持: 是`的方法都支持使用use扩展
|
|
||||||
|
|
||||||
## usePlugin
|
## usePlugin
|
||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|
||||||
相对于[use](#use), usePlugin支持更加灵活更加细致的扩展, 上述方法中标记有`扩展支持: 是`的方法都支持使用usePlugin扩展
|
usePlugin支持灵活细致的扩展, 上述方法中标记有`扩展支持: 是`的方法都支持使用usePlugin扩展
|
||||||
|
|
||||||
每个支持扩展的方法都支持定制before、after两个hook来干预原有方法的行为,before可以用于修改传入参数,after可以用于修改返回的值
|
每个支持扩展的方法都支持定制before、after两个hook来干预原有方法的行为,before可以用于修改传入参数,after可以用于修改返回的值
|
||||||
|
|
||||||
|
|||||||
@ -298,6 +298,10 @@ dataSourceService.setFormMethod("http", [
|
|||||||
|
|
||||||
- **参数:**
|
- **参数:**
|
||||||
- {`DataSourceSchema`} config 数据源配置
|
- {`DataSourceSchema`} config 数据源配置
|
||||||
|
- `{Object}` options 可选配置
|
||||||
|
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false)
|
||||||
|
- `{string}` historyDescription 见 [editorService 历史记录相关 options](./editorServiceMethods.md#历史记录相关-options)
|
||||||
|
- `{HistoryOpSource}` historySource 见 [editorService 历史记录相关 options](./editorServiceMethods.md#历史记录相关-options)
|
||||||
|
|
||||||
- **返回:**
|
- **返回:**
|
||||||
- {`DataSourceSchema`} 添加后的数据源配置
|
- {`DataSourceSchema`} 添加后的数据源配置
|
||||||
@ -306,6 +310,12 @@ dataSourceService.setFormMethod("http", [
|
|||||||
|
|
||||||
添加一个数据源,如果配置中没有id或id已存在,会自动生成新的id
|
添加一个数据源,如果配置中没有id或id已存在,会自动生成新的id
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
添加成功会自动调用 `historyService.pushDataSource` 入栈一条 `oldSchema=null` 的新增记录,
|
||||||
|
参见 [historyService.pushDataSource](./historyServiceMethods.md#pushdatasource)。
|
||||||
|
传入 `doNotPushHistory: true` 可跳过写入历史栈,常用于批量导入、外部同步等非用户操作场景。
|
||||||
|
:::
|
||||||
|
|
||||||
- **示例:**
|
- **示例:**
|
||||||
|
|
||||||
```js
|
```js
|
||||||
@ -329,6 +339,9 @@ console.log(newDs.id); // 自动生成的id
|
|||||||
- {`DataSourceSchema`} config 数据源配置
|
- {`DataSourceSchema`} config 数据源配置
|
||||||
- `{Object}` options 可选配置
|
- `{Object}` options 可选配置
|
||||||
- {`ChangeRecord`[]} changeRecords 变更记录
|
- {`ChangeRecord`[]} changeRecords 变更记录
|
||||||
|
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false)
|
||||||
|
- `{string}` historyDescription 见 [editorService 历史记录相关 options](./editorServiceMethods.md#历史记录相关-options)
|
||||||
|
- `{HistoryOpSource}` historySource 见 [editorService 历史记录相关 options](./editorServiceMethods.md#历史记录相关-options)
|
||||||
|
|
||||||
::: details 查看 ChangeRecord 类型定义
|
::: details 查看 ChangeRecord 类型定义
|
||||||
<<< @/../packages/form-schema/src/base.ts#ChangeRecord{ts}
|
<<< @/../packages/form-schema/src/base.ts#ChangeRecord{ts}
|
||||||
@ -341,6 +354,12 @@ console.log(newDs.id); // 自动生成的id
|
|||||||
|
|
||||||
更新数据源
|
更新数据源
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
更新成功会自动调用 `historyService.pushDataSource` 入栈一条 `oldSchema` / `newSchema`
|
||||||
|
均为对应 schema 的更新记录,传入的 `changeRecords` 也会一并写进 step;撤销/重做时调用方可据此按
|
||||||
|
`propPath` 局部回放,缺省才退化为整 schema 替换。传入 `doNotPushHistory: true` 可跳过写入历史栈。
|
||||||
|
:::
|
||||||
|
|
||||||
- **示例:**
|
- **示例:**
|
||||||
|
|
||||||
```js
|
```js
|
||||||
@ -362,6 +381,10 @@ console.log(updatedDs);
|
|||||||
|
|
||||||
- **参数:**
|
- **参数:**
|
||||||
- `{string}` id 数据源id
|
- `{string}` id 数据源id
|
||||||
|
- `{Object}` options 可选配置
|
||||||
|
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false)
|
||||||
|
- `{string}` historyDescription 见 [editorService 历史记录相关 options](./editorServiceMethods.md#历史记录相关-options)
|
||||||
|
- `{HistoryOpSource}` historySource 见 [editorService 历史记录相关 options](./editorServiceMethods.md#历史记录相关-options)
|
||||||
|
|
||||||
- **返回:**
|
- **返回:**
|
||||||
- `{void}`
|
- `{void}`
|
||||||
@ -370,6 +393,11 @@ console.log(updatedDs);
|
|||||||
|
|
||||||
删除指定id的数据源
|
删除指定id的数据源
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
对实际存在的数据源会自动调用 `historyService.pushDataSource` 入栈一条 `newSchema=null`
|
||||||
|
的删除记录;不存在的 id 不会入历史。传入 `doNotPushHistory: true` 也可显式跳过写入历史栈。
|
||||||
|
:::
|
||||||
|
|
||||||
- **示例:**
|
- **示例:**
|
||||||
|
|
||||||
```js
|
```js
|
||||||
@ -378,6 +406,78 @@ import { dataSourceService } from "@tmagic/editor";
|
|||||||
dataSourceService.remove("ds_123");
|
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
|
## createId
|
||||||
|
|
||||||
- **[扩展支持](../../guide/editor-expand#行为扩展):** 是
|
- **[扩展支持](../../guide/editor-expand#行为扩展):** 是
|
||||||
@ -421,6 +521,72 @@ const ds = dataSourceService.getDataSourceById("ds_123");
|
|||||||
console.log(ds);
|
console.log(ds);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## undo
|
||||||
|
|
||||||
|
- **参数:**
|
||||||
|
- `{Id}` id 数据源id
|
||||||
|
|
||||||
|
- **返回:**
|
||||||
|
- {`DataSourceStepValue` | null} 撤销的 step;栈不存在或已无可撤销时返回 `null`
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
撤销指定数据源的最近一次变更。内部根据 [historyService](./historyServiceMethods.md) 取出 step 后,
|
||||||
|
复用 [add](#add) / [update](#update) / [remove](#remove) 写回,并自动带上 `doNotPushHistory: true`,
|
||||||
|
确保不会再次入栈。
|
||||||
|
|
||||||
|
写回会触发对应的 `add` / `update` / `remove` 事件,编辑器内部据此重新维护数据源相关的依赖收集
|
||||||
|
(`DepTargetType.DATA_SOURCE` / `DATA_SOURCE_COND` / `DATA_SOURCE_METHOD`),无需调用方额外处理。
|
||||||
|
|
||||||
|
对于带有 `changeRecords` 的更新 step,会按 `propPath` 局部 patch 当前数据源;缺省才退化为整 schema 替换,
|
||||||
|
避免冲掉同节点上的其它无关变更。
|
||||||
|
|
||||||
|
- **示例:**
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { dataSourceService } from "@tmagic/editor";
|
||||||
|
|
||||||
|
if (dataSourceService.canUndo("ds_123")) {
|
||||||
|
dataSourceService.undo("ds_123");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## redo
|
||||||
|
|
||||||
|
- **参数:**
|
||||||
|
- `{Id}` id 数据源id
|
||||||
|
|
||||||
|
- **返回:**
|
||||||
|
- {`DataSourceStepValue` | null} 重做的 step;栈不存在或已无可重做时返回 `null`
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
重做指定数据源的下一次变更。其它行为同 [undo](#undo)。
|
||||||
|
|
||||||
|
## canUndo
|
||||||
|
|
||||||
|
- **参数:**
|
||||||
|
- `{Id}` id 数据源id
|
||||||
|
|
||||||
|
- **返回:**
|
||||||
|
- `{boolean}`
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
当前指定数据源是否可撤销,等价于 `historyService.canUndoDataSource(id)`。
|
||||||
|
|
||||||
|
## canRedo
|
||||||
|
|
||||||
|
- **参数:**
|
||||||
|
- `{Id}` id 数据源id
|
||||||
|
|
||||||
|
- **返回:**
|
||||||
|
- `{boolean}`
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
当前指定数据源是否可重做,等价于 `historyService.canRedoDataSource(id)`。
|
||||||
|
|
||||||
## copyWithRelated
|
## copyWithRelated
|
||||||
|
|
||||||
- **参数:**
|
- **参数:**
|
||||||
|
|||||||
@ -1,5 +1,48 @@
|
|||||||
# editorService方法
|
# 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
|
## get
|
||||||
|
|
||||||
- **参数:**
|
- **参数:**
|
||||||
@ -358,6 +401,9 @@ editorService.highlight("text_123");
|
|||||||
- `{Object}` options 可选配置
|
- `{Object}` options 可选配置
|
||||||
- `{boolean}` doNotSelect 添加后是否不更新当前选中节点(默认 false,添加后会选中新增的节点)
|
- `{boolean}` doNotSelect 添加后是否不更新当前选中节点(默认 false,添加后会选中新增的节点)
|
||||||
- `{boolean}` doNotSwitchPage 添加后是否不切换当前页面(默认 false;新增页面 / 跨页新增时为 true 会跳过会引发页面切换的选中操作)
|
- `{boolean}` doNotSwitchPage 添加后是否不切换当前页面(默认 false;新增页面 / 跨页新增时为 true 会跳过会引发页面切换的选中操作)
|
||||||
|
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false)
|
||||||
|
- `{string}` historyDescription 见[历史记录相关 options](#历史记录相关-options)
|
||||||
|
- `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options)
|
||||||
|
|
||||||
- **返回:**
|
- **返回:**
|
||||||
- {Promise<`MNode` | `MNode`[]>} 新增的组件或组件集合
|
- {Promise<`MNode` | `MNode`[]>} 新增的组件或组件集合
|
||||||
@ -403,6 +449,9 @@ editorService.highlight("text_123");
|
|||||||
- `{Object}` options 可选配置
|
- `{Object}` options 可选配置
|
||||||
- `{boolean}` doNotSelect 删除后是否不更新当前选中节点(默认 false,删除后会选中父节点或首个页面)
|
- `{boolean}` doNotSelect 删除后是否不更新当前选中节点(默认 false,删除后会选中父节点或首个页面)
|
||||||
- `{boolean}` doNotSwitchPage 删除后是否不切换当前页面(默认 false;删除页面 / 页面片段时为 true 会跳过自动切换到首个剩余页面)
|
- `{boolean}` doNotSwitchPage 删除后是否不切换当前页面(默认 false;删除页面 / 页面片段时为 true 会跳过自动切换到首个剩余页面)
|
||||||
|
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false)
|
||||||
|
- `{string}` historyDescription 见[历史记录相关 options](#历史记录相关-options)
|
||||||
|
- `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options)
|
||||||
|
|
||||||
- **返回:**
|
- **返回:**
|
||||||
- `{Promise<void>}`
|
- `{Promise<void>}`
|
||||||
@ -454,7 +503,15 @@ editorService.highlight("text_123");
|
|||||||
- **参数:**
|
- **参数:**
|
||||||
- {`MNode` | `MNode`[]} config 新的节点或节点集合
|
- {`MNode` | `MNode`[]} config 新的节点或节点集合
|
||||||
- `{Object}` data 可选配置
|
- `{Object}` data 可选配置
|
||||||
- {`ChangeRecord`[]} changeRecords 变更记录
|
- {`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}
|
||||||
|
:::
|
||||||
|
|
||||||
- **返回:**
|
- **返回:**
|
||||||
- {Promise<`MNode` | `MNode`[]>} 新的节点或节点集合
|
- {Promise<`MNode` | `MNode`[]>} 新的节点或节点集合
|
||||||
@ -471,6 +528,16 @@ editorService.highlight("text_123");
|
|||||||
编辑器内部更新组件都是调用update来实现的,update除了更新操作外,还会记录历史堆,还会更新[代码块](../../guide/advanced/code-block.md)关系链。
|
编辑器内部更新组件都是调用update来实现的,update除了更新操作外,还会记录历史堆,还会更新[代码块](../../guide/advanced/code-block.md)关系链。
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
:::tip
|
||||||
|
**多节点场景必须使用 `changeRecordList`**:每个节点应保留自己独立的 records,不能把多个节点的
|
||||||
|
records 合并到同一个 `changeRecords` 数组里,否则 `doUpdate` / 依赖收集 / 历史回放都会按错误的
|
||||||
|
`propPath` 处理。
|
||||||
|
|
||||||
|
写入历史时,每个节点的 records 会单独保存到 `updatedItems[i].changeRecords`;撤销/重做时若有
|
||||||
|
records,则仅按 `propPath` 局部更新对应字段,避免整节点替换冲掉同节点上的其它无关变更;缺省
|
||||||
|
才退化为整节点替换(如内部 `sort` / `moveLayer` / 拖动等纯快照场景)。
|
||||||
|
:::
|
||||||
|
|
||||||
## sort
|
## sort
|
||||||
|
|
||||||
- **[扩展支持](../../guide/editor-expand#行为扩展):** 是
|
- **[扩展支持](../../guide/editor-expand#行为扩展):** 是
|
||||||
@ -481,6 +548,8 @@ editorService.highlight("text_123");
|
|||||||
- `{Object}` options 可选配置
|
- `{Object}` options 可选配置
|
||||||
- `{boolean}` doNotSelect 排序后是否不更新当前选中节点(默认 false)
|
- `{boolean}` doNotSelect 排序后是否不更新当前选中节点(默认 false)
|
||||||
- `{boolean}` doNotSwitchPage 排序后是否不切换当前页面(排序只发生在同一父节点内,方法内为空操作;保留以与其它 DSL 操作 API 一致)
|
- `{boolean}` doNotSwitchPage 排序后是否不切换当前页面(排序只发生在同一父节点内,方法内为空操作;保留以与其它 DSL 操作 API 一致)
|
||||||
|
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false)
|
||||||
|
- `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options)
|
||||||
|
|
||||||
- **返回:**
|
- **返回:**
|
||||||
- `{Promise<void>}`
|
- `{Promise<void>}`
|
||||||
@ -548,6 +617,9 @@ editorService.highlight("text_123");
|
|||||||
- `{Object}` options 可选配置
|
- `{Object}` options 可选配置
|
||||||
- `{boolean}` doNotSelect 粘贴后是否不更新当前选中节点(默认 false)
|
- `{boolean}` doNotSelect 粘贴后是否不更新当前选中节点(默认 false)
|
||||||
- `{boolean}` doNotSwitchPage 粘贴后是否不切换当前页面(默认 false;跨页粘贴时为 true 会跳过页面切换)
|
- `{boolean}` doNotSwitchPage 粘贴后是否不切换当前页面(默认 false;跨页粘贴时为 true 会跳过页面切换)
|
||||||
|
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false)
|
||||||
|
- `{string}` historyDescription 见[历史记录相关 options](#历史记录相关-options)
|
||||||
|
- `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options)
|
||||||
|
|
||||||
- **返回:**
|
- **返回:**
|
||||||
- {Promise<`MNode` | `MNode`[]>} 添加后的组件节点配置
|
- {Promise<`MNode` | `MNode`[]>} 添加后的组件节点配置
|
||||||
@ -585,6 +657,9 @@ editorService.highlight("text_123");
|
|||||||
- `{Object}` options 可选配置
|
- `{Object}` options 可选配置
|
||||||
- `{boolean}` doNotSelect 居中后是否不更新当前选中节点(默认 false)
|
- `{boolean}` doNotSelect 居中后是否不更新当前选中节点(默认 false)
|
||||||
- `{boolean}` doNotSwitchPage 居中后是否不切换当前页面(居中只更新节点 style,方法内为空操作;保留以与其它 DSL 操作 API 一致)
|
- `{boolean}` doNotSwitchPage 居中后是否不切换当前页面(居中只更新节点 style,方法内为空操作;保留以与其它 DSL 操作 API 一致)
|
||||||
|
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false)
|
||||||
|
- `{string}` historyDescription 见[历史记录相关 options](#历史记录相关-options)
|
||||||
|
- `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options)
|
||||||
|
|
||||||
- **返回:**
|
- **返回:**
|
||||||
- {Promise<`MNode` | `MNode`[]>}
|
- {Promise<`MNode` | `MNode`[]>}
|
||||||
@ -605,6 +680,10 @@ alignCenter可以支持一次水平居中多个组件,alignCenter是通过调
|
|||||||
|
|
||||||
- **参数:**
|
- **参数:**
|
||||||
- `{number | 'top' | 'bottom'}` offset
|
- `{number | 'top' | 'bottom'}` offset
|
||||||
|
- `{Object}` options 可选配置
|
||||||
|
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false)
|
||||||
|
- `{string}` historyDescription 见[历史记录相关 options](#历史记录相关-options)
|
||||||
|
- `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options)
|
||||||
|
|
||||||
- **返回:**
|
- **返回:**
|
||||||
- `{Promise<void>}`
|
- `{Promise<void>}`
|
||||||
@ -625,6 +704,9 @@ alignCenter可以支持一次水平居中多个组件,alignCenter是通过调
|
|||||||
- `{Object}` options 可选配置
|
- `{Object}` options 可选配置
|
||||||
- `{boolean}` doNotSelect 移动后是否不更新当前选中节点(默认 false)
|
- `{boolean}` doNotSelect 移动后是否不更新当前选中节点(默认 false)
|
||||||
- `{boolean}` doNotSwitchPage 移动后是否不切换当前页面(默认 false;目标容器位于其它页面时为 true 会跳过自动选中以避免页面切换)
|
- `{boolean}` doNotSwitchPage 移动后是否不切换当前页面(默认 false;目标容器位于其它页面时为 true 会跳过自动选中以避免页面切换)
|
||||||
|
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false)
|
||||||
|
- `{string}` historyDescription 见[历史记录相关 options](#历史记录相关-options)
|
||||||
|
- `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options)
|
||||||
|
|
||||||
- **返回:**
|
- **返回:**
|
||||||
- Promise<`MNode` | undefined>
|
- Promise<`MNode` | undefined>
|
||||||
@ -639,6 +721,10 @@ alignCenter可以支持一次水平居中多个组件,alignCenter是通过调
|
|||||||
- {`MNode` | `MNode`[]} config 需要拖拽的节点或节点集合
|
- {`MNode` | `MNode`[]} config 需要拖拽的节点或节点集合
|
||||||
- {`MContainer`} targetParent 目标父容器
|
- {`MContainer`} targetParent 目标父容器
|
||||||
- `{number}` targetIndex 目标位置索引
|
- `{number}` targetIndex 目标位置索引
|
||||||
|
- `{Object}` options 可选配置
|
||||||
|
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false)
|
||||||
|
- `{string}` historyDescription 见[历史记录相关 options](#历史记录相关-options)
|
||||||
|
- `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options)
|
||||||
|
|
||||||
- **返回:**
|
- **返回:**
|
||||||
- `{Promise<void>}`
|
- `{Promise<void>}`
|
||||||
@ -647,6 +733,115 @@ alignCenter可以支持一次水平居中多个组件,alignCenter是通过调
|
|||||||
|
|
||||||
将节点(支持多选)拖拽到目标容器的指定位置,会自动处理跨容器布局切换并记录历史
|
将节点(支持多选)拖拽到目标容器的指定位置,会自动处理跨容器布局切换并记录历史
|
||||||
|
|
||||||
|
## addAndGetHistoryId
|
||||||
|
|
||||||
|
- **参数:** 同 [add](#add)
|
||||||
|
|
||||||
|
- **返回:**
|
||||||
|
- {Promise<`string` | null>} 本次写入历史记录的 [uuid](#历史记录-uuid-与-andgethistoryid);未写入历史时返回 `null`
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
与 [add](#add) 行为完全一致,仅把返回值换成本次写入历史记录的 `uuid`,见[历史记录 uuid 与 \*AndGetHistoryId](#历史记录-uuid-与-andgethistoryid)
|
||||||
|
|
||||||
|
- **示例:**
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { editorService } from "@tmagic/editor";
|
||||||
|
|
||||||
|
const historyId = await editorService.addAndGetHistoryId(
|
||||||
|
{ type: "text", text: "hello" },
|
||||||
|
parent,
|
||||||
|
{ historySource: "api" },
|
||||||
|
);
|
||||||
|
console.log(historyId); // 本次新增对应的历史记录 uuid,或 null
|
||||||
|
```
|
||||||
|
|
||||||
|
## removeAndGetHistoryId
|
||||||
|
|
||||||
|
- **参数:** 同 [remove](#remove)
|
||||||
|
|
||||||
|
- **返回:**
|
||||||
|
- {Promise<`string` | null>} 本次写入历史记录的 [uuid](#历史记录-uuid-与-andgethistoryid);未写入历史时返回 `null`
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
与 [remove](#remove) 行为完全一致,仅把返回值换成本次写入历史记录的 `uuid`
|
||||||
|
|
||||||
|
## updateAndGetHistoryId
|
||||||
|
|
||||||
|
- **参数:** 同 [update](#update)
|
||||||
|
|
||||||
|
- **返回:**
|
||||||
|
- {Promise<`string` | null>} 本次写入历史记录的 [uuid](#历史记录-uuid-与-andgethistoryid);未写入历史时返回 `null`
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
与 [update](#update) 行为完全一致,仅把返回值换成本次写入历史记录的 `uuid`
|
||||||
|
|
||||||
|
## moveLayerAndGetHistoryId
|
||||||
|
|
||||||
|
- **参数:** 同 [moveLayer](#movelayer)
|
||||||
|
|
||||||
|
- **返回:**
|
||||||
|
- {Promise<`string` | null>} 本次写入历史记录的 [uuid](#历史记录-uuid-与-andgethistoryid);未写入历史时返回 `null`
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
与 [moveLayer](#movelayer) 行为完全一致,仅把返回值换成本次写入历史记录的 `uuid`
|
||||||
|
|
||||||
|
## moveToContainerAndGetHistoryId
|
||||||
|
|
||||||
|
- **参数:** 同 [moveToContainer](#movetocontainer)
|
||||||
|
|
||||||
|
- **返回:**
|
||||||
|
- {Promise<`string` | null>} 本次写入历史记录的 [uuid](#历史记录-uuid-与-andgethistoryid);未写入历史时返回 `null`
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
与 [moveToContainer](#movetocontainer) 行为完全一致,仅把返回值换成本次写入历史记录的 `uuid`
|
||||||
|
|
||||||
|
## dragToAndGetHistoryId
|
||||||
|
|
||||||
|
- **参数:** 同 [dragTo](#dragto)
|
||||||
|
|
||||||
|
- **返回:**
|
||||||
|
- {Promise<`string` | null>} 本次写入历史记录的 [uuid](#历史记录-uuid-与-andgethistoryid);未写入历史时返回 `null`
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
与 [dragTo](#dragto) 行为完全一致,仅把返回值换成本次写入历史记录的 `uuid`
|
||||||
|
|
||||||
|
## revertPageStepById
|
||||||
|
|
||||||
|
- **参数:**
|
||||||
|
- `{string}` uuid 目标历史记录的 [uuid](#历史记录-uuid-与-andgethistoryid)(通常由 `*AndGetHistoryId` 方法返回)
|
||||||
|
|
||||||
|
- **返回:**
|
||||||
|
- {Promise<`StepValue` | null>} 反向应用后产生的新 step;找不到对应 uuid / 该步未应用 / 反向失败时返回 `null`
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
通过历史记录 uuid「回滚」当前页面的某条历史步骤(类 git revert 语义):不移动游标、不丢弃任何步骤,而是把目标 step 的修改**反向应用为一条全新的步骤**压入栈顶。语义与按 index 回滚一致,仅入参从 index 改为 uuid,更适合业务侧持有引用后再回滚。
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
`opType: 'update'` 的步骤必须携带 `changeRecords` 才支持回滚(否则只能整节点替换,会冲掉后续无关变更);未应用(已被撤销)的步骤无法回滚。
|
||||||
|
:::
|
||||||
|
|
||||||
|
- **示例:**
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { editorService } from "@tmagic/editor";
|
||||||
|
|
||||||
|
// 执行操作时拿到本次历史记录 uuid
|
||||||
|
const historyId = await editorService.addAndGetHistoryId({ type: "text", text: "hello" });
|
||||||
|
|
||||||
|
// 之后任意时机按 uuid 回滚该步骤
|
||||||
|
if (historyId) {
|
||||||
|
await editorService.revertPageStepById(historyId);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## undo
|
## undo
|
||||||
|
|
||||||
- **[扩展支持](../../guide/editor-expand#行为扩展):** 是
|
- **[扩展支持](../../guide/editor-expand#行为扩展):** 是
|
||||||
@ -659,6 +854,8 @@ alignCenter可以支持一次水平居中多个组件,alignCenter是通过调
|
|||||||
|
|
||||||
<<< @/../packages/editor/src/type.ts#HistoryOpType{ts}
|
<<< @/../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#Id{ts}
|
||||||
:::
|
:::
|
||||||
|
|
||||||
@ -673,6 +870,16 @@ alignCenter可以支持一次水平居中多个组件,alignCenter是通过调
|
|||||||
- **返回:**
|
- **返回:**
|
||||||
- {Promise<`StepValue` | null>}
|
- {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}
|
||||||
|
:::
|
||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|
||||||
恢复到下一步
|
恢复到下一步
|
||||||
@ -684,6 +891,10 @@ alignCenter可以支持一次水平居中多个组件,alignCenter是通过调
|
|||||||
- **参数:**
|
- **参数:**
|
||||||
- `{number}` left
|
- `{number}` left
|
||||||
- `{number}` top
|
- `{number}` top
|
||||||
|
- `{Object}` options 可选配置
|
||||||
|
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false)
|
||||||
|
- `{string}` historyDescription 见[历史记录相关 options](#历史记录相关-options)
|
||||||
|
- `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options)
|
||||||
|
|
||||||
- **返回:**
|
- **返回:**
|
||||||
- `{Promise<void>}`
|
- `{Promise<void>}`
|
||||||
@ -712,45 +923,11 @@ alignCenter可以支持一次水平居中多个组件,alignCenter是通过调
|
|||||||
|
|
||||||
移除所有事件监听,清空state,移除所有插件
|
移除所有事件监听,清空state,移除所有插件
|
||||||
|
|
||||||
## use
|
|
||||||
|
|
||||||
使用中间件的方式扩展方法,上述方法中标记有`扩展支持: 是`的方法都支持使用use扩展
|
|
||||||
|
|
||||||
- **示例:**
|
|
||||||
|
|
||||||
```js
|
|
||||||
import { editorService, getAddParent } from "@tmagic/editor";
|
|
||||||
import { ElMessageBox } from "element-plus";
|
|
||||||
|
|
||||||
editorService.use({
|
|
||||||
// 添加是否删除节点确认提示
|
|
||||||
async remove(node, next) {
|
|
||||||
await ElMessageBox.confirm("是否删除", "提示", {
|
|
||||||
confirmButtonText: "确定",
|
|
||||||
cancelButtonText: "取消",
|
|
||||||
type: "warning",
|
|
||||||
});
|
|
||||||
|
|
||||||
next();
|
|
||||||
},
|
|
||||||
|
|
||||||
add(node, next) {
|
|
||||||
// text组件只能添加到container中
|
|
||||||
const parentNode = getAddParent(node);
|
|
||||||
if (node.type === "text" && parentNode?.type !== "container") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
next();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## usePlugin
|
## usePlugin
|
||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|
||||||
相对于[use](#use), usePlugin支持更加灵活更加细致的扩展, 上述方法中标记有`扩展支持: 是`的方法都支持使用usePlugin扩展
|
usePlugin支持灵活细致的扩展, 上述方法中标记有`扩展支持: 是`的方法都支持使用usePlugin扩展
|
||||||
|
|
||||||
每个支持扩展的方法都支持定制before、after两个hook来干预原有方法的行为,before可以用于修改传入参数,after可以用于修改返回的值
|
每个支持扩展的方法都支持定制before、after两个hook来干预原有方法的行为,before可以用于修改传入参数,after可以用于修改返回的值
|
||||||
|
|
||||||
|
|||||||
@ -21,6 +21,8 @@
|
|||||||
|
|
||||||
<<< @/../packages/editor/src/type.ts#HistoryOpType{ts}
|
<<< @/../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#Id{ts}
|
||||||
|
|
||||||
<<< @/../packages/schema/src/index.ts#MNode{ts}
|
<<< @/../packages/schema/src/index.ts#MNode{ts}
|
||||||
@ -29,3 +31,78 @@
|
|||||||
:::tip
|
:::tip
|
||||||
当游标处于历史栈边界(已经无法继续撤销或重做)时,`UndoRedo.undo()` / `redo()` 返回 `null`,对应 `change` 回调收到的 `state` 为 `null`
|
当游标处于历史栈边界(已经无法继续撤销或重做)时,`UndoRedo.undo()` / `redo()` 返回 `null`,对应 `change` 回调收到的 `state` 为 `null`
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
## code-block-history-change
|
||||||
|
|
||||||
|
- **详情:** 代码块历史记录发生变化(`pushCodeBlock` / `undoCodeBlock` / `redoCodeBlock` 成功时触发)
|
||||||
|
|
||||||
|
- **事件回调函数:** `(codeBlockId: Id, step: CodeBlockStepValue) => void`
|
||||||
|
|
||||||
|
::: 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}
|
||||||
|
:::
|
||||||
|
|
||||||
|
:::tip
|
||||||
|
- 新增触发的 step 中 `oldContent` 为 `null`
|
||||||
|
- 删除触发的 step 中 `newContent` 为 `null`
|
||||||
|
- `undo` / `redo` 返回 `null`(边界状态)时不会触发该事件
|
||||||
|
:::
|
||||||
|
|
||||||
|
## data-source-history-change
|
||||||
|
|
||||||
|
- **详情:** 数据源历史记录发生变化(`pushDataSource` / `undoDataSource` / `redoDataSource` 成功时触发)
|
||||||
|
|
||||||
|
- **事件回调函数:** `(dataSourceId: Id, step: DataSourceStepValue) => void`
|
||||||
|
|
||||||
|
::: details 查看 DataSourceStepValue 及关联类型定义
|
||||||
|
<<< @/../packages/editor/src/type.ts#DataSourceStepValue{ts}
|
||||||
|
|
||||||
|
<<< @/../packages/editor/src/type.ts#HistoryOpSource{ts}
|
||||||
|
|
||||||
|
<<< @/../packages/schema/src/index.ts#Id{ts}
|
||||||
|
:::
|
||||||
|
|
||||||
|
:::tip
|
||||||
|
- 新增触发的 step 中 `oldSchema` 为 `null`
|
||||||
|
- 删除触发的 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}
|
||||||
|
:::
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|
||||||
重置记录
|
重置全部历史记录(包括页面节点栈、代码块栈、数据源栈),并重置当前页面 id / canRedo / canUndo
|
||||||
|
|
||||||
## resetPage
|
## resetPage
|
||||||
|
|
||||||
@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|
||||||
重置历史记录全部内部状态(清空 pageId、pageSteps、canRedo、canUndo)
|
重置历史记录全部内部状态(清空 pageId、pageSteps、canRedo、canUndo、codeBlockState、dataSourceState)
|
||||||
|
|
||||||
## changePage
|
## changePage
|
||||||
|
|
||||||
@ -43,9 +43,13 @@
|
|||||||
|
|
||||||
<<< @/../packages/editor/src/type.ts#HistoryOpType{ts}
|
<<< @/../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#Id{ts}
|
||||||
|
|
||||||
<<< @/../packages/schema/src/index.ts#MNode{ts}
|
<<< @/../packages/schema/src/index.ts#MNode{ts}
|
||||||
|
|
||||||
|
<<< @/../packages/form-schema/src/base.ts#ChangeRecord{ts}
|
||||||
:::
|
:::
|
||||||
|
|
||||||
- **返回:**
|
- **返回:**
|
||||||
@ -55,6 +59,20 @@
|
|||||||
|
|
||||||
添加一条历史记录
|
添加一条历史记录
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
`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
|
## undo
|
||||||
|
|
||||||
- **返回:**
|
- **返回:**
|
||||||
@ -62,7 +80,8 @@
|
|||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|
||||||
撤销当前操作
|
撤销当前操作。`opType: 'update'` 时,若 `updatedItems[i].changeRecords` 存在,会按
|
||||||
|
`propPath` 从 `oldNode` 取值做局部回滚;否则用 `oldNode` 整节点替换。
|
||||||
|
|
||||||
## redo
|
## redo
|
||||||
|
|
||||||
@ -71,7 +90,291 @@
|
|||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|
||||||
恢复到下一步
|
恢复到下一步。`opType: 'update'` 时,若 `updatedItems[i].changeRecords` 存在,会按
|
||||||
|
`propPath` 从 `newNode` 取值做局部重做;否则用 `newNode` 整节点替换。
|
||||||
|
|
||||||
|
## pushCodeBlock
|
||||||
|
|
||||||
|
- **参数:**
|
||||||
|
- `{Id} codeBlockId` 代码块 id
|
||||||
|
- `{Object} payload`
|
||||||
|
- `{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}
|
||||||
|
:::
|
||||||
|
|
||||||
|
- **返回:**
|
||||||
|
- `{CodeBlockStepValue | null}` 入栈失败(未传 id)时返回 `null`
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
推入一条代码块变更记录。与页面 / 节点完全无关,按 `codeBlockId` 维度独立一份 `UndoRedo` 栈,
|
||||||
|
栈实例存放在 `historyService.state.codeBlockState[codeBlockId]`。
|
||||||
|
|
||||||
|
入栈成功后会触发 `code-block-history-change` 事件。
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
`codeBlockService.setCodeDslByIdSync` 与 `codeBlockService.deleteCodeDslByIds` 内部已经
|
||||||
|
自动调用本方法,业务代码通常无需手动调用。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## undoCodeBlock
|
||||||
|
|
||||||
|
- **参数:**
|
||||||
|
- `{Id} codeBlockId`
|
||||||
|
|
||||||
|
- **返回:**
|
||||||
|
- `{CodeBlockStepValue | null}` 栈不存在或已无可撤销记录时返回 `null`
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
撤销指定代码块的最近一次变更。成功时会触发 `code-block-history-change` 事件。
|
||||||
|
拿到 step 后由调用方根据 `step.oldContent` 写回 `codeBlockService`(本方法不会自动回放)。
|
||||||
|
|
||||||
|
## redoCodeBlock
|
||||||
|
|
||||||
|
- **参数:**
|
||||||
|
- `{Id} codeBlockId`
|
||||||
|
|
||||||
|
- **返回:**
|
||||||
|
- `{CodeBlockStepValue | null}` 栈不存在或已无可重做记录时返回 `null`
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
重做指定代码块的下一次变更。成功时会触发 `code-block-history-change` 事件。
|
||||||
|
|
||||||
|
## canUndoCodeBlock
|
||||||
|
|
||||||
|
- **参数:**
|
||||||
|
- `{Id} codeBlockId`
|
||||||
|
|
||||||
|
- **返回:**
|
||||||
|
- `{boolean}`
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
指定代码块当前是否可撤销。栈不存在时返回 `false`。
|
||||||
|
|
||||||
|
## canRedoCodeBlock
|
||||||
|
|
||||||
|
- **参数:**
|
||||||
|
- `{Id} codeBlockId`
|
||||||
|
|
||||||
|
- **返回:**
|
||||||
|
- `{boolean}`
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
指定代码块当前是否可重做。栈不存在时返回 `false`。
|
||||||
|
|
||||||
|
## pushDataSource
|
||||||
|
|
||||||
|
- **参数:**
|
||||||
|
- `{Id} dataSourceId` 数据源 id
|
||||||
|
- `{Object} payload`
|
||||||
|
- `{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}
|
||||||
|
:::
|
||||||
|
|
||||||
|
- **返回:**
|
||||||
|
- `{DataSourceStepValue | null}` 入栈失败(未传 id)时返回 `null`
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
推入一条数据源变更记录。与页面 / 节点完全无关,按 `dataSourceId` 维度独立一份 `UndoRedo` 栈,
|
||||||
|
栈实例存放在 `historyService.state.dataSourceState[dataSourceId]`。
|
||||||
|
|
||||||
|
入栈成功后会触发 `data-source-history-change` 事件。
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
`dataSourceService.add` / `update` / `remove` 内部已经自动调用本方法,业务代码通常无需手动调用。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## undoDataSource
|
||||||
|
|
||||||
|
- **参数:**
|
||||||
|
- `{Id} dataSourceId`
|
||||||
|
|
||||||
|
- **返回:**
|
||||||
|
- `{DataSourceStepValue | null}`
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
撤销指定数据源的最近一次变更。成功时会触发 `data-source-history-change` 事件。
|
||||||
|
拿到 step 后由调用方根据 `step.oldSchema` 写回 `dataSourceService`(本方法不会自动回放)。
|
||||||
|
|
||||||
|
## redoDataSource
|
||||||
|
|
||||||
|
- **参数:**
|
||||||
|
- `{Id} dataSourceId`
|
||||||
|
|
||||||
|
- **返回:**
|
||||||
|
- `{DataSourceStepValue | null}`
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
重做指定数据源的下一次变更。成功时会触发 `data-source-history-change` 事件。
|
||||||
|
|
||||||
|
## canUndoDataSource
|
||||||
|
|
||||||
|
- **参数:**
|
||||||
|
- `{Id} dataSourceId`
|
||||||
|
|
||||||
|
- **返回:**
|
||||||
|
- `{boolean}`
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
指定数据源当前是否可撤销。栈不存在时返回 `false`。
|
||||||
|
|
||||||
|
## canRedoDataSource
|
||||||
|
|
||||||
|
- **参数:**
|
||||||
|
- `{Id} dataSourceId`
|
||||||
|
|
||||||
|
- **返回:**
|
||||||
|
- `{boolean}`
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
指定数据源当前是否可重做。栈不存在时返回 `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
|
## destroy
|
||||||
|
|
||||||
|
|||||||
@ -260,7 +260,7 @@ icon使用的是[element-plus icon](https://element-plus.org/zh-CN/component/ico
|
|||||||
|
|
||||||
顶部工具栏
|
顶部工具栏
|
||||||
|
|
||||||
系统提供了几个常用功能: `'/' | 'delete' | 'undo' | 'redo' | 'zoom-in' | 'zoom-out' | 'zoom' | 'guides' | 'rule' | 'scale-to-original' | 'scale-to-fit'`
|
系统提供了几个常用功能: `'/' | 'delete' | 'undo' | 'redo' | 'zoom-in' | 'zoom-out' | 'zoom' | 'guides' | 'rule' | 'scale-to-original' | 'scale-to-fit' | 'history-list'`
|
||||||
|
|
||||||
'/': 分隔符
|
'/': 分隔符
|
||||||
|
|
||||||
@ -284,6 +284,8 @@ icon使用的是[element-plus icon](https://element-plus.org/zh-CN/component/ico
|
|||||||
|
|
||||||
'scale-to-fit': 缩放以适应
|
'scale-to-fit': 缩放以适应
|
||||||
|
|
||||||
|
'history-list': 历史记录面板(按 页面 / 数据源 / 代码块 三个 tab 展示操作历史,相邻同目标修改自动合并,支持点击跳转、回到初始状态、单步回滚及差异对比,详见[历史记录面板](/guide/advanced/history-list.md))
|
||||||
|
|
||||||
- **默认值:**
|
- **默认值:**
|
||||||
|
|
||||||
```js
|
```js
|
||||||
@ -1218,6 +1220,28 @@ const guidesOptions = {
|
|||||||
</template>
|
</template>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## disabledFlashTip
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
禁用「非点击画布选中组件时的高亮闪烁提示」。
|
||||||
|
|
||||||
|
当组件不是通过点击画布选中(如从组件树、面包屑等外部方式选中)时,编辑器会在画布上对选中区域做一次高亮闪烁,帮助用户快速定位组件在画布中的位置。设置为 `true` 可关闭该提示。
|
||||||
|
|
||||||
|
注:选中页面(`magic-ui-page`)时不会触发闪烁。
|
||||||
|
|
||||||
|
- **默认值:** `false`
|
||||||
|
|
||||||
|
- **类型:** `boolean`
|
||||||
|
|
||||||
|
- **示例:**
|
||||||
|
|
||||||
|
```html
|
||||||
|
<template>
|
||||||
|
<m-editor :disabled-flash-tip="true"></m-editor>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
## disabledStageOverlay
|
## disabledStageOverlay
|
||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
@ -1506,6 +1530,55 @@ 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
|
## pageBarSortOptions
|
||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|||||||
@ -254,15 +254,11 @@
|
|||||||
|
|
||||||
销毁propsService
|
销毁propsService
|
||||||
|
|
||||||
## use
|
|
||||||
|
|
||||||
使用中间件的方式扩展方法,上述方法中标记有`扩展支持: 是`的方法都支持使用use扩展
|
|
||||||
|
|
||||||
## usePlugin
|
## usePlugin
|
||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|
||||||
相对于[use](#use), usePlugin支持更加灵活更加细致的扩展, 上述方法中标记有`扩展支持: 是`的方法都支持使用usePlugin扩展
|
usePlugin支持灵活细致的扩展, 上述方法中标记有`扩展支持: 是`的方法都支持使用usePlugin扩展
|
||||||
|
|
||||||
每个支持扩展的方法都支持定制before、after两个hook来干预原有方法的行为,before可以用于修改传入参数,after可以用于修改返回的值
|
每个支持扩展的方法都支持定制before、after两个hook来干预原有方法的行为,before可以用于修改传入参数,after可以用于修改返回的值
|
||||||
|
|
||||||
|
|||||||
@ -231,28 +231,11 @@ import { storageService } from '@tmagic/editor';
|
|||||||
storageService.destroy();
|
storageService.destroy();
|
||||||
```
|
```
|
||||||
|
|
||||||
## use
|
|
||||||
|
|
||||||
使用中间件的方式扩展方法,上述方法中标记有`扩展支持: 是`的方法都支持使用use扩展
|
|
||||||
|
|
||||||
- **示例:**
|
|
||||||
|
|
||||||
```js
|
|
||||||
import { storageService } from '@tmagic/editor';
|
|
||||||
|
|
||||||
storageService.use({
|
|
||||||
getItem(key, options, next) {
|
|
||||||
console.log('获取存储项:', key);
|
|
||||||
return next();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## usePlugin
|
## usePlugin
|
||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|
||||||
相对于[use](#use), usePlugin支持更加灵活更加细致的扩展, 上述方法中标记有`扩展支持: 是`的方法都支持使用usePlugin扩展
|
usePlugin支持灵活细致的扩展, 上述方法中标记有`扩展支持: 是`的方法都支持使用usePlugin扩展
|
||||||
|
|
||||||
每个支持扩展的方法都支持定制before、after两个hook来干预原有方法的行为,before可以用于修改传入参数,after可以用于修改返回的值
|
每个支持扩展的方法都支持定制before、after两个hook来干预原有方法的行为,before可以用于修改传入参数,after可以用于修改返回的值
|
||||||
|
|
||||||
|
|||||||
@ -179,29 +179,11 @@ import { uiService } from '@tmagic/editor';
|
|||||||
uiService.destroy();
|
uiService.destroy();
|
||||||
```
|
```
|
||||||
|
|
||||||
## use
|
|
||||||
|
|
||||||
使用中间件的方式扩展方法,上述方法中标记有`扩展支持: 是`的方法都支持使用use扩展
|
|
||||||
|
|
||||||
- **示例:**
|
|
||||||
|
|
||||||
```js
|
|
||||||
import { uiService } from '@tmagic/editor';
|
|
||||||
|
|
||||||
uiService.use({
|
|
||||||
async zoom(value, next) {
|
|
||||||
console.log('缩放前:', uiService.get('zoom'));
|
|
||||||
await next();
|
|
||||||
console.log('缩放后:', uiService.get('zoom'));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## usePlugin
|
## usePlugin
|
||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|
||||||
相对于[use](#use), usePlugin支持更加灵活更加细致的扩展, 上述方法中标记有`扩展支持: 是`的方法都支持使用usePlugin扩展
|
usePlugin支持灵活细致的扩展, 上述方法中标记有`扩展支持: 是`的方法都支持使用usePlugin扩展
|
||||||
|
|
||||||
每个支持扩展的方法都支持定制before、after两个hook来干预原有方法的行为,before可以用于修改传入参数,after可以用于修改返回的值
|
每个支持扩展的方法都支持定制before、after两个hook来干预原有方法的行为,before可以用于修改传入参数,after可以用于修改返回的值
|
||||||
|
|
||||||
|
|||||||
@ -85,6 +85,44 @@
|
|||||||
|
|
||||||
- **类型:** `boolean`
|
- **类型:** `boolean`
|
||||||
|
|
||||||
|
## showDiff
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
自定义“是否展示对比内容”的判断函数(仅在 `isCompare === true` 时生效)。
|
||||||
|
|
||||||
|
- 不传:使用默认逻辑 `!isEqual(curValue, lastValue)`(基于 lodash `isEqual`);
|
||||||
|
- 传函数:完全以函数返回值为准,返回 `true` 才展示前后两份对比内容。
|
||||||
|
|
||||||
|
该 prop 通过 `formState` 透传到所有层级的 Container 中,调用方只需在 MForm 这一层传一次即可对整棵表单生效。
|
||||||
|
|
||||||
|
典型场景:某些字段语义上相等但结构不同(例如 `code-select` 字段中 `''` 与 `{ hookType: 'code', hookData: [] }` 应视为相等),调用方在此处显式声明,避免被 `isEqual` 误判为差异。
|
||||||
|
|
||||||
|
- **类型:** `(data: { curValue: any; lastValue: any; config: FormItemConfig }) => boolean`
|
||||||
|
|
||||||
|
- **示例:**
|
||||||
|
|
||||||
|
```html
|
||||||
|
<template>
|
||||||
|
<m-form :config="config" :is-compare="true" :last-values="lastValues" :show-diff="showDiff"></m-form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { isEqual } from 'lodash-es';
|
||||||
|
|
||||||
|
const showDiff = ({ curValue, lastValue, config }) => {
|
||||||
|
if (config?.type === 'code-select') {
|
||||||
|
// 业务侧自定义:双方都是“空形态”视为相等,不展示对比
|
||||||
|
const isEmpty = (v) =>
|
||||||
|
v === '' || v === undefined || v === null ||
|
||||||
|
(typeof v === 'object' && v.hookType === 'code' && Array.isArray(v.hookData) && v.hookData.length === 0);
|
||||||
|
if (isEmpty(curValue) && isEmpty(lastValue)) return false;
|
||||||
|
}
|
||||||
|
return !isEqual(curValue, lastValue);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
## parentValues
|
## parentValues
|
||||||
|
|
||||||
- **详情:** 父级表单值
|
- **详情:** 父级表单值
|
||||||
|
|||||||
@ -18,7 +18,7 @@ function submitForm(options: SubmitFormOptions): Promise<any>;
|
|||||||
|
|
||||||
## 参数
|
## 参数
|
||||||
|
|
||||||
`options` 与 `MForm` 组件的 props 基本对齐,额外提供了 `native`、`appContext`、`timeout` 三个参数。
|
`options` 与 `MForm` 组件的 props 基本对齐,额外提供了 `native`、`returnChangeRecords`、`appContext`、`timeout` 等参数。
|
||||||
|
|
||||||
| 名称 | 类型 | 默认值 | 说明 |
|
| 名称 | 类型 | 默认值 | 说明 |
|
||||||
| ---------------------- | ------------------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------- |
|
| ---------------------- | ------------------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------- |
|
||||||
@ -39,17 +39,22 @@ function submitForm(options: SubmitFormOptions): Promise<any>;
|
|||||||
| `preventSubmitDefault` | `boolean` | — | 是否阻止表单原生 submit |
|
| `preventSubmitDefault` | `boolean` | — | 是否阻止表单原生 submit |
|
||||||
| `extendState` | `(state: FormState) => Record<string, any> \| Promise<Record<string, any>>` | — | 扩展 `formState` |
|
| `extendState` | `(state: FormState) => Record<string, any> \| Promise<Record<string, any>>` | — | 扩展 `formState` |
|
||||||
| `native` | `boolean` | `false` | 透传给 `Form.submitForm`。`true` 时返回内部响应式 `values`,否则返回 `cloneDeep(toRaw(values))` |
|
| `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` 获取 |
|
| `appContext` | `AppContext \| null` | `null` | 父级 Vue 应用上下文。需要继承全局组件、指令、provide 等时传入,常通过 `app._context` 或 `getCurrentInstance()?.appContext` 获取 |
|
||||||
| `timeout` | `number` | `10000` | 等待表单初始化的最长时间(毫秒)。超时将以错误 reject。设为 `<= 0` 时关闭超时兜底 |
|
| `timeout` | `number` | `10000` | 等待表单初始化的最长时间(毫秒)。超时将以错误 reject。设为 `<= 0` 时关闭超时兜底 |
|
||||||
|
|
||||||
## 返回值
|
## 返回值
|
||||||
|
|
||||||
- `校验通过` — `Promise<any>` resolve 当前表单值(`native` 决定是否克隆)
|
- `校验通过` — `Promise<any>` resolve 当前表单值(`native` 决定是否克隆);当 `returnChangeRecords` 为 `true` 时,resolve `{ values, changeRecords }`
|
||||||
- `校验失败` — `Promise<any>` reject 一个 `Error`,`message` 中包含逐条字段错误信息(格式 `${text} -> ${message}`,多条用 `<br>` 分隔)
|
- `校验失败` — `Promise<any>` reject 一个 `Error`,`message` 中包含逐条字段错误信息(格式 `${text} -> ${message}`,多条用 `<br>` 分隔)
|
||||||
- `初始化超时` — `Promise<any>` reject `Error('submitForm timeout after ${timeout}ms: form is not initialized.')`
|
- `初始化超时` — `Promise<any>` reject `Error('submitForm timeout after ${timeout}ms: form is not initialized.')`
|
||||||
|
|
||||||
无论成功或失败,函数都会在最后自动 `unmount` 内部 app 并移除挂载用的 DOM 容器,无需调用方手动清理。
|
无论成功或失败,函数都会在最后自动 `unmount` 内部 app 并移除挂载用的 DOM 容器,无需调用方手动清理。
|
||||||
|
|
||||||
|
::: tip 关于 changeRecords
|
||||||
|
`changeRecords` 记录的是表单挂载后发生的字段变更(由各字段的 `change` 事件累积而来)。在 `submitForm` 这种命令式、无用户交互的场景下,通常为空数组;只有在 `extendState` 或字段联动等逻辑中触发了变更时才会有内容。`MForm` 内部的 `submitForm` 在校验通过后会清空变更记录,因此本函数会在调用前先对其做快照再返回。
|
||||||
|
:::
|
||||||
|
|
||||||
## 基础用法
|
## 基础用法
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
@ -73,6 +78,23 @@ 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` 把父级应用上下文带过去:
|
`MForm` 内部使用 `@tmagic/design` 的组件(背后可能是 `element-plus` 或 `tdesign`),需要宿主应用先完成相应的 `app.use(...)` 安装。在 `submitForm` 这种脱离常规组件树的命令式调用中,可通过 `appContext` 把父级应用上下文带过去:
|
||||||
@ -190,3 +212,7 @@ console.log(values);
|
|||||||
::: details 查看 `SubmitFormOptions` 类型定义
|
::: details 查看 `SubmitFormOptions` 类型定义
|
||||||
<<< @/../packages/form/src/submitForm.ts#SubmitFormOptions{ts}
|
<<< @/../packages/form/src/submitForm.ts#SubmitFormOptions{ts}
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
::: details 查看 `SubmitFormResult` 类型定义
|
||||||
|
<<< @/../packages/form/src/submitForm.ts#SubmitFormResult{ts}
|
||||||
|
:::
|
||||||
|
|||||||
@ -1,7 +1,30 @@
|
|||||||
# 表单对比
|
# 表单对比
|
||||||
tmagic-form可以支持两个版本的表单值对比,如果有容器嵌套,将在tab标签页展示对应tab下存在的差异数,便于在复杂嵌套表单场景下直观的看到差异情况
|
tmagic-form可以支持两个版本的表单值对比,如果有容器嵌套,将在tab标签页展示对应tab下存在的差异数,便于在复杂嵌套表单场景下直观的看到差异情况
|
||||||
|
|
||||||
## 使用方法
|
## 使用方法
|
||||||
在初始化表单时,需要传入当前版本的表单值,上一版本的表单值,以及表单配置,具体可参见[Form Playground](https://tencent.github.io/tmagic-editor/playground/index.html#/form)的demo演示
|
在初始化表单时,开启对比模式 `is-compare`,并传入当前版本的表单值(`init-values`)、上一版本的表单值(`last-values`)以及表单配置,具体可参见[Form Playground](https://tencent.github.io/tmagic-editor/playground/index.html#/form)的demo演示。
|
||||||
|
|
||||||
|
```html
|
||||||
|
<m-form
|
||||||
|
:config="config"
|
||||||
|
:is-compare="true"
|
||||||
|
:init-values="curValues"
|
||||||
|
:last-values="lastValues"
|
||||||
|
></m-form>
|
||||||
|
```
|
||||||
|
|
||||||
|
相关属性详见 Form 组件 props:
|
||||||
|
|
||||||
|
- [`isCompare`](/api/form/form-props.html#iscompare):是否开启对比模式;
|
||||||
|
- [`lastValues`](/api/form/form-props.html#lastvalues):需对比的上一版本表单值;
|
||||||
|
- [`showDiff`](/api/form/form-props.html#showdiff):自定义「是否展示对比内容」的判断函数,用于规避语义相等但结构不同导致的误判。
|
||||||
|
|
||||||
|
## 对比模式下的字段行为
|
||||||
|
对比模式下,表单仅做只读展示:增删、复制、排序、导入、编辑等写操作按钮会被隐藏。对于由列表或嵌套子表单组成的复合字段(如 `event-select`、`code-select`、`code-select-col`),表单会按索引对齐前后值,逐项展示新增 / 删除 / 修改的高亮差异,而不会渲染出两套独立组件。
|
||||||
|
|
||||||
|
## 应用场景
|
||||||
|
编辑器的[历史记录面板](/guide/advanced/history-list.md)即基于该能力,对历史步骤的前后值做表单形式的差异对比。
|
||||||
|
|
||||||
## 效果展示
|
## 效果展示
|
||||||
<img src="https://vip.image.video.qpic.cn/vupload/20230301/c626071677661813135.png" alt="表单对比"/>
|
<img src="https://vip.image.video.qpic.cn/vupload/20230301/c626071677661813135.png" alt="表单对比"/>
|
||||||
|
|
||||||
|
|||||||
@ -135,6 +135,18 @@
|
|||||||
}]
|
}]
|
||||||
}]"></demo-block>
|
}]"></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
|
### panel
|
||||||
|
|
||||||
<demo-block type="form" :config="[{
|
<demo-block type="form" :config="[{
|
||||||
|
|||||||
134
docs/guide/advanced/history-list.md
Normal file
134
docs/guide/advanced/history-list.md
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
# 历史记录面板
|
||||||
|
|
||||||
|
编辑器内置了一个可视化的「历史记录面板」,用于查看与回溯编辑过程中产生的所有操作。相比顶部菜单栏只能「撤销 / 重做」相邻一步,历史记录面板提供了对整条历史栈的全局视角:可以按页面、数据源、代码块分类浏览,点击任意一步直接跳转,查看每一步的前后差异,甚至像 `git revert` 一样单独回滚某一步而不破坏后续操作。
|
||||||
|
|
||||||
|
## 开启面板
|
||||||
|
|
||||||
|
历史记录面板以一个内置菜单项 `'history-list'` 的形式提供,将它加入 [`menu`](/api/editor/props.html#menu) 配置即可在顶部工具栏出现一个时钟图标,点击展开面板:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<template>
|
||||||
|
<m-editor :menu="menu"></m-editor>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
const menu = ref({
|
||||||
|
left: [],
|
||||||
|
center: ['delete', 'undo', 'redo', '/', 'history-list'],
|
||||||
|
right: [],
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 面板结构
|
||||||
|
|
||||||
|
面板分为三个 tab,分别对应三类可被历史记录追踪的对象,tab 标题后的数字为各自的分组数量:
|
||||||
|
|
||||||
|
| Tab | 内容 | 跳转 API |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 页面 | 当前活动页面的节点操作历史 | `editorService.gotoPageStep(cursor)` |
|
||||||
|
| 数据源 | 按 `dataSource.id` 分组的数据源变更历史 | `dataSourceService.goto(id, cursor)` |
|
||||||
|
| 代码块 | 按 `codeBlock.id` 分组的代码块变更历史 | `codeBlockService.goto(id, cursor)` |
|
||||||
|
|
||||||
|
### 相邻同目标自动合并
|
||||||
|
|
||||||
|
为了避免「连续微调同一个节点 / 数据源 / 代码块」时产生大量碎片化记录,面板会把**相邻的、针对同一目标的连续 `update`** 自动合并成一个分组:
|
||||||
|
|
||||||
|
- 页面 tab:连续修改同一节点(按节点 id 判定)的多步合并为一组,点击组头部可展开查看每一子步;
|
||||||
|
- 数据源 / 代码块 tab:相邻的连续 `update` 按目标 id 合并;`add` / `remove` 始终独立成组(语义上是一次性事件)。
|
||||||
|
|
||||||
|
> 合并仅作用于展示与交互,不改变底层 undo/redo 栈的真实结构。
|
||||||
|
|
||||||
|
## 交互能力
|
||||||
|
|
||||||
|
每个分组 / 步骤支持以下操作:
|
||||||
|
|
||||||
|
### 1. 点击跳转
|
||||||
|
|
||||||
|
点击任意一条记录,编辑器会跳转到「应用至该步完成」的状态。其本质是把对应栈的游标(cursor)移动到 `step.index + 1`,由 service 层的 undo/redo 链路完成中间步骤的批量正向 / 反向应用。
|
||||||
|
|
||||||
|
### 2. 回到初始状态
|
||||||
|
|
||||||
|
每个 tab 列表底部提供「回到初始状态」入口,等价于把对应栈游标移到 `0`(所有真实步骤全部撤销)。
|
||||||
|
|
||||||
|
### 3. 单步回滚(类 git revert)
|
||||||
|
|
||||||
|
对于历史中间的某一步,可以单独「回滚」它,而保留它之后的所有操作。该行为不会倒带游标,而是把目标步骤的修改**反向应用为一次全新的操作**并压入栈顶,因此不会破坏既有历史结构:
|
||||||
|
|
||||||
|
- 页面:`editorService.revertPageStep(index)`
|
||||||
|
- 数据源:`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` 步骤上提供「查看差异」入口,点击后弹出差异对话框。对话框支持两个维度的切换:
|
||||||
|
|
||||||
|
- **对比对象**
|
||||||
|
- `与修改前对比`:该步骤修改前 vs 修改后(默认,体现这一步带来的变化);
|
||||||
|
- `与当前对比`:该步骤修改后 vs 编辑器中的最新值(用于确认「这一步之后是否又被改动过」,当前值缺失时禁用)。
|
||||||
|
- **展示形态**
|
||||||
|
- `表单对比`:以属性表单形式逐字段对比,可读性更好(基于 [表单对比](/form-config/compare.md) 能力);
|
||||||
|
- `源码对比`:以 JSON 源码做整体 diff(基于 monaco diff 编辑器),可以看到表单未覆盖到的字段。
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
表单对比依赖 `@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` 正常工作。
|
||||||
|
|
||||||
|
若某些字段语义上相等但结构不同(例如 `code-select` 字段中 `''` 与 `{ hookType: 'code', hookData: [] }` 应视为相等),可借助 `@tmagic/form` 的 [`showDiff`](/api/form/form-props.html#showdiff) 自定义判断函数避免被误判为差异。
|
||||||
|
|
||||||
|
## 相关 API
|
||||||
|
|
||||||
|
历史面板的数据均来自 `historyService` 暴露的聚合方法,详见 [historyService 方法](/api/editor/historyServiceMethods.md)。
|
||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.0-beta.1",
|
"version": "1.8.0-beta.4",
|
||||||
"name": "tmagic",
|
"name": "tmagic",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.0-beta.1",
|
"version": "1.8.0-beta.4",
|
||||||
"name": "@tmagic/cli",
|
"name": "@tmagic/cli",
|
||||||
"main": "lib/index.js",
|
"main": "lib/index.js",
|
||||||
"types": "lib/index.d.ts",
|
"types": "lib/index.d.ts",
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.0-beta.1",
|
"version": "1.8.0-beta.4",
|
||||||
"name": "@tmagic/core",
|
"name": "@tmagic/core",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.0-beta.1",
|
"version": "1.8.0-beta.4",
|
||||||
"name": "@tmagic/data-source",
|
"name": "@tmagic/data-source",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -241,7 +241,7 @@ export const registerDataSourceOnDemand = async (
|
|||||||
dsl: MApp,
|
dsl: MApp,
|
||||||
dataSourceModules: Record<string, () => Promise<AsyncDataSourceResolveResult>>,
|
dataSourceModules: Record<string, () => Promise<AsyncDataSourceResolveResult>>,
|
||||||
) => {
|
) => {
|
||||||
const { dataSourceMethodsDeps = {}, dataSourceCondDeps = {}, dataSourceDeps = {}, dataSources = [] } = dsl;
|
const { dataSourceMethodDeps = {}, dataSourceCondDeps = {}, dataSourceDeps = {}, dataSources = [] } = dsl;
|
||||||
|
|
||||||
const dsModuleMap: Record<string, () => Promise<AsyncDataSourceResolveResult>> = {};
|
const dsModuleMap: Record<string, () => Promise<AsyncDataSourceResolveResult>> = {};
|
||||||
|
|
||||||
@ -253,7 +253,7 @@ export const registerDataSourceOnDemand = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!Object.keys(dep).length) {
|
if (!Object.keys(dep).length) {
|
||||||
dep = dataSourceMethodsDeps[ds.id] || {};
|
dep = dataSourceMethodDeps[ds.id] || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(dep).length && dataSourceModules[ds.type]) {
|
if (Object.keys(dep).length && dataSourceModules[ds.type]) {
|
||||||
|
|||||||
@ -377,7 +377,7 @@ describe('registerDataSourceOnDemand', () => {
|
|||||||
],
|
],
|
||||||
dataSourceDeps: { a: { node1: { name: 'n', keys: ['x'] } } },
|
dataSourceDeps: { a: { node1: { name: 'n', keys: ['x'] } } },
|
||||||
dataSourceCondDeps: { c: { node2: { name: 'n', keys: ['y'] } } },
|
dataSourceCondDeps: { c: { node2: { name: 'n', keys: ['y'] } } },
|
||||||
dataSourceMethodsDeps: {},
|
dataSourceMethodDeps: {},
|
||||||
};
|
};
|
||||||
const httpModule = { default: class HttpDS {} };
|
const httpModule = { default: class HttpDS {} };
|
||||||
const mockModule = { default: class MockDS {} };
|
const mockModule = { default: class MockDS {} };
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.0-beta.1",
|
"version": "1.8.0-beta.4",
|
||||||
"name": "@tmagic/dep",
|
"name": "@tmagic/dep",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -127,10 +127,38 @@ export default class Watcher {
|
|||||||
deep = false,
|
deep = false,
|
||||||
type?: DepTargetType | string,
|
type?: DepTargetType | string,
|
||||||
) {
|
) {
|
||||||
this.collectByCallback(nodes, type, ({ node, target }) => {
|
const targets = this.getCollectableTargets(type);
|
||||||
this.removeTargetDep(target, node);
|
|
||||||
this.collectItem(node, target, depExtendedData, deep);
|
if (!targets.length) {
|
||||||
});
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 整棵树只遍历一次、在每个属性上检查所有 target,把结构遍历开销从 ×targets 降到 ×1(详见 collectItems)
|
||||||
|
for (const node of nodes) {
|
||||||
|
this.removeTargetsDep(targets, node);
|
||||||
|
this.collectItems(node, targets, depExtendedData, deep);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取本次需要参与收集的 target(过滤规则与 collectByCallback 一致)
|
||||||
|
*
|
||||||
|
* 注:供 editor 的 dep service / worker 跨包批量收集时复用,因此为 public。
|
||||||
|
* @param type 强制收集指定类型的依赖
|
||||||
|
*/
|
||||||
|
public getCollectableTargets(type?: DepTargetType | string): Target[] {
|
||||||
|
const targets: Target[] = [];
|
||||||
|
traverseTarget(
|
||||||
|
this.targetsList,
|
||||||
|
(target) => {
|
||||||
|
if (!type && !target.isCollectByDefault) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
targets.push(target);
|
||||||
|
},
|
||||||
|
type,
|
||||||
|
);
|
||||||
|
return targets;
|
||||||
}
|
}
|
||||||
|
|
||||||
public collectByCallback(
|
public collectByCallback(
|
||||||
@ -195,53 +223,11 @@ export default class Watcher {
|
|||||||
this.clear(nodes, type);
|
this.clear(nodes, type);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 收集单个 target 的依赖,等价于 collectItems(node, [target], ...)
|
||||||
|
*/
|
||||||
public collectItem(node: TargetNode, target: Target, depExtendedData: DepExtendedData = {}, deep = false) {
|
public collectItem(node: TargetNode, target: Target, depExtendedData: DepExtendedData = {}, deep = false) {
|
||||||
if (node[NODE_DISABLE_DATA_SOURCE_KEY] && DATA_SOURCE_TARGET_TYPES.has(target.type)) {
|
this.collectItems(node, [target], depExtendedData, deep);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node[NODE_DISABLE_CODE_BLOCK_KEY] && target.type === DepTargetType.CODE_BLOCK) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const collectTarget = (config: Record<string | number, any>, prop = '') => {
|
|
||||||
const doCollect = (key: string, value: any) => {
|
|
||||||
const keyIsItems = key === this.childrenProp;
|
|
||||||
const fullKey = prop ? `${prop}.${key}` : key;
|
|
||||||
|
|
||||||
if (target.isTarget(fullKey, value)) {
|
|
||||||
target.updateDep({
|
|
||||||
id: node[this.idProp],
|
|
||||||
name: `${node[this.nameProp] || node[this.idProp]}`,
|
|
||||||
data: depExtendedData,
|
|
||||||
key: fullKey,
|
|
||||||
});
|
|
||||||
} else if (!keyIsItems && Array.isArray(value)) {
|
|
||||||
for (let i = 0, l = value.length; i < l; i++) {
|
|
||||||
const item = value[i];
|
|
||||||
if (isObject(item)) {
|
|
||||||
collectTarget(item, `${fullKey}[${i}]`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (isObject(value)) {
|
|
||||||
collectTarget(value, fullKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (keyIsItems && deep && Array.isArray(value)) {
|
|
||||||
for (const child of value) {
|
|
||||||
this.collectItem(child, target, depExtendedData, deep);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(config)) {
|
|
||||||
if (typeof value === 'undefined' || value === '') continue;
|
|
||||||
|
|
||||||
doCollect(key, value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
collectTarget(node);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public removeTargetDep(target: Target, node: TargetNode, key?: string | number) {
|
public removeTargetDep(target: Target, node: TargetNode, key?: string | number) {
|
||||||
@ -252,4 +238,117 @@ export default class Watcher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 与 removeTargetDep 等价,但一次子树递归同时处理多个 target,
|
||||||
|
* 把删除阶段的结构遍历从 ×targets 降到 ×1。
|
||||||
|
*
|
||||||
|
* 注:供 editor 的 dep service 跨包批量删除时复用,因此为 public。
|
||||||
|
*/
|
||||||
|
public removeTargetsDep(targets: Target[], node: TargetNode, key?: string | number) {
|
||||||
|
const id = node[this.idProp];
|
||||||
|
for (const target of targets) {
|
||||||
|
target.removeDep(id, key);
|
||||||
|
}
|
||||||
|
if (typeof key === 'undefined' && Array.isArray(node[this.childrenProp]) && node[this.childrenProp].length) {
|
||||||
|
for (const item of node[this.childrenProp] as TargetNode[]) {
|
||||||
|
this.removeTargetsDep(targets, item, key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 与 collectItem 等价,但一次遍历同时处理多个 target(不含删除阶段)。
|
||||||
|
*
|
||||||
|
* 关键优化:原实现对每个 target 都完整遍历一遍节点树(O(targets × 树规模)),大页面 + 大量数据源时,
|
||||||
|
* 结构遍历(Object.entries / 递归 / fullKey 字符串拼接)会被重复 targets 次。这里改为「整棵树只遍历一次,
|
||||||
|
* 在每个属性上检查所有 target」,把结构遍历开销从 ×targets 降到 ×1,isTarget 调用次数不变,收集结果完全一致。
|
||||||
|
*
|
||||||
|
* 注:供 editor 的 dep service / worker 跨包批量收集时复用,因此为 public。
|
||||||
|
*/
|
||||||
|
public collectItems(node: TargetNode, targets: Target[], depExtendedData: DepExtendedData = {}, deep = false) {
|
||||||
|
// 对应 collectItem 开头的 NODE_DISABLE_* 判断:被禁用的 target 在该节点及其子树都不收集
|
||||||
|
const activeTargets = this.filterTargetsByNode(node, targets);
|
||||||
|
|
||||||
|
if (!activeTargets.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.collectTargetForTargets(node, node, '', activeTargets, depExtendedData, deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
private filterTargetsByNode(node: TargetNode, targets: Target[]): Target[] {
|
||||||
|
const disableDataSource = Boolean(node[NODE_DISABLE_DATA_SOURCE_KEY]);
|
||||||
|
const disableCodeBlock = Boolean(node[NODE_DISABLE_CODE_BLOCK_KEY]);
|
||||||
|
|
||||||
|
if (!disableDataSource && !disableCodeBlock) {
|
||||||
|
return targets;
|
||||||
|
}
|
||||||
|
|
||||||
|
return targets.filter((target) => {
|
||||||
|
if (disableDataSource && DATA_SOURCE_TARGET_TYPES.has(target.type)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (disableCodeBlock && target.type === DepTargetType.CODE_BLOCK) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private collectTargetForTargets(
|
||||||
|
node: TargetNode,
|
||||||
|
config: Record<string | number, any>,
|
||||||
|
prop: string,
|
||||||
|
targets: Target[],
|
||||||
|
depExtendedData: DepExtendedData,
|
||||||
|
deep: boolean,
|
||||||
|
) {
|
||||||
|
const id = node[this.idProp];
|
||||||
|
const name = `${node[this.nameProp] || node[this.idProp]}`;
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(config)) {
|
||||||
|
if (typeof value === 'undefined' || value === '') continue;
|
||||||
|
|
||||||
|
const keyIsItems = key === this.childrenProp;
|
||||||
|
const fullKey = prop ? `${prop}.${key}` : key;
|
||||||
|
|
||||||
|
// 在该属性上检查所有 target:命中的更新依赖;未命中的留待递归到更深层
|
||||||
|
let notMatched: Target[] | null = null;
|
||||||
|
for (let i = 0, l = targets.length; i < l; i++) {
|
||||||
|
const target = targets[i];
|
||||||
|
if (target.isTarget(fullKey, value, config)) {
|
||||||
|
target.updateDep({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
data: depExtendedData,
|
||||||
|
key: fullKey,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
(notMatched || (notMatched = [])).push(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对应原 doCollect 的 else-if 分支:仅未命中的 target 才继续往 value 内部递归
|
||||||
|
if (notMatched) {
|
||||||
|
if (!keyIsItems && Array.isArray(value)) {
|
||||||
|
for (let i = 0, l = value.length; i < l; i++) {
|
||||||
|
const item = value[i];
|
||||||
|
if (isObject(item)) {
|
||||||
|
this.collectTargetForTargets(node, item, `${fullKey}[${i}]`, notMatched, depExtendedData, deep);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (isObject(value)) {
|
||||||
|
this.collectTargetForTargets(node, value, fullKey, notMatched, depExtendedData, deep);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对应原 doCollect 末尾的无条件子节点递归
|
||||||
|
if (keyIsItems && deep && Array.isArray(value)) {
|
||||||
|
for (const child of value) {
|
||||||
|
this.collectItems(child, targets, depExtendedData, deep);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,7 +15,7 @@ export enum DepTargetType {
|
|||||||
DATA_SOURCE_COND = 'data-source-cond',
|
DATA_SOURCE_COND = 'data-source-cond',
|
||||||
}
|
}
|
||||||
|
|
||||||
export type IsTarget = (key: string | number, value: any) => boolean;
|
export type IsTarget = (key: string | number, value: any, data?: Record<string, any>) => boolean;
|
||||||
|
|
||||||
export interface TargetOptions {
|
export interface TargetOptions {
|
||||||
isTarget: IsTarget;
|
isTarget: IsTarget;
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.0-beta.1",
|
"version": "1.8.0-beta.4",
|
||||||
"name": "@tmagic/design",
|
"name": "@tmagic/design",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": [
|
"sideEffects": [
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.0-beta.1",
|
"version": "1.8.0-beta.4",
|
||||||
"name": "@tmagic/editor",
|
"name": "@tmagic/editor",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": [
|
"sideEffects": [
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
:disabled-page-fragment="disabledPageFragment"
|
:disabled-page-fragment="disabledPageFragment"
|
||||||
:page-bar-sort-options="pageBarSortOptions"
|
:page-bar-sort-options="pageBarSortOptions"
|
||||||
:page-filter-function="pageFilterFunction"
|
:page-filter-function="pageFilterFunction"
|
||||||
|
:hide-sidebar="hideSidebar"
|
||||||
>
|
>
|
||||||
<template #header>
|
<template #header>
|
||||||
<slot name="header"></slot>
|
<slot name="header"></slot>
|
||||||
@ -220,6 +221,7 @@ const stageOptions: StageOptions = {
|
|||||||
guidesOptions: props.guidesOptions,
|
guidesOptions: props.guidesOptions,
|
||||||
disabledMultiSelect: props.disabledMultiSelect,
|
disabledMultiSelect: props.disabledMultiSelect,
|
||||||
alwaysMultiSelect: props.alwaysMultiSelect,
|
alwaysMultiSelect: props.alwaysMultiSelect,
|
||||||
|
disabledFlashTip: props.disabledFlashTip,
|
||||||
beforeDblclick: props.beforeDblclick,
|
beforeDblclick: props.beforeDblclick,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -229,6 +231,19 @@ provide('services', services);
|
|||||||
|
|
||||||
provide('codeOptions', props.codeOptions);
|
provide('codeOptions', props.codeOptions);
|
||||||
provide('stageOptions', stageOptions);
|
provide('stageOptions', stageOptions);
|
||||||
|
/**
|
||||||
|
* 把顶层 `extendFormState` 提供给非 PropsPanel 链路上的组件使用(例如历史差异对话框 HistoryDiffDialog
|
||||||
|
* 内部的 CompareForm)。这样所有依赖业务上下文的表单 filterFunction 都能拿到一致的扩展状态,
|
||||||
|
* 与 PropsPanel 通过 `:extend-state` 显式传入的方式保持等价。
|
||||||
|
*/
|
||||||
|
provide('extendFormState', props.extendFormState);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把历史记录面板的自定义扩展 tab 提供给深层的 HistoryListPanel(它挂在 NavMenu 中,
|
||||||
|
* 以 markRaw component 形式渲染,无法直接通过 props 透传)。业务方可借此在历史记录
|
||||||
|
* 面板内追加自定义模块的历史 tab。
|
||||||
|
*/
|
||||||
|
provide('historyListExtraTabs', props.historyListExtraTabs);
|
||||||
|
|
||||||
provide<EventBus>('eventBus', new EventEmitter());
|
provide<EventBus>('eventBus', new EventEmitter());
|
||||||
|
|
||||||
|
|||||||
@ -34,7 +34,7 @@
|
|||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<TMagicDialog title="查看修改" v-model="difVisible" fullscreen destroy-on-close>
|
<TMagicDialog title="查看修改" v-model="difVisible" fullscreen destroy-on-close>
|
||||||
<div style="display: flex; margin-bottom: 10px">
|
<div style="display: flex; margin-bottom: 10px">
|
||||||
<div style="flex: 1"><TMagicTag size="small" type="info">修改前</TMagicTag></div>
|
<div style="flex: 1"><TMagicTag size="small" type="danger">修改前</TMagicTag></div>
|
||||||
<div style="flex: 1"><TMagicTag size="small" type="success">修改后</TMagicTag></div>
|
<div style="flex: 1"><TMagicTag size="small" type="success">修改后</TMagicTag></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -63,14 +63,7 @@ import { computed, inject, nextTick, Ref, ref, useTemplateRef, watch } from 'vue
|
|||||||
|
|
||||||
import type { CodeBlockContent } from '@tmagic/core';
|
import type { CodeBlockContent } from '@tmagic/core';
|
||||||
import { TMagicButton, TMagicDialog, tMagicMessage, tMagicMessageBox, TMagicTag } from '@tmagic/design';
|
import { TMagicButton, TMagicDialog, tMagicMessage, tMagicMessageBox, TMagicTag } from '@tmagic/design';
|
||||||
import {
|
import { type ContainerChangeEventData, type FormConfig, MFormBox } from '@tmagic/form';
|
||||||
type ContainerChangeEventData,
|
|
||||||
defineFormConfig,
|
|
||||||
defineFormItem,
|
|
||||||
type FormConfig,
|
|
||||||
MFormBox,
|
|
||||||
type TableColumnConfig,
|
|
||||||
} from '@tmagic/form';
|
|
||||||
|
|
||||||
import FloatingBox from '@editor/components/FloatingBox.vue';
|
import FloatingBox from '@editor/components/FloatingBox.vue';
|
||||||
import { useEditorContentHeight } from '@editor/hooks/use-editor-content-height';
|
import { useEditorContentHeight } from '@editor/hooks/use-editor-content-height';
|
||||||
@ -78,6 +71,7 @@ import { useNextFloatBoxPosition } from '@editor/hooks/use-next-float-box-positi
|
|||||||
import { useServices } from '@editor/hooks/use-services';
|
import { useServices } from '@editor/hooks/use-services';
|
||||||
import { useWindowRect } from '@editor/hooks/use-window-rect';
|
import { useWindowRect } from '@editor/hooks/use-window-rect';
|
||||||
import CodeEditor from '@editor/layouts/CodeEditor.vue';
|
import CodeEditor from '@editor/layouts/CodeEditor.vue';
|
||||||
|
import { getCodeBlockFormConfig } from '@editor/utils/code-block';
|
||||||
import { getEditorConfig } from '@editor/utils/config';
|
import { getEditorConfig } from '@editor/utils/config';
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
@ -119,106 +113,23 @@ const diffChange = () => {
|
|||||||
difVisible.value = false;
|
difVisible.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultParamColConfig = defineFormItem<TableColumnConfig>({
|
const codeOptions = inject<Record<string, any>>('codeOptions', {});
|
||||||
type: 'row',
|
|
||||||
label: '参数类型',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
text: '参数类型',
|
|
||||||
labelWidth: '70px',
|
|
||||||
type: 'select',
|
|
||||||
name: 'type',
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
text: '数字',
|
|
||||||
label: '数字',
|
|
||||||
value: 'number',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: '字符串',
|
|
||||||
label: '字符串',
|
|
||||||
value: 'text',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: '组件',
|
|
||||||
label: '组件',
|
|
||||||
value: 'ui-select',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const functionConfig = computed(
|
/**
|
||||||
() =>
|
* 代码块编辑表单配置。统一委托到 utils/code-block 的 `getCodeBlockFormConfig`,
|
||||||
defineFormConfig([
|
* 与 CompareForm 等其它使用方共享同一份 schema,避免双份维护。
|
||||||
{
|
*
|
||||||
text: '名称',
|
* 这里以 computed 包裹是为了让 `props.isDataSource` / `props.dataSourceType` 变化时
|
||||||
name: 'name',
|
* "执行时机"字段的可见性与可选项实时刷新。
|
||||||
rules: [{ required: true, message: '请输入名称', trigger: 'blur' }],
|
*/
|
||||||
},
|
const functionConfig = computed<FormConfig>(() =>
|
||||||
{
|
getCodeBlockFormConfig({
|
||||||
text: '描述',
|
paramColConfig: codeBlockService.getParamsColConfig(),
|
||||||
name: 'desc',
|
isDataSource: () => Boolean(props.isDataSource),
|
||||||
},
|
dataSourceType: () => props.dataSourceType,
|
||||||
{
|
codeOptions,
|
||||||
text: '执行时机',
|
editable: true,
|
||||||
name: 'timing',
|
}),
|
||||||
type: 'select',
|
|
||||||
options: () => {
|
|
||||||
const options = [
|
|
||||||
{ text: '初始化前', value: 'beforeInit' },
|
|
||||||
{ text: '初始化后', value: 'afterInit' },
|
|
||||||
];
|
|
||||||
if (props.dataSourceType !== 'base') {
|
|
||||||
options.push({ text: '请求前', value: 'beforeRequest' });
|
|
||||||
options.push({ text: '请求后', value: 'afterRequest' });
|
|
||||||
}
|
|
||||||
return options;
|
|
||||||
},
|
|
||||||
display: () => props.isDataSource,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'table',
|
|
||||||
border: true,
|
|
||||||
text: '参数',
|
|
||||||
enableFullscreen: false,
|
|
||||||
enableToggleMode: false,
|
|
||||||
name: 'params',
|
|
||||||
dropSort: false,
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
label: '参数名',
|
|
||||||
name: 'name',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
label: '描述',
|
|
||||||
name: 'extra',
|
|
||||||
},
|
|
||||||
codeBlockService.getParamsColConfig() || defaultParamColConfig,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'content',
|
|
||||||
type: 'vs-code',
|
|
||||||
options: inject('codeOptions', {}),
|
|
||||||
autosize: { minRows: 10, maxRows: 30 },
|
|
||||||
onChange: (_formState, code: string) => {
|
|
||||||
try {
|
|
||||||
// 检测js代码是否存在语法错误
|
|
||||||
getEditorConfig('parseDSL')(code);
|
|
||||||
|
|
||||||
return code;
|
|
||||||
} catch (error: any) {
|
|
||||||
tMagicMessage.error(error.message);
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]) as FormConfig,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const parseContent = (content: any) => {
|
const parseContent = (content: any) => {
|
||||||
|
|||||||
@ -3,6 +3,8 @@
|
|||||||
ref="form"
|
ref="form"
|
||||||
:config="codeParamsConfig"
|
:config="codeParamsConfig"
|
||||||
:init-values="model"
|
:init-values="model"
|
||||||
|
:last-values="lastValues"
|
||||||
|
:is-compare="isCompare"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:size="size"
|
:size="size"
|
||||||
:watch-props="false"
|
:watch-props="false"
|
||||||
@ -24,6 +26,10 @@ defineOptions({
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
model: any;
|
model: any;
|
||||||
|
/** 对比模式下的历史值,透传给内部 MForm 用于逐项展示参数差异 */
|
||||||
|
lastValues?: any;
|
||||||
|
/** 是否开启对比模式 */
|
||||||
|
isCompare?: boolean;
|
||||||
size?: 'small' | 'default' | 'large';
|
size?: 'small' | 'default' | 'large';
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
258
packages/editor/src/components/CompareForm.vue
Normal file
258
packages/editor/src/components/CompareForm.vue
Normal file
@ -0,0 +1,258 @@
|
|||||||
|
<template>
|
||||||
|
<div class="m-editor-compare-form-wrapper" :style="wrapperStyle">
|
||||||
|
<MForm
|
||||||
|
v-if="config.length"
|
||||||
|
ref="form"
|
||||||
|
class="m-editor-compare-form"
|
||||||
|
:config="config"
|
||||||
|
:init-values="currentValues"
|
||||||
|
:last-values="lastValuesProcessed"
|
||||||
|
:is-compare="true"
|
||||||
|
:disabled="true"
|
||||||
|
:label-width="labelWidth"
|
||||||
|
:extend-state="extendState"
|
||||||
|
:show-diff="showDiff"
|
||||||
|
></MForm>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, inject, type Ref, ref, type ShallowRef, useTemplateRef, watch, watchEffect } from 'vue';
|
||||||
|
import { isEqual } from 'lodash-es';
|
||||||
|
|
||||||
|
import { type CodeBlockContent, type DataSourceSchema, HookType, type MNode } from '@tmagic/core';
|
||||||
|
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',
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
/** 当前值(修改后的值) */
|
||||||
|
value: Partial<MNode> | Partial<DataSourceSchema> | Partial<CodeBlockContent> | Record<string, any>;
|
||||||
|
/** 用于对比的旧值(修改前的值) */
|
||||||
|
lastValue?: Partial<MNode> | Partial<DataSourceSchema> | Partial<CodeBlockContent> | Record<string, any>;
|
||||||
|
/**
|
||||||
|
* 类型说明:
|
||||||
|
* - `category` 为 `node` 时,`type` 为节点组件的类型,例如 'text'、'button'、'page'、'container' 等
|
||||||
|
* - `category` 为 `data-source` 时,`type` 为数据源类型,例如 'base'、'http'
|
||||||
|
* - `category` 为 `code-block` 时,`type` 可不传
|
||||||
|
*/
|
||||||
|
type?: string;
|
||||||
|
/** 表单配置类别,决定从哪里取 FormConfig */
|
||||||
|
category?: CompareCategory;
|
||||||
|
/** 数据源代码块场景下的数据源类型(base/http),用于代码块表单中"执行时机"展示 */
|
||||||
|
dataSourceType?: string;
|
||||||
|
labelWidth?: string;
|
||||||
|
/**
|
||||||
|
* 外层容器高度。设置后表单内容超出时会在 CompareForm 内部出现滚动条,
|
||||||
|
* 避免 dialog / 面板使用方需要自行处理滚动。可传任意 CSS 长度,例如 `60vh` / `400px` / `100%`。
|
||||||
|
*/
|
||||||
|
height?: string;
|
||||||
|
/**
|
||||||
|
* 用户自定义注入到 MForm.formState 的扩展字段,与 Editor 顶层的 `extendFormState`、
|
||||||
|
* PropsPanel 的 `extend-state` 语义一致。表单 item 的 `display` / `disabled` 等
|
||||||
|
* filterFunction 经常依赖这里注入的字段(如 stage、自定义业务上下文等),
|
||||||
|
* 因此在差异对比场景下也需要透传,避免出现 `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',
|
||||||
|
labelWidth: '120px',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const { propsService, dataSourceService, codeBlockService, editorService } = useServices();
|
||||||
|
const services = useServices();
|
||||||
|
|
||||||
|
const config = ref<FormConfig>([]);
|
||||||
|
|
||||||
|
/** vs-code 编辑器的 monaco 配置项,沿用 Editor 顶层 provide('codeOptions', ...) 的注入。 */
|
||||||
|
const codeOptions = inject<Record<string, any>>('codeOptions', {});
|
||||||
|
|
||||||
|
/** 将代码块的 content 字段统一成字符串,便于在表单/对比中展示 */
|
||||||
|
const normalizeCodeBlockValue = (
|
||||||
|
v: Partial<CodeBlockContent> | Record<string, any> | undefined,
|
||||||
|
): Record<string, any> => {
|
||||||
|
if (!v) return {};
|
||||||
|
const next: Record<string, any> = { ...v };
|
||||||
|
if (next.content && typeof next.content !== 'string') {
|
||||||
|
try {
|
||||||
|
next.content = next.content.toString();
|
||||||
|
} catch {
|
||||||
|
next.content = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentValues = computed<FormValue>(() => {
|
||||||
|
if (props.category === 'code-block') {
|
||||||
|
return normalizeCodeBlockValue(props.value as Partial<CodeBlockContent>);
|
||||||
|
}
|
||||||
|
return (props.value || {}) as FormValue;
|
||||||
|
});
|
||||||
|
|
||||||
|
const lastValuesProcessed = computed<FormValue>(() => {
|
||||||
|
if (props.category === 'code-block') {
|
||||||
|
return normalizeCodeBlockValue(props.lastValue as Partial<CodeBlockContent>);
|
||||||
|
}
|
||||||
|
return (props.lastValue || {}) as FormValue;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 外层包裹层的样式:当传入 `height` 时启用固定高度 + 内部滚动,
|
||||||
|
* 这样滚动条会出现在 CompareForm 内部,避免父容器(如 Dialog)自身也产生滚动。
|
||||||
|
*/
|
||||||
|
const wrapperStyle = computed(() => {
|
||||||
|
if (!props.height) return undefined;
|
||||||
|
return {
|
||||||
|
height: props.height,
|
||||||
|
overflow: 'auto',
|
||||||
|
} as Record<string, string>;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `code-select` 字段在历史数据中存在两种"语义为空"的形态:
|
||||||
|
* - 字符串 `''`(旧数据 / 用户从未配置过钩子);
|
||||||
|
* - `{ hookType: HookType.CODE, hookData: [] }`(CodeSelect.vue 在挂载时
|
||||||
|
* 写入的默认结构,参见 packages/editor/src/fields/CodeSelect.vue 中
|
||||||
|
* `props.model[props.name] = { hookType: HookType.CODE, hookData: [] }`)。
|
||||||
|
*
|
||||||
|
* 直接 `isEqual` 会把两者判为不等,从而在历史对比里对每个未配置过钩子的组件
|
||||||
|
* 都展示一份"差异",体验很糟糕。这里把它们视为相等,跳过对比。
|
||||||
|
*
|
||||||
|
* 其它类型字段沿用 MForm/Container 的默认 `!isEqual` 判断逻辑。
|
||||||
|
*/
|
||||||
|
const isEmptyCodeSelectValue = (v: any): boolean => {
|
||||||
|
if (v === '' || v === undefined || v === null) return true;
|
||||||
|
if (Array.isArray(v) && v.length === 0) return true;
|
||||||
|
return typeof v === 'object' && v.hookType === HookType.CODE && Array.isArray(v.hookData) && v.hookData.length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const showDiff = ({ curValue, lastValue, config }: { curValue: any; lastValue: any; config: any }) => {
|
||||||
|
if (config?.type === 'code-select') {
|
||||||
|
// 双方都是"空形态",视为相等,不展示对比
|
||||||
|
if (isEmptyCodeSelectValue(curValue) && isEmptyCodeSelectValue(lastValue)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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> => {
|
||||||
|
switch (props.category) {
|
||||||
|
case 'node': {
|
||||||
|
if (!props.type) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return removeStyleDisplayConfig(
|
||||||
|
await propsService.getPropsConfig(props.type, { node: props.value as unknown as MNode }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case 'data-source': {
|
||||||
|
return dataSourceService.getFormConfig(props.type || 'base');
|
||||||
|
}
|
||||||
|
case 'code-block': {
|
||||||
|
return getCodeBlockFormConfig({
|
||||||
|
paramColConfig: codeBlockService.getParamsColConfig(),
|
||||||
|
// 通过传入 dataSourceType 间接表达"是数据源代码块"——在对比场景下 props.dataSourceType
|
||||||
|
// 由调用方按 step 上下文显式传入,未传则视为普通代码块,「执行时机」字段隐藏。
|
||||||
|
isDataSource: () => Boolean(props.dataSourceType),
|
||||||
|
dataSourceType: () => props.dataSourceType,
|
||||||
|
codeOptions,
|
||||||
|
// 对比模式只读,不需要校验/语法检查
|
||||||
|
editable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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],
|
||||||
|
() => {
|
||||||
|
loadConfig();
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const formRef = useTemplateRef<InstanceType<typeof MForm>>('form');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把 services / stage 注入 MForm 的 formState,避免 propsService 注入的表单配置中
|
||||||
|
* 形如 `display: ({ services }) => services.uiService.get(...)` 的 filterFunction
|
||||||
|
* 在执行时拿不到 `formState.services` 而报错。
|
||||||
|
*
|
||||||
|
* 与 props-panel/FormPanel.vue 中的注入方式保持一致:
|
||||||
|
* - services:整个 useServices() 返回的服务集合;
|
||||||
|
* - stage:当前 editorService.get('stage') 的最新值。
|
||||||
|
*/
|
||||||
|
const stage = computed(() => editorService.get('stage'));
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
if (formRef.value) {
|
||||||
|
formRef.value.formState.stage = stage.value;
|
||||||
|
formRef.value.formState.services = services;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
defineExpose<{
|
||||||
|
form: ShallowRef<InstanceType<typeof MForm> | null>;
|
||||||
|
config: Ref<FormConfig>;
|
||||||
|
reload: () => Promise<void>;
|
||||||
|
}>({
|
||||||
|
form: formRef,
|
||||||
|
config,
|
||||||
|
reload: loadConfig,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@ -15,6 +15,7 @@ import type {
|
|||||||
ComponentGroup,
|
ComponentGroup,
|
||||||
CustomContentMenuFunction,
|
CustomContentMenuFunction,
|
||||||
DatasourceTypeOption,
|
DatasourceTypeOption,
|
||||||
|
HistoryListExtraTab,
|
||||||
IsExpandableFunction,
|
IsExpandableFunction,
|
||||||
MenuBarData,
|
MenuBarData,
|
||||||
MenuButton,
|
MenuButton,
|
||||||
@ -34,6 +35,8 @@ export interface EditorProps {
|
|||||||
datasourceList?: DatasourceTypeOption[];
|
datasourceList?: DatasourceTypeOption[];
|
||||||
/** 左侧面板配置 */
|
/** 左侧面板配置 */
|
||||||
sidebar?: SideBarData;
|
sidebar?: SideBarData;
|
||||||
|
/** 是否隐藏左侧面板 */
|
||||||
|
hideSidebar?: boolean;
|
||||||
/** 顶部工具栏配置 */
|
/** 顶部工具栏配置 */
|
||||||
menu?: MenuBarData;
|
menu?: MenuBarData;
|
||||||
/** 组件树右键菜单 */
|
/** 组件树右键菜单 */
|
||||||
@ -84,6 +87,8 @@ export interface EditorProps {
|
|||||||
alwaysMultiSelect?: boolean;
|
alwaysMultiSelect?: boolean;
|
||||||
/** 禁用页面片 */
|
/** 禁用页面片 */
|
||||||
disabledPageFragment?: boolean;
|
disabledPageFragment?: boolean;
|
||||||
|
/** 禁用「非点击画布选中组件时(如从图层树、面包屑等外部选中),对选中区域做高亮闪烁提示」,默认 false(即默认开启闪烁) */
|
||||||
|
disabledFlashTip?: boolean;
|
||||||
/** 禁用双击在浮层中单独编辑选中组件 */
|
/** 禁用双击在浮层中单独编辑选中组件 */
|
||||||
disabledStageOverlay?: boolean;
|
disabledStageOverlay?: boolean;
|
||||||
/** 禁用属性配置面板右下角显示源码的按钮 */
|
/** 禁用属性配置面板右下角显示源码的按钮 */
|
||||||
@ -123,6 +128,8 @@ export interface EditorProps {
|
|||||||
/** 组件树节点双击前的钩子函数,返回 false 则阻止默认的双击行为 */
|
/** 组件树节点双击前的钩子函数,返回 false 则阻止默认的双击行为 */
|
||||||
beforeLayerNodeDblclick?: (event: MouseEvent, data: TreeNodeData) => Promise<boolean | void> | boolean | void;
|
beforeLayerNodeDblclick?: (event: MouseEvent, data: TreeNodeData) => Promise<boolean | void> | boolean | void;
|
||||||
extendFormState?: (state: FormState) => Record<string, any> | Promise<Record<string, any>>;
|
extendFormState?: (state: FormState) => Record<string, any> | Promise<Record<string, any>>;
|
||||||
|
/** 历史记录面板的自定义扩展 tab,追加在内置的页面/数据源/代码块 tab 之后 */
|
||||||
|
historyListExtraTabs?: HistoryListExtraTab[];
|
||||||
/** 页面顺序拖拽配置参数 */
|
/** 页面顺序拖拽配置参数 */
|
||||||
pageBarSortOptions?: PageBarSortOptions;
|
pageBarSortOptions?: PageBarSortOptions;
|
||||||
/** 页面搜索函数 */
|
/** 页面搜索函数 */
|
||||||
@ -134,6 +141,7 @@ export const defaultEditorProps = {
|
|||||||
disabledMultiSelect: false,
|
disabledMultiSelect: false,
|
||||||
alwaysMultiSelect: false,
|
alwaysMultiSelect: false,
|
||||||
disabledPageFragment: false,
|
disabledPageFragment: false,
|
||||||
|
disabledFlashTip: false,
|
||||||
disabledStageOverlay: false,
|
disabledStageOverlay: false,
|
||||||
containerHighlightClassName: CONTAINER_HIGHLIGHT_CLASS_NAME,
|
containerHighlightClassName: CONTAINER_HIGHLIGHT_CLASS_NAME,
|
||||||
containerHighlightDuration: 800,
|
containerHighlightDuration: 800,
|
||||||
@ -143,6 +151,7 @@ export const defaultEditorProps = {
|
|||||||
disabledCodeBlock: false,
|
disabledCodeBlock: false,
|
||||||
componentGroupList: () => [],
|
componentGroupList: () => [],
|
||||||
datasourceList: () => [],
|
datasourceList: () => [],
|
||||||
|
historyListExtraTabs: () => [],
|
||||||
menu: () => ({ left: [], right: [] }),
|
menu: () => ({ left: [], right: [] }),
|
||||||
layerContentMenu: () => [],
|
layerContentMenu: () => [],
|
||||||
stageContentMenu: () => [],
|
stageContentMenu: () => [],
|
||||||
|
|||||||
@ -1,11 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<MagicCodeEditor
|
<MagicCodeEditor
|
||||||
:height="config.height"
|
:height="config.height"
|
||||||
:init-values="model[name]"
|
:type="diffMode ? 'diff' : undefined"
|
||||||
|
:init-values="diffMode ? (lastValues || {})[name] : model[name]"
|
||||||
|
:modified-values="diffMode ? model[name] : undefined"
|
||||||
:language="config.language"
|
:language="config.language"
|
||||||
:options="{
|
:options="{
|
||||||
...config.options,
|
...config.options,
|
||||||
readOnly: disabled,
|
readOnly: diffMode ? true : disabled,
|
||||||
}"
|
}"
|
||||||
:autosize="config.autosize"
|
:autosize="config.autosize"
|
||||||
:parse="config.parse"
|
:parse="config.parse"
|
||||||
@ -15,6 +17,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
import type { CodeConfig, FieldProps } from '@tmagic/form';
|
import type { CodeConfig, FieldProps } from '@tmagic/form';
|
||||||
|
|
||||||
import MagicCodeEditor from '@editor/layouts/CodeEditor.vue';
|
import MagicCodeEditor from '@editor/layouts/CodeEditor.vue';
|
||||||
@ -27,10 +31,22 @@ const emit = defineEmits<{
|
|||||||
change: [value: string | any];
|
change: [value: string | any];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
withDefaults(defineProps<FieldProps<CodeConfig>>(), {
|
const props = withDefaults(defineProps<FieldProps<CodeConfig>>(), {
|
||||||
disabled: false,
|
disabled: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对比模式判定:
|
||||||
|
*
|
||||||
|
* - 当 `isCompare === true` 时,由父级 `MFormContainer` 统一渲染一次本字段(不再渲染前后两份独立组件),
|
||||||
|
* 并把 `model`(当前值)与 `lastValues`(历史值)一并传入;
|
||||||
|
* - 此时本字段切换到 monaco 自带的 diff 编辑器(左侧旧、右侧新),相比"两个独立 monaco 实例"更直观,
|
||||||
|
* 也避免了同一表单内重复实例化重型编辑器带来的开销。
|
||||||
|
*
|
||||||
|
* 仅当存在历史值(lastValues)且开启了对比模式时启用 diff,避免在 lastValues 缺失时退化为空对比。
|
||||||
|
*/
|
||||||
|
const diffMode = computed(() => Boolean(props.isCompare && props.lastValues));
|
||||||
|
|
||||||
const save = (v: string | any) => {
|
const save = (v: string | any) => {
|
||||||
emit('change', v);
|
emit('change', v);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -4,11 +4,11 @@
|
|||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, reactive, watch } from 'vue';
|
import { computed, reactive, watch } from 'vue';
|
||||||
import serialize from 'serialize-javascript';
|
|
||||||
|
|
||||||
import type { CodeLinkConfig, FieldProps, MLink } from '@tmagic/form';
|
import type { CodeLinkConfig, FieldProps, MLink } from '@tmagic/form';
|
||||||
|
|
||||||
import { getEditorConfig } from '@editor/utils/config';
|
import { getEditorConfig } from '@editor/utils/config';
|
||||||
|
import { serializeConfig } from '@editor/utils/editor';
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'MFieldsCodeLink',
|
name: 'MFieldsCodeLink',
|
||||||
@ -47,10 +47,7 @@ watch(
|
|||||||
() => props.model[props.name],
|
() => props.model[props.name],
|
||||||
(value) => {
|
(value) => {
|
||||||
modelValue.form = {
|
modelValue.form = {
|
||||||
[props.name]: serialize(value, {
|
[props.name]: serializeConfig(value),
|
||||||
space: 2,
|
|
||||||
unsafe: true,
|
|
||||||
}).replace(/"(\w+)":\s/g, '$1: '),
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -6,7 +6,8 @@
|
|||||||
:size="size"
|
:size="size"
|
||||||
:prop="prop"
|
:prop="prop"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:lastValues="lastValues"
|
:is-compare="isCompareMode"
|
||||||
|
:last-values="lastValues?.[name]"
|
||||||
:model="model[name]"
|
:model="model[name]"
|
||||||
@change="changeHandler"
|
@change="changeHandler"
|
||||||
>
|
>
|
||||||
@ -38,6 +39,21 @@ const { dataSourceService, codeBlockService } = useServices();
|
|||||||
|
|
||||||
const props = withDefaults(defineProps<FieldProps<CodeSelectConfig>>(), {});
|
const props = withDefaults(defineProps<FieldProps<CodeSelectConfig>>(), {});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对比模式判定:
|
||||||
|
*
|
||||||
|
* code-select 仅是对内部「钩子列表」group-list 的包裹,本身不渲染叶子字段。父级 `MFormContainer`
|
||||||
|
* 已将其归入「自接管对比字段」(见 Container.vue 的 `SELF_DIFF_FIELD_TYPES`),即对比时只渲染一次
|
||||||
|
* 本组件,并把当前值 `model` 与历史值 `lastValues` 一并传入,由本组件把 `is-compare`/`lastValues`
|
||||||
|
* 透传给内部 MContainer,再由 group-list / code-select-col 等子级逐项展示前后差异。
|
||||||
|
*
|
||||||
|
* 注意:`model` 传入的是 `model[name]`(钩子值本身),因此 `lastValues` 也必须同层取 `lastValues[name]`,
|
||||||
|
* 否则前后值的嵌套层级不一致会导致对比错位。
|
||||||
|
*
|
||||||
|
* 仅当存在历史值时才启用对比,避免 lastValues 缺失时退化为「全部新增」的空对比。
|
||||||
|
*/
|
||||||
|
const isCompareMode = computed(() => Boolean(props.isCompare && props.lastValues));
|
||||||
|
|
||||||
const codeConfig = computed<GroupListConfig>(() => ({
|
const codeConfig = computed<GroupListConfig>(() => ({
|
||||||
type: 'group-list',
|
type: 'group-list',
|
||||||
name: 'hookData',
|
name: 'hookData',
|
||||||
|
|||||||
@ -2,7 +2,20 @@
|
|||||||
<div class="m-fields-code-select-col">
|
<div class="m-fields-code-select-col">
|
||||||
<div class="code-select-container">
|
<div class="code-select-container">
|
||||||
<!-- 代码块下拉框 -->
|
<!-- 代码块下拉框 -->
|
||||||
|
<!-- 对比模式下交由 MFormContainer 展示下拉框的前后差异(codeId 变化时高亮新旧代码块名),
|
||||||
|
普通模式仍直接渲染 MSelect 以保留选择 / 写值逻辑 -->
|
||||||
|
<MFormContainer
|
||||||
|
v-if="isCompareMode"
|
||||||
|
class="select"
|
||||||
|
:config="selectConfig"
|
||||||
|
:model="model"
|
||||||
|
:last-values="lastValues"
|
||||||
|
:is-compare="true"
|
||||||
|
:size="size"
|
||||||
|
:prop="prop"
|
||||||
|
></MFormContainer>
|
||||||
<MSelect
|
<MSelect
|
||||||
|
v-else
|
||||||
class="select"
|
class="select"
|
||||||
:config="selectConfig"
|
:config="selectConfig"
|
||||||
:name="name"
|
:name="name"
|
||||||
@ -12,9 +25,9 @@
|
|||||||
@change="onCodeIdChangeHandler"
|
@change="onCodeIdChangeHandler"
|
||||||
></MSelect>
|
></MSelect>
|
||||||
|
|
||||||
<!-- 查看/编辑按钮 -->
|
<!-- 查看/编辑按钮:对比模式为只读,不展示 -->
|
||||||
<TMagicButton
|
<TMagicButton
|
||||||
v-if="model[name] && hasCodeBlockSidePanel"
|
v-if="!isCompareMode && model[name] && hasCodeBlockSidePanel"
|
||||||
class="m-fields-select-action-button"
|
class="m-fields-select-action-button"
|
||||||
:size="size"
|
:size="size"
|
||||||
@click="editCode(model[name])"
|
@click="editCode(model[name])"
|
||||||
@ -29,6 +42,8 @@
|
|||||||
name="params"
|
name="params"
|
||||||
:key="model[name]"
|
:key="model[name]"
|
||||||
:model="model"
|
:model="model"
|
||||||
|
:last-values="lastValues"
|
||||||
|
:is-compare="isCompareMode"
|
||||||
:size="size"
|
:size="size"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:params-config="paramsConfig"
|
:params-config="paramsConfig"
|
||||||
@ -52,6 +67,7 @@ import {
|
|||||||
filterFunction,
|
filterFunction,
|
||||||
type FormItemConfig,
|
type FormItemConfig,
|
||||||
type FormState,
|
type FormState,
|
||||||
|
MContainer as MFormContainer,
|
||||||
MSelect,
|
MSelect,
|
||||||
type SelectConfig,
|
type SelectConfig,
|
||||||
} from '@tmagic/form';
|
} from '@tmagic/form';
|
||||||
@ -77,6 +93,18 @@ const props = withDefaults(defineProps<FieldProps<CodeSelectColConfig>>(), {
|
|||||||
disabled: false,
|
disabled: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对比模式判定:
|
||||||
|
*
|
||||||
|
* code-select-col 由「代码块下拉框 + 参数子表单」组成,属于复合字段。父级 `MFormContainer` 已将其
|
||||||
|
* 归入「自接管对比字段」(见 Container.vue 的 `SELF_DIFF_FIELD_TYPES`),即对比时只渲染一次本组件,
|
||||||
|
* 并把当前值 `model` 与历史值 `lastValues` 一并传入,由本组件把 `is-compare`/`lastValues` 透传给
|
||||||
|
* 内部的下拉框(MFormContainer)与参数表单(CodeParams),逐项展示前后差异。
|
||||||
|
*
|
||||||
|
* 仅当存在历史值时才启用对比,避免 lastValues 缺失时退化为「全部新增」的空对比。
|
||||||
|
*/
|
||||||
|
const isCompareMode = computed(() => Boolean(props.isCompare && props.lastValues));
|
||||||
|
|
||||||
const notEditable = computed(() => filterFunction(mForm, props.config.notEditable, props));
|
const notEditable = computed(() => filterFunction(mForm, props.config.notEditable, props));
|
||||||
|
|
||||||
const hasCodeBlockSidePanel = computed(() =>
|
const hasCodeBlockSidePanel = computed(() =>
|
||||||
|
|||||||
@ -72,7 +72,10 @@
|
|||||||
@change="onChangeHandler"
|
@change="onChangeHandler"
|
||||||
></TMagicCascader>
|
></TMagicCascader>
|
||||||
|
|
||||||
<TMagicTooltip v-if="selectDataSourceId && hasDataSourceSidePanel" :content="notEditable ? '查看' : '编辑'">
|
<TMagicTooltip
|
||||||
|
v-if="selectDataSourceId && hasDataSourceSidePanel && !isCompare"
|
||||||
|
:content="notEditable ? '查看' : '编辑'"
|
||||||
|
>
|
||||||
<TMagicButton class="m-fields-select-action-button" :size="size" @click="editHandler(selectDataSourceId)"
|
<TMagicButton class="m-fields-select-action-button" :size="size" @click="editHandler(selectDataSourceId)"
|
||||||
><MIcon :icon="!notEditable ? Edit : View"></MIcon
|
><MIcon :icon="!notEditable ? Edit : View"></MIcon
|
||||||
></TMagicButton>
|
></TMagicButton>
|
||||||
@ -133,6 +136,9 @@ const dataSources = computed(() => {
|
|||||||
const valueIsKey = computed(() => props.value === 'key');
|
const valueIsKey = computed(() => props.value === 'key');
|
||||||
const notEditable = computed(() => filterFunction(mForm, props.notEditable, props));
|
const notEditable = computed(() => filterFunction(mForm, props.notEditable, props));
|
||||||
|
|
||||||
|
/** 对比模式下隐藏查看/编辑操作按钮,仅保留只读展示。 */
|
||||||
|
const isCompare = computed(() => Boolean(mForm?.isCompare));
|
||||||
|
|
||||||
const dataSourcesOptions = computed<SelectOption[]>(() =>
|
const dataSourcesOptions = computed<SelectOption[]>(() =>
|
||||||
dataSources.value.map((ds) => ({
|
dataSources.value.map((ds) => ({
|
||||||
text: ds.title || ds.id,
|
text: ds.title || ds.id,
|
||||||
|
|||||||
@ -28,14 +28,14 @@
|
|||||||
></component>
|
></component>
|
||||||
|
|
||||||
<TMagicTooltip
|
<TMagicTooltip
|
||||||
v-if="config.fieldConfig && !disabledDataSource"
|
v-if="config.fieldConfig && !disabledDataSource && !mForm?.isCompare"
|
||||||
:disabled="showDataSourceFieldSelect"
|
:disabled="showDataSourceFieldSelect"
|
||||||
content="选择数据源"
|
content="选择数据源"
|
||||||
>
|
>
|
||||||
<TMagicButton
|
<TMagicButton
|
||||||
:type="showDataSourceFieldSelect ? 'primary' : 'default'"
|
:type="showDataSourceFieldSelect ? 'primary' : 'default'"
|
||||||
:size="size"
|
:size="size"
|
||||||
:disabled="disabled || mForm?.isCompare"
|
:disabled="disabled"
|
||||||
@click="onToggleDataSourceFieldSelectHandler"
|
@click="onToggleDataSourceFieldSelectHandler"
|
||||||
><MIcon :icon="Coin"></MIcon
|
><MIcon :icon="Coin"></MIcon
|
||||||
></TMagicButton>
|
></TMagicButton>
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="m-editor-data-source-fields">
|
<div class="m-editor-data-source-fields">
|
||||||
<MagicTable :data="model[name]" :columns="fieldColumns" :border="true"></MagicTable>
|
<MagicTable :data="model[name]" :columns="displayColumns" :border="true"></MagicTable>
|
||||||
|
|
||||||
<div class="m-editor-data-source-fields-footer">
|
<div v-if="!isCompare" class="m-editor-data-source-fields-footer">
|
||||||
<TMagicButton size="small" :disabled="disabled" plain @click="newFromJsonHandler()">快速添加</TMagicButton>
|
<TMagicButton size="small" :disabled="disabled" plain @click="newFromJsonHandler()">快速添加</TMagicButton>
|
||||||
<TMagicButton size="small" type="primary" :disabled="disabled" plain @click="newHandler()">添加</TMagicButton>
|
<TMagicButton size="small" type="primary" :disabled="disabled" plain @click="newHandler()">添加</TMagicButton>
|
||||||
</div>
|
</div>
|
||||||
@ -47,7 +47,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { inject, Ref, ref } from 'vue';
|
import { computed, inject, Ref, ref } from 'vue';
|
||||||
|
|
||||||
import type { DataSchema } from '@tmagic/core';
|
import type { DataSchema } from '@tmagic/core';
|
||||||
import { TMagicButton, tMagicMessage, tMagicMessageBox } from '@tmagic/design';
|
import { TMagicButton, tMagicMessage, tMagicMessageBox } from '@tmagic/design';
|
||||||
@ -84,6 +84,10 @@ const emit = defineEmits<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { uiService } = useServices();
|
const { uiService } = useServices();
|
||||||
|
const mForm = inject<FormState | undefined>('mForm');
|
||||||
|
|
||||||
|
/** 对比模式下隐藏新增/编辑/删除等操作按钮,仅保留只读展示。 */
|
||||||
|
const isCompare = computed(() => Boolean(mForm?.isCompare));
|
||||||
|
|
||||||
const fieldValues = ref<Record<string, any>>({});
|
const fieldValues = ref<Record<string, any>>({});
|
||||||
const fieldTitle = ref('');
|
const fieldTitle = ref('');
|
||||||
@ -176,6 +180,11 @@ const fieldColumns: ColumnConfig[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** 对比模式下移除「操作」列(编辑/删除按钮),仅保留只读列。 */
|
||||||
|
const displayColumns = computed<ColumnConfig[]>(() =>
|
||||||
|
isCompare.value ? fieldColumns.filter((col) => !col.actions) : fieldColumns,
|
||||||
|
);
|
||||||
|
|
||||||
const dataSourceFieldsConfig: FormConfig = [
|
const dataSourceFieldsConfig: FormConfig = [
|
||||||
{ name: 'index', type: 'hidden', filter: 'number', defaultValue: -1 },
|
{ name: 'index', type: 'hidden', filter: 'number', defaultValue: -1 },
|
||||||
{
|
{
|
||||||
|
|||||||
@ -13,7 +13,7 @@
|
|||||||
></MCascader>
|
></MCascader>
|
||||||
|
|
||||||
<TMagicTooltip
|
<TMagicTooltip
|
||||||
v-if="model[name] && isCustomMethod && hasDataSourceSidePanel"
|
v-if="model[name] && isCustomMethod && hasDataSourceSidePanel && !isCompare"
|
||||||
:content="notEditable ? '查看' : '编辑'"
|
:content="notEditable ? '查看' : '编辑'"
|
||||||
>
|
>
|
||||||
<TMagicButton class="m-fields-select-action-button" :size="size" @click="editCodeHandler">
|
<TMagicButton class="m-fields-select-action-button" :size="size" @click="editCodeHandler">
|
||||||
@ -81,6 +81,9 @@ const hasDataSourceSidePanel = computed(() =>
|
|||||||
|
|
||||||
const notEditable = computed(() => filterFunction(mForm, props.config.notEditable, props));
|
const notEditable = computed(() => filterFunction(mForm, props.config.notEditable, props));
|
||||||
|
|
||||||
|
/** 对比模式下隐藏查看/编辑操作按钮,仅保留只读展示。 */
|
||||||
|
const isCompare = computed(() => Boolean(mForm?.isCompare));
|
||||||
|
|
||||||
const dataSources = computed(() => dataSourceService.get('dataSources'));
|
const dataSources = computed(() => dataSourceService.get('dataSources'));
|
||||||
|
|
||||||
const isCustomMethod = computed(() => {
|
const isCustomMethod = computed(() => {
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="m-editor-data-source-methods">
|
<div class="m-editor-data-source-methods">
|
||||||
<MagicTable :data="model[name]" :columns="methodColumns" :border="true"></MagicTable>
|
<MagicTable :data="model[name]" :columns="displayColumns" :border="true"></MagicTable>
|
||||||
|
|
||||||
<div class="m-editor-data-source-methods-footer">
|
<div v-if="!isCompare" class="m-editor-data-source-methods-footer">
|
||||||
<TMagicButton size="small" type="primary" :disabled="disabled" plain @click="createCodeHandler"
|
<TMagicButton size="small" type="primary" :disabled="disabled" plain @click="createCodeHandler"
|
||||||
>添加</TMagicButton
|
>添加</TMagicButton
|
||||||
>
|
>
|
||||||
@ -21,12 +21,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { nextTick, ref, useTemplateRef } from 'vue';
|
import { computed, inject, nextTick, ref, useTemplateRef } from 'vue';
|
||||||
import { cloneDeep } from 'lodash-es';
|
import { cloneDeep } from 'lodash-es';
|
||||||
|
|
||||||
import type { CodeBlockContent } from '@tmagic/core';
|
import type { CodeBlockContent } from '@tmagic/core';
|
||||||
import { TMagicButton, tMagicMessageBox } from '@tmagic/design';
|
import { TMagicButton, tMagicMessageBox } from '@tmagic/design';
|
||||||
import type { ContainerChangeEventData, DataSourceMethodsConfig, FieldProps } from '@tmagic/form';
|
import type { ContainerChangeEventData, DataSourceMethodsConfig, FieldProps, FormState } from '@tmagic/form';
|
||||||
import { type ColumnConfig, MagicTable } from '@tmagic/table';
|
import { type ColumnConfig, MagicTable } from '@tmagic/table';
|
||||||
|
|
||||||
import CodeBlockEditor from '@editor/components/CodeBlockEditor.vue';
|
import CodeBlockEditor from '@editor/components/CodeBlockEditor.vue';
|
||||||
@ -42,6 +42,11 @@ const props = withDefaults(defineProps<FieldProps<DataSourceMethodsConfig>>(), {
|
|||||||
|
|
||||||
const emit = defineEmits(['change']);
|
const emit = defineEmits(['change']);
|
||||||
|
|
||||||
|
const mForm = inject<FormState | undefined>('mForm');
|
||||||
|
|
||||||
|
/** 对比模式下隐藏新增/编辑/删除等操作按钮,仅保留只读展示。 */
|
||||||
|
const isCompare = computed(() => Boolean(mForm?.isCompare));
|
||||||
|
|
||||||
const codeConfig = ref<Omit<CodeBlockContent, 'content'> & { content: string }>();
|
const codeConfig = ref<Omit<CodeBlockContent, 'content'> & { content: string }>();
|
||||||
const codeBlockEditorRef = useTemplateRef<InstanceType<typeof CodeBlockEditor>>('codeBlockEditor');
|
const codeBlockEditorRef = useTemplateRef<InstanceType<typeof CodeBlockEditor>>('codeBlockEditor');
|
||||||
|
|
||||||
@ -107,6 +112,11 @@ const methodColumns: ColumnConfig[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** 对比模式下移除「操作」列(编辑/删除按钮),仅保留只读列。 */
|
||||||
|
const displayColumns = computed<ColumnConfig[]>(() =>
|
||||||
|
isCompare.value ? methodColumns.filter((col) => !col.actions) : methodColumns,
|
||||||
|
);
|
||||||
|
|
||||||
const createCodeHandler = () => {
|
const createCodeHandler = () => {
|
||||||
codeConfig.value = {
|
codeConfig.value = {
|
||||||
name: '',
|
name: '',
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="m-editor-data-source-fields">
|
<div class="m-editor-data-source-fields">
|
||||||
<MagicTable :data="model[name]" :columns="columns"></MagicTable>
|
<MagicTable :data="model[name]" :columns="displayColumns"></MagicTable>
|
||||||
|
|
||||||
<div class="m-editor-data-source-fields-footer">
|
<div v-if="!isCompare" class="m-editor-data-source-fields-footer">
|
||||||
<TMagicButton size="small" type="primary" :disabled="disabled" plain @click="newHandler()">添加</TMagicButton>
|
<TMagicButton size="small" type="primary" :disabled="disabled" plain @click="newHandler()">添加</TMagicButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -28,7 +28,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { inject, Ref, ref } from 'vue';
|
import { computed, inject, Ref, ref } from 'vue';
|
||||||
|
|
||||||
import type { MockSchema } from '@tmagic/core';
|
import type { MockSchema } from '@tmagic/core';
|
||||||
import { TMagicButton, tMagicMessageBox, TMagicSwitch } from '@tmagic/design';
|
import { TMagicButton, tMagicMessageBox, TMagicSwitch } from '@tmagic/design';
|
||||||
@ -54,6 +54,11 @@ const props = withDefaults(defineProps<FieldProps<DataSourceMocksConfig>>(), {
|
|||||||
const emit = defineEmits(['change']);
|
const emit = defineEmits(['change']);
|
||||||
|
|
||||||
const { uiService } = useServices();
|
const { uiService } = useServices();
|
||||||
|
const mForm = inject<FormState | undefined>('mForm');
|
||||||
|
|
||||||
|
/** 对比模式下隐藏新增/编辑/删除等操作按钮,仅保留只读展示。 */
|
||||||
|
const isCompare = computed(() => Boolean(mForm?.isCompare));
|
||||||
|
|
||||||
const width = defineModel<number>('width', { default: 670 });
|
const width = defineModel<number>('width', { default: 670 });
|
||||||
|
|
||||||
const drawerTitle = ref('');
|
const drawerTitle = ref('');
|
||||||
@ -202,6 +207,11 @@ const columns: ColumnConfig[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** 对比模式下移除「操作」列(编辑/删除按钮),仅保留只读列。 */
|
||||||
|
const displayColumns = computed<ColumnConfig[]>(() =>
|
||||||
|
isCompare.value ? columns.filter((col) => !col.actions) : columns,
|
||||||
|
);
|
||||||
|
|
||||||
const newHandler = () => {
|
const newHandler = () => {
|
||||||
const isFirstRow = props.model[props.name].length === 0;
|
const isFirstRow = props.model[props.name].length === 0;
|
||||||
formValues.value = {
|
formValues.value = {
|
||||||
|
|||||||
@ -11,7 +11,7 @@
|
|||||||
@change="changeHandler"
|
@change="changeHandler"
|
||||||
></MSelect>
|
></MSelect>
|
||||||
|
|
||||||
<TMagicTooltip v-if="model[name] && hasDataSourceSidePanel" :content="notEditable ? '查看' : '编辑'">
|
<TMagicTooltip v-if="model[name] && hasDataSourceSidePanel && !isCompare" :content="notEditable ? '查看' : '编辑'">
|
||||||
<TMagicButton class="m-fields-select-action-button" :size="size" @click="editHandler"
|
<TMagicButton class="m-fields-select-action-button" :size="size" @click="editHandler"
|
||||||
><MIcon :icon="!notEditable ? Edit : View"></MIcon
|
><MIcon :icon="!notEditable ? Edit : View"></MIcon
|
||||||
></TMagicButton>
|
></TMagicButton>
|
||||||
@ -56,6 +56,9 @@ const dataSources = computed(() => dataSourceService.get('dataSources'));
|
|||||||
|
|
||||||
const notEditable = computed(() => filterFunction(mForm, props.config.notEditable, props));
|
const notEditable = computed(() => filterFunction(mForm, props.config.notEditable, props));
|
||||||
|
|
||||||
|
/** 对比模式下隐藏查看/编辑操作按钮,仅保留只读展示。 */
|
||||||
|
const isCompare = computed(() => Boolean(mForm?.isCompare));
|
||||||
|
|
||||||
const hasDataSourceSidePanel = computed(() =>
|
const hasDataSourceSidePanel = computed(() =>
|
||||||
uiService.get('sideBarItems').find((item) => item.$key === SideItemKey.DATA_SOURCE),
|
uiService.get('sideBarItems').find((item) => item.$key === SideItemKey.DATA_SOURCE),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -6,22 +6,32 @@
|
|||||||
:size="size"
|
:size="size"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:model="model"
|
:model="model"
|
||||||
|
:last-values="lastValues"
|
||||||
|
:is-compare="isCompareMode"
|
||||||
:config="tableConfig"
|
:config="tableConfig"
|
||||||
@change="onChangeHandler"
|
@change="onChangeHandler"
|
||||||
></MTable>
|
></MTable>
|
||||||
|
|
||||||
<div v-else class="fullWidth">
|
<div v-else class="fullWidth">
|
||||||
<TMagicButton class="create-button" type="primary" :size="size" :disabled="disabled" @click="addEvent()"
|
<TMagicButton
|
||||||
|
v-if="!isCompareMode"
|
||||||
|
class="create-button"
|
||||||
|
type="primary"
|
||||||
|
:size="size"
|
||||||
|
:disabled="disabled"
|
||||||
|
@click="addEvent()"
|
||||||
>添加事件</TMagicButton
|
>添加事件</TMagicButton
|
||||||
>
|
>
|
||||||
<MPanel
|
<MPanel
|
||||||
v-for="(cardItem, index) in model[name]"
|
v-for="entry in displayList"
|
||||||
:key="index"
|
:key="entry.index"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:size="size"
|
:size="size"
|
||||||
:prop="`${prop}.${index}`"
|
:prop="`${prop}.${entry.index}`"
|
||||||
:config="actionsConfig"
|
:config="actionsConfig"
|
||||||
:model="cardItem"
|
:model="entry.cardItem"
|
||||||
|
:last-values="entry.lastCardItem"
|
||||||
|
:is-compare="isCompareMode"
|
||||||
:label-width="config.labelWidth || '100px'"
|
:label-width="config.labelWidth || '100px'"
|
||||||
@change="onChangeHandler"
|
@change="onChangeHandler"
|
||||||
>
|
>
|
||||||
@ -29,19 +39,22 @@
|
|||||||
<MFormContainer
|
<MFormContainer
|
||||||
class="fullWidth"
|
class="fullWidth"
|
||||||
:config="eventNameConfig"
|
:config="eventNameConfig"
|
||||||
:model="cardItem"
|
:model="entry.cardItem"
|
||||||
|
:last-values="entry.lastCardItem"
|
||||||
|
:is-compare="isCompareMode"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:size="size"
|
:size="size"
|
||||||
:prop="`${prop}.${index}`"
|
:prop="`${prop}.${entry.index}`"
|
||||||
@change="eventNameChangeHandler"
|
@change="eventNameChangeHandler"
|
||||||
></MFormContainer>
|
></MFormContainer>
|
||||||
<TMagicButton
|
<TMagicButton
|
||||||
|
v-if="!isCompareMode"
|
||||||
style="color: #f56c6c"
|
style="color: #f56c6c"
|
||||||
link
|
link
|
||||||
:icon="Delete"
|
:icon="Delete"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:size="size"
|
:size="size"
|
||||||
@click="removeEvent(Number(index))"
|
@click="removeEvent(Number(entry.index))"
|
||||||
></TMagicButton>
|
></TMagicButton>
|
||||||
</template>
|
</template>
|
||||||
</MPanel>
|
</MPanel>
|
||||||
@ -374,6 +387,42 @@ const isOldVersion = computed(() => {
|
|||||||
return !has(props.model[props.name][0], 'actions');
|
return !has(props.model[props.name][0], 'actions');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对比模式判定:
|
||||||
|
*
|
||||||
|
* event-select 内部由「事件列表 + 嵌套子表单」组成,属于复合字段。父级 `MFormContainer` 已将其
|
||||||
|
* 归入「自接管对比字段」(见 Container.vue 的 `SELF_DIFF_FIELD_TYPES`),即对比时只渲染一次本组件,
|
||||||
|
* 并把当前值 `model` 与历史值 `lastValues` 一并传入,由本组件把 `is-compare`/`lastValues` 透传给
|
||||||
|
* 内部的 MPanel / MFormContainer,逐项(事件名、动作)展示前后差异。
|
||||||
|
*
|
||||||
|
* 仅当存在历史值时才启用对比,避免 lastValues 缺失时退化为「全部新增」的空对比。
|
||||||
|
*/
|
||||||
|
const isCompareMode = computed(() => Boolean(props.isCompare && props.lastValues));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 待渲染的事件卡片列表。
|
||||||
|
*
|
||||||
|
* - 非对比模式:直接映射当前事件列表,`lastCardItem` 为空;
|
||||||
|
* - 对比模式:按索引对齐当前值与历史值,取两者长度的最大值,使得「新增」(仅当前有)与
|
||||||
|
* 「删除」(仅历史有)的事件都能被渲染出来;缺失的一侧用空对象兜底,从而让子级正确高亮差异。
|
||||||
|
*/
|
||||||
|
const displayList = computed<{ cardItem: any; lastCardItem: any; index: number }[]>(() => {
|
||||||
|
const current = props.model[props.name] || [];
|
||||||
|
|
||||||
|
if (!isCompareMode.value) {
|
||||||
|
return current.map((cardItem: any, index: number) => ({ cardItem, lastCardItem: undefined, index }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const last = props.lastValues?.[props.name] || [];
|
||||||
|
const length = Math.max(current.length, last.length);
|
||||||
|
|
||||||
|
return Array.from({ length }, (_, index) => ({
|
||||||
|
cardItem: current[index] ?? {},
|
||||||
|
lastCardItem: last[index] ?? {},
|
||||||
|
index,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
// 添加事件
|
// 添加事件
|
||||||
const addEvent = () => {
|
const addEvent = () => {
|
||||||
const defaultEvent = {
|
const defaultEvent = {
|
||||||
|
|||||||
@ -19,6 +19,7 @@
|
|||||||
></TMagicInput>
|
></TMagicInput>
|
||||||
|
|
||||||
<TMagicButton
|
<TMagicButton
|
||||||
|
v-if="!isCompare"
|
||||||
class="m-fields-key-value-delete"
|
class="m-fields-key-value-delete"
|
||||||
type="danger"
|
type="danger"
|
||||||
:size="size"
|
:size="size"
|
||||||
@ -30,7 +31,14 @@
|
|||||||
></TMagicButton>
|
></TMagicButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TMagicButton type="primary" :size="size" :disabled="disabled" plain :icon="Plus" @click="addHandler"
|
<TMagicButton
|
||||||
|
v-if="!isCompare"
|
||||||
|
type="primary"
|
||||||
|
:size="size"
|
||||||
|
:disabled="disabled"
|
||||||
|
plain
|
||||||
|
:icon="Plus"
|
||||||
|
@click="addHandler"
|
||||||
>添加</TMagicButton
|
>添加</TMagicButton
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@ -52,7 +60,7 @@
|
|||||||
></MagicCodeEditor>
|
></MagicCodeEditor>
|
||||||
|
|
||||||
<TMagicButton
|
<TMagicButton
|
||||||
v-if="config.advanced"
|
v-if="config.advanced && !isCompare"
|
||||||
size="default"
|
size="default"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
link
|
link
|
||||||
@ -63,11 +71,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watchEffect } from 'vue';
|
import { computed, inject, ref, watchEffect } from 'vue';
|
||||||
import { Delete, Plus } from '@element-plus/icons-vue';
|
import { Delete, Plus } from '@element-plus/icons-vue';
|
||||||
|
|
||||||
import { TMagicButton, TMagicInput } from '@tmagic/design';
|
import { TMagicButton, TMagicInput } from '@tmagic/design';
|
||||||
import type { FieldProps, KeyValueConfig } from '@tmagic/form';
|
import type { FieldProps, FormState, KeyValueConfig } from '@tmagic/form';
|
||||||
|
|
||||||
import CodeIcon from '@editor/icons/CodeIcon.vue';
|
import CodeIcon from '@editor/icons/CodeIcon.vue';
|
||||||
import MagicCodeEditor from '@editor/layouts/CodeEditor.vue';
|
import MagicCodeEditor from '@editor/layouts/CodeEditor.vue';
|
||||||
@ -84,6 +92,11 @@ const emit = defineEmits<{
|
|||||||
change: [value: Record<string, any>];
|
change: [value: Record<string, any>];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const mForm = inject<FormState | undefined>('mForm');
|
||||||
|
|
||||||
|
/** 对比模式下隐藏增删/代码切换等操作按钮,仅保留只读展示。 */
|
||||||
|
const isCompare = computed(() => Boolean(mForm?.isCompare));
|
||||||
|
|
||||||
const records = ref<[string, string][]>([]);
|
const records = ref<[string, string][]>([]);
|
||||||
const showCode = ref(false);
|
const showCode = ref(false);
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="m-fields-ui-select" v-else style="display: flex">
|
<div class="m-fields-ui-select" v-else style="display: flex">
|
||||||
<template v-if="val">
|
<template v-if="val">
|
||||||
<TMagicTooltip content="清除" placement="top">
|
<TMagicTooltip v-if="!isCompare" content="清除" placement="top">
|
||||||
<TMagicButton
|
<TMagicButton
|
||||||
style="padding: 0"
|
style="padding: 0"
|
||||||
type="danger"
|
type="danger"
|
||||||
@ -32,7 +32,7 @@
|
|||||||
</TMagicTooltip>
|
</TMagicTooltip>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<TMagicTooltip v-else content="点击此处选择" placement="top">
|
<TMagicTooltip v-else-if="!isCompare" content="点击此处选择" placement="top">
|
||||||
<TMagicButton link style="padding: 0; margin: 0" :disabled="disabled" :size="size" @click="startSelect"
|
<TMagicButton link style="padding: 0; margin: 0" :disabled="disabled" :size="size" @click="startSelect"
|
||||||
>点击此处选择</TMagicButton
|
>点击此处选择</TMagicButton
|
||||||
>
|
>
|
||||||
@ -67,6 +67,9 @@ const mForm = inject<FormState>('mForm');
|
|||||||
const val = computed(() => props.model[props.name]);
|
const val = computed(() => props.model[props.name]);
|
||||||
const uiSelectMode = ref(false);
|
const uiSelectMode = ref(false);
|
||||||
|
|
||||||
|
/** 对比模式下隐藏清除/选择等操作按钮,仅保留只读展示。 */
|
||||||
|
const isCompare = computed(() => Boolean(mForm?.isCompare));
|
||||||
|
|
||||||
const cancelHandler = () => {
|
const cancelHandler = () => {
|
||||||
uiService.set('uiSelectMode', false);
|
uiService.set('uiSelectMode', false);
|
||||||
uiSelectMode.value = false;
|
uiSelectMode.value = false;
|
||||||
|
|||||||
@ -3,9 +3,10 @@ import { cloneDeep } from 'lodash-es';
|
|||||||
|
|
||||||
import type { CodeBlockContent } from '@tmagic/core';
|
import type { CodeBlockContent } from '@tmagic/core';
|
||||||
import { tMagicMessage } from '@tmagic/design';
|
import { tMagicMessage } from '@tmagic/design';
|
||||||
|
import type { ContainerChangeEventData } from '@tmagic/form';
|
||||||
|
|
||||||
import CodeBlockEditor from '@editor/components/CodeBlockEditor.vue';
|
import CodeBlockEditor from '@editor/components/CodeBlockEditor.vue';
|
||||||
import type { Services } from '@editor/type';
|
import type { HistoryOpSource, Services } from '@editor/type';
|
||||||
|
|
||||||
export const useCodeBlockEdit = (codeBlockService: Services['codeBlockService']) => {
|
export const useCodeBlockEdit = (codeBlockService: Services['codeBlockService']) => {
|
||||||
const codeConfig = ref<Omit<CodeBlockContent, 'content'> & { content: string }>();
|
const codeConfig = ref<Omit<CodeBlockContent, 'content'> & { content: string }>();
|
||||||
@ -57,14 +58,17 @@ export const useCodeBlockEdit = (codeBlockService: Services['codeBlockService'])
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 删除代码块
|
// 删除代码块
|
||||||
const deleteCode = async (key: string) => {
|
const deleteCode = async (key: string, { historySource }: { historySource?: HistoryOpSource } = {}) => {
|
||||||
codeBlockService.deleteCodeDslByIds([key]);
|
codeBlockService.deleteCodeDslByIds([key], { historySource });
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitCodeBlockHandler = async (values: CodeBlockContent) => {
|
const submitCodeBlockHandler = async (values: CodeBlockContent, eventData?: ContainerChangeEventData) => {
|
||||||
if (!codeId.value) return;
|
if (!codeId.value) return;
|
||||||
|
|
||||||
await codeBlockService.setCodeDslById(codeId.value, values);
|
await codeBlockService.setCodeDslById(codeId.value, values, {
|
||||||
|
changeRecords: eventData?.changeRecords,
|
||||||
|
historySource: 'props',
|
||||||
|
});
|
||||||
|
|
||||||
codeBlockEditorRef.value?.hide();
|
codeBlockEditorRef.value?.hide();
|
||||||
};
|
};
|
||||||
|
|||||||
@ -27,9 +27,9 @@ export const useDataSourceEdit = (dataSourceService: Services['dataSourceService
|
|||||||
|
|
||||||
const submitDataSourceHandler = (value: DataSourceSchema, eventData: ContainerChangeEventData) => {
|
const submitDataSourceHandler = (value: DataSourceSchema, eventData: ContainerChangeEventData) => {
|
||||||
if (value.id) {
|
if (value.id) {
|
||||||
dataSourceService.update(value, { changeRecords: eventData.changeRecords });
|
dataSourceService.update(value, { changeRecords: eventData.changeRecords, historySource: 'props' });
|
||||||
} else {
|
} else {
|
||||||
dataSourceService.add(value);
|
dataSourceService.add(value, { historySource: 'props' });
|
||||||
}
|
}
|
||||||
|
|
||||||
editDialog.value?.hide();
|
editDialog.value?.hide();
|
||||||
|
|||||||
@ -48,6 +48,7 @@ export const useStage = (stageOptions: StageOptions) => {
|
|||||||
disabledMultiSelect: stageOptions.disabledMultiSelect,
|
disabledMultiSelect: stageOptions.disabledMultiSelect,
|
||||||
alwaysMultiSelect: stageOptions.alwaysMultiSelect,
|
alwaysMultiSelect: stageOptions.alwaysMultiSelect,
|
||||||
disabledRule: stageOptions.disabledRule,
|
disabledRule: stageOptions.disabledRule,
|
||||||
|
disabledFlashTip: stageOptions.disabledFlashTip,
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@ -97,32 +98,48 @@ export const useStage = (stageOptions: StageOptions) => {
|
|||||||
|
|
||||||
stage.on('update', (ev: UpdateEventData) => {
|
stage.on('update', (ev: UpdateEventData) => {
|
||||||
if (ev.parentEl) {
|
if (ev.parentEl) {
|
||||||
for (const data of ev.data) {
|
// 拖动多选元素到一个新容器:整批合成一次 moveToContainer,只产生一条历史记录
|
||||||
const id = getIdFromEl()(data.el);
|
const pId = getIdFromEl()(ev.parentEl);
|
||||||
const pId = getIdFromEl()(ev.parentEl);
|
if (!pId) return;
|
||||||
id && pId && editorService.moveToContainer({ id, style: data.style }, pId);
|
const configs = ev.data
|
||||||
|
.map((data) => {
|
||||||
|
const id = getIdFromEl()(data.el);
|
||||||
|
if (!id) return null;
|
||||||
|
const cfg: MNode = { id, style: data.style };
|
||||||
|
return cfg;
|
||||||
|
})
|
||||||
|
.filter((cfg): cfg is MNode => Boolean(cfg));
|
||||||
|
if (configs.length > 0) {
|
||||||
|
editorService.moveToContainer(configs, pId);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 为每个元素单独更新,确保 changeRecords 与对应的元素关联
|
// 多选拖动 / 多选缩放:所有元素整批走一次 update,避免历史栈被切成 N 条
|
||||||
|
// changeRecordList 与 configs 同序,每个节点保留自己的 records;
|
||||||
|
// 不能把多个节点的 records 合并到同一个数组里,否则 doUpdate / nodeUpdateHandler 会把别的节点的 propPath 当成自己的。
|
||||||
|
const configs: MNode[] = [];
|
||||||
|
const changeRecordList: ReturnType<typeof buildChangeRecords>[] = [];
|
||||||
ev.data.forEach((data) => {
|
ev.data.forEach((data) => {
|
||||||
const id = getIdFromEl()(data.el);
|
const id = getIdFromEl()(data.el);
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
|
|
||||||
const { style = {} } = data;
|
const { style = {} } = data;
|
||||||
|
configs.push({ id, style });
|
||||||
editorService.update({ id, style }, { changeRecords: buildChangeRecords(style, 'style') });
|
changeRecordList.push(buildChangeRecords(style, 'style'));
|
||||||
});
|
});
|
||||||
|
if (configs.length === 0) return;
|
||||||
|
|
||||||
|
editorService.update(configs, { changeRecordList, historySource: 'stage' });
|
||||||
});
|
});
|
||||||
|
|
||||||
stage.on('sort', (ev: SortEventData) => {
|
stage.on('sort', (ev: SortEventData) => {
|
||||||
editorService.sort(ev.src, ev.dist);
|
editorService.sort(ev.src, ev.dist, { historySource: 'stage' });
|
||||||
});
|
});
|
||||||
|
|
||||||
stage.on('remove', (ev: RemoveEventData) => {
|
stage.on('remove', (ev: RemoveEventData) => {
|
||||||
const nodes = ev.data.map(({ el }) => editorService.getNodeById(getIdFromEl()(el) || ''));
|
const nodes = ev.data.map(({ el }) => editorService.getNodeById(getIdFromEl()(el) || ''));
|
||||||
editorService.remove(nodes.filter((node) => Boolean(node)) as MNode[]);
|
editorService.remove(nodes.filter((node) => Boolean(node)) as MNode[], { historySource: 'stage' });
|
||||||
});
|
});
|
||||||
|
|
||||||
stage.on('select-parent', () => {
|
stage.on('select-parent', () => {
|
||||||
|
|||||||
@ -70,6 +70,9 @@ export { default as LayoutContainer } from './components/SplitView.vue';
|
|||||||
export { default as SplitView } from './components/SplitView.vue';
|
export { default as SplitView } from './components/SplitView.vue';
|
||||||
export { default as Resizer } from './components/Resizer.vue';
|
export { default as Resizer } from './components/Resizer.vue';
|
||||||
export { default as CodeBlockEditor } from './components/CodeBlockEditor.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 HistoryDiffDialog } from './layouts/history-list/HistoryDiffDialog.vue';
|
||||||
export { default as FloatingBox } from './components/FloatingBox.vue';
|
export { default as FloatingBox } from './components/FloatingBox.vue';
|
||||||
export { default as Tree } from './components/Tree.vue';
|
export { default as Tree } from './components/Tree.vue';
|
||||||
export { default as TreeNode } from './components/TreeNode.vue';
|
export { default as TreeNode } from './components/TreeNode.vue';
|
||||||
|
|||||||
@ -21,12 +21,12 @@ import { computed, nextTick, onBeforeUnmount, onMounted, onUnmounted, ref, useTe
|
|||||||
import { FullScreen } from '@element-plus/icons-vue';
|
import { FullScreen } from '@element-plus/icons-vue';
|
||||||
import { throttle } from 'lodash-es';
|
import { throttle } from 'lodash-es';
|
||||||
import type * as Monaco from 'monaco-editor';
|
import type * as Monaco from 'monaco-editor';
|
||||||
import serialize from 'serialize-javascript';
|
|
||||||
|
|
||||||
import { TMagicButton } from '@tmagic/design';
|
import { TMagicButton } from '@tmagic/design';
|
||||||
|
|
||||||
import MIcon from '@editor/components/Icon.vue';
|
import MIcon from '@editor/components/Icon.vue';
|
||||||
import { getEditorConfig } from '@editor/utils/config';
|
import { getEditorConfig } from '@editor/utils/config';
|
||||||
|
import { serializeConfig } from '@editor/utils/editor';
|
||||||
import loadMonaco from '@editor/utils/monaco-editor';
|
import loadMonaco from '@editor/utils/monaco-editor';
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
@ -163,10 +163,7 @@ const toString = (v: string | any, language: string): string => {
|
|||||||
if (language === 'json') {
|
if (language === 'json') {
|
||||||
value = JSON.stringify(v, null, 2);
|
value = JSON.stringify(v, null, 2);
|
||||||
} else {
|
} else {
|
||||||
value = serialize(v, {
|
value = serializeConfig(v);
|
||||||
space: 2,
|
|
||||||
unsafe: true,
|
|
||||||
}).replace(/"(\w+)":\s/g, '$1: ');
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
value = v;
|
value = v;
|
||||||
@ -330,6 +327,21 @@ watch(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// diff 模式下,对比的"当前值"(modifiedValues)也可能在外部变化(例如 lastValues 不变、当前 model 变了),
|
||||||
|
// 此时同样需要重新设置编辑器值,否则右侧编辑器内容会停留在初始化时的快照。
|
||||||
|
watch(
|
||||||
|
() => props.modifiedValues,
|
||||||
|
(v, preV) => {
|
||||||
|
if (props.type !== 'diff') return;
|
||||||
|
if (v !== preV) {
|
||||||
|
setEditorValue(props.initValues, props.modifiedValues);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
deep: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.options,
|
() => props.options,
|
||||||
(v) => {
|
(v) => {
|
||||||
|
|||||||
@ -23,15 +23,15 @@
|
|||||||
left-class="m-editor-framework-left"
|
left-class="m-editor-framework-left"
|
||||||
center-class="m-editor-framework-center"
|
center-class="m-editor-framework-center"
|
||||||
right-class="m-editor-framework-right"
|
right-class="m-editor-framework-right"
|
||||||
:left="columnWidth.left"
|
:left="hideSidebar ? undefined : columnWidth.left"
|
||||||
:right="columnWidth.right"
|
:right="columnWidth.right"
|
||||||
:min-left="MIN_LEFT_COLUMN_WIDTH"
|
:min-left="hideSidebar ? 0 : MIN_LEFT_COLUMN_WIDTH"
|
||||||
:min-right="MIN_RIGHT_COLUMN_WIDTH"
|
:min-right="MIN_RIGHT_COLUMN_WIDTH"
|
||||||
:min-center="MIN_CENTER_COLUMN_WIDTH"
|
:min-center="MIN_CENTER_COLUMN_WIDTH"
|
||||||
:width="frameworkRect.width"
|
:width="frameworkRect.width"
|
||||||
@change="columnWidthChange"
|
@change="columnWidthChange"
|
||||||
>
|
>
|
||||||
<template #left>
|
<template v-if="!hideSidebar" #left>
|
||||||
<slot name="sidebar"></slot>
|
<slot name="sidebar"></slot>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -94,10 +94,12 @@ defineOptions({
|
|||||||
name: 'MEditorFramework',
|
name: 'MEditorFramework',
|
||||||
});
|
});
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
disabledPageFragment: boolean;
|
disabledPageFragment: boolean;
|
||||||
pageBarSortOptions?: PageBarSortOptions;
|
pageBarSortOptions?: PageBarSortOptions;
|
||||||
pageFilterFunction?: (_page: MPage | MPageFragment, _keyword: string) => boolean;
|
pageFilterFunction?: (_page: MPage | MPageFragment, _keyword: string) => boolean;
|
||||||
|
/** 是否隐藏左侧面板 */
|
||||||
|
hideSidebar?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const codeOptions = inject('codeOptions', {});
|
const codeOptions = inject('codeOptions', {});
|
||||||
@ -132,7 +134,10 @@ watch(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const columnWidthChange = (columnW: GetColumnWidth) => {
|
const columnWidthChange = (columnW: GetColumnWidth) => {
|
||||||
storageService.setItem(LEFT_COLUMN_WIDTH_STORAGE_KEY, columnW.left, { protocol: Protocol.NUMBER });
|
// 隐藏左侧面板时 left 恒为 0,不覆盖已持久化的左栏宽度,避免重新展示时丢失宽度
|
||||||
|
if (!props.hideSidebar) {
|
||||||
|
storageService.setItem(LEFT_COLUMN_WIDTH_STORAGE_KEY, columnW.left, { protocol: Protocol.NUMBER });
|
||||||
|
}
|
||||||
storageService.setItem(RIGHT_COLUMN_WIDTH_STORAGE_KEY, columnW.right, { protocol: Protocol.NUMBER });
|
storageService.setItem(RIGHT_COLUMN_WIDTH_STORAGE_KEY, columnW.right, { protocol: Protocol.NUMBER });
|
||||||
|
|
||||||
uiService.set('columnWidth', columnW);
|
uiService.set('columnWidth', columnW);
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import { NodeType } from '@tmagic/core';
|
|||||||
import { useServices } from '@editor/hooks/use-services';
|
import { useServices } from '@editor/hooks/use-services';
|
||||||
import { ColumnLayout, MenuBarData, MenuButton, MenuComponent, MenuItem } from '@editor/type';
|
import { ColumnLayout, MenuBarData, MenuButton, MenuComponent, MenuItem } from '@editor/type';
|
||||||
|
|
||||||
|
import HistoryListPanel from './history-list/HistoryListPanel.vue';
|
||||||
import NavMenuColumn from './NavMenuColumn.vue';
|
import NavMenuColumn from './NavMenuColumn.vue';
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
@ -79,7 +80,7 @@ const getConfig = (item: MenuItem): (MenuButton | MenuComponent)[] => {
|
|||||||
disabled: () => editorService.get('node')?.type === NodeType.PAGE,
|
disabled: () => editorService.get('node')?.type === NodeType.PAGE,
|
||||||
handler: () => {
|
handler: () => {
|
||||||
const node = editorService.get('node');
|
const node = editorService.get('node');
|
||||||
node && editorService.remove(node);
|
node && editorService.remove(node, { historySource: 'toolbar' });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
@ -103,6 +104,14 @@ const getConfig = (item: MenuItem): (MenuButton | MenuComponent)[] => {
|
|||||||
handler: () => editorService.redo(),
|
handler: () => editorService.redo(),
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
case 'history-list':
|
||||||
|
// 历史记录面板:以 component 形式挂入,自带 popover;点击 nav 上的图标弹出。
|
||||||
|
config.push({
|
||||||
|
type: 'component',
|
||||||
|
className: 'history-list',
|
||||||
|
component: markRaw(HistoryListPanel),
|
||||||
|
});
|
||||||
|
break;
|
||||||
case 'zoom-in':
|
case 'zoom-in':
|
||||||
config.push({
|
config.push({
|
||||||
type: 'button',
|
type: 'button',
|
||||||
|
|||||||
125
packages/editor/src/layouts/history-list/Bucket.vue
Normal file
125
packages/editor/src/layouts/history-list/Bucket.vue
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
<template>
|
||||||
|
<div class="m-editor-history-list-bucket">
|
||||||
|
<div class="m-editor-history-list-bucket-title">
|
||||||
|
<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="`${prefix}-${bucketId}-${group.steps[0]?.index}`"
|
||||||
|
:group-key="`${prefix}-${bucketId}-${group.steps[0]?.index}`"
|
||||||
|
:applied="group.applied"
|
||||||
|
:merged="group.steps.length > 1"
|
||||||
|
:op-type="group.opType"
|
||||||
|
:desc="describeGroup(group)"
|
||||||
|
:source="groupSource(group)"
|
||||||
|
:time="formatHistoryTime(groupTimestamp(group))"
|
||||||
|
:time-title="formatHistoryFullTime(groupTimestamp(group))"
|
||||||
|
:step-count="group.steps.length"
|
||||||
|
:sub-steps="
|
||||||
|
group.steps.map((s) => ({
|
||||||
|
index: s.index,
|
||||||
|
applied: s.applied,
|
||||||
|
isCurrent: s.isCurrent,
|
||||||
|
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),
|
||||||
|
}))
|
||||||
|
"
|
||||||
|
:is-current="group.isCurrent"
|
||||||
|
:expanded="!!expanded[`${prefix}-${bucketId}-${group.steps[0]?.index}`]"
|
||||||
|
:goto-enabled="gotoEnabled"
|
||||||
|
@toggle="(key: string) => $emit('toggle', key)"
|
||||||
|
@goto="(index: number) => $emit('goto', bucketId, index)"
|
||||||
|
@diff-step="(index: number) => $emit('diff-step', bucketId, index)"
|
||||||
|
@revert-step="(index: number) => $emit('revert-step', bucketId, index)"
|
||||||
|
/>
|
||||||
|
<!--
|
||||||
|
初始状态项:永远位于该 bucket 列表底部(同样按倒序展示,最底部 = 最早状态)。
|
||||||
|
当 bucket 内所有 group 都未 applied 时即为当前位置。
|
||||||
|
showInitial=false 时不展示(用于没有"撤销到初始状态"语义的自定义历史,如业务模块历史)。
|
||||||
|
-->
|
||||||
|
<InitialRow
|
||||||
|
v-if="showInitial !== false"
|
||||||
|
:is-current="isInitial"
|
||||||
|
:goto-enabled="gotoEnabled"
|
||||||
|
@goto-initial="$emit('goto-initial', bucketId)"
|
||||||
|
/>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup generic="T extends BaseStepValue = BaseStepValue">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
import type { BaseStepValue } from '@editor/type';
|
||||||
|
|
||||||
|
import type { HistoryBucketGroup } from './composables';
|
||||||
|
import { formatHistoryFullTime, formatHistoryTime, groupSource, groupTimestamp } from './composables';
|
||||||
|
import GroupRow from './GroupRow.vue';
|
||||||
|
import InitialRow from './InitialRow.vue';
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'MEditorHistoryListBucket',
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
/** Bucket 标题,例如 "数据源" / "代码块",渲染在 bucket 头部。 */
|
||||||
|
title: string;
|
||||||
|
/** 当前 bucket 对应的目标 id(dataSource.id 或 codeBlock.id),同时用于组装子项的 key。 */
|
||||||
|
bucketId: string | number;
|
||||||
|
/**
|
||||||
|
* 子项 key 的命名空间前缀:内置 `ds` 表示数据源,`cb` 表示代码块;
|
||||||
|
* 业务方复用 Bucket 时可传入自定义前缀(如 `mod`)。与上层折叠状态 key 保持一致。
|
||||||
|
*/
|
||||||
|
prefix: string;
|
||||||
|
/** 是否展示底部「回到初始状态」入口,默认 true。无 undo cursor 语义的自定义历史可传 false 关闭。 */
|
||||||
|
showInitial?: boolean;
|
||||||
|
/** 当前 bucket 下的所有历史分组,按时间倒序展示(最近的操作在前)。 */
|
||||||
|
groups: HistoryBucketGroup<T>[];
|
||||||
|
/** 组级描述文案生成器,接收一个 group,返回展示文本。由父组件按业务类型注入。 */
|
||||||
|
describeGroup: (_group: any) => string;
|
||||||
|
/** 单步描述文案生成器,接收一个 step,返回展示文本。用于合并组展开后的子步列表。 */
|
||||||
|
describeStep: (_step: T) => string;
|
||||||
|
/** 判断某个 step 是否可查看差异(前后值都存在)。由父组件按业务类型注入;不传则一律不展示差异入口。 */
|
||||||
|
isStepDiffable?: (_step: T) => boolean;
|
||||||
|
/** 判断某个 step 是否支持回滚(如更新需带 changeRecords)。由父组件按业务类型注入;不传则已应用即可回滚。 */
|
||||||
|
isStepRevertable?: (_step: T) => boolean;
|
||||||
|
/** 共享的折叠状态表(key -> 是否展开),由顶层 panel 统一维护以便跨 tab 复用。 */
|
||||||
|
expanded: Record<string, boolean>;
|
||||||
|
/** 是否支持「跳转到该记录」(goto)。默认 true。 */
|
||||||
|
gotoEnabled?: boolean;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
showInitial: true,
|
||||||
|
gotoEnabled: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
/** 透传子组件 GroupRow 的 toggle,由上层 panel 更新 expanded。 */
|
||||||
|
(_e: 'toggle', _key: string): void;
|
||||||
|
/**
|
||||||
|
* 透传子组件 GroupRow 的 goto,并附带当前 bucket 对应的 id(dataSourceId / codeBlockId),
|
||||||
|
* 上层据此调用对应 service.goto(id, targetCursor)。
|
||||||
|
*/
|
||||||
|
(_e: 'goto', _bucketId: string | number, _index: number): void;
|
||||||
|
/** 用户点击初始项希望该 bucket 回到未修改状态;携带 bucketId 用于上层路由到正确的 service。 */
|
||||||
|
(_e: 'goto-initial', _bucketId: string | number): void;
|
||||||
|
/** 用户点击"查看差异",携带 bucketId 与 step 索引。 */
|
||||||
|
(_e: 'diff-step', _bucketId: string | number, _index: number): void;
|
||||||
|
/** 用户点击"回滚"按钮,携带 bucketId 与 step 索引,类 git revert。 */
|
||||||
|
(_e: 'revert-step', _bucketId: string | number, _index: number): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
/** 该 bucket 是否处于初始状态(栈 cursor=0),等价于全部 group 都未 applied。 */
|
||||||
|
const isInitial = computed(() => props.groups.length > 0 && props.groups.every((g) => !g.applied));
|
||||||
|
</script>
|
||||||
90
packages/editor/src/layouts/history-list/BucketTab.vue
Normal file
90
packages/editor/src/layouts/history-list/BucketTab.vue
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
<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="`清空${title}的历史记录`" @click="$emit('clear')">清空</span>
|
||||||
|
</div>
|
||||||
|
<TMagicScrollbar max-height="360px">
|
||||||
|
<Bucket
|
||||||
|
v-for="bucket in buckets"
|
||||||
|
:key="`${prefix}-${bucket.id}`"
|
||||||
|
:title="title"
|
||||||
|
:bucket-id="bucket.id"
|
||||||
|
:prefix="prefix"
|
||||||
|
:groups="bucket.groups"
|
||||||
|
:describe-group="describeGroup"
|
||||||
|
:describe-step="describeStep"
|
||||||
|
:is-step-diffable="isStepDiffable"
|
||||||
|
:is-step-revertable="isStepRevertable"
|
||||||
|
:expanded="expanded"
|
||||||
|
:goto-enabled="gotoEnabled"
|
||||||
|
@toggle="(key: string) => $emit('toggle', key)"
|
||||||
|
@goto="(id: string | number, index: number) => $emit('goto', id, index)"
|
||||||
|
@goto-initial="(id: string | number) => $emit('goto-initial', id)"
|
||||||
|
@diff-step="(id: string | number, index: number) => $emit('diff-step', id, index)"
|
||||||
|
@revert-step="(id: string | number, index: number) => $emit('revert-step', id, index)"
|
||||||
|
/>
|
||||||
|
</TMagicScrollbar>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup generic="T extends BaseStepValue = BaseStepValue">
|
||||||
|
import { TMagicScrollbar } from '@tmagic/design';
|
||||||
|
|
||||||
|
import type { BaseStepValue } from '@editor/type';
|
||||||
|
|
||||||
|
import Bucket from './Bucket.vue';
|
||||||
|
import type { HistoryBucketGroup } from './composables';
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'MEditorHistoryListBucketTab',
|
||||||
|
});
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
/** bucket 头部展示的标题,例如 "数据源" / "代码块"。 */
|
||||||
|
title: string;
|
||||||
|
/** 子项 key 的命名空间前缀(`ds` 数据源 / `cb` 代码块),与上层折叠状态 key 保持一致。 */
|
||||||
|
prefix: string;
|
||||||
|
/**
|
||||||
|
* 已按目标 id 聚拢成的 bucket 列表,每个 bucket 内部的 groups 已按时间倒序排好。
|
||||||
|
* 空数组时显示空态。
|
||||||
|
*/
|
||||||
|
buckets: { id: string | number; groups: HistoryBucketGroup<T>[] }[];
|
||||||
|
/** 组级描述文案生成器,由父组件按业务类型注入。 */
|
||||||
|
describeGroup: (_group: any) => string;
|
||||||
|
/** 单步描述文案生成器,由父组件按业务类型注入。 */
|
||||||
|
describeStep: (_step: T) => string;
|
||||||
|
/** 判断某个 step 是否可查看差异(前后值都存在)。由父组件按业务类型注入。 */
|
||||||
|
isStepDiffable: (_step: T) => boolean;
|
||||||
|
/** 判断某个 step 是否支持回滚(如更新需带 changeRecords)。由父组件按业务类型注入;不传则已应用即可回滚。 */
|
||||||
|
isStepRevertable?: (_step: T) => boolean;
|
||||||
|
/**
|
||||||
|
* 共享的折叠状态表(key -> 是否展开),由顶层 panel 统一维护。
|
||||||
|
* 本 tab 使用 `${prefix}-${id}-${组内首步 index}` 作为 key——以稳定的 step 索引而非展示位置标识分组,
|
||||||
|
* 这样历史数据更新后已展开的分组状态仍能正确保持。
|
||||||
|
*/
|
||||||
|
expanded: Record<string, boolean>;
|
||||||
|
/** 是否支持「跳转到该记录」(goto),透传给 Bucket。默认 true。 */
|
||||||
|
gotoEnabled?: boolean;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
gotoEnabled: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
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>
|
||||||
273
packages/editor/src/layouts/history-list/GroupRow.vue
Normal file
273
packages/editor/src/layouts/history-list/GroupRow.vue
Normal file
@ -0,0 +1,273 @@
|
|||||||
|
<template>
|
||||||
|
<li
|
||||||
|
class="m-editor-history-list-item m-editor-history-list-group"
|
||||||
|
:class="{ 'is-undone': !applied, 'is-merged': merged, 'is-current': isCurrent }"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="m-editor-history-list-group-head"
|
||||||
|
:class="{ 'is-clickable': isHeadClickable }"
|
||||||
|
:title="headTitle"
|
||||||
|
@click="onHeadClick"
|
||||||
|
>
|
||||||
|
<span class="m-editor-history-list-item-index" :title="headIndexTitle">{{ headIndexLabel }}</span>
|
||||||
|
<span class="m-editor-history-list-item-op" :class="`op-${opType}`">{{ opLabel(opType) }}</span>
|
||||||
|
<span class="m-editor-history-list-item-desc">{{ desc }}</span>
|
||||||
|
|
||||||
|
<span v-if="headSaved" class="m-editor-history-list-item-saved" title="该记录为最近一次保存的状态">已保存</span>
|
||||||
|
|
||||||
|
<span
|
||||||
|
v-if="!merged && sourceLabel(source)"
|
||||||
|
class="m-editor-history-list-item-source"
|
||||||
|
:title="`操作途径:${sourceLabel(source)}`"
|
||||||
|
>{{ sourceLabel(source) }}</span
|
||||||
|
>
|
||||||
|
|
||||||
|
<span v-if="!merged && time" class="m-editor-history-list-item-time" :title="timeTitle || time">{{ 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 && gotoEnabled && !isCurrent && subSteps.length"
|
||||||
|
class="m-editor-history-list-item-goto"
|
||||||
|
title="回到该记录"
|
||||||
|
@click.stop="onGotoClick(subSteps[0].index)"
|
||||||
|
>回到</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="!merged && headDiffable"
|
||||||
|
class="m-editor-history-list-item-diff"
|
||||||
|
title="查看修改差异"
|
||||||
|
@click.stop="onDiffClick(subSteps[0].index)"
|
||||||
|
>查看差异</span
|
||||||
|
>
|
||||||
|
<span v-if="merged" class="m-editor-history-list-group-toggle" :class="{ 'is-expanded': expanded }">▾</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul v-if="merged && expanded" class="m-editor-history-list-substeps">
|
||||||
|
<li
|
||||||
|
v-for="s in subStepsDisplay"
|
||||||
|
:key="s.index"
|
||||||
|
:class="{ 'is-undone': !s.applied, 'is-current': s.isCurrent }"
|
||||||
|
:title="subStepTitle(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="sourceLabel(s.source)"
|
||||||
|
class="m-editor-history-list-item-source"
|
||||||
|
:title="`操作途径:${sourceLabel(s.source)}`"
|
||||||
|
>{{ sourceLabel(s.source) }}</span
|
||||||
|
>
|
||||||
|
<span v-if="s.time" class="m-editor-history-list-item-time" :title="s.timeTitle || s.time">{{ s.time }}</span>
|
||||||
|
<span
|
||||||
|
v-if="s.revertable"
|
||||||
|
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
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
import type { HistoryOpSource, HistoryOpType } from '@editor/type';
|
||||||
|
|
||||||
|
import { opLabel, sourceLabel } from './composables';
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'MEditorHistoryListGroupRow',
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
/** 唯一标识当前组的 key,作为 toggle 事件的 payload 回传给上层。形如 `pg-${首步 index}` / `ds-${id}-${首步 index}` / `cb-${id}-${首步 index}`,以稳定的 step 索引标识分组。 */
|
||||||
|
groupKey: string;
|
||||||
|
/** 该组当前是否处于已应用状态(false 表示已被 undo 撤销,UI 会显示为灰态)。 */
|
||||||
|
applied: boolean;
|
||||||
|
/** 是否为合并组(即组内 step 数大于 1,由多次连续操作合并而来)。决定是否展示合并标记与可展开的子步列表。 */
|
||||||
|
merged: boolean;
|
||||||
|
/** 操作类型:`add` / `remove` / `update`,用于决定操作徽标的颜色和文案。 */
|
||||||
|
opType: HistoryOpType;
|
||||||
|
/** 组的整体描述文案,由上层根据 step / group 计算后传入,例如 "修改 button · style.color"。 */
|
||||||
|
desc: string;
|
||||||
|
/** 组的操作途径(一般取组内最近一步),用于头部展示「画布 / 树面板 / 配置面板…」标签。 */
|
||||||
|
source?: HistoryOpSource;
|
||||||
|
/** 组头部展示的时间文案(一般为组内最近一步的时间),为空时不渲染。 */
|
||||||
|
time?: string;
|
||||||
|
/** 组头部时间的 title 悬浮提示(完整时间),缺省时回退为 time。 */
|
||||||
|
timeTitle?: string;
|
||||||
|
/** 组内的 step 总数,仅在 merged 为 true 时显示为 "合并 N 步"。 */
|
||||||
|
stepCount: number;
|
||||||
|
/** 子步列表,用于在展开状态下逐条展示每个 step 的索引、应用状态与描述文案。 */
|
||||||
|
subSteps: {
|
||||||
|
index: number;
|
||||||
|
applied: boolean;
|
||||||
|
desc: string;
|
||||||
|
isCurrent?: boolean;
|
||||||
|
/** 该子步是否为最近一次保存的记录,用于展示「已保存」标记。 */
|
||||||
|
saved?: boolean;
|
||||||
|
diffable?: boolean;
|
||||||
|
/** 是否可对该子步执行「回滚」(已应用 + 业务侧确认支持反向)。父级根据 step 与 applied 决定。 */
|
||||||
|
revertable?: boolean;
|
||||||
|
/** 该子步的操作途径,用于展示「画布 / 树面板 / 配置面板…」标签。 */
|
||||||
|
source?: HistoryOpSource;
|
||||||
|
/** 该子步的时间文案,为空时不渲染。 */
|
||||||
|
time?: string;
|
||||||
|
/** 该子步时间的 title 悬浮提示(完整时间),缺省时回退为 time。 */
|
||||||
|
timeTitle?: string;
|
||||||
|
}[];
|
||||||
|
/** 当前组是否处于展开状态。仅在 merged 为 true 时生效,控制子步列表是否渲染。 */
|
||||||
|
expanded: boolean;
|
||||||
|
/** 是否为当前所在的分组(包含栈中最近一次已应用步骤的那一组),UI 高亮展示。 */
|
||||||
|
isCurrent?: boolean;
|
||||||
|
/**
|
||||||
|
* 是否支持「跳转到该记录」(goto)。默认 true。
|
||||||
|
* 为 false 时:单步组头部与子步条目都不再可点击跳转、也不会触发 goto 事件,
|
||||||
|
* 仅保留合并组头部的展开 / 收起能力,以及查看差异、回滚等其它入口。
|
||||||
|
*/
|
||||||
|
gotoEnabled?: boolean;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
isCurrent: false,
|
||||||
|
gotoEnabled: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
/**
|
||||||
|
* 用户点击合并组头部时触发,携带 groupKey;上层用其切换 expanded 状态。
|
||||||
|
* 对单步组(非合并)头部点击不会发该事件——因为单步组没有"展开"的概念。
|
||||||
|
*/
|
||||||
|
(_e: 'toggle', _key: string): void;
|
||||||
|
/**
|
||||||
|
* 用户希望跳转到该记录时触发,携带"目标 step 在所属栈中的索引"——上层据此计算目标 cursor (= index + 1)。
|
||||||
|
* 触发场景:
|
||||||
|
* - 单步组(merged=false)头部:取该唯一 step 的 index;
|
||||||
|
* - 子步条目:取该子步的 index。
|
||||||
|
* 合并组头部不再触发 goto,避免与展开/收起冲突;用户应展开后点具体子步精准跳转。
|
||||||
|
* 当前所在的步骤(isCurrent)始终不会触发 goto。
|
||||||
|
*/
|
||||||
|
(_e: 'goto', _index: number): void;
|
||||||
|
/**
|
||||||
|
* 用户希望查看该 step 的修改差异(旧值 vs 新值)。
|
||||||
|
* 只在 step 满足"前后值都存在"(如 update / 数据源、代码块的 update)时由父级标记 `diffable=true`。
|
||||||
|
* payload 为该 step 在所属栈中的索引,由上层根据 index 取 step 内容并展示对比。
|
||||||
|
*/
|
||||||
|
(_e: 'diff-step', _index: number): void;
|
||||||
|
/**
|
||||||
|
* 用户希望「回滚」该 step——把它的修改作为一次新操作反向应用(类 git revert)。
|
||||||
|
* payload 为该 step 在所属栈中的索引。仅在单步组头部(headRevertable)或合并组的可回滚子步上触发。
|
||||||
|
*/
|
||||||
|
(_e: 'revert-step', _index: number): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仅合并组头部可点击(切换展开 / 收起);
|
||||||
|
* 单步组的跳转改由头部的「回退」按钮触发,整行不再可点击。
|
||||||
|
*/
|
||||||
|
const isHeadClickable = computed(() => props.merged);
|
||||||
|
|
||||||
|
const headTitle = computed(() => {
|
||||||
|
if (props.merged) return props.expanded ? '点击收起子步' : '点击展开子步';
|
||||||
|
if (props.isCurrent) return '当前所在记录';
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 头部点击行为:仅合并组切换展开 / 收起;单步组不再响应整行点击。
|
||||||
|
*/
|
||||||
|
const onHeadClick = () => {
|
||||||
|
if (props.merged) {
|
||||||
|
emit('toggle', props.groupKey);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onGotoClick = (index: number) => {
|
||||||
|
if (!props.gotoEnabled) return;
|
||||||
|
emit('goto', index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const subStepTitle = (s: { isCurrent?: boolean }) => {
|
||||||
|
if (s.isCurrent) return '当前所在记录';
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 头部是否展示「已保存」标记:
|
||||||
|
* - 单步组:取该唯一子步的 saved;
|
||||||
|
* - 合并组:组内任一子步为已保存即在头部提示(具体落在哪一步可展开查看)。
|
||||||
|
*/
|
||||||
|
const headSaved = computed(() =>
|
||||||
|
props.merged ? props.subSteps.some((s) => s.saved) : Boolean(props.subSteps[0]?.saved),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 单步组头部是否展示"查看差异"入口:要求该唯一子步本身可对比。 */
|
||||||
|
const headDiffable = computed(() => !props.merged && Boolean(props.subSteps[0]?.diffable));
|
||||||
|
|
||||||
|
/** 单步组头部是否展示"回滚"入口:要求该唯一子步本身可回滚(已应用)。 */
|
||||||
|
const headRevertable = computed(() => !props.merged && Boolean(props.subSteps[0]?.revertable));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合并组展开后的子步渲染顺序:与外层分组列表保持一致——倒序展示(最新的子步在最上方)。
|
||||||
|
* 外层 page tab / bucket 都已对 groups 做了 reverse,子步沿用同样的视觉规则更直观。
|
||||||
|
* 注意:仅用于渲染,原 `subSteps` 保持时间正序,`headIndexLabel` 等基于首尾索引的展示语义不变。
|
||||||
|
*/
|
||||||
|
const subStepsDisplay = computed(() => props.subSteps.slice().reverse());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 头部索引展示:
|
||||||
|
* - 单步组(merged=false):显示该唯一 step 的编号,如 `#5`;
|
||||||
|
* - 合并组:显示组内 step 的编号范围,如 `#3-#7`(首尾相同则退化为 `#5`)。
|
||||||
|
*
|
||||||
|
* 这里展示的是 step.index + 1(与子步列表 `#{{ s.index + 1 }}` 保持一致),从 1 起编号更符合直觉。
|
||||||
|
*/
|
||||||
|
const headIndexLabel = computed(() => {
|
||||||
|
const list = props.subSteps;
|
||||||
|
if (!list.length) return '';
|
||||||
|
const first = list[0].index + 1;
|
||||||
|
const last = list[list.length - 1].index + 1;
|
||||||
|
if (!props.merged || first === last) return `#${first}`;
|
||||||
|
return `#${first}-#${last}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const headIndexTitle = computed(() => {
|
||||||
|
if (!props.merged) return `历史步骤编号 #${props.subSteps[0]?.index + 1}`;
|
||||||
|
return `合并了第 ${props.subSteps[0]?.index + 1} 至第 ${
|
||||||
|
props.subSteps[props.subSteps.length - 1]?.index + 1
|
||||||
|
} 共 ${props.subSteps.length} 条历史步骤`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const onDiffClick = (index: number) => {
|
||||||
|
emit('diff-step', index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRevertClick = (index: number) => {
|
||||||
|
emit('revert-step', index);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
232
packages/editor/src/layouts/history-list/HistoryDiffDialog.vue
Normal file
232
packages/editor/src/layouts/history-list/HistoryDiffDialog.vue
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<TMagicDialog
|
||||||
|
v-model="visible"
|
||||||
|
class="m-editor-history-diff-dialog"
|
||||||
|
:title="dialogTitle"
|
||||||
|
top="5vh"
|
||||||
|
destroy-on-close
|
||||||
|
append-to-body
|
||||||
|
:width="width"
|
||||||
|
@close="onClose"
|
||||||
|
>
|
||||||
|
<div v-if="payload" class="m-editor-history-diff-dialog-body">
|
||||||
|
<div v-if="onConfirm" class="m-editor-history-diff-dialog-notice">仅回滚有差异的字段</div>
|
||||||
|
|
||||||
|
<div class="m-editor-history-diff-dialog-header">
|
||||||
|
<span class="m-editor-history-diff-dialog-target">{{ targetText }}</span>
|
||||||
|
<div class="m-editor-history-diff-dialog-controls">
|
||||||
|
<TMagicRadioGroup v-model="viewMode" size="small" class="m-editor-history-diff-dialog-view">
|
||||||
|
<TMagicRadioButton value="form">表单对比</TMagicRadioButton>
|
||||||
|
<TMagicRadioButton value="code">源码对比</TMagicRadioButton>
|
||||||
|
</TMagicRadioGroup>
|
||||||
|
|
||||||
|
<TMagicRadioGroup v-model="mode" size="small" class="m-editor-history-diff-dialog-mode">
|
||||||
|
<TMagicRadioButton value="before">与修改前对比</TMagicRadioButton>
|
||||||
|
<TMagicRadioButton value="current" :disabled="!hasCurrent">与当前对比</TMagicRadioButton>
|
||||||
|
</TMagicRadioGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="m-editor-history-diff-dialog-legend">
|
||||||
|
<TMagicTag size="small" type="danger">{{ leftLabel }}</TMagicTag>
|
||||||
|
<span class="m-editor-history-diff-dialog-arrow">→</span>
|
||||||
|
<TMagicTag size="small" type="success">{{ rightLabel }}</TMagicTag>
|
||||||
|
<span v-if="mode === 'current' && isSameAsCurrent" class="m-editor-history-diff-dialog-tip">
|
||||||
|
当前值与该步修改后一致,无差异
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CompareForm
|
||||||
|
v-if="viewMode === 'form'"
|
||||||
|
:category="payload.category"
|
||||||
|
:type="payload.type"
|
||||||
|
:data-source-type="payload.dataSourceType"
|
||||||
|
:value="rightValue"
|
||||||
|
:last-value="leftValue"
|
||||||
|
:extend-state="extendState"
|
||||||
|
:load-config="loadConfig"
|
||||||
|
:self-diff-field-types="selfDiffFieldTypes"
|
||||||
|
height="70vh"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CodeEditor
|
||||||
|
v-else
|
||||||
|
type="diff"
|
||||||
|
language="json"
|
||||||
|
:init-values="leftValue"
|
||||||
|
:modified-values="rightValue"
|
||||||
|
:options="codeDiffOptions"
|
||||||
|
disabled-full-screen
|
||||||
|
height="70vh"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<template v-if="onConfirm">
|
||||||
|
<TMagicButton size="small" @click="visible = false">取消</TMagicButton>
|
||||||
|
<TMagicButton size="small" type="primary" @click="onConfirmClick">确定回滚</TMagicButton>
|
||||||
|
</template>
|
||||||
|
<TMagicButton v-else size="small" @click="visible = false">关闭</TMagicButton>
|
||||||
|
</template>
|
||||||
|
</TMagicDialog>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
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 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;
|
||||||
|
onConfirm?: () => void;
|
||||||
|
selfDiffFieldTypes?: string[];
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
width: '900px',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits(['close']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 差异对比模式:
|
||||||
|
* - before:该步骤修改前 vs 该步骤修改后(默认行为,体现这一步带来的变化)
|
||||||
|
* - current:该步骤修改后 vs 当前最新值(用于查看「该步骤之后是否还被改过」)
|
||||||
|
*/
|
||||||
|
type DiffMode = 'before' | 'current';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 展示形态:
|
||||||
|
* - form:以属性表单形式逐字段对比(默认,可读性更好)
|
||||||
|
* - code:以 JSON 源码形式做整体 diff(贴近"看原始数据差异",可看到表单未覆盖的字段)
|
||||||
|
*/
|
||||||
|
type ViewMode = 'form' | 'code';
|
||||||
|
|
||||||
|
const visible = ref(false);
|
||||||
|
const payload = ref<DiffDialogPayload | null>(null);
|
||||||
|
const mode = ref<DiffMode>('before');
|
||||||
|
const viewMode = ref<ViewMode>('form');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 源码对比始终只读,关闭小地图,强制左右并排(side-by-side)展示。
|
||||||
|
*
|
||||||
|
* monaco diff 编辑器在宽度低于 `renderSideBySideInlineBreakpoint`(默认 900px)时
|
||||||
|
* 会自动退化为 inline(上下/单栏)视图。本弹窗宽度约 900px,去掉内边距后编辑器实际
|
||||||
|
* 宽度小于该阈值,会被切到 inline。这里通过 `useInlineViewWhenSpaceIsLimited: false`
|
||||||
|
* 关闭该自动降级,确保始终保持左右两栏对比。
|
||||||
|
*/
|
||||||
|
const codeDiffOptions = {
|
||||||
|
readOnly: true,
|
||||||
|
tabSize: 2,
|
||||||
|
minimap: { enabled: false },
|
||||||
|
renderSideBySide: true,
|
||||||
|
useInlineViewWhenSpaceIsLimited: false,
|
||||||
|
scrollBeyondLastLine: false,
|
||||||
|
hideUnchangedRegions: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const dialogTitle = computed(() => (props.onConfirm ? '确认回滚' : '查看修改差异'));
|
||||||
|
|
||||||
|
const hasCurrent = computed(() => payload.value?.currentValue !== undefined && payload.value?.currentValue !== null);
|
||||||
|
|
||||||
|
/** 左侧(旧/参照)值 */
|
||||||
|
const leftValue = computed<Record<string, any>>(() => {
|
||||||
|
if (!payload.value) return {};
|
||||||
|
if (mode.value === 'current') return payload.value.value;
|
||||||
|
return payload.value.lastValue;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 右侧(新/对比)值 */
|
||||||
|
const rightValue = computed<Record<string, any>>(() => {
|
||||||
|
if (!payload.value) return {};
|
||||||
|
if (mode.value === 'current') return payload.value.currentValue || {};
|
||||||
|
return payload.value.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const leftLabel = computed(() => (mode.value === 'current' ? '该步修改后' : '修改前'));
|
||||||
|
const rightLabel = computed(() => (mode.value === 'current' ? '当前' : '修改后'));
|
||||||
|
|
||||||
|
/** 「与当前对比」模式下,若当前值与该步修改后值相等,则展示提示 */
|
||||||
|
const isSameAsCurrent = computed(() => {
|
||||||
|
if (mode.value !== 'current' || !payload.value) return false;
|
||||||
|
return isEqual(payload.value.value, payload.value.currentValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
const onConfirmClick = () => {
|
||||||
|
const cb = props.onConfirm;
|
||||||
|
|
||||||
|
cb?.();
|
||||||
|
|
||||||
|
visible.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const targetText = computed(() => {
|
||||||
|
if (!payload.value) return '';
|
||||||
|
const categoryText: Record<CompareCategory, string> = {
|
||||||
|
node: '节点',
|
||||||
|
'data-source': '数据源',
|
||||||
|
'code-block': '代码块',
|
||||||
|
};
|
||||||
|
const { category } = payload.value;
|
||||||
|
const prefix = category ? categoryText[category] : '';
|
||||||
|
const label = payload.value.targetLabel || payload.value.type || '';
|
||||||
|
const { id } = payload.value;
|
||||||
|
const labelWithId = id !== undefined && id !== '' ? `${label}(${id})` : label;
|
||||||
|
return [prefix, labelWithId].filter(Boolean).join(':');
|
||||||
|
});
|
||||||
|
|
||||||
|
const open = (p: DiffDialogPayload) => {
|
||||||
|
payload.value = p;
|
||||||
|
// 每次打开按需重置默认模式:有当前值时优先「与当前对比」更贴近"看现在差什么",否则回退到默认
|
||||||
|
mode.value = 'before';
|
||||||
|
// 默认回到表单对比形态,避免残留上一次选择的源码模式
|
||||||
|
viewMode.value = 'form';
|
||||||
|
visible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
visible.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 关闭后清理 payload,避免下一次打开时残留旧值闪现
|
||||||
|
watch(visible, (v) => {
|
||||||
|
if (!v) {
|
||||||
|
payload.value = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const onClose = () => {
|
||||||
|
emit('close');
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
open,
|
||||||
|
close,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
450
packages/editor/src/layouts/history-list/HistoryListPanel.vue
Normal file
450
packages/editor/src/layouts/history-list/HistoryListPanel.vue
Normal file
@ -0,0 +1,450 @@
|
|||||||
|
<template>
|
||||||
|
<TMagicPopover
|
||||||
|
popper-class="m-editor-history-list-popover"
|
||||||
|
placement="bottom"
|
||||||
|
trigger="click"
|
||||||
|
:visible="visible"
|
||||||
|
:width="660"
|
||||||
|
>
|
||||||
|
<div class="m-editor-history-list">
|
||||||
|
<TMagicTooltip effect="dark" placement="top" content="关闭">
|
||||||
|
<TMagicButton class="m-editor-history-list-close" size="small" link @click="visible = false">
|
||||||
|
<template #icon>
|
||||||
|
<MIcon :icon="CloseIcon"></MIcon>
|
||||||
|
</template>
|
||||||
|
</TMagicButton>
|
||||||
|
</TMagicTooltip>
|
||||||
|
|
||||||
|
<TMagicTabs v-model="activeTab" class="m-editor-history-list-tabs">
|
||||||
|
<component
|
||||||
|
:is="tabPaneComponent?.component || 'el-tab-pane'"
|
||||||
|
v-bind="tabPaneComponent?.props({ name: 'page', label: `页面 (${pageGroups.length})` }) || {}"
|
||||||
|
>
|
||||||
|
<PageTab
|
||||||
|
:list="pageGroupsDisplay"
|
||||||
|
:expanded="expanded"
|
||||||
|
@toggle="toggleGroup"
|
||||||
|
@goto="onPageGoto"
|
||||||
|
@goto-initial="onPageGotoInitial"
|
||||||
|
@diff-step="onPageDiff"
|
||||||
|
@revert-step="onPageRevert"
|
||||||
|
@clear="onPageClear"
|
||||||
|
/>
|
||||||
|
</component>
|
||||||
|
|
||||||
|
<component
|
||||||
|
v-if="!disabledDataSource"
|
||||||
|
:is="tabPaneComponent?.component || 'el-tab-pane'"
|
||||||
|
v-bind="tabPaneComponent?.props({ name: 'data-source', label: `数据源 (${dataSourceGroups.length})` }) || {}"
|
||||||
|
>
|
||||||
|
<BucketTab
|
||||||
|
title="数据源"
|
||||||
|
prefix="ds"
|
||||||
|
:buckets="dataSourceGroupsByTarget"
|
||||||
|
:expanded="expanded"
|
||||||
|
:describe-group="describeDataSourceGroup"
|
||||||
|
:describe-step="describeDataSourceStep"
|
||||||
|
:is-step-diffable="isDataSourceStepDiffable"
|
||||||
|
:is-step-revertable="isDataSourceStepRevertable"
|
||||||
|
@toggle="toggleGroup"
|
||||||
|
@goto="onDataSourceGoto"
|
||||||
|
@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
|
||||||
|
title="代码块"
|
||||||
|
prefix="cb"
|
||||||
|
:buckets="codeBlockGroupsByTarget"
|
||||||
|
:expanded="expanded"
|
||||||
|
:describe-group="describeCodeBlockGroup"
|
||||||
|
:describe-step="describeCodeBlockStep"
|
||||||
|
:is-step-diffable="isCodeBlockStepDiffable"
|
||||||
|
:is-step-revertable="isCodeBlockStepRevertable"
|
||||||
|
@toggle="toggleGroup"
|
||||||
|
@goto="onCodeBlockGoto"
|
||||||
|
@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>
|
||||||
|
|
||||||
|
<template #reference>
|
||||||
|
<TMagicTooltip effect="dark" placement="bottom" content="历史记录">
|
||||||
|
<TMagicButton size="small" link @click="visible = !visible">
|
||||||
|
<template #icon>
|
||||||
|
<MIcon :icon="ClockIcon"></MIcon>
|
||||||
|
</template>
|
||||||
|
</TMagicButton>
|
||||||
|
</TMagicTooltip>
|
||||||
|
</template>
|
||||||
|
</TMagicPopover>
|
||||||
|
|
||||||
|
<HistoryDiffDialog
|
||||||
|
ref="diffDialog"
|
||||||
|
:extend-state="extendFormState"
|
||||||
|
:on-confirm="onConfirmRevert"
|
||||||
|
@close="onDiffDialogClose"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 历史记录面板:在顶部 NavMenu 上点击图标打开 popover,分三个 tab:
|
||||||
|
* - 页面:当前活动页面的历史栈,连续修改同一节点的多步会被合并成一组
|
||||||
|
* - 数据源:以 dataSource.id 分组,每组内部相邻的连续 update 自动合并
|
||||||
|
* - 代码块:同上,按 codeBlock.id 分组并合并相邻 update
|
||||||
|
*
|
||||||
|
* 数据通过 historyService 暴露的聚合 API 读取,UI 仅用于只读展示,
|
||||||
|
* 同时支持点击任意一条记录跳转至该状态:
|
||||||
|
* - 页面 tab:调用 editorService.gotoPageStep(targetCursor)
|
||||||
|
* - 数据源 tab:调用 dataSourceService.goto(id, targetCursor)
|
||||||
|
* - 代码块 tab:调用 codeBlockService.goto(id, targetCursor)
|
||||||
|
*
|
||||||
|
* 这里的 targetCursor = 用户点击的 step.index + 1,即"应用至此步完成的状态"。
|
||||||
|
*
|
||||||
|
* 此外每条 step 上提供"查看差异"入口(仅在前后值都存在的 update 步骤显示),
|
||||||
|
* 点击后弹出 HistoryDiffDialog,使用 CompareForm 组件以表单形式展示新旧值差异。
|
||||||
|
*
|
||||||
|
* 各 tab 的内容拆分为独立的 SFC:页面用 PageTab,数据源 / 代码块复用通用的 BucketTab
|
||||||
|
* (通过 title / prefix / describe* / isStepDiffable 注入差异)。
|
||||||
|
* 共享的描述生成与折叠状态在 composables.ts 中维护。
|
||||||
|
*/
|
||||||
|
import { computed, inject, markRaw, ref, shallowRef, useTemplateRef, watch } from 'vue';
|
||||||
|
import { Clock, Close } from '@element-plus/icons-vue';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getDesignConfig,
|
||||||
|
TMagicButton,
|
||||||
|
tMagicMessageBox,
|
||||||
|
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, HistoryListExtraTab } from '@editor/type';
|
||||||
|
|
||||||
|
import BucketTab from './BucketTab.vue';
|
||||||
|
import {
|
||||||
|
describeCodeBlockGroup,
|
||||||
|
describeCodeBlockStep,
|
||||||
|
describeDataSourceGroup,
|
||||||
|
describeDataSourceStep,
|
||||||
|
isCodeBlockStepRevertable,
|
||||||
|
isDataSourceStepRevertable,
|
||||||
|
useHistoryList,
|
||||||
|
} from './composables';
|
||||||
|
import HistoryDiffDialog from './HistoryDiffDialog.vue';
|
||||||
|
import PageTab from './PageTab.vue';
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'MEditorHistoryListPanel',
|
||||||
|
});
|
||||||
|
|
||||||
|
const ClockIcon = markRaw(Clock);
|
||||||
|
const CloseIcon = markRaw(Close);
|
||||||
|
const activeTab = ref<string>('page');
|
||||||
|
|
||||||
|
/** 面板显隐受控: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 } = 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';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过 inject 拿到 Editor 顶层注入的 `extendFormState`,转交给 HistoryDiffDialog
|
||||||
|
* 内部的 CompareForm,使差异对比表单的 filterFunction 能拿到完整的业务上下文。
|
||||||
|
* 未提供时为 undefined,CompareForm/MForm 会跳过 extendState 处理。
|
||||||
|
*/
|
||||||
|
const extendFormState = inject<((_state: FormState) => Record<string, any> | Promise<Record<string, any>>) | undefined>(
|
||||||
|
'extendFormState',
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
expanded,
|
||||||
|
toggleGroup,
|
||||||
|
pageGroups,
|
||||||
|
dataSourceGroups,
|
||||||
|
codeBlockGroups,
|
||||||
|
pageGroupsDisplay,
|
||||||
|
dataSourceGroupsByTarget,
|
||||||
|
codeBlockGroupsByTarget,
|
||||||
|
} = useHistoryList();
|
||||||
|
|
||||||
|
/** 数据源 step 仅 update(前后 schema 都存在)时可查看差异。 */
|
||||||
|
const isDataSourceStepDiffable = (step: DataSourceStepValue) => Boolean(step.oldSchema && step.newSchema);
|
||||||
|
|
||||||
|
/** 代码块 step 仅 update(前后 content 都存在)时可查看差异。 */
|
||||||
|
const isCodeBlockStepDiffable = (step: CodeBlockStepValue) => Boolean(step.oldContent && step.newContent);
|
||||||
|
|
||||||
|
/** 把"目标 step 索引"翻译成"目标 cursor"(已应用步骤数量)。 */
|
||||||
|
const indexToCursor = (index: number) => index + 1;
|
||||||
|
|
||||||
|
const onPageGoto = (index: number) => {
|
||||||
|
editorService.gotoPageStep(indexToCursor(index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDataSourceGoto = (id: string | number, index: number) => {
|
||||||
|
dataSourceService.goto(id, indexToCursor(index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCodeBlockGoto = (id: string | number, index: number) => {
|
||||||
|
codeBlockService.goto(id, indexToCursor(index));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "回到初始状态" = 把对应栈 cursor 移到 0(全部已撤销)。
|
||||||
|
* 复用 service.goto*(0) 即可,所有真实 step 的反向应用由 service 层的 undo 链路完成。
|
||||||
|
*/
|
||||||
|
const onPageGotoInitial = () => {
|
||||||
|
editorService.gotoPageStep(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDataSourceGotoInitial = (id: string | number) => {
|
||||||
|
dataSourceService.goto(id, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCodeBlockGotoInitial = (id: string | number) => {
|
||||||
|
codeBlockService.goto(id, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const diffDialogRef = useTemplateRef<InstanceType<typeof HistoryDiffDialog>>('diffDialog');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造页面 step 的差异弹窗入参:仅 update 单节点修改可对比,传入旧/新节点。
|
||||||
|
* 节点类型 `type` 优先取 newNode.type,再回退 oldNode.type。
|
||||||
|
* `currentValue` 取自 editorService 中该节点当前实际值,用于支持「与当前对比」。
|
||||||
|
* 无可对比内容(如多节点 / add / remove)时返回 null。
|
||||||
|
*/
|
||||||
|
const buildPageDiffPayload = (index: number): DiffDialogPayload | null => {
|
||||||
|
const groups = historyService.getPageHistoryGroups();
|
||||||
|
for (const group of groups) {
|
||||||
|
const entry = group.steps.find((s) => s.index === index);
|
||||||
|
if (!entry) continue;
|
||||||
|
const item = entry.step.updatedItems?.[0];
|
||||||
|
if (!item?.oldNode || !item?.newNode) return null;
|
||||||
|
const type = (item.newNode.type as string) || (item.oldNode.type as string) || '';
|
||||||
|
const nodeId = item.newNode.id ?? item.oldNode.id;
|
||||||
|
const currentNode = nodeId !== undefined ? editorService.getNodeById(nodeId) : null;
|
||||||
|
return {
|
||||||
|
category: 'node',
|
||||||
|
type,
|
||||||
|
lastValue: item.oldNode as Record<string, any>,
|
||||||
|
value: item.newNode as Record<string, any>,
|
||||||
|
currentValue: (currentNode as Record<string, any>) || null,
|
||||||
|
targetLabel: (item.newNode.name as string) || (item.oldNode.name as string) || type,
|
||||||
|
id: nodeId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在指定分组列表中按 id / index 查找命中的 step,命中后交由 build 构造差异弹窗入参。
|
||||||
|
* 用于统一数据源、代码块两类历史的查找逻辑。
|
||||||
|
*/
|
||||||
|
const findGroupStep = <G extends { id: string | number; steps: { index: number; step: any }[] }>(
|
||||||
|
groups: G[],
|
||||||
|
id: string | number,
|
||||||
|
index: number,
|
||||||
|
build: (_step: G['steps'][number]['step']) => DiffDialogPayload | null,
|
||||||
|
): DiffDialogPayload | null => {
|
||||||
|
for (const group of groups) {
|
||||||
|
if (group.id !== id) continue;
|
||||||
|
const entry = group.steps.find((s) => s.index === index);
|
||||||
|
if (!entry) continue;
|
||||||
|
return build(entry.step);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildDataSourceDiffPayload = (id: string | number, index: number): DiffDialogPayload | null =>
|
||||||
|
findGroupStep(historyService.getDataSourceHistoryGroups(), id, index, ({ oldSchema, newSchema }) => {
|
||||||
|
if (!oldSchema || !newSchema) return null;
|
||||||
|
const currentSchema = dataSourceService.getDataSourceById(`${id}`);
|
||||||
|
return {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildCodeBlockDiffPayload = (id: string | number, index: number): DiffDialogPayload | null =>
|
||||||
|
findGroupStep(historyService.getCodeBlockHistoryGroups(), id, index, ({ oldContent, newContent }) => {
|
||||||
|
if (!oldContent || !newContent) return null;
|
||||||
|
const currentContent = codeBlockService.getCodeContentById(id);
|
||||||
|
return {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onConfirmRevert = shallowRef();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「回滚」入口:把目标历史步骤的修改作为一次新操作反向应用(类 git revert),
|
||||||
|
* 不破坏原有栈结构。各 service 内部完成反向 + 入栈,并自带描述用于面板展示。
|
||||||
|
*
|
||||||
|
* 交互:先弹出该步骤的差异弹窗供用户确认,点击「确定回滚」后再真正执行回滚;
|
||||||
|
* 对没有可对比内容的步骤(如 add / remove / 多节点更新)则直接回滚。
|
||||||
|
*/
|
||||||
|
const onPageRevert = (index: number) => {
|
||||||
|
const payload = buildPageDiffPayload(index);
|
||||||
|
onConfirmRevert.value = () => editorService.revertPageStep(index);
|
||||||
|
if (payload) {
|
||||||
|
diffDialogRef.value?.open({ ...payload });
|
||||||
|
} else {
|
||||||
|
onConfirmRevert.value();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDataSourceRevert = (id: string | number, index: number) => {
|
||||||
|
const payload = buildDataSourceDiffPayload(id, index);
|
||||||
|
onConfirmRevert.value = () => dataSourceService.revert(id, index);
|
||||||
|
if (payload) {
|
||||||
|
diffDialogRef.value?.open({ ...payload });
|
||||||
|
} else {
|
||||||
|
onConfirmRevert.value();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCodeBlockRevert = (id: string | number, index: number) => {
|
||||||
|
const payload = buildCodeBlockDiffPayload(id, index);
|
||||||
|
onConfirmRevert.value = () => codeBlockService.revert(id, index);
|
||||||
|
if (payload) {
|
||||||
|
diffDialogRef.value?.open({ ...payload });
|
||||||
|
} else {
|
||||||
|
onConfirmRevert.value();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDiffDialogClose = () => {
|
||||||
|
onConfirmRevert.value = undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「清空历史记录」入口:先弹出二次确认,确认后清空对应类别的历史栈。
|
||||||
|
* 仅删除撤销/重做记录,不会改动当前 DSL / 数据源 / 代码块本身。
|
||||||
|
* 用户取消(confirm reject)时静默忽略。
|
||||||
|
*/
|
||||||
|
const confirmClear = async (message: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
await tMagicMessageBox.confirm(message, '提示', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把内存中(已清空对应类别后的)历史状态重新写回 IndexedDB,
|
||||||
|
* 使本地持久化的那份与内存保持一致——即「连同本地保存的一并删除」。
|
||||||
|
* 不支持 IndexedDB 或写入失败时静默忽略(内存清空已生效)。
|
||||||
|
*/
|
||||||
|
const syncIndexedDB = async () => {
|
||||||
|
try {
|
||||||
|
await historyService.saveToIndexedDB();
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
} catch (e) {
|
||||||
|
// ignore: 内存清空已生效,本地同步失败不阻塞交互
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPageClear = async () => {
|
||||||
|
if (
|
||||||
|
await confirmClear('确定清空当前页面的历史记录吗?清空后将无法撤销/重做之前的操作,本地保存的记录也会一并删除。')
|
||||||
|
) {
|
||||||
|
historyService.clearPage();
|
||||||
|
await syncIndexedDB();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDataSourceClear = async () => {
|
||||||
|
if (await confirmClear('确定清空数据源的历史记录吗?清空后将无法撤销/重做之前的操作,本地保存的记录也会一并删除。')) {
|
||||||
|
historyService.clearDataSource();
|
||||||
|
await syncIndexedDB();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCodeBlockClear = async () => {
|
||||||
|
if (await confirmClear('确定清空代码块的历史记录吗?清空后将无法撤销/重做之前的操作,本地保存的记录也会一并删除。')) {
|
||||||
|
historyService.clearCodeBlock();
|
||||||
|
await syncIndexedDB();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
52
packages/editor/src/layouts/history-list/InitialRow.vue
Normal file
52
packages/editor/src/layouts/history-list/InitialRow.vue
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
<template>
|
||||||
|
<li
|
||||||
|
class="m-editor-history-list-item m-editor-history-list-initial"
|
||||||
|
:class="{ 'is-current': isCurrent, 'is-clickable': !isCurrent }"
|
||||||
|
:title="isCurrent ? '当前已回到未修改的初始状态' : '点击回到未修改的初始状态'"
|
||||||
|
>
|
||||||
|
<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">未修改的初始状态</span>
|
||||||
|
<span
|
||||||
|
v-if="gotoEnabled && !isCurrent"
|
||||||
|
class="m-editor-history-list-item-goto"
|
||||||
|
title="回到该记录"
|
||||||
|
@click.stop="onClick"
|
||||||
|
>回到</span
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 「初始状态」记录行:渲染于历史列表底部,作为整个栈的"零点"。
|
||||||
|
* - 点击该行会把对应栈撤销到 cursor === 0(即没有任何已应用步骤),等同于回到所有修改之前。
|
||||||
|
* - 当对应栈本身已处于 cursor === 0 时(isCurrent=true),用户已在初始状态,点击不再触发动作。
|
||||||
|
*
|
||||||
|
* 该行不是真实 step,仅作为 UI 入口;上层负责把"点击"翻译为 `service.goto*(0)`。
|
||||||
|
*/
|
||||||
|
defineOptions({
|
||||||
|
name: 'MEditorHistoryListInitialRow',
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
/** 当前对应栈是否已经处于初始状态 (cursor === 0)。true 时用蓝条高亮并禁用点击。 */
|
||||||
|
isCurrent: boolean;
|
||||||
|
gotoEnabled?: boolean;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
gotoEnabled: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
/** 点击非当前的初始项时触发,由上层调用对应 service 的 goto 把 cursor 移到 0。 */
|
||||||
|
(_e: 'goto-initial'): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const onClick = () => {
|
||||||
|
if (props.isCurrent) return;
|
||||||
|
emit('goto-initial');
|
||||||
|
};
|
||||||
|
</script>
|
||||||
120
packages/editor/src/layouts/history-list/PageTab.vue
Normal file
120
packages/editor/src/layouts/history-list/PageTab.vue
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="!list.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="清空当前页面的历史记录" @click="$emit('clear')">清空</span>
|
||||||
|
</div>
|
||||||
|
<TMagicScrollbar max-height="360px">
|
||||||
|
<ul class="m-editor-history-list-ul">
|
||||||
|
<GroupRow
|
||||||
|
v-for="group in list"
|
||||||
|
:key="`pg-${group.steps[0]?.index}`"
|
||||||
|
:group-key="`pg-${group.steps[0]?.index}`"
|
||||||
|
:applied="group.applied"
|
||||||
|
:merged="group.steps.length > 1"
|
||||||
|
:op-type="group.opType"
|
||||||
|
:desc="describePageGroup(group)"
|
||||||
|
:source="groupSource(group)"
|
||||||
|
:time="formatHistoryTime(groupTimestamp(group))"
|
||||||
|
:time-title="formatHistoryFullTime(groupTimestamp(group))"
|
||||||
|
:step-count="group.steps.length"
|
||||||
|
:sub-steps="
|
||||||
|
group.steps.map((s) => ({
|
||||||
|
index: s.index,
|
||||||
|
applied: s.applied,
|
||||||
|
isCurrent: s.isCurrent,
|
||||||
|
saved: s.step.saved,
|
||||||
|
desc: describePageStep(s.step),
|
||||||
|
diffable: isPageStepDiffable(s.step),
|
||||||
|
revertable: s.applied && isPageStepRevertable(s.step),
|
||||||
|
source: s.step.source,
|
||||||
|
time: formatHistoryTime(s.step.timestamp),
|
||||||
|
timeTitle: formatHistoryFullTime(s.step.timestamp),
|
||||||
|
}))
|
||||||
|
"
|
||||||
|
:is-current="group.isCurrent"
|
||||||
|
:expanded="!!expanded[`pg-${group.steps[0]?.index}`]"
|
||||||
|
@toggle="(key: string) => $emit('toggle', key)"
|
||||||
|
@goto="(index: number) => $emit('goto', index)"
|
||||||
|
@diff-step="(index: number) => $emit('diff-step', index)"
|
||||||
|
@revert-step="(index: number) => $emit('revert-step', index)"
|
||||||
|
/>
|
||||||
|
<!--
|
||||||
|
初始状态项:永远位于列表底部(页面 tab 倒序展示,最底部=最早),
|
||||||
|
作为"未修改"零点。当所有 group 都未 applied 时它即为当前位置。
|
||||||
|
-->
|
||||||
|
<InitialRow :is-current="isInitial" @goto-initial="$emit('goto-initial')" />
|
||||||
|
</ul>
|
||||||
|
</TMagicScrollbar>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
import { TMagicScrollbar } from '@tmagic/design';
|
||||||
|
|
||||||
|
import type { PageHistoryGroup, StepValue } from '@editor/type';
|
||||||
|
|
||||||
|
import {
|
||||||
|
describePageGroup,
|
||||||
|
describePageStep,
|
||||||
|
formatHistoryFullTime,
|
||||||
|
formatHistoryTime,
|
||||||
|
groupSource,
|
||||||
|
groupTimestamp,
|
||||||
|
isPageStepRevertable,
|
||||||
|
} from './composables';
|
||||||
|
import GroupRow from './GroupRow.vue';
|
||||||
|
import InitialRow from './InitialRow.vue';
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'MEditorHistoryListPageTab',
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
/** 当前活动页面的历史分组列表,已按时间倒序排好(最新一组在最前)。空数组时显示空态。 */
|
||||||
|
list: PageHistoryGroup[];
|
||||||
|
/**
|
||||||
|
* 共享的折叠状态表(key -> 是否展开),由顶层 panel 统一维护。
|
||||||
|
* 本 tab 使用 `pg-${组内首步 index}` 作为 key——以稳定的 step 索引而非展示位置标识分组,
|
||||||
|
* 这样历史数据更新(新增 / 撤销重做导致列表顺序变化)后,已展开的分组状态仍能正确保持。
|
||||||
|
*/
|
||||||
|
expanded: Record<string, boolean>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
/** 透传 GroupRow 的 toggle 事件给上层 panel,由其更新 expanded。 */
|
||||||
|
(_e: 'toggle', _key: string): void;
|
||||||
|
/** 透传 GroupRow 的 goto 事件,携带目标 step 在栈中的索引。 */
|
||||||
|
(_e: 'goto', _index: number): void;
|
||||||
|
/** 用户点击初始项希望回到未修改的状态(cursor=0)。 */
|
||||||
|
(_e: 'goto-initial'): void;
|
||||||
|
/** 用户点击"查看差异"按钮,携带目标 step 在栈中的索引。 */
|
||||||
|
(_e: 'diff-step', _index: number): void;
|
||||||
|
/** 用户点击"回滚"按钮,携带目标 step 在栈中的索引,类 git revert。 */
|
||||||
|
(_e: 'revert-step', _index: number): void;
|
||||||
|
/** 用户点击"清空"按钮,请求清空当前页面的历史记录(由上层弹窗二次确认后执行)。 */
|
||||||
|
(_e: 'clear'): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前 step 是否可查看差异:
|
||||||
|
* - 仅 update 操作;
|
||||||
|
* - 单节点更新(updatedItems.length === 1),且 oldNode / newNode 都存在。
|
||||||
|
* 多节点更新难以选定单一对比目标,统一不展示差异入口。
|
||||||
|
*/
|
||||||
|
const isPageStepDiffable = (step: StepValue): boolean => {
|
||||||
|
if (step.opType !== 'update') return false;
|
||||||
|
const items = step.updatedItems ?? [];
|
||||||
|
if (items.length !== 1) return false;
|
||||||
|
return Boolean(items[0]?.oldNode && items[0]?.newNode);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否处于"初始状态"——即对应页面历史栈 cursor===0:
|
||||||
|
* 当 list 中所有 group 的 applied 都为 false 时即为该状态。
|
||||||
|
* 没有任何 group 的情况由外层"暂无操作记录"分支兜底,本计算可以不考虑。
|
||||||
|
*/
|
||||||
|
const isInitial = computed(() => props.list.length > 0 && props.list.every((g) => !g.applied));
|
||||||
|
</script>
|
||||||
302
packages/editor/src/layouts/history-list/composables.ts
Normal file
302
packages/editor/src/layouts/history-list/composables.ts
Normal file
@ -0,0 +1,302 @@
|
|||||||
|
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,
|
||||||
|
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 }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 历史记录面板共享逻辑:
|
||||||
|
* - 暴露三类历史的聚合数据(页面 / 数据源 / 代码块);
|
||||||
|
* - 提供折叠状态管理;
|
||||||
|
* - 提供操作描述文案生成器。
|
||||||
|
*
|
||||||
|
* 所有数据基于 historyService 的 reactive state 派生,自动跟随历史变化刷新。
|
||||||
|
*/
|
||||||
|
export const useHistoryList = () => {
|
||||||
|
const { historyService } = useServices();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 折叠状态:key 形如 `pg-${组内首步 index}` / `ds-${id}-${组内首步 index}` / `cb-${id}-${组内首步 index}`。
|
||||||
|
* 用组内首步的稳定 index(而非展示位置)作为 key,确保历史数据更新后已展开的分组状态保持不变。
|
||||||
|
*/
|
||||||
|
const expanded = reactive<Record<string, boolean>>({});
|
||||||
|
const toggleGroup = (key: string) => {
|
||||||
|
expanded[key] = !expanded[key];
|
||||||
|
};
|
||||||
|
|
||||||
|
const pageGroups = computed(() => historyService.getPageHistoryGroups());
|
||||||
|
const dataSourceGroups = computed(() => historyService.getDataSourceHistoryGroups());
|
||||||
|
const codeBlockGroups = computed(() => historyService.getCodeBlockHistoryGroups());
|
||||||
|
|
||||||
|
/** 页面 tab 倒序展示(最新一组在最上面)。 */
|
||||||
|
const pageGroupsDisplay = computed(() => pageGroups.value.slice().reverse());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把按时间正序的 group 列表,再按 id 聚拢成 bucket(同 id 的所有分组放一起)。
|
||||||
|
* 每个 bucket 内部仍然按时间倒序展示(最近的操作最先看到)。
|
||||||
|
*/
|
||||||
|
const groupByTarget = <G extends { id: string | number }>(groups: G[]) => {
|
||||||
|
const map = new Map<string | number, G[]>();
|
||||||
|
groups.forEach((g) => {
|
||||||
|
const list = map.get(g.id) ?? [];
|
||||||
|
list.push(g);
|
||||||
|
map.set(g.id, list);
|
||||||
|
});
|
||||||
|
return Array.from(map.entries()).map(([id, gs]) => ({ id, groups: gs.slice().reverse() }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const dataSourceGroupsByTarget = computed(() => groupByTarget(dataSourceGroups.value));
|
||||||
|
const codeBlockGroupsByTarget = computed(() => groupByTarget(codeBlockGroups.value));
|
||||||
|
|
||||||
|
return {
|
||||||
|
expanded,
|
||||||
|
toggleGroup,
|
||||||
|
pageGroups,
|
||||||
|
dataSourceGroups,
|
||||||
|
codeBlockGroups,
|
||||||
|
pageGroupsDisplay,
|
||||||
|
dataSourceGroupsByTarget,
|
||||||
|
codeBlockGroupsByTarget,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 历史面板的时间展示:
|
||||||
|
* - 当天的记录只显示 `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':
|
||||||
|
return '新增';
|
||||||
|
case 'remove':
|
||||||
|
return '删除';
|
||||||
|
case 'update':
|
||||||
|
default:
|
||||||
|
return '修改';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 内置操作途径的中文文案;自定义来源直接回显原值,未知 / 缺省返回空串(UI 据此不渲染)。 */
|
||||||
|
const HISTORY_SOURCE_LABELS: Record<string, string> = {
|
||||||
|
stage: '画布',
|
||||||
|
tree: '树面板',
|
||||||
|
'component-panel': '组件面板',
|
||||||
|
props: '配置面板',
|
||||||
|
code: '源码',
|
||||||
|
'stage-contextmenu': '画布菜单',
|
||||||
|
'tree-contextmenu': '树菜单',
|
||||||
|
toolbar: '工具栏',
|
||||||
|
shortcut: '快捷键',
|
||||||
|
rollback: '回滚',
|
||||||
|
api: '接口',
|
||||||
|
ai: 'AI',
|
||||||
|
unknown: '未知',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 操作途径文案:用于历史面板展示「画布 / 树面板 / 配置面板…」标签。 */
|
||||||
|
export const sourceLabel = (source: HistoryOpSource = 'unknown'): string => {
|
||||||
|
return HISTORY_SOURCE_LABELS[source] ?? `${source}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 取一组历史步骤里最后一步(最近一次)的操作途径,用于组头部展示。 */
|
||||||
|
export const groupSource = (group: { steps: { step: { source?: HistoryOpSource } }[] }): HistoryOpSource | undefined =>
|
||||||
|
group.steps[group.steps.length - 1]?.step.source;
|
||||||
|
|
||||||
|
const nameOf = (node: { name?: string; id?: string | number; type?: string }) =>
|
||||||
|
node?.name || node?.type || `${node?.id ?? ''}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 默认描述里展示「名称 (id: xxx)」,便于区分同名实体。
|
||||||
|
* - 当未传入 id,或 label 本身就是 id 字符串(即没有 name/type/title 可用)时,仅展示 label,避免出现「123 (id: 123)」。
|
||||||
|
*/
|
||||||
|
const labelWithId = (label: string | number | undefined, id: string | number | undefined): string => {
|
||||||
|
const labelStr = label === undefined || label === null ? '' : `${label}`;
|
||||||
|
if (id === undefined || id === null || id === '') return labelStr;
|
||||||
|
if (labelStr === '' || labelStr === `${id}`) return `${id}`;
|
||||||
|
return `${labelStr} (id: ${id})`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 从一组可选 historyDescription 中取最后一条非空值;都为空时返回 undefined。 */
|
||||||
|
const pickLastDescription = (descs: (string | undefined)[]): string | undefined => {
|
||||||
|
for (let i = descs.length - 1; i >= 0; i--) {
|
||||||
|
if (descs[i]) return descs[i];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const describePageStep = (step: StepValue) => {
|
||||||
|
if (step.historyDescription) return step.historyDescription;
|
||||||
|
const { opType } = step;
|
||||||
|
if (opType === 'add') {
|
||||||
|
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 = step.removedItems?.length ?? 0;
|
||||||
|
const node = step.removedItems?.[0]?.node;
|
||||||
|
return `删除 ${count} 个节点${count === 1 && node ? `(${labelWithId(nameOf(node), node.id)})` : ''}`;
|
||||||
|
}
|
||||||
|
const updated = step.updatedItems ?? [];
|
||||||
|
if (!updated.length) return '修改节点';
|
||||||
|
if (updated.length === 1) {
|
||||||
|
const { newNode, changeRecords } = updated[0];
|
||||||
|
const propPath = changeRecords?.[0]?.propPath;
|
||||||
|
const target = labelWithId(nameOf(newNode), newNode?.id);
|
||||||
|
return `修改 ${target}${propPath ? ` · ${propPath}` : ''}`;
|
||||||
|
}
|
||||||
|
return `修改 ${updated.length} 个节点`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合并组的展示文案:
|
||||||
|
* - 若组内任一步显式提供了 historyDescription:取最后一条非空 historyDescription(最近一次的描述更准确);
|
||||||
|
* - 单步组:复用 describePageStep;
|
||||||
|
* - 多步组(连续修改同一节点):展示节点名 + 涉及的前几个 propPath。
|
||||||
|
*/
|
||||||
|
export const describePageGroup = (group: PageHistoryGroup) => {
|
||||||
|
const lastDesc = pickLastDescription(group.steps.map((s) => s.step.historyDescription));
|
||||||
|
if (lastDesc) return lastDesc;
|
||||||
|
if (group.steps.length === 1) return describePageStep(group.steps[0].step);
|
||||||
|
const paths = new Set<string>();
|
||||||
|
group.steps.forEach((s) => {
|
||||||
|
s.step.updatedItems?.[0]?.changeRecords?.forEach((r) => r.propPath && paths.add(r.propPath));
|
||||||
|
});
|
||||||
|
const pathList = Array.from(paths).slice(0, 3).join(', ');
|
||||||
|
const target = labelWithId(
|
||||||
|
group.targetName ?? (group.targetId !== undefined ? `${group.targetId}` : '节点'),
|
||||||
|
group.targetId,
|
||||||
|
);
|
||||||
|
return pathList ? `修改 ${target} · ${pathList}${paths.size > 3 ? '…' : ''}` : `修改 ${target}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const describeDataSourceStep = (step: DataSourceStepValue) => {
|
||||||
|
if (step.historyDescription) return step.historyDescription;
|
||||||
|
if (step.oldSchema === null && step.newSchema)
|
||||||
|
return `创建 ${labelWithId(step.newSchema.title, step.newSchema.id ?? step.id)}`;
|
||||||
|
if (step.newSchema === null && step.oldSchema)
|
||||||
|
return `删除 ${labelWithId(step.oldSchema.title, step.oldSchema.id ?? step.id)}`;
|
||||||
|
const propPath = step.changeRecords?.[0]?.propPath;
|
||||||
|
const title = labelWithId(step.newSchema?.title || step.oldSchema?.title, step.id);
|
||||||
|
return propPath ? `修改 ${title} · ${propPath}` : `修改 ${title}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const describeDataSourceGroup = (group: DataSourceHistoryGroup) => {
|
||||||
|
const lastDesc = pickLastDescription(group.steps.map((s) => s.step.historyDescription));
|
||||||
|
if (lastDesc) return lastDesc;
|
||||||
|
if (group.steps.length === 1) return describeDataSourceStep(group.steps[0].step);
|
||||||
|
const paths = new Set<string>();
|
||||||
|
group.steps.forEach((s) => {
|
||||||
|
s.step.changeRecords?.forEach((r) => r.propPath && paths.add(r.propPath));
|
||||||
|
});
|
||||||
|
const pathList = Array.from(paths).slice(0, 3).join(', ');
|
||||||
|
const rawTitle = group.steps[group.steps.length - 1].step.newSchema?.title || group.steps[0].step.oldSchema?.title;
|
||||||
|
const target = labelWithId(rawTitle, group.id);
|
||||||
|
return pathList ? `修改 ${target} · ${pathList}${paths.size > 3 ? '…' : ''}` : `修改 ${target}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const describeCodeBlockStep = (step: CodeBlockStepValue) => {
|
||||||
|
if (step.historyDescription) return step.historyDescription;
|
||||||
|
if (step.oldContent === null && step.newContent)
|
||||||
|
return `创建 ${labelWithId(step.newContent.name, step.newContent.id ?? step.id)}`;
|
||||||
|
if (step.newContent === null && step.oldContent)
|
||||||
|
return `删除 ${labelWithId(step.oldContent.name, step.oldContent.id ?? step.id)}`;
|
||||||
|
const propPath = step.changeRecords?.[0]?.propPath;
|
||||||
|
const title = labelWithId(step.newContent?.name || step.oldContent?.name, step.id);
|
||||||
|
return propPath ? `修改 ${title} · ${propPath}` : `修改 ${title}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const describeCodeBlockGroup = (group: CodeBlockHistoryGroup) => {
|
||||||
|
const lastDesc = pickLastDescription(group.steps.map((s) => s.step.historyDescription));
|
||||||
|
if (lastDesc) return lastDesc;
|
||||||
|
if (group.steps.length === 1) return describeCodeBlockStep(group.steps[0].step);
|
||||||
|
const paths = new Set<string>();
|
||||||
|
group.steps.forEach((s) => {
|
||||||
|
s.step.changeRecords?.forEach((r) => r.propPath && paths.add(r.propPath));
|
||||||
|
});
|
||||||
|
const pathList = Array.from(paths).slice(0, 3).join(', ');
|
||||||
|
const rawName = group.steps[group.steps.length - 1].step.newContent?.name || group.steps[0].step.oldContent?.name;
|
||||||
|
const 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.updatedItems ?? [];
|
||||||
|
if (!items.length) return false;
|
||||||
|
return items.every((item) => Boolean(item.changeRecords?.length));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据源 step 是否支持「回滚」:
|
||||||
|
* - 新增(oldSchema=null)/ 删除(newSchema=null):不依赖 changeRecords,始终可回滚;
|
||||||
|
* - 更新(前后 schema 都存在):必须有 changeRecords 才支持局部反向 patch,否则不支持回滚。
|
||||||
|
*/
|
||||||
|
export const isDataSourceStepRevertable = (step: DataSourceStepValue): boolean => {
|
||||||
|
if (step.oldSchema === null || step.newSchema === null) return true;
|
||||||
|
return Boolean(step.changeRecords?.length);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 代码块 step 是否支持「回滚」:
|
||||||
|
* - 新增(oldContent=null)/ 删除(newContent=null):不依赖 changeRecords,始终可回滚;
|
||||||
|
* - 更新(前后 content 都存在):必须有 changeRecords 才支持局部反向 patch,否则不支持回滚。
|
||||||
|
*/
|
||||||
|
export const isCodeBlockStepRevertable = (step: CodeBlockStepValue): boolean => {
|
||||||
|
if (step.oldContent === null || step.newContent === null) return true;
|
||||||
|
return Boolean(step.changeRecords?.length);
|
||||||
|
};
|
||||||
@ -151,7 +151,11 @@ const submit = async (v: MNode, eventData?: ContainerChangeEventData) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
editorService.update(newValue, { changeRecords: eventData?.changeRecords });
|
// 区分操作途径:表单字段编辑(MForm @change)会带上 eventData(含 changeRecords);
|
||||||
|
// 源码编辑器(CodeEditor @save → saveCode)保存时不带 eventData,据此标记为「源码编辑器」。
|
||||||
|
const historySource = eventData ? 'props' : 'code';
|
||||||
|
|
||||||
|
editorService.update(newValue, { changeRecords: eventData?.changeRecords, historySource });
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
emit('submit-error', e);
|
emit('submit-error', e);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -94,11 +94,15 @@ let clientX: number;
|
|||||||
let clientY: number;
|
let clientY: number;
|
||||||
|
|
||||||
const appendComponent = ({ text, type, data = {} }: ComponentItem): void => {
|
const appendComponent = ({ text, type, data = {} }: ComponentItem): void => {
|
||||||
editorService.add({
|
editorService.add(
|
||||||
name: text,
|
{
|
||||||
type,
|
name: text,
|
||||||
...data,
|
type,
|
||||||
});
|
...data,
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{ historySource: 'component-panel' },
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const dragstartHandler = ({ text, type, data = {} }: ComponentItem, e: DragEvent) => {
|
const dragstartHandler = ({ text, type, data = {} }: ComponentItem, e: DragEvent) => {
|
||||||
|
|||||||
@ -24,7 +24,7 @@
|
|||||||
<Icon :icon="editable ? Edit : View" class="edit-icon" @click.stop="editCode(`${data.key}`)"></Icon>
|
<Icon :icon="editable ? Edit : View" class="edit-icon" @click.stop="editCode(`${data.key}`)"></Icon>
|
||||||
</TMagicTooltip>
|
</TMagicTooltip>
|
||||||
<TMagicTooltip v-if="data.type === 'code' && editable" effect="dark" content="删除" placement="bottom">
|
<TMagicTooltip v-if="data.type === 'code' && editable" effect="dark" content="删除" placement="bottom">
|
||||||
<Icon :icon="Close" class="edit-icon" @click.stop="deleteCode(`${data.key}`)"></Icon>
|
<Icon :icon="Close" class="edit-icon" @click.stop="deleteCode(`${data.key}`, { historySource: 'tree' })"></Icon>
|
||||||
</TMagicTooltip>
|
</TMagicTooltip>
|
||||||
<slot name="code-block-panel-tool" :id="data.key" :data="data"></slot>
|
<slot name="code-block-panel-tool" :id="data.key" :data="data"></slot>
|
||||||
</template>
|
</template>
|
||||||
@ -44,7 +44,7 @@ import Tree from '@editor/components/Tree.vue';
|
|||||||
import { useFilter } from '@editor/hooks/use-filter';
|
import { useFilter } from '@editor/hooks/use-filter';
|
||||||
import { useNodeStatus } from '@editor/hooks/use-node-status';
|
import { useNodeStatus } from '@editor/hooks/use-node-status';
|
||||||
import { useServices } from '@editor/hooks/use-services';
|
import { useServices } from '@editor/hooks/use-services';
|
||||||
import { type CodeBlockListSlots, CodeDeleteErrorType, type TreeNodeData } from '@editor/type';
|
import { type CodeBlockListSlots, CodeDeleteErrorType, HistoryOpSource, type TreeNodeData } from '@editor/type';
|
||||||
|
|
||||||
defineSlots<CodeBlockListSlots>();
|
defineSlots<CodeBlockListSlots>();
|
||||||
|
|
||||||
@ -60,7 +60,7 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
edit: [id: string];
|
edit: [id: string];
|
||||||
remove: [id: string];
|
remove: [id: string, { historySource?: HistoryOpSource }];
|
||||||
'node-contextmenu': [event: MouseEvent, data: TreeNodeData];
|
'node-contextmenu': [event: MouseEvent, data: TreeNodeData];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
@ -142,7 +142,7 @@ const editCode = (id: string) => {
|
|||||||
emit('edit', id);
|
emit('edit', id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteCode = async (id: string) => {
|
const deleteCode = async (id: string, { historySource }: { historySource?: HistoryOpSource } = {}) => {
|
||||||
const currentCode = codeList.value.find((codeItem) => codeItem.id === id);
|
const currentCode = codeList.value.find((codeItem) => codeItem.id === id);
|
||||||
const existBinds = Boolean(currentCode?.items?.length);
|
const existBinds = Boolean(currentCode?.items?.length);
|
||||||
const undeleteableList = codeBlockService.getUndeletableList() || [];
|
const undeleteableList = codeBlockService.getUndeletableList() || [];
|
||||||
@ -154,7 +154,7 @@ const deleteCode = async (id: string) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 无绑定关系,且不在不可删除列表中
|
// 无绑定关系,且不在不可删除列表中
|
||||||
emit('remove', id);
|
emit('remove', id, { historySource });
|
||||||
} else {
|
} else {
|
||||||
if (typeof props.customError === 'function') {
|
if (typeof props.customError === 'function') {
|
||||||
props.customError(id, existBinds ? CodeDeleteErrorType.BIND : CodeDeleteErrorType.UNDELETEABLE);
|
props.customError(id, existBinds ? CodeDeleteErrorType.BIND : CodeDeleteErrorType.UNDELETEABLE);
|
||||||
|
|||||||
@ -122,7 +122,7 @@ const {
|
|||||||
menuData: contentMenuData,
|
menuData: contentMenuData,
|
||||||
contentMenuHideHandler,
|
contentMenuHideHandler,
|
||||||
} = useContentMenu((id: string) => {
|
} = useContentMenu((id: string) => {
|
||||||
codeBlockListRef.value?.deleteCode(id);
|
codeBlockListRef.value?.deleteCode(id, { historySource: 'tree-contextmenu' });
|
||||||
});
|
});
|
||||||
const menuData = computed<(MenuButton | MenuComponent)[]>(() => props.customContentMenu(contentMenuData, 'code-block'));
|
const menuData = computed<(MenuButton | MenuComponent)[]>(() => props.customContentMenu(contentMenuData, 'code-block'));
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -41,7 +41,7 @@ export const useContentMenu = (deleteCode: (id: string) => void) => {
|
|||||||
|
|
||||||
const newCodeId = await codeBlockService.getUniqueId();
|
const newCodeId = await codeBlockService.getUniqueId();
|
||||||
|
|
||||||
codeBlockService.setCodeDslById(newCodeId, cloneDeep(codeBlock));
|
codeBlockService.setCodeDslById(newCodeId, cloneDeep(codeBlock), { historySource: 'tree-contextmenu' });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -129,7 +129,7 @@ const removeHandler = async (id: string) => {
|
|||||||
type: 'warning',
|
type: 'warning',
|
||||||
});
|
});
|
||||||
|
|
||||||
dataSourceService.remove(id);
|
dataSourceService.remove(id, { historySource: 'tree-contextmenu' });
|
||||||
};
|
};
|
||||||
|
|
||||||
const dataSourceListRef = useTemplateRef<InstanceType<typeof DataSourceList>>('dataSourceList');
|
const dataSourceListRef = useTemplateRef<InstanceType<typeof DataSourceList>>('dataSourceList');
|
||||||
|
|||||||
@ -39,7 +39,7 @@ export const useContentMenu = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
dataSourceService.add(cloneDeep(ds));
|
dataSourceService.add(cloneDeep(ds), { historySource: 'tree-contextmenu' });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -41,11 +41,15 @@ const createMenuItems = (group: ComponentGroup): MenuButton[] =>
|
|||||||
type: 'button',
|
type: 'button',
|
||||||
icon: component.icon,
|
icon: component.icon,
|
||||||
handler: () => {
|
handler: () => {
|
||||||
editorService.add({
|
editorService.add(
|
||||||
name: component.text,
|
{
|
||||||
type: component.type,
|
name: component.text,
|
||||||
...(component.data || {}),
|
type: component.type,
|
||||||
});
|
...(component.data || {}),
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{ historySource: 'tree-contextmenu' },
|
||||||
|
);
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -57,9 +61,13 @@ const getSubMenuData = computed<MenuButton[]>(() => {
|
|||||||
type: 'button',
|
type: 'button',
|
||||||
icon: Files,
|
icon: Files,
|
||||||
handler: () => {
|
handler: () => {
|
||||||
editorService.add({
|
editorService.add(
|
||||||
type: 'tab-pane',
|
{
|
||||||
});
|
type: 'tab-pane',
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{ historySource: 'tree-contextmenu' },
|
||||||
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@ -106,9 +114,9 @@ const menuData = computed<(MenuButton | MenuComponent)[]>(() =>
|
|||||||
items: getSubMenuData.value,
|
items: getSubMenuData.value,
|
||||||
},
|
},
|
||||||
useCopyMenu(),
|
useCopyMenu(),
|
||||||
usePasteMenu(),
|
usePasteMenu('tree-contextmenu'),
|
||||||
useDeleteMenu(),
|
useDeleteMenu('tree-contextmenu'),
|
||||||
useMoveToMenu(services),
|
useMoveToMenu(services, 'tree-contextmenu'),
|
||||||
...props.layerContentMenu,
|
...props.layerContentMenu,
|
||||||
],
|
],
|
||||||
'layer',
|
'layer',
|
||||||
|
|||||||
@ -25,9 +25,12 @@ const props = defineProps<{
|
|||||||
const { editorService } = useServices();
|
const { editorService } = useServices();
|
||||||
|
|
||||||
const setNodeVisible = (visible: boolean) => {
|
const setNodeVisible = (visible: boolean) => {
|
||||||
editorService.update({
|
editorService.update(
|
||||||
id: props.data.id,
|
{
|
||||||
visible,
|
id: props.data.id,
|
||||||
});
|
visible,
|
||||||
|
},
|
||||||
|
{ historySource: 'tree' },
|
||||||
|
);
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -9,12 +9,15 @@ import { updateStatus } from '@editor/utils/tree';
|
|||||||
const createPageNodeStatus = (page: MPage | MPageFragment, initialLayerNodeStatus?: Map<Id, LayerNodeStatus>) => {
|
const createPageNodeStatus = (page: MPage | MPageFragment, initialLayerNodeStatus?: Map<Id, LayerNodeStatus>) => {
|
||||||
const map = new Map<Id, LayerNodeStatus>();
|
const map = new Map<Id, LayerNodeStatus>();
|
||||||
|
|
||||||
map.set(page.id, {
|
map.set(
|
||||||
visible: true,
|
page.id,
|
||||||
expand: true,
|
initialLayerNodeStatus?.get(page.id) || {
|
||||||
selected: true,
|
visible: true,
|
||||||
draggable: false,
|
expand: true,
|
||||||
});
|
selected: true,
|
||||||
|
draggable: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
page.items.forEach((node: MNode) =>
|
page.items.forEach((node: MNode) =>
|
||||||
traverseNode<MNode>(node, (node) => {
|
traverseNode<MNode>(node, (node) => {
|
||||||
|
|||||||
@ -380,7 +380,7 @@ const dropHandler = async (e: DragEvent) => {
|
|||||||
|
|
||||||
config.data.inputEvent = e;
|
config.data.inputEvent = e;
|
||||||
|
|
||||||
editorService.add(config.data, parent);
|
editorService.add(config.data, parent, { historySource: 'component-panel' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -49,11 +49,11 @@ const menuData = computed<(MenuButton | MenuComponent)[]>(() =>
|
|||||||
display: () => canCenter.value,
|
display: () => canCenter.value,
|
||||||
handler: () => {
|
handler: () => {
|
||||||
if (!nodes.value) return;
|
if (!nodes.value) return;
|
||||||
editorService.alignCenter(nodes.value);
|
editorService.alignCenter(nodes.value, { historySource: 'stage-contextmenu' });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
useCopyMenu(),
|
useCopyMenu(),
|
||||||
usePasteMenu(menuRef),
|
usePasteMenu('stage-contextmenu', menuRef),
|
||||||
{
|
{
|
||||||
type: 'divider',
|
type: 'divider',
|
||||||
direction: 'horizontal',
|
direction: 'horizontal',
|
||||||
@ -68,7 +68,7 @@ const menuData = computed<(MenuButton | MenuComponent)[]>(() =>
|
|||||||
icon: markRaw(Top),
|
icon: markRaw(Top),
|
||||||
display: () => !isPage(node.value) && !isPageFragment(node.value) && !props.isMultiSelect,
|
display: () => !isPage(node.value) && !isPageFragment(node.value) && !props.isMultiSelect,
|
||||||
handler: () => {
|
handler: () => {
|
||||||
editorService.moveLayer(1);
|
editorService.moveLayer(1, { historySource: 'stage-contextmenu' });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -77,7 +77,7 @@ const menuData = computed<(MenuButton | MenuComponent)[]>(() =>
|
|||||||
icon: markRaw(Bottom),
|
icon: markRaw(Bottom),
|
||||||
display: () => !isPage(node.value) && !isPageFragment(node.value) && !props.isMultiSelect,
|
display: () => !isPage(node.value) && !isPageFragment(node.value) && !props.isMultiSelect,
|
||||||
handler: () => {
|
handler: () => {
|
||||||
editorService.moveLayer(-1);
|
editorService.moveLayer(-1, { historySource: 'stage-contextmenu' });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -86,7 +86,7 @@ const menuData = computed<(MenuButton | MenuComponent)[]>(() =>
|
|||||||
icon: markRaw(Top),
|
icon: markRaw(Top),
|
||||||
display: () => !isPage(node.value) && !isPageFragment(node.value) && !props.isMultiSelect,
|
display: () => !isPage(node.value) && !isPageFragment(node.value) && !props.isMultiSelect,
|
||||||
handler: () => {
|
handler: () => {
|
||||||
editorService.moveLayer(LayerOffset.TOP);
|
editorService.moveLayer(LayerOffset.TOP, { historySource: 'stage-contextmenu' });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -95,16 +95,16 @@ const menuData = computed<(MenuButton | MenuComponent)[]>(() =>
|
|||||||
icon: markRaw(Bottom),
|
icon: markRaw(Bottom),
|
||||||
display: () => !isPage(node.value) && !isPageFragment(node.value) && !props.isMultiSelect,
|
display: () => !isPage(node.value) && !isPageFragment(node.value) && !props.isMultiSelect,
|
||||||
handler: () => {
|
handler: () => {
|
||||||
editorService.moveLayer(LayerOffset.BOTTOM);
|
editorService.moveLayer(LayerOffset.BOTTOM, { historySource: 'stage-contextmenu' });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
useMoveToMenu(services),
|
useMoveToMenu(services, 'stage-contextmenu'),
|
||||||
{
|
{
|
||||||
type: 'divider',
|
type: 'divider',
|
||||||
direction: 'horizontal',
|
direction: 'horizontal',
|
||||||
display: () => !isPage(node.value) && !isPageFragment(node.value) && !props.isMultiSelect,
|
display: () => !isPage(node.value) && !isPageFragment(node.value) && !props.isMultiSelect,
|
||||||
},
|
},
|
||||||
useDeleteMenu(),
|
useDeleteMenu('stage-contextmenu'),
|
||||||
{
|
{
|
||||||
type: 'divider',
|
type: 'divider',
|
||||||
direction: 'horizontal',
|
direction: 'horizontal',
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-misused-promises */
|
|
||||||
/*
|
/*
|
||||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||||
*
|
*
|
||||||
@ -19,45 +18,32 @@
|
|||||||
|
|
||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
|
|
||||||
import { compose } from '@editor/utils/compose';
|
|
||||||
|
|
||||||
const methodName = (prefix: string, name: string) => `${prefix}${name[0].toUpperCase()}${name.substring(1)}`;
|
const methodName = (prefix: string, name: string) => `${prefix}${name[0].toUpperCase()}${name.substring(1)}`;
|
||||||
|
|
||||||
const isError = (error: any): boolean => Object.prototype.toString.call(error) === '[object Error]';
|
const isError = (error: any): boolean => Object.prototype.toString.call(error) === '[object Error]';
|
||||||
|
|
||||||
const doAction = (
|
const doAction = (args: any[], scope: any, sourceMethod: any, beforeMethodName: string, afterMethodName: string) => {
|
||||||
args: any[],
|
let beforeArgs = args;
|
||||||
scope: any,
|
|
||||||
sourceMethod: any,
|
|
||||||
beforeMethodName: string,
|
|
||||||
afterMethodName: string,
|
|
||||||
fn: (args: any[], next?: Function | undefined) => void,
|
|
||||||
) => {
|
|
||||||
try {
|
|
||||||
let beforeArgs = args;
|
|
||||||
|
|
||||||
for (const beforeMethod of scope.pluginOptionsList[beforeMethodName]) {
|
for (const beforeMethod of scope.pluginOptionsList[beforeMethodName]) {
|
||||||
beforeArgs = beforeMethod(...beforeArgs) || [];
|
beforeArgs = beforeMethod(...beforeArgs) || [];
|
||||||
|
|
||||||
if (isError(beforeArgs)) throw beforeArgs;
|
if (isError(beforeArgs)) throw beforeArgs;
|
||||||
|
|
||||||
if (!Array.isArray(beforeArgs)) {
|
if (!Array.isArray(beforeArgs)) {
|
||||||
beforeArgs = [beforeArgs];
|
beforeArgs = [beforeArgs];
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let returnValue: any = fn(beforeArgs, sourceMethod.bind(scope));
|
|
||||||
|
|
||||||
for (const afterMethod of scope.pluginOptionsList[afterMethodName]) {
|
|
||||||
returnValue = afterMethod(returnValue, ...beforeArgs);
|
|
||||||
|
|
||||||
if (isError(returnValue)) throw returnValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
return returnValue;
|
|
||||||
} catch (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let returnValue: any = sourceMethod.apply(scope, beforeArgs);
|
||||||
|
|
||||||
|
for (const afterMethod of scope.pluginOptionsList[afterMethodName]) {
|
||||||
|
returnValue = afterMethod(returnValue, ...beforeArgs);
|
||||||
|
|
||||||
|
if (isError(returnValue)) throw returnValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return returnValue;
|
||||||
};
|
};
|
||||||
|
|
||||||
const doAsyncAction = async (
|
const doAsyncAction = async (
|
||||||
@ -66,40 +52,34 @@ const doAsyncAction = async (
|
|||||||
sourceMethod: any,
|
sourceMethod: any,
|
||||||
beforeMethodName: string,
|
beforeMethodName: string,
|
||||||
afterMethodName: string,
|
afterMethodName: string,
|
||||||
fn: (args: any[], next?: Function | undefined) => Promise<void> | void,
|
|
||||||
) => {
|
) => {
|
||||||
try {
|
let beforeArgs = args;
|
||||||
let beforeArgs = args;
|
|
||||||
|
|
||||||
for (const beforeMethod of scope.pluginOptionsList[beforeMethodName]) {
|
for (const beforeMethod of scope.pluginOptionsList[beforeMethodName]) {
|
||||||
beforeArgs = (await beforeMethod(...beforeArgs)) || [];
|
beforeArgs = (await beforeMethod(...beforeArgs)) || [];
|
||||||
|
|
||||||
if (isError(beforeArgs)) throw beforeArgs;
|
if (isError(beforeArgs)) throw beforeArgs;
|
||||||
|
|
||||||
if (!Array.isArray(beforeArgs)) {
|
if (!Array.isArray(beforeArgs)) {
|
||||||
beforeArgs = [beforeArgs];
|
beforeArgs = [beforeArgs];
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let returnValue: any = await fn(beforeArgs, sourceMethod.bind(scope));
|
|
||||||
|
|
||||||
for (const afterMethod of scope.pluginOptionsList[afterMethodName]) {
|
|
||||||
returnValue = await afterMethod(returnValue, ...beforeArgs);
|
|
||||||
|
|
||||||
if (isError(returnValue)) throw returnValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
return returnValue;
|
|
||||||
} catch (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let returnValue: any = await sourceMethod.apply(scope, beforeArgs);
|
||||||
|
|
||||||
|
for (const afterMethod of scope.pluginOptionsList[afterMethodName]) {
|
||||||
|
returnValue = await afterMethod(returnValue, ...beforeArgs);
|
||||||
|
|
||||||
|
if (isError(returnValue)) throw returnValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return returnValue;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 提供两种方式对Class进行扩展
|
* 对Class进行扩展
|
||||||
* 方法1:
|
|
||||||
* 给Class中的每个方法都添加before after两个钩子
|
* 给Class中的每个方法都添加before after两个钩子
|
||||||
* 给Class添加一个usePlugin方法,use方法可以传入一个包含before或者after方法的对象
|
* 给Class添加一个usePlugin方法,usePlugin方法可以传入一个包含before或者after方法的对象
|
||||||
*
|
*
|
||||||
* 例如:
|
* 例如:
|
||||||
* Class EditorService extends BaseService {
|
* Class EditorService extends BaseService {
|
||||||
@ -124,27 +104,9 @@ const doAsyncAction = async (
|
|||||||
*
|
*
|
||||||
* 调用时的参数会透传到before方法的参数中, 然后before的return 会作为原方法的参数和after的参数,after第一个参数则是原方法的return值;
|
* 调用时的参数会透传到before方法的参数中, 然后before的return 会作为原方法的参数和after的参数,after第一个参数则是原方法的return值;
|
||||||
* 如需终止后续方法调用可以return new Error();
|
* 如需终止后续方法调用可以return new Error();
|
||||||
*
|
|
||||||
* 方法2:
|
|
||||||
* 给Class中的每个方法都添加中间件
|
|
||||||
* 给Class添加一个use方法,use方法可以传入一个包含源对象方法名作为key值的对象
|
|
||||||
*
|
|
||||||
* 例如:
|
|
||||||
* Class EditorService extends BaseService {
|
|
||||||
* constructor() {
|
|
||||||
* super([ { name: 'add', isAsync: true },]);
|
|
||||||
* }
|
|
||||||
* add(value) { return result; }
|
|
||||||
* };
|
|
||||||
*
|
|
||||||
* const editorService = new EditorService();
|
|
||||||
* editorService.use({
|
|
||||||
* add(value, next) { console.log(value); next() },
|
|
||||||
* });
|
|
||||||
*/
|
*/
|
||||||
export default class extends EventEmitter {
|
class BaseService extends EventEmitter {
|
||||||
private pluginOptionsList: Record<string, Function[]> = {};
|
private pluginOptionsList: Record<string, Function[]> = {};
|
||||||
private middleware: Record<string, Function[]> = {};
|
|
||||||
private taskList: (() => Promise<void>)[] = [];
|
private taskList: (() => Promise<void>)[] = [];
|
||||||
private doingTask = false;
|
private doingTask = false;
|
||||||
|
|
||||||
@ -161,14 +123,12 @@ export default class extends EventEmitter {
|
|||||||
|
|
||||||
this.pluginOptionsList[beforeMethodName] = [];
|
this.pluginOptionsList[beforeMethodName] = [];
|
||||||
this.pluginOptionsList[afterMethodName] = [];
|
this.pluginOptionsList[afterMethodName] = [];
|
||||||
this.middleware[propertyName] = [];
|
|
||||||
|
|
||||||
const fn = compose(this.middleware[propertyName], isAsync);
|
|
||||||
Object.defineProperty(scope, propertyName, {
|
Object.defineProperty(scope, propertyName, {
|
||||||
value: isAsync
|
value: isAsync
|
||||||
? async (...args: any[]) => {
|
? async (...args: any[]) => {
|
||||||
if (!serialMethods.includes(propertyName)) {
|
if (!serialMethods.includes(propertyName)) {
|
||||||
return doAsyncAction(args, scope, sourceMethod, beforeMethodName, afterMethodName, fn);
|
return doAsyncAction(args, scope, sourceMethod, beforeMethodName, afterMethodName);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 由于async await,所以会出现函数执行到await时让出线程,导致执行顺序出错,例如调用了select(1) -> update -> select(2),这个时候就有可能出现update了2;
|
// 由于async await,所以会出现函数执行到await时让出线程,导致执行顺序出错,例如调用了select(1) -> update -> select(2),这个时候就有可能出现update了2;
|
||||||
@ -176,7 +136,7 @@ export default class extends EventEmitter {
|
|||||||
const promise = new Promise<any>((resolve, reject) => {
|
const promise = new Promise<any>((resolve, reject) => {
|
||||||
this.taskList.push(async () => {
|
this.taskList.push(async () => {
|
||||||
try {
|
try {
|
||||||
const value = await doAsyncAction(args, scope, sourceMethod, beforeMethodName, afterMethodName, fn);
|
const value = await doAsyncAction(args, scope, sourceMethod, beforeMethodName, afterMethodName);
|
||||||
resolve(value);
|
resolve(value);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
reject(e);
|
reject(e);
|
||||||
@ -190,20 +150,11 @@ export default class extends EventEmitter {
|
|||||||
|
|
||||||
return promise;
|
return promise;
|
||||||
}
|
}
|
||||||
: (...args: any[]) => doAction(args, scope, sourceMethod, beforeMethodName, afterMethodName, fn),
|
: (...args: any[]) => doAction(args, scope, sourceMethod, beforeMethodName, afterMethodName),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated 请使用usePlugin代替
|
|
||||||
*/
|
|
||||||
public use(options: Record<string, Function>) {
|
|
||||||
for (const [methodName, method] of Object.entries(options)) {
|
|
||||||
if (typeof method === 'function') this.middleware[methodName].push(method);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public usePlugin(options: Record<string, Function>) {
|
public usePlugin(options: Record<string, Function>) {
|
||||||
for (const [methodName, method] of Object.entries(options)) {
|
for (const [methodName, method] of Object.entries(options)) {
|
||||||
if (typeof method === 'function' && !this.pluginOptionsList[methodName].includes(method)) {
|
if (typeof method === 'function' && !this.pluginOptionsList[methodName].includes(method)) {
|
||||||
@ -224,10 +175,6 @@ export default class extends EventEmitter {
|
|||||||
for (const key of Object.keys(this.pluginOptionsList)) {
|
for (const key of Object.keys(this.pluginOptionsList)) {
|
||||||
this.pluginOptionsList[key] = [];
|
this.pluginOptionsList[key] = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const key of Object.keys(this.middleware)) {
|
|
||||||
this.middleware[key] = [];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async doTask() {
|
private async doTask() {
|
||||||
@ -240,3 +187,5 @@ export default class extends EventEmitter {
|
|||||||
this.doingTask = false;
|
this.doingTask = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default BaseService;
|
||||||
|
|||||||
@ -23,10 +23,18 @@ import type { Writable } from 'type-fest';
|
|||||||
import type { CodeBlockContent, CodeBlockDSL, Id, MNode, TargetOptions } from '@tmagic/core';
|
import type { CodeBlockContent, CodeBlockDSL, Id, MNode, TargetOptions } from '@tmagic/core';
|
||||||
import { Target, Watcher } from '@tmagic/core';
|
import { Target, Watcher } from '@tmagic/core';
|
||||||
import type { TableColumnConfig } from '@tmagic/form';
|
import type { TableColumnConfig } from '@tmagic/form';
|
||||||
|
import { getValueByKeyPath, setValueByKeyPath } from '@tmagic/utils';
|
||||||
|
|
||||||
import editorService from '@editor/services/editor';
|
import editorService from '@editor/services/editor';
|
||||||
|
import historyService from '@editor/services/history';
|
||||||
import storageService, { Protocol } from '@editor/services/storage';
|
import storageService, { Protocol } from '@editor/services/storage';
|
||||||
import type { AsyncHookPlugin, CodeState } from '@editor/type';
|
import type {
|
||||||
|
AsyncHookPlugin,
|
||||||
|
CodeBlockStepValue,
|
||||||
|
CodeState,
|
||||||
|
HistoryOpOptions,
|
||||||
|
HistoryOpOptionsWithChangeRecords,
|
||||||
|
} from '@editor/type';
|
||||||
import { CODE_DRAFT_STORAGE_KEY } from '@editor/type';
|
import { CODE_DRAFT_STORAGE_KEY } from '@editor/type';
|
||||||
import { getEditorConfig } from '@editor/utils/config';
|
import { getEditorConfig } from '@editor/utils/config';
|
||||||
import { COPY_CODE_STORAGE_KEY } from '@editor/utils/editor';
|
import { COPY_CODE_STORAGE_KEY } from '@editor/utils/editor';
|
||||||
@ -40,6 +48,18 @@ const canUsePluginMethods = {
|
|||||||
|
|
||||||
type AsyncMethodName = Writable<(typeof canUsePluginMethods)['async']>;
|
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 {
|
class CodeBlock extends BaseService {
|
||||||
private state = reactive<CodeState>({
|
private state = reactive<CodeState>({
|
||||||
codeDsl: null,
|
codeDsl: null,
|
||||||
@ -49,6 +69,17 @@ class CodeBlock extends BaseService {
|
|||||||
paramsColConfig: undefined,
|
paramsColConfig: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 最近一次写入历史栈的代码块历史记录 uuid(单条写入场景:新增 / 更新)。
|
||||||
|
* 供 setCodeDslById(Sync)AndGetHistoryId 取回,普通方法不读取它。
|
||||||
|
*/
|
||||||
|
private lastPushedHistoryId: string | null = null;
|
||||||
|
/**
|
||||||
|
* deleteCodeDslByIds 一次删除多个代码块时,按写入顺序收集的历史记录 uuid 列表。
|
||||||
|
* 在 deleteCodeDslByIds 入口处重置,供 deleteCodeDslByIdsAndGetHistoryId 取回。
|
||||||
|
*/
|
||||||
|
private lastDeletedHistoryIds: string[] = [];
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super([
|
super([
|
||||||
...canUsePluginMethods.async.map((methodName) => ({ name: methodName, isAsync: true })),
|
...canUsePluginMethods.async.map((methodName) => ({ name: methodName, isAsync: true })),
|
||||||
@ -92,10 +123,27 @@ class CodeBlock extends BaseService {
|
|||||||
* 设置代码块ID和代码内容到源dsl
|
* 设置代码块ID和代码内容到源dsl
|
||||||
* @param {Id} id 代码块id
|
* @param {Id} id 代码块id
|
||||||
* @param {CodeBlockContent} codeConfig 代码块内容配置信息
|
* @param {CodeBlockContent} codeConfig 代码块内容配置信息
|
||||||
|
* @param options 可选配置
|
||||||
|
* @param options.changeRecords form 端 propPath/value 列表,用于历史记录的精细化撤销/重做
|
||||||
|
* @param options.doNotPushHistory 是否不写入历史记录(默认 false)
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
public async setCodeDslById(id: Id, codeConfig: Partial<CodeBlockContent>): Promise<void> {
|
public async setCodeDslById(
|
||||||
this.setCodeDslByIdSync(id, codeConfig, true);
|
id: Id,
|
||||||
|
codeConfig: Partial<CodeBlockContent>,
|
||||||
|
{
|
||||||
|
changeRecords,
|
||||||
|
doNotPushHistory = false,
|
||||||
|
historyDescription,
|
||||||
|
historySource,
|
||||||
|
}: HistoryOpOptionsWithChangeRecords = {},
|
||||||
|
): Promise<void> {
|
||||||
|
this.setCodeDslByIdSync(id, codeConfig, true, {
|
||||||
|
changeRecords,
|
||||||
|
doNotPushHistory,
|
||||||
|
historyDescription,
|
||||||
|
historySource,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -104,9 +152,23 @@ class CodeBlock extends BaseService {
|
|||||||
* @param {Id} id 代码块id
|
* @param {Id} id 代码块id
|
||||||
* @param {CodeBlockContent} codeConfig 代码块内容配置信息
|
* @param {CodeBlockContent} codeConfig 代码块内容配置信息
|
||||||
* @param {boolean} force 是否强制写入,默认true
|
* @param {boolean} force 是否强制写入,默认true
|
||||||
|
* @param options 可选配置
|
||||||
|
* @param options.changeRecords form 端 propPath/value 列表,用于历史记录的精细化撤销/重做
|
||||||
|
* @param options.doNotPushHistory 是否不写入历史记录(默认 false)
|
||||||
|
* @param options.historyDescription 入栈时附带的人类可读描述,用于历史面板展示
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
public setCodeDslByIdSync(id: Id, codeConfig: Partial<CodeBlockContent>, force = true): void {
|
public setCodeDslByIdSync(
|
||||||
|
id: Id,
|
||||||
|
codeConfig: Partial<CodeBlockContent>,
|
||||||
|
force = true,
|
||||||
|
{
|
||||||
|
changeRecords,
|
||||||
|
doNotPushHistory = false,
|
||||||
|
historyDescription,
|
||||||
|
historySource,
|
||||||
|
}: HistoryOpOptionsWithChangeRecords = {},
|
||||||
|
): void {
|
||||||
const codeDsl = this.getCodeDsl();
|
const codeDsl = this.getCodeDsl();
|
||||||
|
|
||||||
if (!codeDsl) {
|
if (!codeDsl) {
|
||||||
@ -123,6 +185,9 @@ class CodeBlock extends BaseService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 历史记录:在写入前快照旧内容,区分新增/更新
|
||||||
|
const oldContent: CodeBlockContent | null = codeDsl[id] ? cloneDeep(codeDsl[id]) : null;
|
||||||
|
|
||||||
const existContent = codeDsl[id] || {};
|
const existContent = codeDsl[id] || {};
|
||||||
|
|
||||||
codeDsl[id] = {
|
codeDsl[id] = {
|
||||||
@ -130,6 +195,19 @@ class CodeBlock extends BaseService {
|
|||||||
...codeConfigProcessed,
|
...codeConfigProcessed,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const newContent = cloneDeep(codeDsl[id]);
|
||||||
|
|
||||||
|
if (!doNotPushHistory) {
|
||||||
|
this.lastPushedHistoryId =
|
||||||
|
historyService.pushCodeBlock(id, {
|
||||||
|
oldContent,
|
||||||
|
newContent,
|
||||||
|
changeRecords,
|
||||||
|
historyDescription,
|
||||||
|
source: historySource,
|
||||||
|
})?.uuid ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
this.emit('addOrUpdate', id, codeDsl[id]);
|
this.emit('addOrUpdate', id, codeDsl[id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -218,19 +296,82 @@ class CodeBlock extends BaseService {
|
|||||||
/**
|
/**
|
||||||
* 在dsl数据源中删除指定id的代码块
|
* 在dsl数据源中删除指定id的代码块
|
||||||
* @param {Id[]} codeIds 需要删除的代码块id数组
|
* @param {Id[]} codeIds 需要删除的代码块id数组
|
||||||
|
* @param options 可选配置
|
||||||
|
* @param options.doNotPushHistory 是否不写入历史记录(默认 false)
|
||||||
*/
|
*/
|
||||||
public async deleteCodeDslByIds(codeIds: Id[]): Promise<void> {
|
public async deleteCodeDslByIds(
|
||||||
|
codeIds: Id[],
|
||||||
|
{ doNotPushHistory = false, historyDescription, historySource }: HistoryOpOptions = {},
|
||||||
|
): Promise<void> {
|
||||||
const currentDsl = await this.getCodeDsl();
|
const currentDsl = await this.getCodeDsl();
|
||||||
|
|
||||||
if (!currentDsl) return;
|
if (!currentDsl) return;
|
||||||
|
|
||||||
|
this.lastDeletedHistoryIds = [];
|
||||||
|
|
||||||
codeIds.forEach((id) => {
|
codeIds.forEach((id) => {
|
||||||
|
// 历史记录:删除前快照内容;不存在的 id 直接跳过历史推入
|
||||||
|
const oldContent: CodeBlockContent | null = currentDsl[id] ? cloneDeep(currentDsl[id]) : null;
|
||||||
|
|
||||||
delete currentDsl[id];
|
delete currentDsl[id];
|
||||||
|
|
||||||
|
if (oldContent && !doNotPushHistory) {
|
||||||
|
const uuid = historyService.pushCodeBlock(id, {
|
||||||
|
oldContent,
|
||||||
|
newContent: null,
|
||||||
|
historyDescription,
|
||||||
|
source: historySource,
|
||||||
|
})?.uuid;
|
||||||
|
if (uuid) this.lastDeletedHistoryIds.push(uuid);
|
||||||
|
}
|
||||||
|
|
||||||
this.emit('remove', id);
|
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 {
|
public setParamsColConfig(config: TableColumnConfig): void {
|
||||||
this.state.paramsColConfig = config;
|
this.state.paramsColConfig = config;
|
||||||
}
|
}
|
||||||
@ -239,6 +380,103 @@ class CodeBlock extends BaseService {
|
|||||||
return this.state.paramsColConfig;
|
return this.state.paramsColConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 撤销指定代码块的最近一次变更。
|
||||||
|
*
|
||||||
|
* 内部走 setCodeDslByIdSync / deleteCodeDslByIds,因此会自动触发 codeBlockService 的
|
||||||
|
* `addOrUpdate` / `remove` 事件,由 initService 中的 handler 重新维护 dep target
|
||||||
|
* (DepTargetType.CODE_BLOCK 的 add / remove)。所有写回都带 `doNotPushHistory: true`,
|
||||||
|
* 确保不会在历史栈里产生新的记录。
|
||||||
|
*
|
||||||
|
* @param id 代码块 id
|
||||||
|
* @returns 撤销的 step;栈不存在或已无可撤销时返回 null
|
||||||
|
*/
|
||||||
|
public async undo(id: Id): Promise<CodeBlockStepValue | null> {
|
||||||
|
const step = historyService.undoCodeBlock(id);
|
||||||
|
if (!step) return null;
|
||||||
|
await this.applyHistoryStep(step, true);
|
||||||
|
return step;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重做指定代码块的下一次变更。
|
||||||
|
* @param id 代码块 id
|
||||||
|
* @returns 重做的 step;栈不存在或已无可重做时返回 null
|
||||||
|
*/
|
||||||
|
public async redo(id: Id): Promise<CodeBlockStepValue | null> {
|
||||||
|
const step = historyService.redoCodeBlock(id);
|
||||||
|
if (!step) return null;
|
||||||
|
await this.applyHistoryStep(step, false);
|
||||||
|
return step;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否可对指定代码块撤销。 */
|
||||||
|
public canUndo(id: Id): boolean {
|
||||||
|
return historyService.canUndoCodeBlock(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否可对指定代码块重做。 */
|
||||||
|
public canRedo(id: Id): boolean {
|
||||||
|
return historyService.canRedoCodeBlock(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 跳转指定代码块的历史栈到目标游标。语义同 editor.gotoPageStep。
|
||||||
|
*
|
||||||
|
* @param id 代码块 id
|
||||||
|
* @param targetCursor 目标游标位置(已应用步骤数量)
|
||||||
|
* @returns 实际移动到的最终游标位置
|
||||||
|
*/
|
||||||
|
public async goto(id: Id, targetCursor: number): Promise<number> {
|
||||||
|
let cursor = historyService.getCodeBlockCursor(id);
|
||||||
|
const target = Math.max(0, targetCursor);
|
||||||
|
while (cursor > target) {
|
||||||
|
const step = await this.undo(id);
|
||||||
|
if (!step) break;
|
||||||
|
cursor -= 1;
|
||||||
|
}
|
||||||
|
while (cursor < target) {
|
||||||
|
const step = await this.redo(id);
|
||||||
|
if (!step) break;
|
||||||
|
cursor += 1;
|
||||||
|
}
|
||||||
|
return cursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「回滚」指定代码块历史步骤(类 git revert 语义):
|
||||||
|
* - 不动原始栈结构(不移动 cursor、不丢弃任何步骤);
|
||||||
|
* - 把目标 step 的修改**反向应用**一次,并作为**新步骤**追加到栈顶;
|
||||||
|
* - 仅对已应用的步骤生效。
|
||||||
|
*
|
||||||
|
* @param id 代码块 id
|
||||||
|
* @param index 目标 step 在该栈中的索引(0 为最早),通常由历史面板传入
|
||||||
|
* @returns 反向后产生的新 step;目标不存在 / 未应用时返回 null
|
||||||
|
*/
|
||||||
|
public async revert(id: Id, index: number): Promise<CodeBlockStepValue | null> {
|
||||||
|
const list = historyService.getCodeBlockStepList(id);
|
||||||
|
const entry = list[index];
|
||||||
|
if (!entry?.applied) return null;
|
||||||
|
// 更新类步骤(前后 content 都存在)必须带 changeRecords 才支持回滚,否则只能整内容替换,会冲掉后续无关变更。
|
||||||
|
if (entry.step.oldContent && entry.step.newContent && !entry.step.changeRecords?.length) return null;
|
||||||
|
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
|
* 生成代码块唯一id
|
||||||
* @returns {Id} 代码块唯一id
|
* @returns {Id} 代码块唯一id
|
||||||
@ -320,6 +558,120 @@ class CodeBlock extends BaseService {
|
|||||||
public usePlugin(options: AsyncHookPlugin<AsyncMethodName, CodeBlock>): void {
|
public usePlugin(options: AsyncHookPlugin<AsyncMethodName, CodeBlock>): void {
|
||||||
super.usePlugin(options);
|
super.usePlugin(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 反向应用一个 step 并以新 step 入栈。逻辑与 applyHistoryStep(reverse=true) 同构,
|
||||||
|
* 差异仅在于通过公开的 setCodeDslByIdSync / deleteCodeDslByIds 触发 push。
|
||||||
|
*/
|
||||||
|
private async applyRevertStep(
|
||||||
|
step: CodeBlockStepValue,
|
||||||
|
historyDescription: string,
|
||||||
|
): Promise<CodeBlockStepValue | null> {
|
||||||
|
const { id, oldContent, newContent, changeRecords } = step;
|
||||||
|
|
||||||
|
// 原本是新增 → revert 即删除
|
||||||
|
if (oldContent === null && newContent) {
|
||||||
|
await this.deleteCodeDslByIds([id], { historyDescription, historySource: 'rollback' });
|
||||||
|
return historyService.getCodeBlockStepList(id).slice(-1)[0]?.step ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 原本是删除 → revert 即写回
|
||||||
|
if (oldContent && newContent === null) {
|
||||||
|
this.setCodeDslByIdSync(id, cloneDeep(oldContent), true, { historyDescription, historySource: 'rollback' });
|
||||||
|
return historyService.getCodeBlockStepList(id).slice(-1)[0]?.step ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!oldContent || !newContent) return null;
|
||||||
|
|
||||||
|
// 原本是更新 → 把 oldContent 写回;优先按 changeRecords 局部 patch
|
||||||
|
if (changeRecords?.length) {
|
||||||
|
const current = this.getCodeContentById(id);
|
||||||
|
if (!current) return null;
|
||||||
|
const patched = cloneDeep(current) as CodeBlockContent;
|
||||||
|
let fallbackToFullReplace = false;
|
||||||
|
for (const record of changeRecords) {
|
||||||
|
if (!record.propPath) {
|
||||||
|
fallbackToFullReplace = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const value = cloneDeep(getValueByKeyPath(record.propPath, oldContent));
|
||||||
|
setValueByKeyPath(record.propPath, value, patched);
|
||||||
|
}
|
||||||
|
this.setCodeDslByIdSync(id, fallbackToFullReplace ? cloneDeep(oldContent) : patched, true, {
|
||||||
|
changeRecords,
|
||||||
|
historyDescription,
|
||||||
|
historySource: 'rollback',
|
||||||
|
});
|
||||||
|
return historyService.getCodeBlockStepList(id).slice(-1)[0]?.step ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setCodeDslByIdSync(id, cloneDeep(oldContent), true, { historyDescription, historySource: 'rollback' });
|
||||||
|
return historyService.getCodeBlockStepList(id).slice(-1)[0]?.step ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把一条历史 step 应用到当前代码块服务上。
|
||||||
|
*
|
||||||
|
* 复用现有的 setCodeDslByIdSync / deleteCodeDslByIds,目的是借助它们发出的事件
|
||||||
|
* 触发 initService 中的 dep target 维护(CODE_BLOCK 的 add / remove)。
|
||||||
|
* 所有写回都带 `doNotPushHistory: true`,确保不会在历史栈里产生新的记录。
|
||||||
|
*
|
||||||
|
* - oldContent=null, newContent≠null:原始为新增 → undo 删除;redo 再次 setCodeDslByIdSync
|
||||||
|
* - oldContent≠null, newContent=null:原始为删除 → undo 还原写入;redo 再次删除
|
||||||
|
* - 两侧都有:原始为更新 → 按 changeRecords 局部 patch;缺省退化为整内容替换
|
||||||
|
*
|
||||||
|
* @param step 历史 step
|
||||||
|
* @param reverse true=撤销,false=重做
|
||||||
|
*/
|
||||||
|
private async applyHistoryStep(step: CodeBlockStepValue, reverse: boolean): Promise<void> {
|
||||||
|
const { id, oldContent, newContent, changeRecords } = step;
|
||||||
|
|
||||||
|
// 新增 / 删除:直接 set 或 delete,不走 patch 逻辑
|
||||||
|
if (oldContent === null && newContent) {
|
||||||
|
if (reverse) {
|
||||||
|
await this.deleteCodeDslByIds([id], { doNotPushHistory: true });
|
||||||
|
} else {
|
||||||
|
this.setCodeDslByIdSync(id, cloneDeep(newContent), true, { doNotPushHistory: true });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldContent && newContent === null) {
|
||||||
|
if (reverse) {
|
||||||
|
this.setCodeDslByIdSync(id, cloneDeep(oldContent), true, { doNotPushHistory: true });
|
||||||
|
} else {
|
||||||
|
await this.deleteCodeDslByIds([id], { doNotPushHistory: true });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!oldContent || !newContent) return;
|
||||||
|
|
||||||
|
// 更新场景:优先按 changeRecords 局部 patch;缺省退化为整内容替换
|
||||||
|
const sourceForValues = reverse ? oldContent : newContent;
|
||||||
|
|
||||||
|
if (changeRecords?.length) {
|
||||||
|
const current = this.getCodeContentById(id);
|
||||||
|
if (!current) return;
|
||||||
|
const patched = cloneDeep(current) as CodeBlockContent;
|
||||||
|
let fallbackToFullReplace = false;
|
||||||
|
for (const record of changeRecords) {
|
||||||
|
if (!record.propPath) {
|
||||||
|
fallbackToFullReplace = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const value = cloneDeep(getValueByKeyPath(record.propPath, sourceForValues));
|
||||||
|
setValueByKeyPath(record.propPath, value, patched);
|
||||||
|
}
|
||||||
|
this.setCodeDslByIdSync(id, fallbackToFullReplace ? cloneDeep(sourceForValues) : patched, true, {
|
||||||
|
changeRecords,
|
||||||
|
doNotPushHistory: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setCodeDslByIdSync(id, cloneDeep(sourceForValues), true, { doNotPushHistory: true });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CodeBlockService = CodeBlock;
|
export type CodeBlockService = CodeBlock;
|
||||||
|
|||||||
@ -4,12 +4,19 @@ import type { Writable } from 'type-fest';
|
|||||||
|
|
||||||
import type { DataSourceSchema, EventOption, Id, MNode, TargetOptions } from '@tmagic/core';
|
import type { DataSourceSchema, EventOption, Id, MNode, TargetOptions } from '@tmagic/core';
|
||||||
import { Target, Watcher } from '@tmagic/core';
|
import { Target, Watcher } from '@tmagic/core';
|
||||||
import type { ChangeRecord, FormConfig } from '@tmagic/form';
|
import type { FormConfig } from '@tmagic/form';
|
||||||
import { guid, toLine } from '@tmagic/utils';
|
import { getValueByKeyPath, guid, setValueByKeyPath, toLine } from '@tmagic/utils';
|
||||||
|
|
||||||
import editorService from '@editor/services/editor';
|
import editorService from '@editor/services/editor';
|
||||||
|
import historyService from '@editor/services/history';
|
||||||
import storageService, { Protocol } from '@editor/services/storage';
|
import storageService, { Protocol } from '@editor/services/storage';
|
||||||
import type { DatasourceTypeOption, SyncHookPlugin } from '@editor/type';
|
import type {
|
||||||
|
DataSourceStepValue,
|
||||||
|
DatasourceTypeOption,
|
||||||
|
HistoryOpOptions,
|
||||||
|
HistoryOpOptionsWithChangeRecords,
|
||||||
|
SyncHookPlugin,
|
||||||
|
} from '@editor/type';
|
||||||
import { getFormConfig, getFormValue } from '@editor/utils/data-source';
|
import { getFormConfig, getFormValue } from '@editor/utils/data-source';
|
||||||
import { COPY_DS_STORAGE_KEY } from '@editor/utils/editor';
|
import { COPY_DS_STORAGE_KEY } from '@editor/utils/editor';
|
||||||
|
|
||||||
@ -47,6 +54,19 @@ const canUsePluginMethods = {
|
|||||||
|
|
||||||
type SyncMethodName = Writable<(typeof canUsePluginMethods)['sync']>;
|
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 {
|
class DataSource extends BaseService {
|
||||||
private state = reactive<State>({
|
private state = reactive<State>({
|
||||||
datasourceTypeList: [],
|
datasourceTypeList: [],
|
||||||
@ -58,6 +78,13 @@ class DataSource extends BaseService {
|
|||||||
methods: {},
|
methods: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 最近一次写入历史栈的数据源历史记录 uuid。
|
||||||
|
* 供 *AndGetHistoryId 系列方法在调用 add / update / remove 后取回本次产生的历史记录 id;
|
||||||
|
* 普通方法不读取它,调用前由 *AndGetHistoryId 重置为 null。
|
||||||
|
*/
|
||||||
|
private lastPushedHistoryId: string | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super(canUsePluginMethods.sync.map((methodName) => ({ name: methodName, isAsync: false })));
|
super(canUsePluginMethods.sync.map((methodName) => ({ name: methodName, isAsync: false })));
|
||||||
}
|
}
|
||||||
@ -102,7 +129,17 @@ class DataSource extends BaseService {
|
|||||||
this.get('methods')[toLine(type)] = value;
|
this.get('methods')[toLine(type)] = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
public add(config: DataSourceSchema) {
|
/**
|
||||||
|
* 新增数据源
|
||||||
|
* @param config 数据源配置
|
||||||
|
* @param options 可选配置
|
||||||
|
* @param options.doNotPushHistory 是否不写入历史记录(默认 false)
|
||||||
|
* @param options.historyDescription 入栈时附带的人类可读描述,用于历史面板展示
|
||||||
|
*/
|
||||||
|
public add(
|
||||||
|
config: DataSourceSchema,
|
||||||
|
{ doNotPushHistory = false, historyDescription, historySource }: HistoryOpOptions = {},
|
||||||
|
) {
|
||||||
const newConfig = {
|
const newConfig = {
|
||||||
...config,
|
...config,
|
||||||
id: config.id && !this.getDataSourceById(config.id) ? config.id : this.createId(),
|
id: config.id && !this.getDataSourceById(config.id) ? config.id : this.createId(),
|
||||||
@ -110,12 +147,38 @@ class DataSource extends BaseService {
|
|||||||
|
|
||||||
this.get('dataSources').push(newConfig);
|
this.get('dataSources').push(newConfig);
|
||||||
|
|
||||||
|
if (!doNotPushHistory) {
|
||||||
|
this.lastPushedHistoryId =
|
||||||
|
historyService.pushDataSource(newConfig.id, {
|
||||||
|
oldSchema: null,
|
||||||
|
newSchema: newConfig,
|
||||||
|
historyDescription,
|
||||||
|
source: historySource,
|
||||||
|
})?.uuid ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
this.emit('add', newConfig);
|
this.emit('add', newConfig);
|
||||||
|
|
||||||
return newConfig;
|
return newConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
public update(config: DataSourceSchema, { changeRecords = [] }: { changeRecords?: ChangeRecord[] } = {}) {
|
/**
|
||||||
|
* 更新数据源
|
||||||
|
* @param config 数据源配置
|
||||||
|
* @param data 额外数据
|
||||||
|
* @param data.changeRecords form 端变更记录
|
||||||
|
* @param data.doNotPushHistory 是否不写入历史记录(默认 false)
|
||||||
|
* @param data.historyDescription 入栈时附带的人类可读描述,用于历史面板展示
|
||||||
|
*/
|
||||||
|
public update(
|
||||||
|
config: DataSourceSchema,
|
||||||
|
{
|
||||||
|
changeRecords = [],
|
||||||
|
doNotPushHistory = false,
|
||||||
|
historyDescription,
|
||||||
|
historySource,
|
||||||
|
}: HistoryOpOptionsWithChangeRecords = {},
|
||||||
|
) {
|
||||||
const dataSources = this.get('dataSources');
|
const dataSources = this.get('dataSources');
|
||||||
|
|
||||||
const index = dataSources.findIndex((ds) => ds.id === config.id);
|
const index = dataSources.findIndex((ds) => ds.id === config.id);
|
||||||
@ -125,6 +188,17 @@ class DataSource extends BaseService {
|
|||||||
|
|
||||||
dataSources[index] = newConfig;
|
dataSources[index] = newConfig;
|
||||||
|
|
||||||
|
if (!doNotPushHistory) {
|
||||||
|
this.lastPushedHistoryId =
|
||||||
|
historyService.pushDataSource(newConfig.id, {
|
||||||
|
oldSchema: oldConfig ? cloneDeep(oldConfig) : null,
|
||||||
|
newSchema: newConfig,
|
||||||
|
changeRecords,
|
||||||
|
historyDescription,
|
||||||
|
source: historySource,
|
||||||
|
})?.uuid ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
this.emit('update', newConfig, {
|
this.emit('update', newConfig, {
|
||||||
oldConfig,
|
oldConfig,
|
||||||
changeRecords,
|
changeRecords,
|
||||||
@ -133,14 +207,160 @@ class DataSource extends BaseService {
|
|||||||
return newConfig;
|
return newConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
public remove(id: string) {
|
/**
|
||||||
|
* 删除数据源
|
||||||
|
* @param id 数据源 id
|
||||||
|
* @param options 可选配置
|
||||||
|
* @param options.doNotPushHistory 是否不写入历史记录(默认 false)
|
||||||
|
* @param options.historyDescription 入栈时附带的人类可读描述,用于历史面板展示
|
||||||
|
*/
|
||||||
|
public remove(id: string, { doNotPushHistory = false, historyDescription, historySource }: HistoryOpOptions = {}) {
|
||||||
const dataSources = this.get('dataSources');
|
const dataSources = this.get('dataSources');
|
||||||
const index = dataSources.findIndex((ds) => ds.id === id);
|
const index = dataSources.findIndex((ds) => ds.id === id);
|
||||||
|
const oldConfig = index !== -1 ? dataSources[index] : null;
|
||||||
dataSources.splice(index, 1);
|
dataSources.splice(index, 1);
|
||||||
|
|
||||||
|
if (oldConfig && !doNotPushHistory) {
|
||||||
|
this.lastPushedHistoryId =
|
||||||
|
historyService.pushDataSource(id, {
|
||||||
|
oldSchema: cloneDeep(oldConfig),
|
||||||
|
newSchema: null,
|
||||||
|
historyDescription,
|
||||||
|
source: historySource,
|
||||||
|
})?.uuid ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
this.emit('remove', id);
|
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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 撤销指定数据源的最近一次变更。
|
||||||
|
*
|
||||||
|
* 内部走 add / update / remove,因此会自动触发 dataSourceService 的事件,
|
||||||
|
* 由 initService 中的对应 handler 重新收集 dep(DATA_SOURCE / DATA_SOURCE_COND / DATA_SOURCE_METHOD)。
|
||||||
|
* 所有写回都带 `doNotPushHistory: true`,避免在历史栈里产生新的记录。
|
||||||
|
*
|
||||||
|
* @param id 数据源 id
|
||||||
|
* @returns 撤销的 step;栈不存在或已无可撤销时返回 null
|
||||||
|
*/
|
||||||
|
public undo(id: Id) {
|
||||||
|
const step = historyService.undoDataSource(id);
|
||||||
|
if (!step) return null;
|
||||||
|
this.applyHistoryStep(step, true);
|
||||||
|
return step;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重做指定数据源的下一次变更。
|
||||||
|
* @param id 数据源 id
|
||||||
|
* @returns 重做的 step;栈不存在或已无可重做时返回 null
|
||||||
|
*/
|
||||||
|
public redo(id: Id) {
|
||||||
|
const step = historyService.redoDataSource(id);
|
||||||
|
if (!step) return null;
|
||||||
|
this.applyHistoryStep(step, false);
|
||||||
|
return step;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否可对指定数据源撤销。 */
|
||||||
|
public canUndo(id: Id): boolean {
|
||||||
|
return historyService.canUndoDataSource(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否可对指定数据源重做。 */
|
||||||
|
public canRedo(id: Id): boolean {
|
||||||
|
return historyService.canRedoDataSource(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 跳转指定数据源的历史栈到目标游标。语义同 editor.gotoPageStep。
|
||||||
|
*
|
||||||
|
* @param id 数据源 id
|
||||||
|
* @param targetCursor 目标游标位置(已应用步骤数量)
|
||||||
|
* @returns 实际移动到的最终游标位置
|
||||||
|
*/
|
||||||
|
public goto(id: Id, targetCursor: number): number {
|
||||||
|
let cursor = historyService.getDataSourceCursor(id);
|
||||||
|
const target = Math.max(0, targetCursor);
|
||||||
|
while (cursor > target) {
|
||||||
|
if (!this.undo(id)) break;
|
||||||
|
cursor -= 1;
|
||||||
|
}
|
||||||
|
while (cursor < target) {
|
||||||
|
if (!this.redo(id)) break;
|
||||||
|
cursor += 1;
|
||||||
|
}
|
||||||
|
return cursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「回滚」指定数据源历史步骤(类 git revert 语义):
|
||||||
|
* - 不动原始栈结构(不移动 cursor、不丢弃任何步骤);
|
||||||
|
* - 把目标 step 的修改**反向应用**一次,并作为**新步骤**追加到栈顶;
|
||||||
|
* - 仅对已应用的步骤生效——未应用步骤本身就不存在于当前 schema 中,反向无意义。
|
||||||
|
*
|
||||||
|
* @param id 数据源 id
|
||||||
|
* @param index 目标 step 在该栈中的索引(0 为最早),通常由历史面板传入
|
||||||
|
* @returns 反向后产生的新 step;目标不存在 / 未应用时返回 null
|
||||||
|
*/
|
||||||
|
public revert(id: Id, index: number): DataSourceStepValue | null {
|
||||||
|
const list = historyService.getDataSourceStepList(id);
|
||||||
|
const entry = list[index];
|
||||||
|
if (!entry?.applied) return null;
|
||||||
|
// 更新类步骤(前后 schema 都存在)必须带 changeRecords 才支持回滚,否则只能整 schema 替换,会冲掉后续无关变更。
|
||||||
|
if (entry.step.oldSchema && entry.step.newSchema && !entry.step.changeRecords?.length) return null;
|
||||||
|
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 {
|
public createId(): string {
|
||||||
return `ds_${guid()}`;
|
return `ds_${guid()}`;
|
||||||
}
|
}
|
||||||
@ -215,6 +435,117 @@ class DataSource extends BaseService {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 反向应用一个 step 并以新 step 入栈(不带 doNotPushHistory)。逻辑与 applyHistoryStep(reverse=true)
|
||||||
|
* 同构,差异仅在于走对应的公共 add / update / remove 而不是带 doNotPushHistory 的版本。
|
||||||
|
*/
|
||||||
|
private applyRevertStep(step: DataSourceStepValue, historyDescription: string): DataSourceStepValue | null {
|
||||||
|
const { id, oldSchema, newSchema, changeRecords } = step;
|
||||||
|
|
||||||
|
// 原本是新增 → revert 即删除
|
||||||
|
if (oldSchema === null && newSchema) {
|
||||||
|
this.remove(`${id}`, { historyDescription, historySource: 'rollback' });
|
||||||
|
return historyService.getDataSourceStepList(id).slice(-1)[0]?.step ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 原本是删除 → revert 即重新加回
|
||||||
|
if (oldSchema && newSchema === null) {
|
||||||
|
this.add(cloneDeep(oldSchema), { historyDescription, historySource: 'rollback' });
|
||||||
|
return historyService.getDataSourceStepList(id).slice(-1)[0]?.step ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!oldSchema || !newSchema) return null;
|
||||||
|
|
||||||
|
// 原本是更新 → 把 oldSchema 写回;优先按 changeRecords 局部 patch
|
||||||
|
if (changeRecords?.length) {
|
||||||
|
const current = this.getDataSourceById(`${id}`);
|
||||||
|
if (!current) return null;
|
||||||
|
const patched = cloneDeep(current) as DataSourceSchema;
|
||||||
|
let fallbackToFullReplace = false;
|
||||||
|
for (const record of changeRecords) {
|
||||||
|
if (!record.propPath) {
|
||||||
|
fallbackToFullReplace = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const value = cloneDeep(getValueByKeyPath(record.propPath, oldSchema));
|
||||||
|
setValueByKeyPath(record.propPath, value, patched);
|
||||||
|
}
|
||||||
|
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' });
|
||||||
|
return historyService.getDataSourceStepList(id).slice(-1)[0]?.step ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把一条历史 step 应用到当前数据源服务上。
|
||||||
|
*
|
||||||
|
* 复用现有的 add / update / remove,目的是借助它们发出的事件触发 initService 中
|
||||||
|
* 的依赖收集逻辑(DATA_SOURCE / DATA_SOURCE_COND / DATA_SOURCE_METHOD)。
|
||||||
|
* 所有写回都带 `doNotPushHistory: true`,确保不会在历史栈里产生新的记录。
|
||||||
|
*
|
||||||
|
* - oldSchema=null, newSchema≠null:原始为新增 → undo 删除;redo 再次 add
|
||||||
|
* - oldSchema≠null, newSchema=null:原始为删除 → undo 还原 add;redo 再次删除
|
||||||
|
* - 两侧都有:原始为更新 → 按 changeRecords 局部 patch;缺省退化为整 schema 替换
|
||||||
|
*
|
||||||
|
* @param step 历史 step
|
||||||
|
* @param reverse true=撤销,false=重做
|
||||||
|
*/
|
||||||
|
private applyHistoryStep(step: DataSourceStepValue, reverse: boolean): void {
|
||||||
|
const { id, oldSchema, newSchema, changeRecords } = step;
|
||||||
|
|
||||||
|
// 新增 / 删除:直接 add 或 remove,不走 patch 逻辑
|
||||||
|
if (oldSchema === null && newSchema) {
|
||||||
|
if (reverse) {
|
||||||
|
this.remove(`${id}`, { doNotPushHistory: true });
|
||||||
|
} else {
|
||||||
|
this.add(cloneDeep(newSchema), { doNotPushHistory: true });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldSchema && newSchema === null) {
|
||||||
|
if (reverse) {
|
||||||
|
this.add(cloneDeep(oldSchema), { doNotPushHistory: true });
|
||||||
|
} else {
|
||||||
|
this.remove(`${id}`, { doNotPushHistory: true });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!oldSchema || !newSchema) return;
|
||||||
|
|
||||||
|
// 更新场景:优先按 changeRecords 局部 patch;缺省退化为整 schema 替换
|
||||||
|
const sourceForValues = reverse ? oldSchema : newSchema;
|
||||||
|
|
||||||
|
if (changeRecords?.length) {
|
||||||
|
const current = this.getDataSourceById(`${id}`);
|
||||||
|
if (!current) return;
|
||||||
|
const patched = cloneDeep(current) as DataSourceSchema;
|
||||||
|
let fallbackToFullReplace = false;
|
||||||
|
for (const record of changeRecords) {
|
||||||
|
if (!record.propPath) {
|
||||||
|
fallbackToFullReplace = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const value = cloneDeep(getValueByKeyPath(record.propPath, sourceForValues));
|
||||||
|
setValueByKeyPath(record.propPath, value, patched);
|
||||||
|
}
|
||||||
|
this.update(fallbackToFullReplace ? cloneDeep(sourceForValues) : patched, {
|
||||||
|
changeRecords,
|
||||||
|
doNotPushHistory: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.update(cloneDeep(sourceForValues), { doNotPushHistory: true });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DataSourceService = DataSource;
|
export type DataSourceService = DataSource;
|
||||||
|
|||||||
@ -109,9 +109,23 @@ class Dep extends BaseService {
|
|||||||
|
|
||||||
public collect(nodes: MNode[], depExtendedData: DepExtendedData = {}, deep = false, type?: DepTargetType) {
|
public collect(nodes: MNode[], depExtendedData: DepExtendedData = {}, deep = false, type?: DepTargetType) {
|
||||||
this.set('collecting', true);
|
this.set('collecting', true);
|
||||||
this.watcher.collectByCallback(nodes, type, ({ node, target }) => {
|
|
||||||
this.collectNode(node, target, depExtendedData, deep);
|
const targets = this.watcher.getCollectableTargets(type);
|
||||||
});
|
|
||||||
|
if (targets.length) {
|
||||||
|
for (const node of nodes) {
|
||||||
|
// 先删除原有依赖,再批量收集
|
||||||
|
if (isPage(node)) {
|
||||||
|
for (const target of targets) {
|
||||||
|
this.removePageDep(target, depExtendedData);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.watcher.removeTargetsDep(targets, node);
|
||||||
|
}
|
||||||
|
this.watcher.collectItems(node, targets, depExtendedData, deep);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.set('collecting', false);
|
this.set('collecting', false);
|
||||||
|
|
||||||
this.emit('collected', nodes, deep);
|
this.emit('collected', nodes, deep);
|
||||||
@ -176,7 +190,7 @@ class Dep extends BaseService {
|
|||||||
dsl.dataSourceDeps[target.id] = target.deps;
|
dsl.dataSourceDeps[target.id] = target.deps;
|
||||||
} else if (target.type === DepTargetType.DATA_SOURCE_COND && dsl.dataSourceCondDeps) {
|
} else if (target.type === DepTargetType.DATA_SOURCE_COND && dsl.dataSourceCondDeps) {
|
||||||
dsl.dataSourceCondDeps[target.id] = target.deps;
|
dsl.dataSourceCondDeps[target.id] = target.deps;
|
||||||
} else if (target.type === DepTargetType.DATA_SOURCE_METHOD) {
|
} else if (target.type === DepTargetType.DATA_SOURCE_METHOD && dsl.dataSourceMethodDeps) {
|
||||||
dsl.dataSourceMethodDeps[target.id] = target.deps;
|
dsl.dataSourceMethodDeps[target.id] = target.deps;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -194,11 +208,7 @@ class Dep extends BaseService {
|
|||||||
public collectNode(node: MNode, target: Target, depExtendedData: DepExtendedData = {}, deep = false) {
|
public collectNode(node: MNode, target: Target, depExtendedData: DepExtendedData = {}, deep = false) {
|
||||||
// 先删除原有依赖,重新收集
|
// 先删除原有依赖,重新收集
|
||||||
if (isPage(node)) {
|
if (isPage(node)) {
|
||||||
for (const [depKey, dep] of Object.entries(target.deps)) {
|
this.removePageDep(target, depExtendedData);
|
||||||
if (dep.data?.pageId && dep.data.pageId === depExtendedData.pageId) {
|
|
||||||
delete target.deps[depKey];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
this.watcher.removeTargetDep(target, node);
|
this.watcher.removeTargetDep(target, node);
|
||||||
}
|
}
|
||||||
@ -263,6 +273,19 @@ class Dep extends BaseService {
|
|||||||
return super.emit(eventName, ...args);
|
return super.emit(eventName, ...args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除指定 page 在该 target 下的旧依赖:
|
||||||
|
* 按 pageId 匹配,可清掉页面内已被删除节点的残留依赖
|
||||||
|
*/
|
||||||
|
private removePageDep(target: Target, depExtendedData: DepExtendedData = {}) {
|
||||||
|
for (const depKey of Object.keys(target.deps)) {
|
||||||
|
const dep = target.deps[depKey];
|
||||||
|
if (dep.data?.pageId && dep.data.pageId === depExtendedData.pageId) {
|
||||||
|
delete target.deps[depKey];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private enqueueTask(node: MNode, target: Target, depExtendedData: DepExtendedData, deep: boolean) {
|
private enqueueTask(node: MNode, target: Target, depExtendedData: DepExtendedData, deep: boolean) {
|
||||||
this.idleTask.enqueueTask(
|
this.idleTask.enqueueTask(
|
||||||
({ node, deep, target }) => {
|
({ node, deep, target }) => {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -17,20 +17,243 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { reactive } from 'vue';
|
import { reactive } from 'vue';
|
||||||
|
import { cloneDeep } from 'lodash-es';
|
||||||
|
import serialize from 'serialize-javascript';
|
||||||
|
|
||||||
import type { MPage, MPageFragment } from '@tmagic/core';
|
import type { CodeBlockContent, DataSourceSchema, Id, MPage, MPageFragment } from '@tmagic/core';
|
||||||
|
import type { ChangeRecord } from '@tmagic/form';
|
||||||
|
import { guid } from '@tmagic/utils';
|
||||||
|
|
||||||
import type { HistoryState, StepValue } from '@editor/type';
|
import type {
|
||||||
|
CodeBlockHistoryGroup,
|
||||||
|
CodeBlockStepValue,
|
||||||
|
DataSourceHistoryGroup,
|
||||||
|
DataSourceStepValue,
|
||||||
|
HistoryOpSource,
|
||||||
|
HistoryPersistOptions,
|
||||||
|
HistoryState,
|
||||||
|
PageHistoryGroup,
|
||||||
|
PageHistoryStepEntry,
|
||||||
|
PersistedHistoryState,
|
||||||
|
StepValue,
|
||||||
|
} from '@editor/type';
|
||||||
|
import { getEditorConfig } from '@editor/utils/config';
|
||||||
|
import { idbGet, idbSet } from '@editor/utils/indexed-db';
|
||||||
import { UndoRedo } from '@editor/utils/undo-redo';
|
import { UndoRedo } from '@editor/utils/undo-redo';
|
||||||
|
|
||||||
import BaseService from './BaseService';
|
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 {
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把单个栈当前游标所在记录标记为已保存:先清除该栈内全部旧标记,保证同一栈最多一条 `saved`。
|
||||||
|
* 栈处于「全部已撤销」(cursor 为 0)时不会留下已保存记录,恢复时其游标回到 0。
|
||||||
|
*/
|
||||||
|
private static markStackSaved<S extends { saved?: boolean }>(undoRedo?: UndoRedo<S>): void {
|
||||||
|
if (!undoRedo) return;
|
||||||
|
undoRedo.updateElements((element) => {
|
||||||
|
element.saved = false;
|
||||||
|
});
|
||||||
|
undoRedo.updateCurrentElement((element) => {
|
||||||
|
element.saved = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把 `Record<Id, UndoRedo>` 整体序列化为 `Record<Id, SerializedUndoRedo>`。 */
|
||||||
|
private static serializeStacks<T>(stacks: Record<Id, UndoRedo<T>>) {
|
||||||
|
const result: Record<Id, ReturnType<UndoRedo<T>['serialize']>> = {};
|
||||||
|
Object.entries(stacks).forEach(([id, undoRedo]) => {
|
||||||
|
if (undoRedo) result[id] = undoRedo.serialize();
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把 `Record<Id, SerializedUndoRedo>` 整体还原为 `Record<Id, UndoRedo>`。
|
||||||
|
* 还原时把每个栈的游标定位到最近一条已保存(`saved === true`)记录之后。
|
||||||
|
*/
|
||||||
|
private static deserializeStacks<T extends { saved?: boolean }>(
|
||||||
|
stacks: Record<Id, ReturnType<UndoRedo<T>['serialize']>> = {},
|
||||||
|
): Record<Id, UndoRedo<T>> {
|
||||||
|
const result: Record<Id, UndoRedo<T>> = {};
|
||||||
|
Object.entries(stacks).forEach(([id, serialized]) => {
|
||||||
|
if (serialized) {
|
||||||
|
result[id] = UndoRedo.fromSerialized<T>(serialized, { isSavedStep: (element) => element.saved === true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
public state = reactive<HistoryState>({
|
public state = reactive<HistoryState>({
|
||||||
pageSteps: {},
|
pageSteps: {},
|
||||||
pageId: undefined,
|
pageId: undefined,
|
||||||
canRedo: false,
|
canRedo: false,
|
||||||
canUndo: false,
|
canUndo: false,
|
||||||
|
codeBlockState: {},
|
||||||
|
dataSourceState: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@ -41,6 +264,8 @@ class History extends BaseService {
|
|||||||
|
|
||||||
public reset() {
|
public reset() {
|
||||||
this.state.pageSteps = {};
|
this.state.pageSteps = {};
|
||||||
|
this.state.codeBlockState = {};
|
||||||
|
this.state.dataSourceState = {};
|
||||||
this.resetPage();
|
this.resetPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -69,16 +294,158 @@ class History extends BaseService {
|
|||||||
this.state.pageSteps = {};
|
this.state.pageSteps = {};
|
||||||
this.state.canRedo = false;
|
this.state.canRedo = false;
|
||||||
this.state.canUndo = false;
|
this.state.canUndo = false;
|
||||||
|
this.state.codeBlockState = {};
|
||||||
|
this.state.dataSourceState = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
public push(state: StepValue): StepValue | null {
|
/**
|
||||||
const undoRedo = this.getUndoRedo();
|
* 把一条步骤推入指定页面的栈;不指定 pageId 时落到当前活动页。
|
||||||
|
*
|
||||||
|
* 跨页操作(例如 `moveToContainer` 把节点搬到其它页)必须显式传入 `pageId`,
|
||||||
|
* 否则会把记录错误地落到操作发起页 / 当前激活页,破坏目标页 / 源页的撤销栈语义。
|
||||||
|
*/
|
||||||
|
public push(state: StepValue, pageId?: Id): StepValue | null {
|
||||||
|
const undoRedo = this.getUndoRedo(pageId);
|
||||||
if (!undoRedo) return null;
|
if (!undoRedo) return null;
|
||||||
|
if (state.uuid === undefined) state.uuid = guid();
|
||||||
|
if (state.timestamp === undefined) state.timestamp = Date.now();
|
||||||
undoRedo.pushElement(state);
|
undoRedo.pushElement(state);
|
||||||
this.emit('change', state);
|
// 仅当推入的是当前活动页时才需要刷新 canUndo/canRedo —— 其它页栈对当前 UI 状态没影响。
|
||||||
|
if (pageId === undefined || `${pageId}` === `${this.state.pageId}`) {
|
||||||
|
this.emit('change', state);
|
||||||
|
}
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 推入一条代码块变更记录(与页面/节点完全无关),按 `codeBlockId` 维度独立一份 UndoRedo 栈。
|
||||||
|
*
|
||||||
|
* - 新增:oldContent = null,newContent = 新内容
|
||||||
|
* - 更新:oldContent / newContent 都为对应内容
|
||||||
|
* - 删除:newContent = null,oldContent = 删除前内容
|
||||||
|
* - `changeRecords` 来自 form 端,撤销/重做时若有则按 propPath 局部覆盖;缺省才退化为整内容替换。
|
||||||
|
* - 不直接驱动 codeBlockService,调用方负责实际写回。
|
||||||
|
*/
|
||||||
|
public pushCodeBlock(
|
||||||
|
codeBlockId: Id,
|
||||||
|
payload: {
|
||||||
|
oldContent: CodeBlockContent | null;
|
||||||
|
newContent: CodeBlockContent | null;
|
||||||
|
changeRecords?: ChangeRecord[];
|
||||||
|
/** 可选的人类可读描述(如「修改按钮颜色」),仅用于历史面板展示。 */
|
||||||
|
historyDescription?: string;
|
||||||
|
/** 可选的操作途径(配置面板 / 菜单 / 接口等),仅用于历史面板展示与埋点。 */
|
||||||
|
source?: HistoryOpSource;
|
||||||
|
},
|
||||||
|
): CodeBlockStepValue | null {
|
||||||
|
if (!codeBlockId) return null;
|
||||||
|
|
||||||
|
const step: CodeBlockStepValue = {
|
||||||
|
uuid: guid(),
|
||||||
|
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,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.getCodeBlockUndoRedo(codeBlockId).pushElement(step);
|
||||||
|
this.emit('code-block-history-change', codeBlockId, step);
|
||||||
|
return step;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 推入一条数据源变更记录(与页面/节点完全无关),按 `dataSourceId` 维度独立一份 UndoRedo 栈。
|
||||||
|
* 行为同 pushCodeBlock(新增 oldSchema=null;删除 newSchema=null)。
|
||||||
|
*/
|
||||||
|
public pushDataSource(
|
||||||
|
dataSourceId: Id,
|
||||||
|
payload: {
|
||||||
|
oldSchema: DataSourceSchema | null;
|
||||||
|
newSchema: DataSourceSchema | null;
|
||||||
|
changeRecords?: ChangeRecord[];
|
||||||
|
/** 可选的人类可读描述,仅用于历史面板展示。 */
|
||||||
|
historyDescription?: string;
|
||||||
|
/** 可选的操作途径(配置面板 / 菜单 / 接口等),仅用于历史面板展示与埋点。 */
|
||||||
|
source?: HistoryOpSource;
|
||||||
|
},
|
||||||
|
): DataSourceStepValue | null {
|
||||||
|
if (!dataSourceId) return null;
|
||||||
|
|
||||||
|
const step: DataSourceStepValue = {
|
||||||
|
uuid: guid(),
|
||||||
|
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,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.getDataSourceUndoRedo(dataSourceId).pushElement(step);
|
||||||
|
this.emit('data-source-history-change', dataSourceId, step);
|
||||||
|
return step;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 撤销指定代码块的最近一次变更。 */
|
||||||
|
public undoCodeBlock(codeBlockId: Id): CodeBlockStepValue | null {
|
||||||
|
const undoRedo = this.state.codeBlockState[codeBlockId];
|
||||||
|
if (!undoRedo) return null;
|
||||||
|
const step = undoRedo.undo();
|
||||||
|
if (step) this.emit('code-block-history-change', codeBlockId, step);
|
||||||
|
return step;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重做指定代码块的下一次变更。 */
|
||||||
|
public redoCodeBlock(codeBlockId: Id): CodeBlockStepValue | null {
|
||||||
|
const undoRedo = this.state.codeBlockState[codeBlockId];
|
||||||
|
if (!undoRedo) return null;
|
||||||
|
const step = undoRedo.redo();
|
||||||
|
if (step) this.emit('code-block-history-change', codeBlockId, step);
|
||||||
|
return step;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否可对指定代码块撤销。 */
|
||||||
|
public canUndoCodeBlock(codeBlockId: Id): boolean {
|
||||||
|
return this.state.codeBlockState[codeBlockId]?.canUndo() ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否可对指定代码块重做。 */
|
||||||
|
public canRedoCodeBlock(codeBlockId: Id): boolean {
|
||||||
|
return this.state.codeBlockState[codeBlockId]?.canRedo() ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 撤销指定数据源的最近一次变更。 */
|
||||||
|
public undoDataSource(dataSourceId: Id): DataSourceStepValue | null {
|
||||||
|
const undoRedo = this.state.dataSourceState[dataSourceId];
|
||||||
|
if (!undoRedo) return null;
|
||||||
|
const step = undoRedo.undo();
|
||||||
|
if (step) this.emit('data-source-history-change', dataSourceId, step);
|
||||||
|
return step;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重做指定数据源的下一次变更。 */
|
||||||
|
public redoDataSource(dataSourceId: Id): DataSourceStepValue | null {
|
||||||
|
const undoRedo = this.state.dataSourceState[dataSourceId];
|
||||||
|
if (!undoRedo) return null;
|
||||||
|
const step = undoRedo.redo();
|
||||||
|
if (step) this.emit('data-source-history-change', dataSourceId, step);
|
||||||
|
return step;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否可对指定数据源撤销。 */
|
||||||
|
public canUndoDataSource(dataSourceId: Id): boolean {
|
||||||
|
return this.state.dataSourceState[dataSourceId]?.canUndo() ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否可对指定数据源重做。 */
|
||||||
|
public canRedoDataSource(dataSourceId: Id): boolean {
|
||||||
|
return this.state.dataSourceState[dataSourceId]?.canRedo() ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
public undo(): StepValue | null {
|
public undo(): StepValue | null {
|
||||||
const undoRedo = this.getUndoRedo();
|
const undoRedo = this.getUndoRedo();
|
||||||
if (!undoRedo) return null;
|
if (!undoRedo) return null;
|
||||||
@ -101,9 +468,306 @@ class History extends BaseService {
|
|||||||
this.removeAllPlugins();
|
this.removeAllPlugins();
|
||||||
}
|
}
|
||||||
|
|
||||||
private getUndoRedo() {
|
/**
|
||||||
if (!this.state.pageId) return null;
|
* 清空指定页面(缺省当前活动页)的历史记录栈。
|
||||||
return this.state.pageSteps[this.state.pageId];
|
* 仅删除撤销/重做记录,不会改动当前 DSL;清空后该页将无法再撤销/重做之前的操作。
|
||||||
|
*/
|
||||||
|
public clearPage(pageId?: Id): void {
|
||||||
|
const targetPageId = pageId ?? this.state.pageId;
|
||||||
|
if (!targetPageId) return;
|
||||||
|
this.state.pageSteps[targetPageId] = new UndoRedo<StepValue>();
|
||||||
|
if (`${targetPageId}` === `${this.state.pageId}`) {
|
||||||
|
this.setCanUndoRedo();
|
||||||
|
this.emit('change', null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空数据源历史记录栈:传入 `dataSourceId` 仅清空该数据源,缺省清空全部数据源。
|
||||||
|
* 仅删除撤销/重做记录,不会改动数据源本身。
|
||||||
|
*/
|
||||||
|
public clearDataSource(dataSourceId?: Id): void {
|
||||||
|
if (dataSourceId !== undefined) {
|
||||||
|
delete this.state.dataSourceState[dataSourceId];
|
||||||
|
} else {
|
||||||
|
this.state.dataSourceState = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空代码块历史记录栈:传入 `codeBlockId` 仅清空该代码块,缺省清空全部代码块。
|
||||||
|
* 仅删除撤销/重做记录,不会改动代码块本身。
|
||||||
|
*/
|
||||||
|
public clearCodeBlock(codeBlockId?: Id): void {
|
||||||
|
if (codeBlockId !== undefined) {
|
||||||
|
delete this.state.codeBlockState[codeBlockId];
|
||||||
|
} else {
|
||||||
|
this.state.codeBlockState = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标记「整份 DSL 已保存」:把页面 / 代码块 / 数据源所有栈当前游标所在的记录都标为 `saved`。
|
||||||
|
* 适用于「整体落库」场景;若只保存了其中一类,请改用更细粒度的
|
||||||
|
* {@link markPageSaved} / {@link markCodeBlockSaved} / {@link markDataSourceSaved}。
|
||||||
|
*/
|
||||||
|
public markSaved(): void {
|
||||||
|
Object.values(this.state.pageSteps).forEach(History.markStackSaved);
|
||||||
|
Object.values(this.state.codeBlockState).forEach(History.markStackSaved);
|
||||||
|
Object.values(this.state.dataSourceState).forEach(History.markStackSaved);
|
||||||
|
this.emit('mark-saved', { kind: 'all' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标记指定页面(缺省为当前活动页)的历史栈当前记录为已保存。
|
||||||
|
* 仅影响该页面自己的栈,不波及代码块 / 数据源 / 其它页面。
|
||||||
|
*/
|
||||||
|
public markPageSaved(pageId?: Id): void {
|
||||||
|
const targetPageId = pageId ?? this.state.pageId;
|
||||||
|
if (!targetPageId) return;
|
||||||
|
History.markStackSaved(this.state.pageSteps[targetPageId]);
|
||||||
|
this.emit('mark-saved', { kind: 'page', id: targetPageId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 标记指定代码块的历史栈当前记录为已保存,仅影响该代码块自己的栈。 */
|
||||||
|
public markCodeBlockSaved(codeBlockId: Id): void {
|
||||||
|
if (!codeBlockId) return;
|
||||||
|
History.markStackSaved(this.state.codeBlockState[codeBlockId]);
|
||||||
|
this.emit('mark-saved', { kind: 'code-block', id: codeBlockId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 标记指定数据源的历史栈当前记录为已保存,仅影响该数据源自己的栈。 */
|
||||||
|
public markDataSourceSaved(dataSourceId: Id): void {
|
||||||
|
if (!dataSourceId) return;
|
||||||
|
History.markStackSaved(this.state.dataSourceState[dataSourceId]);
|
||||||
|
this.emit('mark-saved', { kind: 'data-source', id: dataSourceId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把当前内存中的全部历史栈(页面 / 代码块 / 数据源)序列化后写入本地 IndexedDB。
|
||||||
|
*
|
||||||
|
* - 每个 UndoRedo 栈连同其游标、容量一并保存,恢复后可继续 undo/redo;
|
||||||
|
* - `key` 用于区分不同活动页 / 项目(同一 store 下可保存多份快照),缺省为 `default`;
|
||||||
|
* - 返回写入成功的快照对象,便于调用方记录 savedAt 等信息;
|
||||||
|
* - 不支持 IndexedDB 的环境(如 SSR)会 reject。
|
||||||
|
*/
|
||||||
|
public async saveToIndexedDB(options: HistoryPersistOptions = {}): Promise<PersistedHistoryState> {
|
||||||
|
const { dbName = DEFAULT_DB_NAME, storeName = DEFAULT_STORE_NAME, key = DEFAULT_KEY } = options;
|
||||||
|
|
||||||
|
const snapshot: PersistedHistoryState = {
|
||||||
|
version: PERSIST_VERSION,
|
||||||
|
pageId: this.state.pageId,
|
||||||
|
pageSteps: History.serializeStacks(this.state.pageSteps),
|
||||||
|
codeBlockState: History.serializeStacks(this.state.codeBlockState),
|
||||||
|
dataSourceState: History.serializeStacks(this.state.dataSourceState),
|
||||||
|
savedAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 历史记录里可能包含函数(如代码块内容 / 节点事件 / 数据源方法),IndexedDB 的结构化克隆无法写入函数,
|
||||||
|
// 因此用 serialize-javascript 序列化成字符串后再写入(支持函数 / Map 等),读取时用 parseDSL 还原。
|
||||||
|
await idbSet(this.resolveDbName(dbName), storeName, key, serialize(snapshot));
|
||||||
|
this.emit('save-to-indexed-db', snapshot);
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从本地 IndexedDB 读取此前保存的历史快照并重建全部撤销/重做栈。
|
||||||
|
*
|
||||||
|
* - 读取到的每个栈都会经 {@link UndoRedo.fromSerialized} 还原(含游标),随后可直接 undo/redo;
|
||||||
|
* - 会整体覆盖当前内存中的历史状态,并把活动页恢复为快照中的 pageId;
|
||||||
|
* - 找不到对应记录时返回 null,且不改动当前状态;
|
||||||
|
* - 不支持 IndexedDB 的环境(如 SSR)会 reject。
|
||||||
|
*/
|
||||||
|
public async restoreFromIndexedDB(options: HistoryPersistOptions = {}): Promise<PersistedHistoryState | null> {
|
||||||
|
const { dbName = DEFAULT_DB_NAME, storeName = DEFAULT_STORE_NAME, key = DEFAULT_KEY } = options;
|
||||||
|
|
||||||
|
const raw = await idbGet<string | PersistedHistoryState>(this.resolveDbName(dbName), storeName, key);
|
||||||
|
if (!raw) return null;
|
||||||
|
|
||||||
|
// 新版以序列化字符串存储(含函数),用 parseDSL 还原;兼容历史上以对象形式存入的旧数据。
|
||||||
|
const snapshot = (typeof raw === 'string' ? getEditorConfig('parseDSL')(`(${raw})`) : raw) as PersistedHistoryState;
|
||||||
|
if (!snapshot) return null;
|
||||||
|
|
||||||
|
this.state.pageSteps = History.deserializeStacks(snapshot.pageSteps);
|
||||||
|
this.state.codeBlockState = History.deserializeStacks(snapshot.codeBlockState);
|
||||||
|
this.state.dataSourceState = History.deserializeStacks(snapshot.dataSourceState);
|
||||||
|
this.state.pageId = snapshot.pageId;
|
||||||
|
|
||||||
|
this.setCanUndoRedo();
|
||||||
|
this.emit('restore-from-indexed-db', snapshot);
|
||||||
|
this.emit('change', null);
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取出当前活动页的历史步骤平铺列表(包含已应用 + 已撤销)。
|
||||||
|
* 列表按时间正序,最早一步在最前面。
|
||||||
|
* 通常 UI 应使用 `getPageHistoryGroups` 取已合并分组的版本;本方法仅为兼容/调试保留。
|
||||||
|
*/
|
||||||
|
public getPageStepList(pageId?: Id): PageHistoryStepEntry[] {
|
||||||
|
const targetPageId = pageId ?? this.state.pageId;
|
||||||
|
if (!targetPageId) return [];
|
||||||
|
const undoRedo = this.state.pageSteps[targetPageId];
|
||||||
|
if (!undoRedo) return [];
|
||||||
|
const list = undoRedo.getElementList();
|
||||||
|
const cursor = undoRedo.getCursor();
|
||||||
|
return list.map((step, index) => ({
|
||||||
|
step,
|
||||||
|
index,
|
||||||
|
applied: index < cursor,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取出当前活动页的历史栈,按"目标节点"做相邻合并:
|
||||||
|
* - 连续修改同一节点(单节点 update)的多步合并为一个 group,组内可展开查看每步;
|
||||||
|
* - add / remove / 多节点 update 始终独立成组。
|
||||||
|
* 用于历史面板的"页面"tab 展示。
|
||||||
|
*/
|
||||||
|
public getPageHistoryGroups(pageId?: Id): PageHistoryGroup[] {
|
||||||
|
const targetPageId = pageId ?? this.state.pageId;
|
||||||
|
if (!targetPageId) return [];
|
||||||
|
const undoRedo = this.state.pageSteps[targetPageId];
|
||||||
|
if (!undoRedo) return [];
|
||||||
|
const list = undoRedo.getElementList();
|
||||||
|
if (!list.length) return [];
|
||||||
|
const cursor = undoRedo.getCursor();
|
||||||
|
return History.mergePageSteps(targetPageId, list, cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取出全部代码块的历史栈,按 codeBlockId 分组。
|
||||||
|
* 同一栈内相邻、同 opType 且作用于同一 id 的多步会被合并为一个 group:
|
||||||
|
* - 这正是"代码块/数据源各自按 id 分栈"的天然表现,再叠加"连续修改同目标的相邻步骤合并展示"。
|
||||||
|
* - 合并后 group 暴露子步骤数组,UI 可展开查看每一步的 changeRecords。
|
||||||
|
* - applied 字段:组内最后一步是否处于已应用段。
|
||||||
|
*/
|
||||||
|
public getCodeBlockHistoryGroups(): CodeBlockHistoryGroup[] {
|
||||||
|
const groups: CodeBlockHistoryGroup[] = [];
|
||||||
|
Object.entries(this.state.codeBlockState).forEach(([id, undoRedo]) => {
|
||||||
|
if (!undoRedo) return;
|
||||||
|
const list = undoRedo.getElementList();
|
||||||
|
if (!list.length) return;
|
||||||
|
const cursor = undoRedo.getCursor();
|
||||||
|
groups.push(...History.mergeCodeBlockSteps(id, list, cursor));
|
||||||
|
});
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取指定页面历史栈的当前游标(已应用步骤数量)。不传则取当前活动页。
|
||||||
|
* 没有对应栈时返回 0。
|
||||||
|
*/
|
||||||
|
public getPageCursor(pageId?: Id): number {
|
||||||
|
const targetPageId = pageId ?? this.state.pageId;
|
||||||
|
if (!targetPageId) return 0;
|
||||||
|
return this.state.pageSteps[targetPageId]?.getCursor() ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取指定代码块历史栈的当前游标。 */
|
||||||
|
public getCodeBlockCursor(codeBlockId: Id): number {
|
||||||
|
return this.state.codeBlockState[codeBlockId]?.getCursor() ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取指定数据源历史栈的当前游标。 */
|
||||||
|
public getDataSourceCursor(dataSourceId: Id): number {
|
||||||
|
return this.state.dataSourceState[dataSourceId]?.getCursor() ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取出指定代码块历史栈的平铺步骤列表(含 applied 标记)。供 revert 等按 index 索引步骤使用。
|
||||||
|
*/
|
||||||
|
public getCodeBlockStepList(codeBlockId: Id): { step: CodeBlockStepValue; index: number; applied: boolean }[] {
|
||||||
|
const undoRedo = this.state.codeBlockState[codeBlockId];
|
||||||
|
if (!undoRedo) return [];
|
||||||
|
const list = undoRedo.getElementList();
|
||||||
|
const cursor = undoRedo.getCursor();
|
||||||
|
return list.map((step, index) => ({ step, index, applied: index < cursor }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取出指定数据源历史栈的平铺步骤列表(含 applied 标记)。供 revert 等按 index 索引步骤使用。
|
||||||
|
*/
|
||||||
|
public getDataSourceStepList(dataSourceId: Id): { step: DataSourceStepValue; index: number; applied: boolean }[] {
|
||||||
|
const undoRedo = this.state.dataSourceState[dataSourceId];
|
||||||
|
if (!undoRedo) return [];
|
||||||
|
const list = undoRedo.getElementList();
|
||||||
|
const cursor = undoRedo.getCursor();
|
||||||
|
return list.map((step, index) => ({ step, index, applied: index < cursor }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按历史记录 uuid 在指定页面(默认当前活动页)的栈中查找其索引。
|
||||||
|
* 找不到时返回 -1。供「按 uuid 回滚」等需要把 uuid 映射回 index 的场景使用。
|
||||||
|
*/
|
||||||
|
public getPageStepIndexByUuid(uuid: string, pageId?: Id): number {
|
||||||
|
if (!uuid) return -1;
|
||||||
|
return this.getPageStepList(pageId).findIndex((entry) => entry.step.uuid === uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按历史记录 uuid 在全部代码块栈中查找其所属 codeBlockId 与索引。
|
||||||
|
* 找不到时返回 null。
|
||||||
|
*/
|
||||||
|
public findCodeBlockStepLocationByUuid(uuid: string): { id: Id; index: number } | null {
|
||||||
|
if (!uuid) return null;
|
||||||
|
for (const id of Object.keys(this.state.codeBlockState)) {
|
||||||
|
const index = this.getCodeBlockStepList(id).findIndex((entry) => entry.step.uuid === uuid);
|
||||||
|
if (index >= 0) return { id, index };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按历史记录 uuid 在全部数据源栈中查找其所属 dataSourceId 与索引。
|
||||||
|
* 找不到时返回 null。
|
||||||
|
*/
|
||||||
|
public findDataSourceStepLocationByUuid(uuid: string): { id: Id; index: number } | null {
|
||||||
|
if (!uuid) return null;
|
||||||
|
for (const id of Object.keys(this.state.dataSourceState)) {
|
||||||
|
const index = this.getDataSourceStepList(id).findIndex((entry) => entry.step.uuid === uuid);
|
||||||
|
if (index >= 0) return { id, index };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取出全部数据源的历史栈,按 dataSourceId 分组。同上。
|
||||||
|
*/
|
||||||
|
public getDataSourceHistoryGroups(): DataSourceHistoryGroup[] {
|
||||||
|
const groups: DataSourceHistoryGroup[] = [];
|
||||||
|
Object.entries(this.state.dataSourceState).forEach(([id, undoRedo]) => {
|
||||||
|
if (!undoRedo) return;
|
||||||
|
const list = undoRedo.getElementList();
|
||||||
|
if (!list.length) return;
|
||||||
|
const cursor = undoRedo.getCursor();
|
||||||
|
groups.push(...History.mergeDataSourceSteps(id, list, cursor));
|
||||||
|
});
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取出指定页面的栈;不传 pageId 时按当前活动页取。
|
||||||
|
*
|
||||||
|
* 跨页 push 时如果目标页的栈尚不存在(用户从未进入过该页),会按需创建一条空栈,
|
||||||
|
* 这样切到目标页时 Ctrl+Z 也能撤回该步骤。
|
||||||
|
*/
|
||||||
|
private getUndoRedo(pageId?: Id) {
|
||||||
|
const targetPageId = pageId ?? this.state.pageId;
|
||||||
|
if (!targetPageId) return null;
|
||||||
|
if (!this.state.pageSteps[targetPageId]) {
|
||||||
|
this.state.pageSteps[targetPageId] = new UndoRedo<StepValue>();
|
||||||
|
}
|
||||||
|
return this.state.pageSteps[targetPageId];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把基础 dbName 与当前 DSL(root app)的 id 拼成最终库名,实现不同应用历史隔离。
|
||||||
|
* 取不到 app id(如尚未加载 DSL)时退回基础 dbName。
|
||||||
|
*/
|
||||||
|
private resolveDbName(dbName: string): string {
|
||||||
|
const appId = editorService.get('root')?.id;
|
||||||
|
return appId ? `${dbName}-${appId}` : dbName;
|
||||||
}
|
}
|
||||||
|
|
||||||
private setCanUndoRedo(): void {
|
private setCanUndoRedo(): void {
|
||||||
@ -111,6 +775,26 @@ class History extends BaseService {
|
|||||||
this.state.canRedo = undoRedo?.canRedo() || false;
|
this.state.canRedo = undoRedo?.canRedo() || false;
|
||||||
this.state.canUndo = undoRedo?.canUndo() || 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];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type HistoryService = History;
|
export type HistoryService = History;
|
||||||
|
|||||||
@ -20,7 +20,7 @@ class Keybinding extends BaseService {
|
|||||||
const nodes = editorService.get('nodes');
|
const nodes = editorService.get('nodes');
|
||||||
|
|
||||||
if (!nodes || isPage(nodes[0]) || isPageFragment(nodes[0])) return;
|
if (!nodes || isPage(nodes[0]) || isPageFragment(nodes[0])) return;
|
||||||
editorService.remove(nodes);
|
editorService.remove(nodes, { historySource: 'shortcut' });
|
||||||
},
|
},
|
||||||
[KeyBindingCommand.COPY_NODE]: () => {
|
[KeyBindingCommand.COPY_NODE]: () => {
|
||||||
const nodes = editorService.get('nodes');
|
const nodes = editorService.get('nodes');
|
||||||
@ -31,11 +31,11 @@ class Keybinding extends BaseService {
|
|||||||
|
|
||||||
if (!nodes || isPage(nodes[0]) || isPageFragment(nodes[0])) return;
|
if (!nodes || isPage(nodes[0]) || isPageFragment(nodes[0])) return;
|
||||||
editorService.copy(nodes);
|
editorService.copy(nodes);
|
||||||
editorService.remove(nodes);
|
editorService.remove(nodes, { historySource: 'shortcut' });
|
||||||
},
|
},
|
||||||
[KeyBindingCommand.PASTE_NODE]: () => {
|
[KeyBindingCommand.PASTE_NODE]: () => {
|
||||||
const nodes = editorService.get('nodes');
|
const nodes = editorService.get('nodes');
|
||||||
nodes && editorService.paste({ offsetX: 10, offsetY: 10 });
|
nodes && editorService.paste({ offsetX: 10, offsetY: 10 }, undefined, { historySource: 'shortcut' });
|
||||||
},
|
},
|
||||||
[KeyBindingCommand.UNDO]: () => {
|
[KeyBindingCommand.UNDO]: () => {
|
||||||
editorService.undo();
|
editorService.undo();
|
||||||
|
|||||||
@ -97,9 +97,9 @@ class Props extends BaseService {
|
|||||||
return this.state.propsConfigMap;
|
return this.state.propsConfigMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async fillConfig(config: FormConfig, labelWidth?: string) {
|
public async fillConfig(config: FormConfig, labelWidth = '80px') {
|
||||||
return fillConfig(config, {
|
return fillConfig(config, {
|
||||||
labelWidth: typeof labelWidth !== 'function' ? labelWidth : '80px',
|
labelWidth,
|
||||||
disabledDataSource: this.getDisabledDataSource(),
|
disabledDataSource: this.getDisabledDataSource(),
|
||||||
disabledCodeBlock: this.getDisabledCodeBlock(),
|
disabledCodeBlock: this.getDisabledCodeBlock(),
|
||||||
});
|
});
|
||||||
|
|||||||
512
packages/editor/src/theme/history-list-panel.scss
Normal file
512
packages/editor/src/theme/history-list-panel.scss
Normal file
@ -0,0 +1,512 @@
|
|||||||
|
.m-editor-history-list-popover {
|
||||||
|
padding: 0 !important;
|
||||||
|
|
||||||
|
.m-editor-history-list {
|
||||||
|
position: relative;
|
||||||
|
padding: 4px 8px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭按钮悬浮在 tab 标题同一行(绝对定位,不占布局空间)。
|
||||||
|
// top 对齐容器顶部内边距,height 跟 el-tabs/t-tabs 头部默认高度一致,
|
||||||
|
// 再用 flex 居中让图标与 tab 标题视觉对齐。
|
||||||
|
.m-editor-history-list-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 4px;
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 40px;
|
||||||
|
margin: 0;
|
||||||
|
color: #909399;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #409eff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-editor-history-list-tabs {
|
||||||
|
.el-tabs__header,
|
||||||
|
.t-tabs__header {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-editor-history-list-empty {
|
||||||
|
padding: 24px 0;
|
||||||
|
text-align: center;
|
||||||
|
color: #909399;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-editor-history-list-ul {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
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;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: #303133;
|
||||||
|
cursor: default;
|
||||||
|
|
||||||
|
// 合并组(卡片)自己定义了 hover/active 视觉,跳过通用单步 hover 避免色彩叠加
|
||||||
|
&:not(.m-editor-history-list-group.is-merged):hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-undone {
|
||||||
|
color: #c0c4cc;
|
||||||
|
|
||||||
|
.m-editor-history-list-item-op {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当前所在的「单步」记录:左侧蓝条 + 浅蓝底;
|
||||||
|
// 合并组的当前态由 `.is-merged.is-current` 单独覆盖(border-left + 卡片背景),
|
||||||
|
// 故这里仅作用于「非合并组」的单步条目,避免与卡片样式互相干扰。
|
||||||
|
&.is-current:not(.m-editor-history-list-group.is-merged) {
|
||||||
|
background-color: rgba(64, 158, 255, 0.1);
|
||||||
|
box-shadow: inset 2px 0 0 #409eff;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(64, 158, 255, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-editor-history-list-item-desc {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #409eff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-editor-history-list-group {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0;
|
||||||
|
|
||||||
|
.m-editor-history-list-group-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
cursor: default;
|
||||||
|
|
||||||
|
&.is-clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-editor-history-list-group-toggle {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: #909399;
|
||||||
|
font-size: 12px;
|
||||||
|
user-select: none;
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
&.is-expanded {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 合并组(≥2 个连续同节点的修改被聚合)需要与单步条目明显区分:
|
||||||
|
// - 左侧 3px 紫色色条暗示「这是一组」;
|
||||||
|
// - 浅紫背景 + 边框把整个组视觉化为一张「卡片」;
|
||||||
|
// - 头部加 padding,避免和单步条目混在一起难以辨认。
|
||||||
|
&.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;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
// 卡片本体已经有背景色,hover 状态以更深的同色提示交互
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(47, 84, 235, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-editor-history-list-group-head {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1d39c4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已撤销态:整张卡片去色
|
||||||
|
&.is-undone {
|
||||||
|
background-color: rgba(192, 196, 204, 0.08);
|
||||||
|
border-color: rgba(192, 196, 204, 0.4);
|
||||||
|
border-left-color: #c0c4cc;
|
||||||
|
|
||||||
|
.m-editor-history-list-group-head {
|
||||||
|
color: #c0c4cc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当前组:卡片左条变蓝,与单步「当前」高亮一致
|
||||||
|
&.is-current {
|
||||||
|
background-color: rgba(64, 158, 255, 0.08);
|
||||||
|
border-color: rgba(64, 158, 255, 0.3);
|
||||||
|
border-left-color: #409eff;
|
||||||
|
box-shadow: none; // 覆盖 .is-current 公共的 inset 阴影
|
||||||
|
|
||||||
|
.m-editor-history-list-group-head {
|
||||||
|
color: #409eff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-editor-history-list-substeps {
|
||||||
|
margin: 6px 0 0 6px;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
border-left: 1px dashed rgba(47, 84, 235, 0.45);
|
||||||
|
|
||||||
|
li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #606266;
|
||||||
|
cursor: default;
|
||||||
|
border-radius: 3px;
|
||||||
|
|
||||||
|
&.is-clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(47, 84, 235, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-undone {
|
||||||
|
color: #c0c4cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-current {
|
||||||
|
color: #409eff;
|
||||||
|
font-weight: 600;
|
||||||
|
background-color: rgba(64, 158, 255, 0.08);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-editor-history-list-item-current {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 2px;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 16px;
|
||||||
|
color: #fff;
|
||||||
|
background-color: #409eff;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-editor-history-list-item-index {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: #909399;
|
||||||
|
font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 400; // 防止被合并组头部的粗体继承
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 操作时间:弱化展示,紧贴在描述之后、各操作按钮之前。
|
||||||
|
.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;
|
||||||
|
border-radius: 2px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 18px;
|
||||||
|
color: #fff;
|
||||||
|
background-color: #909399;
|
||||||
|
|
||||||
|
&.op-add {
|
||||||
|
background-color: #67c23a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.op-remove {
|
||||||
|
background-color: #f56c6c;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.op-update {
|
||||||
|
background-color: #e6a23c;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.op-initial {
|
||||||
|
background-color: #909399;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-editor-history-list-initial {
|
||||||
|
cursor: default;
|
||||||
|
color: #606266;
|
||||||
|
border-top: 1px dashed #dcdfe6;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding-top: 8px;
|
||||||
|
|
||||||
|
&.is-clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-editor-history-list-item-desc {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-editor-history-list-item-desc {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
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;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 16px;
|
||||||
|
color: #fff;
|
||||||
|
background-color: #2f54eb;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-editor-history-list-item-diff {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 2px;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 16px;
|
||||||
|
color: #409eff;
|
||||||
|
background-color: rgba(64, 158, 255, 0.1);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(64, 158, 255, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 「跳转」按钮:将历史游标移动到该 step,替代原先点击整行跳转的交互。
|
||||||
|
// 使用与组卡片一致的紫色色系,与「查看差异」「回滚」区分开。
|
||||||
|
.m-editor-history-list-item-goto {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 2px;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 16px;
|
||||||
|
color: #606266;
|
||||||
|
background-color: rgba(96, 98, 102, 0.1);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(96, 98, 102, 0.18);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 「回滚」按钮:类 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);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(245, 108, 108, 0.25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-editor-history-list-substep-desc {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-editor-history-list-bucket {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-editor-history-list-bucket-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #606266;
|
||||||
|
background-color: #f5f7fa;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
code {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #409eff;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-editor-history-list-bucket-count {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: #909399;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-editor-history-diff-dialog {
|
||||||
|
.m-editor-history-diff-dialog-body {
|
||||||
|
display: flex;
|
||||||
|
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;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background-color: #f5f7fa;
|
||||||
|
border-radius: 4px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-editor-history-diff-dialog-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-editor-history-diff-dialog-view,
|
||||||
|
.m-editor-history-diff-dialog-mode {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-editor-history-diff-dialog-target {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #303133;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-editor-history-diff-dialog-legend {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-editor-history-diff-dialog-arrow {
|
||||||
|
color: #909399;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-editor-history-diff-dialog-tip {
|
||||||
|
margin-left: 8px;
|
||||||
|
color: #e6a23c;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -58,7 +58,7 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
right: 15px;
|
right: 15px;
|
||||||
bottom: 15px;
|
bottom: 15px;
|
||||||
z-index: 30;
|
z-index: 32;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
@ -70,7 +70,7 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
right: 15px;
|
right: 15px;
|
||||||
bottom: 60px;
|
bottom: 60px;
|
||||||
z-index: 30;
|
z-index: 31;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
@ -82,7 +82,7 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 10;
|
z-index: 31;
|
||||||
}
|
}
|
||||||
|
|
||||||
.m-editor-resizer {
|
.m-editor-resizer {
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
@use "./search-input.scss";
|
@use "./search-input.scss";
|
||||||
@use "./nav-menu.scss";
|
@use "./nav-menu.scss";
|
||||||
|
@use "./history-list-panel.scss";
|
||||||
@use "./framework.scss";
|
@use "./framework.scss";
|
||||||
@use "./sidebar.scss";
|
@use "./sidebar.scss";
|
||||||
@use "./layer-panel.scss";
|
@use "./layer-panel.scss";
|
||||||
|
|||||||
@ -22,7 +22,17 @@ import type * as Monaco from 'monaco-editor';
|
|||||||
import type { default as Sortable, Options, SortableEvent } from 'sortablejs';
|
import type { default as Sortable, Options, SortableEvent } from 'sortablejs';
|
||||||
import type { PascalCasedProperties, Writable } from 'type-fest';
|
import type { PascalCasedProperties, Writable } from 'type-fest';
|
||||||
|
|
||||||
import type { CodeBlockContent, CodeBlockDSL, Id, MApp, MContainer, MNode, MPage, MPageFragment } from '@tmagic/core';
|
import type {
|
||||||
|
CodeBlockContent,
|
||||||
|
CodeBlockDSL,
|
||||||
|
DataSourceSchema,
|
||||||
|
Id,
|
||||||
|
MApp,
|
||||||
|
MContainer,
|
||||||
|
MNode,
|
||||||
|
MPage,
|
||||||
|
MPageFragment,
|
||||||
|
} from '@tmagic/core';
|
||||||
import type { ChangeRecord, FormConfig, TableColumnConfig, TypeFunction } from '@tmagic/form';
|
import type { ChangeRecord, FormConfig, TableColumnConfig, TypeFunction } from '@tmagic/form';
|
||||||
import type StageCore from '@tmagic/stage';
|
import type StageCore from '@tmagic/stage';
|
||||||
import type {
|
import type {
|
||||||
@ -46,7 +56,7 @@ import type { PropsService } from './services/props';
|
|||||||
import type { StageOverlayService } from './services/stageOverlay';
|
import type { StageOverlayService } from './services/stageOverlay';
|
||||||
import type { StorageService } from './services/storage';
|
import type { StorageService } from './services/storage';
|
||||||
import type { UiService } from './services/ui';
|
import type { UiService } from './services/ui';
|
||||||
import type { UndoRedo } from './utils/undo-redo';
|
import type { SerializedUndoRedo, UndoRedo } from './utils/undo-redo';
|
||||||
|
|
||||||
export type EditorSlots = FrameworkSlots &
|
export type EditorSlots = FrameworkSlots &
|
||||||
WorkspaceSlots &
|
WorkspaceSlots &
|
||||||
@ -138,7 +148,7 @@ export interface EditorInstallOptions {
|
|||||||
customCreateMonacoDiffEditor: (
|
customCreateMonacoDiffEditor: (
|
||||||
monaco: typeof import('monaco-editor'),
|
monaco: typeof import('monaco-editor'),
|
||||||
codeEditorEl: HTMLElement,
|
codeEditorEl: HTMLElement,
|
||||||
options: Monaco.editor.IStandaloneEditorConstructionOptions & { editorCustomType?: string },
|
options: Monaco.editor.IStandaloneDiffEditorConstructionOptions & { editorCustomType?: string },
|
||||||
) => Promise<Monaco.editor.IStandaloneDiffEditor> | Monaco.editor.IStandaloneDiffEditor;
|
) => Promise<Monaco.editor.IStandaloneDiffEditor> | Monaco.editor.IStandaloneDiffEditor;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
@ -189,6 +199,11 @@ export interface StageOptions {
|
|||||||
*/
|
*/
|
||||||
alwaysMultiSelect?: boolean;
|
alwaysMultiSelect?: boolean;
|
||||||
disabledRule?: boolean;
|
disabledRule?: boolean;
|
||||||
|
/**
|
||||||
|
* 禁用「非点击画布选中组件时(如从图层树、面包屑等外部选中),对选中区域做高亮闪烁提示」,
|
||||||
|
* 默认 false(即默认开启闪烁)
|
||||||
|
*/
|
||||||
|
disabledFlashTip?: boolean;
|
||||||
zoom?: number;
|
zoom?: number;
|
||||||
/** 画布双击前的钩子函数,返回 false 则阻止默认的双击行为 */
|
/** 画布双击前的钩子函数,返回 false 则阻止默认的双击行为 */
|
||||||
beforeDblclick?: (event: MouseEvent) => Promise<boolean | void> | boolean | void;
|
beforeDblclick?: (event: MouseEvent) => Promise<boolean | void> | boolean | void;
|
||||||
@ -407,6 +422,7 @@ export interface MenuComponent {
|
|||||||
* 'rule': 显示隐藏标尺
|
* 'rule': 显示隐藏标尺
|
||||||
* 'scale-to-original': 缩放到实际大小
|
* 'scale-to-original': 缩放到实际大小
|
||||||
* 'scale-to-fit': 缩放以适应
|
* 'scale-to-fit': 缩放以适应
|
||||||
|
* 'history-list': 历史记录面板(按 页面 / 数据源 / 代码块 三个 tab 展示,相邻同目标修改自动合并)
|
||||||
*/
|
*/
|
||||||
// #region MenuItem
|
// #region MenuItem
|
||||||
export type MenuItem =
|
export type MenuItem =
|
||||||
@ -421,6 +437,7 @@ export type MenuItem =
|
|||||||
| 'rule'
|
| 'rule'
|
||||||
| 'scale-to-original'
|
| 'scale-to-original'
|
||||||
| 'scale-to-fit'
|
| 'scale-to-fit'
|
||||||
|
| 'history-list'
|
||||||
| MenuButton
|
| MenuButton
|
||||||
| MenuComponent
|
| MenuComponent
|
||||||
| string;
|
| string;
|
||||||
@ -463,6 +480,63 @@ export interface SideComponent extends MenuComponent {
|
|||||||
}
|
}
|
||||||
// #endregion SideComponent
|
// #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
|
// #region SideItemKey
|
||||||
export enum SideItemKey {
|
export enum SideItemKey {
|
||||||
COMPONENT_LIST = 'component-list',
|
COMPONENT_LIST = 'component-list',
|
||||||
@ -609,8 +683,80 @@ export interface CodeParamStatement {
|
|||||||
export type HistoryOpType = 'add' | 'remove' | 'update';
|
export type HistoryOpType = 'add' | 'remove' | 'update';
|
||||||
// #endregion HistoryOpType
|
// #endregion HistoryOpType
|
||||||
|
|
||||||
|
// #region HistoryOpSource
|
||||||
|
/**
|
||||||
|
* 历史记录的「操作途径」——标记本次变更由哪条交互入口触发,仅用于历史面板展示 / 业务埋点,
|
||||||
|
* 不影响 undo/redo 行为。缺省(未传)时 UI 视为「未知」。
|
||||||
|
*
|
||||||
|
* - `stage`:画布(拖拽 / 缩放 / 排序等舞台直接操作)
|
||||||
|
* - `tree`:树形面板(图层 / 数据源 / 代码块等树形结构里的拖拽 / 菜单操作)
|
||||||
|
* - `component-panel`:组件面板(左侧组件列表点击 / 拖拽新增组件)
|
||||||
|
* - `props`:配置面板表单(属性表单字段编辑)
|
||||||
|
* - `code`:源码编辑器(配置面板「源码」面板里直接编辑 JSON/代码后保存)
|
||||||
|
* - `stage-contextmenu`:画布右键菜单(舞台上节点的右键上下文菜单)
|
||||||
|
* - `tree-contextmenu`:树面板右键菜单(图层 / 数据源 / 代码块等树形列表上的右键上下文菜单)
|
||||||
|
* - `toolbar`:工具栏菜单(顶部导航工具栏按钮)
|
||||||
|
* - `shortcut`:键盘快捷键
|
||||||
|
* - `rollback`:历史回滚(历史面板里对某条历史「回滚」,反向应用为一条新记录,类 git revert)
|
||||||
|
* - `api`:代码 / 接口调用(程序化触发)
|
||||||
|
* - `ai`:AI 生成 / 智能助手触发的变更
|
||||||
|
* - `unknown`:未知来源
|
||||||
|
*
|
||||||
|
* 通过 `(string & {})` 允许业务侧扩展自定义途径字符串,同时保留内置值的自动补全。
|
||||||
|
*/
|
||||||
|
export type HistoryOpSource =
|
||||||
|
| 'stage'
|
||||||
|
| 'tree'
|
||||||
|
| 'component-panel'
|
||||||
|
| 'props'
|
||||||
|
| 'code'
|
||||||
|
| 'stage-contextmenu'
|
||||||
|
| 'tree-contextmenu'
|
||||||
|
| 'toolbar'
|
||||||
|
| 'shortcut'
|
||||||
|
| 'rollback'
|
||||||
|
| 'api'
|
||||||
|
| 'ai'
|
||||||
|
| 'unknown'
|
||||||
|
| (string & {});
|
||||||
|
// #endregion HistoryOpSource
|
||||||
|
|
||||||
|
// #region BaseStepValue
|
||||||
|
/**
|
||||||
|
* 历史记录条目公共字段,被 {@link StepValue} / {@link CodeBlockStepValue} / {@link DataSourceStepValue} 复用。
|
||||||
|
*/
|
||||||
|
export interface BaseStepValue {
|
||||||
|
/**
|
||||||
|
* 历史记录唯一标识(uuid)。入栈时自动写入(若调用方未指定),
|
||||||
|
* 用于精确定位 / 引用某一条历史记录(如 revert、埋点、跨端同步等)。
|
||||||
|
* 注意与各自的 `id`(关联的页面 / 代码块 / 数据源 id)区分。
|
||||||
|
*/
|
||||||
|
uuid: string;
|
||||||
|
/**
|
||||||
|
* 调用方可选传入的人类可读描述(如「调整按钮颜色」),用于历史面板展示。
|
||||||
|
* 不影响 undo/redo 行为;缺省时面板会根据节点 / propPath 自动生成描述。
|
||||||
|
*/
|
||||||
|
historyDescription?: string;
|
||||||
|
/**
|
||||||
|
* 操作途径:标记本次变更由哪条交互入口触发,取值见 {@link HistoryOpSource}
|
||||||
|
* (画布 / 树面板 / 组件面板 / 配置面板 / 源码编辑器 / 右键菜单 / 工具栏 / 快捷键 / 回滚 / 接口 等)。
|
||||||
|
* 仅用于历史面板展示与业务埋点,不影响 undo/redo 行为;缺省时面板视为「未知」。
|
||||||
|
*/
|
||||||
|
source?: HistoryOpSource;
|
||||||
|
/**
|
||||||
|
* 入栈时间戳(毫秒)。入栈时自动写入(若调用方未指定),仅用于历史面板展示。
|
||||||
|
*/
|
||||||
|
timestamp?: number;
|
||||||
|
/**
|
||||||
|
* 是否为「已保存」记录:DSL 落库(如保存到后端 / 本地)时由 historyService.markSaved 标记。
|
||||||
|
* 同一栈内任意时刻最多只有一条记录为 true;从 IndexedDB 恢复时游标会被定位到最近一条已保存记录之后。
|
||||||
|
*/
|
||||||
|
saved?: boolean;
|
||||||
|
}
|
||||||
|
// #endregion BaseStepValue
|
||||||
|
|
||||||
// #region StepValue
|
// #region StepValue
|
||||||
export interface StepValue {
|
export interface StepValue extends BaseStepValue {
|
||||||
/** 页面信息 */
|
/** 页面信息 */
|
||||||
data: { name: string; id: Id };
|
data: { name: string; id: Id };
|
||||||
opType: HistoryOpType;
|
opType: HistoryOpType;
|
||||||
@ -627,18 +773,185 @@ export interface StepValue {
|
|||||||
indexMap?: Record<string, number>;
|
indexMap?: Record<string, number>;
|
||||||
/** opType 'remove': 被删除的节点及其位置信息 */
|
/** opType 'remove': 被删除的节点及其位置信息 */
|
||||||
removedItems?: { node: MNode; parentId: Id; index: number }[];
|
removedItems?: { node: MNode; parentId: Id; index: number }[];
|
||||||
/** opType 'update': 变更前后的节点快照 */
|
/**
|
||||||
updatedItems?: { oldNode: MNode; newNode: MNode }[];
|
* opType 'update': 变更前后的节点快照
|
||||||
|
*
|
||||||
|
* `changeRecords` 来自 form 端的 propPath/value 列表,撤销/重做时只对这些 propPath 做局部更新;
|
||||||
|
* 缺省(未传 / 空数组)才退化为整节点替换。
|
||||||
|
*/
|
||||||
|
updatedItems?: { oldNode: MNode; newNode: MNode; changeRecords?: ChangeRecord[] }[];
|
||||||
}
|
}
|
||||||
// #endregion StepValue
|
// #endregion StepValue
|
||||||
|
|
||||||
|
// #region CodeBlockStepValue
|
||||||
|
/**
|
||||||
|
* 代码块历史记录条目。按 codeBlock.id 分组保存到 historyState.codeBlockState。
|
||||||
|
* - 新增:oldContent = null,newContent = 新内容
|
||||||
|
* - 更新:oldContent / newContent 都为对应内容
|
||||||
|
* - 删除:newContent = null,oldContent = 删除前内容
|
||||||
|
*/
|
||||||
|
export interface CodeBlockStepValue extends BaseStepValue {
|
||||||
|
/** 关联的代码块 id */
|
||||||
|
id: Id;
|
||||||
|
/** 变更前的代码块内容,新增时为 null */
|
||||||
|
oldContent: CodeBlockContent | null;
|
||||||
|
/** 变更后的代码块内容,删除时为 null */
|
||||||
|
newContent: CodeBlockContent | null;
|
||||||
|
/**
|
||||||
|
* form 端 propPath/value 列表。撤销/重做时若有则按 propPath 局部更新;
|
||||||
|
* 缺省才退化为整内容替换。新增/删除场景通常无 changeRecords。
|
||||||
|
*/
|
||||||
|
changeRecords?: ChangeRecord[];
|
||||||
|
}
|
||||||
|
// #endregion CodeBlockStepValue
|
||||||
|
|
||||||
|
// #region DataSourceStepValue
|
||||||
|
/**
|
||||||
|
* 数据源历史记录条目。按 dataSource.id 分组保存到 historyState.dataSourceState。
|
||||||
|
* - 新增:oldSchema = null,newSchema = 新 schema
|
||||||
|
* - 更新:oldSchema / newSchema 都为对应 schema
|
||||||
|
* - 删除:newSchema = null,oldSchema = 删除前 schema
|
||||||
|
*/
|
||||||
|
export interface DataSourceStepValue extends BaseStepValue {
|
||||||
|
/** 关联的数据源 id */
|
||||||
|
id: Id;
|
||||||
|
/** 变更前的数据源 schema,新增时为 null */
|
||||||
|
oldSchema: DataSourceSchema | null;
|
||||||
|
/** 变更后的数据源 schema,删除时为 null */
|
||||||
|
newSchema: DataSourceSchema | null;
|
||||||
|
/**
|
||||||
|
* form 端 propPath/value 列表。撤销/重做时若有则按 propPath 局部更新;
|
||||||
|
* 缺省才退化为整 schema 替换。新增/删除场景通常无 changeRecords。
|
||||||
|
*/
|
||||||
|
changeRecords?: ChangeRecord[];
|
||||||
|
}
|
||||||
|
// #endregion DataSourceStepValue
|
||||||
|
|
||||||
export interface HistoryState {
|
export interface HistoryState {
|
||||||
pageId?: Id;
|
pageId?: Id;
|
||||||
pageSteps: Record<Id, UndoRedo<StepValue>>;
|
pageSteps: Record<Id, UndoRedo<StepValue>>;
|
||||||
canRedo: boolean;
|
canRedo: boolean;
|
||||||
canUndo: boolean;
|
canUndo: boolean;
|
||||||
|
/**
|
||||||
|
* 代码块历史栈,按 codeBlock.id 分组(每个代码块独立一份 UndoRedo)。
|
||||||
|
* 与页面/节点无关,支持独立 undo/redo。
|
||||||
|
*/
|
||||||
|
codeBlockState: Record<Id, UndoRedo<CodeBlockStepValue>>;
|
||||||
|
/**
|
||||||
|
* 数据源历史栈,按 dataSource.id 分组(每个数据源独立一份 UndoRedo)。
|
||||||
|
* 与页面/节点无关,支持独立 undo/redo。
|
||||||
|
*/
|
||||||
|
dataSourceState: Record<Id, UndoRedo<DataSourceStepValue>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #region PersistedHistoryState
|
||||||
|
/**
|
||||||
|
* 历史记录的可持久化快照。由 historyService.saveToIndexedDB 写入 IndexedDB,
|
||||||
|
* 再由 historyService.restoreFromIndexedDB 读出并重建各 UndoRedo 栈。
|
||||||
|
*/
|
||||||
|
export interface PersistedHistoryState {
|
||||||
|
/** 快照结构版本号,便于后续兼容升级。 */
|
||||||
|
version: number;
|
||||||
|
/** 保存时的活动页 id。 */
|
||||||
|
pageId?: Id;
|
||||||
|
/** 各页面历史栈的序列化快照,按 pageId 分组。 */
|
||||||
|
pageSteps: Record<Id, SerializedUndoRedo<StepValue>>;
|
||||||
|
/** 各代码块历史栈的序列化快照,按 codeBlockId 分组。 */
|
||||||
|
codeBlockState: Record<Id, SerializedUndoRedo<CodeBlockStepValue>>;
|
||||||
|
/** 各数据源历史栈的序列化快照,按 dataSourceId 分组。 */
|
||||||
|
dataSourceState: Record<Id, SerializedUndoRedo<DataSourceStepValue>>;
|
||||||
|
/** 保存时间戳(毫秒)。 */
|
||||||
|
savedAt: number;
|
||||||
|
}
|
||||||
|
// #endregion PersistedHistoryState
|
||||||
|
|
||||||
|
// #region HistoryPersistOptions
|
||||||
|
/** historyService 持久化相关 API 的可选配置。 */
|
||||||
|
export interface HistoryPersistOptions {
|
||||||
|
/** IndexedDB 数据库名,默认 `tmagic-editor`(最终库名会拼上当前 DSL app id)。 */
|
||||||
|
dbName?: string;
|
||||||
|
/** objectStore 名,默认 `history`。 */
|
||||||
|
storeName?: string;
|
||||||
|
/** 记录 key,用于区分不同活动页 / 项目,默认 `default`。 */
|
||||||
|
key?: IDBValidKey;
|
||||||
|
}
|
||||||
|
// #endregion HistoryPersistOptions
|
||||||
|
|
||||||
|
// #region HistoryListEntry
|
||||||
|
/**
|
||||||
|
* 历史面板用:当前页面的一条历史步骤(包含位置和是否已应用)。
|
||||||
|
*/
|
||||||
|
export interface PageHistoryStepEntry {
|
||||||
|
/** 步骤内容 */
|
||||||
|
step: StepValue;
|
||||||
|
/** 在所属栈中的索引(0 为最早) */
|
||||||
|
index: number;
|
||||||
|
/** 是否处于"已应用"段(即位于栈游标之前)。撤销后变为 false。 */
|
||||||
|
applied: boolean;
|
||||||
|
/** 是否为当前所在的步骤(栈中最近一次已应用的那一步,即 index === cursor - 1)。 */
|
||||||
|
isCurrent?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 页面历史面板分组。
|
||||||
|
* - 连续修改同一目标节点(updatedItems[0].oldNode.id 一致)的 'update' 步骤合并成一组;
|
||||||
|
* - 多节点更新 / add / remove 始终独立成组(无法明确归属单一目标)。
|
||||||
|
* - targetId 为 undefined 表示"无明确目标"(如 add/remove/多节点 update),不参与合并。
|
||||||
|
*/
|
||||||
|
export interface PageHistoryGroup {
|
||||||
|
kind: 'page';
|
||||||
|
/** 所属页面 id */
|
||||||
|
pageId: Id;
|
||||||
|
/** 该分组的操作类型 */
|
||||||
|
opType: HistoryOpType;
|
||||||
|
/**
|
||||||
|
* 合并的目标节点 id;只有"单节点 update"才有值,并按此 id 与相邻同 id 的 update 合并。
|
||||||
|
* undefined 表示该分组不可被合并(add / remove / 多节点 update)。
|
||||||
|
*/
|
||||||
|
targetId?: Id;
|
||||||
|
/** 目标节点的可读名(取最后一步的 newNode.name/type/id) */
|
||||||
|
targetName?: string;
|
||||||
|
/** 组内所有步骤,按时间正序 */
|
||||||
|
steps: PageHistoryStepEntry[];
|
||||||
|
/** 组内最后一步是否已应用 */
|
||||||
|
applied: boolean;
|
||||||
|
/** 是否为当前所在的分组(包含栈中最近一次已应用步骤的那一组)。 */
|
||||||
|
isCurrent?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 代码块历史面板分组。
|
||||||
|
* - 同一 codeBlockId 的栈内,相邻的 'update' 操作会合并成一个 group;
|
||||||
|
* - 'add' / 'remove' 始终独立成组(语义上是一次性事件)。
|
||||||
|
*/
|
||||||
|
export interface CodeBlockHistoryGroup {
|
||||||
|
kind: 'code-block';
|
||||||
|
/** 关联的 codeBlock id */
|
||||||
|
id: Id;
|
||||||
|
/** 该分组的操作类型 */
|
||||||
|
opType: HistoryOpType;
|
||||||
|
/** 组内所有步骤,按时间正序 */
|
||||||
|
steps: { step: CodeBlockStepValue; index: number; applied: boolean; isCurrent?: boolean }[];
|
||||||
|
/** 组内最后一步是否已应用,用于整组的状态展示 */
|
||||||
|
applied: boolean;
|
||||||
|
/** 是否为当前所在的分组(包含该栈最近一次已应用步骤的那一组)。 */
|
||||||
|
isCurrent?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据源历史面板分组,结构同 CodeBlockHistoryGroup。
|
||||||
|
*/
|
||||||
|
export interface DataSourceHistoryGroup {
|
||||||
|
kind: 'data-source';
|
||||||
|
id: Id;
|
||||||
|
opType: HistoryOpType;
|
||||||
|
steps: { step: DataSourceStepValue; index: number; applied: boolean; isCurrent?: boolean }[];
|
||||||
|
applied: boolean;
|
||||||
|
/** 是否为当前所在的分组(包含该栈最近一次已应用步骤的那一组)。 */
|
||||||
|
isCurrent?: boolean;
|
||||||
|
}
|
||||||
|
// #endregion HistoryListEntry
|
||||||
|
|
||||||
export enum KeyBindingCommand {
|
export enum KeyBindingCommand {
|
||||||
/** 复制 */
|
/** 复制 */
|
||||||
COPY_NODE = 'tmagic-system-copy-node',
|
COPY_NODE = 'tmagic-system-copy-node',
|
||||||
@ -874,12 +1187,58 @@ export const canUsePluginMethods = {
|
|||||||
|
|
||||||
export type AsyncMethodName = Writable<(typeof canUsePluginMethods)['async']>;
|
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)。
|
||||||
|
*/
|
||||||
|
export interface HistoryOpOptionsWithChangeRecords extends HistoryOpOptions {
|
||||||
|
changeRecords?: ChangeRecord[];
|
||||||
|
}
|
||||||
|
// #endregion HistoryOpOptionsWithChangeRecords
|
||||||
|
|
||||||
|
// #region DslOpOptions
|
||||||
/**
|
/**
|
||||||
* DSL 修改类操作的通用配置
|
* DSL 修改类操作的通用配置
|
||||||
* - doNotSelect: 操作后是否不要自动触发选中(不调用 this.select / this.multiSelect / stage.select / stage.multiSelect)
|
* - doNotSelect: 操作后是否不要自动触发选中(不调用 this.select / this.multiSelect / stage.select / stage.multiSelect)
|
||||||
* - doNotSwitchPage: 操作若会引发当前页面切换(如新增 / 删除 / 跨页移动),是否跳过这次切换
|
* - doNotSwitchPage: 操作若会引发当前页面切换(如新增 / 删除 / 跨页移动),是否跳过这次切换
|
||||||
*/
|
*/
|
||||||
export type DslOpOptions = {
|
export interface DslOpOptions extends HistoryOpOptions {
|
||||||
doNotSelect?: boolean;
|
doNotSelect?: boolean;
|
||||||
doNotSwitchPage?: 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;
|
||||||
|
}
|
||||||
|
|||||||
150
packages/editor/src/utils/code-block.ts
Normal file
150
packages/editor/src/utils/code-block.ts
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
/*
|
||||||
|
* 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 { tMagicMessage } from '@tmagic/design';
|
||||||
|
import { defineFormConfig, defineFormItem, type FormConfig, type TableColumnConfig } from '@tmagic/form';
|
||||||
|
|
||||||
|
import { getEditorConfig } from './config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 代码块编辑表单配置的统一入口。
|
||||||
|
*
|
||||||
|
* 用于:
|
||||||
|
* - `CodeBlockEditor.vue`:作为代码块新增/编辑的表单结构;
|
||||||
|
* - `CompareForm.vue`:在历史差异对比中以"代码块"形态展示前后值。
|
||||||
|
*
|
||||||
|
* 通过参数控制两端的细微差异(是否启用必填校验 / vs-code onChange 校验 / dataSource 相关字段显示规则等),
|
||||||
|
* 避免两边同步两份相同的 schema 而造成漂移。
|
||||||
|
*/
|
||||||
|
export interface GetCodeBlockFormConfigOptions {
|
||||||
|
/**
|
||||||
|
* 自定义 `params` 表格的"参数类型"列配置。优先级高于内置默认值;
|
||||||
|
* 通常由 `codeBlockService.getParamsColConfig()` 注入业务侧的扩展。
|
||||||
|
*/
|
||||||
|
paramColConfig?: TableColumnConfig;
|
||||||
|
/**
|
||||||
|
* 是否在「数据源代码块」语境下使用:
|
||||||
|
* - true:`执行时机` 字段会展示,`请求前 / 请求后` 选项仅在 `dataSourceType !== 'base'` 时出现;
|
||||||
|
* - false:`执行时机` 字段隐藏(普通代码块没有时机概念)。
|
||||||
|
*
|
||||||
|
* 注意这里以函数形式传入是为了让 `display` / `options` 在每次渲染时实时取值,
|
||||||
|
* 例如外部 `props.isDataSource` 切换或 `dataSourceType` 变更都能立即生效。
|
||||||
|
*/
|
||||||
|
isDataSource?: () => boolean;
|
||||||
|
/** 当 isDataSource 为 true 时使用:返回当前数据源类型(`base` / `http` / ...),决定时机选项是否包含请求前后。 */
|
||||||
|
dataSourceType?: () => string | undefined;
|
||||||
|
/** vs-code 编辑器的额外 monaco options。一般传 `inject('codeOptions', {})` 的结果。 */
|
||||||
|
codeOptions?: Record<string, any>;
|
||||||
|
/**
|
||||||
|
* 是否启用编辑态特性:
|
||||||
|
* - true(默认):`name` 字段加必填校验;vs-code 的 `onChange` 会调用 `parseDSL` 检查语法,出错时弹消息并抛错;
|
||||||
|
* - false:纯只读/对比场景,不加校验逻辑。
|
||||||
|
*/
|
||||||
|
editable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 默认的"参数类型"列配置:数字 / 字符串 / 组件 三选一。 */
|
||||||
|
const defaultParamColConfig = () =>
|
||||||
|
defineFormItem<TableColumnConfig>({
|
||||||
|
type: 'row',
|
||||||
|
label: '参数类型',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
text: '参数类型',
|
||||||
|
labelWidth: '70px',
|
||||||
|
type: 'select',
|
||||||
|
name: 'type',
|
||||||
|
options: [
|
||||||
|
{ text: '数字', label: '数字', value: 'number' },
|
||||||
|
{ text: '字符串', label: '字符串', value: 'text' },
|
||||||
|
{ text: '组件', label: '组件', value: 'ui-select' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成代码块表单配置。返回的是普通对象数组,可直接传给 `<MForm :config>`;
|
||||||
|
* 如需响应式的 props 变化(如切换 dataSourceType),调用方在 `computed` 中再次调用本函数即可。
|
||||||
|
*/
|
||||||
|
export const getCodeBlockFormConfig = (options: GetCodeBlockFormConfigOptions = {}): FormConfig => {
|
||||||
|
const { paramColConfig, isDataSource, dataSourceType, codeOptions = {}, editable = true } = options;
|
||||||
|
|
||||||
|
return defineFormConfig([
|
||||||
|
{
|
||||||
|
text: '名称',
|
||||||
|
name: 'name',
|
||||||
|
...(editable ? { rules: [{ required: true, message: '请输入名称', trigger: 'blur' }] } : {}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '描述',
|
||||||
|
name: 'desc',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '执行时机',
|
||||||
|
name: 'timing',
|
||||||
|
type: 'select',
|
||||||
|
options: () => {
|
||||||
|
const list = [
|
||||||
|
{ text: '初始化前', value: 'beforeInit' },
|
||||||
|
{ text: '初始化后', value: 'afterInit' },
|
||||||
|
];
|
||||||
|
if (dataSourceType?.() !== 'base') {
|
||||||
|
list.push({ text: '请求前', value: 'beforeRequest' });
|
||||||
|
list.push({ text: '请求后', value: 'afterRequest' });
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
},
|
||||||
|
display: () => Boolean(isDataSource?.()),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'table',
|
||||||
|
border: true,
|
||||||
|
text: '参数',
|
||||||
|
enableFullscreen: false,
|
||||||
|
enableToggleMode: false,
|
||||||
|
name: 'params',
|
||||||
|
dropSort: false,
|
||||||
|
items: [
|
||||||
|
{ type: 'text', label: '参数名', name: 'name' },
|
||||||
|
{ type: 'text', label: '描述', name: 'extra' },
|
||||||
|
paramColConfig || defaultParamColConfig(),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'content',
|
||||||
|
type: 'vs-code',
|
||||||
|
options: codeOptions,
|
||||||
|
autosize: { minRows: 10, maxRows: 30 },
|
||||||
|
...(editable
|
||||||
|
? {
|
||||||
|
onChange: (_formState: any, code: string) => {
|
||||||
|
try {
|
||||||
|
// 检测 js 代码是否存在语法错误
|
||||||
|
getEditorConfig('parseDSL')(code);
|
||||||
|
return code;
|
||||||
|
} catch (error: any) {
|
||||||
|
tMagicMessage.error(error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
]) as FormConfig;
|
||||||
|
};
|
||||||
@ -1,52 +0,0 @@
|
|||||||
/**
|
|
||||||
* @param {Array} middleware
|
|
||||||
* @return {Function}
|
|
||||||
*/
|
|
||||||
export const compose = (middleware: Function[], isAsync: boolean) => {
|
|
||||||
if (!Array.isArray(middleware)) throw new TypeError('Middleware 必须是一个数组!');
|
|
||||||
for (const fn of middleware) {
|
|
||||||
if (typeof fn !== 'function') throw new TypeError('Middleware 必须由函数组成!');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {Object} args
|
|
||||||
* @return {Promise}
|
|
||||||
* @api public
|
|
||||||
*/
|
|
||||||
return (args: any[], next?: Function) => {
|
|
||||||
// last called middleware #
|
|
||||||
let index = -1;
|
|
||||||
return dispatch(0);
|
|
||||||
function dispatch(i: number): Promise<void> | void {
|
|
||||||
if (i <= index) {
|
|
||||||
const error = new Error('next() 被多次调用');
|
|
||||||
if (isAsync) {
|
|
||||||
return Promise.reject(error);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
index = i;
|
|
||||||
let fn = middleware[i];
|
|
||||||
if (i === middleware.length && next) fn = next;
|
|
||||||
if (!fn) {
|
|
||||||
if (isAsync) {
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isAsync) {
|
|
||||||
try {
|
|
||||||
return Promise.resolve(fn(...args, dispatch.bind(null, i + 1)));
|
|
||||||
} catch (err) {
|
|
||||||
return Promise.reject(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return fn(...args, dispatch.bind(null, i + 1));
|
|
||||||
} catch (err) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@ -1,15 +1,20 @@
|
|||||||
import { computed, markRaw, type ShallowRef } from 'vue';
|
import { computed, markRaw, type ShallowRef } from 'vue';
|
||||||
import { CopyDocument, Delete, DocumentCopy } from '@element-plus/icons-vue';
|
import { CopyDocument, Delete, DocumentCopy } from '@element-plus/icons-vue';
|
||||||
|
|
||||||
import { Id, MContainer, NodeType } from '@tmagic/core';
|
import { cloneDeep, Id, MContainer, NodeType } from '@tmagic/core';
|
||||||
import { calcValueByFontsize, isPage, isPageFragment } from '@tmagic/utils';
|
import { calcValueByFontsize, isPage, isPageFragment } from '@tmagic/utils';
|
||||||
|
|
||||||
import ContentMenu from '@editor/components/ContentMenu.vue';
|
import ContentMenu from '@editor/components/ContentMenu.vue';
|
||||||
import type { MenuButton, Services } from '@editor/type';
|
import type { HistoryOpSource, MenuButton, Services } from '@editor/type';
|
||||||
|
|
||||||
import { COPY_STORAGE_KEY } from './editor';
|
import { COPY_STORAGE_KEY } from './editor';
|
||||||
|
|
||||||
export const useDeleteMenu = (): MenuButton => ({
|
/**
|
||||||
|
* 共享的右键菜单项构造器(画布 ViewerMenu 与图层树 LayerMenu 共用)。
|
||||||
|
* `historySource` 用于标记本次操作的途径,调用方按所在面板传入:
|
||||||
|
* 画布传 `'stage-contextmenu'`,树形面板传 `'tree-contextmenu'`。
|
||||||
|
*/
|
||||||
|
export const useDeleteMenu = (historySource?: HistoryOpSource): MenuButton => ({
|
||||||
type: 'button',
|
type: 'button',
|
||||||
text: '删除',
|
text: '删除',
|
||||||
icon: Delete,
|
icon: Delete,
|
||||||
@ -19,7 +24,7 @@ export const useDeleteMenu = (): MenuButton => ({
|
|||||||
},
|
},
|
||||||
handler: ({ editorService }) => {
|
handler: ({ editorService }) => {
|
||||||
const nodes = editorService.get('nodes');
|
const nodes = editorService.get('nodes');
|
||||||
nodes && editorService.remove(nodes);
|
nodes && editorService.remove(nodes, { historySource });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -33,7 +38,10 @@ export const useCopyMenu = (): MenuButton => ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const usePasteMenu = (menu?: ShallowRef<InstanceType<typeof ContentMenu> | null>): MenuButton => ({
|
export const usePasteMenu = (
|
||||||
|
historySource?: HistoryOpSource,
|
||||||
|
menu?: ShallowRef<InstanceType<typeof ContentMenu> | null>,
|
||||||
|
): MenuButton => ({
|
||||||
type: 'button',
|
type: 'button',
|
||||||
text: '粘贴',
|
text: '粘贴',
|
||||||
icon: markRaw(DocumentCopy),
|
icon: markRaw(DocumentCopy),
|
||||||
@ -52,24 +60,28 @@ export const usePasteMenu = (menu?: ShallowRef<InstanceType<typeof ContentMenu>
|
|||||||
const initialTop =
|
const initialTop =
|
||||||
calcValueByFontsize(stage?.renderer?.getDocument(), (rect.top || 0) - (parentRect?.top || 0)) /
|
calcValueByFontsize(stage?.renderer?.getDocument(), (rect.top || 0) - (parentRect?.top || 0)) /
|
||||||
uiService.get('zoom');
|
uiService.get('zoom');
|
||||||
editorService.paste({ left: initialLeft, top: initialTop });
|
editorService.paste({ left: initialLeft, top: initialTop }, undefined, { historySource });
|
||||||
} else {
|
} else {
|
||||||
editorService.paste();
|
editorService.paste(undefined, undefined, { historySource });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const moveTo = (id: Id, { editorService }: Services) => {
|
const moveTo = async (id: Id, { editorService }: Services, historySource?: HistoryOpSource) => {
|
||||||
const nodes = editorService.get('nodes') || [];
|
const nodes = editorService.get('nodes') || [];
|
||||||
const parent = editorService.getNodeById(id) as MContainer;
|
const parent = editorService.getNodeById(id) as MContainer;
|
||||||
|
|
||||||
if (!parent) return;
|
if (!parent || nodes.length === 0) return;
|
||||||
|
|
||||||
editorService.add(nodes, parent);
|
// 直接调用 moveToContainer:内部对多选场景已做合并,整批只产生一条历史记录。
|
||||||
editorService.remove(nodes);
|
// 不要再走 remove + add 两步,否则会被切成两条历史(且语义也不正确)。
|
||||||
|
await editorService.moveToContainer(cloneDeep(nodes), parent.id, {
|
||||||
|
doNotSwitchPage: true,
|
||||||
|
historySource,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useMoveToMenu = ({ editorService }: Services): MenuButton => {
|
export const useMoveToMenu = ({ editorService }: Services, historySource?: HistoryOpSource): MenuButton => {
|
||||||
const root = computed(() => editorService.get('root'));
|
const root = computed(() => editorService.get('root'));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -86,7 +98,7 @@ export const useMoveToMenu = ({ editorService }: Services): MenuButton => {
|
|||||||
text: `${page.name}(${page.id})`,
|
text: `${page.name}(${page.id})`,
|
||||||
type: 'button',
|
type: 'button',
|
||||||
handler: (services: Services) => {
|
handler: (services: Services) => {
|
||||||
moveTo(page.id, services);
|
moveTo(page.id, services, historySource);
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
|||||||
@ -42,9 +42,11 @@ onmessage = (e) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watcher.collectByCallback(mApp.items, undefined, ({ node, target }) => {
|
// worker 中 target 均为新建(deps 为空),无需删除阶段,直接批量收集
|
||||||
watcher.collectItem(node, target, { pageId: node.id }, true);
|
const targets = watcher.getCollectableTargets();
|
||||||
});
|
for (const page of mApp.items) {
|
||||||
|
watcher.collectItems(page, targets, { pageId: page.id }, true);
|
||||||
|
}
|
||||||
|
|
||||||
const data: Record<string, Record<Id, DepData>> = {
|
const data: Record<string, Record<Id, DepData>> = {
|
||||||
[DepTargetType.DATA_SOURCE]: {},
|
[DepTargetType.DATA_SOURCE]: {},
|
||||||
|
|||||||
@ -1,138 +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 { toRaw } from 'vue';
|
|
||||||
import { cloneDeep } from 'lodash-es';
|
|
||||||
|
|
||||||
import type { Id, MApp, MContainer, MNode, MPage, MPageFragment } from '@tmagic/core';
|
|
||||||
import { NodeType } from '@tmagic/core';
|
|
||||||
import type StageCore from '@tmagic/stage';
|
|
||||||
import { isPage, isPageFragment } from '@tmagic/utils';
|
|
||||||
|
|
||||||
import type { EditorNodeInfo, StepValue } from '@editor/type';
|
|
||||||
import { getNodeIndex } from '@editor/utils/editor';
|
|
||||||
|
|
||||||
export interface HistoryOpContext {
|
|
||||||
root: MApp;
|
|
||||||
stage: StageCore | null;
|
|
||||||
getNodeById(id: Id, raw?: boolean): MNode | null;
|
|
||||||
getNodeInfo(id: Id, raw?: boolean): EditorNodeInfo;
|
|
||||||
setRoot(root: MApp): void;
|
|
||||||
setPage(page: MPage | MPageFragment): void;
|
|
||||||
getPage(): MPage | MPageFragment | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 应用 add 类型的历史操作
|
|
||||||
* reverse=true(撤销):从父节点中移除已添加的节点
|
|
||||||
* reverse=false(重做):重新添加节点到父节点中
|
|
||||||
*/
|
|
||||||
export async function applyHistoryAddOp(step: StepValue, reverse: boolean, ctx: HistoryOpContext): Promise<void> {
|
|
||||||
const { root, stage } = ctx;
|
|
||||||
|
|
||||||
if (reverse) {
|
|
||||||
for (const node of step.nodes ?? []) {
|
|
||||||
const parent = ctx.getNodeById(step.parentId!, false) as MContainer;
|
|
||||||
if (!parent?.items) continue;
|
|
||||||
const idx = getNodeIndex(node.id, parent);
|
|
||||||
if (typeof idx === 'number' && idx !== -1) {
|
|
||||||
parent.items.splice(idx, 1);
|
|
||||||
}
|
|
||||||
await stage?.remove({ id: node.id, parentId: parent.id, root: cloneDeep(root) });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const parent = ctx.getNodeById(step.parentId!, false) as MContainer;
|
|
||||||
if (parent?.items) {
|
|
||||||
for (const node of step.nodes ?? []) {
|
|
||||||
const idx = step.indexMap?.[node.id] ?? parent.items.length;
|
|
||||||
parent.items.splice(idx, 0, cloneDeep(node));
|
|
||||||
await stage?.add({
|
|
||||||
config: cloneDeep(node),
|
|
||||||
parent: cloneDeep(parent),
|
|
||||||
parentId: parent.id,
|
|
||||||
root: cloneDeep(root),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 应用 remove 类型的历史操作
|
|
||||||
* reverse=true(撤销):将已删除的节点按原位置重新插入
|
|
||||||
* reverse=false(重做):再次删除节点
|
|
||||||
*/
|
|
||||||
export async function applyHistoryRemoveOp(step: StepValue, reverse: boolean, ctx: HistoryOpContext): Promise<void> {
|
|
||||||
const { root, stage } = ctx;
|
|
||||||
|
|
||||||
if (reverse) {
|
|
||||||
const sorted = [...(step.removedItems ?? [])].sort((a, b) => a.index - b.index);
|
|
||||||
for (const { node, parentId, index } of sorted) {
|
|
||||||
const parent = ctx.getNodeById(parentId, false) as MContainer;
|
|
||||||
if (!parent?.items) continue;
|
|
||||||
parent.items.splice(index, 0, cloneDeep(node));
|
|
||||||
await stage?.add({ config: cloneDeep(node), parent: cloneDeep(parent), parentId, root: cloneDeep(root) });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for (const { node, parentId } of step.removedItems ?? []) {
|
|
||||||
const parent = ctx.getNodeById(parentId, false) as MContainer;
|
|
||||||
if (!parent?.items) continue;
|
|
||||||
const idx = getNodeIndex(node.id, parent);
|
|
||||||
if (typeof idx === 'number' && idx !== -1) {
|
|
||||||
parent.items.splice(idx, 1);
|
|
||||||
}
|
|
||||||
await stage?.remove({ id: node.id, parentId, root: cloneDeep(root) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 应用 update 类型的历史操作
|
|
||||||
* reverse=true(撤销):将节点恢复为 oldNode
|
|
||||||
* reverse=false(重做):将节点更新为 newNode
|
|
||||||
*/
|
|
||||||
export async function applyHistoryUpdateOp(step: StepValue, reverse: boolean, ctx: HistoryOpContext): Promise<void> {
|
|
||||||
const { root, stage } = ctx;
|
|
||||||
const items = step.updatedItems ?? [];
|
|
||||||
|
|
||||||
for (const { oldNode, newNode } of items) {
|
|
||||||
const config = reverse ? oldNode : newNode;
|
|
||||||
if (config.type === NodeType.ROOT) {
|
|
||||||
ctx.setRoot(cloneDeep(config) as MApp);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const info = ctx.getNodeInfo(config.id, false);
|
|
||||||
if (!info.parent) continue;
|
|
||||||
const idx = getNodeIndex(config.id, info.parent);
|
|
||||||
if (typeof idx !== 'number' || idx === -1) continue;
|
|
||||||
info.parent.items![idx] = cloneDeep(config);
|
|
||||||
|
|
||||||
if (isPage(config) || isPageFragment(config)) {
|
|
||||||
ctx.setPage(config as MPage | MPageFragment);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const curPage = ctx.getPage();
|
|
||||||
if (stage && curPage) {
|
|
||||||
await stage.update({
|
|
||||||
config: cloneDeep(toRaw(curPage)),
|
|
||||||
parentId: root.id,
|
|
||||||
root: cloneDeep(toRaw(root)),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -18,6 +18,7 @@
|
|||||||
|
|
||||||
export * from './config';
|
export * from './config';
|
||||||
export * from './props';
|
export * from './props';
|
||||||
|
export * from './code-block';
|
||||||
export * from './logger';
|
export * from './logger';
|
||||||
export * from './editor';
|
export * from './editor';
|
||||||
export * from './operator';
|
export * from './operator';
|
||||||
@ -26,5 +27,6 @@ export * from './dep/idle-task';
|
|||||||
export * from './scroll-viewer';
|
export * from './scroll-viewer';
|
||||||
export * from './tree';
|
export * from './tree';
|
||||||
export * from './undo-redo';
|
export * from './undo-redo';
|
||||||
|
export * from './indexed-db';
|
||||||
export * from './const';
|
export * from './const';
|
||||||
export { default as loadMonaco } from './monaco-editor';
|
export { default as loadMonaco } from './monaco-editor';
|
||||||
|
|||||||
122
packages/editor/src/utils/indexed-db.ts
Normal file
122
packages/editor/src/utils/indexed-db.ts
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
/*
|
||||||
|
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||||
|
*
|
||||||
|
* Copyright (C) 2025 Tencent. All rights reserved.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一组极简的、基于原生 IndexedDB 的 Promise KV 工具,避免引入额外依赖。
|
||||||
|
* 仅用于浏览器环境;在不支持 IndexedDB 的环境(如 SSR / 部分测试环境)下会 reject。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 是否处于支持 IndexedDB 的环境。 */
|
||||||
|
export const isIndexedDBSupported = (): boolean => typeof indexedDB !== 'undefined' && indexedDB !== null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开(必要时升级)数据库,确保目标 objectStore 存在后返回连接。
|
||||||
|
*
|
||||||
|
* 由于 objectStore 只能在 `onupgradeneeded` 内创建,这里先以当前版本打开,
|
||||||
|
* 若发现 store 不存在则关闭连接、以更高版本重开来按需创建,兼容动态 storeName。
|
||||||
|
*/
|
||||||
|
export const openIndexedDB = (dbName: string, storeName: string): Promise<IDBDatabase> =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
if (!isIndexedDBSupported()) {
|
||||||
|
reject(new Error('当前环境不支持 IndexedDB'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = indexedDB.open(dbName);
|
||||||
|
|
||||||
|
request.onupgradeneeded = () => {
|
||||||
|
const db = request.result;
|
||||||
|
if (!db.objectStoreNames.contains(storeName)) {
|
||||||
|
db.createObjectStore(storeName);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const db = request.result;
|
||||||
|
if (db.objectStoreNames.contains(storeName)) {
|
||||||
|
resolve(db);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// store 不存在:以更高版本重开,在 onupgradeneeded 中创建。
|
||||||
|
const nextVersion = db.version + 1;
|
||||||
|
db.close();
|
||||||
|
const upgradeRequest = indexedDB.open(dbName, nextVersion);
|
||||||
|
upgradeRequest.onupgradeneeded = () => {
|
||||||
|
const upgradeDb = upgradeRequest.result;
|
||||||
|
if (!upgradeDb.objectStoreNames.contains(storeName)) {
|
||||||
|
upgradeDb.createObjectStore(storeName);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
upgradeRequest.onerror = () => reject(upgradeRequest.error);
|
||||||
|
upgradeRequest.onsuccess = () => resolve(upgradeRequest.result);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 写入(覆盖)一条记录。value 通过结构化克隆存储,支持 Map / Set 等结构。 */
|
||||||
|
export const idbSet = async (dbName: string, storeName: string, key: IDBValidKey, value: unknown): Promise<void> => {
|
||||||
|
const db = await openIndexedDB(dbName, storeName);
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const tx = db.transaction(storeName, 'readwrite');
|
||||||
|
tx.objectStore(storeName).put(value, key);
|
||||||
|
tx.oncomplete = () => resolve();
|
||||||
|
tx.onabort = () => reject(tx.error);
|
||||||
|
tx.onerror = () => reject(tx.error);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 读取一条记录,不存在时返回 undefined。 */
|
||||||
|
export const idbGet = async <T = unknown>(
|
||||||
|
dbName: string,
|
||||||
|
storeName: string,
|
||||||
|
key: IDBValidKey,
|
||||||
|
): Promise<T | undefined> => {
|
||||||
|
const db = await openIndexedDB(dbName, storeName);
|
||||||
|
try {
|
||||||
|
return await new Promise<T | undefined>((resolve, reject) => {
|
||||||
|
const tx = db.transaction(storeName, 'readonly');
|
||||||
|
const request = tx.objectStore(storeName).get(key);
|
||||||
|
request.onsuccess = () => resolve(request.result as T | undefined);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 删除一条记录。 */
|
||||||
|
export const idbDelete = async (dbName: string, storeName: string, key: IDBValidKey): Promise<void> => {
|
||||||
|
const db = await openIndexedDB(dbName, storeName);
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const tx = db.transaction(storeName, 'readwrite');
|
||||||
|
tx.objectStore(storeName).delete(key);
|
||||||
|
tx.oncomplete = () => resolve();
|
||||||
|
tx.onabort = () => reject(tx.error);
|
||||||
|
tx.onerror = () => reject(tx.error);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -18,8 +18,59 @@
|
|||||||
|
|
||||||
import { cloneDeep } from 'lodash-es';
|
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
|
// #region UndoRedo
|
||||||
export class UndoRedo<T = any> {
|
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 elementList: T[];
|
||||||
private listCursor: number;
|
private listCursor: number;
|
||||||
private listMaxSize: number;
|
private listMaxSize: number;
|
||||||
@ -31,6 +82,18 @@ export class UndoRedo<T = any> {
|
|||||||
this.listMaxSize = listMaxSize > minListMaxSize ? listMaxSize : minListMaxSize;
|
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 {
|
public pushElement(element: T): void {
|
||||||
// 新元素进来时,把游标之外的元素全部丢弃,并把新元素放进来
|
// 新元素进来时,把游标之外的元素全部丢弃,并把新元素放进来
|
||||||
this.elementList.splice(this.listCursor, this.elementList.length - this.listCursor, cloneDeep(element));
|
this.elementList.splice(this.listCursor, this.elementList.length - this.listCursor, cloneDeep(element));
|
||||||
@ -75,5 +138,42 @@ export class UndoRedo<T = any> {
|
|||||||
}
|
}
|
||||||
return cloneDeep(this.elementList[this.listCursor - 1]);
|
return cloneDeep(this.elementList[this.listCursor - 1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对当前游标所在元素(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 为最早一步)。
|
||||||
|
* 仅用于历史面板等只读展示场景,不应直接修改返回值。
|
||||||
|
*/
|
||||||
|
public getElementList(): T[] {
|
||||||
|
return this.elementList.slice();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前游标位置:表示已应用的步骤数量。
|
||||||
|
* - cursor === 0 表示全部已撤销
|
||||||
|
* - cursor === length 表示已重做到末尾
|
||||||
|
* 历史面板用于区分"已应用 / 已撤销"两段。
|
||||||
|
*/
|
||||||
|
public getCursor(): number {
|
||||||
|
return this.listCursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 栈内总步数。 */
|
||||||
|
public getLength(): number {
|
||||||
|
return this.elementList.length;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// #endregion UndoRedo
|
// #endregion UndoRedo
|
||||||
|
|||||||
@ -12,14 +12,16 @@ import * as utilsMod from '@editor/utils';
|
|||||||
|
|
||||||
const submitMock = vi.fn();
|
const submitMock = vi.fn();
|
||||||
let lastConfig: any;
|
let lastConfig: any;
|
||||||
|
let lastProps: any;
|
||||||
|
|
||||||
vi.mock('@tmagic/form', () => ({
|
vi.mock('@tmagic/form', () => ({
|
||||||
MForm: defineComponent({
|
MForm: defineComponent({
|
||||||
name: 'MFormStub',
|
name: 'MFormStub',
|
||||||
props: ['config', 'initValues', 'disabled', 'size', 'watchProps'],
|
props: ['config', 'initValues', 'disabled', 'size', 'watchProps', 'lastValues', 'isCompare'],
|
||||||
emits: ['change'],
|
emits: ['change'],
|
||||||
setup(props, { expose, emit }) {
|
setup(props, { expose, emit }) {
|
||||||
lastConfig = props.config;
|
lastConfig = props.config;
|
||||||
|
lastProps = props;
|
||||||
expose({ submitForm: submitMock });
|
expose({ submitForm: submitMock });
|
||||||
return () =>
|
return () =>
|
||||||
h('div', {
|
h('div', {
|
||||||
@ -38,6 +40,7 @@ describe('CodeParams.vue', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
submitMock.mockReset();
|
submitMock.mockReset();
|
||||||
lastConfig = null;
|
lastConfig = null;
|
||||||
|
lastProps = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@ -96,6 +99,20 @@ describe('CodeParams.vue', () => {
|
|||||||
expect(events?.[0]?.[0]).toEqual({ p: { a: 1 } });
|
expect(events?.[0]?.[0]).toEqual({ p: { a: 1 } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('对比模式 isCompare/lastValues 透传给内部 MForm', () => {
|
||||||
|
mount(CodeParams as any, {
|
||||||
|
props: {
|
||||||
|
model: { p: { a: 'new' } },
|
||||||
|
name: 'p',
|
||||||
|
isCompare: true,
|
||||||
|
lastValues: { p: { a: 'old' } },
|
||||||
|
paramsConfig: [{ name: 'a', text: 'A', type: 'vs-code' }] as any,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(lastProps.isCompare).toBe(true);
|
||||||
|
expect(lastProps.lastValues).toEqual({ p: { a: 'old' } });
|
||||||
|
});
|
||||||
|
|
||||||
test('submitForm 抛错时调用 error 不抛出', async () => {
|
test('submitForm 抛错时调用 error 不抛出', async () => {
|
||||||
submitMock.mockRejectedValueOnce(new Error('bad'));
|
submitMock.mockRejectedValueOnce(new Error('bad'));
|
||||||
const wrapper = mount(CodeParams as any, {
|
const wrapper = mount(CodeParams as any, {
|
||||||
|
|||||||
@ -3,33 +3,245 @@
|
|||||||
*
|
*
|
||||||
* Copyright (C) 2025 Tencent.
|
* Copyright (C) 2025 Tencent.
|
||||||
*/
|
*/
|
||||||
import { describe, expect, test, vi } from 'vitest';
|
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||||
import { defineComponent, h } from 'vue';
|
import { defineComponent, h } from 'vue';
|
||||||
import { mount } from '@vue/test-utils';
|
import { mount } from '@vue/test-utils';
|
||||||
|
|
||||||
import Code from '@editor/fields/Code.vue';
|
import Code from '@editor/fields/Code.vue';
|
||||||
|
|
||||||
|
// 用一个简单的桩组件代替 MagicCodeEditor,把所有 props 原样渲染到 data-* 属性上,
|
||||||
|
// 这样可以直接断言父组件 Code.vue 透传过去的内容是否正确。
|
||||||
vi.mock('@editor/layouts/CodeEditor.vue', () => ({
|
vi.mock('@editor/layouts/CodeEditor.vue', () => ({
|
||||||
default: defineComponent({
|
default: defineComponent({
|
||||||
name: 'CodeEditor',
|
name: 'CodeEditor',
|
||||||
props: ['height', 'initValues', 'language', 'options', 'autosize', 'parse', 'editorCustomType'],
|
props: {
|
||||||
|
height: { type: [String, Number], default: undefined },
|
||||||
|
type: { type: String, default: undefined },
|
||||||
|
initValues: { type: null, default: undefined },
|
||||||
|
modifiedValues: { type: null, default: undefined },
|
||||||
|
language: { type: String, default: undefined },
|
||||||
|
options: { type: Object, default: undefined },
|
||||||
|
autosize: { type: Object, default: undefined },
|
||||||
|
parse: { type: Boolean, default: undefined },
|
||||||
|
editorCustomType: { type: String, default: undefined },
|
||||||
|
},
|
||||||
emits: ['save'],
|
emits: ['save'],
|
||||||
setup(_p, { emit }) {
|
setup(p, { emit }) {
|
||||||
return () => h('div', { class: 'fake-code-editor', onClick: () => emit('save', 'newvalue') });
|
return () =>
|
||||||
|
h('div', {
|
||||||
|
class: 'fake-code-editor',
|
||||||
|
'data-height': p.height,
|
||||||
|
'data-type': p.type ?? '',
|
||||||
|
'data-language': p.language ?? '',
|
||||||
|
'data-init': JSON.stringify(p.initValues ?? null),
|
||||||
|
'data-modified': JSON.stringify(p.modifiedValues ?? null),
|
||||||
|
'data-options': JSON.stringify(p.options ?? null),
|
||||||
|
'data-autosize': JSON.stringify(p.autosize ?? null),
|
||||||
|
'data-parse': String(p.parse ?? ''),
|
||||||
|
'data-custom-type': p.editorCustomType ?? '',
|
||||||
|
onClick: () => emit('save', 'newvalue'),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const mountCode = (props: Record<string, any>) =>
|
||||||
|
mount(Code, {
|
||||||
|
props: {
|
||||||
|
// FieldProps 必填字段,用 as any 绕过测试中类型严格匹配
|
||||||
|
config: { height: '100px', language: 'javascript' },
|
||||||
|
model: { codeField: 'oldval' },
|
||||||
|
name: 'codeField',
|
||||||
|
prop: 'codeField',
|
||||||
|
...props,
|
||||||
|
} as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
const getEl = (wrapper: ReturnType<typeof mountCode>) => wrapper.find('.fake-code-editor').element as HTMLElement;
|
||||||
|
|
||||||
|
const readJson = (el: HTMLElement, attr: string) => JSON.parse(el.getAttribute(attr) || 'null');
|
||||||
|
|
||||||
describe('Code', () => {
|
describe('Code', () => {
|
||||||
test('save 触发 change', async () => {
|
beforeEach(() => {
|
||||||
const wrapper = mount(Code, {
|
vi.clearAllMocks();
|
||||||
props: {
|
});
|
||||||
config: { height: '100px', language: 'js' },
|
|
||||||
model: { codeField: 'oldval' },
|
describe('基本透传与事件', () => {
|
||||||
name: 'codeField',
|
test('save 触发 change 事件,参数原样透传 (字符串)', async () => {
|
||||||
} as any,
|
const wrapper = mountCode({});
|
||||||
|
await wrapper.find('.fake-code-editor').trigger('click');
|
||||||
|
expect(wrapper.emitted('change')?.[0]?.[0]).toBe('newvalue');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('save 触发 change 事件,参数可以是对象', async () => {
|
||||||
|
// 替换桩的 emit 内容:通过自定义子组件方式覆盖默认 emit value 时太复杂,
|
||||||
|
// 这里直接以 vm.$emit 等价的方式构造数据:通过 wrapper 触发 onClick 是字符串,
|
||||||
|
// 但 setup 内 save 函数本身也接受任意类型,因此用一个最小用例验证函数行为:
|
||||||
|
const wrapper = mountCode({});
|
||||||
|
// 直接调用底层桩组件 emit,模拟 save 抛出对象
|
||||||
|
const child = wrapper.findComponent({ name: 'CodeEditor' });
|
||||||
|
child.vm.$emit('save', { a: 1 });
|
||||||
|
expect(wrapper.emitted('change')?.[0]?.[0]).toEqual({ a: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('透传 height / language / autosize / parse / editorCustomType', () => {
|
||||||
|
const wrapper = mountCode({
|
||||||
|
config: {
|
||||||
|
height: '200px',
|
||||||
|
language: 'json',
|
||||||
|
autosize: { minRows: 2, maxRows: 8 },
|
||||||
|
parse: true,
|
||||||
|
mFormItemType: 'vs-code-extra',
|
||||||
|
options: { tabSize: 4 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const el = getEl(wrapper);
|
||||||
|
expect(el.getAttribute('data-height')).toBe('200px');
|
||||||
|
expect(el.getAttribute('data-language')).toBe('json');
|
||||||
|
expect(readJson(el, 'data-autosize')).toEqual({ minRows: 2, maxRows: 8 });
|
||||||
|
expect(el.getAttribute('data-parse')).toBe('true');
|
||||||
|
expect(el.getAttribute('data-custom-type')).toBe('vs-code-extra');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('组件名为 MFieldsVsCode', () => {
|
||||||
|
const wrapper = mountCode({});
|
||||||
|
expect((wrapper.vm.$options as any).name).toBe('MFieldsVsCode');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('非 diff 模式 (非对比)', () => {
|
||||||
|
test('init-values 来自 model[name],modified-values 为 null/undefined', () => {
|
||||||
|
const wrapper = mountCode({
|
||||||
|
model: { codeField: 'hello' },
|
||||||
|
});
|
||||||
|
const el = getEl(wrapper);
|
||||||
|
expect(el.getAttribute('data-type')).toBe('');
|
||||||
|
expect(readJson(el, 'data-init')).toBe('hello');
|
||||||
|
expect(readJson(el, 'data-modified')).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disabled=true 时 options.readOnly=true', () => {
|
||||||
|
const wrapper = mountCode({ disabled: true });
|
||||||
|
const el = getEl(wrapper);
|
||||||
|
expect(readJson(el, 'data-options')).toMatchObject({ readOnly: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disabled=false 时 options.readOnly=false', () => {
|
||||||
|
const wrapper = mountCode({ disabled: false });
|
||||||
|
const el = getEl(wrapper);
|
||||||
|
expect(readJson(el, 'data-options')).toMatchObject({ readOnly: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('readOnly 应覆盖 config.options 中已有的 readOnly 字段', () => {
|
||||||
|
const wrapper = mountCode({
|
||||||
|
// 故意把 config.options.readOnly 设为 true,期望 disabled=false 时仍以 disabled 为准
|
||||||
|
config: { height: '100px', language: 'javascript', options: { tabSize: 4, readOnly: true } },
|
||||||
|
disabled: false,
|
||||||
|
});
|
||||||
|
const el = getEl(wrapper);
|
||||||
|
const opts = readJson(el, 'data-options');
|
||||||
|
expect(opts.tabSize).toBe(4);
|
||||||
|
expect(opts.readOnly).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isCompare=true 但缺少 lastValues 时不进入 diff', () => {
|
||||||
|
const wrapper = mountCode({
|
||||||
|
isCompare: true,
|
||||||
|
// 不传 lastValues
|
||||||
|
model: { codeField: 'cur' },
|
||||||
|
});
|
||||||
|
const el = getEl(wrapper);
|
||||||
|
expect(el.getAttribute('data-type')).toBe('');
|
||||||
|
expect(readJson(el, 'data-init')).toBe('cur');
|
||||||
|
expect(readJson(el, 'data-modified')).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isCompare=false 即使带 lastValues 也不进入 diff', () => {
|
||||||
|
const wrapper = mountCode({
|
||||||
|
isCompare: false,
|
||||||
|
lastValues: { codeField: 'old' },
|
||||||
|
model: { codeField: 'cur' },
|
||||||
|
});
|
||||||
|
const el = getEl(wrapper);
|
||||||
|
expect(el.getAttribute('data-type')).toBe('');
|
||||||
|
expect(readJson(el, 'data-init')).toBe('cur');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('diff 模式 (对比)', () => {
|
||||||
|
test('isCompare=true 且有 lastValues 时切换为 diff 模式', () => {
|
||||||
|
const wrapper = mountCode({
|
||||||
|
isCompare: true,
|
||||||
|
lastValues: { codeField: 'old' },
|
||||||
|
model: { codeField: 'new' },
|
||||||
|
});
|
||||||
|
const el = getEl(wrapper);
|
||||||
|
expect(el.getAttribute('data-type')).toBe('diff');
|
||||||
|
expect(readJson(el, 'data-init')).toBe('old');
|
||||||
|
expect(readJson(el, 'data-modified')).toBe('new');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('diff 模式下 readOnly 强制为 true,忽略 disabled', () => {
|
||||||
|
const wrapper = mountCode({
|
||||||
|
isCompare: true,
|
||||||
|
lastValues: { codeField: 'old' },
|
||||||
|
model: { codeField: 'new' },
|
||||||
|
disabled: false,
|
||||||
|
});
|
||||||
|
const el = getEl(wrapper);
|
||||||
|
expect(readJson(el, 'data-options')).toMatchObject({ readOnly: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('diff 模式下当 lastValues 中无对应 name 字段时,init-values 退化为 null/{}', () => {
|
||||||
|
const wrapper = mountCode({
|
||||||
|
isCompare: true,
|
||||||
|
// 有 lastValues 对象但没有该字段
|
||||||
|
lastValues: {},
|
||||||
|
model: { codeField: 'new' },
|
||||||
|
});
|
||||||
|
const el = getEl(wrapper);
|
||||||
|
expect(el.getAttribute('data-type')).toBe('diff');
|
||||||
|
// 源码逻辑:(lastValues || {})[name],此处 lastValues 是 {},结果为 undefined
|
||||||
|
expect(readJson(el, 'data-init')).toBe(null);
|
||||||
|
expect(readJson(el, 'data-modified')).toBe('new');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('切换 isCompare 时模式跟随变化', async () => {
|
||||||
|
const wrapper = mountCode({
|
||||||
|
isCompare: false,
|
||||||
|
lastValues: { codeField: 'old' },
|
||||||
|
model: { codeField: 'new' },
|
||||||
|
});
|
||||||
|
expect(getEl(wrapper).getAttribute('data-type')).toBe('');
|
||||||
|
|
||||||
|
await wrapper.setProps({ isCompare: true } as any);
|
||||||
|
expect(getEl(wrapper).getAttribute('data-type')).toBe('diff');
|
||||||
|
expect(readJson(getEl(wrapper), 'data-init')).toBe('old');
|
||||||
|
expect(readJson(getEl(wrapper), 'data-modified')).toBe('new');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('切换 lastValues 后 init-values 同步更新', async () => {
|
||||||
|
const wrapper = mountCode({
|
||||||
|
isCompare: true,
|
||||||
|
lastValues: { codeField: 'v1' },
|
||||||
|
model: { codeField: 'cur' },
|
||||||
|
});
|
||||||
|
expect(readJson(getEl(wrapper), 'data-init')).toBe('v1');
|
||||||
|
|
||||||
|
await wrapper.setProps({ lastValues: { codeField: 'v2' } } as any);
|
||||||
|
expect(readJson(getEl(wrapper), 'data-init')).toBe('v2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('diff 模式下 model 变化时 modified-values 同步更新', async () => {
|
||||||
|
const wrapper = mountCode({
|
||||||
|
isCompare: true,
|
||||||
|
lastValues: { codeField: 'old' },
|
||||||
|
model: { codeField: 'a' },
|
||||||
|
});
|
||||||
|
expect(readJson(getEl(wrapper), 'data-modified')).toBe('a');
|
||||||
|
|
||||||
|
await wrapper.setProps({ model: { codeField: 'b' } } as any);
|
||||||
|
expect(readJson(getEl(wrapper), 'data-modified')).toBe('b');
|
||||||
});
|
});
|
||||||
await wrapper.find('.fake-code-editor').trigger('click');
|
|
||||||
expect(wrapper.emitted('change')?.[0]?.[0]).toBe('newvalue');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -28,7 +28,7 @@ vi.mock('@tmagic/form', async (importOriginal) => {
|
|||||||
...actual,
|
...actual,
|
||||||
MContainer: defineComponent({
|
MContainer: defineComponent({
|
||||||
name: 'MContainer',
|
name: 'MContainer',
|
||||||
props: ['config', 'size', 'prop', 'disabled', 'lastValues', 'model'],
|
props: ['config', 'size', 'prop', 'disabled', 'lastValues', 'isCompare', 'model'],
|
||||||
emits: ['change'],
|
emits: ['change'],
|
||||||
setup() {
|
setup() {
|
||||||
return () => h('div', { class: 'fake-container' });
|
return () => h('div', { class: 'fake-container' });
|
||||||
@ -149,4 +149,21 @@ describe('CodeSelect', () => {
|
|||||||
codeBlockService.getEditStatus.mockReturnValue(true);
|
codeBlockService.getEditStatus.mockReturnValue(true);
|
||||||
dataSourceService.get.mockReturnValue(true);
|
dataSourceService.get.mockReturnValue(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('对比模式', () => {
|
||||||
|
test('isCompare 但无 lastValues 时不进入对比', () => {
|
||||||
|
const wrapper = mount(CodeSelect, { props: baseProps({ isCompare: true }) as any });
|
||||||
|
const container = wrapper.findComponent({ name: 'MContainer' });
|
||||||
|
expect(container.props('isCompare')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('对比模式将 isCompare 与同层 lastValues[name] 透传给内部容器', () => {
|
||||||
|
const lastValues = { cs: { hookType: 'code', hookData: [{ codeType: 'code', codeId: 'c2' }] } };
|
||||||
|
const wrapper = mount(CodeSelect, { props: baseProps({ isCompare: true, lastValues }) as any });
|
||||||
|
const container = wrapper.findComponent({ name: 'MContainer' });
|
||||||
|
expect(container.props('isCompare')).toBe(true);
|
||||||
|
// 注意:model 传入的是 model[name],因此 lastValues 也需取同层的 lastValues[name]
|
||||||
|
expect(container.props('lastValues')).toEqual(lastValues.cs);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -42,6 +42,13 @@ vi.mock('@tmagic/form', async (importOriginal) => {
|
|||||||
return () => h('select', { class: 'fake-select' });
|
return () => h('select', { class: 'fake-select' });
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
MContainer: defineComponent({
|
||||||
|
name: 'MFormContainer',
|
||||||
|
props: ['model', 'lastValues', 'isCompare', 'size', 'prop', 'config'],
|
||||||
|
setup() {
|
||||||
|
return () => h('div', { class: 'fake-diff-select' });
|
||||||
|
},
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -63,7 +70,7 @@ vi.mock('@editor/components/Icon.vue', () => ({
|
|||||||
vi.mock('@editor/components/CodeParams.vue', () => ({
|
vi.mock('@editor/components/CodeParams.vue', () => ({
|
||||||
default: defineComponent({
|
default: defineComponent({
|
||||||
name: 'CodeParams',
|
name: 'CodeParams',
|
||||||
props: ['name', 'model', 'size', 'disabled', 'paramsConfig'],
|
props: ['name', 'model', 'size', 'disabled', 'paramsConfig', 'lastValues', 'isCompare'],
|
||||||
emits: ['change'],
|
emits: ['change'],
|
||||||
setup(_p, { emit }) {
|
setup(_p, { emit }) {
|
||||||
return () =>
|
return () =>
|
||||||
@ -148,9 +155,43 @@ describe('CodeSelectCol', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('codeDsl 为空时 selectConfig.options 返回空数组', () => {
|
test('codeDsl 为空时 selectConfig.options 返回空数组', () => {
|
||||||
codeBlockService.getCodeDsl.mockReturnValue(null);
|
codeBlockService.getCodeDsl.mockReturnValue(null as any);
|
||||||
const wrapper = mount(CodeSelectCol, { props: baseProps({ model: { codeId: '', params: {} } }) as any });
|
const wrapper = mount(CodeSelectCol, { props: baseProps({ model: { codeId: '', params: {} } }) as any });
|
||||||
const select = wrapper.findComponent({ name: 'MSelect' });
|
const select = wrapper.findComponent({ name: 'MSelect' });
|
||||||
expect((select.props('config') as any).options()).toEqual([]);
|
expect((select.props('config') as any).options()).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('对比模式', () => {
|
||||||
|
test('isCompare 但无 lastValues 时仍渲染普通 MSelect', () => {
|
||||||
|
const wrapper = mount(CodeSelectCol, { props: baseProps({ isCompare: true }) as any });
|
||||||
|
expect(wrapper.findComponent({ name: 'MSelect' }).exists()).toBe(true);
|
||||||
|
expect(wrapper.find('.fake-diff-select').exists()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('对比模式下拉框改用 MFormContainer,并隐藏编辑按钮', () => {
|
||||||
|
const wrapper = mount(CodeSelectCol, {
|
||||||
|
props: baseProps({
|
||||||
|
isCompare: true,
|
||||||
|
lastValues: { codeId: 'c2', params: {} },
|
||||||
|
}) as any,
|
||||||
|
});
|
||||||
|
expect(wrapper.find('.fake-diff-select').exists()).toBe(true);
|
||||||
|
expect(wrapper.findComponent({ name: 'MSelect' }).exists()).toBe(false);
|
||||||
|
// 对比模式为只读,不展示查看/编辑按钮
|
||||||
|
expect(wrapper.find('button').exists()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('对比模式将 isCompare/lastValues 透传给参数表单 CodeParams', () => {
|
||||||
|
const wrapper = mount(CodeSelectCol, {
|
||||||
|
props: baseProps({
|
||||||
|
isCompare: true,
|
||||||
|
lastValues: { codeId: 'c1', params: { p1: 'old' } },
|
||||||
|
}) as any,
|
||||||
|
});
|
||||||
|
const params = wrapper.findComponent({ name: 'CodeParams' });
|
||||||
|
expect(params.exists()).toBe(true);
|
||||||
|
expect(params.props('isCompare')).toBe(true);
|
||||||
|
expect(params.props('lastValues')).toEqual({ codeId: 'c1', params: { p1: 'old' } });
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -261,7 +261,7 @@ describe('DataSourceFieldSelect Index', () => {
|
|||||||
expect(wrapper.findAll('.fake-cascader').length).toBe(0);
|
expect(wrapper.findAll('.fake-cascader').length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('对比模式(mForm.isCompare=true)下切换按钮被禁用,点击不切换 showDataSourceFieldSelect', async () => {
|
test('对比模式(mForm.isCompare=true)下不渲染「选择数据源」切换按钮', async () => {
|
||||||
const wrapper = mount(DSFSIndex, {
|
const wrapper = mount(DSFSIndex, {
|
||||||
props: {
|
props: {
|
||||||
config: { fieldConfig: { type: 'text' } },
|
config: { fieldConfig: { type: 'text' } },
|
||||||
@ -275,10 +275,8 @@ describe('DataSourceFieldSelect Index', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const toggleBtn = wrapper.find('.fake-btn');
|
// 对比模式仅做只读展示,切换按钮整体隐藏(不再以禁用态保留)
|
||||||
expect((toggleBtn.element as HTMLButtonElement).hasAttribute('disabled')).toBe(true);
|
expect(wrapper.find('.fake-btn').exists()).toBe(false);
|
||||||
|
|
||||||
await toggleBtn.trigger('click');
|
|
||||||
expect(wrapper.findAll('.fake-cascader').length).toBe(0);
|
expect(wrapper.findAll('.fake-cascader').length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -55,7 +55,7 @@ vi.mock('@tmagic/form', async (importOriginal) => {
|
|||||||
}),
|
}),
|
||||||
MPanel: defineComponent({
|
MPanel: defineComponent({
|
||||||
name: 'MPanel',
|
name: 'MPanel',
|
||||||
props: ['model', 'config', 'prop', 'disabled', 'size', 'labelWidth'],
|
props: ['model', 'config', 'prop', 'disabled', 'size', 'labelWidth', 'lastValues', 'isCompare'],
|
||||||
emits: ['change'],
|
emits: ['change'],
|
||||||
setup(_p, { slots }) {
|
setup(_p, { slots }) {
|
||||||
return () => h('div', { class: 'fake-panel' }, slots.header?.());
|
return () => h('div', { class: 'fake-panel' }, slots.header?.());
|
||||||
@ -360,6 +360,50 @@ describe('EventSelect', () => {
|
|||||||
expect(methodCol.options(undefined, { model: { to: '1' } })).toEqual([]);
|
expect(methodCol.options(undefined, { model: { to: '1' } })).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('对比模式', () => {
|
||||||
|
test('isCompare 但无 lastValues 时不进入对比,仍显示添加按钮', () => {
|
||||||
|
const wrapper = mount(EventSelect, {
|
||||||
|
props: baseProps({ isCompare: true, model: { events: [] } }) as any,
|
||||||
|
});
|
||||||
|
expect(wrapper.find('.create-button').exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('对比模式隐藏「添加事件」与删除按钮', () => {
|
||||||
|
const wrapper = mount(EventSelect, {
|
||||||
|
props: baseProps({
|
||||||
|
isCompare: true,
|
||||||
|
model: { events: [{ name: 'a', actions: [] }] },
|
||||||
|
lastValues: { events: [{ name: 'a', actions: [] }] },
|
||||||
|
}) as any,
|
||||||
|
});
|
||||||
|
expect(wrapper.find('.create-button').exists()).toBe(false);
|
||||||
|
// 对比模式 panel header 内不渲染删除按钮(仅 MFormContainer 占位)
|
||||||
|
expect(wrapper.findAll('button').length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('对比模式按索引对齐当前值与历史值,取最大长度渲染', () => {
|
||||||
|
const wrapper = mount(EventSelect, {
|
||||||
|
props: baseProps({
|
||||||
|
isCompare: true,
|
||||||
|
model: { events: [{ name: 'a', actions: [] }] },
|
||||||
|
lastValues: {
|
||||||
|
events: [
|
||||||
|
{ name: 'a', actions: [] },
|
||||||
|
{ name: 'b', actions: [] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}) as any,
|
||||||
|
});
|
||||||
|
// 当前 1 项 + 历史 2 项 → 取 max=2,渲染 2 个 panel(含被删除的事件)
|
||||||
|
expect(wrapper.findAll('.fake-panel').length).toBe(2);
|
||||||
|
const panels = wrapper.findAllComponents({ name: 'MPanel' });
|
||||||
|
expect(panels[0].props('isCompare')).toBe(true);
|
||||||
|
// 缺失一侧用空对象兜底
|
||||||
|
expect(panels[1].props('model')).toEqual({});
|
||||||
|
expect(panels[1].props('lastValues')).toEqual({ name: 'b', actions: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('removeEvent 通过 panel header 删除按钮调用', async () => {
|
test('removeEvent 通过 panel header 删除按钮调用', async () => {
|
||||||
const m: any = {
|
const m: any = {
|
||||||
events: [
|
events: [
|
||||||
|
|||||||
@ -104,7 +104,7 @@ describe('useCodeBlockEdit', () => {
|
|||||||
const deleteCodeDslByIds = vi.fn();
|
const deleteCodeDslByIds = vi.fn();
|
||||||
const hook = mountHook({ deleteCodeDslByIds });
|
const hook = mountHook({ deleteCodeDslByIds });
|
||||||
await hook.deleteCode('k');
|
await hook.deleteCode('k');
|
||||||
expect(deleteCodeDslByIds).toHaveBeenCalledWith(['k']);
|
expect(deleteCodeDslByIds).toHaveBeenCalledWith(['k'], { historySource: undefined });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('submitCodeBlockHandler - 没有 codeId 时跳过', async () => {
|
test('submitCodeBlockHandler - 没有 codeId 时跳过', async () => {
|
||||||
@ -119,7 +119,30 @@ describe('useCodeBlockEdit', () => {
|
|||||||
const hook = mountHook({ setCodeDslById });
|
const hook = mountHook({ setCodeDslById });
|
||||||
hook.codeId.value = 'id1';
|
hook.codeId.value = 'id1';
|
||||||
await hook.submitCodeBlockHandler({ name: 'b' } as any);
|
await hook.submitCodeBlockHandler({ name: 'b' } as any);
|
||||||
expect(setCodeDslById).toHaveBeenCalledWith('id1', { name: 'b' });
|
expect(setCodeDslById).toHaveBeenCalledWith(
|
||||||
|
'id1',
|
||||||
|
{ name: 'b' },
|
||||||
|
{
|
||||||
|
changeRecords: undefined,
|
||||||
|
historySource: 'props',
|
||||||
|
},
|
||||||
|
);
|
||||||
expect(hideMock).toHaveBeenCalled();
|
expect(hideMock).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('submitCodeBlockHandler - 透传 eventData.changeRecords 给 setCodeDslById', async () => {
|
||||||
|
const setCodeDslById = vi.fn();
|
||||||
|
const hook = mountHook({ setCodeDslById });
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
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