mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-07-12 22:01:11 +08:00
Compare commits
129 Commits
v1.7.14-be
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42d45bed18 | ||
|
|
de7a4f73ad | ||
|
|
605f07c8fe | ||
|
|
90492ad3ea | ||
|
|
b183e71f64 | ||
|
|
78a1259665 | ||
|
|
4a893d35df | ||
|
|
3c21f45e88 | ||
|
|
aa71fe9ddd | ||
|
|
9e8272b521 | ||
|
|
9aa251ce57 | ||
|
|
3b9fb714e5 | ||
|
|
284be0d276 | ||
|
|
c57ef89715 | ||
|
|
1ef3ea9eb9 | ||
|
|
83f4e52845 | ||
|
|
4330738b9f | ||
|
|
e59c3d2dcd | ||
|
|
55135fc063 | ||
|
|
af197363cf | ||
|
|
7bbb1f24c0 | ||
|
|
f7afed66aa | ||
|
|
fd359e493f | ||
|
|
5e661d0958 | ||
|
|
5ea992f5f7 | ||
|
|
4c81f22485 | ||
|
|
40acb5b34c | ||
|
|
ce65b18dbb | ||
|
|
1c936ff439 | ||
|
|
e32780b0fb | ||
|
|
7e789aa83a | ||
|
|
0f42989ca3 | ||
|
|
bfdaf2b244 | ||
|
|
1ade61d62e | ||
|
|
9f2fa1a9c8 | ||
|
|
38065f013f | ||
|
|
d5c1fab016 | ||
|
|
bbf79fd6df | ||
|
|
24b9b34f65 | ||
|
|
95769d6ae0 | ||
|
|
8933c46b73 | ||
|
|
c811908971 | ||
|
|
0774aa8e59 | ||
|
|
2ff7c479c2 | ||
|
|
4a8289137b | ||
|
|
ffcc734102 | ||
|
|
b35132e93e | ||
|
|
921188d2da | ||
|
|
6960bd50e1 | ||
|
|
27fac02e99 | ||
|
|
1298104732 | ||
|
|
9fe10e274c | ||
|
|
89cef4e9a9 | ||
|
|
273d13dd1f | ||
|
|
771880b994 | ||
|
|
113af7dd51 | ||
|
|
846f05e04d | ||
|
|
fd652b0d13 | ||
|
|
7d45aa5eec | ||
|
|
171d31e207 | ||
|
|
6ba59c0d14 | ||
|
|
4f284e8d9c | ||
|
|
c4ec2c5c72 | ||
|
|
48519b0155 | ||
|
|
a965dfb06e | ||
|
|
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 | ||
|
|
6c40425d8c | ||
|
|
b8b0490260 | ||
|
|
2846f9eb2a | ||
|
|
62fc818ae1 | ||
|
|
ff810d09e4 | ||
|
|
b1193b909e | ||
|
|
540a2716d8 | ||
|
|
a1fcb191d2 | ||
|
|
b9a6dd5b84 | ||
|
|
08011efd6d | ||
|
|
fbbd05e291 | ||
|
|
9b65917371 | ||
|
|
3d038513e3 | ||
|
|
eb1c5a3ec1 | ||
|
|
7ff590b1b6 | ||
|
|
7eeb9b544e | ||
|
|
638c3e9f3c | ||
|
|
2d31b3812f | ||
|
|
05e512b1fe | ||
|
|
1e69bc221d | ||
|
|
12ce19fb02 | ||
|
|
aa2ee9fd4b |
227
CHANGELOG.md
227
CHANGELOG.md
@ -1,3 +1,230 @@
|
|||||||
|
# [1.8.0-beta.11](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.10...v1.8.0-beta.11) (2026-06-29)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **editor:** support custom width and initial diff mode for history diff dialog ([83f4e52](https://github.com/Tencent/tmagic-editor/commit/83f4e528450be3971eea25b59dab126e04ac4df4))
|
||||||
|
* **form:** add debug mode to submitForm ([4330738](https://github.com/Tencent/tmagic-editor/commit/4330738b9f3d91c728826f5a74129cf1febbcf48))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# [1.8.0-beta.10](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.9...v1.8.0-beta.10) (2026-06-26)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **vue-runtime-help:** handle remove when root is not initialized ([af19736](https://github.com/Tencent/tmagic-editor/commit/af197363cfaa5c555758c19354a0b3a5031b973e))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **editor:** add unified change events ([7bbb1f2](https://github.com/Tencent/tmagic-editor/commit/7bbb1f24c0f7180f301f573de1b916bfd0a35152))
|
||||||
|
* **editor:** refine history diff defaults ([f7afed6](https://github.com/Tencent/tmagic-editor/commit/f7afed66aaa8f57cdde02f0e25cca1dab825e7c0))
|
||||||
|
* **form:** support useFieldTextInError option ([fd359e4](https://github.com/Tencent/tmagic-editor/commit/fd359e493fdf205126957799a4a228f31267f3c2))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# [1.8.0-beta.9](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.8...v1.8.0-beta.9) (2026-06-25)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **editor:** 优化历史记录对比与回滚流程 ([ce65b18](https://github.com/Tencent/tmagic-editor/commit/ce65b18dbb2976db484ab35a98479858b4c426e4))
|
||||||
|
* 导出 componentListService 和 keybindingService ([40acb5b](https://github.com/Tencent/tmagic-editor/commit/40acb5b34cb3b492bc5234c75bde4fbf8e787be2))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# [1.8.0-beta.8](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.7...v1.8.0-beta.8) (2026-06-23)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **playground:** 调整初始化时序避免历史恢复被覆盖 ([bfdaf2b](https://github.com/Tencent/tmagic-editor/commit/bfdaf2b244dfe7387cac5b399a84ccd614201e73))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **editor:** 历史记录支持展示操作人 ([1ade61d](https://github.com/Tencent/tmagic-editor/commit/1ade61d62e61c86fcff2033a3dd57c533ce4241c))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# [1.8.0-beta.7](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.6...v1.8.0-beta.7) (2026-06-17)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **editor:** sidebar 侧栏项 key 与 SideItemKey 常量对齐 ([8933c46](https://github.com/Tencent/tmagic-editor/commit/8933c46b73525aa2aeca81aeea88610539a218d9))
|
||||||
|
* **editor:** 修复历史记录面板分组与展示逻辑 ([bbf79fd](https://github.com/Tencent/tmagic-editor/commit/bbf79fd6df505351bc28d63bea1a5c0cd16a5984))
|
||||||
|
* **editor:** 恢复新增组件在当前视窗中垂直居中 ([24b9b34](https://github.com/Tencent/tmagic-editor/commit/24b9b34f6569ed671edaf4e9f14622597688d72c))
|
||||||
|
* **editor:** 数据源配置面板默认激活「数据定义」tab ([95769d6](https://github.com/Tencent/tmagic-editor/commit/95769d6ae05d6171366ad5995f6e8ce79491d5cd))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# [1.8.0-beta.6](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.5...v1.8.0-beta.6) (2026-06-15)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **editor:** 仅在使用 left/top 定位时修正节点位置 ([27fac02](https://github.com/Tencent/tmagic-editor/commit/27fac02e994b69417622d009cb69037d99eef10a))
|
||||||
|
* **editor:** 完善 fixed 与 absolute 定位切换逻辑 ([1298104](https://github.com/Tencent/tmagic-editor/commit/1298104732de1074ecc1398777459dd2fcbb74f7))
|
||||||
|
* **playground:** 修复历史恢复后加载 DSL 时缺少页面基线记录 ([4a82891](https://github.com/Tencent/tmagic-editor/commit/4a8289137b539a6597d1b978418bb9901392e5c3))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **editor:** 增强 *AndGetHistoryId 与 revertById 的 historyIds 能力 ([6960bd5](https://github.com/Tencent/tmagic-editor/commit/6960bd50e16fe612e486eb78af7630642f5e6c8f))
|
||||||
|
* **editor:** 数据源与代码块历史记录不再合并相邻操作 ([89cef4e](https://github.com/Tencent/tmagic-editor/commit/89cef4e9a9b1ffb11ab72c33e8b283bf59377f47))
|
||||||
|
* **editor:** 查看/编辑时自动切换 sidebar 并定位到对应配置 ([ffcc734](https://github.com/Tencent/tmagic-editor/commit/ffcc7341021da525263819cb6559cf20c2afea6a))
|
||||||
|
* **editor:** 边框方向有配置时显示高亮状态 ([b35132e](https://github.com/Tencent/tmagic-editor/commit/b35132e93eba6136990ba82912e53887c17c0cc1))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# [1.8.0-beta.5](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.4...v1.8.0-beta.5) (2026-06-11)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **editor:** 优化历史回滚确认流程 ([48519b0](https://github.com/Tencent/tmagic-editor/commit/48519b0155a7cda8226217fa3bfd97a92410a7a6))
|
||||||
|
* **editor:** 修复历史对比属性配置上下文缺失 ([be3a900](https://github.com/Tencent/tmagic-editor/commit/be3a900e6a132751f3b1d59c06b850c00604ee15))
|
||||||
|
* **stage:** 复用 TargetShadow 修正闪烁高亮定位 ([171d31e](https://github.com/Tencent/tmagic-editor/commit/171d31e20797ab0e68ac8b2a4c39740e1f636634))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **design:** popover 支持点击外部关闭 ([846f05e](https://github.com/Tencent/tmagic-editor/commit/846f05e04d6d85d37611148185eae93661e9d0da))
|
||||||
|
* **editor:** 将侧边栏激活面板状态同步至 uiService ([6ba59c0](https://github.com/Tencent/tmagic-editor/commit/6ba59c0d141947727c83bc708c9fb7fc6b71a47f))
|
||||||
|
* **editor:** 支持历史记录持久化 ([614f12a](https://github.com/Tencent/tmagic-editor/commit/614f12adf3174a4dadac028bda27057d18831a81))
|
||||||
|
* **editor:** 支持按历史记录 uuid 回滚 ([bddc6f3](https://github.com/Tencent/tmagic-editor/commit/bddc6f343cc97d3034c869c3fc46780759134f7c))
|
||||||
|
* **editor:** 支持页面初始基线与 root 变更历史记录 ([4f284e8](https://github.com/Tencent/tmagic-editor/commit/4f284e8d9cf6af9af234d345c14a2bf9176e5284))
|
||||||
|
* **editor:** 页面删除前增加确认弹窗并支持危险样式按钮 ([113af7d](https://github.com/Tencent/tmagic-editor/commit/113af7dd5104f5f49515abd66f12f5e62098f7e2))
|
||||||
|
* **editor:** 页面历史记录点击选中对应画布节点 ([fd652b0](https://github.com/Tencent/tmagic-editor/commit/fd652b0d13a2bf87db55d03013dc2c9ff01ff45d))
|
||||||
|
|
||||||
|
|
||||||
|
### Performance Improvements
|
||||||
|
|
||||||
|
* **editor:** 优化节点信息查找性能 ([c4ec2c5](https://github.com/Tencent/tmagic-editor/commit/c4ec2c5c722963c95141ac2d2ddf94d952d2e47d))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# [1.8.0-beta.4](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.3...v1.8.0-beta.4) (2026-06-04)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **editor:** 修复历史对比样式配置显示 ([cd19dec](https://github.com/Tencent/tmagic-editor/commit/cd19dec7907cac5cff775f1cbde24cb3f384e87b))
|
||||||
|
* **editor:** 修复合并历史记录信息展示 ([3bd0eec](https://github.com/Tencent/tmagic-editor/commit/3bd0eecb42d06f06f50cc4736ecc31cc07cc1886))
|
||||||
|
* **editor:** 禁止缺少变更记录的历史回滚 ([10b70c3](https://github.com/Tencent/tmagic-editor/commit/10b70c36bbace6af48bf6fa63f2df0704c6861af))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **editor:** 历史记录支持操作来源 ([27b2c2c](https://github.com/Tencent/tmagic-editor/commit/27b2c2c68598264e97a1e1ecc34121829851c85e))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# [1.8.0-beta.3](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.2...v1.8.0-beta.3) (2026-06-04)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **form:** 对比模式下无 name 字段时不展示差异 ([64d35d5](https://github.com/Tencent/tmagic-editor/commit/64d35d53631698e8d94362765a1621654bd3d1f6))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **editor:** 历史记录列表展示时间并优化回滚差异弹窗 ([a9e9e65](https://github.com/Tencent/tmagic-editor/commit/a9e9e65f9c50e47b22de8eab7184cebd87632bc6))
|
||||||
|
* **editor:** 历史记录差异对比弹窗关闭时派发 close 事件 ([42162f2](https://github.com/Tencent/tmagic-editor/commit/42162f2e4ac651ad78ff2f5291e00639a658a1ae))
|
||||||
|
* **editor:** 历史记录面板支持自定义扩展 tab 并开放 Bucket/goto 配置 ([8612311](https://github.com/Tencent/tmagic-editor/commit/8612311db12a22adcc30188ae1ead03729fa6a7a))
|
||||||
|
* **editor:** 对比表单支持自定义 loadConfig 加载逻辑 ([1cd69b3](https://github.com/Tencent/tmagic-editor/commit/1cd69b33fecd75fe8522d9a261e1c03e806ecf69))
|
||||||
|
* **form:** fieldset legend 支持函数动态生成标题 ([35fc394](https://github.com/Tencent/tmagic-editor/commit/35fc39419902e14e2d5bdf98f99802f05a4b5934))
|
||||||
|
* **form:** submitForm 支持返回 changeRecords ([12069e0](https://github.com/Tencent/tmagic-editor/commit/12069e0937589cf9b7684e4bd5ed927e15462513))
|
||||||
|
* **stage:** 非点击画布选中组件时高亮闪烁选中区域 ([444d422](https://github.com/Tencent/tmagic-editor/commit/444d4223a943d763a33b752ffbbfa704591820ca))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# [1.8.0-beta.2](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.1...v1.8.0-beta.2) (2026-05-29)
|
||||||
|
|
||||||
|
|
||||||
|
### 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)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **core:** app.emit 在节点配置事件时不应短路 super.emit ([2846f9e](https://github.com/Tencent/tmagic-editor/commit/2846f9eb2a8655175a024b16eaba22b522e88603))
|
||||||
|
* **editor:** serializeConfig 只去掉对象 key 的引号,避免破坏字符串 value 内的引号 ([540a271](https://github.com/Tencent/tmagic-editor/commit/540a2716d8e8e7b947ec5aa6352736dff6ee225c))
|
||||||
|
* **editor:** 修复 root 整体替换时图层面板节点状态残留与组件树闪烁问题 ([b9a6dd5](https://github.com/Tencent/tmagic-editor/commit/b9a6dd5b84d6f043eda94dbc1a07b75aea87e6f2))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **editor:** 数据源字段选择按钮在对比模式与禁用态下禁止切换 ([ff810d0](https://github.com/Tencent/tmagic-editor/commit/ff810d09e41163834f0ac9fd2057bd9fb9d53c55))
|
||||||
|
* **editor:** 样式设置器 StyleSetter 支持表单对比模式 ([b1193b9](https://github.com/Tencent/tmagic-editor/commit/b1193b909e5e15f78783f72eb21959a52128e973))
|
||||||
|
* **eslint-config:** 禁止匿名 default class/function 导出 ([a1fcb19](https://github.com/Tencent/tmagic-editor/commit/a1fcb191d243b3c7034f31f753757ca4bbd83f5f))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# [1.8.0-beta.0](https://github.com/Tencent/tmagic-editor/compare/v1.7.14-beta.3...v1.8.0-beta.0) (2026-05-22)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **editor:** 属性面板 padding 仅作用于最外层表单 ([eb1c5a3](https://github.com/Tencent/tmagic-editor/commit/eb1c5a3ec1c5987b50c700dfb9019aad695e042a))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **editor:** 新增 DSL 修改方法的 doNotSwitchPage 选项 ([3d03851](https://github.com/Tencent/tmagic-editor/commit/3d038513e3f0d1c303332fd902c1ef83d7dfe860))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## [1.7.14-beta.3](https://github.com/Tencent/tmagic-editor/compare/v1.7.14-beta.2...v1.7.14-beta.3) (2026-05-21)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **form:** select 在 model 值变化时补拉 init 选项 ([aa2ee9f](https://github.com/Tencent/tmagic-editor/commit/aa2ee9fd4b08a4a2896eead33dfd1d4ba029c501))
|
||||||
|
* **form:** 修复table-group-list中model属性可能为undefined导致的报错 ([12ce19f](https://github.com/Tencent/tmagic-editor/commit/12ce19fb02af7ac621d220b7e6d0a98859e631de))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **editor:** 新增 DSL 修改方法的 doNotSelect 选项 ([05e512b](https://github.com/Tencent/tmagic-editor/commit/05e512b1fe978e26aa3064e7deae9a1aeadcae25))
|
||||||
|
* **form:** 容器组件新增 extendState 属性 ([2d31b38](https://github.com/Tencent/tmagic-editor/commit/2d31b3812f2195f4afc5f16774e155f00cb0ec20))
|
||||||
|
* **form:** 新增 submitForm 命令式提交函数 ([638c3e9](https://github.com/Tencent/tmagic-editor/commit/638c3e9f3cb550da2749fd4814c3bec9d518d081))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## [1.7.14-beta.2](https://github.com/Tencent/tmagic-editor/compare/v1.7.14-beta.1...v1.7.14-beta.2) (2026-05-18)
|
## [1.7.14-beta.2](https://github.com/Tencent/tmagic-editor/compare/v1.7.14-beta.1...v1.7.14-beta.2) (2026-05-18)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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',
|
||||||
@ -197,7 +201,16 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'uiService',
|
text: 'uiService',
|
||||||
link: '/api/editor/uiServiceMethods.md',
|
items: [
|
||||||
|
{
|
||||||
|
text: '方法',
|
||||||
|
link: '/api/editor/uiServiceMethods.md',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '事件',
|
||||||
|
link: '/api/editor/uiServiceEvents.md',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'codeBlockService',
|
text: 'codeBlockService',
|
||||||
@ -250,6 +263,15 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
text: '工具函数',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
text: 'submitForm',
|
||||||
|
link: '/api/form/submit-form'
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
# codeBlockService方法
|
# codeBlockService方法
|
||||||
|
|
||||||
|
写入历史栈的方法([setCodeDslById](#setcodedslbyid)、[setCodeDslByIdSync](#setcodedslbyidsync)、[deleteCodeDslByIds](#deletecodedslbyids) 等)的 `options` 支持
|
||||||
|
[historyDescription / historySource](./editorServiceMethods.md#历史记录相关-options),会透传到 `historyService.push('codeBlock', step, id)` 入栈记录的 `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.push('codeBlock', step, id)`
|
||||||
|
把本次变更入历史栈,参见 [historyService.push](./historyServiceMethods.md#push)。
|
||||||
|
传入的 `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,160 @@
|
|||||||
|
|
||||||
在dsl数据源中删除指定id的代码块,每删除一个会触发一次 `remove` 事件
|
在dsl数据源中删除指定id的代码块,每删除一个会触发一次 `remove` 事件
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
对每个实际存在并被删除的代码块,会自动调用 `historyService.push('codeBlock', step, id)` 入栈一条
|
||||||
|
`newSchema=null` 的删除记录;不存在的 id 不会入历史。传入 `doNotPushHistory: true` 也可显式跳过写入历史栈。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## setCodeDslByIdAndGetHistoryId
|
||||||
|
|
||||||
|
- **参数:** 同 [setCodeDslById](#setcodedslbyid)
|
||||||
|
|
||||||
|
- **返回:**
|
||||||
|
- {`Promise<`[`DslOpWithHistoryIdsResult<void>`](./editorServiceMethods.md#历史记录-uuid-与-andgethistoryid)`>`}
|
||||||
|
- `historyIds`:本次写入历史记录的 uuid 列表;未写入历史(`doNotPushHistory: true` 等)时为 `[]`
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
与 [setCodeDslById](#setcodedslbyid) 行为完全一致,并在返回值中额外提供 `historyIds`,可用于精确引用 / 定位该条历史记录。
|
||||||
|
参见 [editorService 历史记录 uuid 与 \*AndGetHistoryId](./editorServiceMethods.md#历史记录-uuid-与-andgethistoryid)。
|
||||||
|
|
||||||
|
- **示例:**
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { codeBlockService } from "@tmagic/editor";
|
||||||
|
|
||||||
|
const { historyIds } = await codeBlockService.setCodeDslByIdAndGetHistoryId("code_1234", {
|
||||||
|
name: "代码块1",
|
||||||
|
content: "() => {}",
|
||||||
|
});
|
||||||
|
console.log(historyIds); // 本次变更对应的历史记录 uuid 列表,或 []
|
||||||
|
```
|
||||||
|
|
||||||
|
## setCodeDslByIdSyncAndGetHistoryId
|
||||||
|
|
||||||
|
- **参数:** 同 [setCodeDslByIdSync](#setcodedslbyidsync)
|
||||||
|
|
||||||
|
- **返回:**
|
||||||
|
- {[`DslOpWithHistoryIdsResult<void>`](./editorServiceMethods.md#历史记录-uuid-与-andgethistoryid)}
|
||||||
|
- `historyIds`:本次写入历史记录的 uuid 列表;未写入历史(`doNotPushHistory: true`、或 `force=false` 跳过等)时为 `[]`
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
与 [setCodeDslByIdSync](#setcodedslbyidsync) 行为完全一致(同步),并在返回值中额外提供 `historyIds`
|
||||||
|
|
||||||
|
## deleteCodeDslByIdsAndGetHistoryId
|
||||||
|
|
||||||
|
- **参数:** 同 [deleteCodeDslByIds](#deletecodedslbyids)
|
||||||
|
|
||||||
|
- **返回:**
|
||||||
|
- {`Promise<`[`DslOpWithHistoryIdsResult<void>`](./editorServiceMethods.md#历史记录-uuid-与-andgethistoryid)`>`}
|
||||||
|
- `historyIds`:本次写入的全部历史记录 uuid(按删除顺序);未写入任何历史时为 `[]`
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
与 [deleteCodeDslByIds](#deletecodedslbyids) 行为完全一致。由于一次可删除多个代码块、会产生多条历史记录,因此使用 `historyIds` 数组(每条删除记录一个 uuid);不存在的 id 不会入历史,也不会出现在 `historyIds` 中。
|
||||||
|
|
||||||
|
- **示例:**
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { codeBlockService } from "@tmagic/editor";
|
||||||
|
|
||||||
|
const { historyIds } = await codeBlockService.deleteCodeDslByIdsAndGetHistoryId(["code_1", "code_2"]);
|
||||||
|
console.log(historyIds); // ['xxxx', 'yyyy'],或 []
|
||||||
|
```
|
||||||
|
|
||||||
|
## revertById
|
||||||
|
|
||||||
|
- **参数:**
|
||||||
|
- `{string[]}` uuids 目标历史记录的 uuid 列表(通常由 [setCodeDslByIdAndGetHistoryId](#setcodedslbyidandgethistoryid) 等方法返回的 `historyIds`)
|
||||||
|
|
||||||
|
- **返回:**
|
||||||
|
- {`Promise<(`CodeBlockStepValue` | null)[]>`} 与入参同序的回滚结果列表,某项失败时为 `null`
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
通过历史记录 uuid「回滚」代码块历史步骤(类 git revert 语义),语义同按 `(id, index)` 回滚,
|
||||||
|
仅无需调用方再传 `codeBlockId` 与 `index`。**按数组顺序依次回滚**。
|
||||||
|
参见 [editorService 历史记录 uuid 与 \*AndGetHistoryId](./editorServiceMethods.md#历史记录-uuid-与-andgethistoryid)。
|
||||||
|
|
||||||
|
- **示例:**
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { codeBlockService } from "@tmagic/editor";
|
||||||
|
|
||||||
|
const { historyIds } = await codeBlockService.setCodeDslByIdAndGetHistoryId("code_1234", { name: "代码块1" });
|
||||||
|
if (historyIds.length) {
|
||||||
|
await codeBlockService.revertById(historyIds);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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.canUndo('codeBlock', id)`。
|
||||||
|
|
||||||
|
## canRedo
|
||||||
|
|
||||||
|
- **参数:**
|
||||||
|
- `{Id}` id 代码块id
|
||||||
|
|
||||||
|
- **返回:**
|
||||||
|
- `{boolean}`
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
当前指定代码块是否可重做,等价于 `historyService.canRedo('codeBlock', id)`。
|
||||||
|
|
||||||
## setParamsColConfig
|
## setParamsColConfig
|
||||||
|
|
||||||
- **参数:**
|
- **参数:**
|
||||||
@ -288,15 +470,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.push('dataSource', step, id)` 入栈一条 `oldSchema=null` 的新增记录,
|
||||||
|
参见 [historyService.push](./historyServiceMethods.md#push)。
|
||||||
|
传入 `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.push('dataSource', step, id)` 入栈一条 `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.push('dataSource', step, id)` 入栈一条 `newSchema=null`
|
||||||
|
的删除记录;不存在的 id 不会入历史。传入 `doNotPushHistory: true` 也可显式跳过写入历史栈。
|
||||||
|
:::
|
||||||
|
|
||||||
- **示例:**
|
- **示例:**
|
||||||
|
|
||||||
```js
|
```js
|
||||||
@ -378,6 +406,84 @@ import { dataSourceService } from "@tmagic/editor";
|
|||||||
dataSourceService.remove("ds_123");
|
dataSourceService.remove("ds_123");
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## addAndGetHistoryId
|
||||||
|
|
||||||
|
- **参数:** 同 [add](#add)
|
||||||
|
|
||||||
|
- **返回:**
|
||||||
|
- {[`DslOpWithHistoryIdsResult<DataSourceSchema>`](./editorServiceMethods.md#历史记录-uuid-与-andgethistoryid)}
|
||||||
|
- `result`:同 [add](#add) 的返回值(新增的数据源配置)
|
||||||
|
- `historyIds`:本次写入历史记录的 uuid 列表;未写入历史(`doNotPushHistory: true` 等)时为 `[]`
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
与 [add](#add) 行为完全一致,并在返回值中额外提供 `historyIds`,可用于精确引用 / 定位该条历史记录。
|
||||||
|
参见 [editorService 历史记录 uuid 与 \*AndGetHistoryId](./editorServiceMethods.md#历史记录-uuid-与-andgethistoryid)。
|
||||||
|
|
||||||
|
- **示例:**
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { dataSourceService } from "@tmagic/editor";
|
||||||
|
|
||||||
|
const { result, historyIds } = dataSourceService.addAndGetHistoryId({
|
||||||
|
type: "http",
|
||||||
|
title: "用户信息",
|
||||||
|
url: "/api/user",
|
||||||
|
});
|
||||||
|
console.log(result); // 新增的数据源配置
|
||||||
|
console.log(historyIds); // 本次新增对应的历史记录 uuid 列表,或 []
|
||||||
|
```
|
||||||
|
|
||||||
|
## updateAndGetHistoryId
|
||||||
|
|
||||||
|
- **参数:** 同 [update](#update)
|
||||||
|
|
||||||
|
- **返回:**
|
||||||
|
- {[`DslOpWithHistoryIdsResult<DataSourceSchema>`](./editorServiceMethods.md#历史记录-uuid-与-andgethistoryid)}
|
||||||
|
- `result`:同 [update](#update) 的返回值(更新后的数据源配置)
|
||||||
|
- `historyIds`:本次写入历史记录的 uuid 列表;未写入历史时为 `[]`
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
与 [update](#update) 行为完全一致,并在返回值中额外提供 `historyIds`
|
||||||
|
|
||||||
|
## removeAndGetHistoryId
|
||||||
|
|
||||||
|
- **参数:** 同 [remove](#remove)
|
||||||
|
|
||||||
|
- **返回:**
|
||||||
|
- {[`DslOpWithHistoryIdsResult<void>`](./editorServiceMethods.md#历史记录-uuid-与-andgethistoryid)}
|
||||||
|
- `historyIds`:本次写入历史记录的 uuid 列表;删除的 id 不存在或未写入历史时为 `[]`
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
与 [remove](#remove) 行为完全一致,并在返回值中额外提供 `historyIds`
|
||||||
|
|
||||||
|
## revertById
|
||||||
|
|
||||||
|
- **参数:**
|
||||||
|
- `{string[]}` uuids 目标历史记录的 uuid 列表(通常由 [addAndGetHistoryId](#addandgethistoryid) 等方法返回的 `historyIds`)
|
||||||
|
|
||||||
|
- **返回:**
|
||||||
|
- {(`DataSourceStepValue` | null)[]} 与入参同序的回滚结果列表,某项失败时为 `null`
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
通过历史记录 uuid「回滚」数据源历史步骤(类 git revert 语义),语义同按 `(id, index)` 回滚,
|
||||||
|
仅无需调用方再传 `dataSourceId` 与 `index`。**按数组顺序依次回滚**。
|
||||||
|
参见 [editorService 历史记录 uuid 与 \*AndGetHistoryId](./editorServiceMethods.md#历史记录-uuid-与-andgethistoryid)。
|
||||||
|
|
||||||
|
- **示例:**
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { dataSourceService } from "@tmagic/editor";
|
||||||
|
|
||||||
|
const { historyIds } = dataSourceService.addAndGetHistoryId({ type: "http", title: "用户信息" });
|
||||||
|
if (historyIds.length) {
|
||||||
|
dataSourceService.revertById(historyIds);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## createId
|
## createId
|
||||||
|
|
||||||
- **[扩展支持](../../guide/editor-expand#行为扩展):** 是
|
- **[扩展支持](../../guide/editor-expand#行为扩展):** 是
|
||||||
@ -421,6 +527,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.canUndo('dataSource', id)`。
|
||||||
|
|
||||||
|
## canRedo
|
||||||
|
|
||||||
|
- **参数:**
|
||||||
|
- `{Id}` id 数据源id
|
||||||
|
|
||||||
|
- **返回:**
|
||||||
|
- `{boolean}`
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
当前指定数据源是否可重做,等价于 `historyService.canRedo('dataSource', id)`。
|
||||||
|
|
||||||
## copyWithRelated
|
## copyWithRelated
|
||||||
|
|
||||||
- **参数:**
|
- **参数:**
|
||||||
|
|||||||
@ -87,3 +87,19 @@
|
|||||||
- **详情:** 历史记录改变,[editorService.redo(),editorService.undo()](./editorServiceMethods.md#undo)后触发
|
- **详情:** 历史记录改变,[editorService.redo(),editorService.undo()](./editorServiceMethods.md#undo)后触发
|
||||||
|
|
||||||
- **事件回调函数:** `(data: MPage | MPageFragment) => void`
|
- **事件回调函数:** `(data: MPage | MPageFragment) => void`
|
||||||
|
|
||||||
|
## change
|
||||||
|
|
||||||
|
- **详情:** DSL 发生变更后统一触发,免去分别监听 `add` / `remove` / `update` / `move-layer` / `drag-to`。在 [editorService.add()](./editorServiceMethods.md#add)、[editorService.remove()](./editorServiceMethods.md#remove)、[editorService.update()](./editorServiceMethods.md#update)、[editorService.moveLayer()](./editorServiceMethods.md#movelayer)、[editorService.dragTo()](./editorServiceMethods.md#dragto) 后触发。
|
||||||
|
|
||||||
|
回调参数 `event` 通过 `type` 区分操作类型,并携带本次变更的节点列表 `data`,每项包含变更的 `node` 及其所属的 `page`(可能为 `null`)。`move-layer` 额外携带层级偏移 `offset`,`drag-to` 额外携带目标位置 `targetIndex` / `targetParent`。
|
||||||
|
|
||||||
|
::: warning 注意
|
||||||
|
撤销 / 重做(`undo` / `redo`)内部同样会经由 `add` / `remove` / `update` 触发本事件;如需区分「用户操作」与「撤销重做」,请配合 [history-change](#history-change) 事件判断。
|
||||||
|
:::
|
||||||
|
|
||||||
|
- **事件回调函数:** `(event: EditorChangeEvent) => void`
|
||||||
|
|
||||||
|
::: details 查看 EditorChangeEvent 类型定义
|
||||||
|
<<< @/../packages/editor/src/type.ts#EditorChangeEvent{ts}
|
||||||
|
:::
|
||||||
|
|||||||
@ -1,5 +1,50 @@
|
|||||||
# 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` 方法:它们与原方法行为完全一致,返回值类型为 [`DslOpWithHistoryIdsResult<T>`](#历史记录-uuid-与-andgethistoryid)(`result` 为原方法返回值,`historyIds` 为本次写入的 uuid 列表)。当本次操作未写入历史(`doNotPushHistory: true`、无实际变更或提前返回)时 `historyIds` 为 `[]`;单次操作通常返回含一个 uuid 的数组。
|
||||||
|
|
||||||
|
| 原方法 | 取 uuid 的方法 | 返回值 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| [add](#add) | [addAndGetHistoryId](#addandgethistoryid) | `Promise<DslOpWithHistoryIdsResult<MNode \| MNode[]>>` |
|
||||||
|
| [remove](#remove) | [removeAndGetHistoryId](#removeandgethistoryid) | `Promise<DslOpWithHistoryIdsResult<void>>` |
|
||||||
|
| [update](#update) | [updateAndGetHistoryId](#updateandgethistoryid) | `Promise<DslOpWithHistoryIdsResult<MNode \| MNode[]>>` |
|
||||||
|
| [moveLayer](#movelayer) | [moveLayerAndGetHistoryId](#movelayerandgethistoryid) | `Promise<DslOpWithHistoryIdsResult<void>>` |
|
||||||
|
| [moveToContainer](#movetocontainer) | [moveToContainerAndGetHistoryId](#movetocontainerandgethistoryid) | `Promise<DslOpWithHistoryIdsResult<MNode \| MNode[]>>` |
|
||||||
|
| [dragTo](#dragto) | [dragToAndGetHistoryId](#dragtoandgethistoryid) | `Promise<DslOpWithHistoryIdsResult<void>>` |
|
||||||
|
|
||||||
|
[dataSourceService](./dataSourceServiceMethods.md) / [codeBlockService](./codeBlockServiceMethods.md) 也提供了同名约定的 `*AndGetHistoryId` 方法,返回值约定相同(`result` + `historyIds`)。
|
||||||
|
|
||||||
|
拿到 `uuid` 后,可在需要时按 uuid「回滚」对应的历史记录(类 git revert 语义,详见[历史记录面板](../../guide/advanced/history-list.md))。相比按 index 回滚,uuid 不会随栈内步骤增删而变化,更适合业务侧持有引用后再回滚:
|
||||||
|
|
||||||
|
- 页面:[editorService.revertPageStepById(uuids)](#revertpagestepbyid)
|
||||||
|
- 数据源:[dataSourceService.revertById(uuids)](./dataSourceServiceMethods.md#revertbyid)
|
||||||
|
- 代码块:[codeBlockService.revertById(uuids)](./codeBlockServiceMethods.md#revertbyid)
|
||||||
|
|
||||||
|
::: details 查看 HistoryOpOptions / DslOpOptions / DslOpWithHistoryIdsResult / HistoryOpSource 类型定义
|
||||||
|
<<< @/../packages/editor/src/type.ts#HistoryOpOptions{ts}
|
||||||
|
|
||||||
|
<<< @/../packages/editor/src/type.ts#DslOpOptions{ts}
|
||||||
|
|
||||||
|
<<< @/../packages/editor/src/type.ts#DslOpWithHistoryIdsResult{ts}
|
||||||
|
|
||||||
|
<<< @/../packages/editor/src/type.ts#HistoryOpSource{ts}
|
||||||
|
:::
|
||||||
|
|
||||||
## get
|
## get
|
||||||
|
|
||||||
- **参数:**
|
- **参数:**
|
||||||
@ -170,6 +215,29 @@ const parent = editorService.getParentById("text_123");
|
|||||||
console.log(parent);
|
console.log(parent);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## isOnDifferentPage
|
||||||
|
|
||||||
|
- **参数:**
|
||||||
|
- {`MNode`} node 节点配置
|
||||||
|
|
||||||
|
- **返回:**
|
||||||
|
- `{boolean}` true 表示该节点位于非当前页面(即选中该节点将会引起当前页面切换)
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
判断给定节点是否位于非当前页面,通常用于配合 `doNotSwitchPage` 选项判断 DSL 操作是否会引起页面切换
|
||||||
|
|
||||||
|
- **示例:**
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { editorService } from "@tmagic/editor";
|
||||||
|
|
||||||
|
const otherPageNode = editorService.getNodeById("text_456");
|
||||||
|
if (editorService.isOnDifferentPage(otherPageNode)) {
|
||||||
|
console.log("该节点在其它页面,操作会触发页面切换");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## getLayout
|
## getLayout
|
||||||
|
|
||||||
- **[扩展支持](../../guide/editor-expand#行为扩展):** 是
|
- **[扩展支持](../../guide/editor-expand#行为扩展):** 是
|
||||||
@ -332,6 +400,13 @@ editorService.highlight("text_123");
|
|||||||
|
|
||||||
- {`MContainer`} parent 指定的容器组件节点配置,如果不设置,默认为当前选中的组件的父节点
|
- {`MContainer`} parent 指定的容器组件节点配置,如果不设置,默认为当前选中的组件的父节点
|
||||||
|
|
||||||
|
- `{Object}` options 可选配置
|
||||||
|
- `{boolean}` doNotSelect 添加后是否不更新当前选中节点(默认 false,添加后会选中新增的节点)
|
||||||
|
- `{boolean}` doNotSwitchPage 添加后是否不切换当前页面(默认 false;新增页面 / 跨页新增时为 true 会跳过会引发页面切换的选中操作)
|
||||||
|
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false)
|
||||||
|
- `{string}` historyDescription 见[历史记录相关 options](#历史记录相关-options)
|
||||||
|
- `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options)
|
||||||
|
|
||||||
- **返回:**
|
- **返回:**
|
||||||
- {Promise<`MNode` | `MNode`[]>} 新增的组件或组件集合
|
- {Promise<`MNode` | `MNode`[]>} 新增的组件或组件集合
|
||||||
|
|
||||||
@ -352,6 +427,9 @@ editorService.highlight("text_123");
|
|||||||
- **[扩展支持](../../guide/editor-expand#行为扩展):** 是
|
- **[扩展支持](../../guide/editor-expand#行为扩展):** 是
|
||||||
- **参数:**
|
- **参数:**
|
||||||
- {`MNode`} node 要删除的节点
|
- {`MNode`} node 要删除的节点
|
||||||
|
- `{Object}` options 可选配置
|
||||||
|
- `{boolean}` doNotSelect 删除后是否不更新当前选中节点(默认 false)
|
||||||
|
- `{boolean}` doNotSwitchPage 删除后是否不切换当前页面(默认 false;删除页面 / 页面片段时为 true 会跳过自动切换到首个剩余页面)
|
||||||
|
|
||||||
- **返回:**
|
- **返回:**
|
||||||
- `{Promise<void>}`
|
- `{Promise<void>}`
|
||||||
@ -360,12 +438,22 @@ editorService.highlight("text_123");
|
|||||||
|
|
||||||
删除指定的组件或者页面
|
删除指定的组件或者页面
|
||||||
|
|
||||||
|
:::tip
|
||||||
|
无论是否传入 `doNotSelect` / `doNotSwitchPage`,当被删除节点在当前选中列表中时,state 都会自动移除该节点的引用;当被删除的正好是当前页面时,state.page 也会同步清空,避免持有已删除节点
|
||||||
|
:::
|
||||||
|
|
||||||
## remove
|
## remove
|
||||||
|
|
||||||
- **[扩展支持](../../guide/editor-expand#行为扩展):** 是
|
- **[扩展支持](../../guide/editor-expand#行为扩展):** 是
|
||||||
|
|
||||||
- **参数:**
|
- **参数:**
|
||||||
- {`MNode` | `MNode`[])} node 要删除的节点或节点集合
|
- {`MNode` | `MNode`[])} node 要删除的节点或节点集合
|
||||||
|
- `{Object}` options 可选配置
|
||||||
|
- `{boolean}` doNotSelect 删除后是否不更新当前选中节点(默认 false,删除后会选中父节点或首个页面)
|
||||||
|
- `{boolean}` doNotSwitchPage 删除后是否不切换当前页面(默认 false;删除页面 / 页面片段时为 true 会跳过自动切换到首个剩余页面)
|
||||||
|
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false)
|
||||||
|
- `{string}` historyDescription 见[历史记录相关 options](#历史记录相关-options)
|
||||||
|
- `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options)
|
||||||
|
|
||||||
- **返回:**
|
- **返回:**
|
||||||
- `{Promise<void>}`
|
- `{Promise<void>}`
|
||||||
@ -390,7 +478,6 @@ editorService.highlight("text_123");
|
|||||||
- {`MNode`} config 新的节点
|
- {`MNode`} config 新的节点
|
||||||
- `{Object}` data 可选配置
|
- `{Object}` data 可选配置
|
||||||
- {`ChangeRecord`[]} changeRecords 变更记录
|
- {`ChangeRecord`[]} changeRecords 变更记录
|
||||||
- `{boolean}` selectedAfterUpdate 更新后是否将新节点同步到当前选中节点列表
|
|
||||||
|
|
||||||
- **返回:**
|
- **返回:**
|
||||||
- `{Promise<{ newNode: MNode; oldNode: MNode; changeRecords?: ChangeRecord[] }>}` 更新前后的节点信息
|
- `{Promise<{ newNode: MNode; oldNode: MNode; changeRecords?: ChangeRecord[] }>}` 更新前后的节点信息
|
||||||
@ -405,6 +492,10 @@ editorService.highlight("text_123");
|
|||||||
|
|
||||||
:::tip
|
:::tip
|
||||||
节点中应该要有id,不然不知道要更新哪个节点
|
节点中应该要有id,不然不知道要更新哪个节点
|
||||||
|
|
||||||
|
当被更新节点正好在当前选中列表中时,state 会自动同步到新的节点引用,无需调用方处理
|
||||||
|
|
||||||
|
当被更新节点正好是当前页面时,state.page 也会同步到新的节点引用;更新非当前页面(不同 ID)时不会把编辑器切到该页
|
||||||
:::
|
:::
|
||||||
|
|
||||||
## update
|
## update
|
||||||
@ -414,8 +505,15 @@ editorService.highlight("text_123");
|
|||||||
- **参数:**
|
- **参数:**
|
||||||
- {`MNode` | `MNode`[]} config 新的节点或节点集合
|
- {`MNode` | `MNode`[]} config 新的节点或节点集合
|
||||||
- `{Object}` data 可选配置
|
- `{Object}` data 可选配置
|
||||||
- {`ChangeRecord`[]} changeRecords 变更记录
|
- {`ChangeRecord`[]} changeRecords 单节点 form 端变更记录(多节点场景下被忽略,使用 `changeRecordList`)
|
||||||
- `{boolean}` selectedAfterUpdate 更新后是否同步到当前选中节点列表
|
- {`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`[]>} 新的节点或节点集合
|
||||||
@ -432,6 +530,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#行为扩展):** 是
|
||||||
@ -439,6 +547,11 @@ editorService.highlight("text_123");
|
|||||||
- **参数:**
|
- **参数:**
|
||||||
- `{ string | number }` id1
|
- `{ string | number }` id1
|
||||||
- `{ string | number }` id2
|
- `{ string | number }` id2
|
||||||
|
- `{Object}` options 可选配置
|
||||||
|
- `{boolean}` doNotSelect 排序后是否不更新当前选中节点(默认 false)
|
||||||
|
- `{boolean}` doNotSwitchPage 排序后是否不切换当前页面(排序只发生在同一父节点内,方法内为空操作;保留以与其它 DSL 操作 API 一致)
|
||||||
|
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false)
|
||||||
|
- `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options)
|
||||||
|
|
||||||
- **返回:**
|
- **返回:**
|
||||||
- `{Promise<void>}`
|
- `{Promise<void>}`
|
||||||
@ -502,6 +615,14 @@ editorService.highlight("text_123");
|
|||||||
<<< @/../packages/editor/src/type.ts#PastePosition{ts}
|
<<< @/../packages/editor/src/type.ts#PastePosition{ts}
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
- `{TargetOptions}` collectorOptions 可选的依赖收集器配置
|
||||||
|
- `{Object}` options 可选配置
|
||||||
|
- `{boolean}` doNotSelect 粘贴后是否不更新当前选中节点(默认 false)
|
||||||
|
- `{boolean}` doNotSwitchPage 粘贴后是否不切换当前页面(默认 false;跨页粘贴时为 true 会跳过页面切换)
|
||||||
|
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false)
|
||||||
|
- `{string}` historyDescription 见[历史记录相关 options](#历史记录相关-options)
|
||||||
|
- `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options)
|
||||||
|
|
||||||
- **返回:**
|
- **返回:**
|
||||||
- {Promise<`MNode` | `MNode`[]>} 添加后的组件节点配置
|
- {Promise<`MNode` | `MNode`[]>} 添加后的组件节点配置
|
||||||
|
|
||||||
@ -535,6 +656,12 @@ editorService.highlight("text_123");
|
|||||||
|
|
||||||
- **参数:**
|
- **参数:**
|
||||||
- {`MNode` | `MNode`[]} config 需要居中的组件或者组件集合
|
- {`MNode` | `MNode`[]} config 需要居中的组件或者组件集合
|
||||||
|
- `{Object}` options 可选配置
|
||||||
|
- `{boolean}` doNotSelect 居中后是否不更新当前选中节点(默认 false)
|
||||||
|
- `{boolean}` doNotSwitchPage 居中后是否不切换当前页面(居中只更新节点 style,方法内为空操作;保留以与其它 DSL 操作 API 一致)
|
||||||
|
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false)
|
||||||
|
- `{string}` historyDescription 见[历史记录相关 options](#历史记录相关-options)
|
||||||
|
- `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options)
|
||||||
|
|
||||||
- **返回:**
|
- **返回:**
|
||||||
- {Promise<`MNode` | `MNode`[]>}
|
- {Promise<`MNode` | `MNode`[]>}
|
||||||
@ -555,6 +682,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>}`
|
||||||
@ -572,6 +703,12 @@ alignCenter可以支持一次水平居中多个组件,alignCenter是通过调
|
|||||||
- **参数:**
|
- **参数:**
|
||||||
- {`MNode`} config 需要移动的节点
|
- {`MNode`} config 需要移动的节点
|
||||||
- `{string | number}` targetId 容器ID
|
- `{string | number}` targetId 容器ID
|
||||||
|
- `{Object}` options 可选配置
|
||||||
|
- `{boolean}` doNotSelect 移动后是否不更新当前选中节点(默认 false)
|
||||||
|
- `{boolean}` doNotSwitchPage 移动后是否不切换当前页面(默认 false;目标容器位于其它页面时为 true 会跳过自动选中以避免页面切换)
|
||||||
|
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false)
|
||||||
|
- `{string}` historyDescription 见[历史记录相关 options](#历史记录相关-options)
|
||||||
|
- `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options)
|
||||||
|
|
||||||
- **返回:**
|
- **返回:**
|
||||||
- Promise<`MNode` | undefined>
|
- Promise<`MNode` | undefined>
|
||||||
@ -586,6 +723,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>}`
|
||||||
@ -594,6 +735,123 @@ alignCenter可以支持一次水平居中多个组件,alignCenter是通过调
|
|||||||
|
|
||||||
将节点(支持多选)拖拽到目标容器的指定位置,会自动处理跨容器布局切换并记录历史
|
将节点(支持多选)拖拽到目标容器的指定位置,会自动处理跨容器布局切换并记录历史
|
||||||
|
|
||||||
|
## addAndGetHistoryId
|
||||||
|
|
||||||
|
- **参数:** 同 [add](#add)
|
||||||
|
|
||||||
|
- **返回:**
|
||||||
|
- {Promise<[`DslOpWithHistoryIdsResult<MNode | MNode[]>`](#历史记录-uuid-与-andgethistoryid)>}
|
||||||
|
- `result`:同 [add](#add) 的返回值(新增节点 / 节点数组)
|
||||||
|
- `historyIds`:本次写入历史记录的 [uuid](#历史记录-uuid-与-andgethistoryid) 列表;未写入历史时为 `[]`
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
与 [add](#add) 行为完全一致,并在返回值中额外提供 `historyIds`,见[历史记录 uuid 与 \*AndGetHistoryId](#历史记录-uuid-与-andgethistoryid)
|
||||||
|
|
||||||
|
- **示例:**
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { editorService } from "@tmagic/editor";
|
||||||
|
|
||||||
|
const { result, historyIds } = await editorService.addAndGetHistoryId(
|
||||||
|
{ type: "text", text: "hello" },
|
||||||
|
parent,
|
||||||
|
{ historySource: "api" },
|
||||||
|
);
|
||||||
|
console.log(result); // 新增节点
|
||||||
|
console.log(historyIds); // 本次新增对应的历史记录 uuid 列表,或 []
|
||||||
|
```
|
||||||
|
|
||||||
|
## removeAndGetHistoryId
|
||||||
|
|
||||||
|
- **参数:** 同 [remove](#remove)
|
||||||
|
|
||||||
|
- **返回:**
|
||||||
|
- {Promise<[`DslOpWithHistoryIdsResult<void>`](#历史记录-uuid-与-andgethistoryid)>}
|
||||||
|
- `historyIds`:本次写入历史记录的 [uuid](#历史记录-uuid-与-andgethistoryid) 列表;未写入历史时为 `[]`
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
与 [remove](#remove) 行为完全一致,并在返回值中额外提供 `historyIds`
|
||||||
|
|
||||||
|
## updateAndGetHistoryId
|
||||||
|
|
||||||
|
- **参数:** 同 [update](#update)
|
||||||
|
|
||||||
|
- **返回:**
|
||||||
|
- {Promise<[`DslOpWithHistoryIdsResult<MNode | MNode[]>`](#历史记录-uuid-与-andgethistoryid)>}
|
||||||
|
- `result`:同 [update](#update) 的返回值(更新后的节点 / 节点数组)
|
||||||
|
- `historyIds`:本次写入历史记录的 [uuid](#历史记录-uuid-与-andgethistoryid) 列表;未写入历史时为 `[]`
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
与 [update](#update) 行为完全一致,并在返回值中额外提供 `historyIds`
|
||||||
|
|
||||||
|
## moveLayerAndGetHistoryId
|
||||||
|
|
||||||
|
- **参数:** 同 [moveLayer](#movelayer)
|
||||||
|
|
||||||
|
- **返回:**
|
||||||
|
- {Promise<[`DslOpWithHistoryIdsResult<void>`](#历史记录-uuid-与-andgethistoryid)>}
|
||||||
|
- `historyIds`:本次写入历史记录的 [uuid](#历史记录-uuid-与-andgethistoryid) 列表;未写入历史时为 `[]`
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
与 [moveLayer](#movelayer) 行为完全一致,并在返回值中额外提供 `historyIds`
|
||||||
|
|
||||||
|
## moveToContainerAndGetHistoryId
|
||||||
|
|
||||||
|
- **参数:** 同 [moveToContainer](#movetocontainer)
|
||||||
|
|
||||||
|
- **返回:**
|
||||||
|
- {Promise<[`DslOpWithHistoryIdsResult<MNode | MNode[]>`](#历史记录-uuid-与-andgethistoryid)>}
|
||||||
|
- `result`:同 [moveToContainer](#movetocontainer) 的返回值(移动后的节点 / 节点数组)
|
||||||
|
- `historyIds`:本次写入历史记录的 [uuid](#历史记录-uuid-与-andgethistoryid) 列表;未写入历史时为 `[]`
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
与 [moveToContainer](#movetocontainer) 行为完全一致,并在返回值中额外提供 `historyIds`
|
||||||
|
|
||||||
|
## dragToAndGetHistoryId
|
||||||
|
|
||||||
|
- **参数:** 同 [dragTo](#dragto)
|
||||||
|
|
||||||
|
- **返回:**
|
||||||
|
- {Promise<[`DslOpWithHistoryIdsResult<void>`](#历史记录-uuid-与-andgethistoryid)>}
|
||||||
|
- `historyIds`:本次写入历史记录的 [uuid](#历史记录-uuid-与-andgethistoryid) 列表;未写入历史时为 `[]`
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
与 [dragTo](#dragto) 行为完全一致,并在返回值中额外提供 `historyIds`
|
||||||
|
|
||||||
|
## revertPageStepById
|
||||||
|
|
||||||
|
- **参数:**
|
||||||
|
- `{string[]}` uuids 目标历史记录的 uuid 列表(通常由 `*AndGetHistoryId` 方法返回的 `historyIds`)
|
||||||
|
|
||||||
|
- **返回:**
|
||||||
|
- {Promise<(`StepValue` | null)[]>} 与入参同序的回滚结果列表,某项失败时为 `null`
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
通过历史记录 uuid「回滚」当前页面的历史步骤(类 git revert 语义):不移动游标、不丢弃任何步骤,而是把目标 step 的修改**反向应用为一条全新的步骤**压入栈顶。**按数组顺序依次回滚**。
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
`opType: 'update'` 的步骤必须携带 `changeRecords` 才支持回滚(否则只能整节点替换,会冲掉后续无关变更);未应用(已被撤销)的步骤无法回滚。
|
||||||
|
:::
|
||||||
|
|
||||||
|
- **示例:**
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { editorService } from "@tmagic/editor";
|
||||||
|
|
||||||
|
// 执行操作时拿到本次历史记录 uuid
|
||||||
|
const { historyIds } = await editorService.addAndGetHistoryId({ type: "text", text: "hello" });
|
||||||
|
|
||||||
|
// 之后任意时机按 uuid 回滚(支持单个或整批 historyIds)
|
||||||
|
await editorService.revertPageStepById(historyIds);
|
||||||
|
```
|
||||||
|
|
||||||
## undo
|
## undo
|
||||||
|
|
||||||
- **[扩展支持](../../guide/editor-expand#行为扩展):** 是
|
- **[扩展支持](../../guide/editor-expand#行为扩展):** 是
|
||||||
@ -604,8 +862,14 @@ alignCenter可以支持一次水平居中多个组件,alignCenter是通过调
|
|||||||
::: details 查看 StepValue 及关联类型定义
|
::: details 查看 StepValue 及关联类型定义
|
||||||
<<< @/../packages/editor/src/type.ts#StepValue{ts}
|
<<< @/../packages/editor/src/type.ts#StepValue{ts}
|
||||||
|
|
||||||
|
<<< @/../packages/editor/src/type.ts#BaseStepValue{ts}
|
||||||
|
|
||||||
|
<<< @/../packages/editor/src/type.ts#StepExtra{ts}
|
||||||
|
|
||||||
<<< @/../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}
|
||||||
:::
|
:::
|
||||||
|
|
||||||
@ -620,6 +884,20 @@ alignCenter可以支持一次水平居中多个组件,alignCenter是通过调
|
|||||||
- **返回:**
|
- **返回:**
|
||||||
- {Promise<`StepValue` | null>}
|
- {Promise<`StepValue` | null>}
|
||||||
|
|
||||||
|
::: details 查看 StepValue 及关联类型定义
|
||||||
|
<<< @/../packages/editor/src/type.ts#StepValue{ts}
|
||||||
|
|
||||||
|
<<< @/../packages/editor/src/type.ts#BaseStepValue{ts}
|
||||||
|
|
||||||
|
<<< @/../packages/editor/src/type.ts#StepExtra{ts}
|
||||||
|
|
||||||
|
<<< @/../packages/editor/src/type.ts#HistoryOpType{ts}
|
||||||
|
|
||||||
|
<<< @/../packages/editor/src/type.ts#HistoryOpSource{ts}
|
||||||
|
|
||||||
|
<<< @/../packages/schema/src/index.ts#Id{ts}
|
||||||
|
:::
|
||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|
||||||
恢复到下一步
|
恢复到下一步
|
||||||
@ -631,6 +909,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>}`
|
||||||
@ -659,45 +941,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可以用于修改返回的值
|
||||||
|
|
||||||
|
|||||||
@ -1,31 +1,122 @@
|
|||||||
# historyService事件
|
# historyService事件
|
||||||
|
|
||||||
## page-change
|
|
||||||
|
|
||||||
- **详情:** 页面切换
|
|
||||||
|
|
||||||
- **事件回调函数:** `(undoRedo: UndoRedo) => void`
|
|
||||||
|
|
||||||
::: details 查看 UndoRedo 类定义
|
|
||||||
<<< @/../packages/editor/src/utils/undo-redo.ts#UndoRedo{ts}
|
|
||||||
:::
|
|
||||||
|
|
||||||
## change
|
## change
|
||||||
|
|
||||||
- **详情:** 历史记录发生变化
|
- **详情:** 页面历史记录发生变化(`page` 类型 `push` / `undo` / `redo` 成功时触发;与 `code-block-history-change` / `data-source-history-change` 同构)
|
||||||
|
|
||||||
- **事件回调函数:** `(state: StepValue | null) => void`
|
- **事件回调函数:** `(pageId: Id, step: StepValue) => void`
|
||||||
|
|
||||||
::: details 查看 StepValue 及关联类型定义
|
::: details 查看 StepValue 及关联类型定义
|
||||||
<<< @/../packages/editor/src/type.ts#StepValue{ts}
|
<<< @/../packages/editor/src/type.ts#StepValue{ts}
|
||||||
|
|
||||||
|
<<< @/../packages/editor/src/type.ts#BaseStepValue{ts}
|
||||||
|
|
||||||
|
<<< @/../packages/editor/src/type.ts#StepExtra{ts}
|
||||||
|
|
||||||
<<< @/../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}
|
||||||
:::
|
:::
|
||||||
|
|
||||||
:::tip
|
:::tip
|
||||||
当游标处于历史栈边界(已经无法继续撤销或重做)时,`UndoRedo.undo()` / `redo()` 返回 `null`,对应 `change` 回调收到的 `state` 为 `null`
|
回调签名已与其它历史类型统一为 `(id, step)`。当游标处于历史栈边界(已无法继续撤销 / 重做)时 `undo` / `redo` 返回 `null`,此时不会触发该事件。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## marker-change
|
||||||
|
|
||||||
|
- **详情:** 通过 [`setMarker`](./historyServiceMethods.md#setmarker) 为某个历史栈种入 `initial` 基线时触发(适用于所有类型)
|
||||||
|
|
||||||
|
- **事件回调函数:** `(id: Id, marker: StepValue, stepType: HistoryStepType) => void`
|
||||||
|
|
||||||
|
## clear
|
||||||
|
|
||||||
|
- **详情:** 调用 [`clear`](./historyServiceMethods.md#clear) 清空历史栈时触发(适用于所有类型)
|
||||||
|
|
||||||
|
- **事件回调函数:** `(id: Id | undefined, stepType: HistoryStepType) => void`
|
||||||
|
|
||||||
|
:::tip
|
||||||
|
`id` 缺省(清空 `stepType` 下全部栈)时回调的 `id` 为 `undefined`。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## code-block-history-change
|
||||||
|
|
||||||
|
- **详情:** 代码块历史记录发生变化(`push('codeBlock', step, codeBlockId)` / `undo('codeBlock', id)` / `redo('codeBlock', id)` 成功时触发)
|
||||||
|
|
||||||
|
- **事件回调函数:** `(codeBlockId: Id, step: CodeBlockStepValue) => void`
|
||||||
|
|
||||||
|
::: details 查看 CodeBlockStepValue 及关联类型定义
|
||||||
|
<<< @/../packages/editor/src/type.ts#CodeBlockStepValue{ts}
|
||||||
|
|
||||||
|
<<< @/../packages/editor/src/type.ts#BaseStepValue{ts}
|
||||||
|
|
||||||
|
<<< @/../packages/editor/src/type.ts#HistoryOpSource{ts}
|
||||||
|
|
||||||
|
<<< @/../packages/schema/src/index.ts#CodeBlockContent{ts}
|
||||||
|
|
||||||
|
<<< @/../packages/schema/src/index.ts#Id{ts}
|
||||||
|
:::
|
||||||
|
|
||||||
|
:::tip
|
||||||
|
- 新增触发的 step 其 diff 项 `oldSchema` 为 `null`
|
||||||
|
- 删除触发的 step 其 diff 项 `newSchema` 为 `null`
|
||||||
|
- `undo` / `redo` 返回 `null`(边界状态)时不会触发该事件
|
||||||
|
:::
|
||||||
|
|
||||||
|
## data-source-history-change
|
||||||
|
|
||||||
|
- **详情:** 数据源历史记录发生变化(`push('dataSource', step, dataSourceId)` / `undo('dataSource', id)` / `redo('dataSource', id)` 成功时触发)
|
||||||
|
|
||||||
|
- **事件回调函数:** `(dataSourceId: Id, step: DataSourceStepValue) => void`
|
||||||
|
|
||||||
|
::: details 查看 DataSourceStepValue 及关联类型定义
|
||||||
|
<<< @/../packages/editor/src/type.ts#DataSourceStepValue{ts}
|
||||||
|
|
||||||
|
<<< @/../packages/editor/src/type.ts#BaseStepValue{ts}
|
||||||
|
|
||||||
|
<<< @/../packages/editor/src/type.ts#HistoryOpSource{ts}
|
||||||
|
|
||||||
|
<<< @/../packages/schema/src/index.ts#Id{ts}
|
||||||
|
:::
|
||||||
|
|
||||||
|
:::tip
|
||||||
|
- 新增触发的 step 其 diff 项 `oldSchema` 为 `null`
|
||||||
|
- 删除触发的 step 其 diff 项 `newSchema` 为 `null`
|
||||||
|
- `undo` / `redo` 返回 `null`(边界状态)时不会触发该事件
|
||||||
|
:::
|
||||||
|
|
||||||
|
## mark-saved
|
||||||
|
|
||||||
|
- **详情:** 调用 [`markSaved`](./historyServiceMethods.md#marksaved) 标记「已保存」记录时触发
|
||||||
|
|
||||||
|
- **事件回调函数:** `(payload: { kind: 'all' | HistoryStepType; id?: Id }) => void`
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
- `markSaved(stepType)`(缺省 id)触发时 `kind` 为 `all`,无 `id`(此时 `stepType` 不生效)
|
||||||
|
- `markSaved(stepType, id)` 触发时 `kind` 为对应的 `stepType`(`page` / `codeBlock` / `dataSource` / 扩展),`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,78 +4,286 @@
|
|||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|
||||||
重置记录
|
重置全部历史记录:清空 `state.steps` 下的页面 / 代码块 / 数据源 / 扩展类型全部栈(保留已注册的扩展类型键)。
|
||||||
|
|
||||||
## resetPage
|
|
||||||
|
|
||||||
- **详情:**
|
|
||||||
|
|
||||||
重置当前页面的历史记录状态(清空当前页面id,重置 canRedo/canUndo)
|
|
||||||
|
|
||||||
## resetState
|
## resetState
|
||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|
||||||
重置历史记录全部内部状态(清空 pageId、pageSteps、canRedo、canUndo)
|
同 [`reset`](#reset),清空 `state.steps` 下全部栈。
|
||||||
|
|
||||||
## changePage
|
::: tip
|
||||||
|
历史服务不再维护「当前活动页」状态(已移除 `state.pageId` / `state.canUndo` / `state.canRedo`)。
|
||||||
|
活动页由 `editorService` 维护,撤销 / 重做 / 读取页面历史时请显式传入 pageId。
|
||||||
|
是否可撤销 / 重做请改用 [`canUndo`](#canundo) / [`canRedo`](#canredo)。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## registerStepType
|
||||||
|
|
||||||
- **参数:**
|
- **参数:**
|
||||||
- `{MPage | MPageFragment} page`
|
- `{string} stepType` 自定义历史类型标识(勿与内置 `page` / `codeBlock` / `dataSource` 重名)
|
||||||
|
- `{Object} options` 可选
|
||||||
::: details 查看 MPage / MPageFragment 类型定义
|
- `{string} event` push / undo / redo 后派发的事件名;缺省为 `${stepType}-history-change`
|
||||||
<<< @/../packages/schema/src/index.ts#MPage{ts}
|
- `{string} name` 历史面板中的展示名称(tab / 分组标题等);缺省回退到 stepType 本身
|
||||||
|
|
||||||
<<< @/../packages/schema/src/index.ts#MPageFragment{ts}
|
|
||||||
:::
|
|
||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|
||||||
按页面切换历史堆栈
|
注册一个扩展历史类型,使其可与内置 `page` / `codeBlock` / `dataSource` 一样走统一的
|
||||||
|
[`push`](#push) / [`undo`](#undo) / [`redo`](#redo)(按 id 分栈、独立 undo/redo)。
|
||||||
|
注册后该类型的栈存放在 `historyService.state.steps[stepType]`,展示名称存放在 `historyService.state.stepNames[stepType]`。
|
||||||
|
|
||||||
|
## getStepName
|
||||||
|
|
||||||
|
- **参数:**
|
||||||
|
- `{HistoryStepType} stepType` 历史类型
|
||||||
|
|
||||||
|
- **返回:**
|
||||||
|
- `{string}` 该类型的展示名称(用于历史面板 tab / 分组标题等);未登记时回退到 stepType 本身
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
读取指定历史类型的展示名称。内置 `page` / `codeBlock` / `dataSource` 默认分别为「页面 / 代码块 / 数据源」。
|
||||||
|
|
||||||
|
## setStepName
|
||||||
|
|
||||||
|
- **参数:**
|
||||||
|
- `{HistoryStepType} stepType` 历史类型
|
||||||
|
- `{string} name` 展示名称
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
设置指定历史类型的展示名称(写入 `historyService.state.stepNames`,历史面板会响应式刷新)。
|
||||||
|
内置 `page` / `codeBlock` / `dataSource` 也可在此覆盖默认中文名。
|
||||||
|
|
||||||
## push
|
## push
|
||||||
|
|
||||||
- **参数:**
|
- **参数:**
|
||||||
- `{StepValue} state`
|
- `{HistoryStepType} stepType` 历史类型,内置 `'page'` / `'codeBlock'` / `'dataSource'`,并支持通过 [`registerStepType`](#registersteptype) 扩展
|
||||||
|
- `{StepValue | BaseStepValue} step` 已构造好的历史记录(缺省自动补全 `uuid` / `timestamp`)
|
||||||
|
- `{Id} id` 必填;目标栈 id(`page` 为 pageId,其余类型为对应资源 id)
|
||||||
|
|
||||||
::: details 查看 StepValue 及关联类型定义
|
::: details 查看 StepValue 及关联类型定义
|
||||||
<<< @/../packages/editor/src/type.ts#StepValue{ts}
|
<<< @/../packages/editor/src/type.ts#StepValue{ts}
|
||||||
|
|
||||||
|
<<< @/../packages/editor/src/type.ts#BaseStepValue{ts}
|
||||||
|
|
||||||
|
<<< @/../packages/editor/src/type.ts#StepExtra{ts}
|
||||||
|
|
||||||
<<< @/../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}
|
||||||
:::
|
:::
|
||||||
|
|
||||||
- **返回:**
|
- **返回:**
|
||||||
- `{StepValue | null}`
|
- `{StepValue | BaseStepValue | null}` 入栈失败(未传 / 无效 id)时返回 `null`
|
||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|
||||||
添加一条历史记录
|
添加一条历史记录。统一入口,所有类型(`page` / `codeBlock` / `dataSource` / 扩展)行为完全一致:按 `stepType` 选择目标栈类型、按 `id`(必填)选择具体栈,按需建栈后入栈,并派发对应的历史变更事件(`page` 为 `change`,其余如 `code-block-history-change` / `data-source-history-change`),回调签名统一为 `(id, step)`。
|
||||||
|
|
||||||
|
跨页 / 跨资源操作(如把节点搬到其它页)必须显式传入目标 id。`codeBlock` / `dataSource` 的 step 通常由 `createStackStep` 等工具按 `oldValue` / `newValue` 构造后传入。
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
`opType: 'update'` 的每个 diff 项上可携带 `changeRecords`,用于撤销 / 重做时仅按
|
||||||
|
`propPath` 局部更新对应字段,避免整节点替换冲掉同节点上的其它无关变更;不带
|
||||||
|
`changeRecords` 时退化为整节点替换(如 `sort` / `moveLayer` / 拖动等纯快照场景)。
|
||||||
|
|
||||||
|
`step` 上的 `historyDescription` / `source` 仅用于历史面板展示与埋点,不影响 undo/redo 行为。
|
||||||
|
|
||||||
|
入栈时会为每条记录自动生成唯一标识 `uuid`(调用方未指定时),可用于精确引用 / 定位某一条历史记录。
|
||||||
|
若需要在执行 DSL 操作后拿到本次写入记录的 `uuid`,可使用 editorService / dataSourceService /
|
||||||
|
codeBlockService 提供的 `*AndGetHistoryId` 方法,参见
|
||||||
|
[editorService 历史记录 uuid 与 \*AndGetHistoryId](./editorServiceMethods.md#历史记录-uuid-与-andgethistoryid)。
|
||||||
|
:::
|
||||||
|
|
||||||
## undo
|
## undo
|
||||||
|
|
||||||
|
- **参数:**
|
||||||
|
- `{HistoryStepType} stepType` 历史类型
|
||||||
|
- `{Id} id` 必填;目标栈 id(`page` 为 pageId,其余类型为对应资源 id)
|
||||||
|
|
||||||
- **返回:**
|
- **返回:**
|
||||||
- `{StepValue | null}`
|
- `{StepValue | BaseStepValue | null}`
|
||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|
||||||
撤销当前操作
|
撤销指定历史栈的最近一次变更。所有类型行为一致:按 `stepType` + `id` 定位栈,不会越过 index 0 的 initial 基线(所有类型同等适用,见 [`setMarker`](#setmarker)),仅在确有可撤销 step 时派发对应的历史变更事件(`page` 为 `change`,回调签名 `(id, step)`)。
|
||||||
|
|
||||||
|
`page` 类型 `opType: 'update'` 时,若 diff 项的 `changeRecords` 存在,会按 `propPath` 从 `oldSchema` 取值做局部回滚;否则用 `oldSchema` 整节点替换。`codeBlock` / `dataSource` 拿到 step 后由调用方写回对应 service(本方法不会自动回放)。
|
||||||
|
|
||||||
## redo
|
## redo
|
||||||
|
|
||||||
|
- **参数:**
|
||||||
|
- `{HistoryStepType} stepType` 历史类型
|
||||||
|
- `{Id} id` 必填;目标栈 id(`page` 为 pageId,其余类型为对应资源 id)
|
||||||
|
|
||||||
- **返回:**
|
- **返回:**
|
||||||
- `{StepValue | null}`
|
- `{StepValue | BaseStepValue | null}`
|
||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|
||||||
恢复到下一步
|
恢复指定历史栈到下一步,语义与 [`undo`](#undo) 对称。`page` 类型 `opType: 'update'` 时,若 diff 项的 `changeRecords` 存在,会按 `propPath` 从 `newSchema` 取值做局部重做;否则用 `newSchema` 整节点替换。
|
||||||
|
|
||||||
|
## canUndo
|
||||||
|
|
||||||
|
- **参数:**
|
||||||
|
- `{HistoryStepType} stepType` 历史类型
|
||||||
|
- `{Id} id` 可选;目标栈 id;缺省 / 无效时返回 `false`
|
||||||
|
|
||||||
|
- **返回:**
|
||||||
|
- `{boolean}`
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
指定历史栈当前是否可撤销(游标高于 index 0 的 initial 基线底线)。适用于所有类型(`page` / `codeBlock` / `dataSource` / 扩展)。
|
||||||
|
|
||||||
|
## canRedo
|
||||||
|
|
||||||
|
- **参数:**
|
||||||
|
- `{HistoryStepType} stepType` 历史类型
|
||||||
|
- `{Id} id` 可选;目标栈 id;缺省 / 无效时返回 `false`
|
||||||
|
|
||||||
|
- **返回:**
|
||||||
|
- `{boolean}`
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
指定历史栈当前是否可重做。适用于所有类型(`page` / `codeBlock` / `dataSource` / 扩展)。
|
||||||
|
|
||||||
|
## setMarker
|
||||||
|
|
||||||
|
- **参数:**
|
||||||
|
- `{HistoryStepType} stepType` 历史类型
|
||||||
|
- `{Id} id` 目标栈 id(`page` 为 pageId,其余类型为对应资源 id)
|
||||||
|
- `{Object} options` 可选:`name` / `description` / `source`,用于基线的展示信息
|
||||||
|
|
||||||
|
- **返回:**
|
||||||
|
- `{StepValue | null}` 已存在基线时返回原基线;栈非空(无基线)或 id 无效时返回 `null`
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
为指定历史栈种入一条 `opType: 'initial'` 的「初始基线」记录,作为该栈 index 0 的固定底线:它是真实入栈并随栈持久化的 step,但被钉为撤销 / 回滚的下限,`undo` / `goto` / `revert` 都不会越过它。所有类型(含扩展类型)均可设置基线,仅当目标栈为空时种入。
|
||||||
|
|
||||||
|
## getMarker
|
||||||
|
|
||||||
|
- **参数:**
|
||||||
|
- `{HistoryStepType} stepType` 历史类型
|
||||||
|
- `{Id} id` 可选;目标栈 id;缺省 / 无效时返回 `undefined`
|
||||||
|
|
||||||
|
- **返回:**
|
||||||
|
- `{StepValue | undefined}`
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
读取指定历史栈的初始基线 step(栈 index 0 且 `opType: 'initial'`),不存在时返回 `undefined`。
|
||||||
|
|
||||||
|
## markSaved
|
||||||
|
|
||||||
|
- **参数:**
|
||||||
|
- `{HistoryStepType} stepType` 历史类型,内置另有 `'codeBlock'` / `'dataSource'`,并支持扩展(仅在传入 `id` 时生效)
|
||||||
|
- `{Id} id` 可选;目标栈 id。缺省表示标记全部类型、全部栈
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
标记历史记录为「已保存」(把对应栈当前游标所在的记录标记为 `saved = true`)。统一入口:
|
||||||
|
|
||||||
|
- **缺省 `id`**:标记「整份 DSL 已保存」——把所有类型、所有栈当前游标所在的记录都标记为已保存(此时 `stepType` 不生效),触发 `mark-saved` 事件且 `{ kind: 'all' }`。通常在 DSL 整体落库成功后调用。
|
||||||
|
- **传入 `id`**:仅标记 `stepType` 下该 id 对应的栈,触发 `mark-saved` 事件且 `{ kind: stepType, id }`(如 `{ kind: 'page', id }` / `{ kind: 'codeBlock', id }` / `{ kind: 'dataSource', id }`)。
|
||||||
|
|
||||||
|
同一栈内任意时刻最多保留一条已保存记录(标记前会清除该栈内全部旧标记);某个栈处于「全部已撤销」(cursor 为 0)时不会留下已保存记录,从 IndexedDB 恢复时其游标会回到 0。配合 [`restoreFromIndexedDB`](#restorefromindexeddb) 把游标恢复到此处。
|
||||||
|
|
||||||
|
## clear
|
||||||
|
|
||||||
|
- **参数:**
|
||||||
|
- `{HistoryStepType} stepType` 历史类型,内置另有 `'codeBlock'` / `'dataSource'`,并支持扩展
|
||||||
|
- `{Id} id` 可选;目标栈 id。缺省表示清空 `stepType` 下的全部栈
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
清空历史记录栈。统一入口,所有类型(page / codeBlock / dataSource / 扩展)行为一致:
|
||||||
|
|
||||||
|
- **传入 `id`**:仅清空 `stepType` 下该 id 对应的栈;
|
||||||
|
- **缺省 `id`**:清空 `stepType` 下的全部栈。
|
||||||
|
|
||||||
|
仅删除撤销/重做记录,不会改动 DSL / 代码块 / 数据源本身。清空时会**保留各栈原有的 initial 基线**(文案 / 来源,见 [`setMarker`](#setmarker)),无基线时清空成空栈。清空后触发 `clear` 事件(签名 `(id, stepType)`)。
|
||||||
|
|
||||||
|
## 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 对齐;
|
||||||
|
- 会整体覆盖当前内存中的历史状态(活动页由 `editorService` 维护,不在此恢复);
|
||||||
|
- 找不到对应记录时返回 `null` 且不改动当前状态;不支持 IndexedDB 的环境会 reject。
|
||||||
|
|
||||||
|
成功后触发 `restore-from-indexed-db` 事件。
|
||||||
|
|
||||||
|
## findStepLocationByUuid
|
||||||
|
|
||||||
|
- **参数:**
|
||||||
|
- `{HistoryStepType} stepType` 历史类型
|
||||||
|
- `{string} uuid` 目标历史记录的 uuid
|
||||||
|
- `{Id} id` 可选;目标栈 id
|
||||||
|
|
||||||
|
- **返回:**
|
||||||
|
- `{ { id: Id; index: number } | null }` 找到时返回所属栈 id 与步骤索引;找不到时返回 `null`
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
按历史记录 uuid 在指定历史类型的栈中查找其所属 id 与索引,统一入口。
|
||||||
|
|
||||||
|
- **传入 `id`**:仅在该 id 对应的单个栈中查找(如页面历史按活动页查看,传入 pageId);
|
||||||
|
- **缺省 `id`**:遍历该类型下全部栈查找(代码块 / 数据源等按全部资源分桶的场景)。
|
||||||
|
|
||||||
|
供「按 uuid 回滚」等需要把 uuid 映射回 `(id, index)` 的场景使用,如 [editorService.revertPageStepById](./editorServiceMethods.md#revertpagestepbyid) / [codeBlockService.revertById](./codeBlockServiceMethods.md#revertbyid) / [dataSourceService.revertById](./dataSourceServiceMethods.md#revertbyid) 内部均通过本方法定位步骤。
|
||||||
|
|
||||||
## 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可以用于修改返回的值
|
||||||
|
|
||||||
|
|||||||
28
docs/api/editor/uiServiceEvents.md
Normal file
28
docs/api/editor/uiServiceEvents.md
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
# uiService事件
|
||||||
|
|
||||||
|
## state-change
|
||||||
|
|
||||||
|
- **详情:** UI 状态发生变化时触发,[uiService.set()](./uiServiceMethods.md#set) 在写入的新值与旧值不同时触发
|
||||||
|
|
||||||
|
- **事件回调函数:** `(name: keyof UiState, value: UiState[typeof name], preValue: UiState[typeof name]) => void`
|
||||||
|
|
||||||
|
::: details 查看 UiState 类型定义
|
||||||
|
<<< @/../packages/editor/src/type.ts#UiState{ts}
|
||||||
|
:::
|
||||||
|
|
||||||
|
- **示例:**
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { uiService } from '@tmagic/editor';
|
||||||
|
|
||||||
|
uiService.on('state-change', (name, value, preValue) => {
|
||||||
|
console.log(`${name} 从`, preValue, '变为', value);
|
||||||
|
});
|
||||||
|
|
||||||
|
uiService.set('zoom', 1.5);
|
||||||
|
```
|
||||||
|
|
||||||
|
:::tip
|
||||||
|
- 新值与旧值相同时不会触发该事件
|
||||||
|
- 通过 `set('stageRect', value)` 修改画布尺寸时,内部会走 `setStageRect` 逻辑并可能联动更新 `zoom`,但不会触发 `state-change` 事件
|
||||||
|
:::
|
||||||
@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|
||||||
设置UI服务的状态
|
设置UI服务的状态。新值与旧值不同时会触发 [`state-change`](./uiServiceEvents.md#state-change) 事件
|
||||||
|
|
||||||
可用的状态键:
|
可用的状态键:
|
||||||
- `uiSelectMode`: UI选择模式
|
- `uiSelectMode`: UI选择模式
|
||||||
@ -31,6 +31,7 @@
|
|||||||
- `showPageListButton`: 是否显示页面列表按钮
|
- `showPageListButton`: 是否显示页面列表按钮
|
||||||
- `hideSlideBar`: 是否隐藏侧边栏
|
- `hideSlideBar`: 是否隐藏侧边栏
|
||||||
- `sideBarItems`: 侧边栏项目
|
- `sideBarItems`: 侧边栏项目
|
||||||
|
- `sideBarActiveTabName`: 当前激活的侧边栏面板
|
||||||
- `navMenuRect`: 导航菜单尺寸
|
- `navMenuRect`: 导航菜单尺寸
|
||||||
- `frameworkRect`: 框架尺寸
|
- `frameworkRect`: 框架尺寸
|
||||||
|
|
||||||
@ -179,29 +180,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可以用于修改返回的值
|
||||||
|
|
||||||
|
|||||||
@ -150,6 +150,14 @@
|
|||||||
|
|
||||||
- **类型:** `boolean`
|
- **类型:** `boolean`
|
||||||
|
|
||||||
|
## useFieldTextInError
|
||||||
|
|
||||||
|
- **详情:** 透传给内部 `Form`,控制表单校验失败时错误提示前缀是否使用字段的 `text` 文案。`false` 时直接使用字段 `name`
|
||||||
|
|
||||||
|
- **默认值:** `true`
|
||||||
|
|
||||||
|
- **类型:** `boolean`
|
||||||
|
|
||||||
## closeOnClickModal
|
## closeOnClickModal
|
||||||
|
|
||||||
- **详情:** 是否可以通过点击 modal 关闭 Dialog
|
- **详情:** 是否可以通过点击 modal 关闭 Dialog
|
||||||
|
|||||||
@ -18,6 +18,8 @@
|
|||||||
|
|
||||||
- **详情:** 提交表单,先执行校验,校验通过后清空 `changeRecords` 并返回当前表单值
|
- **详情:** 提交表单,先执行校验,校验通过后清空 `changeRecords` 并返回当前表单值
|
||||||
|
|
||||||
|
- **相关:** 如果你想脱离组件树以函数方式完成一次表单提交,参见 [`submitForm` 函数](./submit-form.md)
|
||||||
|
|
||||||
## changeHandler
|
## changeHandler
|
||||||
|
|
||||||
- **签名:** `(prop: string, value: any, eventData?: ContainerChangeEventData) => void`
|
- **签名:** `(prop: string, value: any, eventData?: ContainerChangeEventData) => void`
|
||||||
@ -38,6 +40,8 @@
|
|||||||
|
|
||||||
- **详情:** 通过 `name` 从表单 `config` 中查找对应表单项的 `text`
|
- **详情:** 通过 `name` 从表单 `config` 中查找对应表单项的 `text`
|
||||||
|
|
||||||
|
- **相关:** 表单校验失败时是否使用该方法生成错误提示前缀,由 [`useFieldTextInError`](./form-props.md#usefieldtextinerror) prop 控制(默认 `true`)
|
||||||
|
|
||||||
## values
|
## values
|
||||||
|
|
||||||
- **类型:** `Ref<FormValue>`
|
- **类型:** `Ref<FormValue>`
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
- **详情:** 父级表单值
|
- **详情:** 父级表单值
|
||||||
@ -168,6 +206,19 @@
|
|||||||
|
|
||||||
- **类型:** `boolean`
|
- **类型:** `boolean`
|
||||||
|
|
||||||
|
## useFieldTextInError
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
表单校验失败时,错误提示前缀是否使用字段的 `text` 文案(通过 `getTextByName` 从 `config` 中查找)。
|
||||||
|
|
||||||
|
- `true`(默认):错误提示形如 `字段文案 -> 错误信息`,找不到 `text` 时回退为字段 `name`;
|
||||||
|
- `false`:跳过查找,直接使用字段 `name` 作为错误提示前缀(形如 `字段name -> 错误信息`)。
|
||||||
|
|
||||||
|
- **默认值:** `true`
|
||||||
|
|
||||||
|
- **类型:** `boolean`
|
||||||
|
|
||||||
## extendState
|
## extendState
|
||||||
|
|
||||||
- **详情:** 扩展 formState 的钩子函数,返回的对象会被合并到 formState 上
|
- **详情:** 扩展 formState 的钩子函数,返回的对象会被合并到 formState 上
|
||||||
|
|||||||
219
docs/api/form/submit-form.md
Normal file
219
docs/api/form/submit-form.md
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
# submitForm 函数
|
||||||
|
|
||||||
|
以命令式方式调用 `MForm` 组件完成一次表单校验/提交,类似 `ElMessage` 的用法。
|
||||||
|
|
||||||
|
调用时函数内部会临时挂载一个不可见的 `MForm` 实例,把入参作为 props 透传给它,等待初始化完成后调用其 `submitForm` 方法。校验通过则 `resolve` 表单值,校验失败则 `reject` 错误信息,最后自动卸载实例并清理 DOM。
|
||||||
|
|
||||||
|
适用于一些没有合适的容器、但又需要复用 `MForm` 校验逻辑的场景,例如:
|
||||||
|
|
||||||
|
- 通过快捷菜单/命令面板触发一次性表单
|
||||||
|
- 在脚本/服务层完成一次表单值校验后再发请求
|
||||||
|
- 把 `config` 配置当作"可执行的校验规则"使用
|
||||||
|
|
||||||
|
## 签名
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function submitForm(options: SubmitFormOptions): Promise<any>;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 参数
|
||||||
|
|
||||||
|
`options` 与 `MForm` 组件的 props 基本对齐,额外提供了 `native`、`returnChangeRecords`、`appContext`、`timeout` 等参数。
|
||||||
|
|
||||||
|
| 名称 | 类型 | 默认值 | 说明 |
|
||||||
|
| ---------------------- | ------------------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------- |
|
||||||
|
| `config` | `FormConfig` | — | 必填,表单配置 |
|
||||||
|
| `initValues` | `Record<string, any>` | `{}` | 表单初始值 |
|
||||||
|
| `lastValues` | `Record<string, any>` | `{}` | 需对比的值(开启对比模式时传入) |
|
||||||
|
| `isCompare` | `boolean` | `false` | 是否开启对比模式 |
|
||||||
|
| `parentValues` | `Record<string, any>` | `{}` | 父级 values,透传给字段的回调 |
|
||||||
|
| `labelWidth` | `string` | `'200px'` | label 宽度 |
|
||||||
|
| `disabled` | `boolean` | `false` | 是否禁用 |
|
||||||
|
| `height` | `string` | `'auto'` | 表单高度 |
|
||||||
|
| `stepActive` | `string \| number` | `1` | 步骤表单当前激活步骤 |
|
||||||
|
| `size` | `'small' \| 'default' \| 'large'` | — | 组件尺寸 |
|
||||||
|
| `inline` | `boolean` | `false` | 是否行内表单 |
|
||||||
|
| `labelPosition` | `string` | `'right'` | label 对齐方式 |
|
||||||
|
| `keyProp` | `string` | `'__key'` | 配置项的唯一 key |
|
||||||
|
| `popperClass` | `string` | — | 弹层 className |
|
||||||
|
| `preventSubmitDefault` | `boolean` | — | 是否阻止表单原生 submit |
|
||||||
|
| `useFieldTextInError` | `boolean` | `true` | 校验失败时错误提示前缀是否使用字段的 `text` 文案;`false` 时直接使用字段 `name` |
|
||||||
|
| `extendState` | `(state: FormState) => Record<string, any> \| Promise<Record<string, any>>` | — | 扩展 `formState` |
|
||||||
|
| `native` | `boolean` | `false` | 透传给 `Form.submitForm`。`true` 时返回内部响应式 `values`,否则返回 `cloneDeep(toRaw(values))` |
|
||||||
|
| `returnChangeRecords` | `boolean` | `false` | `true` 时 resolve 结果为 `{ values, changeRecords }`,携带表单变更记录;否则仅 resolve `values` |
|
||||||
|
| `appContext` | `AppContext \| null` | `null` | 父级 Vue 应用上下文。需要继承全局组件、指令、provide 等时传入,常通过 `app._context` 或 `getCurrentInstance()?.appContext` 获取 |
|
||||||
|
| `timeout` | `number` | `10000` | 等待表单初始化的最长时间(毫秒)。超时将以错误 reject。设为 `<= 0` 时关闭超时兜底 |
|
||||||
|
|
||||||
|
## 返回值
|
||||||
|
|
||||||
|
- `校验通过` — `Promise<any>` resolve 当前表单值(`native` 决定是否克隆);当 `returnChangeRecords` 为 `true` 时,resolve `{ values, changeRecords }`
|
||||||
|
- `校验失败` — `Promise<any>` reject 一个 `Error`,`message` 中包含逐条字段错误信息(格式 `${text} -> ${message}`,多条用 `<br>` 分隔)
|
||||||
|
- `初始化超时` — `Promise<any>` reject `Error('submitForm timeout after ${timeout}ms: form is not initialized.')`
|
||||||
|
|
||||||
|
无论成功或失败,函数都会在最后自动 `unmount` 内部 app 并移除挂载用的 DOM 容器,无需调用方手动清理。
|
||||||
|
|
||||||
|
::: tip 关于 changeRecords
|
||||||
|
`changeRecords` 记录的是表单挂载后发生的字段变更(由各字段的 `change` 事件累积而来)。在 `submitForm` 这种命令式、无用户交互的场景下,通常为空数组;只有在 `extendState` 或字段联动等逻辑中触发了变更时才会有内容。`MForm` 内部的 `submitForm` 在校验通过后会清空变更记录,因此本函数会在调用前先对其做快照再返回。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 基础用法
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { submitForm } from '@tmagic/form';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const values = await submitForm({
|
||||||
|
config: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
name: 'username',
|
||||||
|
text: '用户名',
|
||||||
|
rules: [{ required: true, message: '请输入用户名' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
initValues: { username: '' },
|
||||||
|
});
|
||||||
|
console.log('提交成功', values);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('校验失败', e);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 同时获取变更记录(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` 把父级应用上下文带过去:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { getCurrentInstance } from 'vue';
|
||||||
|
|
||||||
|
import { submitForm } from '@tmagic/form';
|
||||||
|
|
||||||
|
const { appContext } = getCurrentInstance()!;
|
||||||
|
|
||||||
|
const onClick = async () => {
|
||||||
|
const values = await submitForm({
|
||||||
|
config: [{ type: 'text', name: 'text', text: '文本' }],
|
||||||
|
initValues: { text: 'hello' },
|
||||||
|
appContext,
|
||||||
|
});
|
||||||
|
console.log(values);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
也可以在初始化 app 时把上下文缓存下来,再在任意位置复用:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { createApp } from 'vue';
|
||||||
|
import ElementPlus from 'element-plus';
|
||||||
|
import MagicForm, { type SubmitFormOptions, submitForm as rawSubmitForm } from '@tmagic/form';
|
||||||
|
|
||||||
|
import App from './App.vue';
|
||||||
|
|
||||||
|
const app = createApp(App);
|
||||||
|
app.use(ElementPlus);
|
||||||
|
app.use(MagicForm);
|
||||||
|
app.mount('#app');
|
||||||
|
|
||||||
|
export const submitForm = (options: Omit<SubmitFormOptions, 'appContext'>) =>
|
||||||
|
rawSubmitForm({ ...options, appContext: app._context });
|
||||||
|
```
|
||||||
|
|
||||||
|
## 处理校验错误
|
||||||
|
|
||||||
|
校验失败时 reject 的 `Error.message` 已经把出错字段拼好,可以直接展示到用户:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { tMagicMessage } from '@tmagic/design';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const values = await submitForm({ config, initValues });
|
||||||
|
await save(values);
|
||||||
|
} catch (e: any) {
|
||||||
|
tMagicMessage.error({
|
||||||
|
dangerouslyUseHTMLString: true,
|
||||||
|
message: e.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 运行环境
|
||||||
|
|
||||||
|
`submitForm` 内部依赖 `document` / `window` 来挂载临时 Vue 实例,因此**只能在浏览器或具备 DOM 环境的运行时中使用**。
|
||||||
|
|
||||||
|
| 环境 | 是否可用 | 说明 |
|
||||||
|
| ----------------------------------------------- | -------- | --------------------------------------------------------------------------------- |
|
||||||
|
| 浏览器 / Electron 渲染进程 / 浏览器扩展 | ✅ | 直接可用 |
|
||||||
|
| Vitest / Jest + `happy-dom` / `jsdom` | ✅ | 项目自身的单测就跑在这种环境下 |
|
||||||
|
| 纯 Node.js / Bun / Deno(无 DOM polyfill) | ❌ | 模块顶层就会读 `document`,会抛 `document is not defined` |
|
||||||
|
| Node.js + 手动注入 `happy-dom` / `jsdom` | ⚠️ | 可用,需要在 import `@tmagic/form` **之前**完成全局变量注入;校验行为不一定与浏览器完全一致 |
|
||||||
|
|
||||||
|
### 在 Node.js 中使用(需要先准备 DOM)
|
||||||
|
|
||||||
|
下面是一个在 Node 脚本里调用 `submitForm` 的完整例子,使用 [`happy-dom`](https://github.com/capricorn86/happy-dom) 作为 DOM polyfill:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// scripts/check-form.ts
|
||||||
|
import { Window } from 'happy-dom';
|
||||||
|
|
||||||
|
const window = new Window();
|
||||||
|
Object.assign(globalThis, {
|
||||||
|
window,
|
||||||
|
document: window.document,
|
||||||
|
navigator: window.navigator,
|
||||||
|
HTMLElement: window.HTMLElement,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 注意:DOM polyfill 必须先注入到 globalThis,再用动态 import
|
||||||
|
// 加载业务模块,否则 @tmagic/design 等模块顶层执行时就会读 document
|
||||||
|
const { createApp } = await import('vue');
|
||||||
|
const ElementPlus = (await import('element-plus')).default;
|
||||||
|
const MagicForm = (await import('@tmagic/form')).default;
|
||||||
|
const { submitForm } = await import('@tmagic/form');
|
||||||
|
|
||||||
|
const parentApp = createApp({ render: () => null });
|
||||||
|
parentApp.use(ElementPlus);
|
||||||
|
parentApp.use(MagicForm);
|
||||||
|
|
||||||
|
const values = await submitForm({
|
||||||
|
config: [{ type: 'text', name: 'username', text: '用户名' }],
|
||||||
|
initValues: { username: 'foo' },
|
||||||
|
appContext: parentApp._context,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(values);
|
||||||
|
```
|
||||||
|
|
||||||
|
::: warning 注意
|
||||||
|
- DOM polyfill 必须在 **import 业务模块之前** 注入到 `globalThis`,否则模块顶层执行时仍会失败
|
||||||
|
- 在 `happy-dom` / `jsdom` 中,`element-plus` 的部分 `validate()` 行为不一定能 1:1 复现真实浏览器(例如某些场景下必填规则可能不触发),建议关键校验使用自定义 `validator` 函数确保稳定
|
||||||
|
- 如果只是想在 Node 端做一次纯校验,更稳妥的做法是直接复用 [`async-validator`](https://github.com/yiminghe/async-validator)(element-plus 内部用的就是它),绕开整个 Vue 渲染层
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 类型定义
|
||||||
|
|
||||||
|
::: details 查看 `SubmitFormOptions` 类型定义
|
||||||
|
<<< @/../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="[{
|
||||||
|
|||||||
173
docs/guide/advanced/history-list.md
Normal file
173
docs/guide/advanced/history-list.md
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
# 历史记录面板
|
||||||
|
|
||||||
|
编辑器内置了一个可视化的「历史记录面板」,用于查看与回溯编辑过程中产生的所有操作。相比顶部菜单栏只能「撤销 / 重做」相邻一步,历史记录面板提供了对整条历史栈的全局视角:可以按页面、数据源、代码块分类浏览,点击任意一步直接跳转,查看每一步的前后差异,甚至像 `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(见 [uuid 与 *AndGetHistoryId](/api/editor/editorServiceMethods.md#历史记录-uuid-与-andgethistoryid) 返回的 `historyIds`),也可以直接按 uuid 回滚(无需再关心 index / id,且 uuid 不会随栈内步骤增删而变化):
|
||||||
|
|
||||||
|
- 页面:`editorService.revertPageStepById(uuids)`
|
||||||
|
- 数据源:`dataSourceService.revertById(uuids)`
|
||||||
|
- 代码块:`codeBlockService.revertById(uuids)`
|
||||||
|
|
||||||
|
#### 复用面板的「交互式回滚 / 查看差异」流程
|
||||||
|
|
||||||
|
上面的 `revert*` 方法是**静默**的:它们直接执行反向应用,不做任何校验与二次确认。如果业务方想在自己的入口(自定义按钮、右键菜单等)里复用历史面板那一套**完整交互流程**——即「目标数据已删除的前置校验 + 失败提示」「可差异步骤弹差异确认弹窗 / 其余步骤弹普通二次确认框」「用户确认后才回滚」,以及「查看差异」弹窗——可以直接 import `useHistoryRevert`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { useHistoryRevert } from '@tmagic/editor';
|
||||||
|
|
||||||
|
// editorRef 为 <m-editor> 组件实例,其 expose 出的即各 service 集合(Services)
|
||||||
|
const {
|
||||||
|
onPageRevert,
|
||||||
|
onDataSourceRevert,
|
||||||
|
onCodeBlockRevert,
|
||||||
|
onPageDiff,
|
||||||
|
onDataSourceDiff,
|
||||||
|
onCodeBlockDiff,
|
||||||
|
} = useHistoryRevert({}, editorRef.value);
|
||||||
|
|
||||||
|
// 回滚:可差异步骤弹出差异确认弹窗、其余步骤弹普通二次确认框;用户点「确定」后回滚第 index 步,
|
||||||
|
// 命中前置校验或用户取消时不执行,返回 null
|
||||||
|
await onPageRevert(index);
|
||||||
|
await onDataSourceRevert(id, index);
|
||||||
|
await onCodeBlockRevert(id, index);
|
||||||
|
|
||||||
|
// 查看差异:可差异步骤弹出只读差异弹窗展示前后值差异,无可对比内容时不弹窗
|
||||||
|
onPageDiff(index);
|
||||||
|
onDataSourceDiff(id, index);
|
||||||
|
onCodeBlockDiff(id, index);
|
||||||
|
```
|
||||||
|
|
||||||
|
回滚确认弹窗与查看差异弹窗均由 `useHistoryRevert` 内部**按需动态挂载** `HistoryDiffDialog` 实现,业务方无需自行挂载任何弹窗组件。第二个参数 `options` 可选:
|
||||||
|
|
||||||
|
| 字段 | 必填 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `appContext` | 否 | 父级应用上下文,用于让动态挂载的差异确认弹窗继承全局组件 / 指令 / provide / 插件(Element Plus、`@tmagic/form` 字段组件等)。在组件 `setup` 中调用时会自动取当前组件的 `appContext`,无需手动传;仅当在组件 setup 之外调用时才需显式传入(如 `editorApp._context`)。 |
|
||||||
|
| `extendState` | 否 | 透传给差异确认弹窗的 `extendState`(同 Editor 的 [`extendFormState`](#自定义对比判断)),使对比表单中依赖业务上下文的 `display` / `disabled` 等 `filterFunction` 正常工作。 |
|
||||||
|
| `dialogWidth` | 否 | 内置页面 / 数据源 / 代码块的差异 / 回滚确认弹窗默认宽度(透传给 `TMagicDialog` 的 `width`),如 `'1200px'` / `'80%'`。缺省时使用弹窗内置默认宽度(`900px`)。业务自有历史可在 `viewDiff` / `confirmAndRevert` 调用时通过各自入参的 `width` 单独覆盖。 |
|
||||||
|
|
||||||
|
> 若只需要无确认、无校验的静默回滚,直接用上面的 `editorService.revertPageStep` 等即可,无需 `useHistoryRevert`。
|
||||||
|
|
||||||
|
### 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)。
|
||||||
@ -349,6 +349,43 @@ export default {
|
|||||||
lib: 'always',
|
lib: 'always',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
/**
|
||||||
|
* 禁止匿名 default class / default function 导出
|
||||||
|
* @reason 匿名 default 导出在 dts 聚合(rolldown / api-extractor / vue-tsc 等)时会被命名为
|
||||||
|
* `export_default`,导致跨包继承链在 .vue / .tsx 文件下解析失败,
|
||||||
|
* 父类成员(如 EventEmitter 的 on/off)无法被 ts-plugin 推断出来。
|
||||||
|
* 必须使用具名形式:先 `export class Foo {}` 再 `export default Foo;`,
|
||||||
|
* 或 `export default class Foo {}`,确保类型聚合后保留原标识符。
|
||||||
|
*
|
||||||
|
* 注:此处需要重申 base.mjs 中已有的 no-restricted-syntax 选择器
|
||||||
|
* (ForIn / Labeled / With),否则在 .ts/.tsx 下会被本规则整体覆盖。
|
||||||
|
*/
|
||||||
|
'no-restricted-syntax': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
selector: 'ExportDefaultDeclaration > ClassDeclaration[id=null]',
|
||||||
|
message:
|
||||||
|
'禁止匿名 default class 导出。请改为具名形式(如 `export default class Foo extends Bar {}`),否则聚合 dts 会丢失类型信息,导致跨包继承的成员(on/off/emit 等)无法被推断。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: 'ExportDefaultDeclaration > FunctionDeclaration[id=null]',
|
||||||
|
message:
|
||||||
|
'禁止匿名 default function 导出。请改为具名形式(如 `export default function foo() {}`),便于 dts 聚合保留原标识符与跨包类型推断。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: 'ForInStatement',
|
||||||
|
message:
|
||||||
|
'for..in loops iterate over the entire prototype chain, which is virtually never what you want. Use Object.{keys,values,entries}, and iterate over the resulting array.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: 'LabeledStatement',
|
||||||
|
message: 'Labels are a form of GOTO; using them makes code confusing and hard to maintain and understand.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: 'WithStatement',
|
||||||
|
message: '`with` is disallowed in strict mode because it makes code impossible to predict and optimize.',
|
||||||
|
},
|
||||||
|
],
|
||||||
/**
|
/**
|
||||||
* 在类型注释周围需要一致的间距
|
* 在类型注释周围需要一致的间距
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.7.14-beta.2",
|
"version": "1.8.0-beta.11",
|
||||||
"name": "tmagic",
|
"name": "tmagic",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@ -66,8 +66,8 @@
|
|||||||
"prettier": "^3.8.3",
|
"prettier": "^3.8.3",
|
||||||
"recast": "^0.23.11",
|
"recast": "^0.23.11",
|
||||||
"rimraf": "^3.0.2",
|
"rimraf": "^3.0.2",
|
||||||
"rolldown": "^1.0.1",
|
"rolldown": "^1.1.2",
|
||||||
"rolldown-plugin-dts": "^0.25.1",
|
"rolldown-plugin-dts": "^0.26.0",
|
||||||
"sass-embedded": "^1.99.0",
|
"sass-embedded": "^1.99.0",
|
||||||
"semver": "^7.7.3",
|
"semver": "^7.7.3",
|
||||||
"serialize-javascript": "^7.0.0",
|
"serialize-javascript": "^7.0.0",
|
||||||
@ -75,7 +75,7 @@
|
|||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"vite": "catalog:",
|
"vite": "catalog:",
|
||||||
"vitepress": "^2.0.0-alpha.17",
|
"vitepress": "^2.0.0-alpha.17",
|
||||||
"vitest": "^4.1.6",
|
"vitest": "^4.1.9",
|
||||||
"vue": "catalog:",
|
"vue": "catalog:",
|
||||||
"vue-tsc": "^3.2.9"
|
"vue-tsc": "^3.2.9"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.7.14-beta.2",
|
"version": "1.8.0-beta.11",
|
||||||
"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.7.14-beta.2",
|
"version": "1.8.0-beta.11",
|
||||||
"name": "@tmagic/core",
|
"name": "@tmagic/core",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -244,7 +244,7 @@ class App extends EventEmitter {
|
|||||||
node.data?.id &&
|
node.data?.id &&
|
||||||
node.eventKeys.has(`${String(name)}_${node.data.id}`)
|
node.eventKeys.has(`${String(name)}_${node.data.id}`)
|
||||||
) {
|
) {
|
||||||
return this.eventHelper.emit(node.eventKeys.get(`${String(name)}_${node.data.id}`)!, node, ...otherArgs);
|
this.eventHelper.emit(node.eventKeys.get(`${String(name)}_${node.data.id}`)!, node, ...otherArgs);
|
||||||
}
|
}
|
||||||
return super.emit(name, ...args);
|
return super.emit(name, ...args);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -76,9 +76,11 @@ class IteratorContainer extends Node {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
config.items?.forEach((element: MNode) => {
|
if (Array.isArray(config.items)) {
|
||||||
this.initNode(element, node, map);
|
config.items?.forEach((element: MNode) => {
|
||||||
});
|
this.initNode(element, node, map);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public setNodes(nodes: MNode[], index: number) {
|
public setNodes(nodes: MNode[], index: number) {
|
||||||
|
|||||||
@ -79,9 +79,11 @@ class Page extends Node {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
config.items?.forEach((element: MComponent | MContainer) => {
|
if (Array.isArray(config.items)) {
|
||||||
this.initNode(element, node);
|
config.items?.forEach((element: MComponent | MContainer) => {
|
||||||
});
|
this.initNode(element, node);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public getNode<T extends TMagicNode = TMagicNode>(
|
public getNode<T extends TMagicNode = TMagicNode>(
|
||||||
|
|||||||
@ -431,6 +431,58 @@ describe('App 配置/方法/组件注册', () => {
|
|||||||
expect(typeof result).toBe('boolean');
|
expect(typeof result).toBe('boolean');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 回归用例:节点配置了 events 时,eventHelper 派发不能短路掉 super.emit,
|
||||||
|
// 即 app.on(name, cb) 注册的回调依然要被触发。
|
||||||
|
test('emit: 节点已绑定 events 时,app.on 注册的监听器仍然会被调用', () => {
|
||||||
|
const app = new App({
|
||||||
|
config: {
|
||||||
|
type: NodeType.ROOT,
|
||||||
|
id: 'app',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
type: NodeType.PAGE,
|
||||||
|
id: 'p1',
|
||||||
|
items: [{ id: 'btn', type: 'button', events: [{ name: 'click', actions: [] }] }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as any,
|
||||||
|
});
|
||||||
|
const node = app.getNode('btn')!;
|
||||||
|
const cb = vi.fn();
|
||||||
|
app.on('click', cb);
|
||||||
|
|
||||||
|
const result = app.emit('click', node, 'arg1');
|
||||||
|
|
||||||
|
expect(cb).toHaveBeenCalledTimes(1);
|
||||||
|
expect(cb).toHaveBeenCalledWith(node, 'arg1');
|
||||||
|
// EventEmitter.emit 在有 listener 时返回 true
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('emit: 未命中节点 eventKeys 时,app.on 注册的监听器正常被调用', () => {
|
||||||
|
const app = new App({
|
||||||
|
config: {
|
||||||
|
type: NodeType.ROOT,
|
||||||
|
id: 'app',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
type: NodeType.PAGE,
|
||||||
|
id: 'p1',
|
||||||
|
items: [{ id: 'btn', type: 'button' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as any,
|
||||||
|
});
|
||||||
|
const node = app.getNode('btn')!;
|
||||||
|
const cb = vi.fn();
|
||||||
|
app.on('click', cb);
|
||||||
|
|
||||||
|
app.emit('click', node, 'arg1');
|
||||||
|
|
||||||
|
expect(cb).toHaveBeenCalledTimes(1);
|
||||||
|
expect(cb).toHaveBeenCalledWith(node, 'arg1');
|
||||||
|
});
|
||||||
|
|
||||||
test('destroy 清理所有资源', () => {
|
test('destroy 清理所有资源', () => {
|
||||||
const app = new App({
|
const app = new App({
|
||||||
config: {
|
config: {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.7.14-beta.2",
|
"version": "1.8.0-beta.11",
|
||||||
"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.7.14-beta.2",
|
"version": "1.8.0-beta.11",
|
||||||
"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.7.14-beta.2",
|
"version": "1.8.0-beta.11",
|
||||||
"name": "@tmagic/design",
|
"name": "@tmagic/design",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": [
|
"sideEffects": [
|
||||||
|
|||||||
@ -43,8 +43,16 @@ const props = withDefaults(defineProps<PopoverProps>(), {
|
|||||||
visible: undefined,
|
visible: undefined,
|
||||||
tabindex: 0,
|
tabindex: 0,
|
||||||
destroyOnClose: false,
|
destroyOnClose: false,
|
||||||
|
closeOnClickOutside: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
/** 受控模式(传入了 visible)下点击外部收起时触发,便于配合 v-model:visible。 */
|
||||||
|
'update:visible': [_visible: boolean];
|
||||||
|
/** 点击 popover 及其衍生浮层以外的区域时触发。 */
|
||||||
|
clickoutside: [_event: MouseEvent];
|
||||||
|
}>();
|
||||||
|
|
||||||
const popoverVisible = ref(false);
|
const popoverVisible = ref(false);
|
||||||
|
|
||||||
const visibleWatch = watch(
|
const visibleWatch = watch(
|
||||||
@ -179,6 +187,70 @@ if (props.trigger === 'hover' && typeof props.visible === 'undefined') {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* popover 内部触发、却挂载到 body(在 popper 之外)的浮层:弹窗、二次确认框、tooltip、
|
||||||
|
* 下拉 / 日期选择等。点击它们属于 popover 内部交互,不应顺带把 popover 关闭。
|
||||||
|
*
|
||||||
|
* 由于 @tmagic/design 通过适配器支持 element-plus、tdesign 等多套 UI 库,这里同时列出
|
||||||
|
* 两套库的浮层 class(class 名互不冲突,未命中的选择器无副作用),避免切换适配器后失效。
|
||||||
|
*/
|
||||||
|
const DEFAULT_CLICK_OUTSIDE_IGNORE = [
|
||||||
|
// @tmagic/design 自身(与适配器无关)
|
||||||
|
'.tmagic-design-dialog',
|
||||||
|
// element-plus
|
||||||
|
'.el-overlay',
|
||||||
|
'.el-message-box',
|
||||||
|
'.el-popper',
|
||||||
|
'.el-select-dropdown',
|
||||||
|
'.el-picker__popper',
|
||||||
|
'.el-dropdown__popper',
|
||||||
|
'.el-cascader__dropdown',
|
||||||
|
// tdesign:弹窗 / 消息确认(DialogPlugin / MessagePlugin)与各类浮层(tooltip / select / dropdown / 日期选择等均挂在 .t-popup 内)
|
||||||
|
'.t-dialog__ctx',
|
||||||
|
'.t-dialog',
|
||||||
|
'.t-message',
|
||||||
|
'.t-popup',
|
||||||
|
].join(',');
|
||||||
|
|
||||||
|
const clickOutsideIgnoreSelector = computed(() =>
|
||||||
|
[DEFAULT_CLICK_OUTSIDE_IGNORE, props.clickOutsideIgnore].filter(Boolean).join(','),
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (props.disabled) return;
|
||||||
|
|
||||||
|
const target = e.target as HTMLElement | null;
|
||||||
|
if (!target) return;
|
||||||
|
|
||||||
|
// 点击 reference、popper 自身或衍生浮层时保持打开
|
||||||
|
if (referenceElementRef.value?.contains(target)) return;
|
||||||
|
if (popperElementRef.value?.contains(target)) return;
|
||||||
|
if (target.closest(clickOutsideIgnoreSelector.value)) return;
|
||||||
|
|
||||||
|
emit('clickoutside', e);
|
||||||
|
|
||||||
|
// 非受控:直接收起;受控:通过 update:visible 通知父级(可配合 v-model:visible)
|
||||||
|
if (typeof props.visible === 'undefined') {
|
||||||
|
popoverVisible.value = false;
|
||||||
|
} else {
|
||||||
|
emit('update:visible', false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const bindClickOutside = () => globalThis.document?.addEventListener('click', handleClickOutside);
|
||||||
|
const unbindClickOutside = () => globalThis.document?.removeEventListener('click', handleClickOutside);
|
||||||
|
|
||||||
|
watch(popoverVisible, (visible) => {
|
||||||
|
if (!props.closeOnClickOutside) return;
|
||||||
|
|
||||||
|
if (visible) {
|
||||||
|
// 延后到「打开 popover 的这一次点击」冒泡结束后再监听,避免刚打开就被立即关闭
|
||||||
|
nextTick(bindClickOutside);
|
||||||
|
} else {
|
||||||
|
unbindClickOutside();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const destroy = () => {
|
const destroy = () => {
|
||||||
if (!instanceRef.value) return;
|
if (!instanceRef.value) return;
|
||||||
|
|
||||||
@ -188,5 +260,6 @@ const destroy = () => {
|
|||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
destroy();
|
destroy();
|
||||||
|
unbindClickOutside();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -234,6 +234,9 @@ export interface PaginationProps {
|
|||||||
|
|
||||||
export interface PopconfirmProps {
|
export interface PopconfirmProps {
|
||||||
title?: string;
|
title?: string;
|
||||||
|
width?: string | number;
|
||||||
|
/** 浮层是否插入到 body,默认 true。设为 false 时浮层内联渲染,便于嵌套在 hover 浮层中避免父级因 mouseleave 收起。 */
|
||||||
|
teleported?: boolean;
|
||||||
placement?:
|
placement?:
|
||||||
| 'top'
|
| 'top'
|
||||||
| 'left'
|
| 'left'
|
||||||
@ -258,6 +261,13 @@ export interface PopoverProps {
|
|||||||
popperClass?: string;
|
popperClass?: string;
|
||||||
tabindex?: number;
|
tabindex?: number;
|
||||||
destroyOnClose?: boolean;
|
destroyOnClose?: boolean;
|
||||||
|
/** 点击 popover 及其衍生浮层以外的区域时收起,默认开启。 */
|
||||||
|
closeOnClickOutside?: boolean;
|
||||||
|
/**
|
||||||
|
* 追加的「点击不关闭」选择器,会与内置的弹窗 / 确认框 / 下拉等浮层选择器合并。
|
||||||
|
* 用于 popover 内部触发、挂载到 body 之外的浮层不应顺带关闭 popover 的场景。
|
||||||
|
*/
|
||||||
|
clickOutsideIgnore?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RadioProps {
|
export interface RadioProps {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.7.14-beta.2",
|
"version": "1.8.0-beta.11",
|
||||||
"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>
|
||||||
@ -134,7 +135,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
|
|
||||||
import { provide } from 'vue';
|
import { provide, ref } from 'vue';
|
||||||
|
|
||||||
import type { MApp } from '@tmagic/core';
|
import type { MApp } from '@tmagic/core';
|
||||||
|
|
||||||
@ -220,22 +221,45 @@ 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,
|
||||||
};
|
};
|
||||||
|
|
||||||
stageOverlayService.set('stageOptions', stageOptions);
|
stageOverlayService.set('stageOptions', stageOptions);
|
||||||
|
|
||||||
|
const propsPanelRef = ref<InstanceType<typeof FormPanel> | null>(null);
|
||||||
|
|
||||||
provide('services', services);
|
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);
|
||||||
|
/**
|
||||||
|
* 提供 PropsPanel 主属性表单的 formState getter,供历史差异弹窗复用,
|
||||||
|
* 让 CompareForm 与 PropsPanel 的 filterFunction 上下文保持一致。
|
||||||
|
*/
|
||||||
|
provide('getPropsPanelFormState', () => propsPanelRef.value?.configForm?.formState);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把历史记录面板的自定义扩展 tab 提供给深层的 HistoryListPanel(它挂在 NavMenu 中,
|
||||||
|
* 以 markRaw component 形式渲染,无法直接通过 props 透传)。业务方可借此在历史记录
|
||||||
|
* 面板内追加自定义模块的历史 tab。
|
||||||
|
*/
|
||||||
|
provide('historyListExtraTabs', props.historyListExtraTabs);
|
||||||
|
|
||||||
provide<EventBus>('eventBus', new EventEmitter());
|
provide<EventBus>('eventBus', new EventEmitter());
|
||||||
|
|
||||||
const propsPanelMountedHandler = (e: InstanceType<typeof FormPanel>) => {
|
const propsPanelMountedHandler = (e: InstanceType<typeof FormPanel>) => {
|
||||||
|
propsPanelRef.value = e;
|
||||||
emit('props-panel-mounted', e);
|
emit('props-panel-mounted', e);
|
||||||
};
|
};
|
||||||
const propsPanelUnmountedHandler = () => {
|
const propsPanelUnmountedHandler = () => {
|
||||||
|
propsPanelRef.value = null;
|
||||||
emit('props-panel-unmounted');
|
emit('props-panel-unmounted');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,9 @@
|
|||||||
v-model:visible="boxVisible"
|
v-model:visible="boxVisible"
|
||||||
v-model:width="width"
|
v-model:width="width"
|
||||||
v-model:height="codeBlockEditorHeight"
|
v-model:height="codeBlockEditorHeight"
|
||||||
|
:body-style="{ padding: '0 16px' }"
|
||||||
:title="content.name ? `${disabled ? '查看' : '编辑'}${content.name}` : '新增代码'"
|
:title="content.name ? `${disabled ? '查看' : '编辑'}${content.name}` : '新增代码'"
|
||||||
|
:framework-width="frameworkWidth"
|
||||||
:position="boxPosition"
|
:position="boxPosition"
|
||||||
:before-close="beforeClose"
|
:before-close="beforeClose"
|
||||||
>
|
>
|
||||||
@ -34,7 +36,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 +65,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 +73,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 +115,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) => {
|
||||||
@ -295,6 +208,7 @@ const closedHandler = () => {
|
|||||||
|
|
||||||
const parentFloating = inject<Ref<HTMLDivElement | null>>('parentFloating', ref(null));
|
const parentFloating = inject<Ref<HTMLDivElement | null>>('parentFloating', ref(null));
|
||||||
const { boxPosition, calcBoxPosition } = useNextFloatBoxPosition(uiService, parentFloating);
|
const { boxPosition, calcBoxPosition } = useNextFloatBoxPosition(uiService, parentFloating);
|
||||||
|
const frameworkWidth = computed(() => uiService.get('frameworkRect')?.width || 0);
|
||||||
|
|
||||||
watch(boxVisible, (visible) => {
|
watch(boxVisible, (visible) => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
284
packages/editor/src/components/CompareForm.vue
Normal file
284
packages/editor/src/components/CompareForm.vue
Normal file
@ -0,0 +1,284 @@
|
|||||||
|
<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="mergedExtendState"
|
||||||
|
:show-diff="showDiff"
|
||||||
|
:self-diff-field-types="selfDiffFieldTypes"
|
||||||
|
:size="size"
|
||||||
|
></MForm>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, inject, provide, 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 FieldSize } from '@tmagic/design';
|
||||||
|
import { type FormConfig, type FormState, type FormValue, MForm } from '@tmagic/form';
|
||||||
|
|
||||||
|
import type { CompareCategory, CompareFormLoadConfig, Services } 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>>;
|
||||||
|
/**
|
||||||
|
* 外部透传的基础 formState(通常来自 PropsPanel 主属性表单)。
|
||||||
|
* CompareForm 会提取其中的扩展字段覆盖到自己的 formState,保证 filterFunction 上下文一致。
|
||||||
|
*/
|
||||||
|
baseFormState?: FormState;
|
||||||
|
/** 需要走 self diff 的字段类型(例如 mod-cond)。 */
|
||||||
|
selfDiffFieldTypes?: string[];
|
||||||
|
/**
|
||||||
|
* 表单内组件的尺寸(透传给 MForm 的 `size`),可选 'large' | 'default' | 'small'。
|
||||||
|
* 缺省时使用 MForm 内置默认尺寸。
|
||||||
|
*/
|
||||||
|
size?: FieldSize;
|
||||||
|
/**
|
||||||
|
* 自定义 FormConfig 加载逻辑。传入后将接管内置的按 `category`(node/data-source/code-block)
|
||||||
|
* 取配置逻辑,调用方可根据业务自行返回(或异步返回)表单配置。可通过
|
||||||
|
* `ctx.defaultLoadConfig()` 复用默认结果再做二次加工。返回的 config 直接用于对比展示。
|
||||||
|
*/
|
||||||
|
loadConfig?: CompareFormLoadConfig;
|
||||||
|
/** 编辑器服务集合,由调用方传入(不再通过 inject('services') 获取)。 */
|
||||||
|
services?: Services;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
category: 'node',
|
||||||
|
labelWidth: '120px',
|
||||||
|
extendState: (state: FormState) => state,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
provide('services', props.services);
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const mergedExtendState = (state: FormState) => {
|
||||||
|
return props.extendState(props.baseFormState || state);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 内置的默认 FormConfig 加载逻辑:按 `category` 从对应 service / 工具取配置。
|
||||||
|
* 作为 ctx.defaultLoadConfig 透传给自定义 `loadConfig`,方便复用与二次加工。
|
||||||
|
*/
|
||||||
|
const defaultLoadConfig = async (): Promise<FormConfig> => {
|
||||||
|
if (!props.services) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (props.category) {
|
||||||
|
case 'node': {
|
||||||
|
if (!props.type) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return removeStyleDisplayConfig(
|
||||||
|
await props.services.propsService.getPropsConfig(props.type, { node: props.value as unknown as MNode }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case 'data-source': {
|
||||||
|
const config = props.services.dataSourceService.getFormConfig(props.type || 'base');
|
||||||
|
// 数据源表单外层 tab 的「数据定义」项 status 为 'fields',tab-pane name 随之为 'fields'。
|
||||||
|
// 未显式设置 active 时,Tabs 默认取 '0',与 'fields' 不匹配会导致打开弹窗时无默认激活项,
|
||||||
|
// 这里与 DataSourceConfigPanel 保持一致,默认激活「数据定义」tab。
|
||||||
|
return config.map((item) => ('type' in item && item.type === 'tab' ? { ...item, active: 'fields' } : item));
|
||||||
|
}
|
||||||
|
case 'code-block': {
|
||||||
|
return getCodeBlockFormConfig({
|
||||||
|
paramColConfig: props.services.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') 的最新值。
|
||||||
|
*/
|
||||||
|
watchEffect(() => {
|
||||||
|
if (formRef.value && props.services) {
|
||||||
|
formRef.value.formState.stage = props.services.editorService.get('stage');
|
||||||
|
formRef.value.formState.services = props.services;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
defineExpose<{
|
||||||
|
form: ShallowRef<InstanceType<typeof MForm> | null>;
|
||||||
|
config: Ref<FormConfig>;
|
||||||
|
reload: () => Promise<void>;
|
||||||
|
}>({
|
||||||
|
form: formRef,
|
||||||
|
config,
|
||||||
|
reload: loadConfig,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@ -1,6 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<Teleport to="body" v-if="visible">
|
<Teleport to="body" v-if="visible">
|
||||||
<div ref="target" class="m-editor-float-box" :style="{ ...style, zIndex: curZIndex }" @mousedown="nextZIndex">
|
<div
|
||||||
|
ref="target"
|
||||||
|
class="m-editor-float-box"
|
||||||
|
v-bind="$attrs"
|
||||||
|
:style="{ ...style, zIndex: curZIndex }"
|
||||||
|
@mousedown="nextZIndex"
|
||||||
|
>
|
||||||
<div ref="title" class="m-editor-float-box-title">
|
<div ref="title" class="m-editor-float-box-title">
|
||||||
<slot name="title">
|
<slot name="title">
|
||||||
<span>{{ title }}</span>
|
<span>{{ title }}</span>
|
||||||
@ -9,7 +15,7 @@
|
|||||||
<TMagicButton link size="small" @click="closeHandler"><MIcon :icon="Close"></MIcon></TMagicButton>
|
<TMagicButton link size="small" @click="closeHandler"><MIcon :icon="Close"></MIcon></TMagicButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="m-editor-float-box-body" :style="{ height: `${bodyHeight}px` }">
|
<div class="m-editor-float-box-body" :style="{ height: `${bodyHeight}px`, ...bodyStyle }">
|
||||||
<slot name="body"></slot>
|
<slot name="body"></slot>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -17,14 +23,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onBeforeUnmount, provide, ref, useTemplateRef, watch } from 'vue';
|
import { computed, type CSSProperties, nextTick, onBeforeUnmount, provide, ref, useTemplateRef, watch } from 'vue';
|
||||||
import { Close } from '@element-plus/icons-vue';
|
import { Close } from '@element-plus/icons-vue';
|
||||||
import VanillaMoveable from 'moveable';
|
import VanillaMoveable from 'moveable';
|
||||||
|
|
||||||
import { TMagicButton, useZIndex } from '@tmagic/design';
|
import { TMagicButton, useZIndex } from '@tmagic/design';
|
||||||
|
|
||||||
import MIcon from '@editor/components/Icon.vue';
|
import MIcon from '@editor/components/Icon.vue';
|
||||||
import { useServices } from '@editor/hooks/use-services';
|
|
||||||
|
|
||||||
interface Position {
|
interface Position {
|
||||||
left: number;
|
left: number;
|
||||||
@ -39,11 +44,18 @@ const props = withDefaults(
|
|||||||
defineProps<{
|
defineProps<{
|
||||||
position?: Position;
|
position?: Position;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
bodyStyle?: CSSProperties;
|
||||||
|
/** 浮窗初始样式,会与内部计算样式合并,外部传入优先 */
|
||||||
|
initialStyle?: CSSProperties;
|
||||||
|
/** 用于约束浮窗 left 的容器宽度,传入时按宽度收敛 left,避免超出右边界;默认取视窗宽度 */
|
||||||
|
frameworkWidth?: number;
|
||||||
beforeClose?: (_done: (_cancel?: boolean) => void) => void;
|
beforeClose?: (_done: (_cancel?: boolean) => void) => void;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
title: '',
|
title: '',
|
||||||
position: () => ({ left: 0, top: 0 }),
|
position: () => ({ left: 0, top: 0 }),
|
||||||
|
initialStyle: () => ({}),
|
||||||
|
frameworkWidth: 0,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -66,12 +78,11 @@ const bodyHeight = computed(() => {
|
|||||||
return 'auto';
|
return 'auto';
|
||||||
});
|
});
|
||||||
|
|
||||||
const { uiService } = useServices();
|
|
||||||
const frameworkWidth = computed(() => uiService.get('frameworkRect').width || 0);
|
|
||||||
const style = computed(() => {
|
const style = computed(() => {
|
||||||
let { left } = props.position;
|
let { left } = props.position;
|
||||||
if (width.value) {
|
const frameworkWidth = props.frameworkWidth || globalThis.window?.innerWidth || 0;
|
||||||
left = left + width.value > frameworkWidth.value ? frameworkWidth.value - width.value : left;
|
if (width.value && frameworkWidth) {
|
||||||
|
left = left + width.value > frameworkWidth ? frameworkWidth - width.value : left;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -79,11 +90,60 @@ const style = computed(() => {
|
|||||||
top: `${props.position.top}px`,
|
top: `${props.position.top}px`,
|
||||||
width: width.value ? `${width.value}px` : 'auto',
|
width: width.value ? `${width.value}px` : 'auto',
|
||||||
height: height.value ? `${height.value}px` : 'auto',
|
height: height.value ? `${height.value}px` : 'auto',
|
||||||
|
...props.initialStyle,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
let moveable: VanillaMoveable | null = null;
|
let moveable: VanillaMoveable | null = null;
|
||||||
|
|
||||||
|
// 拖拽/缩放时用于覆盖 iframe 的遮罩,防止鼠标进入 iframe 区域后事件被 iframe 吞掉导致拖拽丢失
|
||||||
|
let dragMask: HTMLDivElement | null = null;
|
||||||
|
let dragMaskVisible = false;
|
||||||
|
|
||||||
|
// 兜底:正常情况下遮罩由 moveable 的 dragEnd/resizeEnd 移除;但某些极端场景
|
||||||
|
// (拖拽中鼠标移出窗口后松开、alt-tab 切走窗口失焦、标签页被隐藏等)结束事件不会触发,
|
||||||
|
// 会导致全屏透明遮罩残留、整个编辑器无法点击。这里挂全局 pointerup/blur/visibilitychange 强制收尾。
|
||||||
|
const bindDragMaskSafety = () => {
|
||||||
|
globalThis.window?.addEventListener('pointerup', hideDragMask, true);
|
||||||
|
globalThis.window?.addEventListener('blur', hideDragMask);
|
||||||
|
globalThis.document?.addEventListener('visibilitychange', hideDragMask);
|
||||||
|
};
|
||||||
|
|
||||||
|
const unbindDragMaskSafety = () => {
|
||||||
|
globalThis.window?.removeEventListener('pointerup', hideDragMask, true);
|
||||||
|
globalThis.window?.removeEventListener('blur', hideDragMask);
|
||||||
|
globalThis.document?.removeEventListener('visibilitychange', hideDragMask);
|
||||||
|
};
|
||||||
|
|
||||||
|
const showDragMask = () => {
|
||||||
|
if (dragMaskVisible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!dragMask) {
|
||||||
|
dragMask = globalThis.document.createElement('div');
|
||||||
|
dragMask.className = 'm-editor-float-box-drag-mask';
|
||||||
|
}
|
||||||
|
globalThis.document.body.appendChild(dragMask);
|
||||||
|
dragMaskVisible = true;
|
||||||
|
bindDragMaskSafety();
|
||||||
|
|
||||||
|
// 拖拽标题时,root 上的 @mousedown="nextZIndex" 会把浮窗 z-index 抬高,
|
||||||
|
// 若此时才读取会拿到旧值导致遮罩被浮窗(及其内部 iframe)盖住,故用 nextTick 在 z-index 稳定后再设置到浮窗之上
|
||||||
|
const setMaskZIndex = () => {
|
||||||
|
if (dragMask) {
|
||||||
|
dragMask.style.zIndex = `${curZIndex.value + 1}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
setMaskZIndex();
|
||||||
|
nextTick(setMaskZIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hideDragMask = () => {
|
||||||
|
unbindDragMaskSafety();
|
||||||
|
dragMask?.parentNode?.removeChild(dragMask);
|
||||||
|
dragMaskVisible = false;
|
||||||
|
};
|
||||||
|
|
||||||
const initMoveable = () => {
|
const initMoveable = () => {
|
||||||
moveable = new VanillaMoveable(globalThis.document.body, {
|
moveable = new VanillaMoveable(globalThis.document.body, {
|
||||||
className: 'm-editor-floating-box-moveable',
|
className: 'm-editor-floating-box-moveable',
|
||||||
@ -101,6 +161,14 @@ const initMoveable = () => {
|
|||||||
bounds: { left: 0, top: 0, right: 0, bottom: 0, position: 'css' },
|
bounds: { left: 0, top: 0, right: 0, bottom: 0, position: 'css' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 仅在真正发生拖拽/缩放位移时插入遮罩:moveable 的 dragStart/resizeStart 在 mousedown 时即触发,
|
||||||
|
// 若此时就盖遮罩,会盖住浮窗本身导致 mouseup 落在遮罩上、关闭按钮的 click 无法触发(点击关闭不了)。
|
||||||
|
// 改为在 drag/resize(实际位移)时才显示,纯点击不再触发遮罩。
|
||||||
|
moveable.on('drag', showDragMask);
|
||||||
|
moveable.on('resize', showDragMask);
|
||||||
|
moveable.on('dragEnd', hideDragMask);
|
||||||
|
moveable.on('resizeEnd', hideDragMask);
|
||||||
|
|
||||||
moveable.on('drag', (e) => {
|
moveable.on('drag', (e) => {
|
||||||
e.target.style.transform = e.transform;
|
e.target.style.transform = e.transform;
|
||||||
});
|
});
|
||||||
@ -115,6 +183,7 @@ const initMoveable = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const destroyMoveable = () => {
|
const destroyMoveable = () => {
|
||||||
|
hideDragMask();
|
||||||
moveable?.destroy();
|
moveable?.destroy();
|
||||||
moveable = null;
|
moveable = null;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
<template v-else-if="data.type === 'button'">
|
<template v-else-if="data.type === 'button'">
|
||||||
<TMagicTooltip v-if="data.tooltip" effect="dark" placement="bottom-start" :content="data.tooltip">
|
<TMagicTooltip v-if="data.tooltip" effect="dark" placement="bottom-start" :content="data.tooltip">
|
||||||
<TMagicButton size="small" link :disabled="disabled">
|
<TMagicButton size="small" link :disabled="disabled" v-bind="data.buttonProps || {}">
|
||||||
<template #icon v-if="data.icon">
|
<template #icon v-if="data.icon">
|
||||||
<MIcon :icon="data.icon"></MIcon>
|
<MIcon :icon="data.icon"></MIcon>
|
||||||
</template>
|
</template>
|
||||||
@ -21,7 +21,7 @@
|
|||||||
</TMagicButton>
|
</TMagicButton>
|
||||||
</TMagicTooltip>
|
</TMagicTooltip>
|
||||||
|
|
||||||
<TMagicButton v-else size="small" link :disabled="disabled" :title="data.text">
|
<TMagicButton v-else size="small" link :disabled="disabled" :title="data.text" v-bind="data.buttonProps || {}">
|
||||||
<template #icon v-if="data.icon">
|
<template #icon v-if="data.icon">
|
||||||
<MIcon :icon="data.icon"></MIcon>
|
<MIcon :icon="data.icon"></MIcon>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -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,12 +93,26 @@ 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 codeBlockSidePanel = computed(() =>
|
||||||
(uiService.get('sideBarItems') || []).find((item) => item.$key === SideItemKey.CODE_BLOCK),
|
(uiService.get('sideBarItems') || []).find((item) => item.$key === SideItemKey.CODE_BLOCK),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const hasCodeBlockSidePanel = computed(() => codeBlockSidePanel.value);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据代码块id获取代码块参数配置
|
* 根据代码块id获取代码块参数配置
|
||||||
* @param codeId 代码块ID
|
* @param codeId 代码块ID
|
||||||
@ -163,6 +193,10 @@ const onParamsChangeHandler = (value: any, eventData: ContainerChangeEventData)
|
|||||||
};
|
};
|
||||||
|
|
||||||
const editCode = (id: string) => {
|
const editCode = (id: string) => {
|
||||||
|
const sideBarItem = codeBlockSidePanel.value;
|
||||||
|
if (sideBarItem) {
|
||||||
|
uiService.set('sideBarActiveTabName', sideBarItem.text || sideBarItem.$key || SideItemKey.CODE_BLOCK);
|
||||||
|
}
|
||||||
eventBus?.emit('edit-code', id);
|
eventBus?.emit('edit-code', id);
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -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,
|
||||||
@ -205,11 +211,25 @@ const onChangeHandler = (v: string[] = []) => {
|
|||||||
emit('change', v);
|
emit('change', v);
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasDataSourceSidePanel = computed(() =>
|
const dataSourceSidePanel = computed(() =>
|
||||||
uiService.get('sideBarItems').find((item) => item.$key === SideItemKey.DATA_SOURCE),
|
uiService.get('sideBarItems').find((item) => item.$key === SideItemKey.DATA_SOURCE),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const hasDataSourceSidePanel = computed(() => dataSourceSidePanel.value);
|
||||||
|
|
||||||
const editHandler = (id: string) => {
|
const editHandler = (id: string) => {
|
||||||
eventBus?.emit('edit-data-source', removeDataSourceFieldPrefix(id));
|
const sideBarItem = dataSourceSidePanel.value;
|
||||||
|
if (sideBarItem) {
|
||||||
|
uiService.set('sideBarActiveTabName', sideBarItem.text || sideBarItem.$key || SideItemKey.DATA_SOURCE);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataSourceId = removeDataSourceFieldPrefix(id);
|
||||||
|
const fieldPath = selectFieldsId.value;
|
||||||
|
|
||||||
|
if (fieldPath.length) {
|
||||||
|
eventBus?.emit('edit-data-source-field', dataSourceId, [...fieldPath]);
|
||||||
|
} else {
|
||||||
|
eventBus?.emit('edit-data-source', dataSourceId);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -28,14 +28,15 @@
|
|||||||
></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"
|
||||||
@click="showDataSourceFieldSelect = !showDataSourceFieldSelect"
|
:disabled="disabled"
|
||||||
|
@click="onToggleDataSourceFieldSelectHandler"
|
||||||
><MIcon :icon="Coin"></MIcon
|
><MIcon :icon="Coin"></MIcon
|
||||||
></TMagicButton>
|
></TMagicButton>
|
||||||
</TMagicTooltip>
|
</TMagicTooltip>
|
||||||
@ -185,4 +186,10 @@ const onChangeHandler = (value: string[], eventData?: ContainerChangeEventData)
|
|||||||
emit('change', [dsId], eventData);
|
emit('change', [dsId], eventData);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onToggleDataSourceFieldSelectHandler = () => {
|
||||||
|
// 禁用或对比模式下禁止切换
|
||||||
|
if (props.disabled || mForm?.isCompare) return;
|
||||||
|
showDataSourceFieldSelect.value = !showDataSourceFieldSelect.value;
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,17 +1,19 @@
|
|||||||
<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>
|
||||||
|
|
||||||
<FloatingBox
|
<FloatingBox
|
||||||
|
:body-style="{ padding: '0 16px' }"
|
||||||
v-model:visible="addDialogVisible"
|
v-model:visible="addDialogVisible"
|
||||||
v-model:width="width"
|
v-model:width="width"
|
||||||
v-model:height="editorHeight"
|
v-model:height="editorHeight"
|
||||||
:title="fieldTitle"
|
:title="fieldTitle"
|
||||||
|
:framework-width="frameworkWidth"
|
||||||
:position="boxPosition"
|
:position="boxPosition"
|
||||||
>
|
>
|
||||||
<template #body>
|
<template #body>
|
||||||
@ -28,10 +30,12 @@
|
|||||||
</FloatingBox>
|
</FloatingBox>
|
||||||
|
|
||||||
<FloatingBox
|
<FloatingBox
|
||||||
|
:body-style="{ padding: '0 16px' }"
|
||||||
v-model:visible="addFromJsonDialogVisible"
|
v-model:visible="addFromJsonDialogVisible"
|
||||||
v-model:width="width"
|
v-model:width="width"
|
||||||
v-model:height="editorHeight"
|
v-model:height="editorHeight"
|
||||||
title="快速添加数据定义"
|
title="快速添加数据定义"
|
||||||
|
:framework-width="frameworkWidth"
|
||||||
:position="boxPosition"
|
:position="boxPosition"
|
||||||
>
|
>
|
||||||
<template #body>
|
<template #body>
|
||||||
@ -47,7 +51,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { inject, Ref, ref } from 'vue';
|
import { computed, type ComputedRef, inject, onMounted, provide, 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 +88,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('');
|
||||||
@ -97,6 +105,16 @@ const newHandler = () => {
|
|||||||
addDialogVisible.value = true;
|
addDialogVisible.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const editField = (row: Record<string, any>, index: number) => {
|
||||||
|
fieldValues.value = {
|
||||||
|
...row,
|
||||||
|
index,
|
||||||
|
};
|
||||||
|
fieldTitle.value = `编辑${row.title}`;
|
||||||
|
calcBoxPosition();
|
||||||
|
addDialogVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
const fieldChange = ({ index, ...value }: Record<string, any>, data: ContainerChangeEventData) => {
|
const fieldChange = ({ index, ...value }: Record<string, any>, data: ContainerChangeEventData) => {
|
||||||
addDialogVisible.value = false;
|
addDialogVisible.value = false;
|
||||||
|
|
||||||
@ -154,13 +172,7 @@ const fieldColumns: ColumnConfig[] = [
|
|||||||
{
|
{
|
||||||
text: '编辑',
|
text: '编辑',
|
||||||
handler: (row: Record<string, any>, index: number) => {
|
handler: (row: Record<string, any>, index: number) => {
|
||||||
fieldValues.value = {
|
editField(row, index);
|
||||||
...row,
|
|
||||||
index,
|
|
||||||
};
|
|
||||||
fieldTitle.value = `编辑${row.title}`;
|
|
||||||
calcBoxPosition();
|
|
||||||
addDialogVisible.value = true;
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -176,6 +188,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 },
|
||||||
{
|
{
|
||||||
@ -345,4 +362,30 @@ const { height: editorHeight } = useEditorContentHeight();
|
|||||||
|
|
||||||
const parentFloating = inject<Ref<HTMLDivElement | null>>('parentFloating', ref(null));
|
const parentFloating = inject<Ref<HTMLDivElement | null>>('parentFloating', ref(null));
|
||||||
const { boxPosition, calcBoxPosition } = useNextFloatBoxPosition(uiService, parentFloating);
|
const { boxPosition, calcBoxPosition } = useNextFloatBoxPosition(uiService, parentFloating);
|
||||||
|
const frameworkWidth = computed(() => uiService.get('frameworkRect')?.width || 0);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 由 DataSourceConfigPanel 注入:打开数据源详情后需要直接打开的字段路径(字段名数组)。
|
||||||
|
* 当前层消费 path[0],并把剩余路径下发给嵌套字段,实现逐层打开。
|
||||||
|
*/
|
||||||
|
const editingFieldPath = inject<ComputedRef<string[]>>(
|
||||||
|
'editingDataSourceFieldPath',
|
||||||
|
computed(() => []),
|
||||||
|
);
|
||||||
|
|
||||||
|
provide(
|
||||||
|
'editingDataSourceFieldPath',
|
||||||
|
computed(() => editingFieldPath.value.slice(1)),
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const path = editingFieldPath.value;
|
||||||
|
if (!path.length) return;
|
||||||
|
|
||||||
|
const fields: Record<string, any>[] = props.model[props.name] || [];
|
||||||
|
const index = fields.findIndex((field) => field.name === path[0]);
|
||||||
|
if (index === -1) return;
|
||||||
|
|
||||||
|
editField(fields[index], index);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -30,10 +30,10 @@
|
|||||||
<template #suffix>
|
<template #suffix>
|
||||||
<Icon :icon="Coin" />
|
<Icon :icon="Coin" />
|
||||||
</template>
|
</template>
|
||||||
<template #default="{ item }">
|
<template #default="slotProps">
|
||||||
<div style="display: flex; flex-direction: column; line-height: 1.2em">
|
<div style="display: flex; flex-direction: column; line-height: 1.2em">
|
||||||
<div>{{ item.text }}</div>
|
<div>{{ slotProps?.item?.text || '' }}</div>
|
||||||
<span style="font-size: 10px; color: rgba(0, 0, 0, 0.6)">{{ item.value }}</span>
|
<span style="font-size: 10px; color: rgba(0, 0, 0, 0.6)">{{ slotProps?.item?.value || '' }}</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</component>
|
</component>
|
||||||
|
|||||||
@ -13,7 +13,7 @@
|
|||||||
></MCascader>
|
></MCascader>
|
||||||
|
|
||||||
<TMagicTooltip
|
<TMagicTooltip
|
||||||
v-if="model[name] && isCustomMethod && hasDataSourceSidePanel"
|
v-if="model[name] && isCustomMethod && dataSourceSidePanel && !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">
|
||||||
@ -75,12 +75,15 @@ const props = withDefaults(defineProps<FieldProps<DataSourceMethodSelectConfig>>
|
|||||||
disabled: false,
|
disabled: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasDataSourceSidePanel = computed(() =>
|
const dataSourceSidePanel = computed(() =>
|
||||||
(uiService.get('sideBarItems') || []).find((item) => item.$key === SideItemKey.DATA_SOURCE),
|
(uiService.get('sideBarItems') || []).find((item) => item.$key === SideItemKey.DATA_SOURCE),
|
||||||
);
|
);
|
||||||
|
|
||||||
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(() => {
|
||||||
@ -205,12 +208,17 @@ const onParamsChangeHandler = (value: any, eventData: ContainerChangeEventData)
|
|||||||
};
|
};
|
||||||
|
|
||||||
const editCodeHandler = () => {
|
const editCodeHandler = () => {
|
||||||
const [id] = props.model[props.name];
|
const [id, methodName] = props.model[props.name];
|
||||||
|
|
||||||
const dataSource = dataSourceService.getDataSourceById(id);
|
const dataSource = dataSourceService.getDataSourceById(id);
|
||||||
|
|
||||||
if (!dataSource) return;
|
if (!dataSource) return;
|
||||||
|
|
||||||
eventBus?.emit('edit-data-source', id);
|
const sideBarItem = dataSourceSidePanel.value;
|
||||||
|
if (sideBarItem) {
|
||||||
|
uiService.set('sideBarActiveTabName', sideBarItem.text || sideBarItem.$key || SideItemKey.DATA_SOURCE);
|
||||||
|
}
|
||||||
|
|
||||||
|
eventBus?.emit('edit-data-source-method', id, methodName);
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -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, type ComputedRef, inject, nextTick, onMounted, 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,11 +42,39 @@ 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');
|
||||||
|
|
||||||
let editIndex = -1;
|
let editIndex = -1;
|
||||||
|
|
||||||
|
const editMethod = (method: CodeBlockContent, index: number) => {
|
||||||
|
let codeContent: string = '({ params, dataSource, app }) => {\n // place your code here\n}';
|
||||||
|
|
||||||
|
if (method.content) {
|
||||||
|
if (typeof method.content !== 'string') {
|
||||||
|
codeContent = method.content.toString();
|
||||||
|
} else {
|
||||||
|
codeContent = method.content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
codeConfig.value = {
|
||||||
|
...cloneDeep(method),
|
||||||
|
content: codeContent,
|
||||||
|
};
|
||||||
|
|
||||||
|
editIndex = index;
|
||||||
|
|
||||||
|
nextTick(() => {
|
||||||
|
codeBlockEditorRef.value?.show();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const methodColumns: ColumnConfig[] = [
|
const methodColumns: ColumnConfig[] = [
|
||||||
{
|
{
|
||||||
label: '名称',
|
label: '名称',
|
||||||
@ -72,26 +100,7 @@ const methodColumns: ColumnConfig[] = [
|
|||||||
{
|
{
|
||||||
text: '编辑',
|
text: '编辑',
|
||||||
handler: (method: CodeBlockContent, index: number) => {
|
handler: (method: CodeBlockContent, index: number) => {
|
||||||
let codeContent: string = '({ params, dataSource, app }) => {\n // place your code here\n}';
|
editMethod(method, index);
|
||||||
|
|
||||||
if (method.content) {
|
|
||||||
if (typeof method.content !== 'string') {
|
|
||||||
codeContent = method.content.toString();
|
|
||||||
} else {
|
|
||||||
codeContent = method.content;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
codeConfig.value = {
|
|
||||||
...cloneDeep(method),
|
|
||||||
content: codeContent,
|
|
||||||
};
|
|
||||||
|
|
||||||
editIndex = index;
|
|
||||||
|
|
||||||
nextTick(() => {
|
|
||||||
codeBlockEditorRef.value?.show();
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -107,6 +116,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: '',
|
||||||
@ -148,4 +162,21 @@ const submitCodeHandler = (value: CodeBlockContent, data: ContainerChangeEventDa
|
|||||||
|
|
||||||
codeBlockEditorRef.value?.hide();
|
codeBlockEditorRef.value?.hide();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 由 DataSourceConfigPanel 注入:打开数据源详情后需要直接打开的方法名 */
|
||||||
|
const editingMethodName = inject<ComputedRef<string | undefined>>(
|
||||||
|
'editingDataSourceMethodName',
|
||||||
|
computed(() => ''),
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const methodName = editingMethodName.value;
|
||||||
|
if (!methodName) return;
|
||||||
|
|
||||||
|
const methods: CodeBlockContent[] = props.model[props.name] || [];
|
||||||
|
const index = methods.findIndex((method) => method.name === methodName);
|
||||||
|
if (index === -1) return;
|
||||||
|
|
||||||
|
editMethod(methods[index], index);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,16 +1,18 @@
|
|||||||
<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>
|
||||||
|
|
||||||
<FloatingBox
|
<FloatingBox
|
||||||
|
:body-style="{ padding: '0 16px' }"
|
||||||
v-model:visible="addDialogVisible"
|
v-model:visible="addDialogVisible"
|
||||||
v-model:width="width"
|
v-model:width="width"
|
||||||
v-model:height="editorHeight"
|
v-model:height="editorHeight"
|
||||||
:title="drawerTitle"
|
:title="drawerTitle"
|
||||||
|
:framework-width="frameworkWidth"
|
||||||
:position="boxPosition"
|
:position="boxPosition"
|
||||||
>
|
>
|
||||||
<template #body>
|
<template #body>
|
||||||
@ -28,7 +30,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 +56,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 +209,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 = {
|
||||||
@ -244,4 +256,5 @@ const addDialogVisible = defineModel<boolean>('visible', { default: false });
|
|||||||
const { height: editorHeight } = useEditorContentHeight();
|
const { height: editorHeight } = useEditorContentHeight();
|
||||||
const parentFloating = inject<Ref<HTMLDivElement | null>>('parentFloating', ref(null));
|
const parentFloating = inject<Ref<HTMLDivElement | null>>('parentFloating', ref(null));
|
||||||
const { boxPosition, calcBoxPosition } = useNextFloatBoxPosition(uiService, parentFloating);
|
const { boxPosition, calcBoxPosition } = useNextFloatBoxPosition(uiService, parentFloating);
|
||||||
|
const frameworkWidth = computed(() => uiService.get('frameworkRect')?.width || 0);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
@ -7,9 +7,12 @@
|
|||||||
v-if="item.component"
|
v-if="item.component"
|
||||||
:is="item.component"
|
:is="item.component"
|
||||||
:values="model[name]"
|
:values="model[name]"
|
||||||
|
:last-values="lastValues?.[name]"
|
||||||
|
:is-compare="isCompare"
|
||||||
:size="size"
|
:size="size"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
@change="change"
|
@change="change"
|
||||||
|
@add-diff-count="onAddDiffCount"
|
||||||
></component>
|
></component>
|
||||||
</TMagicCollapseItem>
|
</TMagicCollapseItem>
|
||||||
</template>
|
</template>
|
||||||
@ -36,6 +39,7 @@ const props = defineProps<FieldProps<StyleSchema>>();
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
change: [v: any, eventData: ContainerChangeEventData];
|
change: [v: any, eventData: ContainerChangeEventData];
|
||||||
|
addDiffCount: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const list = [
|
const list = [
|
||||||
@ -82,4 +86,6 @@ const change = (v: any, eventData: ContainerChangeEventData) => {
|
|||||||
});
|
});
|
||||||
emit('change', v, eventData);
|
emit('change', v, eventData);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onAddDiffCount = () => emit('addDiffCount');
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -4,33 +4,46 @@
|
|||||||
<div class="border-icon-container-row">
|
<div class="border-icon-container-row">
|
||||||
<div
|
<div
|
||||||
class="border-icon border-icon-top"
|
class="border-icon border-icon-top"
|
||||||
:class="{ active: direction === 'Top' }"
|
:class="{ active: direction === 'Top', configured: isConfigured('Top') }"
|
||||||
@click="selectDirection('Top')"
|
@click="selectDirection('Top')"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="border-icon-container-row">
|
<div class="border-icon-container-row">
|
||||||
<div
|
<div
|
||||||
class="border-icon border-icon-left"
|
class="border-icon border-icon-left"
|
||||||
:class="{ active: direction === 'Left' }"
|
:class="{ active: direction === 'Left', configured: isConfigured('Left') }"
|
||||||
@click="selectDirection('Left')"
|
@click="selectDirection('Left')"
|
||||||
></div>
|
></div>
|
||||||
<div class="border-icon" :class="{ active: direction === '' }" @click="selectDirection()"></div>
|
<div
|
||||||
|
class="border-icon"
|
||||||
|
:class="{ active: direction === '', configured: isConfigured('') }"
|
||||||
|
@click="selectDirection()"
|
||||||
|
></div>
|
||||||
<div
|
<div
|
||||||
class="border-icon border-icon-right"
|
class="border-icon border-icon-right"
|
||||||
:class="{ active: direction === 'Right' }"
|
:class="{ active: direction === 'Right', configured: isConfigured('Right') }"
|
||||||
@click="selectDirection('Right')"
|
@click="selectDirection('Right')"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="border-icon-container-row">
|
<div class="border-icon-container-row">
|
||||||
<div
|
<div
|
||||||
class="border-icon border-icon-bottom"
|
class="border-icon border-icon-bottom"
|
||||||
:class="{ active: direction === 'Bottom' }"
|
:class="{ active: direction === 'Bottom', configured: isConfigured('Bottom') }"
|
||||||
@click="selectDirection('Bottom')"
|
@click="selectDirection('Bottom')"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="border-value-container">
|
<div class="border-value-container">
|
||||||
<MContainer :config="config" :model="model" :size="size" :disabled="disabled" @change="change"></MContainer>
|
<MContainer
|
||||||
|
:config="config"
|
||||||
|
:model="model"
|
||||||
|
:last-values="lastValues"
|
||||||
|
:is-compare="isCompare"
|
||||||
|
:size="size"
|
||||||
|
:disabled="disabled"
|
||||||
|
@change="change"
|
||||||
|
@add-diff-count="onAddDiffCount"
|
||||||
|
></MContainer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -86,17 +99,25 @@ const selectDirection = (d?: string) => (direction.value = d || '');
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
change: [v: StyleSchema, eventData: ContainerChangeEventData];
|
change: [v: StyleSchema, eventData: ContainerChangeEventData];
|
||||||
|
addDiffCount: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
model: FormValue;
|
model: FormValue;
|
||||||
|
lastValues?: FormValue;
|
||||||
|
isCompare?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
size?: 'large' | 'default' | 'small';
|
size?: 'large' | 'default' | 'small';
|
||||||
}>(),
|
}>(),
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const hasValue = (value: unknown) => value !== undefined && value !== null && value !== '';
|
||||||
|
|
||||||
|
const isConfigured = (d: string) =>
|
||||||
|
['Width', 'Color', 'Style'].some((key) => hasValue(props.model?.[`border${d}${key}`]));
|
||||||
|
|
||||||
const change = (value: StyleSchema, eventData: ContainerChangeEventData) => {
|
const change = (value: StyleSchema, eventData: ContainerChangeEventData) => {
|
||||||
eventData.changeRecords?.forEach((record) => {
|
eventData.changeRecords?.forEach((record) => {
|
||||||
emit('change', record.value, {
|
emit('change', record.value, {
|
||||||
@ -104,4 +125,6 @@ const change = (value: StyleSchema, eventData: ContainerChangeEventData) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onAddDiffCount = () => emit('addDiffCount');
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -64,6 +64,8 @@ withDefaults(
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
size?: 'large' | 'default' | 'small';
|
size?: 'large' | 'default' | 'small';
|
||||||
model: FormValue;
|
model: FormValue;
|
||||||
|
lastValues?: FormValue;
|
||||||
|
isCompare?: boolean;
|
||||||
}>(),
|
}>(),
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,5 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<MContainer :config="config" :model="values" :size="size" :disabled="disabled" @change="change"></MContainer>
|
<MContainer
|
||||||
|
:config="config"
|
||||||
|
:model="values"
|
||||||
|
:last-values="lastValues"
|
||||||
|
:is-compare="isCompare"
|
||||||
|
:size="size"
|
||||||
|
:disabled="disabled"
|
||||||
|
@change="change"
|
||||||
|
@add-diff-count="onAddDiffCount"
|
||||||
|
></MContainer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
@ -13,12 +22,15 @@ import { BackgroundNoRepeat, BackgroundRepeat, BackgroundRepeatX, BackgroundRepe
|
|||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
values: Partial<StyleSchema>;
|
values: Partial<StyleSchema>;
|
||||||
|
lastValues?: Partial<StyleSchema>;
|
||||||
|
isCompare?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
size?: 'large' | 'default' | 'small';
|
size?: 'large' | 'default' | 'small';
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
change: [v: StyleSchema, eventData: ContainerChangeEventData];
|
change: [v: StyleSchema, eventData: ContainerChangeEventData];
|
||||||
|
addDiffCount: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const config = defineFormItem({
|
const config = defineFormItem({
|
||||||
@ -79,4 +91,6 @@ const config = defineFormItem({
|
|||||||
const change = (value: StyleSchema, eventData: ContainerChangeEventData) => {
|
const change = (value: StyleSchema, eventData: ContainerChangeEventData) => {
|
||||||
emit('change', value, eventData);
|
emit('change', value, eventData);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onAddDiffCount = () => emit('addDiffCount');
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,6 +1,23 @@
|
|||||||
<template>
|
<template>
|
||||||
<MContainer :config="config" :model="values" :size="size" :disabled="disabled" @change="change"></MContainer>
|
<MContainer
|
||||||
<Border :model="values" :size="size" :disabled="disabled" @change="change"></Border>
|
:config="config"
|
||||||
|
:model="values"
|
||||||
|
:last-values="lastValues"
|
||||||
|
:is-compare="isCompare"
|
||||||
|
:size="size"
|
||||||
|
:disabled="disabled"
|
||||||
|
@change="change"
|
||||||
|
@add-diff-count="onAddDiffCount"
|
||||||
|
></MContainer>
|
||||||
|
<Border
|
||||||
|
:model="values"
|
||||||
|
:last-values="lastValues"
|
||||||
|
:is-compare="isCompare"
|
||||||
|
:size="size"
|
||||||
|
:disabled="disabled"
|
||||||
|
@change="change"
|
||||||
|
@add-diff-count="onAddDiffCount"
|
||||||
|
></Border>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
@ -11,12 +28,15 @@ import Border from '../components/Border.vue';
|
|||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
values: Partial<StyleSchema>;
|
values: Partial<StyleSchema>;
|
||||||
|
lastValues?: Partial<StyleSchema>;
|
||||||
|
isCompare?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
size?: 'large' | 'default' | 'small';
|
size?: 'large' | 'default' | 'small';
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
change: [v: StyleSchema, eventData: ContainerChangeEventData];
|
change: [v: StyleSchema, eventData: ContainerChangeEventData];
|
||||||
|
addDiffCount: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const config = defineFormItem({
|
const config = defineFormItem({
|
||||||
@ -36,4 +56,6 @@ const config = defineFormItem({
|
|||||||
const change = (value: StyleSchema, eventData: ContainerChangeEventData) => {
|
const change = (value: StyleSchema, eventData: ContainerChangeEventData) => {
|
||||||
emit('change', value, eventData);
|
emit('change', value, eventData);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onAddDiffCount = () => emit('addDiffCount');
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,5 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<MContainer :config="config" :model="values" :size="size" :disabled="disabled" @change="change"></MContainer>
|
<MContainer
|
||||||
|
:config="config"
|
||||||
|
:model="values"
|
||||||
|
:last-values="lastValues"
|
||||||
|
:is-compare="isCompare"
|
||||||
|
:size="size"
|
||||||
|
:disabled="disabled"
|
||||||
|
@change="change"
|
||||||
|
@add-diff-count="onAddDiffCount"
|
||||||
|
></MContainer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
@ -12,12 +21,15 @@ import { AlignCenter, AlignLeft, AlignRight } from '../icons/text-align';
|
|||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
values: Partial<StyleSchema>;
|
values: Partial<StyleSchema>;
|
||||||
|
lastValues?: Partial<StyleSchema>;
|
||||||
|
isCompare?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
size?: 'large' | 'default' | 'small';
|
size?: 'large' | 'default' | 'small';
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
change: [v: StyleSchema, eventData: ContainerChangeEventData];
|
change: [v: StyleSchema, eventData: ContainerChangeEventData];
|
||||||
|
addDiffCount: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const config = defineFormItem({
|
const config = defineFormItem({
|
||||||
@ -91,4 +103,6 @@ const config = defineFormItem({
|
|||||||
const change = (value: StyleSchema, eventData: ContainerChangeEventData) => {
|
const change = (value: StyleSchema, eventData: ContainerChangeEventData) => {
|
||||||
emit('change', value, eventData);
|
emit('change', value, eventData);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onAddDiffCount = () => emit('addDiffCount');
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,8 +1,19 @@
|
|||||||
<template>
|
<template>
|
||||||
<MContainer :config="config" :model="values" :size="size" :disabled="disabled" @change="change"></MContainer>
|
<MContainer
|
||||||
|
:config="config"
|
||||||
|
:model="values"
|
||||||
|
:last-values="lastValues"
|
||||||
|
:is-compare="isCompare"
|
||||||
|
:size="size"
|
||||||
|
:disabled="disabled"
|
||||||
|
@change="change"
|
||||||
|
@add-diff-count="onAddDiffCount"
|
||||||
|
></MContainer>
|
||||||
<Box
|
<Box
|
||||||
v-show="!['fixed', 'absolute'].includes(values.position)"
|
v-show="!['fixed', 'absolute'].includes(values.position)"
|
||||||
:model="values"
|
:model="values"
|
||||||
|
:last-values="lastValues"
|
||||||
|
:is-compare="isCompare"
|
||||||
:size="size"
|
:size="size"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
@change="change"
|
@change="change"
|
||||||
@ -34,12 +45,15 @@ import {
|
|||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
values: Partial<StyleSchema>;
|
values: Partial<StyleSchema>;
|
||||||
|
lastValues?: Partial<StyleSchema>;
|
||||||
|
isCompare?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
size?: 'large' | 'default' | 'small';
|
size?: 'large' | 'default' | 'small';
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
change: [v: string | StyleSchema, eventData: ContainerChangeEventData];
|
change: [v: string | StyleSchema, eventData: ContainerChangeEventData];
|
||||||
|
addDiffCount: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const config = defineFormItem({
|
const config = defineFormItem({
|
||||||
@ -185,4 +199,6 @@ const config = defineFormItem({
|
|||||||
const change = (value: string | StyleSchema, eventData: ContainerChangeEventData) => {
|
const change = (value: string | StyleSchema, eventData: ContainerChangeEventData) => {
|
||||||
emit('change', value, eventData);
|
emit('change', value, eventData);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onAddDiffCount = () => emit('addDiffCount');
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,5 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<MContainer :config="config" :model="values" :size="size" :disabled="disabled" @change="change"></MContainer>
|
<MContainer
|
||||||
|
:config="config"
|
||||||
|
:model="values"
|
||||||
|
:last-values="lastValues"
|
||||||
|
:is-compare="isCompare"
|
||||||
|
:size="size"
|
||||||
|
:disabled="disabled"
|
||||||
|
@change="change"
|
||||||
|
@add-diff-count="onAddDiffCount"
|
||||||
|
></MContainer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
@ -8,12 +17,15 @@ import type { StyleSchema } from '@tmagic/schema';
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
values: Partial<StyleSchema>;
|
values: Partial<StyleSchema>;
|
||||||
|
lastValues?: Partial<StyleSchema>;
|
||||||
|
isCompare?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
size?: 'large' | 'default' | 'small';
|
size?: 'large' | 'default' | 'small';
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
change: [v: string | StyleSchema, eventData: ContainerChangeEventData];
|
change: [v: string | StyleSchema, eventData: ContainerChangeEventData];
|
||||||
|
addDiffCount: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const positionText: Record<string, string> = {
|
const positionText: Record<string, string> = {
|
||||||
@ -100,4 +112,6 @@ const config = defineFormItem({
|
|||||||
const change = (value: string | StyleSchema, eventData: ContainerChangeEventData) => {
|
const change = (value: string | StyleSchema, eventData: ContainerChangeEventData) => {
|
||||||
emit('change', value, eventData);
|
emit('change', value, eventData);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onAddDiffCount = () => emit('addDiffCount');
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,5 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<MContainer :config="config" :model="values" :size="size" :disabled="disabled" @change="change"></MContainer>
|
<MContainer
|
||||||
|
:config="config"
|
||||||
|
:model="values"
|
||||||
|
:last-values="lastValues"
|
||||||
|
:is-compare="isCompare"
|
||||||
|
:size="size"
|
||||||
|
:disabled="disabled"
|
||||||
|
@change="change"
|
||||||
|
@add-diff-count="onAddDiffCount"
|
||||||
|
></MContainer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
@ -8,12 +17,15 @@ import type { StyleSchema } from '@tmagic/schema';
|
|||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
values: Partial<StyleSchema>;
|
values: Partial<StyleSchema>;
|
||||||
|
lastValues?: Partial<StyleSchema>;
|
||||||
|
isCompare?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
size?: 'large' | 'default' | 'small';
|
size?: 'large' | 'default' | 'small';
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
change: [v: StyleSchema, eventData: ContainerChangeEventData];
|
change: [v: StyleSchema, eventData: ContainerChangeEventData];
|
||||||
|
addDiffCount: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const config = defineFormItem({
|
const config = defineFormItem({
|
||||||
@ -51,4 +63,6 @@ const config = defineFormItem({
|
|||||||
const change = (value: StyleSchema, eventData: ContainerChangeEventData) => {
|
const change = (value: StyleSchema, eventData: ContainerChangeEventData) => {
|
||||||
emit('change', value, eventData);
|
emit('change', value, eventData);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onAddDiffCount = () => emit('addDiffCount');
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -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', () => {
|
||||||
|
|||||||
@ -44,6 +44,8 @@ export { default as stageOverlayService } from './services/stageOverlay';
|
|||||||
export { default as uiService } from './services/ui';
|
export { default as uiService } from './services/ui';
|
||||||
export { default as codeBlockService } from './services/codeBlock';
|
export { default as codeBlockService } from './services/codeBlock';
|
||||||
export { default as depService } from './services/dep';
|
export { default as depService } from './services/dep';
|
||||||
|
export { default as componentListService } from './services/componentList';
|
||||||
|
export { default as keybindingService } from './services/keybinding';
|
||||||
export { default as ComponentListPanel } from './layouts/sidebar/ComponentListPanel.vue';
|
export { default as ComponentListPanel } from './layouts/sidebar/ComponentListPanel.vue';
|
||||||
export { default as LayerPanel } from './layouts/sidebar/layer/LayerPanel.vue';
|
export { default as LayerPanel } from './layouts/sidebar/layer/LayerPanel.vue';
|
||||||
export { default as CodeSelect } from './fields/CodeSelect.vue';
|
export { default as CodeSelect } from './fields/CodeSelect.vue';
|
||||||
@ -70,6 +72,12 @@ 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 HistoryListBucketTab } from './layouts/history-list/BucketTab.vue';
|
||||||
|
export { default as HistoryDiffDialog } from './layouts/history-list/HistoryDiffDialog.vue';
|
||||||
|
export { confirmHistoryAction } from './layouts/history-list/composables';
|
||||||
|
export { useHistoryRevert } from './layouts/history-list/useHistoryRevert';
|
||||||
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';
|
||||||
|
|||||||
@ -55,7 +55,7 @@ export const initServiceState = (
|
|||||||
watch(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.modelValue,
|
||||||
(modelValue) => {
|
(modelValue) => {
|
||||||
editorService.set('root', modelValue || null);
|
editorService.set('root', modelValue || null, { historySource: 'initial' });
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
immediate: true,
|
immediate: true,
|
||||||
|
|||||||
@ -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 : minLeftColumnWidth"
|
||||||
:min-right="MIN_RIGHT_COLUMN_WIDTH"
|
:min-right="minRightColumnWidth"
|
||||||
:min-center="MIN_CENTER_COLUMN_WIDTH"
|
:min-center="minCenterColumnWidth"
|
||||||
: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>
|
||||||
|
|
||||||
@ -78,9 +78,6 @@ import { getEditorConfig } from '@editor/utils/config';
|
|||||||
import {
|
import {
|
||||||
DEFAULT_LEFT_COLUMN_WIDTH,
|
DEFAULT_LEFT_COLUMN_WIDTH,
|
||||||
LEFT_COLUMN_WIDTH_STORAGE_KEY,
|
LEFT_COLUMN_WIDTH_STORAGE_KEY,
|
||||||
MIN_CENTER_COLUMN_WIDTH,
|
|
||||||
MIN_LEFT_COLUMN_WIDTH,
|
|
||||||
MIN_RIGHT_COLUMN_WIDTH,
|
|
||||||
RIGHT_COLUMN_WIDTH_STORAGE_KEY,
|
RIGHT_COLUMN_WIDTH_STORAGE_KEY,
|
||||||
} from '@editor/utils/const';
|
} from '@editor/utils/const';
|
||||||
|
|
||||||
@ -94,10 +91,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', {});
|
||||||
@ -114,6 +113,10 @@ const showSrc = computed(() => uiService.get('showSrc'));
|
|||||||
|
|
||||||
const columnWidth = computed(() => uiService.get('columnWidth'));
|
const columnWidth = computed(() => uiService.get('columnWidth'));
|
||||||
|
|
||||||
|
const minLeftColumnWidth = computed(() => uiService.get('minLeftColumnWidth'));
|
||||||
|
const minCenterColumnWidth = computed(() => uiService.get('minCenterColumnWidth'));
|
||||||
|
const minRightColumnWidth = computed(() => uiService.get('minRightColumnWidth'));
|
||||||
|
|
||||||
watch(pageLength, () => {
|
watch(pageLength, () => {
|
||||||
splitViewRef.value?.updateWidth();
|
splitViewRef.value?.updateWidth();
|
||||||
});
|
});
|
||||||
@ -132,7 +135,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);
|
||||||
@ -163,7 +169,7 @@ onBeforeUnmount(() => {
|
|||||||
const saveCode = (value: string) => {
|
const saveCode = (value: string) => {
|
||||||
try {
|
try {
|
||||||
const parseDSL = getEditorConfig('parseDSL');
|
const parseDSL = getEditorConfig('parseDSL');
|
||||||
editorService.set('root', parseDSL(value));
|
editorService.set('root', parseDSL(value), { historySource: 'root-code' });
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
@ -89,7 +90,7 @@ const getConfig = (item: MenuItem): (MenuButton | MenuComponent)[] => {
|
|||||||
className: 'undo',
|
className: 'undo',
|
||||||
icon: markRaw(Back),
|
icon: markRaw(Back),
|
||||||
tooltip: `后退(${ctrl}+z)`,
|
tooltip: `后退(${ctrl}+z)`,
|
||||||
disabled: () => !historyService.state.canUndo,
|
disabled: () => !historyService.canUndo('page', editorService.get('page')?.id),
|
||||||
handler: () => editorService.undo(),
|
handler: () => editorService.undo(),
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
@ -99,10 +100,18 @@ const getConfig = (item: MenuItem): (MenuButton | MenuComponent)[] => {
|
|||||||
className: 'redo',
|
className: 'redo',
|
||||||
icon: markRaw(Right),
|
icon: markRaw(Right),
|
||||||
tooltip: `前进(${ctrl}+Shift+z)`,
|
tooltip: `前进(${ctrl}+Shift+z)`,
|
||||||
disabled: () => !historyService.state.canRedo,
|
disabled: () => !historyService.canRedo('page', editorService.get('page')?.id),
|
||||||
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',
|
||||||
|
|||||||
91
packages/editor/src/layouts/history-list/Bucket.vue
Normal file
91
packages/editor/src/layouts/history-list/Bucket.vue
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
<template>
|
||||||
|
<div class="m-editor-history-list-bucket">
|
||||||
|
<div class="m-editor-history-list-bucket-title">
|
||||||
|
<span>{{ config.title }}</span>
|
||||||
|
<code>{{ String(bucketId) }}</code>
|
||||||
|
<span class="m-editor-history-list-bucket-count">{{ groups.length }} 组</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="m-editor-history-list-ul">
|
||||||
|
<GroupRow
|
||||||
|
v-for="group in groups"
|
||||||
|
:key="rowKey(group)"
|
||||||
|
:group="toRow(group)"
|
||||||
|
:expanded="isHistoryGroupExpanded(expanded, rowKey(group))"
|
||||||
|
:goto-enabled="config.gotoEnabled"
|
||||||
|
@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 时即为当前位置。
|
||||||
|
config.showInitial=false 时不展示(用于没有"撤销到初始状态"语义的自定义历史,如业务模块历史)。
|
||||||
|
-->
|
||||||
|
<InitialRow
|
||||||
|
v-if="config.showInitial !== false"
|
||||||
|
:is-current="isInitial"
|
||||||
|
:goto-enabled="config.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, HistoryBucketConfig } from '@editor/type';
|
||||||
|
|
||||||
|
import type { HistoryBucketGroup, HistoryRowGroup } from './composables';
|
||||||
|
import { isHistoryGroupExpanded, toRowGroup } from './composables';
|
||||||
|
import GroupRow from './GroupRow.vue';
|
||||||
|
import InitialRow from './InitialRow.vue';
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'MEditorHistoryListBucket',
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
/**
|
||||||
|
* 该类历史的整体渲染配置(title / prefix / describe* / isStep* / showInitial / gotoEnabled)。
|
||||||
|
* 由父组件按业务类型注入,组件内部按需读取,避免逐项透传多个 props。
|
||||||
|
*/
|
||||||
|
config: HistoryBucketConfig<T>;
|
||||||
|
/** 当前 bucket 对应的目标 id(dataSource.id 或 codeBlock.id),同时用于组装子项的 key。 */
|
||||||
|
bucketId: string | number;
|
||||||
|
/** 当前 bucket 下的所有历史分组,按时间倒序展示(最近的操作在前)。 */
|
||||||
|
groups: HistoryBucketGroup<T>[];
|
||||||
|
/** 共享的折叠状态表(key -> 是否展开,缺省或 true 为展开、false 为收起),由顶层 panel 统一维护以便跨 tab 复用。 */
|
||||||
|
expanded: Record<string, boolean>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 子项 / 折叠状态 key:`${prefix}-${bucketId}-${组内首步 index}`。
|
||||||
|
* 以稳定的 step 索引(而非展示位置)标识分组,历史数据更新后已展开的分组状态仍能正确保持。
|
||||||
|
*/
|
||||||
|
const rowKey = (group: HistoryBucketGroup<T>) => `${props.config.prefix}-${props.bucketId}-${group.steps[0]?.index}`;
|
||||||
|
|
||||||
|
/** 把原始分组派生为 GroupRow 直接消费的视图模型。 */
|
||||||
|
const toRow = (group: HistoryBucketGroup<T>): HistoryRowGroup => toRowGroup(group, rowKey(group), props.config);
|
||||||
|
|
||||||
|
/** 该 bucket 是否处于初始状态(栈 cursor=0),等价于全部 group 都未 applied。 */
|
||||||
|
const isInitial = computed(() => props.groups.length > 0 && props.groups.every((g) => !g.applied));
|
||||||
|
</script>
|
||||||
72
packages/editor/src/layouts/history-list/BucketTab.vue
Normal file
72
packages/editor/src/layouts/history-list/BucketTab.vue
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="!buckets.length" class="m-editor-history-list-empty">暂无操作记录</div>
|
||||||
|
<template v-else>
|
||||||
|
<div v-if="config.showClear !== false" class="m-editor-history-list-toolbar">
|
||||||
|
<span class="m-editor-history-list-clear" :title="`清空${config.title}的历史记录`" @click="$emit('clear')"
|
||||||
|
>清空</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<TMagicScrollbar max-height="360px">
|
||||||
|
<Bucket
|
||||||
|
v-for="bucket in buckets"
|
||||||
|
:key="`${config.prefix}-${bucket.id}`"
|
||||||
|
:config="config"
|
||||||
|
:bucket-id="bucket.id"
|
||||||
|
:groups="bucket.groups"
|
||||||
|
:expanded="expanded"
|
||||||
|
@toggle="(key: string) => $emit('toggle', key)"
|
||||||
|
@goto="(id: string | number, index: number) => $emit('goto', id, index)"
|
||||||
|
@goto-initial="(id: string | number) => $emit('goto-initial', id)"
|
||||||
|
@diff-step="(id: string | number, index: number) => $emit('diff-step', id, index)"
|
||||||
|
@revert-step="(id: string | number, index: number) => $emit('revert-step', id, index)"
|
||||||
|
/>
|
||||||
|
</TMagicScrollbar>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup generic="T extends BaseStepValue = BaseStepValue">
|
||||||
|
import { TMagicScrollbar } from '@tmagic/design';
|
||||||
|
|
||||||
|
import type { BaseStepValue, HistoryBucketConfig } from '@editor/type';
|
||||||
|
|
||||||
|
import Bucket from './Bucket.vue';
|
||||||
|
import type { HistoryBucketGroup } from './composables';
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'MEditorHistoryListBucketTab',
|
||||||
|
});
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
/**
|
||||||
|
* 该类历史的整体渲染配置(title / prefix / describe* / isStep* / showInitial / gotoEnabled / showClear),
|
||||||
|
* 由父组件按业务类型注入并整体透传给 Bucket,避免逐项透传多个 props。
|
||||||
|
*/
|
||||||
|
config: HistoryBucketConfig<T>;
|
||||||
|
/**
|
||||||
|
* 已按目标 id 聚拢成的 bucket 列表,每个 bucket 内部的 groups 已按时间倒序排好。
|
||||||
|
* 空数组时显示空态。
|
||||||
|
*/
|
||||||
|
buckets: { id: string | number; groups: HistoryBucketGroup<T>[] }[];
|
||||||
|
/**
|
||||||
|
* 共享的折叠状态表(key -> 是否展开),由顶层 panel 统一维护。
|
||||||
|
* key 形如 `${prefix}-${id}-${组内首步 index}`——以稳定的 step 索引而非展示位置标识分组,
|
||||||
|
* 这样历史数据更新后已展开的分组状态仍能正确保持。
|
||||||
|
*/
|
||||||
|
expanded: Record<string, boolean>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
/** 透传子组件 Bucket 的 toggle 事件给上层 panel,由其更新 expanded。 */
|
||||||
|
(_e: 'toggle', _key: string): void;
|
||||||
|
/** 透传 Bucket 的 goto 事件,携带目标 id 与目标 step 索引。 */
|
||||||
|
(_e: 'goto', _targetId: string | number, _index: number): void;
|
||||||
|
/** 透传 Bucket 的 goto-initial 事件,携带目标 id(回到该目标未修改时的状态)。 */
|
||||||
|
(_e: 'goto-initial', _targetId: string | number): void;
|
||||||
|
/** 透传 Bucket 的 diff-step 事件,携带目标 id 与 step 索引。 */
|
||||||
|
(_e: 'diff-step', _targetId: string | number, _index: number): void;
|
||||||
|
/** 透传 Bucket 的 revert-step 事件,携带目标 id 与 step 索引(类 git revert)。 */
|
||||||
|
(_e: 'revert-step', _targetId: string | number, _index: number): void;
|
||||||
|
/** 用户点击"清空"按钮,请求清空该类(数据源 / 代码块)的全部历史记录(由上层弹窗二次确认后执行)。 */
|
||||||
|
(_e: 'clear'): void;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
304
packages/editor/src/layouts/history-list/GroupRow.vue
Normal file
304
packages/editor/src/layouts/history-list/GroupRow.vue
Normal file
@ -0,0 +1,304 @@
|
|||||||
|
<template>
|
||||||
|
<li
|
||||||
|
class="m-editor-history-list-item m-editor-history-list-group"
|
||||||
|
:class="{ 'is-undone': !group.applied, 'is-merged': merged, 'is-current': group.isCurrent }"
|
||||||
|
>
|
||||||
|
<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-${group.opType}`">{{ opLabel(group.opType) }}</span>
|
||||||
|
<span class="m-editor-history-list-item-desc">{{ group.desc }}</span>
|
||||||
|
|
||||||
|
<span v-if="headSaved" class="m-editor-history-list-item-saved" title="该记录为最近一次保存的状态">已保存</span>
|
||||||
|
|
||||||
|
<span
|
||||||
|
v-if="!merged && (headRevertable || headDiffable || canHeadGoto)"
|
||||||
|
class="m-editor-history-list-item-actions"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="headRevertable"
|
||||||
|
class="m-editor-history-list-item-revert"
|
||||||
|
title="将该步骤的修改作为一次新操作反向应用(不影响后续历史)"
|
||||||
|
@click.stop="onRevertClick(group.subSteps[0].index)"
|
||||||
|
>回滚</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="canHeadGoto"
|
||||||
|
class="m-editor-history-list-item-goto"
|
||||||
|
title="回到该记录"
|
||||||
|
@click.stop="onGotoClick(group.subSteps[0].index)"
|
||||||
|
>回到</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="headDiffable"
|
||||||
|
class="m-editor-history-list-item-diff"
|
||||||
|
title="查看修改差异"
|
||||||
|
@click.stop="onDiffClick(group.subSteps[0].index)"
|
||||||
|
>查看差异</span
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span
|
||||||
|
v-if="!merged && sourceLabel(group.source)"
|
||||||
|
class="m-editor-history-list-item-source"
|
||||||
|
:title="`操作途径:${sourceLabel(group.source)}`"
|
||||||
|
>{{ sourceLabel(group.source) }}</span
|
||||||
|
>
|
||||||
|
|
||||||
|
<span
|
||||||
|
v-if="!merged && group.operator"
|
||||||
|
class="m-editor-history-list-item-operator"
|
||||||
|
:title="`操作人:${group.operator}`"
|
||||||
|
>{{ group.operator }}</span
|
||||||
|
>
|
||||||
|
|
||||||
|
<span
|
||||||
|
v-if="!merged && group.time"
|
||||||
|
class="m-editor-history-list-item-time"
|
||||||
|
:title="group.timeTitle || group.time"
|
||||||
|
>{{ group.time }}</span
|
||||||
|
>
|
||||||
|
|
||||||
|
<span v-if="merged" class="m-editor-history-list-item-merge">合并 {{ stepCount }} 步</span>
|
||||||
|
<span v-if="merged" 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, 'is-clickable': selectEnabled }"
|
||||||
|
:title="subStepTitle(s)"
|
||||||
|
@click="onSubStepClick(s.index)"
|
||||||
|
>
|
||||||
|
<span class="m-editor-history-list-item-index">#{{ s.index + 1 }}</span>
|
||||||
|
<span class="m-editor-history-list-substep-desc">{{ s.desc }}</span>
|
||||||
|
<span v-if="s.saved" class="m-editor-history-list-item-saved" title="该记录为最近一次保存的状态">已保存</span>
|
||||||
|
<span
|
||||||
|
v-if="s.revertable || s.diffable || (gotoEnabled && !s.isCurrent)"
|
||||||
|
class="m-editor-history-list-item-actions"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="s.revertable"
|
||||||
|
class="m-editor-history-list-item-revert"
|
||||||
|
title="将该步骤的修改作为一次新操作反向应用(不影响后续历史)"
|
||||||
|
@click.stop="onRevertClick(s.index)"
|
||||||
|
>回滚</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="gotoEnabled && !s.isCurrent"
|
||||||
|
class="m-editor-history-list-item-goto"
|
||||||
|
title="回到该记录"
|
||||||
|
@click.stop="onGotoClick(s.index)"
|
||||||
|
>回到</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="s.diffable"
|
||||||
|
class="m-editor-history-list-item-diff"
|
||||||
|
title="查看修改差异"
|
||||||
|
@click.stop="onDiffClick(s.index)"
|
||||||
|
>查看差异</span
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="sourceLabel(s.source)"
|
||||||
|
class="m-editor-history-list-item-source"
|
||||||
|
:title="`操作途径:${sourceLabel(s.source)}`"
|
||||||
|
>{{ sourceLabel(s.source) }}</span
|
||||||
|
>
|
||||||
|
<span v-if="s.operator" class="m-editor-history-list-item-operator" :title="`操作人:${s.operator}`">{{
|
||||||
|
s.operator
|
||||||
|
}}</span>
|
||||||
|
<span v-if="s.time" class="m-editor-history-list-item-time" :title="s.timeTitle || s.time">{{ s.time }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
import type { HistoryRowGroup, HistoryRowStep } from './composables';
|
||||||
|
import { opLabel, sourceLabel } from './composables';
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'MEditorHistoryListGroupRow',
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
/**
|
||||||
|
* 该组的视图模型(由 `toRowGroup` 统一派生):包含 key、应用状态、操作类型、描述、
|
||||||
|
* 来源 / 时间等头部信息以及子步列表。原先散落的十余个扁平 props 收敛于此单一对象。
|
||||||
|
*/
|
||||||
|
group: HistoryRowGroup;
|
||||||
|
/** 当前组是否处于展开状态(合并组默认展开)。仅在合并组(子步数 > 1)时生效,控制子步列表是否渲染。 */
|
||||||
|
expanded: boolean;
|
||||||
|
/**
|
||||||
|
* 是否支持「跳转到该记录」(goto)。默认 true。
|
||||||
|
* 为 false 时:单步组头部与子步条目都不再可点击跳转、也不会触发 goto 事件,
|
||||||
|
* 仅保留合并组头部的展开 / 收起能力,以及查看差异、回滚等其它入口。
|
||||||
|
*/
|
||||||
|
gotoEnabled?: boolean;
|
||||||
|
/**
|
||||||
|
* 是否支持「点击记录选中对应节点」。默认 false(仅页面 tab 启用,数据源 / 代码块无节点概念)。
|
||||||
|
* 为 true 时:点击单步组头部、子步条目或合并组头部都会发出 `select` 事件(携带对应 step 索引),
|
||||||
|
* 由上层解析出节点 id 并在画布选中;合并组头部同时保留展开 / 收起能力。
|
||||||
|
*/
|
||||||
|
selectEnabled?: boolean;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
gotoEnabled: true,
|
||||||
|
selectEnabled: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
/**
|
||||||
|
* 用户点击合并组头部时触发,携带 group.key;上层用其切换 expanded 状态。
|
||||||
|
* 对单步组(非合并)头部点击不会发该事件——因为单步组没有"展开"的概念。
|
||||||
|
*/
|
||||||
|
(_e: 'toggle', _key: string): void;
|
||||||
|
/**
|
||||||
|
* 用户希望跳转到该记录时触发,携带"目标 step 在所属栈中的索引"——上层据此计算目标 cursor (= index + 1)。
|
||||||
|
* 触发场景:
|
||||||
|
* - 单步组(非合并)头部:取该唯一 step 的 index;
|
||||||
|
* - 子步条目:取该子步的 index。
|
||||||
|
* 合并组头部不再触发 goto,避免与展开/收起冲突;用户应展开后点具体子步精准跳转。
|
||||||
|
* 当前所在的步骤(isCurrent)始终不会触发 goto。
|
||||||
|
*/
|
||||||
|
(_e: 'goto', _index: number): void;
|
||||||
|
/**
|
||||||
|
* 用户希望查看该 step 的修改差异(旧值 vs 新值)。
|
||||||
|
* 只在 step 满足"前后值都存在"(如 update / 数据源、代码块的 update)时由 `toRowGroup` 标记 `diffable=true`。
|
||||||
|
* payload 为该 step 在所属栈中的索引,由上层根据 index 取 step 内容并展示对比。
|
||||||
|
*/
|
||||||
|
(_e: 'diff-step', _index: number): void;
|
||||||
|
/**
|
||||||
|
* 用户希望「回滚」该 step——把它的修改作为一次新操作反向应用(类 git revert)。
|
||||||
|
* payload 为该 step 在所属栈中的索引。仅在单步组头部(headRevertable)或合并组的可回滚子步上触发。
|
||||||
|
*/
|
||||||
|
(_e: 'revert-step', _index: number): void;
|
||||||
|
/**
|
||||||
|
* 用户希望「选中该记录对应的节点」。payload 为该 step 在所属栈中的索引,
|
||||||
|
* 由上层据 index 取出 step、解析出节点 id 并在画布选中。仅在 `selectEnabled` 为 true 时触发。
|
||||||
|
*/
|
||||||
|
(_e: 'select', _index: number): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
/** 子步数大于 1 即为合并组:决定是否展示合并标记与可展开的子步列表。 */
|
||||||
|
const merged = computed(() => props.group.subSteps.length > 1);
|
||||||
|
|
||||||
|
/** 组内 step 总数,仅在合并组时显示为 "合并 N 步"。 */
|
||||||
|
const stepCount = computed(() => props.group.subSteps.length);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 头部可点击的场景:
|
||||||
|
* - 合并组:点击切换展开 / 收起;
|
||||||
|
* - 开启 `selectEnabled`(页面 tab):点击选中对应节点。
|
||||||
|
* 单步组的跳转仍由头部的「回到」按钮触发。
|
||||||
|
*/
|
||||||
|
const isHeadClickable = computed(() => merged.value || props.selectEnabled);
|
||||||
|
|
||||||
|
const headTitle = computed(() => {
|
||||||
|
if (merged.value) {
|
||||||
|
const expandHint = props.expanded ? '点击收起子步' : '点击展开子步';
|
||||||
|
return props.selectEnabled ? `${expandHint}(并选中该节点)` : expandHint;
|
||||||
|
}
|
||||||
|
if (props.selectEnabled) return '点击选中该节点';
|
||||||
|
if (props.group.isCurrent) return '当前所在记录';
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 头部点击行为:
|
||||||
|
* - 开启 selectEnabled 时,发出 select(携带组内首步 index,上层据此选中节点);
|
||||||
|
* - 合并组同时切换展开 / 收起。
|
||||||
|
*/
|
||||||
|
const onHeadClick = () => {
|
||||||
|
if (props.selectEnabled && props.group.subSteps.length) {
|
||||||
|
emit('select', props.group.subSteps[0].index);
|
||||||
|
}
|
||||||
|
if (merged.value) {
|
||||||
|
emit('toggle', props.group.key);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onGotoClick = (index: number) => {
|
||||||
|
if (!props.gotoEnabled) return;
|
||||||
|
emit('goto', index);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 点击子步行:开启 selectEnabled 时选中该子步对应的节点。 */
|
||||||
|
const onSubStepClick = (index: number) => {
|
||||||
|
if (!props.selectEnabled) return;
|
||||||
|
emit('select', index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const subStepTitle = (s: { isCurrent?: boolean }) => {
|
||||||
|
if (props.selectEnabled) return '点击选中该节点';
|
||||||
|
if (s.isCurrent) return '当前所在记录';
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 头部是否展示「已保存」标记:
|
||||||
|
* - 单步组:取该唯一子步的 saved;
|
||||||
|
* - 合并组:组内任一子步为已保存即在头部提示(具体落在哪一步可展开查看)。
|
||||||
|
*/
|
||||||
|
const headSaved = computed(() =>
|
||||||
|
merged.value ? props.group.subSteps.some((s) => s.saved) : Boolean(props.group.subSteps[0]?.saved),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 单步组头部是否展示"查看差异"入口:要求该唯一子步本身可对比。 */
|
||||||
|
const headDiffable = computed(() => !merged.value && Boolean(props.group.subSteps[0]?.diffable));
|
||||||
|
|
||||||
|
/** 单步组头部是否展示"回滚"入口:要求该唯一子步本身可回滚(已应用)。 */
|
||||||
|
const headRevertable = computed(() => !merged.value && Boolean(props.group.subSteps[0]?.revertable));
|
||||||
|
|
||||||
|
/** 单步组头部是否展示"回到"入口:可跳转、非当前、且存在唯一子步。 */
|
||||||
|
const canHeadGoto = computed(
|
||||||
|
() => !merged.value && props.gotoEnabled && !props.group.isCurrent && props.group.subSteps.length > 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合并组展开后的子步渲染顺序:与外层分组列表保持一致——倒序展示(最新的子步在最上方)。
|
||||||
|
* 外层 page tab / bucket 都已对 groups 做了 reverse,子步沿用同样的视觉规则更直观。
|
||||||
|
* 注意:仅用于渲染,原 `subSteps` 保持时间正序,`headIndexLabel` 等基于首尾索引的展示语义不变。
|
||||||
|
*/
|
||||||
|
const subStepsDisplay = computed<HistoryRowStep[]>(() => props.group.subSteps.slice().reverse());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 头部索引展示:
|
||||||
|
* - 单步组(非合并):显示该唯一 step 的编号,如 `#5`;
|
||||||
|
* - 合并组:显示组内 step 的编号范围,如 `#3-#7`(首尾相同则退化为 `#5`)。
|
||||||
|
*
|
||||||
|
* 这里展示的是 step.index + 1(与子步列表 `#{{ s.index + 1 }}` 保持一致),从 1 起编号更符合直觉。
|
||||||
|
*/
|
||||||
|
const headIndexLabel = computed(() => {
|
||||||
|
const list = props.group.subSteps;
|
||||||
|
if (!list.length) return '';
|
||||||
|
const first = list[0].index + 1;
|
||||||
|
const last = list[list.length - 1].index + 1;
|
||||||
|
if (!merged.value || first === last) return `#${first}`;
|
||||||
|
return `#${first}-#${last}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const headIndexTitle = computed(() => {
|
||||||
|
const list = props.group.subSteps;
|
||||||
|
if (!merged.value) return `历史步骤编号 #${list[0]?.index + 1}`;
|
||||||
|
return `合并了第 ${list[0]?.index + 1} 至第 ${list[list.length - 1]?.index + 1} 共 ${list.length} 条历史步骤`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const onDiffClick = (index: number) => {
|
||||||
|
emit('diff-step', index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRevertClick = (index: number) => {
|
||||||
|
emit('revert-step', index);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
291
packages/editor/src/layouts/history-list/HistoryDiffDialog.vue
Normal file
291
packages/editor/src/layouts/history-list/HistoryDiffDialog.vue
Normal file
@ -0,0 +1,291 @@
|
|||||||
|
<template>
|
||||||
|
<TMagicDialog
|
||||||
|
v-model="visible"
|
||||||
|
class="m-editor-history-diff-dialog"
|
||||||
|
:title="dialogTitle"
|
||||||
|
top="5vh"
|
||||||
|
destroy-on-close
|
||||||
|
append-to-body
|
||||||
|
:width="width"
|
||||||
|
@close="onClose"
|
||||||
|
>
|
||||||
|
<div v-if="payload && visible" class="m-editor-history-diff-dialog-body">
|
||||||
|
<div v-if="onConfirm" class="m-editor-history-diff-dialog-notice">仅回滚有差异的字段</div>
|
||||||
|
|
||||||
|
<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" :disabled="!hasValue">与修改前对比</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"
|
||||||
|
:base-form-state="compareFormState"
|
||||||
|
:extend-state="extendState"
|
||||||
|
:load-config="loadConfig"
|
||||||
|
:self-diff-field-types="selfDiffFieldTypes"
|
||||||
|
:services="props.services"
|
||||||
|
:size="props.size"
|
||||||
|
height="70vh"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CodeEditor
|
||||||
|
v-else
|
||||||
|
type="diff"
|
||||||
|
language="json"
|
||||||
|
:init-values="leftValue"
|
||||||
|
:modified-values="rightValue"
|
||||||
|
:options="codeDiffOptions"
|
||||||
|
disabled-full-screen
|
||||||
|
height="70vh"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<template v-if="isConfirm">
|
||||||
|
<TMagicButton size="small" @click="visible = false">取消</TMagicButton>
|
||||||
|
<TMagicButton size="small" type="primary" @click="onConfirmClick">确定回滚</TMagicButton>
|
||||||
|
</template>
|
||||||
|
<TMagicButton v-else size="small" @click="visible = false">关闭</TMagicButton>
|
||||||
|
</template>
|
||||||
|
</TMagicDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { isEqual } from 'lodash-es';
|
||||||
|
|
||||||
|
import {
|
||||||
|
type FieldSize,
|
||||||
|
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, Services } from '@editor/type';
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'MEditorHistoryDiffDialog',
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
/** 编辑器服务集合,由调用方传入(不再通过 inject('services') 获取)。 */
|
||||||
|
services?: Services;
|
||||||
|
/**
|
||||||
|
* 来自 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;
|
||||||
|
/** 差异对比表单内组件的尺寸(透传给 CompareForm 的 `size`),可选 'large' | 'default' | 'small'。 */
|
||||||
|
size?: FieldSize;
|
||||||
|
isConfirm?: boolean;
|
||||||
|
onConfirm?: () => void;
|
||||||
|
selfDiffFieldTypes?: string[];
|
||||||
|
compareFormState?: FormState;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
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 hasValue = computed(() => payload.value?.value !== undefined && payload.value?.value !== null);
|
||||||
|
|
||||||
|
/** 指定模式当前是否可用:before 依赖「修改后的值」,current 依赖「当前值」。 */
|
||||||
|
const isModeAvailable = (m: DiffMode): boolean => (m === 'current' ? hasCurrent.value : hasValue.value);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算 open 时的初始对比模式:
|
||||||
|
* - 调用方通过 payload.mode 指定且该模式可用时,优先使用指定模式;
|
||||||
|
* - 否则没有「修改后的值」但有当前值时(「与修改前对比」不可用),默认进入「与当前对比」;
|
||||||
|
* - 其余情况默认「与修改前对比」。
|
||||||
|
*/
|
||||||
|
const resolveInitialMode = (specified?: DiffMode): DiffMode => {
|
||||||
|
if (specified && isModeAvailable(specified)) return specified;
|
||||||
|
return !hasValue.value && hasCurrent.value ? 'current' : 'before';
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 左侧(旧/参照)值 */
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** confirm() 的 resolve,仅在「等待用户确认回滚」期间存在 */
|
||||||
|
let confirmResolve: ((_value: boolean) => void) | null = null;
|
||||||
|
|
||||||
|
const onConfirmClick = () => {
|
||||||
|
props.onConfirm?.();
|
||||||
|
|
||||||
|
// 用户确认回滚:resolve(true),并清空以避免随后 visible=false 再 resolve(false)
|
||||||
|
confirmResolve?.(true);
|
||||||
|
confirmResolve = null;
|
||||||
|
|
||||||
|
visible.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const targetText = computed(() => {
|
||||||
|
if (!payload.value) return '';
|
||||||
|
const categoryText: Record<CompareCategory, string> = {
|
||||||
|
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;
|
||||||
|
// 每次打开按 payload 重置对比模式(hasValue / hasCurrent 依赖 payload,需先赋值):
|
||||||
|
// 优先使用 payload.mode 指定的模式;不可用或未指定时按数据自动推断。
|
||||||
|
mode.value = resolveInitialMode(p.mode);
|
||||||
|
// 默认回到表单对比形态,避免残留上一次选择的源码模式
|
||||||
|
viewMode.value = 'form';
|
||||||
|
visible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 以 Promise 形式打开确认回滚弹窗:
|
||||||
|
* - 用户点击「确定回滚」时 resolve(true);
|
||||||
|
* - 取消 / 关闭 / 按 Esc 等其他方式关闭弹窗时 resolve(false)。
|
||||||
|
*
|
||||||
|
* 同一时刻只允许一个待确认流程,重复调用会先 resolve(false) 掉上一个。
|
||||||
|
*/
|
||||||
|
const confirm = (p: DiffDialogPayload): Promise<boolean> => {
|
||||||
|
// 终止上一个未完成的确认流程,避免悬挂的 Promise
|
||||||
|
confirmResolve?.(false);
|
||||||
|
confirmResolve = null;
|
||||||
|
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
confirmResolve = resolve;
|
||||||
|
open(p);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
visible.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 关闭后清理 payload,避免下一次打开时残留旧值闪现
|
||||||
|
watch(visible, (v) => {
|
||||||
|
if (!v) {
|
||||||
|
payload.value = null;
|
||||||
|
// 非「确定回滚」方式关闭(取消 / Esc / 点遮罩等)时,resolve(false)
|
||||||
|
confirmResolve?.(false);
|
||||||
|
confirmResolve = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const onClose = () => {
|
||||||
|
emit('close');
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
open,
|
||||||
|
confirm,
|
||||||
|
close,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
367
packages/editor/src/layouts/history-list/HistoryListPanel.vue
Normal file
367
packages/editor/src/layouts/history-list/HistoryListPanel.vue
Normal file
@ -0,0 +1,367 @@
|
|||||||
|
<template>
|
||||||
|
<TMagicPopover
|
||||||
|
popper-class="m-editor-history-list-popover"
|
||||||
|
placement="bottom"
|
||||||
|
trigger="click"
|
||||||
|
v-model: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: `${pageName} (${pageGroups.length})`, lazy: true }) || {}
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<PageTab
|
||||||
|
:list="pageGroupsDisplay"
|
||||||
|
:expanded="expanded"
|
||||||
|
:marker="pageMarker"
|
||||||
|
@toggle="toggleGroup"
|
||||||
|
@goto="onPageGoto"
|
||||||
|
@goto-initial="onPageGotoInitial"
|
||||||
|
@diff-step="onPageDiff"
|
||||||
|
@revert-step="onPageRevert"
|
||||||
|
@select="onPageSelect"
|
||||||
|
@clear="onPageClear"
|
||||||
|
/>
|
||||||
|
</component>
|
||||||
|
|
||||||
|
<component
|
||||||
|
v-if="!disabledDataSource"
|
||||||
|
:is="tabPaneComponent?.component || 'el-tab-pane'"
|
||||||
|
v-bind="
|
||||||
|
tabPaneComponent?.props({
|
||||||
|
name: 'data-source',
|
||||||
|
label: `${dataSourceName} (${dataSourceGroups.length})`,
|
||||||
|
lazy: true,
|
||||||
|
}) || {}
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<BucketTab
|
||||||
|
:config="dataSourceConfig"
|
||||||
|
:buckets="dataSourceGroupsByTarget"
|
||||||
|
:expanded="expanded"
|
||||||
|
@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: `${codeBlockName} (${codeBlockGroups.length})`,
|
||||||
|
lazy: true,
|
||||||
|
}) || {}
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<BucketTab
|
||||||
|
:config="codeBlockConfig"
|
||||||
|
:buckets="codeBlockGroupsByTarget"
|
||||||
|
:expanded="expanded"
|
||||||
|
@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), lazy: true }) || {}"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 历史记录面板:在顶部 NavMenu 上点击图标打开 popover,分三个 tab:
|
||||||
|
* - 页面:当前活动页面的历史栈,连续修改同一节点的多步会被合并成一组
|
||||||
|
* - 数据源:以 dataSource.id 分桶,每条操作记录独立展示
|
||||||
|
* - 代码块:同上,按 codeBlock.id 分桶,每条操作记录独立展示
|
||||||
|
*
|
||||||
|
* 数据通过 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, watch } from 'vue';
|
||||||
|
import { Clock, Close } from '@element-plus/icons-vue';
|
||||||
|
|
||||||
|
import { getDesignConfig, TMagicButton, tMagicMessage, 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 {
|
||||||
|
BaseStepValue,
|
||||||
|
CodeBlockStepValue,
|
||||||
|
DataSourceStepValue,
|
||||||
|
HistoryBucketConfig,
|
||||||
|
HistoryListExtraTab,
|
||||||
|
} from '@editor/type';
|
||||||
|
|
||||||
|
import BucketTab from './BucketTab.vue';
|
||||||
|
import { confirmHistoryAction, describeStep, isSingleDiffStepRevertable } from './composables';
|
||||||
|
import PageTab from './PageTab.vue';
|
||||||
|
import { useHistoryList } from './useHistoryList';
|
||||||
|
import { useHistoryRevert } from './useHistoryRevert';
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'MEditorHistoryListPanel',
|
||||||
|
});
|
||||||
|
|
||||||
|
const ClockIcon = markRaw(Clock);
|
||||||
|
const CloseIcon = markRaw(Close);
|
||||||
|
const activeTab = ref<string>('page');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 面板显隐受控:reference 图标点击切换,右上角关闭按钮置为 false。
|
||||||
|
* 点击面板以外区域的自动收起由 TMagicPopover 通过 v-model:visible 回写完成。
|
||||||
|
*/
|
||||||
|
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 services = useServices();
|
||||||
|
const { editorService, dataSourceService, codeBlockService, historyService, propsService, stageOverlayService } =
|
||||||
|
services;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据源 / 代码块功能可被业务方通过 `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 getPropsPanelFormState = inject<(() => FormState | undefined) | undefined>('getPropsPanelFormState', undefined);
|
||||||
|
|
||||||
|
const {
|
||||||
|
expanded,
|
||||||
|
toggleGroup,
|
||||||
|
pageGroups,
|
||||||
|
dataSourceGroups,
|
||||||
|
codeBlockGroups,
|
||||||
|
pageGroupsDisplay,
|
||||||
|
dataSourceGroupsByTarget,
|
||||||
|
codeBlockGroupsByTarget,
|
||||||
|
} = useHistoryList();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前活动页的「加载/初始」标记记录(设置 root 时生成),透传给 PageTab 的底部初始行展示。
|
||||||
|
* 基于 historyService 的 reactive state 派生,活动页切换或标记写入后自动刷新。
|
||||||
|
*/
|
||||||
|
const pageMarker = computed(() => historyService.getMarker('page', editorService.get('page')?.id));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 各历史类型的展示名称(页面 / 数据源 / 代码块),由 historyService.state.stepNames 配置,
|
||||||
|
* 业务方可通过 historyService.setStepName / registerStepType 覆盖。基于 reactive state 派生,配置变更后自动刷新。
|
||||||
|
*/
|
||||||
|
const pageName = computed(() => historyService.getStepName('page'));
|
||||||
|
const dataSourceName = computed(() => historyService.getStepName('dataSource'));
|
||||||
|
const codeBlockName = computed(() => historyService.getStepName('codeBlock'));
|
||||||
|
|
||||||
|
/** 代码块 step 仅 update(前后 content 都存在)时可查看差异。 */
|
||||||
|
const isStepDiffable = (step: BaseStepValue) => Boolean(step.diff?.[0]?.oldSchema && step.diff?.[0]?.newSchema);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据源 / 代码块两类 bucket 历史的整体渲染配置:把 title / prefix 与各自的描述、
|
||||||
|
* 可差异、可回滚判定收敛为单一对象整体注入 BucketTab,组件内部按需读取。
|
||||||
|
* title / 描述回退名取自可配置的展示名称,故用 computed 使其随 stepNames 变更刷新。
|
||||||
|
*/
|
||||||
|
// 数据源/代码块不做相邻合并,每组恒为单步,省略 describeGroup,由 toRowGroup 回退到 describeStep。
|
||||||
|
const dataSourceConfig = computed<HistoryBucketConfig<DataSourceStepValue>>(() => ({
|
||||||
|
title: dataSourceName.value,
|
||||||
|
prefix: 'ds',
|
||||||
|
describeStep: (step: DataSourceStepValue): string =>
|
||||||
|
describeStep(step, (schema) => schema?.title, dataSourceName.value),
|
||||||
|
isStepDiffable,
|
||||||
|
isStepRevertable: isSingleDiffStepRevertable,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const codeBlockConfig = computed<HistoryBucketConfig<CodeBlockStepValue>>(() => ({
|
||||||
|
title: codeBlockName.value,
|
||||||
|
prefix: 'cb',
|
||||||
|
describeStep: (step: CodeBlockStepValue): string =>
|
||||||
|
describeStep(step, (content) => content?.name, codeBlockName.value),
|
||||||
|
isStepDiffable,
|
||||||
|
isStepRevertable: isSingleDiffStepRevertable,
|
||||||
|
}));
|
||||||
|
|
||||||
|
/** 把"目标 step 索引"翻译成"目标 cursor"(已应用步骤数量)。 */
|
||||||
|
const indexToCursor = (index: number) => index + 1;
|
||||||
|
|
||||||
|
const onPageGoto = (index: number) => {
|
||||||
|
editorService.gotoPageStep(indexToCursor(index));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 点击页面历史记录行:选中该记录对应的画布节点。
|
||||||
|
* - 从目标 step 的 diff 中取节点 id(优先 newSchema,回退 oldSchema),按出现顺序找到第一个当前仍存在的节点;
|
||||||
|
* - 与图层树点击选中一致:editorService.select + 画布 / overlay 画布 select 三者联动;
|
||||||
|
* - 该 step 涉及的节点都已不存在(如删除记录、被撤销的新增)时给出提示,不做选中。
|
||||||
|
*/
|
||||||
|
const onPageSelect = async (index: number) => {
|
||||||
|
const step = historyService.getStepList('page', editorService.get('page')?.id)[index]?.step;
|
||||||
|
if (!step) return;
|
||||||
|
const targetId = (step.diff ?? [])
|
||||||
|
.map((item) => item.newSchema?.id ?? item.oldSchema?.id)
|
||||||
|
.find((id) => id !== undefined && id !== null && editorService.getNodeById(id, false));
|
||||||
|
if (targetId === undefined || targetId === null) {
|
||||||
|
tMagicMessage.warning('该记录对应的节点已不存在,无法选中');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const node = editorService.getNodeById(targetId, false);
|
||||||
|
if (!node) return;
|
||||||
|
await editorService.select(node);
|
||||||
|
editorService.get('stage')?.select(targetId);
|
||||||
|
stageOverlayService.get('stage')?.select(targetId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDataSourceGoto = (id: string | number, index: number) => {
|
||||||
|
dataSourceService.goto(id, indexToCursor(index));
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「单步回滚」与「查看差异」的完整逻辑收敛到 useHistoryRevert,面板与业务方共用:
|
||||||
|
* 二者均由 useHistoryRevert 内部按需动态挂载 HistoryDiffDialog,
|
||||||
|
* 业务方亦可直接 import useHistoryRevert(options, services) 调用,无需自行挂载任何弹窗。
|
||||||
|
*/
|
||||||
|
const { onPageRevert, onDataSourceRevert, onCodeBlockRevert, onPageDiff, onDataSourceDiff, onCodeBlockDiff } =
|
||||||
|
useHistoryRevert({ extendState: extendFormState, getPropsPanelFormState }, services);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把内存中(已清空对应类别后的)历史状态重新写回 IndexedDB,
|
||||||
|
* 使本地持久化的那份与内存保持一致——即「连同本地保存的一并删除」。
|
||||||
|
* 不支持 IndexedDB 或写入失败时静默忽略(内存清空已生效)。
|
||||||
|
*/
|
||||||
|
const syncIndexedDB = async () => {
|
||||||
|
try {
|
||||||
|
await historyService.saveToIndexedDB();
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
} catch (e) {
|
||||||
|
// ignore: 内存清空已生效,本地同步失败不阻塞交互
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPageClear = async () => {
|
||||||
|
if (
|
||||||
|
await confirmHistoryAction(
|
||||||
|
'确定清空当前页面的历史记录吗?清空后将无法撤销/重做之前的操作,本地保存的记录也会一并删除。',
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
historyService.clear('page', editorService.get('page')?.id);
|
||||||
|
await syncIndexedDB();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDataSourceClear = async () => {
|
||||||
|
if (
|
||||||
|
await confirmHistoryAction(
|
||||||
|
'确定清空数据源的历史记录吗?清空后将无法撤销/重做之前的操作,本地保存的记录也会一并删除。',
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
historyService.clear('dataSource');
|
||||||
|
await syncIndexedDB();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCodeBlockClear = async () => {
|
||||||
|
if (
|
||||||
|
await confirmHistoryAction(
|
||||||
|
'确定清空代码块的历史记录吗?清空后将无法撤销/重做之前的操作,本地保存的记录也会一并删除。',
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
historyService.clear('codeBlock');
|
||||||
|
await syncIndexedDB();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
71
packages/editor/src/layouts/history-list/InitialRow.vue
Normal file
71
packages/editor/src/layouts/history-list/InitialRow.vue
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
<template>
|
||||||
|
<li
|
||||||
|
class="m-editor-history-list-item m-editor-history-list-initial"
|
||||||
|
:class="{ 'is-current': isCurrent, 'is-clickable': !isCurrent }"
|
||||||
|
:title="rowTitle"
|
||||||
|
>
|
||||||
|
<span class="m-editor-history-list-item-index" title="历史步骤编号 #0(未修改的初始状态)">#0</span>
|
||||||
|
<span class="m-editor-history-list-item-op op-initial">初始</span>
|
||||||
|
<span class="m-editor-history-list-item-desc">{{ desc }}</span>
|
||||||
|
<span v-if="saved" class="m-editor-history-list-item-saved" title="该记录为最近一次保存的状态">已保存</span>
|
||||||
|
<span v-if="gotoEnabled && !isCurrent" class="m-editor-history-list-item-actions">
|
||||||
|
<span class="m-editor-history-list-item-goto" title="回到该记录" @click.stop="onClick">回到</span>
|
||||||
|
</span>
|
||||||
|
<span v-if="time" class="m-editor-history-list-item-time" :title="timeTitle">{{ time }}</span>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 「初始状态」记录行:渲染于历史列表底部,作为整个栈的"零点"。
|
||||||
|
* - 点击该行会把对应栈撤销到 cursor === 0(即没有任何已应用步骤),等同于回到所有修改之前。
|
||||||
|
* - 当对应栈本身已处于 cursor === 0 时(isCurrent=true),用户已在初始状态,点击不再触发动作。
|
||||||
|
* - 当上层传入 `marker`(设置 root 时为该页生成的「未修改的初始状态」标记)时,
|
||||||
|
* 用标记的文案与时间渲染本行;标记不进入撤销/重做栈,仅作为该页基线展示。
|
||||||
|
*
|
||||||
|
* 该行不是真实 step,仅作为 UI 入口;上层负责把"点击"翻译为 `service.goto*(0)`。
|
||||||
|
*/
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
import type { StepValue } from '@editor/type';
|
||||||
|
|
||||||
|
import { formatHistoryFullTime, formatHistoryTime } from './composables';
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'MEditorHistoryListInitialRow',
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
/** 当前对应栈是否已经处于初始状态 (cursor === 0)。true 时用蓝条高亮并禁用点击。 */
|
||||||
|
isCurrent: boolean;
|
||||||
|
gotoEnabled?: boolean;
|
||||||
|
/** 该页面的「加载/初始」基线记录(设置 root 时生成的 `opType: 'initial'` StepValue);提供时用其文案与时间展示。 */
|
||||||
|
marker?: StepValue;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
gotoEnabled: true,
|
||||||
|
marker: undefined,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const desc = computed(() => props.marker?.historyDescription || '未修改的初始状态');
|
||||||
|
/** 基线(初始状态)是否为最近一次保存点:仅页面栈的 `initial` 基线 step 会被 markSaved 标记。 */
|
||||||
|
const saved = computed(() => Boolean(props.marker?.saved));
|
||||||
|
const time = computed(() => formatHistoryTime(props.marker?.timestamp));
|
||||||
|
const timeTitle = computed(() => formatHistoryFullTime(props.marker?.timestamp));
|
||||||
|
const rowTitle = computed(() => {
|
||||||
|
const base = props.marker?.historyDescription || '未修改的初始状态';
|
||||||
|
return props.isCurrent ? `当前已回到${base}` : `点击回到${base}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
/** 点击非当前的初始项时触发,由上层调用对应 service 的 goto 把 cursor 移到 0。 */
|
||||||
|
(_e: 'goto-initial'): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const onClick = () => {
|
||||||
|
if (props.isCurrent) return;
|
||||||
|
emit('goto-initial');
|
||||||
|
};
|
||||||
|
</script>
|
||||||
118
packages/editor/src/layouts/history-list/PageTab.vue
Normal file
118
packages/editor/src/layouts/history-list/PageTab.vue
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="!list.length && !marker" class="m-editor-history-list-empty">暂无操作记录</div>
|
||||||
|
<template v-else>
|
||||||
|
<div v-if="list.length" class="m-editor-history-list-toolbar">
|
||||||
|
<span class="m-editor-history-list-clear" title="清空当前页面的历史记录" @click="$emit('clear')">清空</span>
|
||||||
|
</div>
|
||||||
|
<TMagicScrollbar max-height="360px">
|
||||||
|
<ul class="m-editor-history-list-ul">
|
||||||
|
<GroupRow
|
||||||
|
v-for="group in list"
|
||||||
|
:key="rowKey(group)"
|
||||||
|
:group="toRow(group)"
|
||||||
|
:expanded="isHistoryGroupExpanded(expanded, rowKey(group))"
|
||||||
|
:select-enabled="true"
|
||||||
|
@toggle="(key: string) => $emit('toggle', key)"
|
||||||
|
@goto="(index: number) => $emit('goto', index)"
|
||||||
|
@diff-step="(index: number) => $emit('diff-step', index)"
|
||||||
|
@revert-step="(index: number) => $emit('revert-step', index)"
|
||||||
|
@select="(index: number) => $emit('select', index)"
|
||||||
|
/>
|
||||||
|
<!--
|
||||||
|
初始状态项:永远位于列表底部(页面 tab 倒序展示,最底部=最早),
|
||||||
|
作为"未修改"零点。当所有 group 都未 applied 时它即为当前位置。
|
||||||
|
设置 root 时生成的「未修改的初始状态」标记(marker)会作为该行的文案与时间来源。
|
||||||
|
-->
|
||||||
|
<InitialRow :is-current="isInitial" :marker="marker" @goto-initial="$emit('goto-initial')" />
|
||||||
|
</ul>
|
||||||
|
</TMagicScrollbar>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
import { TMagicScrollbar } from '@tmagic/design';
|
||||||
|
|
||||||
|
import type { HistoryGroup, HistoryRowDescriptor, StepValue } from '@editor/type';
|
||||||
|
|
||||||
|
import type { HistoryRowGroup } from './composables';
|
||||||
|
import {
|
||||||
|
describePageGroup,
|
||||||
|
describePageStep,
|
||||||
|
isHistoryGroupExpanded,
|
||||||
|
isPageStepRevertable,
|
||||||
|
toRowGroup,
|
||||||
|
} from './composables';
|
||||||
|
import GroupRow from './GroupRow.vue';
|
||||||
|
import InitialRow from './InitialRow.vue';
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'MEditorHistoryListPageTab',
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
/** 当前活动页面的历史分组列表,已按时间倒序排好(最新一组在最前)。空数组时显示空态。 */
|
||||||
|
list: HistoryGroup<StepValue>[];
|
||||||
|
/**
|
||||||
|
* 共享的折叠状态表(key -> 是否展开,缺省或 true 为展开、false 为收起),由顶层 panel 统一维护。
|
||||||
|
* 本 tab 使用 `pg-${组内首步 index}` 作为 key——以稳定的 step 索引而非展示位置标识分组,
|
||||||
|
* 这样历史数据更新(新增 / 撤销重做导致列表顺序变化)后,已展开的分组状态仍能正确保持。
|
||||||
|
*/
|
||||||
|
expanded: Record<string, boolean>;
|
||||||
|
/**
|
||||||
|
* 当前活动页的「加载/初始」基线记录(设置 root 时生成的 `opType: 'initial'` StepValue)。
|
||||||
|
* 提供时即使没有任何操作记录也会展示底部初始行,并用其文案 / 时间渲染。
|
||||||
|
*/
|
||||||
|
marker?: StepValue;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
/** 用户点击记录行希望选中对应节点,携带目标 step 在栈中的索引。 */
|
||||||
|
(_e: 'select', _index: number): void;
|
||||||
|
/** 用户点击"清空"按钮,请求清空当前页面的历史记录(由上层弹窗二次确认后执行)。 */
|
||||||
|
(_e: 'clear'): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前 step 是否可查看差异:
|
||||||
|
* - 仅 update 操作;
|
||||||
|
* - 单节点更新(diff.length === 1),且 oldSchema / newSchema 都存在。
|
||||||
|
* 多节点更新难以选定单一对比目标,统一不展示差异入口。
|
||||||
|
*/
|
||||||
|
const isPageStepDiffable = (step: StepValue): boolean => {
|
||||||
|
if (step.opType !== 'update') return false;
|
||||||
|
const items = step.diff ?? [];
|
||||||
|
if (items.length !== 1) return false;
|
||||||
|
return Boolean(items[0]?.oldSchema && items[0]?.newSchema);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 页面历史的描述 / 可操作性判定集合,注入给统一的 `toRowGroup`。 */
|
||||||
|
const descriptor: HistoryRowDescriptor<StepValue> = {
|
||||||
|
describeGroup: describePageGroup,
|
||||||
|
describeStep: describePageStep,
|
||||||
|
isStepDiffable: isPageStepDiffable,
|
||||||
|
isStepRevertable: isPageStepRevertable,
|
||||||
|
};
|
||||||
|
|
||||||
|
const rowKey = (group: HistoryGroup<StepValue>) => `pg-${group.steps[0]?.index}`;
|
||||||
|
|
||||||
|
const toRow = (group: HistoryGroup<StepValue>): HistoryRowGroup => toRowGroup(group, rowKey(group), descriptor);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否处于"初始状态"——即对应页面历史栈 cursor===0:
|
||||||
|
* 当 list 中所有 group 的 applied 都为 false 时即为该状态(空列表 `every` 返回 true,
|
||||||
|
* 即仅有 marker、无任何操作记录时也视为处于初始状态)。
|
||||||
|
*/
|
||||||
|
const isInitial = computed(() => props.list.every((g) => !g.applied));
|
||||||
|
</script>
|
||||||
309
packages/editor/src/layouts/history-list/composables.ts
Normal file
309
packages/editor/src/layouts/history-list/composables.ts
Normal file
@ -0,0 +1,309 @@
|
|||||||
|
import { tMagicMessageBox } from '@tmagic/design';
|
||||||
|
import { datetimeFormatter } from '@tmagic/form';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
BaseStepValue,
|
||||||
|
HistoryGroup,
|
||||||
|
HistoryOpSource,
|
||||||
|
HistoryOpType,
|
||||||
|
HistoryRowDescriptor,
|
||||||
|
StepValue,
|
||||||
|
} from '@editor/type';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通用 bucket 分组(数据源 / 代码块及业务自定义历史)在面板中的展示结构。
|
||||||
|
* 由 Bucket / BucketTab 复用,step 类型通过泛型 T 收窄(约束为 {@link BaseStepValue})。
|
||||||
|
*/
|
||||||
|
export interface HistoryBucketGroup<T extends BaseStepValue = BaseStepValue> {
|
||||||
|
/** 组内最后一步是否已应用 */
|
||||||
|
applied: boolean;
|
||||||
|
/** 是否为当前所在的分组 */
|
||||||
|
isCurrent?: boolean;
|
||||||
|
/** 该分组的操作类型 */
|
||||||
|
opType: HistoryOpType;
|
||||||
|
/** 组内所有步骤 */
|
||||||
|
steps: { index: number; applied: boolean; isCurrent?: boolean; step: T }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GroupRow 的组头部与子步共用的展示字段(均由 {@link toRowGroup} 预先派生)。
|
||||||
|
* 抽出公共部分避免 {@link HistoryRowStep} / {@link HistoryRowGroup} 重复声明,
|
||||||
|
* 也便于消费方用本类型收窄「组头 / 子步」通用渲染逻辑的入参。
|
||||||
|
*/
|
||||||
|
export interface HistoryRowDisplay {
|
||||||
|
/** 是否已应用(false 表示已被 undo,UI 灰态)。 */
|
||||||
|
applied: boolean;
|
||||||
|
/** 是否为当前所在步骤 / 分组。 */
|
||||||
|
isCurrent: boolean;
|
||||||
|
/** 描述文案。 */
|
||||||
|
desc: string;
|
||||||
|
/** 操作途径。 */
|
||||||
|
source?: HistoryOpSource;
|
||||||
|
/** 操作人。 */
|
||||||
|
operator?: string;
|
||||||
|
/** 时间文案。 */
|
||||||
|
time?: string;
|
||||||
|
/** 时间的完整 title 提示。 */
|
||||||
|
timeTitle?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GroupRow 渲染所需的单个子步视图模型(已由 {@link toRowGroup} 预先派生,组件内部不再触碰原始 step)。 */
|
||||||
|
export interface HistoryRowStep extends HistoryRowDisplay {
|
||||||
|
/** 该子步在所属栈中的稳定索引。 */
|
||||||
|
index: number;
|
||||||
|
/** 是否为最近一次保存的记录。 */
|
||||||
|
saved?: boolean;
|
||||||
|
/** 是否可查看差异。 */
|
||||||
|
diffable?: boolean;
|
||||||
|
/** 是否可回滚。 */
|
||||||
|
revertable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GroupRow 渲染所需的整组视图模型(由 {@link toRowGroup} 统一派生)。
|
||||||
|
* 把原先 GroupRow 上十多个扁平 props 收敛为单一对象,header 信息与子步列表一并携带。
|
||||||
|
*/
|
||||||
|
export interface HistoryRowGroup extends HistoryRowDisplay {
|
||||||
|
/** 分组的稳定 key,作为 toggle 事件 payload 与折叠状态的索引。 */
|
||||||
|
key: string;
|
||||||
|
/** 操作类型,用于徽标颜色与文案。 */
|
||||||
|
opType: HistoryOpType;
|
||||||
|
/** 子步列表(时间正序);其长度即合并步数,length > 1 即为合并组。 */
|
||||||
|
subSteps: HistoryRowStep[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 合并组默认展开;仅当 expanded[key] === false 时为收起。 */
|
||||||
|
export const isHistoryGroupExpanded = (expanded: Record<string, boolean>, key: string) => expanded[key] !== false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 历史面板的时间展示:
|
||||||
|
* - 当天的记录只显示 `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: '源码',
|
||||||
|
'root-code': 'DSL源码',
|
||||||
|
'stage-contextmenu': '画布菜单',
|
||||||
|
'tree-contextmenu': '树菜单',
|
||||||
|
toolbar: '工具栏',
|
||||||
|
shortcut: '快捷键',
|
||||||
|
rollback: '回滚',
|
||||||
|
api: '接口',
|
||||||
|
ai: 'AI',
|
||||||
|
initial: '初始值',
|
||||||
|
sync: '同步',
|
||||||
|
unknown: '未知',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 操作途径文案:用于历史面板展示「画布 / 树面板 / 配置面板…」标签。 */
|
||||||
|
export const sourceLabel = (source: HistoryOpSource = 'unknown'): string => {
|
||||||
|
return HISTORY_SOURCE_LABELS[source] ?? `${source}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 取一组历史步骤里最后一步(最近一次)的操作途径,用于组头部展示。 */
|
||||||
|
export const groupSource = (group: { steps: { step: { source?: HistoryOpSource } }[] }): HistoryOpSource | undefined =>
|
||||||
|
group.steps[group.steps.length - 1]?.step.source;
|
||||||
|
|
||||||
|
/** 取一组历史步骤里最后一步(最近一次)的操作人,用于组头部展示。 */
|
||||||
|
export const groupOperator = (group: { steps: { step: { operator?: string } }[] }): string | undefined =>
|
||||||
|
group.steps[group.steps.length - 1]?.step.operator;
|
||||||
|
|
||||||
|
/** {@link toRowGroup} 接受的最小分组结构 */
|
||||||
|
interface RowGroupInput<T extends BaseStepValue = BaseStepValue> {
|
||||||
|
applied: boolean;
|
||||||
|
isCurrent?: boolean;
|
||||||
|
opType: HistoryOpType;
|
||||||
|
steps: { index: number; applied: boolean; isCurrent?: boolean; step: T }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把一个历史分组(页面 / bucket)派生为 GroupRow 直接消费的视图模型 {@link HistoryRowGroup}。
|
||||||
|
* 统一了原先 PageTab / Bucket 各自内联的 sub-steps 映射逻辑:描述、可差异、可回滚、时间、途径
|
||||||
|
* 全部在此一次性算好,组件层只负责渲染。
|
||||||
|
*/
|
||||||
|
export const toRowGroup = <T extends BaseStepValue = BaseStepValue>(
|
||||||
|
group: RowGroupInput<T>,
|
||||||
|
key: string,
|
||||||
|
descriptor: HistoryRowDescriptor<T>,
|
||||||
|
): HistoryRowGroup => {
|
||||||
|
const { describeGroup, describeStep, isStepDiffable, isStepRevertable } = descriptor;
|
||||||
|
const timestamp = groupTimestamp(group);
|
||||||
|
// 无 describeGroup 时回退到组内最后一步的 describeStep:数据源/代码块不做相邻合并,每组恒为单步,二者等价。
|
||||||
|
const lastStep = group.steps[group.steps.length - 1]?.step;
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
applied: group.applied,
|
||||||
|
isCurrent: Boolean(group.isCurrent),
|
||||||
|
opType: group.opType,
|
||||||
|
desc: describeGroup ? describeGroup(group) : describeStep(lastStep),
|
||||||
|
source: groupSource(group),
|
||||||
|
operator: groupOperator(group),
|
||||||
|
time: formatHistoryTime(timestamp),
|
||||||
|
timeTitle: formatHistoryFullTime(timestamp),
|
||||||
|
subSteps: group.steps.map((s) => ({
|
||||||
|
index: s.index,
|
||||||
|
applied: s.applied,
|
||||||
|
isCurrent: Boolean(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,
|
||||||
|
operator: s.step.operator,
|
||||||
|
time: formatHistoryTime(s.step.timestamp),
|
||||||
|
timeTitle: formatHistoryFullTime(s.step.timestamp),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const nameOf = (node?: { name?: string; id?: string | number; type?: string }) =>
|
||||||
|
node?.name || node?.type || `${node?.id ?? ''}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 默认描述里展示「名称 (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;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 页面 / 数据源 / 代码块三类历史共用的单步描述核心。
|
||||||
|
* 各类型只在「取展示名」与「实体单位名」上有差异,通过参数注入,文案模板完全一致:
|
||||||
|
* - 新增 / 删除:单实体展示「label」,多实体(仅页面可能出现)退化为「N 个X」;
|
||||||
|
* - 修改:展示「label · propPath」,无 diff 时兜底「X」,多实体退化为「N 个X」。
|
||||||
|
* 操作类型(新增 / 删除 / 修改)已由列表行的 op 徽标单独展示,故描述文案不再重复动词。
|
||||||
|
* 展示 id 统一取 schema.id;调用方显式传入的 historyDescription 永远优先。
|
||||||
|
*/
|
||||||
|
export const describeStep = <T>(
|
||||||
|
step: BaseStepValue<T>,
|
||||||
|
getLabel: (_schema?: T) => string | number | undefined,
|
||||||
|
unit: string,
|
||||||
|
): string => {
|
||||||
|
if (step.historyDescription) return step.historyDescription;
|
||||||
|
const items = step.diff ?? [];
|
||||||
|
const label = (schema?: T) => labelWithId(getLabel(schema), (schema as { id?: string | number } | undefined)?.id);
|
||||||
|
|
||||||
|
if (step.opType === 'add') {
|
||||||
|
const node = items[0]?.newSchema;
|
||||||
|
return items.length === 1 && node ? label(node) : `${items.length} 个${unit}`;
|
||||||
|
}
|
||||||
|
if (step.opType === 'remove') {
|
||||||
|
const node = items[0]?.oldSchema;
|
||||||
|
return items.length === 1 && node ? label(node) : `${items.length} 个${unit}`;
|
||||||
|
}
|
||||||
|
if (!items.length) return unit;
|
||||||
|
if (items.length === 1) {
|
||||||
|
const { newSchema, oldSchema, changeRecords } = items[0];
|
||||||
|
const propPath = changeRecords?.map((changeRecord) => changeRecord.propPath).join(',');
|
||||||
|
const target = label(newSchema ?? oldSchema);
|
||||||
|
return propPath ? `${target} · ${propPath}` : target;
|
||||||
|
}
|
||||||
|
return `${items.length} 个${unit}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const describePageStep = (step: StepValue): string => describeStep(step, (node) => nameOf(node), '节点');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合并组的展示文案:
|
||||||
|
* - 若组内任一步显式提供了 historyDescription:取最后一条非空 historyDescription(最近一次的描述更准确);
|
||||||
|
* - 单步组:复用 describePageStep;
|
||||||
|
* - 多步组(连续修改同一节点):展示节点名 + 涉及的前几个 propPath。
|
||||||
|
*/
|
||||||
|
export const describePageGroup = (group: HistoryGroup<StepValue>) => {
|
||||||
|
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);
|
||||||
|
|
||||||
|
return labelWithId(group.targetName ?? (group.targetId !== undefined ? `${group.targetId}` : '节点'), group.targetId);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 页面 step 是否支持「回滚」(类 git revert):
|
||||||
|
* - 新增 / 删除:不依赖 changeRecords,反向应用即删除 / 加回,始终可回滚;
|
||||||
|
* - 更新:必须每个被更新节点都带有 changeRecords,才支持按 propPath 局部反向 patch。
|
||||||
|
* 缺失 changeRecords 的更新只能整节点替换,会冲掉该节点后续的无关变更,因此不支持回滚。
|
||||||
|
*/
|
||||||
|
export const isPageStepRevertable = (step: StepValue): boolean => {
|
||||||
|
if (step.opType !== 'update') return true;
|
||||||
|
const items = step.diff ?? [];
|
||||||
|
if (!items.length) return false;
|
||||||
|
return items.every((item) => Boolean(item.changeRecords?.length));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单 diff 项历史(数据源 / 代码块)是否支持「回滚」:
|
||||||
|
* - 新增(无 oldSchema)/ 删除(无 newSchema):不依赖 changeRecords,始终可回滚;
|
||||||
|
* - 更新(前后内容都存在):必须有 changeRecords 才支持局部反向 patch,否则不支持回滚。
|
||||||
|
*/
|
||||||
|
export const isSingleDiffStepRevertable = (step: BaseStepValue): boolean => {
|
||||||
|
const item = step.diff?.[0];
|
||||||
|
if (!item?.oldSchema || !item?.newSchema) return true;
|
||||||
|
return Boolean(item.changeRecords?.length);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通用二次确认弹窗:清空历史 / 无法差异对比的回滚等会改变状态的操作,先弹出确认框,
|
||||||
|
* 用户点击「确定」返回 true,取消(confirm reject)时返回 false 并静默忽略。
|
||||||
|
*/
|
||||||
|
export const confirmHistoryAction = async (message: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
await tMagicMessageBox.confirm(message, '提示', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
60
packages/editor/src/layouts/history-list/useHistoryList.ts
Normal file
60
packages/editor/src/layouts/history-list/useHistoryList.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { computed, reactive } from 'vue';
|
||||||
|
|
||||||
|
import { useServices } from '@editor/hooks/use-services';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 历史记录面板共享逻辑:
|
||||||
|
* - 暴露三类历史的聚合数据(页面 / 数据源 / 代码块);
|
||||||
|
* - 提供折叠状态管理;
|
||||||
|
* - 提供操作描述文案生成器。
|
||||||
|
*
|
||||||
|
* 所有数据基于 historyService 的 reactive state 派生,自动跟随历史变化刷新。
|
||||||
|
*/
|
||||||
|
export const useHistoryList = () => {
|
||||||
|
const { editorService, historyService } = useServices();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 折叠状态:key 形如 `pg-${组内首步 index}` / `ds-${id}-${组内首步 index}` / `cb-${id}-${组内首步 index}`。
|
||||||
|
* 用组内首步的稳定 index(而非展示位置)作为 key,确保历史数据更新后已展开的分组状态保持不变。
|
||||||
|
* 合并组默认展开;仅当值为 `false` 时表示收起。
|
||||||
|
*/
|
||||||
|
const expanded = reactive<Record<string, boolean>>({});
|
||||||
|
const toggleGroup = (key: string) => {
|
||||||
|
expanded[key] = expanded[key] === false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pageGroups = computed(() => historyService.getHistoryGroups('page', editorService.get('page')?.id));
|
||||||
|
const dataSourceGroups = computed(() => historyService.getHistoryGroups('dataSource'));
|
||||||
|
const codeBlockGroups = computed(() => historyService.getHistoryGroups('codeBlock'));
|
||||||
|
|
||||||
|
/** 页面 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
425
packages/editor/src/layouts/history-list/useHistoryRevert.ts
Normal file
425
packages/editor/src/layouts/history-list/useHistoryRevert.ts
Normal file
@ -0,0 +1,425 @@
|
|||||||
|
import { createApp, getCurrentInstance } from 'vue';
|
||||||
|
|
||||||
|
import { tMagicMessage } from '@tmagic/design';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ConfirmAndRevertOptions,
|
||||||
|
CustomDiffFormOptions,
|
||||||
|
DiffDialogPayload,
|
||||||
|
Services,
|
||||||
|
UseHistoryRevertOptions,
|
||||||
|
} from '@editor/type';
|
||||||
|
|
||||||
|
import { confirmHistoryAction } from './composables';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 三类历史(页面 / 数据源 / 代码块)差异弹窗入参的构造差异,收敛为一份配置:
|
||||||
|
* 仅「分组来源、当前值读取、类型 / 展示名提取」不同,定位 step、校验前后值、组装 payload 的流程共用。
|
||||||
|
*/
|
||||||
|
interface DiffPayloadSource {
|
||||||
|
/** 表单类别:节点 / 数据源 / 代码块。 */
|
||||||
|
category: DiffDialogPayload['category'];
|
||||||
|
/** 该类别按时间正序的历史分组列表(含已撤销)。 */
|
||||||
|
groups: () => { id?: string | number; steps: { index: number; step: { diff?: any[] } }[] }[];
|
||||||
|
/** 读取目标当前实际值,用于「与当前对比」;不存在时返回空即禁用对比。 */
|
||||||
|
getCurrent: (_id: string | number) => Record<string, any> | null | undefined;
|
||||||
|
/** 由新/旧快照提取展示名(含各自的兜底,如节点回退 type、数据源 / 代码块回退 id)。 */
|
||||||
|
resolveLabel: (_newSchema: Record<string, any>, _oldSchema: Record<string, any>, _id: string | number) => string;
|
||||||
|
/** 由新/旧快照提取类型;代码块无 type 字段则不传。 */
|
||||||
|
resolveType?: (_newSchema: Record<string, any>, _oldSchema: Record<string, any>) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造差异弹窗入参:仅 update(前后值都存在)可对比。
|
||||||
|
* - 页面(无 id):在全部分组中按 index 定位 step,目标 id 取自快照;
|
||||||
|
* - 数据源 / 代码块(带 id):先匹配分组 id 再按 index 定位。
|
||||||
|
* 无可对比内容(多节点 / add / remove)或定位不到时返回 null。
|
||||||
|
*/
|
||||||
|
const buildDiffPayload = (source: DiffPayloadSource, index: number, id?: string | number): DiffDialogPayload | null => {
|
||||||
|
for (const group of source.groups()) {
|
||||||
|
if (id !== undefined && group.id !== id) continue;
|
||||||
|
const step = group.steps.find((s) => s.index === index)?.step;
|
||||||
|
if (!step) continue;
|
||||||
|
const oldSchema = step.diff?.[0]?.oldSchema as Record<string, any> | undefined;
|
||||||
|
const newSchema = step.diff?.[0]?.newSchema as Record<string, any> | undefined;
|
||||||
|
if (!oldSchema || !newSchema) return null;
|
||||||
|
const targetId = id ?? newSchema.id ?? oldSchema.id;
|
||||||
|
const type = source.resolveType?.(newSchema, oldSchema);
|
||||||
|
return {
|
||||||
|
category: source.category,
|
||||||
|
...(type !== undefined ? { type } : {}),
|
||||||
|
lastValue: oldSchema,
|
||||||
|
value: newSchema,
|
||||||
|
currentValue: (targetId !== undefined ? source.getCurrent(targetId) : null) || null,
|
||||||
|
targetLabel: source.resolveLabel(newSchema, oldSchema, targetId),
|
||||||
|
id: targetId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface MountedDiffDialog {
|
||||||
|
app: ReturnType<typeof createApp>;
|
||||||
|
instance: {
|
||||||
|
open: (_payload: DiffDialogPayload) => void;
|
||||||
|
confirm: (_payload: DiffDialogPayload) => Promise<boolean>;
|
||||||
|
};
|
||||||
|
/** 卸载弹窗并清理容器(延迟以等待关闭过渡播放完成,避免动画被强行打断)。 */
|
||||||
|
destroy: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按需动态 import 并挂载一个游离的 HistoryDiffDialog,返回其实例与清理函数。
|
||||||
|
* 通过继承 appContext 复用全局组件 / 指令 / provide / 插件(Element Plus、@tmagic/form 字段组件等),
|
||||||
|
* 弹窗组件动态 import,避免拖累其它消费者。供「确认回滚」与「查看差异」两种交互共用。
|
||||||
|
*/
|
||||||
|
const mountHistoryDiffDialog = async (
|
||||||
|
options: Pick<UseHistoryRevertOptions, 'appContext' | 'extendState'> &
|
||||||
|
CustomDiffFormOptions & {
|
||||||
|
services?: Services;
|
||||||
|
isConfirm?: boolean;
|
||||||
|
onClose?: () => void;
|
||||||
|
},
|
||||||
|
): Promise<MountedDiffDialog> => {
|
||||||
|
const { default: historyDiffDialog } = await import('./HistoryDiffDialog.vue');
|
||||||
|
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.style.display = 'none';
|
||||||
|
document.body.appendChild(container);
|
||||||
|
|
||||||
|
const app = createApp(historyDiffDialog, {
|
||||||
|
services: options.services,
|
||||||
|
isConfirm: options.isConfirm,
|
||||||
|
extendState: options.extendState,
|
||||||
|
loadConfig: options.loadConfig,
|
||||||
|
selfDiffFieldTypes: options.selfDiffFieldTypes,
|
||||||
|
compareFormState: options.compareFormState,
|
||||||
|
width: options.width,
|
||||||
|
size: options.size ?? options.services?.uiService?.get('propsPanelSize'),
|
||||||
|
onClose: options.onClose,
|
||||||
|
});
|
||||||
|
if (options.appContext) {
|
||||||
|
Object.assign(app._context, options.appContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
const instance = app.mount(container) as unknown as MountedDiffDialog['instance'];
|
||||||
|
|
||||||
|
const destroy = () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
app.unmount();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
container.parentNode?.removeChild(container);
|
||||||
|
}, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { app, instance, destroy };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 动态挂载一个「确认回滚」模式的 HistoryDiffDialog,等待用户确认:
|
||||||
|
* - 用户点「确定回滚」resolve(true),取消 / 关闭 resolve(false);
|
||||||
|
* - 确认流程结束后自动卸载。
|
||||||
|
*/
|
||||||
|
const confirmRevertWithDiffDialog = async (
|
||||||
|
payload: DiffDialogPayload,
|
||||||
|
options: Pick<UseHistoryRevertOptions, 'appContext' | 'extendState'> &
|
||||||
|
CustomDiffFormOptions & {
|
||||||
|
services?: Services;
|
||||||
|
},
|
||||||
|
): Promise<boolean> => {
|
||||||
|
const { instance, destroy } = await mountHistoryDiffDialog({
|
||||||
|
...options,
|
||||||
|
isConfirm: true,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
return await instance.confirm(payload);
|
||||||
|
} finally {
|
||||||
|
destroy();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 动态挂载一个「查看差异」模式(只读)的 HistoryDiffDialog 并打开:
|
||||||
|
* 弹窗保持打开直到用户关闭,关闭(close 事件)后自动卸载并清理容器。
|
||||||
|
*/
|
||||||
|
const viewHistoryDiffDialog = async (
|
||||||
|
payload: DiffDialogPayload,
|
||||||
|
options: Pick<UseHistoryRevertOptions, 'appContext' | 'extendState'> &
|
||||||
|
CustomDiffFormOptions & {
|
||||||
|
services?: Services;
|
||||||
|
},
|
||||||
|
): Promise<void> => {
|
||||||
|
// onClose 在用户关闭弹窗时才触发,此时 handle.destroy 早已赋值。
|
||||||
|
const handle: { destroy?: () => void } = {};
|
||||||
|
const { instance, destroy } = await mountHistoryDiffDialog({
|
||||||
|
...options,
|
||||||
|
isConfirm: false,
|
||||||
|
onClose: () => handle.destroy?.(),
|
||||||
|
});
|
||||||
|
handle.destroy = destroy;
|
||||||
|
instance.open(payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 历史记录「单步回滚」与「查看差异」的完整交互流程,供历史面板与业务方共用。
|
||||||
|
*
|
||||||
|
* 单步回滚(类 git revert):
|
||||||
|
* 1. 前置校验:目标数据已被删除(update 写回目标 / 页面 remove 的原父容器缺失)时给出「回滚失败」提示并中止;
|
||||||
|
* 2. 二次确认:可差异对比的步骤(单实体 update)弹出差异确认弹窗,其余步骤弹普通二次确认框;
|
||||||
|
* 3. 用户确认后执行对应 service 的 revert(页面 / 数据源 / 代码块)。
|
||||||
|
*
|
||||||
|
* 查看差异:可差异对比的步骤动态挂载只读 HistoryDiffDialog 展示前后值差异。
|
||||||
|
*
|
||||||
|
* 上述弹窗均按需动态挂载 HistoryDiffDialog 实现,业务方无需自行挂载任何弹窗。
|
||||||
|
*
|
||||||
|
* 返回的能力分三组:
|
||||||
|
* - 内置三类(页面 / 数据源 / 代码块):`onPageRevert` / `onDataSourceRevert` / `onCodeBlockRevert` 单步回滚,
|
||||||
|
* `onPageDiff` / `onDataSourceDiff` / `onCodeBlockDiff` 查看差异,及配套的 `buildXxxDiffPayload` / `isXxxRevertTargetMissing`;
|
||||||
|
* - 业务自有历史(如管理台「模块」):`confirmAndRevert` / `viewDiff` 复用同一套交互流程,由业务方自行构造差异入参;
|
||||||
|
* 即上述内置三类本质上是它们各自预置好 payload 构造与 service.revert 的特例。
|
||||||
|
*
|
||||||
|
* 业务方可在拿到 Editor 实例暴露的 `services` 后直接 import 调用:
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* import { useHistoryRevert } from '@tmagic/editor';
|
||||||
|
*
|
||||||
|
* const { onPageRevert, onPageDiff } = useHistoryRevert({}, editorRef.value); // editorRef.value 即 Editor 暴露的 services
|
||||||
|
* await onPageRevert(index); // 弹出差异 / 二次确认弹窗后回滚
|
||||||
|
* onPageDiff(index); // 弹出只读差异弹窗查看前后值差异
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const useHistoryRevert = (options: UseHistoryRevertOptions = {}, services?: Services) => {
|
||||||
|
// 自动捕获调用方所在组件的 appContext(在 setup 中调用时),业务方亦可显式覆盖。
|
||||||
|
const appContext = options.appContext ?? getCurrentInstance()?.appContext ?? null;
|
||||||
|
const { extendState, getPropsPanelFormState, dialogWidth } = options;
|
||||||
|
|
||||||
|
/** 目标数据已被删除、无法回滚时的统一提示。 */
|
||||||
|
const showRevertTargetMissing = () => {
|
||||||
|
tMagicMessage.error('回滚失败:该记录对应的数据已被删除');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「回滚」二次确认:新增 / 删除 / 多节点更新等无法做差异对比的步骤,
|
||||||
|
* 不弹差异弹窗,改用一个普通确认框替代「确定回滚」按钮,避免点击后无任何提示直接执行。
|
||||||
|
* 用户取消时返回 false,调用方据此中止回滚。
|
||||||
|
*/
|
||||||
|
const confirmRevert = (): Promise<boolean> =>
|
||||||
|
confirmHistoryAction(
|
||||||
|
'确定回滚该步骤吗?回滚会将该操作作为一条新记录反向应用(新增将被删除、删除将被还原),不影响后续历史记录。',
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「回滚」统一确认入口:可差异对比的步骤动态挂载 HistoryDiffDialog 走差异确认弹窗,
|
||||||
|
* 无法对比的步骤(add / remove / 多节点更新)回退到普通二次确认框。
|
||||||
|
* `extra` 供业务自有历史(如模块)透传自定义表单配置加载等渲染入参。
|
||||||
|
*/
|
||||||
|
const runRevert = (payload: DiffDialogPayload | null, extra?: CustomDiffFormOptions): Promise<boolean> => {
|
||||||
|
if (payload) {
|
||||||
|
return confirmRevertWithDiffDialog(payload, {
|
||||||
|
appContext,
|
||||||
|
extendState,
|
||||||
|
services,
|
||||||
|
...extra,
|
||||||
|
width: extra?.width ?? dialogWidth,
|
||||||
|
size: extra?.size,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return confirmRevert();
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildPageDiffPayload = (index: number): DiffDialogPayload | null =>
|
||||||
|
buildDiffPayload(
|
||||||
|
{
|
||||||
|
category: 'node',
|
||||||
|
groups: () => services?.historyService.getHistoryGroups('page', services?.editorService.get('page')?.id) ?? [],
|
||||||
|
getCurrent: (id) => services?.editorService.getNodeById(id) as Record<string, any> | null,
|
||||||
|
resolveType: (n, o) => n.type || o.type || '',
|
||||||
|
resolveLabel: (n, o) => n.name || o.name || n.type || o.type || '',
|
||||||
|
},
|
||||||
|
index,
|
||||||
|
);
|
||||||
|
|
||||||
|
const buildDataSourceDiffPayload = (id: string | number, index: number): DiffDialogPayload | null =>
|
||||||
|
buildDiffPayload(
|
||||||
|
{
|
||||||
|
category: 'data-source',
|
||||||
|
groups: () => services?.historyService.getHistoryGroups('dataSource') ?? [],
|
||||||
|
getCurrent: (id) => services?.dataSourceService.getDataSourceById(`${id}`) as Record<string, any> | null,
|
||||||
|
resolveType: (n, o) => n.type || o.type || 'base',
|
||||||
|
resolveLabel: (n, o, id) => n.title || o.title || `${id}`,
|
||||||
|
},
|
||||||
|
index,
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
|
||||||
|
const buildCodeBlockDiffPayload = (id: string | number, index: number): DiffDialogPayload | null =>
|
||||||
|
buildDiffPayload(
|
||||||
|
{
|
||||||
|
category: 'code-block',
|
||||||
|
groups: () => services?.historyService.getHistoryGroups('codeBlock') ?? [],
|
||||||
|
getCurrent: (id) => services?.codeBlockService.getCodeContentById(id) as Record<string, any> | null,
|
||||||
|
resolveLabel: (n, o, id) => n.name || o.name || `${id}`,
|
||||||
|
},
|
||||||
|
index,
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回滚前置校验:若该历史步骤回滚所依赖的目标数据已被删除,则无法回滚。
|
||||||
|
* - update(把旧值写回):被修改的目标必须仍存在;
|
||||||
|
* - 页面 remove(还原被删节点):被删节点的原父容器必须仍存在,否则无处插回;
|
||||||
|
* add(回滚即删除)即使目标已不在,也已达成「删除」目的,不视为失败。
|
||||||
|
*/
|
||||||
|
const isPageRevertTargetMissing = (index: number): boolean => {
|
||||||
|
const step = services?.historyService.getStepList('page', services?.editorService.get('page')?.id)[index]?.step;
|
||||||
|
if (!step) return false;
|
||||||
|
if (step.opType === 'update') {
|
||||||
|
return (step.diff ?? []).some((item) => {
|
||||||
|
const id = item.newSchema?.id ?? item.oldSchema?.id;
|
||||||
|
return id !== undefined && !services?.editorService.getNodeById(id, false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (step.opType === 'remove') {
|
||||||
|
return (step.diff ?? []).some(
|
||||||
|
(item) => item.parentId !== undefined && !services?.editorService.getNodeById(item.parentId, false),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 数据源 update 步骤回滚时,对应数据源必须仍存在(已删除则无处写回旧值)。 */
|
||||||
|
const isDataSourceRevertTargetMissing = (id: string | number, index: number): boolean => {
|
||||||
|
const step = services?.historyService.getStepList('dataSource', id)[index]?.step;
|
||||||
|
return Boolean(step?.opType === 'update' && !services?.dataSourceService.getDataSourceById(`${id}`));
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 代码块 update 步骤回滚时,对应代码块必须仍存在(已删除则无处写回旧值)。 */
|
||||||
|
const isCodeBlockRevertTargetMissing = (id: string | number, index: number): boolean => {
|
||||||
|
const step = services?.historyService.getStepList('codeBlock', id)[index]?.step;
|
||||||
|
return Boolean(step?.opType === 'update' && !services?.codeBlockService.getCodeContentById(id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPageRevert = (index: number) => {
|
||||||
|
if (isPageRevertTargetMissing(index)) {
|
||||||
|
showRevertTargetMissing();
|
||||||
|
return Promise.resolve(null);
|
||||||
|
}
|
||||||
|
return runRevert(buildPageDiffPayload(index), { compareFormState: getPropsPanelFormState?.() }).then((result) =>
|
||||||
|
result ? services?.editorService.revertPageStep(index) : null,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDataSourceRevert = (id: string | number, index: number) => {
|
||||||
|
if (isDataSourceRevertTargetMissing(id, index)) {
|
||||||
|
showRevertTargetMissing();
|
||||||
|
return Promise.resolve(null);
|
||||||
|
}
|
||||||
|
return runRevert(buildDataSourceDiffPayload(id, index)).then((result) =>
|
||||||
|
result ? services?.dataSourceService.revert(id, index) : null,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCodeBlockRevert = (id: string | number, index: number) => {
|
||||||
|
if (isCodeBlockRevertTargetMissing(id, index)) {
|
||||||
|
showRevertTargetMissing();
|
||||||
|
return Promise.resolve(null);
|
||||||
|
}
|
||||||
|
return runRevert(buildCodeBlockDiffPayload(id, index)).then((result) =>
|
||||||
|
result ? services?.codeBlockService.revert(id, index) : null,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「查看差异」:可差异对比的步骤(单实体 update)动态挂载只读 HistoryDiffDialog 展示前后值差异,
|
||||||
|
* 无可对比内容(add / remove / 多节点更新)时不弹窗、静默返回。
|
||||||
|
*/
|
||||||
|
const onPageDiff = (index: number): Promise<void> | void => {
|
||||||
|
const payload = buildPageDiffPayload(index);
|
||||||
|
if (payload) {
|
||||||
|
return viewHistoryDiffDialog(payload, {
|
||||||
|
appContext,
|
||||||
|
extendState,
|
||||||
|
services,
|
||||||
|
width: dialogWidth,
|
||||||
|
compareFormState: getPropsPanelFormState?.(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDataSourceDiff = (id: string | number, index: number): Promise<void> | void => {
|
||||||
|
const payload = buildDataSourceDiffPayload(id, index);
|
||||||
|
if (payload) return viewHistoryDiffDialog(payload, { appContext, extendState, services, width: dialogWidth });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCodeBlockDiff = (id: string | number, index: number): Promise<void> | void => {
|
||||||
|
const payload = buildCodeBlockDiffPayload(id, index);
|
||||||
|
if (payload) return viewHistoryDiffDialog(payload, { appContext, extendState, services, width: dialogWidth });
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 业务自有历史(如管理台「模块」)的「单步回滚」统一入口:
|
||||||
|
* 复用与页面/数据源/代码块一致的「目标校验 → 差异/二次确认弹窗 → 反向回滚」流程,
|
||||||
|
* 内置三类只是它的特例(各自预置了 buildXxxDiffPayload + service.revert)。
|
||||||
|
*
|
||||||
|
* 用户取消或前置校验未过时返回 null,确认并执行回滚后返回 `revert()` 的结果。
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* await confirmAndRevert({
|
||||||
|
* diffPayload: oldSchema && newSchema ? { category: 'module', lastValue: oldSchema, value: newSchema, ... } : null,
|
||||||
|
* loadConfig: (ctx) => loadModFormConfig(modTypeId, values),
|
||||||
|
* selfDiffFieldTypes: ['mod-cond'],
|
||||||
|
* revert: () => moduleHistoryStore.revert(id, index),
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
const confirmAndRevert = async <T = unknown>(revertOptions: ConfirmAndRevertOptions<T>): Promise<T | null> => {
|
||||||
|
if (revertOptions.isTargetMissing?.()) {
|
||||||
|
showRevertTargetMissing();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const confirmed = await runRevert(revertOptions.diffPayload ?? null, {
|
||||||
|
loadConfig: revertOptions.loadConfig,
|
||||||
|
selfDiffFieldTypes: revertOptions.selfDiffFieldTypes,
|
||||||
|
width: revertOptions.width,
|
||||||
|
size: revertOptions.size,
|
||||||
|
});
|
||||||
|
if (!confirmed) return null;
|
||||||
|
return await revertOptions.revert();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 业务自有历史的「查看差异」统一入口:传入自行构造的差异入参即弹出只读差异弹窗,
|
||||||
|
* 可透传 `loadConfig` / `selfDiffFieldTypes`。payload 为 null(不可对比)时静默返回。
|
||||||
|
*/
|
||||||
|
const viewDiff = (payload: DiffDialogPayload | null, extra?: CustomDiffFormOptions): Promise<void> | void => {
|
||||||
|
if (payload)
|
||||||
|
return viewHistoryDiffDialog(payload, {
|
||||||
|
appContext,
|
||||||
|
extendState,
|
||||||
|
services,
|
||||||
|
...extra,
|
||||||
|
width: extra?.width ?? dialogWidth,
|
||||||
|
size: extra?.size,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
onPageRevert,
|
||||||
|
onDataSourceRevert,
|
||||||
|
onCodeBlockRevert,
|
||||||
|
onPageDiff,
|
||||||
|
onDataSourceDiff,
|
||||||
|
onCodeBlockDiff,
|
||||||
|
buildPageDiffPayload,
|
||||||
|
buildDataSourceDiffPayload,
|
||||||
|
buildCodeBlockDiffPayload,
|
||||||
|
isPageRevertTargetMissing,
|
||||||
|
isDataSourceRevertTargetMissing,
|
||||||
|
isCodeBlockRevertTargetMissing,
|
||||||
|
confirmAndRevert,
|
||||||
|
viewDiff,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -51,6 +51,7 @@
|
|||||||
type: 'button',
|
type: 'button',
|
||||||
text: '删除',
|
text: '删除',
|
||||||
icon: Delete,
|
icon: Delete,
|
||||||
|
buttonProps: { type: 'danger' },
|
||||||
handler: () => remove(item),
|
handler: () => remove(item),
|
||||||
}"
|
}"
|
||||||
></ToolButton>
|
></ToolButton>
|
||||||
@ -72,7 +73,7 @@ import { computed, ref, useTemplateRef, watch } from 'vue';
|
|||||||
import { CaretBottom, Delete, DocumentCopy } from '@element-plus/icons-vue';
|
import { CaretBottom, Delete, DocumentCopy } from '@element-plus/icons-vue';
|
||||||
|
|
||||||
import { type Id, type MPage, type MPageFragment, NodeType } from '@tmagic/core';
|
import { type Id, type MPage, type MPageFragment, NodeType } from '@tmagic/core';
|
||||||
import { TMagicIcon, TMagicPopover } from '@tmagic/design';
|
import { TMagicIcon, tMagicMessageBox, TMagicPopover } from '@tmagic/design';
|
||||||
|
|
||||||
import ToolButton from '@editor/components/ToolButton.vue';
|
import ToolButton from '@editor/components/ToolButton.vue';
|
||||||
import { useServices } from '@editor/hooks/use-services';
|
import { useServices } from '@editor/hooks/use-services';
|
||||||
@ -140,7 +141,8 @@ const copy = (node: MPage | MPageFragment) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const remove = (node: MPage | MPageFragment) => {
|
const remove = async (node: MPage | MPageFragment) => {
|
||||||
|
await tMagicMessageBox.confirm('确定删除该页面吗?');
|
||||||
editorService.remove(node);
|
editorService.remove(node);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
<TMagicScrollbar>
|
<TMagicScrollbar>
|
||||||
<MForm
|
<MForm
|
||||||
ref="configForm"
|
ref="configForm"
|
||||||
:class="propsPanelSize"
|
:class="[propsPanelSize, 'm-editor-props-form-panel-form']"
|
||||||
:popper-class="`m-editor-props-panel-popper ${propsPanelSize}`"
|
:popper-class="`m-editor-props-panel-popper ${propsPanelSize}`"
|
||||||
:label-width="labelWidth"
|
:label-width="labelWidth"
|
||||||
:label-position="labelPosition"
|
:label-position="labelPosition"
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { computed, type Ref, watch } from 'vue';
|
|||||||
|
|
||||||
import { Protocol } from '@editor/services/storage';
|
import { Protocol } from '@editor/services/storage';
|
||||||
import { Services } from '@editor/type';
|
import { Services } from '@editor/type';
|
||||||
import { MIN_CENTER_COLUMN_WIDTH, RIGHT_COLUMN_WIDTH_STORAGE_KEY } from '@editor/utils/const';
|
import { RIGHT_COLUMN_WIDTH_STORAGE_KEY } from '@editor/utils/const';
|
||||||
|
|
||||||
export const useStylePanel = (
|
export const useStylePanel = (
|
||||||
{ uiService, storageService }: Pick<Services, 'uiService' | 'storageService'>,
|
{ uiService, storageService }: Pick<Services, 'uiService' | 'storageService'>,
|
||||||
@ -47,8 +47,9 @@ export const useStylePanel = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (columnWidth.center < 0) {
|
if (columnWidth.center < 0) {
|
||||||
columnWidth.right = columnWidth.right + columnWidth.center - MIN_CENTER_COLUMN_WIDTH;
|
const minCenterColumnWidth = uiService.get('minCenterColumnWidth');
|
||||||
columnWidth.center = MIN_CENTER_COLUMN_WIDTH;
|
columnWidth.right = columnWidth.right + columnWidth.center - minCenterColumnWidth;
|
||||||
|
columnWidth.center = minCenterColumnWidth;
|
||||||
|
|
||||||
propsPanelWidth.value = columnWidth.right / 2;
|
propsPanelWidth.value = columnWidth.right / 2;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
@ -132,6 +132,7 @@
|
|||||||
v-model:height="columnLeftHeight"
|
v-model:height="columnLeftHeight"
|
||||||
:width="columnLeftWidth"
|
:width="columnLeftWidth"
|
||||||
:title="config.text"
|
:title="config.text"
|
||||||
|
:framework-width="frameworkWidth"
|
||||||
:position="{
|
:position="{
|
||||||
left: floatBoxStates[config.$key].left,
|
left: floatBoxStates[config.$key].left,
|
||||||
top: floatBoxStates[config.$key].top,
|
top: floatBoxStates[config.$key].top,
|
||||||
@ -221,6 +222,7 @@ const taskLength = computed(() => depService.get('taskLength'));
|
|||||||
const tipsBarVisible = ref(true);
|
const tipsBarVisible = ref(true);
|
||||||
|
|
||||||
const columnLeftWidth = computed(() => uiService.get('columnWidth')[ColumnLayout.LEFT]);
|
const columnLeftWidth = computed(() => uiService.get('columnWidth')[ColumnLayout.LEFT]);
|
||||||
|
const frameworkWidth = computed(() => uiService.get('frameworkRect')?.width || 0);
|
||||||
const { height: editorContentHeight } = useEditorContentHeight();
|
const { height: editorContentHeight } = useEditorContentHeight();
|
||||||
const columnLeftHeight = ref(0);
|
const columnLeftHeight = ref(0);
|
||||||
|
|
||||||
@ -239,7 +241,12 @@ const unWatchEditorContentHeight = watch(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const activeTabName = ref(props.data?.status);
|
const activeTabName = computed<string>({
|
||||||
|
get: () => uiService.get('sideBarActiveTabName'),
|
||||||
|
set: (value) => uiService.set('sideBarActiveTabName', value),
|
||||||
|
});
|
||||||
|
|
||||||
|
uiService.set('sideBarActiveTabName', props.data?.status || '');
|
||||||
|
|
||||||
const getItemConfig = (data: SideItem): SideComponent => {
|
const getItemConfig = (data: SideItem): SideComponent => {
|
||||||
const map: Record<string, SideComponent> = {
|
const map: Record<string, SideComponent> = {
|
||||||
@ -252,7 +259,7 @@ const getItemConfig = (data: SideItem): SideComponent => {
|
|||||||
slots: {},
|
slots: {},
|
||||||
},
|
},
|
||||||
layer: {
|
layer: {
|
||||||
$key: 'layer',
|
$key: SideItemKey.LAYER,
|
||||||
type: 'component',
|
type: 'component',
|
||||||
icon: List,
|
icon: List,
|
||||||
text: '已选组件',
|
text: '已选组件',
|
||||||
@ -272,7 +279,7 @@ const getItemConfig = (data: SideItem): SideComponent => {
|
|||||||
slots: {},
|
slots: {},
|
||||||
},
|
},
|
||||||
[SideItemKey.CODE_BLOCK]: {
|
[SideItemKey.CODE_BLOCK]: {
|
||||||
$key: 'code-block',
|
$key: SideItemKey.CODE_BLOCK,
|
||||||
type: 'component',
|
type: 'component',
|
||||||
icon: EditPen,
|
icon: EditPen,
|
||||||
text: '代码编辑',
|
text: '代码编辑',
|
||||||
|
|||||||
@ -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' });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<FloatingBox
|
<FloatingBox
|
||||||
|
:body-style="{ padding: '0 16px' }"
|
||||||
v-model:visible="boxVisible"
|
v-model:visible="boxVisible"
|
||||||
v-model:width="width"
|
v-model:width="width"
|
||||||
v-model:height="editorHeight"
|
v-model:height="editorHeight"
|
||||||
:title="title"
|
:title="title"
|
||||||
|
:framework-width="frameworkWidth"
|
||||||
:position="boxPosition"
|
:position="boxPosition"
|
||||||
>
|
>
|
||||||
<template #body>
|
<template #body>
|
||||||
@ -22,7 +24,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { inject, nextTick, Ref, ref, watch, watchEffect } from 'vue';
|
import { computed, inject, nextTick, provide, Ref, ref, watch, watchEffect } from 'vue';
|
||||||
|
|
||||||
import type { DataSourceSchema } from '@tmagic/core';
|
import type { DataSourceSchema } from '@tmagic/core';
|
||||||
import { tMagicMessage } from '@tmagic/design';
|
import { tMagicMessage } from '@tmagic/design';
|
||||||
@ -41,6 +43,10 @@ const props = defineProps<{
|
|||||||
title?: string;
|
title?: string;
|
||||||
values: any;
|
values: any;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
|
/** 打开后需要直接定位并打开的方法名,传入时默认激活「方法定义」tab */
|
||||||
|
editMethodName?: string;
|
||||||
|
/** 打开后需要直接定位并打开的字段路径,传入时默认激活「数据定义」tab */
|
||||||
|
editFieldPath?: string[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const boxVisible = defineModel<boolean>('visible', { default: false });
|
const boxVisible = defineModel<boolean>('visible', { default: false });
|
||||||
@ -61,10 +67,36 @@ const { height: editorHeight } = useEditorContentHeight();
|
|||||||
|
|
||||||
const parentFloating = inject<Ref<HTMLDivElement | null>>('parentFloating', ref(null));
|
const parentFloating = inject<Ref<HTMLDivElement | null>>('parentFloating', ref(null));
|
||||||
const { boxPosition, calcBoxPosition } = useNextFloatBoxPosition(uiService, parentFloating);
|
const { boxPosition, calcBoxPosition } = useNextFloatBoxPosition(uiService, parentFloating);
|
||||||
|
const frameworkWidth = computed(() => uiService.get('frameworkRect')?.width || 0);
|
||||||
|
|
||||||
|
/** 供「方法定义」tab 内的字段消费,用于打开数据源详情后自动打开指定方法 */
|
||||||
|
provide(
|
||||||
|
'editingDataSourceMethodName',
|
||||||
|
computed(() => props.editMethodName),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 供「数据定义」tab 内的字段消费,用于打开数据源详情后自动打开指定字段 */
|
||||||
|
provide(
|
||||||
|
'editingDataSourceFieldPath',
|
||||||
|
computed(() => props.editFieldPath || []),
|
||||||
|
);
|
||||||
|
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
initValues.value = props.values;
|
initValues.value = props.values;
|
||||||
dataSourceConfig.value = dataSourceService.getFormConfig(initValues.value.type);
|
const config = dataSourceService.getFormConfig(initValues.value.type);
|
||||||
|
|
||||||
|
// 传入方法名/字段路径时,将外层 tab 容器默认激活到对应 tab(status: methods / fields)
|
||||||
|
// 未传入时默认激活「数据定义」tab(fields)
|
||||||
|
let activeTab = 'fields';
|
||||||
|
if (props.editMethodName) {
|
||||||
|
activeTab = 'methods';
|
||||||
|
} else if (props.editFieldPath?.length) {
|
||||||
|
activeTab = 'fields';
|
||||||
|
}
|
||||||
|
|
||||||
|
dataSourceConfig.value = config.map((item) =>
|
||||||
|
(item as { type?: string }).type === 'tab' ? { ...item, active: activeTab } : item,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const submitHandler = (values: any, data: ContainerChangeEventData) => {
|
const submitHandler = (values: any, data: ContainerChangeEventData) => {
|
||||||
|
|||||||
@ -29,6 +29,8 @@
|
|||||||
:disabled="!editable"
|
:disabled="!editable"
|
||||||
:values="dataSourceValues"
|
:values="dataSourceValues"
|
||||||
:title="dialogTitle"
|
:title="dialogTitle"
|
||||||
|
:edit-method-name="editMethodName"
|
||||||
|
:edit-field-path="editFieldPath"
|
||||||
@submit="submitDataSourceHandler"
|
@submit="submitDataSourceHandler"
|
||||||
@close="editDialogCloseHandler"
|
@close="editDialogCloseHandler"
|
||||||
></DataSourceConfigPanel>
|
></DataSourceConfigPanel>
|
||||||
@ -45,7 +47,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, inject, useTemplateRef, watch } from 'vue';
|
import { computed, inject, ref, useTemplateRef, watch } from 'vue';
|
||||||
import { mergeWith } from 'lodash-es';
|
import { mergeWith } from 'lodash-es';
|
||||||
|
|
||||||
import { tMagicMessageBox, TMagicScrollbar } from '@tmagic/design';
|
import { tMagicMessageBox, TMagicScrollbar } from '@tmagic/design';
|
||||||
@ -79,7 +81,16 @@ const { dataSourceService } = useServices();
|
|||||||
const { editDialog, dataSourceValues, dialogTitle, editable, editHandler, submitDataSourceHandler } =
|
const { editDialog, dataSourceValues, dialogTitle, editable, editHandler, submitDataSourceHandler } =
|
||||||
useDataSourceEdit(dataSourceService);
|
useDataSourceEdit(dataSourceService);
|
||||||
|
|
||||||
|
/** 打开数据源详情时需要直接定位并打开的方法名,为空则正常展示「数据定义」tab */
|
||||||
|
const editMethodName = ref('');
|
||||||
|
|
||||||
|
/** 打开数据源详情时需要直接定位并打开的字段路径,为空则不自动打开字段配置 */
|
||||||
|
const editFieldPath = ref<string[]>([]);
|
||||||
|
|
||||||
const editDialogCloseHandler = () => {
|
const editDialogCloseHandler = () => {
|
||||||
|
editMethodName.value = '';
|
||||||
|
editFieldPath.value = [];
|
||||||
|
|
||||||
if (dataSourceListRef.value) {
|
if (dataSourceListRef.value) {
|
||||||
for (const [, status] of dataSourceListRef.value.nodeStatusMap.entries()) {
|
for (const [, status] of dataSourceListRef.value.nodeStatusMap.entries()) {
|
||||||
status.selected = false;
|
status.selected = false;
|
||||||
@ -129,7 +140,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');
|
||||||
@ -139,6 +150,20 @@ const filterTextChangeHandler = (val: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
eventBus?.on('edit-data-source', (id: string) => {
|
eventBus?.on('edit-data-source', (id: string) => {
|
||||||
|
editMethodName.value = '';
|
||||||
|
editFieldPath.value = [];
|
||||||
|
editHandler(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
eventBus?.on('edit-data-source-method', (id: string, methodName: string) => {
|
||||||
|
editMethodName.value = methodName;
|
||||||
|
editFieldPath.value = [];
|
||||||
|
editHandler(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
eventBus?.on('edit-data-source-field', (id: string, fieldPath: string[]) => {
|
||||||
|
editMethodName.value = '';
|
||||||
|
editFieldPath.value = fieldPath;
|
||||||
editHandler(id);
|
editHandler(id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
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