Compare commits

..

70 Commits

Author SHA1 Message Date
roymondchen
734e6a8ee6 feat(form): 支持 rules.typeMatch 按字段 type 校验值类型与选项匹配
新增可扩展的 typeMatch 校验能力,兼容 async-validator 规则体系,并补充文档与单元测试。
2026-07-13 15:52:56 +08:00
roymondchen
f7d737a0c0 docs: 补充新增修改代码测试覆盖率不低于85%的开发约定
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 14:43:48 +08:00
roymondchen
42d45bed18 fix(form): radio group disabled不生效 2026-07-10 16:40:42 +08:00
roymondchen
de7a4f73ad style(table): 为 sub-actions 类名添加 tmagic-table 命名空间并迁移样式到主题文件 2026-07-07 19:45:38 +08:00
roymondchen
605f07c8fe feat(editor): 支持自定义历史对比/回滚弹窗内表单组件的尺寸 2026-07-06 17:17:39 +08:00
roymondchen
90492ad3ea fix(adapters): tag 非法 type 回退为默认主题
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 16:48:45 +08:00
roymondchen
b183e71f64 feat(table): 抽取 ActionPopconfirm 并优化 sub-actions 展开交互
将 Popconfirm 逻辑抽离为独立组件,ActionButton 内置 tooltip 支持,sub-actions 触发按钮改用箭头图标切换展开状态。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 16:47:16 +08:00
roymondchen
78a1259665 feat(table): 操作列支持 sub-actions 更多菜单并抽取 ActionButton 组件 2026-07-06 15:08:24 +08:00
roymondchen
4a893d35df fix(editor): 为 FloatingBox 拖拽遮罩添加失焦等兜底清理,避免残留导致编辑器无法点击 2026-07-06 11:34:41 +08:00
roymondchen
3c21f45e88 feat(editor): 支持通过 uiService 配置列布局最小宽度
将左侧、中间、右侧列的最小宽度从硬编码常量改为 UiState 配置项,便于业务侧自定义编辑器布局约束。
2026-07-03 19:44:13 +08:00
roymondchen
aa71fe9ddd fix(core): 在 initNode 前校验 config.items 为数组
避免 config.items 非数组时 forEach 抛出运行时错误。
2026-07-03 11:46:09 +08:00
roymondchen
9e8272b521 feat(table): 为操作列新增 Popconfirm 二次确认能力
支持 popconfirm、confirmText、popconfirmWidth 配置,并扩展 Popconfirm 组件的 width 与 teleported 属性。
2026-07-03 11:39:37 +08:00
roymondchen
9aa251ce57 fix(editor): 延迟 FloatingBox 拖拽遮罩到实际位移时显示,修复关闭按钮点击失效 2026-07-02 20:52:17 +08:00
roymondchen
3b9fb714e5 refactor(editor): 解耦 FloatingBox 的 uiService 依赖并改为 props 传入
- FloatingBox 不再强制依赖 uiService
- frameworkWidth 默认回退到视窗宽度
- 新增 initialStyle prop 支持外部设置初始样式
- 各调用方显式传入 frameworkWidth 以保留右边界收敛行为
2026-07-02 20:22:24 +08:00
roymondchen
284be0d276 fix(editor): 修复 FloatingBox 拖动时鼠标进入 iframe 区域事件丢失
- 拖拽/缩放开始时插入全屏透明遮罩盖住 iframe,结束时移除,避免事件被 iframe 吞掉
- 修正拖拽标题时 z-index 竞态导致遮罩被浮窗盖住的问题
- 将 body 内边距从 scss 抽取为 bodyStyle 透传,复用方按需自定义
2026-07-02 20:01:45 +08:00
roymondchen
c57ef89715 chore: update lockfile v1.8.0-beta.11 2026-06-29 21:27:41 +08:00
roymondchen
1ef3ea9eb9 chore: release v1.8.0-beta.11 2026-06-29 21:26:37 +08:00
roymondchen
83f4e52845 feat(editor): support custom width and initial diff mode for history diff dialog
为历史差异/回滚确认弹窗新增可配置宽度(dialogWidth / width 透传至 TMagicDialog),
并支持通过 payload.mode 指定打开时的初始对比模式,不可用时回退自动推断。
2026-06-29 20:23:19 +08:00
roymondchen
4330738b9f feat(form): add debug mode to submitForm
Allow submitForm to render a visible modal for manual validation and submission during debugging.
2026-06-29 15:49:33 +08:00
roymondchen
e59c3d2dcd chore: update lockfile v1.8.0-beta.10 2026-06-26 19:53:58 +08:00
roymondchen
55135fc063 chore: release v1.8.0-beta.10 2026-06-26 19:52:59 +08:00
roymondchen
af197363cf fix(vue-runtime-help): handle remove when root is not initialized
When root is empty but RemoveData carries app config, update root instead of throwing.
2026-06-26 19:51:44 +08:00
roymondchen
7bbb1f24c0 feat(editor): add unified change events 2026-06-26 19:48:38 +08:00
roymondchen
f7afed66aa feat(editor): refine history diff defaults 2026-06-26 17:08:39 +08:00
roymondchen
fd359e493f feat(form): support useFieldTextInError option
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 16:38:40 +08:00
roymondchen
5e661d0958 refactor(editor): 历史对比 services 改为显式传入并可选
CompareForm / HistoryDiffDialog / useHistoryRevert 不再通过 useServices()
默认 inject 获取 services,改为由调用方显式传入并设为可选,避免在
createApp 动态挂载游离弹窗场景下 inject 链不可靠。同步更新调用点、
测试与文档示例签名。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 15:00:08 +08:00
roymondchen
5ea992f5f7 chore: update lockfile v1.8.0-beta.9 2026-06-25 18:17:38 +08:00
roymondchen
4c81f22485 chore: release v1.8.0-beta.9 2026-06-25 18:13:58 +08:00
roymondchen
40acb5b34c feat: 导出 componentListService 和 keybindingService 2026-06-25 17:32:52 +08:00
roymondchen
ce65b18dbb feat(editor): 优化历史记录对比与回滚流程 2026-06-25 16:30:01 +08:00
roymondchen
1c936ff439 chore: update deps 2026-06-24 14:43:11 +08:00
roymondchen
e32780b0fb chore: update lockfile v1.8.0-beta.8 2026-06-23 20:21:19 +08:00
roymondchen
7e789aa83a chore: release v1.8.0-beta.8 2026-06-23 20:20:01 +08:00
roymondchen
0f42989ca3 refactor(editor): 统一历史栈结构,支持扩展历史类型
将 pageSteps/codeBlockState/dataSourceState 三套独立历史栈收敛为统一的 steps 结构
(按 stepType 分桶),并新增 registerStepType/setStepName/getStepName 支持自定义
扩展历史类型。同步重构 history 相关服务、组件、工具方法、测试与文档。
2026-06-23 20:14:41 +08:00
roymondchen
bfdaf2b244 fix(playground): 调整初始化时序避免历史恢复被覆盖
将本地 magicDSL 的恢复逻辑移动到 restoreHistory 之后,避免页面初始化时覆盖已恢复的历史状态。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 17:28:06 +08:00
roymondchen
1ade61d62e feat(editor): 历史记录支持展示操作人
在历史列表分组头部和子步骤中展示 operator 信息,并补充步骤类型字段以承载操作人与扩展数据,同时收敛相关类型定义与插件方法声明,提升历史记录渲染与扩展能力。
2026-06-18 17:24:25 +08:00
roymondchen
9f2fa1a9c8 refactor(editor): 抽离历史记录回滚交互并开放复用入口 2026-06-18 17:04:16 +08:00
roymondchen
38065f013f chore: update lockfile v1.8.0-beta.7 2026-06-17 17:20:52 +08:00
roymondchen
d5c1fab016 chore: release v1.8.0-beta.7 2026-06-17 17:19:49 +08:00
roymondchen
bbf79fd6df fix(editor): 修复历史记录面板分组与展示逻辑
统一历史记录列表在不同维度下的分组与展示行为,避免对比信息与交互状态不一致,并补齐对应单测覆盖。
2026-06-17 17:14:27 +08:00
roymondchen
24b9b34f65 fix(editor): 恢复新增组件在当前视窗中垂直居中
fixNodePosition 不应要求 style 已存在 top 才计算居中位置,否则从组件面板新增时无法写入视窗居中 top。
2026-06-17 14:20:07 +08:00
roymondchen
95769d6ae0 fix(editor): 数据源配置面板默认激活「数据定义」tab
未指定编辑方法或字段路径时,将 tab 容器默认激活到 fields,避免无 active 状态。
2026-06-16 16:34:27 +08:00
roymondchen
8933c46b73 fix(editor): sidebar 侧栏项 key 与 SideItemKey 常量对齐
确保侧栏配置与字段编辑时切换 tab 的 key 一致,避免硬编码字符串不一致。
2026-06-16 14:12:14 +08:00
roymondchen
c811908971 chore: update lockfile v1.8.0-beta.6 2026-06-15 20:47:10 +08:00
roymondchen
0774aa8e59 chore: release v1.8.0-beta.6 2026-06-15 20:46:05 +08:00
roymondchen
2ff7c479c2 test(editor): 补齐 CodeSelectCol 单测 uiService.set mock 2026-06-15 20:45:50 +08:00
roymondchen
4a8289137b fix(playground): 修复历史恢复后加载 DSL 时缺少页面基线记录
初始 set root 时标记各页面历史栈为已保存,并用 dsl.id 恢复 IndexedDB 历史,避免 restore 后再赋值时走 diff 分支导致无 initial 基线。
2026-06-15 20:43:53 +08:00
roymondchen
ffcc734102 feat(editor): 查看/编辑时自动切换 sidebar 并定位到对应配置
点击代码块、数据源字段或方法的选择器编辑/查看按钮时,自动切换到对应 sidebar tab,并打开详情中的目标字段或方法配置。
2026-06-15 17:43:09 +08:00
roymondchen
b35132e93e feat(editor): 边框方向有配置时显示高亮状态 2026-06-15 17:10:55 +08:00
roymondchen
921188d2da refactor(editor): 优化历史记录 IndexedDB 持久化,仅序列化 step diff
避免 serialize-javascript 序列化整份快照,改为结构化克隆写入 IndexedDB;初始历史行展示已保存标记;root-change 事件携带 options。
2026-06-12 17:46:55 +08:00
roymondchen
6960bd50e1 feat(editor): 增强 *AndGetHistoryId 与 revertById 的 historyIds 能力
*AndGetHistoryId 同时返回原操作结果与 historyIds 数组;revertById/revertPageStepById 统一按 uuids 批量回滚。
2026-06-12 16:32:47 +08:00
roymondchen
27fac02e99 fix(editor): 仅在使用 left/top 定位时修正节点位置
避免对使用 right/bottom 定位的绝对定位节点误写 left 或 top。
2026-06-12 15:04:45 +08:00
roymondchen
1298104732 fix(editor): 完善 fixed 与 absolute 定位切换逻辑
EditorNodeInfo 增加 path 复用节点路径,修复 right/bottom 锚定及冲突定位属性的计算问题。
2026-06-11 20:48:47 +08:00
roymondchen
9fe10e274c test: 补充 editor、stage、table 单元测试覆盖 2026-06-11 18:45:42 +08:00
roymondchen
89cef4e9a9 feat(editor): 数据源与代码块历史记录不再合并相邻操作
每条操作独立展示,与页面历史的合并策略区分开。
2026-06-11 17:25:54 +08:00
roymondchen
273d13dd1f chore: update lockfile v1.8.0-beta.5 2026-06-11 17:06:05 +08:00
roymondchen
771880b994 chore: release v1.8.0-beta.5 2026-06-11 17:05:03 +08:00
roymondchen
113af7dd51 feat(editor): 页面删除前增加确认弹窗并支持危险样式按钮 2026-06-11 17:00:10 +08:00
roymondchen
846f05e04d feat(design): popover 支持点击外部关闭
新增 closeOnClickOutside 与 clickOutsideIgnore 配置,
兼容 element-plus / tdesign 衍生浮层。
历史列表面板改用 v-model:visible 配合自动收起。
2026-06-11 16:49:54 +08:00
roymondchen
fd652b0d13 feat(editor): 页面历史记录点击选中对应画布节点
支持在页面历史 tab 点击记录行选中 diff 中的节点,并联动画布与 overlay;清空页面历史改用 clear-page 事件,避免 restore 时重复触发 change。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 16:22:25 +08:00
roymondchen
7d45aa5eec style(editor): 优化历史列表操作区悬停展示与列对齐
将回滚/回到/查看差异收敛为悬停显示的操作区,固定序号列右对齐,并调整「回到」按钮配色。
2026-06-11 15:51:44 +08:00
roymondchen
171d31e207 fix(stage): 复用 TargetShadow 修正闪烁高亮定位
闪烁提示改为通过 TargetShadow 定位,支持 updateDragEl 校准与滚动偏移。
2026-06-11 15:24:50 +08:00
roymondchen
6ba59c0d14 feat(editor): 将侧边栏激活面板状态同步至 uiService 2026-06-11 15:12:56 +08:00
roymondchen
4f284e8d9c feat(editor): 支持页面初始基线与 root 变更历史记录
设置 root 时为各页建立 initial 基线并展示在历史列表底部;编辑期间再次 set root 按页面粒度写入历史,并抽取历史工具函数以支持撤销下限与持久化恢复。
2026-06-11 15:00:11 +08:00
roymondchen
c4ec2c5c72 perf(editor): 优化节点信息查找性能 2026-06-09 14:23:43 +08:00
roymondchen
48519b0155 fix(editor): 优化历史回滚确认流程 2026-06-09 11:05:26 +08:00
roymondchen
a965dfb06e refactor(editor): 优化历史记录列表复用 2026-06-08 20:09:10 +08:00
roymondchen
614f12adf3 feat(editor): 支持历史记录持久化
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 17:04:39 +08:00
roymondchen
bddc6f343c feat(editor): 支持按历史记录 uuid 回滚 2026-06-05 19:25:50 +08:00
roymondchen
be3a900e6a fix(editor): 修复历史对比属性配置上下文缺失 2026-06-05 17:27:20 +08:00
182 changed files with 13783 additions and 3304 deletions

View File

@ -14,12 +14,13 @@ TMagic Editor 是魔方平台的可视化编辑器核心库,提供拖拽式组
## 架构地图 ## 架构地图
关键目录: 关键目录:
- `packages/` — 核心编辑器包202 *.vue, 194 *.ts
- `packages/` — 核心编辑器包
- `runtime/` — Vue/React Runtime 实现 - `runtime/` — Vue/React Runtime 实现
- `vue-components/` — Vue 组件封装 - `vue-components/` — Vue 组件封装
- `react-components/` — React 组件封装 - `react-components/` — React 组件封装
- `playground/` — 演示 playground - `playground/` — 演示 playground
- `docs/` — VitePress 文档100 *.md - `docs/` — VitePress 文档
- `scripts/` — 构建和发布脚本 - `scripts/` — 构建和发布脚本
- `eslint-config/` — 共享 ESLint 配置 - `eslint-config/` — 共享 ESLint 配置
@ -27,10 +28,13 @@ TMagic Editor 是魔方平台的可视化编辑器核心库,提供拖拽式组
**分支策略:** dev=dev, test/prod=master **分支策略:** dev=dev, test/prod=master
**提交规范:** commitlint + husky`type: 描述` **提交规范:** commitlint + husky`type: 描述`
**测试覆盖率:** 新增或修改的代码必须补充单元测试,覆盖率不低于 85%
**禁止事项:** **禁止事项:**
- 禁止在核心包中引入腾讯内部专有依赖(开源项目) - 禁止在核心包中引入腾讯内部专有依赖(开源项目)
- 禁止直接修改 CHANGELOG.md应通过 `pnpm changelog` 生成 - 禁止直接修改 CHANGELOG.md应通过 `pnpm changelog` 生成
- 禁止提交未达 85% 测试覆盖率的新增/修改代码
## 常用命令 ## 常用命令
@ -50,7 +54,7 @@ TMagic Editor 是魔方平台的可视化编辑器核心库,提供拖拽式组
## 深入阅读 ## 深入阅读
| 文档 | 说明 | | 文档 | 说明 |
|------|------| | --------------- | ---------------- |
| docs/ | VitePress 文档站 | | docs/ | VitePress 文档站 |
| CONTRIBUTING.md | 贡献指南 | | CONTRIBUTING.md | 贡献指南 |
| CHANGELOG.md | 变更日志 | | CHANGELOG.md | 变更日志 |

View File

@ -1,3 +1,111 @@
# [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) # [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)

View File

@ -201,8 +201,17 @@ export default defineConfig({
}, },
{ {
text: 'uiService', text: 'uiService',
items: [
{
text: '方法',
link: '/api/editor/uiServiceMethods.md', link: '/api/editor/uiServiceMethods.md',
}, },
{
text: '事件',
link: '/api/editor/uiServiceEvents.md',
},
],
},
{ {
text: 'codeBlockService', text: 'codeBlockService',
link: '/api/editor/codeBlockServiceMethods.md', link: '/api/editor/codeBlockServiceMethods.md',
@ -479,6 +488,15 @@ export default defineConfig({
} }
] ]
}, },
{
text: '表单校验',
items: [
{
text: '表单校验',
link: '/form-config/rules.md',
}
]
},
{ {
text: '表单对比', text: '表单对比',
items: [ items: [

View File

@ -1,7 +1,7 @@
# codeBlockService方法 # codeBlockService方法
写入历史栈的方法([setCodeDslById](#setcodedslbyid)、[setCodeDslByIdSync](#setcodedslbyidsync)、[deleteCodeDslByIds](#deletecodedslbyids) 等)的 `options` 支持 写入历史栈的方法([setCodeDslById](#setcodedslbyid)、[setCodeDslByIdSync](#setcodedslbyidsync)、[deleteCodeDslByIds](#deletecodedslbyids) 等)的 `options` 支持
[historyDescription / historySource](./editorServiceMethods.md#历史记录相关-options),会透传到 `historyService.pushCodeBlock` `historyDescription` / `source` 字段。 [historyDescription / historySource](./editorServiceMethods.md#历史记录相关-options),会透传到 `historyService.push('codeBlock', step, id)` 入栈记录`historyDescription` / `source` 字段。
## setCodeDsl ## setCodeDsl
@ -88,8 +88,8 @@
同步版本的 [setCodeDslById](#setcodedslbyid),并会触发 `addOrUpdate` 事件 同步版本的 [setCodeDslById](#setcodedslbyid),并会触发 `addOrUpdate` 事件
::: tip ::: tip
写入成功时(`force=false` 且同 id 已存在的跳过场景除外)会自动调用 `historyService.pushCodeBlock` 写入成功时(`force=false` 且同 id 已存在的跳过场景除外)会自动调用 `historyService.push('codeBlock', step, id)`
把本次变更入历史栈,参见 [historyService.pushCodeBlock](./historyServiceMethods.md#pushcodeblock)。 把本次变更入历史栈,参见 [historyService.push](./historyServiceMethods.md#push)。
传入的 `changeRecords` 会一同写进 step撤销/重做时调用方可据此按 `propPath` 局部回放。 传入的 `changeRecords` 会一同写进 step撤销/重做时调用方可据此按 `propPath` 局部回放。
传入 `doNotPushHistory: true` 可跳过写入历史栈,常用于批量导入、外部同步等非用户操作场景。 传入 `doNotPushHistory: true` 可跳过写入历史栈,常用于批量导入、外部同步等非用户操作场景。
::: :::
@ -231,10 +231,93 @@
在dsl数据源中删除指定id的代码块每删除一个会触发一次 `remove` 事件 在dsl数据源中删除指定id的代码块每删除一个会触发一次 `remove` 事件
::: tip ::: tip
对每个实际存在并被删除的代码块,会自动调用 `historyService.pushCodeBlock` 入栈一条 对每个实际存在并被删除的代码块,会自动调用 `historyService.push('codeBlock', step, id)` 入栈一条
`newContent=null` 的删除记录;不存在的 id 不会入历史。传入 `doNotPushHistory: true` 也可显式跳过写入历史栈。 `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 ## undo
- **参数:** - **参数:**
@ -287,7 +370,7 @@ if (codeBlockService.canUndo("code_1234")) {
- **详情:** - **详情:**
当前指定代码块是否可撤销,等价于 `historyService.canUndoCodeBlock(id)`。 当前指定代码块是否可撤销,等价于 `historyService.canUndo('codeBlock', id)`。
## canRedo ## canRedo
@ -299,7 +382,7 @@ if (codeBlockService.canUndo("code_1234")) {
- **详情:** - **详情:**
当前指定代码块是否可重做,等价于 `historyService.canRedoCodeBlock(id)`。 当前指定代码块是否可重做,等价于 `historyService.canRedo('codeBlock', id)`。
## setParamsColConfig ## setParamsColConfig

View File

@ -311,8 +311,8 @@ dataSourceService.setFormMethod("http", [
添加一个数据源如果配置中没有id或id已存在会自动生成新的id 添加一个数据源如果配置中没有id或id已存在会自动生成新的id
::: tip ::: tip
添加成功会自动调用 `historyService.pushDataSource` 入栈一条 `oldSchema=null` 的新增记录, 添加成功会自动调用 `historyService.push('dataSource', step, id)` 入栈一条 `oldSchema=null` 的新增记录,
参见 [historyService.pushDataSource](./historyServiceMethods.md#pushdatasource)。 参见 [historyService.push](./historyServiceMethods.md#push)。
传入 `doNotPushHistory: true` 可跳过写入历史栈,常用于批量导入、外部同步等非用户操作场景。 传入 `doNotPushHistory: true` 可跳过写入历史栈,常用于批量导入、外部同步等非用户操作场景。
::: :::
@ -355,7 +355,7 @@ console.log(newDs.id); // 自动生成的id
更新数据源 更新数据源
::: tip ::: tip
更新成功会自动调用 `historyService.pushDataSource` 入栈一条 `oldSchema` / `newSchema` 更新成功会自动调用 `historyService.push('dataSource', step, id)` 入栈一条 `oldSchema` / `newSchema`
均为对应 schema 的更新记录,传入的 `changeRecords` 也会一并写进 step撤销/重做时调用方可据此按 均为对应 schema 的更新记录,传入的 `changeRecords` 也会一并写进 step撤销/重做时调用方可据此按
`propPath` 局部回放,缺省才退化为整 schema 替换。传入 `doNotPushHistory: true` 可跳过写入历史栈。 `propPath` 局部回放,缺省才退化为整 schema 替换。传入 `doNotPushHistory: true` 可跳过写入历史栈。
::: :::
@ -394,7 +394,7 @@ console.log(updatedDs);
删除指定id的数据源 删除指定id的数据源
::: tip ::: tip
对实际存在的数据源会自动调用 `historyService.pushDataSource` 入栈一条 `newSchema=null` 对实际存在的数据源会自动调用 `historyService.push('dataSource', step, id)` 入栈一条 `newSchema=null`
的删除记录;不存在的 id 不会入历史。传入 `doNotPushHistory: true` 也可显式跳过写入历史栈。 的删除记录;不存在的 id 不会入历史。传入 `doNotPushHistory: true` 也可显式跳过写入历史栈。
::: :::
@ -406,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#行为扩展)** 是
@ -501,7 +579,7 @@ if (dataSourceService.canUndo("ds_123")) {
- **详情:** - **详情:**
当前指定数据源是否可撤销,等价于 `historyService.canUndoDataSource(id)`。 当前指定数据源是否可撤销,等价于 `historyService.canUndo('dataSource', id)`。
## canRedo ## canRedo
@ -513,7 +591,7 @@ if (dataSourceService.canUndo("ds_123")) {
- **详情:** - **详情:**
当前指定数据源是否可重做,等价于 `historyService.canRedoDataSource(id)`。 当前指定数据源是否可重做,等价于 `historyService.canRedo('dataSource', id)`。
## copyWithRelated ## copyWithRelated

View File

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

View File

@ -12,11 +12,36 @@
编辑器内置交互(画布、树面板、配置面板、右键菜单、快捷键等)会自动传入对应的 `historySource` 编辑器内置交互(画布、树面板、配置面板、右键菜单、快捷键等)会自动传入对应的 `historySource`
业务侧程序化调用时建议显式传入(如 `api`),便于历史面板区分来源。 业务侧程序化调用时建议显式传入(如 `api`),便于历史面板区分来源。
::: details 查看 HistoryOpOptions / DslOpOptions / HistoryOpSource 类型定义 ## 历史记录 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#HistoryOpOptions{ts}
<<< @/../packages/editor/src/type.ts#DslOpOptions{ts} <<< @/../packages/editor/src/type.ts#DslOpOptions{ts}
<<< @/../packages/editor/src/type.ts#DslOpWithHistoryIdsResult{ts}
<<< @/../packages/editor/src/type.ts#HistoryOpSource{ts} <<< @/../packages/editor/src/type.ts#HistoryOpSource{ts}
::: :::
@ -710,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#行为扩展)** 是
@ -720,6 +862,10 @@ 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/editor/src/type.ts#HistoryOpSource{ts}
@ -741,6 +887,10 @@ 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/editor/src/type.ts#HistoryOpSource{ts}

View File

@ -1,24 +1,18 @@
# 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/editor/src/type.ts#HistoryOpSource{ts}
@ -29,18 +23,36 @@
::: :::
:::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 ## code-block-history-change
- **详情:** 代码块历史记录发生变化(`pushCodeBlock` / `undoCodeBlock` / `redoCodeBlock` 成功时触发) - **详情:** 代码块历史记录发生变化(`push('codeBlock', step, codeBlockId)` / `undo('codeBlock', id)` / `redo('codeBlock', id)` 成功时触发)
- **事件回调函数:** `(codeBlockId: Id, step: CodeBlockStepValue) => void` - **事件回调函数:** `(codeBlockId: Id, step: CodeBlockStepValue) => void`
::: details 查看 CodeBlockStepValue 及关联类型定义 ::: details 查看 CodeBlockStepValue 及关联类型定义
<<< @/../packages/editor/src/type.ts#CodeBlockStepValue{ts} <<< @/../packages/editor/src/type.ts#CodeBlockStepValue{ts}
<<< @/../packages/editor/src/type.ts#BaseStepValue{ts}
<<< @/../packages/editor/src/type.ts#HistoryOpSource{ts} <<< @/../packages/editor/src/type.ts#HistoryOpSource{ts}
<<< @/../packages/schema/src/index.ts#CodeBlockContent{ts} <<< @/../packages/schema/src/index.ts#CodeBlockContent{ts}
@ -49,27 +61,62 @@
::: :::
:::tip :::tip
- 新增触发的 step `oldContent` 为 `null` - 新增触发的 step 其 diff 项 `oldSchema` 为 `null`
- 删除触发的 step `newContent` 为 `null` - 删除触发的 step 其 diff 项 `newSchema` 为 `null`
- `undo` / `redo` 返回 `null`(边界状态)时不会触发该事件 - `undo` / `redo` 返回 `null`(边界状态)时不会触发该事件
::: :::
## data-source-history-change ## data-source-history-change
- **详情:** 数据源历史记录发生变化(`pushDataSource` / `undoDataSource` / `redoDataSource` 成功时触发) - **详情:** 数据源历史记录发生变化(`push('dataSource', step, dataSourceId)` / `undo('dataSource', id)` / `redo('dataSource', id)` 成功时触发)
- **事件回调函数:** `(dataSourceId: Id, step: DataSourceStepValue) => void` - **事件回调函数:** `(dataSourceId: Id, step: DataSourceStepValue) => void`
::: details 查看 DataSourceStepValue 及关联类型定义 ::: details 查看 DataSourceStepValue 及关联类型定义
<<< @/../packages/editor/src/type.ts#DataSourceStepValue{ts} <<< @/../packages/editor/src/type.ts#DataSourceStepValue{ts}
<<< @/../packages/editor/src/type.ts#BaseStepValue{ts}
<<< @/../packages/editor/src/type.ts#HistoryOpSource{ts} <<< @/../packages/editor/src/type.ts#HistoryOpSource{ts}
<<< @/../packages/schema/src/index.ts#Id{ts} <<< @/../packages/schema/src/index.ts#Id{ts}
::: :::
:::tip :::tip
- 新增触发的 step `oldSchema``null` - 新增触发的 step 其 diff 项 `oldSchema``null`
- 删除触发的 step `newSchema``null` - 删除触发的 step 其 diff 项 `newSchema``null`
- `undo` / `redo` 返回 `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}
:::

View File

@ -4,43 +4,71 @@
- **详情:** - **详情:**
重置全部历史记录(包括页面节点栈、代码块栈、数据源栈),并重置当前页面 id / canRedo / canUndo 重置全部历史记录:清空 `state.steps` 下的页面 / 代码块 / 数据源 / 扩展类型全部栈(保留已注册的扩展类型键)。
## resetPage
- **详情:**
重置当前页面的历史记录状态清空当前页面id重置 canRedo/canUndo
## resetState ## resetState
- **详情:** - **详情:**
重置历史记录全部内部状态(清空 pageId、pageSteps、canRedo、canUndo、codeBlockState、dataSourceState 同 [`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/editor/src/type.ts#HistoryOpSource{ts}
@ -53,210 +81,209 @@
::: :::
- **返回:** - **返回:**
- `{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 ::: tip
`opType: 'update'` 的每个 `updatedItems[i]` 上可携带 `changeRecords`,用于撤销 / 重做时仅按 `opType: 'update'` 的每个 diff 项上可携带 `changeRecords`,用于撤销 / 重做时仅按
`propPath` 局部更新对应字段,避免整节点替换冲掉同节点上的其它无关变更;不带 `propPath` 局部更新对应字段,避免整节点替换冲掉同节点上的其它无关变更;不带
`changeRecords` 时退化为整节点替换(如 `sort` / `moveLayer` / 拖动等纯快照场景)。 `changeRecords` 时退化为整节点替换(如 `sort` / `moveLayer` / 拖动等纯快照场景)。
`StepValue` 上的 `historyDescription` / `source` 仅用于历史面板展示与埋点,不影响 undo/redo 行为。 `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}`
- **详情:** - **详情:**
撤销当前操作。`opType: 'update'` 时,若 `updatedItems[i].changeRecords` 存在,会按 撤销指定历史栈的最近一次变更。所有类型行为一致:按 `stepType` + `id` 定位栈,不会越过 index 0 的 initial 基线(所有类型同等适用,见 [`setMarker`](#setmarker)),仅在确有可撤销 step 时派发对应的历史变更事件(`page``change`,回调签名 `(id, step)`)。
`propPath``oldNode` 取值做局部回滚;否则用 `oldNode` 整节点替换。
`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}`
- **详情:** - **详情:**
恢复到下一步。`opType: 'update'` 时,若 `updatedItems[i].changeRecords` 存在,会按 恢复指定历史栈到下一步,语义与 [`undo`](#undo) 对称。`page` 类型 `opType: 'update'` 时,若 diff 项的 `changeRecords` 存在,会按 `propPath``newSchema` 取值做局部重做;否则用 `newSchema` 整节点替换。
`propPath``newNode` 取值做局部重做;否则用 `newNode` 整节点替换。
## pushCodeBlock ## canUndo
- **参数:** - **参数:**
- `{Id} codeBlockId` 代码块 id - `{HistoryStepType} stepType` 历史类型
- `{Object} payload` - `{Id} id` 可选;目标栈 id缺省 / 无效时返回 `false`
- `{CodeBlockContent | null} oldContent` 变更前的代码块内容;新增时为 `null`
- `{CodeBlockContent | null} newContent` 变更后的代码块内容;删除时为 `null`
- `{ChangeRecord[]} changeRecords` 可选form 端 propPath/value 变更列表,撤销/重做时若有则按 propPath 局部更新;缺省(或空数组)才退化为整内容替换
- `{string}` historyDescription 可选;人类可读描述,用于历史面板展示;不影响 undo/redo 行为
- `{HistoryOpSource}` source 可选;操作途径,用于历史面板展示与埋点;不影响 undo/redo 行为
::: details 查看 CodeBlockStepValue 及关联类型定义
<<< @/../packages/editor/src/type.ts#CodeBlockStepValue{ts}
<<< @/../packages/editor/src/type.ts#HistoryOpSource{ts}
<<< @/../packages/schema/src/index.ts#CodeBlockContent{ts}
<<< @/../packages/form-schema/src/base.ts#ChangeRecord{ts}
:::
- **返回:**
- `{CodeBlockStepValue | null}` 入栈失败(未传 id时返回 `null`
- **详情:**
推入一条代码块变更记录。与页面 / 节点完全无关,按 `codeBlockId` 维度独立一份 `UndoRedo` 栈,
栈实例存放在 `historyService.state.codeBlockState[codeBlockId]`
入栈成功后会触发 `code-block-history-change` 事件。
::: tip
`codeBlockService.setCodeDslByIdSync``codeBlockService.deleteCodeDslByIds` 内部已经
自动调用本方法,业务代码通常无需手动调用。
:::
## undoCodeBlock
- **参数:**
- `{Id} codeBlockId`
- **返回:**
- `{CodeBlockStepValue | null}` 栈不存在或已无可撤销记录时返回 `null`
- **详情:**
撤销指定代码块的最近一次变更。成功时会触发 `code-block-history-change` 事件。
拿到 step 后由调用方根据 `step.oldContent` 写回 `codeBlockService`(本方法不会自动回放)。
## redoCodeBlock
- **参数:**
- `{Id} codeBlockId`
- **返回:**
- `{CodeBlockStepValue | null}` 栈不存在或已无可重做记录时返回 `null`
- **详情:**
重做指定代码块的下一次变更。成功时会触发 `code-block-history-change` 事件。
## canUndoCodeBlock
- **参数:**
- `{Id} codeBlockId`
- **返回:** - **返回:**
- `{boolean}` - `{boolean}`
- **详情:** - **详情:**
指定代码块当前是否可撤销。栈不存在时返回 `false` 指定历史栈当前是否可撤销(游标高于 index 0 的 initial 基线底线)。适用于所有类型(`page` / `codeBlock` / `dataSource` / 扩展)。
## canRedoCodeBlock ## canRedo
- **参数:** - **参数:**
- `{Id} codeBlockId` - `{HistoryStepType} stepType` 历史类型
- `{Id} id` 可选;目标栈 id缺省 / 无效时返回 `false`
- **返回:** - **返回:**
- `{boolean}` - `{boolean}`
- **详情:** - **详情:**
指定代码块当前是否可重做。栈不存在时返回 `false` 指定历史栈当前是否可重做。适用于所有类型(`page` / `codeBlock` / `dataSource` / 扩展)。
## pushDataSource ## setMarker
- **参数:** - **参数:**
- `{Id} dataSourceId` 数据源 id - `{HistoryStepType} stepType` 历史类型
- `{Object} payload` - `{Id} id` 目标栈 id`page` 为 pageId其余类型为对应资源 id
- `{DataSourceSchema | null} oldSchema` 变更前的数据源 schema新增时为 `null` - `{Object} options` 可选:`name` / `description` / `source`,用于基线的展示信息
- `{DataSourceSchema | null} newSchema` 变更后的数据源 schema删除时为 `null`
- `{ChangeRecord[]} changeRecords` 可选form 端 propPath/value 变更列表,撤销/重做时若有则按 propPath 局部更新;缺省(或空数组)才退化为整 schema 替换
- `{string}` historyDescription 可选;人类可读描述,用于历史面板展示;不影响 undo/redo 行为
- `{HistoryOpSource}` source 可选;操作途径,用于历史面板展示与埋点;不影响 undo/redo 行为
::: details 查看 DataSourceStepValue 及关联类型定义 - **返回:**
<<< @/../packages/editor/src/type.ts#DataSourceStepValue{ts} - `{StepValue | null}` 已存在基线时返回原基线;栈非空(无基线)或 id 无效时返回 `null`
<<< @/../packages/editor/src/type.ts#HistoryOpSource{ts} - **详情:**
<<< @/../packages/form-schema/src/base.ts#ChangeRecord{ts} 为指定历史栈种入一条 `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}
::: :::
- **返回:** - **返回:**
- `{DataSourceStepValue | null}` 入栈失败(未传 id时返回 `null` - `{Promise<PersistedHistoryState>}` 写入成功的快照对象
- **详情:** - **详情:**
推入一条数据源变更记录。与页面 / 节点完全无关,按 `dataSourceId` 维度独立一份 `UndoRedo` 栈, 把当前内存中的全部历史栈(页面 / 代码块 / 数据源 / 扩展类型)连同各自游标、容量序列化后写入本地 IndexedDB。
栈实例存放在 `historyService.state.dataSourceState[dataSourceId]`
入栈成功后会触发 `data-source-history-change` 事件。 - 最终库名为 `${dbName}-${当前 DSL app id}`,按应用隔离;
- `key` 用于在同一 store 下区分不同记录,缺省为 `default`
- 历史记录里可能包含函数(代码块内容 / 节点事件等),内部使用 `serialize-javascript` 序列化为字符串后写入,恢复时再用 `parseDSL` 还原,因此可安全持久化函数 / `Map` 等;
- 不支持 IndexedDB 的环境(如 SSR会 reject。
::: tip 写入成功后触发 `save-to-indexed-db` 事件。
`dataSourceService.add` / `update` / `remove` 内部已经自动调用本方法,业务代码通常无需手动调用。
::: warning
`beforeunload` / `pagehide` 阶段浏览器不会等待异步 IndexedDB 事务提交,单纯依赖卸载时写入可能丢失最近一次编辑。建议在历史变更时(防抖)即调用本方法持久化,确保刷新后能完整恢复。
::: :::
## undoDataSource ## restoreFromIndexedDB
- **参数:** - **参数:**
- `{Id} dataSourceId` - `{HistoryPersistOptions} options` 可选
- **返回:** - **返回:**
- `{DataSourceStepValue | null}` - `{Promise<PersistedHistoryState | null>}` 找不到记录时返回 `null`
- **详情:** - **详情:**
撤销指定数据源的最近一次变更。成功时会触发 `data-source-history-change` 事件。 从本地 IndexedDB 读取此前保存的历史快照并重建全部撤销/重做栈。
拿到 step 后由调用方根据 `step.oldSchema` 写回 `dataSourceService`(本方法不会自动回放)。
## redoDataSource - 每个栈都会按 `listMaxSize` 裁剪并还原游标;
- 若某个栈存在已保存记录(见 `markSaved`),其游标会被定位到「最近一条已保存记录」之后,使恢复后的状态与落库的 DSL 对齐;
- 会整体覆盖当前内存中的历史状态(活动页由 `editorService` 维护,不在此恢复);
- 找不到对应记录时返回 `null` 且不改动当前状态;不支持 IndexedDB 的环境会 reject。
成功后触发 `restore-from-indexed-db` 事件。
## findStepLocationByUuid
- **参数:** - **参数:**
- `{Id} dataSourceId` - `{HistoryStepType} stepType` 历史类型
- `{string} uuid` 目标历史记录的 uuid
- `{Id} id` 可选;目标栈 id
- **返回:** - **返回:**
- `{DataSourceStepValue | null}` - `{ { id: Id; index: number } | null }` 找到时返回所属栈 id 与步骤索引;找不到时返回 `null`
- **详情:** - **详情:**
重做指定数据源的下一次变更。成功时会触发 `data-source-history-change` 事件。 按历史记录 uuid 在指定历史类型的栈中查找其所属 id 与索引,统一入口
## canUndoDataSource - **传入 `id`**:仅在该 id 对应的单个栈中查找(如页面历史按活动页查看,传入 pageId
- **缺省 `id`**:遍历该类型下全部栈查找(代码块 / 数据源等按全部资源分桶的场景)。
- **参数:** 供「按 uuid 回滚」等需要把 uuid 映射回 `(id, index)` 的场景使用,如 [editorService.revertPageStepById](./editorServiceMethods.md#revertpagestepbyid) / [codeBlockService.revertById](./codeBlockServiceMethods.md#revertbyid) / [dataSourceService.revertById](./dataSourceServiceMethods.md#revertbyid) 内部均通过本方法定位步骤。
- `{Id} dataSourceId`
- **返回:**
- `{boolean}`
- **详情:**
指定数据源当前是否可撤销。栈不存在时返回 `false`
## canRedoDataSource
- **参数:**
- `{Id} dataSourceId`
- **返回:**
- `{boolean}`
- **详情:**
指定数据源当前是否可重做。栈不存在时返回 `false`
## destroy ## destroy
- **详情:** - **详情:**
销毁 销毁

View 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` 事件
:::

View File

@ -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`: 框架尺寸

View File

@ -150,6 +150,14 @@
- **类型:** `boolean` - **类型:** `boolean`
## useFieldTextInError
- **详情:** 透传给内部 `Form`,控制表单校验失败时错误提示前缀是否使用字段的 `text` 文案。`false` 时直接使用字段 `name`
- **默认值:** `true`
- **类型:** `boolean`
## closeOnClickModal ## closeOnClickModal
- **详情:** 是否可以通过点击 modal 关闭 Dialog - **详情:** 是否可以通过点击 modal 关闭 Dialog

View File

@ -40,6 +40,8 @@
- **详情:** 通过 `name` 从表单 `config` 中查找对应表单项的 `text` - **详情:** 通过 `name` 从表单 `config` 中查找对应表单项的 `text`
- **相关:** 表单校验失败时是否使用该方法生成错误提示前缀,由 [`useFieldTextInError`](./form-props.md#usefieldtextinerror) prop 控制(默认 `true`
## values ## values
- **类型:** `Ref<FormValue>` - **类型:** `Ref<FormValue>`

View File

@ -206,6 +206,19 @@
- **类型:** `boolean` - **类型:** `boolean`
## useFieldTextInError
- **详情:**
表单校验失败时,错误提示前缀是否使用字段的 `text` 文案(通过 `getTextByName``config` 中查找)。
- `true`(默认):错误提示形如 `字段文案 -> 错误信息`,找不到 `text` 时回退为字段 `name`
- `false`:跳过查找,直接使用字段 `name` 作为错误提示前缀(形如 `字段name -> 错误信息`)。
- **默认值:** `true`
- **类型:** `boolean`
## extendState ## extendState
- **详情:** 扩展 formState 的钩子函数,返回的对象会被合并到 formState 上 - **详情:** 扩展 formState 的钩子函数,返回的对象会被合并到 formState 上

View File

@ -37,6 +37,7 @@ function submitForm(options: SubmitFormOptions): Promise<any>;
| `keyProp` | `string` | `'__key'` | 配置项的唯一 key | | `keyProp` | `string` | `'__key'` | 配置项的唯一 key |
| `popperClass` | `string` | — | 弹层 className | | `popperClass` | `string` | — | 弹层 className |
| `preventSubmitDefault` | `boolean` | — | 是否阻止表单原生 submit | | `preventSubmitDefault` | `boolean` | — | 是否阻止表单原生 submit |
| `useFieldTextInError` | `boolean` | `true` | 校验失败时错误提示前缀是否使用字段的 `text` 文案;`false` 时直接使用字段 `name` |
| `extendState` | `(state: FormState) => Record<string, any> \| Promise<Record<string, any>>` | — | 扩展 `formState` | | `extendState` | `(state: FormState) => Record<string, any> \| Promise<Record<string, any>>` | — | 扩展 `formState` |
| `native` | `boolean` | `false` | 透传给 `Form.submitForm``true` 时返回内部响应式 `values`,否则返回 `cloneDeep(toRaw(values))` | | `native` | `boolean` | `false` | 透传给 `Form.submitForm``true` 时返回内部响应式 `values`,否则返回 `cloneDeep(toRaw(values))` |
| `returnChangeRecords` | `boolean` | `false` | `true` 时 resolve 结果为 `{ values, changeRecords }`,携带表单变更记录;否则仅 resolve `values` | | `returnChangeRecords` | `boolean` | `false` | `true` 时 resolve 结果为 `{ values, changeRecords }`,携带表单变更记录;否则仅 resolve `values` |
@ -67,7 +68,10 @@ try {
type: 'text', type: 'text',
name: 'username', name: 'username',
text: '用户名', text: '用户名',
rules: [{ required: true, message: '请输入用户名' }], rules: [
{ required: true, message: '请输入用户名' },
{ typeMatch: true, message: '用户名类型不合法' },
],
}, },
], ],
initValues: { username: '' }, initValues: { username: '' },

View File

@ -24,6 +24,26 @@ type为'select'
</template> </template>
</demo-block> </demo-block>
## 选项值校验
可在 `rules` 中开启 `typeMatch`,校验当前值是否落在 `options` 中(`multiple` 时校验数组元素)。详见[表单校验](/form-config/rules.md)。
```ts
{
type: 'select',
name: 'status',
text: '状态',
options: [
{ text: '启用', value: 1 },
{ text: '禁用', value: 0 },
],
rules: [
{ required: true, message: '请选择状态' },
{ typeMatch: true, message: '状态值不合法' },
],
}
```
## 有禁用选项 ## 有禁用选项
<demo-block type="form" :config="[{ <demo-block type="form" :config="[{

View File

@ -82,6 +82,21 @@ Input输入框的type为'text', 是type的默认值所以可以不配置
</template> </template>
</demo-block> </demo-block>
## 表单校验
可通过 `rules` 配置校验规则。其中 `typeMatch: true` 会按字段 type以及 `filter` / `valueFormat` 等)校验值是否合法,详见[表单校验](/form-config/rules.md)。
```ts
{
name: 'text',
text: '输入框',
rules: [
{ required: true, message: '请输入' },
{ typeMatch: true, message: '值类型不合法' },
],
}
```
## 去掉首尾空格 ## 去掉首尾空格
<demo-block type="form" :config="[{ <demo-block type="form" :config="[{
@ -124,6 +139,7 @@ Input输入框的type为'text', 是type的默认值所以可以不配置
| tooltip | 输入时显示内容 | string / `ToolTipConfigType` | — | — | | tooltip | 输入时显示内容 | string / `ToolTipConfigType` | — | — |
| trim | 是否去掉首尾空格 | boolean | — | false | | trim | 是否去掉首尾空格 | boolean | — | false |
| filter | 过滤值 | string / Function | number | - | | filter | 过滤值 | string / Function | number | - |
| rules | 表单验证规则 | `Rule[]` | — | — |
| prepend | 前置内容 | string | — | - | | prepend | 前置内容 | string | — | - |
| append | 后置内容 | string / Object | — | - | | append | 后置内容 | string / Object | — | - |
| onChange | 值变化时触发的函数 | `OnChangeHandler` | — | - | | onChange | 值变化时触发的函数 | `OnChangeHandler` | — | - |
@ -140,6 +156,8 @@ Input输入框的type为'text', 是type的默认值所以可以不配置
<<< @/../packages/form-schema/src/base.ts#FormValue{ts} <<< @/../packages/form-schema/src/base.ts#FormValue{ts}
<<< @/../packages/form-schema/src/base.ts#ToolTipConfigType{ts} <<< @/../packages/form-schema/src/base.ts#ToolTipConfigType{ts}
<<< @/../packages/form-schema/src/base.ts#Rule{ts}
::: :::

183
docs/form-config/rules.md Normal file
View File

@ -0,0 +1,183 @@
# 表单校验
`@tmagic/form` 字段可通过 `rules` 配置校验规则,兼容 [async-validator](https://github.com/yiminghe/async-validator)Element Plus 内部使用)的常见能力,并额外支持按字段 `type` 做值类型 / 选项匹配校验(`typeMatch`)。
## 基础用法
```ts
{
name: 'title',
text: '名称',
rules: [
{ required: true, message: '请输入名称' },
{ typeMatch: true, message: '名称类型不合法' },
],
}
```
`rules` 类型见下方 [`Rule`](#类型定义)。未配置 `typeMatch` 时,现有表单行为不变。
## typeMatch
开启 `typeMatch: true` 后,表单会按字段 `config.type`(及关联配置)校验当前值是否合法。空值(`undefined` / `null` / `''`,多选类空数组 `[]`)直接通过,必填请继续使用 `required`
### 内置映射
| 字段 type | 期望值 |
| --- | --- |
| `text` / `textarea` / `color-picker` / `html` / 默认无 type | `string`;若 `filter: 'number'` 则为 `number``filter` 为自定义函数时跳过内置类型校验 |
| `display` / `hidden` | 不校验 |
| `number` | `number`(非 NaN |
| `number-range` | 长度为 2 的数字数组 |
| `date` / `datetime` / `time` | 按 `valueFormat` 校验;`x` / `timestamp` 期望 `number`,其余按 [Day.js format](https://day.js.org/docs/en/display/format) 严格解析字符串 |
| `daterange` / `timerange` | 无 `names` 时为长度为 2 的数组,元素按 `valueFormat` 校验;有 `names` 时跳过 |
| `switch` / `checkbox` | 值必须是解析后的 `activeValue` / `inactiveValue` 之一(显式配置优先;未配置且 `filter === 'number'` 时为 `1`/`0`;否则为 `true`/`false` |
| `select` | 单选:值 ∈ options`multiple`:数组且每项 ∈ options`allowCreate` / `remote`:只校验基础形态,不做 options 枚举 |
| `radio-group` / `radioGroup` | 值 ∈ options |
| `checkbox-group` / `checkboxGroup` | 数组且每项 ∈ options |
| `cascader` | 有 `valueSeparator` 时可为 `string``array`;默认 `emitPath` 为路径数组;`emitPath: false` 为叶子值;`multiple` 为数组;静态 options 校验路径/叶子;`remote` 只做形态校验 |
| `table` / `group-list` / `groupList` | `array` |
| 容器类(`row` / `tab` / `fieldset` / `panel` / `step` / `flex-layout` / `link` / `component` / `dynamic-field` 等) | 不校验 |
日期类默认 `valueFormat` 与字段组件一致:
- `date``YYYY/MM/DD`
- `datetime` / `daterange``YYYY/MM/DD HH:mm:ss`
- `time` / `timerange``HH:mm:ss`
### 与自定义 validator 共存
同一条 rule 同时配置了 `typeMatch``validator` 时,会先做类型匹配校验,通过后再执行自定义 `validator`
```ts
{
name: 'age',
type: 'number',
rules: [
{
typeMatch: true,
message: '年龄必须是数字',
validator: ({ value, callback }) => {
if (value < 0) {
callback(new Error('年龄不能小于 0'));
return;
}
callback();
},
},
],
}
```
## 扩展自定义 type 规则
业务可覆盖内置规则,或为自定义字段 type 注册校验。自定义规则优先于内置规则。
### 运行时注册
```ts
import {
registerTypeMatchRule,
registerTypeMatchRules,
deleteTypeMatchRule,
clearTypeMatchRules,
} from '@tmagic/form';
// 覆盖内置 text
registerTypeMatchRule('text', (value, { message }) => {
if (typeof value !== 'string') {
return message || '值类型应为字符串';
}
});
// 扩展业务字段
registerTypeMatchRule('vs-code', (value, { message }) => {
if (typeof value !== 'string') {
return message || '代码字段应为字符串';
}
});
// 批量注册
registerTypeMatchRules({
foo: (value) => (Array.isArray(value) ? undefined : '应为数组'),
});
// 删除 / 清空
deleteTypeMatchRule('foo');
clearTypeMatchRules();
```
自定义校验器签名:`(value, context) => string | undefined`。返回错误文案表示失败,返回 `undefined` 表示通过。`context` 包含 `fieldType``mForm``props``message`
### 安装时注册
```ts
import MagicForm from '@tmagic/form';
app.use(MagicForm, {
typeMatchRules: {
'my-field': (value, { message }) => {
if (typeof value !== 'string') {
return message || 'my-field 应为字符串';
}
},
},
});
```
## 示例
### select 选项匹配
```ts
{
name: 'status',
type: 'select',
options: [
{ text: '启用', value: 1 },
{ text: '禁用', value: 0 },
],
rules: [
{ required: true, message: '请选择状态' },
{ typeMatch: true, message: '状态值不合法' },
],
}
```
### date 按 valueFormat 校验
```ts
{
name: 'birthday',
type: 'date',
valueFormat: 'YYYY-MM-DD',
rules: [{ typeMatch: true, message: '日期格式不正确' }],
}
```
### text + filter: number
```ts
{
name: 'width',
type: 'text',
filter: 'number',
rules: [{ typeMatch: true, message: '宽度应为数字' }],
}
```
## 类型定义
::: details 查看 Rule 类型定义
<<< @/../packages/form-schema/src/base.ts#Rule{ts}
:::
::: details 查看 TypeMatchValidator / TypeMatchValidateContext 类型定义
<<< @/../packages/form/src/utils/typeMatch.ts#TypeMatchValidator{ts}
<<< @/../packages/form/src/utils/typeMatch.ts#TypeMatchValidateContext{ts}
:::
::: details 查看 FormInstallOptions 类型定义
<<< @/../packages/form/src/plugin.ts#FormInstallOptions{ts}
:::

View File

@ -61,6 +61,51 @@ const menu = ref({
- 数据源:`dataSourceService.revert(id, index)` - 数据源:`dataSourceService.revert(id, index)`
- 代码块:`codeBlockService.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. 差异对比 ### 4. 差异对比
在前后值都存在的 `update` 步骤上提供「查看差异」入口,点击后弹出差异对话框。对话框支持两个维度的切换: 在前后值都存在的 `update` 步骤上提供「查看差异」入口,点击后弹出差异对话框。对话框支持两个维度的切换:

View File

@ -2,7 +2,7 @@
tmagic-editor的表单配置核心就是使用了 @tmagic/form 来作为渲染器。@tmagic/form 是一个 npm 包,可以安装它,在你想使用的地方单独使用。 tmagic-editor的表单配置核心就是使用了 @tmagic/form 来作为渲染器。@tmagic/form 是一个 npm 包,可以安装它,在你想使用的地方单独使用。
@tmagic/form 接受一个表单配置,详细配置可参考[表单 api](../../api/form/form-props.md)。 @tmagic/form 接受一个表单配置,详细配置可参考[表单 api](../../api/form/form-props.md)。字段校验(含 `typeMatch` 类型匹配)见[表单校验](../../form-config/rules.md)。
## 安装 ## 安装
@ -45,6 +45,20 @@ app.use(MagicForm);
app.mount("#app"); app.mount("#app");
``` ```
也可在安装时传入自定义 `typeMatch` 规则,详见[表单校验 - 扩展自定义 type 规则](../../form-config/rules.md#扩展自定义-type-规则)
```javascript
app.use(MagicForm, {
typeMatchRules: {
'my-field': (value, { message }) => {
if (typeof value !== 'string') {
return message || 'my-field 应为字符串';
}
},
},
});
```
以上代码便完成了 @tmagic/form 的引入。需要注意的是Element Plus 的样式文件需要单独引入。 以上代码便完成了 @tmagic/form 的引入。需要注意的是Element Plus 的样式文件需要单独引入。

View File

@ -1,5 +1,5 @@
{ {
"version": "1.8.0-beta.4", "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"
}, },

View File

@ -1,5 +1,5 @@
{ {
"version": "1.8.0-beta.4", "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",

View File

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

View File

@ -76,10 +76,12 @@ class IteratorContainer extends Node {
} }
} }
if (Array.isArray(config.items)) {
config.items?.forEach((element: MNode) => { config.items?.forEach((element: MNode) => {
this.initNode(element, node, map); this.initNode(element, node, map);
}); });
} }
}
public setNodes(nodes: MNode[], index: number) { public setNodes(nodes: MNode[], index: number) {
const map = this.nodes[index] || new Map(); const map = this.nodes[index] || new Map();

View File

@ -79,10 +79,12 @@ class Page extends Node {
} }
} }
if (Array.isArray(config.items)) {
config.items?.forEach((element: MComponent | MContainer) => { config.items?.forEach((element: MComponent | MContainer) => {
this.initNode(element, node); this.initNode(element, node);
}); });
} }
}
public getNode<T extends TMagicNode = TMagicNode>( public getNode<T extends TMagicNode = TMagicNode>(
id: Id, id: Id,

View File

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

View File

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

View File

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

View File

@ -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-plustdesign 等多套 UI 这里同时列出
* 两套库的浮层 classclass 名互不冲突未命中的选择器无副作用避免切换适配器后失效
*/
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 / MessagePlugintooltip / 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;
// referencepopper
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>

View File

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

View File

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

View File

@ -135,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';
@ -227,6 +227,8 @@ const stageOptions: StageOptions = {
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);
@ -237,6 +239,11 @@ provide('stageOptions', stageOptions);
* PropsPanel 通过 `:extend-state` 显式传入的方式保持等价 * PropsPanel 通过 `:extend-state` 显式传入的方式保持等价
*/ */
provide('extendFormState', props.extendFormState); provide('extendFormState', props.extendFormState);
/**
* 提供 PropsPanel 主属性表单的 formState getter供历史差异弹窗复用
* CompareForm PropsPanel filterFunction 上下文保持一致
*/
provide('getPropsPanelFormState', () => propsPanelRef.value?.configForm?.formState);
/** /**
* 把历史记录面板的自定义扩展 tab 提供给深层的 HistoryListPanel它挂在 NavMenu * 把历史记录面板的自定义扩展 tab 提供给深层的 HistoryListPanel它挂在 NavMenu
@ -248,9 +255,11 @@ 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');
}; };

View File

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

View File

@ -10,21 +10,23 @@
:is-compare="true" :is-compare="true"
:disabled="true" :disabled="true"
:label-width="labelWidth" :label-width="labelWidth"
:extend-state="extendState" :extend-state="mergedExtendState"
:show-diff="showDiff" :show-diff="showDiff"
:self-diff-field-types="selfDiffFieldTypes"
:size="size"
></MForm> ></MForm>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, inject, type Ref, ref, type ShallowRef, useTemplateRef, watch, watchEffect } from 'vue'; import { computed, inject, provide, type Ref, ref, type ShallowRef, useTemplateRef, watch, watchEffect } from 'vue';
import { isEqual } from 'lodash-es'; import { isEqual } from 'lodash-es';
import { type CodeBlockContent, type DataSourceSchema, HookType, type MNode } from '@tmagic/core'; 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 FormConfig, type FormState, type FormValue, MForm } from '@tmagic/form';
import { useServices } from '@editor/hooks/use-services'; import type { CompareCategory, CompareFormLoadConfig, Services } from '@editor/type';
import type { CompareCategory, CompareFormLoadConfig } from '@editor/type';
import { getCodeBlockFormConfig } from '@editor/utils/code-block'; import { getCodeBlockFormConfig } from '@editor/utils/code-block';
defineOptions({ defineOptions({
@ -61,21 +63,35 @@ const props = withDefaults(
* 因此在差异对比场景下也需要透传避免出现 `formState.xxx is undefined` 的运行时错误 * 因此在差异对比场景下也需要透传避免出现 `formState.xxx is undefined` 的运行时错误
*/ */
extendState?: (_state: FormState) => Record<string, any> | Promise<Record<string, any>>; 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) * 自定义 FormConfig 加载逻辑传入后将接管内置的按 `category`(node/data-source/code-block)
* 取配置逻辑调用方可根据业务自行返回或异步返回表单配置可通过 * 取配置逻辑调用方可根据业务自行返回或异步返回表单配置可通过
* `ctx.defaultLoadConfig()` 复用默认结果再做二次加工返回的 config 直接用于对比展示 * `ctx.defaultLoadConfig()` 复用默认结果再做二次加工返回的 config 直接用于对比展示
*/ */
loadConfig?: CompareFormLoadConfig; loadConfig?: CompareFormLoadConfig;
/** 编辑器服务集合,由调用方传入(不再通过 inject('services') 获取)。 */
services?: Services;
}>(), }>(),
{ {
category: 'node', category: 'node',
labelWidth: '120px', labelWidth: '120px',
extendState: (state: FormState) => state,
}, },
); );
const { propsService, dataSourceService, codeBlockService, editorService } = useServices(); provide('services', props.services);
const services = useServices();
const config = ref<FormConfig>([]); const config = ref<FormConfig>([]);
@ -170,24 +186,38 @@ const removeStyleDisplayConfig = (formConfig: FormConfig): FormConfig =>
}; };
}); });
const mergedExtendState = (state: FormState) => {
return props.extendState(props.baseFormState || state);
};
/** /**
* 内置的默认 FormConfig 加载逻辑 `category` 从对应 service / 工具取配置 * 内置的默认 FormConfig 加载逻辑 `category` 从对应 service / 工具取配置
* 作为 ctx.defaultLoadConfig 透传给自定义 `loadConfig`方便复用与二次加工 * 作为 ctx.defaultLoadConfig 透传给自定义 `loadConfig`方便复用与二次加工
*/ */
const defaultLoadConfig = async (): Promise<FormConfig> => { const defaultLoadConfig = async (): Promise<FormConfig> => {
if (!props.services) {
return [];
}
switch (props.category) { switch (props.category) {
case 'node': { case 'node': {
if (!props.type) { if (!props.type) {
return []; return [];
} }
return removeStyleDisplayConfig(await propsService.getPropsConfig(props.type)); return removeStyleDisplayConfig(
await props.services.propsService.getPropsConfig(props.type, { node: props.value as unknown as MNode }),
);
} }
case 'data-source': { case 'data-source': {
return dataSourceService.getFormConfig(props.type || 'base'); 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': { case 'code-block': {
return getCodeBlockFormConfig({ return getCodeBlockFormConfig({
paramColConfig: codeBlockService.getParamsColConfig(), paramColConfig: props.services.codeBlockService.getParamsColConfig(),
// dataSourceType "" props.dataSourceType // dataSourceType "" props.dataSourceType
// step // step
isDataSource: () => Boolean(props.dataSourceType), isDataSource: () => Boolean(props.dataSourceType),
@ -235,12 +265,10 @@ const formRef = useTemplateRef<InstanceType<typeof MForm>>('form');
* - services整个 useServices() 返回的服务集合 * - services整个 useServices() 返回的服务集合
* - stage当前 editorService.get('stage') 的最新值 * - stage当前 editorService.get('stage') 的最新值
*/ */
const stage = computed(() => editorService.get('stage'));
watchEffect(() => { watchEffect(() => {
if (formRef.value) { if (formRef.value && props.services) {
formRef.value.formState.stage = stage.value; formRef.value.formState.stage = props.services.editorService.get('stage');
formRef.value.formState.services = services; formRef.value.formState.services = props.services;
} }
}); });

View File

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

View File

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

View File

@ -107,10 +107,12 @@ 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
@ -191,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>

View File

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

View File

@ -8,10 +8,12 @@
</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 { computed, 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';
@ -101,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;
@ -158,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;
}, },
}, },
{ {
@ -354,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>

View File

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

View File

@ -13,7 +13,7 @@
></MCascader> ></MCascader>
<TMagicTooltip <TMagicTooltip
v-if="model[name] && isCustomMethod && hasDataSourceSidePanel && !isCompare" 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,7 +75,7 @@ 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),
); );
@ -208,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>

View File

@ -21,7 +21,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, inject, 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';
@ -52,6 +52,29 @@ const codeBlockEditorRef = useTemplateRef<InstanceType<typeof 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: '名称',
@ -77,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();
});
}, },
}, },
{ {
@ -158,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>

View File

@ -7,10 +7,12 @@
</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>
@ -254,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>

View File

@ -4,27 +4,31 @@
<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>
@ -98,7 +102,7 @@ const emit = defineEmits<{
addDiffCount: []; addDiffCount: [];
}>(); }>();
withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
model: FormValue; model: FormValue;
lastValues?: FormValue; lastValues?: FormValue;
@ -109,6 +113,11 @@ withDefaults(
{}, {},
); );
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, {

View File

@ -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';
@ -72,7 +74,10 @@ 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 CompareForm } from './components/CompareForm.vue';
export { default as HistoryListBucket } from './layouts/history-list/Bucket.vue'; export { default as HistoryListBucket } from './layouts/history-list/Bucket.vue';
export { default as HistoryListBucketTab } from './layouts/history-list/BucketTab.vue';
export { default as HistoryDiffDialog } from './layouts/history-list/HistoryDiffDialog.vue'; export { default as 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';

View File

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

View File

@ -25,9 +25,9 @@
right-class="m-editor-framework-right" right-class="m-editor-framework-right"
:left="hideSidebar ? undefined : columnWidth.left" :left="hideSidebar ? undefined : columnWidth.left"
:right="columnWidth.right" :right="columnWidth.right"
:min-left="hideSidebar ? 0 : 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"
> >
@ -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';
@ -116,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();
}); });
@ -168,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);
} }

View File

@ -90,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;
@ -100,7 +100,7 @@ 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;

View File

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

View File

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

View File

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

View File

@ -1,5 +1,4 @@
<template> <template>
<Teleport to="body">
<TMagicDialog <TMagicDialog
v-model="visible" v-model="visible"
class="m-editor-history-diff-dialog" class="m-editor-history-diff-dialog"
@ -10,7 +9,7 @@
:width="width" :width="width"
@close="onClose" @close="onClose"
> >
<div v-if="payload" class="m-editor-history-diff-dialog-body"> <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 v-if="onConfirm" class="m-editor-history-diff-dialog-notice">仅回滚有差异的字段</div>
<div class="m-editor-history-diff-dialog-header"> <div class="m-editor-history-diff-dialog-header">
@ -22,7 +21,7 @@
</TMagicRadioGroup> </TMagicRadioGroup>
<TMagicRadioGroup v-model="mode" size="small" class="m-editor-history-diff-dialog-mode"> <TMagicRadioGroup v-model="mode" size="small" class="m-editor-history-diff-dialog-mode">
<TMagicRadioButton value="before">与修改前对比</TMagicRadioButton> <TMagicRadioButton value="before" :disabled="!hasValue">与修改前对比</TMagicRadioButton>
<TMagicRadioButton value="current" :disabled="!hasCurrent">与当前对比</TMagicRadioButton> <TMagicRadioButton value="current" :disabled="!hasCurrent">与当前对比</TMagicRadioButton>
</TMagicRadioGroup> </TMagicRadioGroup>
</div> </div>
@ -44,9 +43,12 @@
:data-source-type="payload.dataSourceType" :data-source-type="payload.dataSourceType"
:value="rightValue" :value="rightValue"
:last-value="leftValue" :last-value="leftValue"
:base-form-state="compareFormState"
:extend-state="extendState" :extend-state="extendState"
:load-config="loadConfig" :load-config="loadConfig"
:self-diff-field-types="selfDiffFieldTypes" :self-diff-field-types="selfDiffFieldTypes"
:services="props.services"
:size="props.size"
height="70vh" height="70vh"
/> />
@ -63,26 +65,32 @@
</div> </div>
<template #footer> <template #footer>
<template v-if="onConfirm"> <template v-if="isConfirm">
<TMagicButton size="small" @click="visible = false">取消</TMagicButton> <TMagicButton size="small" @click="visible = false">取消</TMagicButton>
<TMagicButton size="small" type="primary" @click="onConfirmClick">确定回滚</TMagicButton> <TMagicButton size="small" type="primary" @click="onConfirmClick">确定回滚</TMagicButton>
</template> </template>
<TMagicButton v-else size="small" @click="visible = false">关闭</TMagicButton> <TMagicButton v-else size="small" @click="visible = false">关闭</TMagicButton>
</template> </template>
</TMagicDialog> </TMagicDialog>
</Teleport>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { isEqual } from 'lodash-es'; import { isEqual } from 'lodash-es';
import { TMagicButton, TMagicDialog, TMagicRadioButton, TMagicRadioGroup, TMagicTag } from '@tmagic/design'; import {
type FieldSize,
TMagicButton,
TMagicDialog,
TMagicRadioButton,
TMagicRadioGroup,
TMagicTag,
} from '@tmagic/design';
import type { FormState } from '@tmagic/form'; import type { FormState } from '@tmagic/form';
import CompareForm from '@editor/components/CompareForm.vue'; import CompareForm from '@editor/components/CompareForm.vue';
import CodeEditor from '@editor/layouts/CodeEditor.vue'; import CodeEditor from '@editor/layouts/CodeEditor.vue';
import type { CompareCategory, CompareFormLoadConfig, DiffDialogPayload } from '@editor/type'; import type { CompareCategory, CompareFormLoadConfig, DiffDialogPayload, Services } from '@editor/type';
defineOptions({ defineOptions({
name: 'MEditorHistoryDiffDialog', name: 'MEditorHistoryDiffDialog',
@ -90,6 +98,8 @@ defineOptions({
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
/** 编辑器服务集合,由调用方传入(不再通过 inject('services') 获取)。 */
services?: Services;
/** /**
* 来自 Editor 顶层的 `extendFormState`用于扩展 MForm.formState * 来自 Editor 顶层的 `extendFormState`用于扩展 MForm.formState
* 透传给 CompareForm从而让差异对比时表单 item 中依赖业务上下文的 * 透传给 CompareForm从而让差异对比时表单 item 中依赖业务上下文的
@ -102,8 +112,12 @@ const props = withDefaults(
*/ */
loadConfig?: CompareFormLoadConfig; loadConfig?: CompareFormLoadConfig;
width?: string; width?: string;
/** 差异对比表单内组件的尺寸(透传给 CompareForm 的 `size`),可选 'large' | 'default' | 'small'。 */
size?: FieldSize;
isConfirm?: boolean;
onConfirm?: () => void; onConfirm?: () => void;
selfDiffFieldTypes?: string[]; selfDiffFieldTypes?: string[];
compareFormState?: FormState;
}>(), }>(),
{ {
width: '900px', width: '900px',
@ -155,6 +169,23 @@ const dialogTitle = computed(() => (props.onConfirm ? '确认回滚' : '查看
const hasCurrent = computed(() => payload.value?.currentValue !== undefined && payload.value?.currentValue !== null); 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>>(() => { const leftValue = computed<Record<string, any>>(() => {
if (!payload.value) return {}; if (!payload.value) return {};
@ -178,10 +209,15 @@ const isSameAsCurrent = computed(() => {
return isEqual(payload.value.value, payload.value.currentValue); return isEqual(payload.value.value, payload.value.currentValue);
}); });
const onConfirmClick = () => { /** confirm() 的 resolve仅在「等待用户确认回滚」期间存在 */
const cb = props.onConfirm; let confirmResolve: ((_value: boolean) => void) | null = null;
cb?.(); const onConfirmClick = () => {
props.onConfirm?.();
// resolve(true) visible=false resolve(false)
confirmResolve?.(true);
confirmResolve = null;
visible.value = false; visible.value = false;
}; };
@ -203,13 +239,32 @@ const targetText = computed(() => {
const open = (p: DiffDialogPayload) => { const open = (p: DiffDialogPayload) => {
payload.value = p; payload.value = p;
// ""退 // payload hasValue / hasCurrent payload
mode.value = 'before'; // 使 payload.mode
mode.value = resolveInitialMode(p.mode);
// //
viewMode.value = 'form'; viewMode.value = 'form';
visible.value = true; 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 = () => { const close = () => {
visible.value = false; visible.value = false;
}; };
@ -218,6 +273,9 @@ const close = () => {
watch(visible, (v) => { watch(visible, (v) => {
if (!v) { if (!v) {
payload.value = null; payload.value = null;
// / Esc / resolve(false)
confirmResolve?.(false);
confirmResolve = null;
} }
}); });
@ -227,6 +285,7 @@ const onClose = () => {
defineExpose({ defineExpose({
open, open,
confirm,
close, close,
}); });
</script> </script>

View File

@ -3,7 +3,7 @@
popper-class="m-editor-history-list-popover" popper-class="m-editor-history-list-popover"
placement="bottom" placement="bottom"
trigger="click" trigger="click"
:visible="visible" v-model:visible="visible"
:width="660" :width="660"
> >
<div class="m-editor-history-list"> <div class="m-editor-history-list">
@ -18,60 +18,69 @@
<TMagicTabs v-model="activeTab" class="m-editor-history-list-tabs"> <TMagicTabs v-model="activeTab" class="m-editor-history-list-tabs">
<component <component
:is="tabPaneComponent?.component || 'el-tab-pane'" :is="tabPaneComponent?.component || 'el-tab-pane'"
v-bind="tabPaneComponent?.props({ name: 'page', label: `页面 (${pageGroups.length})` }) || {}" v-bind="
tabPaneComponent?.props({ name: 'page', label: `${pageName} (${pageGroups.length})`, lazy: true }) || {}
"
> >
<PageTab <PageTab
:list="pageGroupsDisplay" :list="pageGroupsDisplay"
:expanded="expanded" :expanded="expanded"
:marker="pageMarker"
@toggle="toggleGroup" @toggle="toggleGroup"
@goto="onPageGoto" @goto="onPageGoto"
@goto-initial="onPageGotoInitial" @goto-initial="onPageGotoInitial"
@diff-step="onPageDiff" @diff-step="onPageDiff"
@revert-step="onPageRevert" @revert-step="onPageRevert"
@select="onPageSelect"
@clear="onPageClear"
/> />
</component> </component>
<component <component
v-if="!disabledDataSource" v-if="!disabledDataSource"
:is="tabPaneComponent?.component || 'el-tab-pane'" :is="tabPaneComponent?.component || 'el-tab-pane'"
v-bind="tabPaneComponent?.props({ name: 'data-source', label: `数据源 (${dataSourceGroups.length})` }) || {}" v-bind="
tabPaneComponent?.props({
name: 'data-source',
label: `${dataSourceName} (${dataSourceGroups.length})`,
lazy: true,
}) || {}
"
> >
<BucketTab <BucketTab
title="数据源" :config="dataSourceConfig"
prefix="ds"
:buckets="dataSourceGroupsByTarget" :buckets="dataSourceGroupsByTarget"
:expanded="expanded" :expanded="expanded"
:describe-group="describeDataSourceGroup"
:describe-step="describeDataSourceStep"
:is-step-diffable="isDataSourceStepDiffable"
:is-step-revertable="isDataSourceStepRevertable"
@toggle="toggleGroup" @toggle="toggleGroup"
@goto="onDataSourceGoto" @goto="onDataSourceGoto"
@goto-initial="onDataSourceGotoInitial" @goto-initial="onDataSourceGotoInitial"
@diff-step="onDataSourceDiff" @diff-step="onDataSourceDiff"
@revert-step="onDataSourceRevert" @revert-step="onDataSourceRevert"
@clear="onDataSourceClear"
/> />
</component> </component>
<component <component
v-if="!disabledCodeBlock" v-if="!disabledCodeBlock"
:is="tabPaneComponent?.component || 'el-tab-pane'" :is="tabPaneComponent?.component || 'el-tab-pane'"
v-bind="tabPaneComponent?.props({ name: 'code-block', label: `代码块 (${codeBlockGroups.length})` }) || {}" v-bind="
tabPaneComponent?.props({
name: 'code-block',
label: `${codeBlockName} (${codeBlockGroups.length})`,
lazy: true,
}) || {}
"
> >
<BucketTab <BucketTab
title="代码块" :config="codeBlockConfig"
prefix="cb"
:buckets="codeBlockGroupsByTarget" :buckets="codeBlockGroupsByTarget"
:expanded="expanded" :expanded="expanded"
:describe-group="describeCodeBlockGroup"
:describe-step="describeCodeBlockStep"
:is-step-diffable="isCodeBlockStepDiffable"
:is-step-revertable="isCodeBlockStepRevertable"
@toggle="toggleGroup" @toggle="toggleGroup"
@goto="onCodeBlockGoto" @goto="onCodeBlockGoto"
@goto-initial="onCodeBlockGotoInitial" @goto-initial="onCodeBlockGotoInitial"
@diff-step="onCodeBlockDiff" @diff-step="onCodeBlockDiff"
@revert-step="onCodeBlockRevert" @revert-step="onCodeBlockRevert"
@clear="onCodeBlockClear"
/> />
</component> </component>
@ -79,7 +88,7 @@
v-for="tab in extraTabs" v-for="tab in extraTabs"
:key="tab.name" :key="tab.name"
:is="tabPaneComponent?.component || 'el-tab-pane'" :is="tabPaneComponent?.component || 'el-tab-pane'"
v-bind="tabPaneComponent?.props({ name: tab.name, label: resolveTabLabel(tab) }) || {}" 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 :is="tab.component" v-bind="tab.props || {}" v-on="tab.listeners || {}" />
</component> </component>
@ -96,21 +105,14 @@
</TMagicTooltip> </TMagicTooltip>
</template> </template>
</TMagicPopover> </TMagicPopover>
<HistoryDiffDialog
ref="diffDialog"
:extend-state="extendFormState"
:on-confirm="onConfirmRevert"
@close="onDiffDialogClose"
/>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
/** /**
* 历史记录面板在顶部 NavMenu 上点击图标打开 popover分三个 tab * 历史记录面板在顶部 NavMenu 上点击图标打开 popover分三个 tab
* - 页面当前活动页面的历史栈连续修改同一节点的多步会被合并成一组 * - 页面当前活动页面的历史栈连续修改同一节点的多步会被合并成一组
* - 数据源 dataSource.id 每组内部相邻的连续 update 自动合并 * - 数据源 dataSource.id 分桶每条操作记录独立展示
* - 代码块同上 codeBlock.id 组并合并相邻 update * - 代码块同上 codeBlock.id 每条操作记录独立展示
* *
* 数据通过 historyService 暴露的聚合 API 读取UI 仅用于只读展示 * 数据通过 historyService 暴露的聚合 API 读取UI 仅用于只读展示
* 同时支持点击任意一条记录跳转至该状态 * 同时支持点击任意一条记录跳转至该状态
@ -127,28 +129,27 @@
* 通过 title / prefix / describe* / isStepDiffable * 通过 title / prefix / describe* / isStepDiffable
* 共享的描述生成与折叠状态在 composables.ts 中维护 * 共享的描述生成与折叠状态在 composables.ts 中维护
*/ */
import { computed, inject, markRaw, ref, shallowRef, useTemplateRef, watch } from 'vue'; import { computed, inject, markRaw, ref, watch } from 'vue';
import { Clock, Close } from '@element-plus/icons-vue'; import { Clock, Close } from '@element-plus/icons-vue';
import { getDesignConfig, TMagicButton, TMagicPopover, TMagicTabs, TMagicTooltip } from '@tmagic/design'; import { getDesignConfig, TMagicButton, tMagicMessage, TMagicPopover, TMagicTabs, TMagicTooltip } from '@tmagic/design';
import type { FormState } from '@tmagic/form'; import type { FormState } from '@tmagic/form';
import MIcon from '@editor/components/Icon.vue'; import MIcon from '@editor/components/Icon.vue';
import { useServices } from '@editor/hooks/use-services'; import { useServices } from '@editor/hooks/use-services';
import type { CodeBlockStepValue, DataSourceStepValue, DiffDialogPayload, HistoryListExtraTab } from '@editor/type'; import type {
BaseStepValue,
CodeBlockStepValue,
DataSourceStepValue,
HistoryBucketConfig,
HistoryListExtraTab,
} from '@editor/type';
import BucketTab from './BucketTab.vue'; import BucketTab from './BucketTab.vue';
import { import { confirmHistoryAction, describeStep, isSingleDiffStepRevertable } from './composables';
describeCodeBlockGroup,
describeCodeBlockStep,
describeDataSourceGroup,
describeDataSourceStep,
isCodeBlockStepRevertable,
isDataSourceStepRevertable,
useHistoryList,
} from './composables';
import HistoryDiffDialog from './HistoryDiffDialog.vue';
import PageTab from './PageTab.vue'; import PageTab from './PageTab.vue';
import { useHistoryList } from './useHistoryList';
import { useHistoryRevert } from './useHistoryRevert';
defineOptions({ defineOptions({
name: 'MEditorHistoryListPanel', name: 'MEditorHistoryListPanel',
@ -158,7 +159,10 @@ const ClockIcon = markRaw(Clock);
const CloseIcon = markRaw(Close); const CloseIcon = markRaw(Close);
const activeTab = ref<string>('page'); const activeTab = ref<string>('page');
/** 面板显隐受控reference 图标点击切换,右上角关闭按钮置为 false。 */ /**
* 面板显隐受控reference 图标点击切换右上角关闭按钮置为 false
* 点击面板以外区域的自动收起由 TMagicPopover 通过 v-model:visible 回写完成
*/
const visible = ref(false); const visible = ref(false);
const tabPaneComponent = getDesignConfig('components')?.tabPane; const tabPaneComponent = getDesignConfig('components')?.tabPane;
@ -172,7 +176,9 @@ const extraTabs = inject<HistoryListExtraTab[]>('historyListExtraTabs', []);
/** label 支持字符串或函数,函数形式便于展示动态数量等内容。 */ /** label 支持字符串或函数,函数形式便于展示动态数量等内容。 */
const resolveTabLabel = (tab: HistoryListExtraTab) => (typeof tab.label === 'function' ? tab.label() : tab.label); const resolveTabLabel = (tab: HistoryListExtraTab) => (typeof tab.label === 'function' ? tab.label() : tab.label);
const { editorService, dataSourceService, codeBlockService, historyService, propsService } = useServices(); const services = useServices();
const { editorService, dataSourceService, codeBlockService, historyService, propsService, stageOverlayService } =
services;
/** /**
* 数据源 / 代码块功能可被业务方通过 `disabledDataSource` / `disabledCodeBlock` 禁用 * 数据源 / 代码块功能可被业务方通过 `disabledDataSource` / `disabledCodeBlock` 禁用
@ -196,6 +202,7 @@ const extendFormState = inject<((_state: FormState) => Record<string, any> | Pro
'extendFormState', 'extendFormState',
undefined, undefined,
); );
const getPropsPanelFormState = inject<(() => FormState | undefined) | undefined>('getPropsPanelFormState', undefined);
const { const {
expanded, expanded,
@ -208,11 +215,46 @@ const {
codeBlockGroupsByTarget, codeBlockGroupsByTarget,
} = useHistoryList(); } = useHistoryList();
/** 数据源 step 仅 update前后 schema 都存在)时可查看差异。 */ /**
const isDataSourceStepDiffable = (step: DataSourceStepValue) => Boolean(step.oldSchema && step.newSchema); * 当前活动页的加载/初始标记记录设置 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 都存在)时可查看差异。 */ /** 代码块 step 仅 update前后 content 都存在)时可查看差异。 */
const isCodeBlockStepDiffable = (step: CodeBlockStepValue) => Boolean(step.oldContent && step.newContent); 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"(已应用步骤数量)。 */ /** 把"目标 step 索引"翻译成"目标 cursor"(已应用步骤数量)。 */
const indexToCursor = (index: number) => index + 1; const indexToCursor = (index: number) => index + 1;
@ -221,6 +263,29 @@ const onPageGoto = (index: number) => {
editorService.gotoPageStep(indexToCursor(index)); 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) => { const onDataSourceGoto = (id: string | number, index: number) => {
dataSourceService.goto(id, indexToCursor(index)); dataSourceService.goto(id, indexToCursor(index));
}; };
@ -245,140 +310,58 @@ const onCodeBlockGotoInitial = (id: string | number) => {
codeBlockService.goto(id, 0); codeBlockService.goto(id, 0);
}; };
const diffDialogRef = useTemplateRef<InstanceType<typeof HistoryDiffDialog>>('diffDialog'); /**
* 单步回滚查看差异的完整逻辑收敛到 useHistoryRevert面板与业务方共用
* 二者均由 useHistoryRevert 内部按需动态挂载 HistoryDiffDialog
* 业务方亦可直接 import useHistoryRevert(options, services) 调用无需自行挂载任何弹窗
*/
const { onPageRevert, onDataSourceRevert, onCodeBlockRevert, onPageDiff, onDataSourceDiff, onCodeBlockDiff } =
useHistoryRevert({ extendState: extendFormState, getPropsPanelFormState }, services);
/** /**
* 构造页面 step 的差异弹窗入参 update 单节点修改可对比传入旧/新节点 * 把内存中已清空对应类别后的历史状态重新写回 IndexedDB
* 节点类型 `type` 优先取 newNode.type再回退 oldNode.type * 使本地持久化的那份与内存保持一致连同本地保存的一并删除
* `currentValue` 取自 editorService 中该节点当前实际值用于支持与当前对比 * 不支持 IndexedDB 或写入失败时静默忽略内存清空已生效
* 无可对比内容如多节点 / add / remove时返回 null
*/ */
const buildPageDiffPayload = (index: number): DiffDialogPayload | null => { const syncIndexedDB = async () => {
const groups = historyService.getPageHistoryGroups(); try {
for (const group of groups) { await historyService.saveToIndexedDB();
const entry = group.steps.find((s) => s.index === index); // eslint-disable-next-line no-unused-vars
if (!entry) continue; } catch (e) {
const item = entry.step.updatedItems?.[0]; // ignore:
if (!item?.oldNode || !item?.newNode) return null;
const type = (item.newNode.type as string) || (item.oldNode.type as string) || '';
const nodeId = item.newNode.id ?? item.oldNode.id;
const currentNode = nodeId !== undefined ? editorService.getNodeById(nodeId) : null;
return {
category: 'node',
type,
lastValue: item.oldNode as Record<string, any>,
value: item.newNode as Record<string, any>,
currentValue: (currentNode as Record<string, any>) || null,
targetLabel: (item.newNode.name as string) || (item.oldNode.name as string) || type,
id: nodeId,
};
}
return null;
};
/**
* 在指定分组列表中按 id / index 查找命中的 step命中后交由 build 构造差异弹窗入参
* 用于统一数据源代码块两类历史的查找逻辑
*/
const findGroupStep = <G extends { id: string | number; steps: { index: number; step: any }[] }>(
groups: G[],
id: string | number,
index: number,
build: (_step: G['steps'][number]['step']) => DiffDialogPayload | null,
): DiffDialogPayload | null => {
for (const group of groups) {
if (group.id !== id) continue;
const entry = group.steps.find((s) => s.index === index);
if (!entry) continue;
return build(entry.step);
}
return null;
};
const buildDataSourceDiffPayload = (id: string | number, index: number): DiffDialogPayload | null =>
findGroupStep(historyService.getDataSourceHistoryGroups(), id, index, ({ oldSchema, newSchema }) => {
if (!oldSchema || !newSchema) return null;
const currentSchema = dataSourceService.getDataSourceById(`${id}`);
return {
category: 'data-source',
type: newSchema.type || oldSchema.type || 'base',
lastValue: oldSchema as Record<string, any>,
value: newSchema as Record<string, any>,
currentValue: (currentSchema as Record<string, any>) || null,
targetLabel: newSchema.title || oldSchema.title || `${id}`,
id,
};
});
const buildCodeBlockDiffPayload = (id: string | number, index: number): DiffDialogPayload | null =>
findGroupStep(historyService.getCodeBlockHistoryGroups(), id, index, ({ oldContent, newContent }) => {
if (!oldContent || !newContent) return null;
const currentContent = codeBlockService.getCodeContentById(id);
return {
category: 'code-block',
lastValue: oldContent as Record<string, any>,
value: newContent as Record<string, any>,
currentValue: (currentContent as Record<string, any>) || null,
targetLabel: newContent.name || oldContent.name || `${id}`,
id,
};
});
const onPageDiff = (index: number) => {
const payload = buildPageDiffPayload(index);
if (payload) diffDialogRef.value?.open(payload);
};
const onDataSourceDiff = (id: string | number, index: number) => {
const payload = buildDataSourceDiffPayload(id, index);
if (payload) diffDialogRef.value?.open(payload);
};
const onCodeBlockDiff = (id: string | number, index: number) => {
const payload = buildCodeBlockDiffPayload(id, index);
if (payload) diffDialogRef.value?.open(payload);
};
const onConfirmRevert = shallowRef();
/**
* 回滚入口把目标历史步骤的修改作为一次新操作反向应用 git revert
* 不破坏原有栈结构 service 内部完成反向 + 入栈并自带描述用于面板展示
*
* 交互先弹出该步骤的差异弹窗供用户确认点击确定回滚后再真正执行回滚
* 对没有可对比内容的步骤 add / remove / 多节点更新则直接回滚
*/
const onPageRevert = (index: number) => {
const payload = buildPageDiffPayload(index);
onConfirmRevert.value = () => editorService.revertPageStep(index);
if (payload) {
diffDialogRef.value?.open({ ...payload });
} else {
onConfirmRevert.value();
} }
}; };
const onDataSourceRevert = (id: string | number, index: number) => { const onPageClear = async () => {
const payload = buildDataSourceDiffPayload(id, index); if (
onConfirmRevert.value = () => dataSourceService.revert(id, index); await confirmHistoryAction(
if (payload) { '确定清空当前页面的历史记录吗?清空后将无法撤销/重做之前的操作,本地保存的记录也会一并删除。',
diffDialogRef.value?.open({ ...payload }); )
} else { ) {
onConfirmRevert.value(); historyService.clear('page', editorService.get('page')?.id);
await syncIndexedDB();
} }
}; };
const onCodeBlockRevert = (id: string | number, index: number) => { const onDataSourceClear = async () => {
const payload = buildCodeBlockDiffPayload(id, index); if (
onConfirmRevert.value = () => codeBlockService.revert(id, index); await confirmHistoryAction(
if (payload) { '确定清空数据源的历史记录吗?清空后将无法撤销/重做之前的操作,本地保存的记录也会一并删除。',
diffDialogRef.value?.open({ ...payload }); )
} else { ) {
onConfirmRevert.value(); historyService.clear('dataSource');
await syncIndexedDB();
} }
}; };
const onDiffDialogClose = () => { const onCodeBlockClear = async () => {
onConfirmRevert.value = undefined; if (
await confirmHistoryAction(
'确定清空代码块的历史记录吗?清空后将无法撤销/重做之前的操作,本地保存的记录也会一并删除。',
)
) {
historyService.clear('codeBlock');
await syncIndexedDB();
}
}; };
</script> </script>

View File

@ -2,18 +2,16 @@
<li <li
class="m-editor-history-list-item m-editor-history-list-initial" class="m-editor-history-list-item m-editor-history-list-initial"
:class="{ 'is-current': isCurrent, 'is-clickable': !isCurrent }" :class="{ 'is-current': isCurrent, 'is-clickable': !isCurrent }"
:title="isCurrent ? '当前已回到未修改的初始状态' : '点击回到未修改的初始状态'" :title="rowTitle"
> >
<span class="m-editor-history-list-item-index" title="历史步骤编号 #0未修改的初始状态">#0</span> <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-op op-initial">初始</span>
<span class="m-editor-history-list-item-desc">未修改的初始状态</span> <span class="m-editor-history-list-item-desc">{{ desc }}</span>
<span <span v-if="saved" class="m-editor-history-list-item-saved" title="该记录为最近一次保存的状态">已保存</span>
v-if="gotoEnabled && !isCurrent" <span v-if="gotoEnabled && !isCurrent" class="m-editor-history-list-item-actions">
class="m-editor-history-list-item-goto" <span class="m-editor-history-list-item-goto" title="回到该记录" @click.stop="onClick">回到</span>
title="回到该记录" </span>
@click.stop="onClick" <span v-if="time" class="m-editor-history-list-item-time" :title="timeTitle">{{ time }}</span>
>回到</span
>
</li> </li>
</template> </template>
@ -22,9 +20,17 @@
* 初始状态记录行渲染于历史列表底部作为整个栈的"零点" * 初始状态记录行渲染于历史列表底部作为整个栈的"零点"
* - 点击该行会把对应栈撤销到 cursor === 0即没有任何已应用步骤等同于回到所有修改之前 * - 点击该行会把对应栈撤销到 cursor === 0即没有任何已应用步骤等同于回到所有修改之前
* - 当对应栈本身已处于 cursor === 0 isCurrent=true用户已在初始状态点击不再触发动作 * - 当对应栈本身已处于 cursor === 0 isCurrent=true用户已在初始状态点击不再触发动作
* - 当上层传入 `marker`设置 root 时为该页生成的未修改的初始状态标记
* 用标记的文案与时间渲染本行标记不进入撤销/重做栈仅作为该页基线展示
* *
* 该行不是真实 step仅作为 UI 入口上层负责把"点击"翻译为 `service.goto*(0)` * 该行不是真实 step仅作为 UI 入口上层负责把"点击"翻译为 `service.goto*(0)`
*/ */
import { computed } from 'vue';
import type { StepValue } from '@editor/type';
import { formatHistoryFullTime, formatHistoryTime } from './composables';
defineOptions({ defineOptions({
name: 'MEditorHistoryListInitialRow', name: 'MEditorHistoryListInitialRow',
}); });
@ -34,12 +40,25 @@ const props = withDefaults(
/** 当前对应栈是否已经处于初始状态 (cursor === 0)。true 时用蓝条高亮并禁用点击。 */ /** 当前对应栈是否已经处于初始状态 (cursor === 0)。true 时用蓝条高亮并禁用点击。 */
isCurrent: boolean; isCurrent: boolean;
gotoEnabled?: boolean; gotoEnabled?: boolean;
/** 该页面的「加载/初始」基线记录(设置 root 时生成的 `opType: 'initial'` StepValue提供时用其文案与时间展示。 */
marker?: StepValue;
}>(), }>(),
{ {
gotoEnabled: true, 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<{ const emit = defineEmits<{
/** 点击非当前的初始项时触发,由上层调用对应 service 的 goto 把 cursor 移到 0。 */ /** 点击非当前的初始项时触发,由上层调用对应 service 的 goto 把 cursor 移到 0。 */
(_e: 'goto-initial'): void; (_e: 'goto-initial'): void;

View File

@ -1,46 +1,32 @@
<template> <template>
<div v-if="!list.length" class="m-editor-history-list-empty">暂无操作记录</div> <div v-if="!list.length && !marker" class="m-editor-history-list-empty">暂无操作记录</div>
<TMagicScrollbar v-else max-height="360px"> <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"> <ul class="m-editor-history-list-ul">
<GroupRow <GroupRow
v-for="group in list" v-for="group in list"
:key="`pg-${group.steps[0]?.index}`" :key="rowKey(group)"
:group-key="`pg-${group.steps[0]?.index}`" :group="toRow(group)"
:applied="group.applied" :expanded="isHistoryGroupExpanded(expanded, rowKey(group))"
:merged="group.steps.length > 1" :select-enabled="true"
:op-type="group.opType"
:desc="describePageGroup(group)"
:source="groupSource(group)"
:time="formatHistoryTime(groupTimestamp(group))"
:time-title="formatHistoryFullTime(groupTimestamp(group))"
:step-count="group.steps.length"
:sub-steps="
group.steps.map((s) => ({
index: s.index,
applied: s.applied,
isCurrent: s.isCurrent,
desc: describePageStep(s.step),
diffable: isPageStepDiffable(s.step),
revertable: s.applied && isPageStepRevertable(s.step),
source: s.step.source,
time: formatHistoryTime(s.step.timestamp),
timeTitle: formatHistoryFullTime(s.step.timestamp),
}))
"
:is-current="group.isCurrent"
:expanded="!!expanded[`pg-${group.steps[0]?.index}`]"
@toggle="(key: string) => $emit('toggle', key)" @toggle="(key: string) => $emit('toggle', key)"
@goto="(index: number) => $emit('goto', index)" @goto="(index: number) => $emit('goto', index)"
@diff-step="(index: number) => $emit('diff-step', index)" @diff-step="(index: number) => $emit('diff-step', index)"
@revert-step="(index: number) => $emit('revert-step', index)" @revert-step="(index: number) => $emit('revert-step', index)"
@select="(index: number) => $emit('select', index)"
/> />
<!-- <!--
初始状态项永远位于列表底部页面 tab 倒序展示最底部=最早 初始状态项永远位于列表底部页面 tab 倒序展示最底部=最早
作为"未修改"零点当所有 group 都未 applied 时它即为当前位置 作为"未修改"零点当所有 group 都未 applied 时它即为当前位置
设置 root 时生成的未修改的初始状态标记marker会作为该行的文案与时间来源
--> -->
<InitialRow :is-current="isInitial" @goto-initial="$emit('goto-initial')" /> <InitialRow :is-current="isInitial" :marker="marker" @goto-initial="$emit('goto-initial')" />
</ul> </ul>
</TMagicScrollbar> </TMagicScrollbar>
</template>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
@ -48,16 +34,15 @@ import { computed } from 'vue';
import { TMagicScrollbar } from '@tmagic/design'; import { TMagicScrollbar } from '@tmagic/design';
import type { PageHistoryGroup, StepValue } from '@editor/type'; import type { HistoryGroup, HistoryRowDescriptor, StepValue } from '@editor/type';
import type { HistoryRowGroup } from './composables';
import { import {
describePageGroup, describePageGroup,
describePageStep, describePageStep,
formatHistoryFullTime, isHistoryGroupExpanded,
formatHistoryTime,
groupSource,
groupTimestamp,
isPageStepRevertable, isPageStepRevertable,
toRowGroup,
} from './composables'; } from './composables';
import GroupRow from './GroupRow.vue'; import GroupRow from './GroupRow.vue';
import InitialRow from './InitialRow.vue'; import InitialRow from './InitialRow.vue';
@ -68,13 +53,18 @@ defineOptions({
const props = defineProps<{ const props = defineProps<{
/** 当前活动页面的历史分组列表,已按时间倒序排好(最新一组在最前)。空数组时显示空态。 */ /** 当前活动页面的历史分组列表,已按时间倒序排好(最新一组在最前)。空数组时显示空态。 */
list: PageHistoryGroup[]; list: HistoryGroup<StepValue>[];
/** /**
* 共享的折叠状态表key -> 是否展开由顶层 panel 统一维护 * 共享的折叠状态表key -> 是否展开缺省或 true 为展开false 为收起由顶层 panel 统一维护
* tab 使用 `pg-${组内首步 index}` 作为 key以稳定的 step 索引而非展示位置标识分组 * tab 使用 `pg-${组内首步 index}` 作为 key以稳定的 step 索引而非展示位置标识分组
* 这样历史数据更新新增 / 撤销重做导致列表顺序变化已展开的分组状态仍能正确保持 * 这样历史数据更新新增 / 撤销重做导致列表顺序变化已展开的分组状态仍能正确保持
*/ */
expanded: Record<string, boolean>; expanded: Record<string, boolean>;
/**
* 当前活动页的加载/初始基线记录设置 root 时生成的 `opType: 'initial'` StepValue
* 提供时即使没有任何操作记录也会展示底部初始行并用其文案 / 时间渲染
*/
marker?: StepValue;
}>(); }>();
defineEmits<{ defineEmits<{
@ -88,25 +78,41 @@ defineEmits<{
(_e: 'diff-step', _index: number): void; (_e: 'diff-step', _index: number): void;
/** 用户点击"回滚"按钮,携带目标 step 在栈中的索引,类 git revert。 */ /** 用户点击"回滚"按钮,携带目标 step 在栈中的索引,类 git revert。 */
(_e: 'revert-step', _index: number): void; (_e: 'revert-step', _index: number): void;
/** 用户点击记录行希望选中对应节点,携带目标 step 在栈中的索引。 */
(_e: 'select', _index: number): void;
/** 用户点击"清空"按钮,请求清空当前页面的历史记录(由上层弹窗二次确认后执行)。 */
(_e: 'clear'): void;
}>(); }>();
/** /**
* 当前 step 是否可查看差异 * 当前 step 是否可查看差异
* - update 操作 * - update 操作
* - 单节点更新updatedItems.length === 1 oldNode / newNode 都存在 * - 单节点更新diff.length === 1 oldSchema / newSchema 都存在
* 多节点更新难以选定单一对比目标统一不展示差异入口 * 多节点更新难以选定单一对比目标统一不展示差异入口
*/ */
const isPageStepDiffable = (step: StepValue): boolean => { const isPageStepDiffable = (step: StepValue): boolean => {
if (step.opType !== 'update') return false; if (step.opType !== 'update') return false;
const items = step.updatedItems ?? []; const items = step.diff ?? [];
if (items.length !== 1) return false; if (items.length !== 1) return false;
return Boolean(items[0]?.oldNode && items[0]?.newNode); return Boolean(items[0]?.oldSchema && items[0]?.newSchema);
}; };
/** 页面历史的描述 / 可操作性判定集合,注入给统一的 `toRowGroup`。 */
const descriptor: HistoryRowDescriptor<StepValue> = {
describeGroup: describePageGroup,
describeStep: describePageStep,
isStepDiffable: isPageStepDiffable,
isStepRevertable: isPageStepRevertable,
};
const rowKey = (group: HistoryGroup<StepValue>) => `pg-${group.steps[0]?.index}`;
const toRow = (group: HistoryGroup<StepValue>): HistoryRowGroup => toRowGroup(group, rowKey(group), descriptor);
/** /**
* 是否处于"初始状态"即对应页面历史栈 cursor===0 * 是否处于"初始状态"即对应页面历史栈 cursor===0
* list 中所有 group applied 都为 false 时即为该状态 * list 中所有 group applied 都为 false 时即为该状态空列表 `every` 返回 true
* 没有任何 group 的情况由外层"暂无操作记录"分支兜底本计算可以不考虑 * 即仅有 marker无任何操作记录时也视为处于初始状态
*/ */
const isInitial = computed(() => props.list.length > 0 && props.list.every((g) => !g.applied)); const isInitial = computed(() => props.list.every((g) => !g.applied));
</script> </script>

View File

@ -1,74 +1,79 @@
import { computed, reactive } from 'vue'; import { tMagicMessageBox } from '@tmagic/design';
import { datetimeFormatter } from '@tmagic/form'; import { datetimeFormatter } from '@tmagic/form';
import { useServices } from '@editor/hooks/use-services';
import type { import type {
CodeBlockHistoryGroup, BaseStepValue,
CodeBlockStepValue, HistoryGroup,
DataSourceHistoryGroup,
DataSourceStepValue,
HistoryOpSource, HistoryOpSource,
HistoryOpType, HistoryOpType,
PageHistoryGroup, HistoryRowDescriptor,
StepValue, StepValue,
} from '@editor/type'; } from '@editor/type';
/** /**
* * bucket /
* - / / * Bucket / BucketTab step T {@link BaseStepValue}
* -
* -
*
* historyService reactive state
*/ */
export const useHistoryList = () => { export interface HistoryBucketGroup<T extends BaseStepValue = BaseStepValue> {
const { historyService } = useServices(); /** 组内最后一步是否已应用 */
applied: boolean;
/** 是否为当前所在的分组 */
isCurrent?: boolean;
/** 该分组的操作类型 */
opType: HistoryOpType;
/** 组内所有步骤 */
steps: { index: number; applied: boolean; isCurrent?: boolean; step: T }[];
}
/** /**
* key `pg-${ index}` / `ds-${id}-${ index}` / `cb-${id}-${ index}` * GroupRow {@link toRowGroup}
* index key * {@link HistoryRowStep} / {@link HistoryRowGroup}
* 便 /
*/ */
const expanded = reactive<Record<string, boolean>>({}); export interface HistoryRowDisplay {
const toggleGroup = (key: string) => { /** 是否已应用false 表示已被 undoUI 灰态)。 */
expanded[key] = !expanded[key]; applied: boolean;
}; /** 是否为当前所在步骤 / 分组。 */
isCurrent: boolean;
/** 描述文案。 */
desc: string;
/** 操作途径。 */
source?: HistoryOpSource;
/** 操作人。 */
operator?: string;
/** 时间文案。 */
time?: string;
/** 时间的完整 title 提示。 */
timeTitle?: string;
}
const pageGroups = computed(() => historyService.getPageHistoryGroups()); /** GroupRow 渲染所需的单个子步视图模型(已由 {@link toRowGroup} 预先派生,组件内部不再触碰原始 step。 */
const dataSourceGroups = computed(() => historyService.getDataSourceHistoryGroups()); export interface HistoryRowStep extends HistoryRowDisplay {
const codeBlockGroups = computed(() => historyService.getCodeBlockHistoryGroups()); /** 该子步在所属栈中的稳定索引。 */
index: number;
/** 是否为最近一次保存的记录。 */
saved?: boolean;
/** 是否可查看差异。 */
diffable?: boolean;
/** 是否可回滚。 */
revertable?: boolean;
}
/** 页面 tab 倒序展示(最新一组在最上面)。 */ /**
const pageGroupsDisplay = computed(() => pageGroups.value.slice().reverse()); * GroupRow {@link toRowGroup}
* GroupRow props header
/**
* group id bucket id
* bucket
*/ */
const groupByTarget = <G extends { id: string | number }>(groups: G[]) => { export interface HistoryRowGroup extends HistoryRowDisplay {
const map = new Map<string | number, G[]>(); /** 分组的稳定 key作为 toggle 事件 payload 与折叠状态的索引。 */
groups.forEach((g) => { key: string;
const list = map.get(g.id) ?? []; /** 操作类型,用于徽标颜色与文案。 */
list.push(g); opType: HistoryOpType;
map.set(g.id, list); /** 子步列表时间正序其长度即合并步数length > 1 即为合并组。 */
}); subSteps: HistoryRowStep[];
return Array.from(map.entries()).map(([id, gs]) => ({ id, groups: gs.slice().reverse() })); }
};
const dataSourceGroupsByTarget = computed(() => groupByTarget(dataSourceGroups.value)); /** 合并组默认展开;仅当 expanded[key] === false 时为收起。 */
const codeBlockGroupsByTarget = computed(() => groupByTarget(codeBlockGroups.value)); export const isHistoryGroupExpanded = (expanded: Record<string, boolean>, key: string) => expanded[key] !== false;
return {
expanded,
toggleGroup,
pageGroups,
dataSourceGroups,
codeBlockGroups,
pageGroupsDisplay,
dataSourceGroupsByTarget,
codeBlockGroupsByTarget,
};
};
/** /**
* *
@ -115,6 +120,7 @@ const HISTORY_SOURCE_LABELS: Record<string, string> = {
'component-panel': '组件面板', 'component-panel': '组件面板',
props: '配置面板', props: '配置面板',
code: '源码', code: '源码',
'root-code': 'DSL源码',
'stage-contextmenu': '画布菜单', 'stage-contextmenu': '画布菜单',
'tree-contextmenu': '树菜单', 'tree-contextmenu': '树菜单',
toolbar: '工具栏', toolbar: '工具栏',
@ -122,6 +128,8 @@ const HISTORY_SOURCE_LABELS: Record<string, string> = {
rollback: '回滚', rollback: '回滚',
api: '接口', api: '接口',
ai: 'AI', ai: 'AI',
initial: '初始值',
sync: '同步',
unknown: '未知', unknown: '未知',
}; };
@ -134,7 +142,59 @@ export const sourceLabel = (source: HistoryOpSource = 'unknown'): string => {
export const groupSource = (group: { steps: { step: { source?: HistoryOpSource } }[] }): HistoryOpSource | undefined => export const groupSource = (group: { steps: { step: { source?: HistoryOpSource } }[] }): HistoryOpSource | undefined =>
group.steps[group.steps.length - 1]?.step.source; group.steps[group.steps.length - 1]?.step.source;
const nameOf = (node: { name?: string; id?: string | number; type?: string }) => /** 取一组历史步骤里最后一步(最近一次)的操作人,用于组头部展示。 */
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 ?? ''}`; node?.name || node?.type || `${node?.id ?? ''}`;
/** /**
@ -156,100 +216,55 @@ const pickLastDescription = (descs: (string | undefined)[]): string | undefined
return undefined; return undefined;
}; };
export const describePageStep = (step: StepValue) => { /**
* / /
*
* - / 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; if (step.historyDescription) return step.historyDescription;
const { opType } = step; const items = step.diff ?? [];
if (opType === 'add') { const label = (schema?: T) => labelWithId(getLabel(schema), (schema as { id?: string | number } | undefined)?.id);
const count = step.nodes?.length ?? 0;
const node = step.nodes?.[0]; if (step.opType === 'add') {
return `新增 ${count} 个节点${count === 1 && node ? `${labelWithId(nameOf(node), node.id)}` : ''}`; const node = items[0]?.newSchema;
return items.length === 1 && node ? label(node) : `${items.length}${unit}`;
} }
if (opType === 'remove') { if (step.opType === 'remove') {
const count = step.removedItems?.length ?? 0; const node = items[0]?.oldSchema;
const node = step.removedItems?.[0]?.node; return items.length === 1 && node ? label(node) : `${items.length}${unit}`;
return `删除 ${count} 个节点${count === 1 && node ? `${labelWithId(nameOf(node), node.id)}` : ''}`;
} }
const updated = step.updatedItems ?? []; if (!items.length) return unit;
if (!updated.length) return '修改节点'; if (items.length === 1) {
if (updated.length === 1) { const { newSchema, oldSchema, changeRecords } = items[0];
const { newNode, changeRecords } = updated[0]; const propPath = changeRecords?.map((changeRecord) => changeRecord.propPath).join(',');
const propPath = changeRecords?.[0]?.propPath; const target = label(newSchema ?? oldSchema);
const target = labelWithId(nameOf(newNode), newNode?.id); return propPath ? `${target} · ${propPath}` : target;
return `修改 ${target}${propPath ? ` · ${propPath}` : ''}`;
} }
return `修改 ${updated.length} 个节点`; return `${items.length}${unit}`;
}; };
export const describePageStep = (step: StepValue): string => describeStep(step, (node) => nameOf(node), '节点');
/** /**
* *
* - historyDescription historyDescription * - historyDescription historyDescription
* - describePageStep * - describePageStep
* - + propPath * - + propPath
*/ */
export const describePageGroup = (group: PageHistoryGroup) => { export const describePageGroup = (group: HistoryGroup<StepValue>) => {
const lastDesc = pickLastDescription(group.steps.map((s) => s.step.historyDescription)); const lastDesc = pickLastDescription(group.steps.map((s) => s.step.historyDescription));
if (lastDesc) return lastDesc; if (lastDesc) return lastDesc;
if (group.steps.length === 1) return describePageStep(group.steps[0].step); if (group.steps.length === 1) return describePageStep(group.steps[0].step);
const paths = new Set<string>();
group.steps.forEach((s) => {
s.step.updatedItems?.[0]?.changeRecords?.forEach((r) => r.propPath && paths.add(r.propPath));
});
const pathList = Array.from(paths).slice(0, 3).join(', ');
const target = labelWithId(
group.targetName ?? (group.targetId !== undefined ? `${group.targetId}` : '节点'),
group.targetId,
);
return pathList ? `修改 ${target} · ${pathList}${paths.size > 3 ? '…' : ''}` : `修改 ${target}`;
};
export const describeDataSourceStep = (step: DataSourceStepValue) => { return labelWithId(group.targetName ?? (group.targetId !== undefined ? `${group.targetId}` : '节点'), group.targetId);
if (step.historyDescription) return step.historyDescription;
if (step.oldSchema === null && step.newSchema)
return `创建 ${labelWithId(step.newSchema.title, step.newSchema.id ?? step.id)}`;
if (step.newSchema === null && step.oldSchema)
return `删除 ${labelWithId(step.oldSchema.title, step.oldSchema.id ?? step.id)}`;
const propPath = step.changeRecords?.[0]?.propPath;
const title = labelWithId(step.newSchema?.title || step.oldSchema?.title, step.id);
return propPath ? `修改 ${title} · ${propPath}` : `修改 ${title}`;
};
export const describeDataSourceGroup = (group: DataSourceHistoryGroup) => {
const lastDesc = pickLastDescription(group.steps.map((s) => s.step.historyDescription));
if (lastDesc) return lastDesc;
if (group.steps.length === 1) return describeDataSourceStep(group.steps[0].step);
const paths = new Set<string>();
group.steps.forEach((s) => {
s.step.changeRecords?.forEach((r) => r.propPath && paths.add(r.propPath));
});
const pathList = Array.from(paths).slice(0, 3).join(', ');
const rawTitle = group.steps[group.steps.length - 1].step.newSchema?.title || group.steps[0].step.oldSchema?.title;
const target = labelWithId(rawTitle, group.id);
return pathList ? `修改 ${target} · ${pathList}${paths.size > 3 ? '…' : ''}` : `修改 ${target}`;
};
export const describeCodeBlockStep = (step: CodeBlockStepValue) => {
if (step.historyDescription) return step.historyDescription;
if (step.oldContent === null && step.newContent)
return `创建 ${labelWithId(step.newContent.name, step.newContent.id ?? step.id)}`;
if (step.newContent === null && step.oldContent)
return `删除 ${labelWithId(step.oldContent.name, step.oldContent.id ?? step.id)}`;
const propPath = step.changeRecords?.[0]?.propPath;
const title = labelWithId(step.newContent?.name || step.oldContent?.name, step.id);
return propPath ? `修改 ${title} · ${propPath}` : `修改 ${title}`;
};
export const describeCodeBlockGroup = (group: CodeBlockHistoryGroup) => {
const lastDesc = pickLastDescription(group.steps.map((s) => s.step.historyDescription));
if (lastDesc) return lastDesc;
if (group.steps.length === 1) return describeCodeBlockStep(group.steps[0].step);
const paths = new Set<string>();
group.steps.forEach((s) => {
s.step.changeRecords?.forEach((r) => r.propPath && paths.add(r.propPath));
});
const pathList = Array.from(paths).slice(0, 3).join(', ');
const rawName = group.steps[group.steps.length - 1].step.newContent?.name || group.steps[0].step.oldContent?.name;
const target = labelWithId(rawName, group.id);
return pathList ? `修改 ${target} · ${pathList}${paths.size > 3 ? '…' : ''}` : `修改 ${target}`;
}; };
/** /**
@ -260,27 +275,35 @@ export const describeCodeBlockGroup = (group: CodeBlockHistoryGroup) => {
*/ */
export const isPageStepRevertable = (step: StepValue): boolean => { export const isPageStepRevertable = (step: StepValue): boolean => {
if (step.opType !== 'update') return true; if (step.opType !== 'update') return true;
const items = step.updatedItems ?? []; const items = step.diff ?? [];
if (!items.length) return false; if (!items.length) return false;
return items.every((item) => Boolean(item.changeRecords?.length)); return items.every((item) => Boolean(item.changeRecords?.length));
}; };
/** /**
* step * diff /
* - oldSchema=null/ newSchema=null changeRecords * - oldSchema/ newSchema changeRecords
* - schema changeRecords patch * - changeRecords patch
*/ */
export const isDataSourceStepRevertable = (step: DataSourceStepValue): boolean => { export const isSingleDiffStepRevertable = (step: BaseStepValue): boolean => {
if (step.oldSchema === null || step.newSchema === null) return true; const item = step.diff?.[0];
return Boolean(step.changeRecords?.length); if (!item?.oldSchema || !item?.newSchema) return true;
return Boolean(item.changeRecords?.length);
}; };
/** /**
* step * /
* - oldContent=null/ newContent=null changeRecords * trueconfirm reject false
* - content changeRecords patch
*/ */
export const isCodeBlockStepRevertable = (step: CodeBlockStepValue): boolean => { export const confirmHistoryAction = async (message: string): Promise<boolean> => {
if (step.oldContent === null || step.newContent === null) return true; try {
return Boolean(step.changeRecords?.length); await tMagicMessageBox.confirm(message, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
});
return true;
} catch {
return false;
}
}; };

View 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,
};
};

View 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,
};
};

View File

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

View File

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

View File

@ -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: '代码编辑',

View File

@ -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 tabstatus: methods / fields
// tabfields
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) => {

View File

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

View File

@ -8,6 +8,7 @@
ref="box" ref="box"
v-model:visible="visible" v-model:visible="visible"
title="当前位置下的组件" title="当前位置下的组件"
:framework-width="frameworkWidth"
:position="menuPosition" :position="menuPosition"
> >
<template #body> <template #body>
@ -36,7 +37,9 @@ import { useNodeStatus } from '@editor/layouts/sidebar/layer/use-node-status';
import type { TreeNodeData } from '@editor/type'; import type { TreeNodeData } from '@editor/type';
const services = useServices(); const services = useServices();
const { editorService } = services; const { editorService, uiService } = services;
const frameworkWidth = computed(() => uiService.get('frameworkRect')?.width || 0);
const visible = ref(false); const visible = ref(false);
const buttonVisible = ref(false); const buttonVisible = ref(false);

View File

@ -32,12 +32,14 @@ import type {
AsyncHookPlugin, AsyncHookPlugin,
CodeBlockStepValue, CodeBlockStepValue,
CodeState, CodeState,
DslOpWithHistoryIdsResult,
HistoryOpOptions, HistoryOpOptions,
HistoryOpOptionsWithChangeRecords, HistoryOpOptionsWithChangeRecords,
} from '@editor/type'; } from '@editor/type';
import { CODE_DRAFT_STORAGE_KEY } from '@editor/type'; import { CODE_DRAFT_STORAGE_KEY } from '@editor/type';
import { getEditorConfig } from '@editor/utils/config'; import { getEditorConfig } from '@editor/utils/config';
import { COPY_CODE_STORAGE_KEY } from '@editor/utils/editor'; import { COPY_CODE_STORAGE_KEY } from '@editor/utils/editor';
import { createStackStep, describeRevertStep, getLastPushedHistoryIds } from '@editor/utils/history';
import BaseService from './BaseService'; import BaseService from './BaseService';
@ -48,18 +50,6 @@ const canUsePluginMethods = {
type AsyncMethodName = Writable<(typeof canUsePluginMethods)['async']>; type AsyncMethodName = Writable<(typeof canUsePluginMethods)['async']>;
/**
* step service 使
*/
const describeRevertCodeBlockStep = (step: CodeBlockStepValue): string => {
const { oldContent, newContent, changeRecords, id } = step;
if (oldContent === null && newContent) return `撤回新增 ${newContent.name || newContent.id || id}`;
if (oldContent && newContent === null) return `还原已删除的 ${oldContent.name || oldContent.id || id}`;
const name = newContent?.name || oldContent?.name || `${id}`;
const propPath = changeRecords?.[0]?.propPath;
return propPath ? `还原 ${name} · ${propPath}` : `还原 ${name}`;
};
class CodeBlock extends BaseService { class CodeBlock extends BaseService {
private state = reactive<CodeState>({ private state = reactive<CodeState>({
codeDsl: null, codeDsl: null,
@ -69,6 +59,17 @@ class CodeBlock extends BaseService {
paramsColConfig: undefined, paramsColConfig: undefined,
}); });
/**
* uuid /
* setCodeDslById(Sync)AndGetHistoryId
*/
private lastPushedHistoryId: string | null = null;
/**
* deleteCodeDslByIds uuid
* deleteCodeDslByIds deleteCodeDslByIdsAndGetHistoryId
*/
private lastDeletedHistoryIds: string[] = [];
constructor() { constructor() {
super([ super([
...canUsePluginMethods.async.map((methodName) => ({ name: methodName, isAsync: true })), ...canUsePluginMethods.async.map((methodName) => ({ name: methodName, isAsync: true })),
@ -84,6 +85,7 @@ class CodeBlock extends BaseService {
public async setCodeDsl(codeDsl: CodeBlockDSL): Promise<void> { public async setCodeDsl(codeDsl: CodeBlockDSL): Promise<void> {
this.state.codeDsl = codeDsl; this.state.codeDsl = codeDsl;
this.emit('code-dsl-change', this.state.codeDsl); this.emit('code-dsl-change', this.state.codeDsl);
this.emit('change', this.state.codeDsl);
} }
/** /**
@ -187,16 +189,18 @@ class CodeBlock extends BaseService {
const newContent = cloneDeep(codeDsl[id]); const newContent = cloneDeep(codeDsl[id]);
if (!doNotPushHistory) { if (!doNotPushHistory) {
historyService.pushCodeBlock(id, { const step = createStackStep<CodeBlockContent, CodeBlockStepValue>(id, {
oldContent, oldValue: oldContent,
newContent, newValue: newContent,
changeRecords, changeRecords,
historyDescription, historyDescription,
source: historySource, source: historySource,
}); });
this.lastPushedHistoryId = (step ? historyService.push('codeBlock', step, id) : null)?.uuid ?? null;
} }
this.emit('addOrUpdate', id, codeDsl[id]); this.emit('addOrUpdate', id, codeDsl[id]);
this.emit('change', this.getCodeDsl());
} }
/** /**
@ -295,20 +299,77 @@ class CodeBlock extends BaseService {
if (!currentDsl) return; if (!currentDsl) return;
this.lastDeletedHistoryIds = [];
codeIds.forEach((id) => { codeIds.forEach((id) => {
// 历史记录:删除前快照内容;不存在的 id 直接跳过历史推入 // 历史记录:删除前快照内容;不存在的 id 直接跳过历史推入
const oldContent: CodeBlockContent | null = currentDsl[id] ? cloneDeep(currentDsl[id]) : null; const oldValue: CodeBlockContent | null = currentDsl[id] ? cloneDeep(currentDsl[id]) : null;
delete currentDsl[id]; delete currentDsl[id];
if (oldContent && !doNotPushHistory) { if (oldValue && !doNotPushHistory) {
historyService.pushCodeBlock(id, { oldContent, newContent: null, historyDescription, source: historySource }); const step = createStackStep<CodeBlockContent, CodeBlockStepValue>(id, {
oldValue,
newValue: null,
historyDescription,
source: historySource,
});
const uuid = step ? historyService.push('codeBlock', step, id)?.uuid : undefined;
if (uuid) this.lastDeletedHistoryIds.push(uuid);
} }
this.emit('remove', id); this.emit('remove', id);
}); });
this.emit('change', this.getCodeDsl());
} }
// #region AndGetHistoryId
/**
* *AndGetHistoryId
* {@link DslOpWithHistoryIdsResult} uuid
* / revert
*
* doNotPushHistory true historyIds `[]`
*/
/** 等价于 {@link setCodeDslById},并额外返回本次写入历史记录的 uuid 列表(未入栈时 historyIds 为 `[]`)。 */
public async setCodeDslByIdAndGetHistoryId(
id: Id,
codeConfig: Partial<CodeBlockContent>,
options: HistoryOpOptionsWithChangeRecords = {},
): Promise<DslOpWithHistoryIdsResult<void>> {
this.lastPushedHistoryId = null;
await this.setCodeDslById(id, codeConfig, options);
return { result: undefined, historyIds: getLastPushedHistoryIds(this.lastPushedHistoryId) };
}
/** 等价于 {@link setCodeDslByIdSync},并额外返回本次写入历史记录的 uuid 列表(未入栈时 historyIds 为 `[]`)。 */
public setCodeDslByIdSyncAndGetHistoryId(
id: Id,
codeConfig: Partial<CodeBlockContent>,
force = true,
options: HistoryOpOptionsWithChangeRecords = {},
): DslOpWithHistoryIdsResult<void> {
this.lastPushedHistoryId = null;
this.setCodeDslByIdSync(id, codeConfig, force, options);
return { result: undefined, historyIds: getLastPushedHistoryIds(this.lastPushedHistoryId) };
}
/**
* {@link deleteCodeDslByIds} uuid
* historyIds `[]`
*/
public async deleteCodeDslByIdsAndGetHistoryId(
codeIds: Id[],
options: HistoryOpOptions = {},
): Promise<DslOpWithHistoryIdsResult<void>> {
this.lastDeletedHistoryIds = [];
await this.deleteCodeDslByIds(codeIds, options);
return { result: undefined, historyIds: [...this.lastDeletedHistoryIds] };
}
// #endregion AndGetHistoryId
public setParamsColConfig(config: TableColumnConfig): void { public setParamsColConfig(config: TableColumnConfig): void {
this.state.paramsColConfig = config; this.state.paramsColConfig = config;
} }
@ -329,7 +390,7 @@ class CodeBlock extends BaseService {
* @returns step null * @returns step null
*/ */
public async undo(id: Id): Promise<CodeBlockStepValue | null> { public async undo(id: Id): Promise<CodeBlockStepValue | null> {
const step = historyService.undoCodeBlock(id); const step = historyService.undo('codeBlock', id);
if (!step) return null; if (!step) return null;
await this.applyHistoryStep(step, true); await this.applyHistoryStep(step, true);
return step; return step;
@ -341,7 +402,7 @@ class CodeBlock extends BaseService {
* @returns step null * @returns step null
*/ */
public async redo(id: Id): Promise<CodeBlockStepValue | null> { public async redo(id: Id): Promise<CodeBlockStepValue | null> {
const step = historyService.redoCodeBlock(id); const step = historyService.redo('codeBlock', id);
if (!step) return null; if (!step) return null;
await this.applyHistoryStep(step, false); await this.applyHistoryStep(step, false);
return step; return step;
@ -349,12 +410,12 @@ class CodeBlock extends BaseService {
/** 是否可对指定代码块撤销。 */ /** 是否可对指定代码块撤销。 */
public canUndo(id: Id): boolean { public canUndo(id: Id): boolean {
return historyService.canUndoCodeBlock(id); return historyService.canUndo('codeBlock', id);
} }
/** 是否可对指定代码块重做。 */ /** 是否可对指定代码块重做。 */
public canRedo(id: Id): boolean { public canRedo(id: Id): boolean {
return historyService.canRedoCodeBlock(id); return historyService.canRedo('codeBlock', id);
} }
/** /**
@ -365,7 +426,7 @@ class CodeBlock extends BaseService {
* @returns * @returns
*/ */
public async goto(id: Id, targetCursor: number): Promise<number> { public async goto(id: Id, targetCursor: number): Promise<number> {
let cursor = historyService.getCodeBlockCursor(id); let cursor = historyService.getCursor('codeBlock', id);
const target = Math.max(0, targetCursor); const target = Math.max(0, targetCursor);
while (cursor > target) { while (cursor > target) {
const step = await this.undo(id); const step = await this.undo(id);
@ -391,15 +452,33 @@ class CodeBlock extends BaseService {
* @returns step / null * @returns step / null
*/ */
public async revert(id: Id, index: number): Promise<CodeBlockStepValue | null> { public async revert(id: Id, index: number): Promise<CodeBlockStepValue | null> {
const list = historyService.getCodeBlockStepList(id); const list = historyService.getStepList('codeBlock', id);
const entry = list[index]; const entry = list[index];
if (!entry?.applied) return null; if (!entry?.applied) return null;
// 更新类步骤(前后 content 都存在)必须带 changeRecords 才支持回滚,否则只能整内容替换,会冲掉后续无关变更。 // 更新类步骤(前后 content 都存在)必须带 changeRecords 才支持回滚,否则只能整内容替换,会冲掉后续无关变更。
if (entry.step.oldContent && entry.step.newContent && !entry.step.changeRecords?.length) return null; const { oldSchema, newSchema, changeRecords } = entry.step.diff?.[0] ?? {};
const description = `回滚 #${index + 1}: ${describeRevertCodeBlockStep(entry.step)}`;
if (oldSchema && newSchema && !changeRecords?.length) return null;
const description = `回滚 #${index + 1}: ${describeRevertStep<CodeBlockContent>(entry.step.data.id, entry.step.diff?.[0], (s) => s.name)}`;
return await this.applyRevertStep(entry.step, description); return await this.applyRevertStep(entry.step, description);
} }
/**
* uuid {@link revert}
* codeBlockId index uuid
* `null`
*
* @param uuids uuid {@link setCodeDslByIdAndGetHistoryId} `historyIds`
*/
public async revertById(uuids: string[]): Promise<(CodeBlockStepValue | null)[]> {
const results: (CodeBlockStepValue | null)[] = [];
for (const uuid of uuids) {
const location = historyService.findStepLocationByUuid('codeBlock', uuid);
results.push(location ? await this.revert(location.id, location.index) : null);
}
return results;
}
/** /**
* id * id
* @returns {Id} id * @returns {Id} id
@ -490,21 +569,22 @@ class CodeBlock extends BaseService {
step: CodeBlockStepValue, step: CodeBlockStepValue,
historyDescription: string, historyDescription: string,
): Promise<CodeBlockStepValue | null> { ): Promise<CodeBlockStepValue | null> {
const { id, oldContent, newContent, changeRecords } = step; const { id } = step.data;
const { oldSchema, newSchema, changeRecords } = step.diff?.[0] ?? {};
// 原本是新增 → revert 即删除 // 原本是新增 → revert 即删除
if (oldContent === null && newContent) { if (!oldSchema && newSchema) {
await this.deleteCodeDslByIds([id], { historyDescription, historySource: 'rollback' }); await this.deleteCodeDslByIds([id], { historyDescription, historySource: 'rollback' });
return historyService.getCodeBlockStepList(id).slice(-1)[0]?.step ?? null; return historyService.getStepList('codeBlock', id).slice(-1)[0]?.step ?? null;
} }
// 原本是删除 → revert 即写回 // 原本是删除 → revert 即写回
if (oldContent && newContent === null) { if (oldSchema && !newSchema) {
this.setCodeDslByIdSync(id, cloneDeep(oldContent), true, { historyDescription, historySource: 'rollback' }); this.setCodeDslByIdSync(id, cloneDeep(oldSchema), true, { historyDescription, historySource: 'rollback' });
return historyService.getCodeBlockStepList(id).slice(-1)[0]?.step ?? null; return historyService.getStepList('codeBlock', id).slice(-1)[0]?.step ?? null;
} }
if (!oldContent || !newContent) return null; if (!oldSchema || !newSchema) return null;
// 原本是更新 → 把 oldContent 写回;优先按 changeRecords 局部 patch // 原本是更新 → 把 oldContent 写回;优先按 changeRecords 局部 patch
if (changeRecords?.length) { if (changeRecords?.length) {
@ -517,19 +597,19 @@ class CodeBlock extends BaseService {
fallbackToFullReplace = true; fallbackToFullReplace = true;
break; break;
} }
const value = cloneDeep(getValueByKeyPath(record.propPath, oldContent)); const value = cloneDeep(getValueByKeyPath(record.propPath, oldSchema));
setValueByKeyPath(record.propPath, value, patched); setValueByKeyPath(record.propPath, value, patched);
} }
this.setCodeDslByIdSync(id, fallbackToFullReplace ? cloneDeep(oldContent) : patched, true, { this.setCodeDslByIdSync(id, fallbackToFullReplace ? cloneDeep(oldSchema) : patched, true, {
changeRecords, changeRecords,
historyDescription, historyDescription,
historySource: 'rollback', historySource: 'rollback',
}); });
return historyService.getCodeBlockStepList(id).slice(-1)[0]?.step ?? null; return historyService.getStepList('codeBlock', id).slice(-1)[0]?.step ?? null;
} }
this.setCodeDslByIdSync(id, cloneDeep(oldContent), true, { historyDescription, historySource: 'rollback' }); this.setCodeDslByIdSync(id, cloneDeep(oldSchema), true, { historyDescription, historySource: 'rollback' });
return historyService.getCodeBlockStepList(id).slice(-1)[0]?.step ?? null; return historyService.getStepList('codeBlock', id).slice(-1)[0]?.step ?? null;
} }
/** /**
@ -547,31 +627,32 @@ class CodeBlock extends BaseService {
* @param reverse true=false= * @param reverse true=false=
*/ */
private async applyHistoryStep(step: CodeBlockStepValue, reverse: boolean): Promise<void> { private async applyHistoryStep(step: CodeBlockStepValue, reverse: boolean): Promise<void> {
const { id, oldContent, newContent, changeRecords } = step; const { id } = step.data;
const { oldSchema, newSchema, changeRecords } = step.diff?.[0] ?? {};
// 新增 / 删除:直接 set 或 delete不走 patch 逻辑 // 新增 / 删除:直接 set 或 delete不走 patch 逻辑
if (oldContent === null && newContent) { if (!oldSchema && newSchema) {
if (reverse) { if (reverse) {
await this.deleteCodeDslByIds([id], { doNotPushHistory: true }); await this.deleteCodeDslByIds([id], { doNotPushHistory: true });
} else { } else {
this.setCodeDslByIdSync(id, cloneDeep(newContent), true, { doNotPushHistory: true }); this.setCodeDslByIdSync(id, cloneDeep(newSchema), true, { doNotPushHistory: true });
} }
return; return;
} }
if (oldContent && newContent === null) { if (oldSchema && !newSchema) {
if (reverse) { if (reverse) {
this.setCodeDslByIdSync(id, cloneDeep(oldContent), true, { doNotPushHistory: true }); this.setCodeDslByIdSync(id, cloneDeep(oldSchema), true, { doNotPushHistory: true });
} else { } else {
await this.deleteCodeDslByIds([id], { doNotPushHistory: true }); await this.deleteCodeDslByIds([id], { doNotPushHistory: true });
} }
return; return;
} }
if (!oldContent || !newContent) return; if (!oldSchema || !newSchema) return;
// 更新场景:优先按 changeRecords 局部 patch缺省退化为整内容替换 // 更新场景:优先按 changeRecords 局部 patch缺省退化为整内容替换
const sourceForValues = reverse ? oldContent : newContent; const sourceForValues = reverse ? oldSchema : newSchema;
if (changeRecords?.length) { if (changeRecords?.length) {
const current = this.getCodeContentById(id); const current = this.getCodeContentById(id);

View File

@ -13,12 +13,14 @@ import storageService, { Protocol } from '@editor/services/storage';
import type { import type {
DataSourceStepValue, DataSourceStepValue,
DatasourceTypeOption, DatasourceTypeOption,
DslOpWithHistoryIdsResult,
HistoryOpOptions, HistoryOpOptions,
HistoryOpOptionsWithChangeRecords, HistoryOpOptionsWithChangeRecords,
SyncHookPlugin, SyncHookPlugin,
} from '@editor/type'; } from '@editor/type';
import { getFormConfig, getFormValue } from '@editor/utils/data-source'; import { getFormConfig, getFormValue } from '@editor/utils/data-source';
import { COPY_DS_STORAGE_KEY } from '@editor/utils/editor'; import { COPY_DS_STORAGE_KEY } from '@editor/utils/editor';
import { createStackStep, describeRevertStep, getLastPushedHistoryIds } from '@editor/utils/history';
import BaseService from './BaseService'; import BaseService from './BaseService';
@ -54,19 +56,6 @@ const canUsePluginMethods = {
type SyncMethodName = Writable<(typeof canUsePluginMethods)['sync']>; type SyncMethodName = Writable<(typeof canUsePluginMethods)['sync']>;
/**
* step
* service 使 UI composables
*/
const describeRevertDataSourceStep = (step: DataSourceStepValue): string => {
const { oldSchema, newSchema, changeRecords, id } = step;
if (oldSchema === null && newSchema) return `撤回新增 ${newSchema.title || newSchema.id || id}`;
if (oldSchema && newSchema === null) return `还原已删除的 ${oldSchema.title || oldSchema.id || id}`;
const title = newSchema?.title || oldSchema?.title || `${id}`;
const propPath = changeRecords?.[0]?.propPath;
return propPath ? `还原 ${title} · ${propPath}` : `还原 ${title}`;
};
class DataSource extends BaseService { class DataSource extends BaseService {
private state = reactive<State>({ private state = reactive<State>({
datasourceTypeList: [], datasourceTypeList: [],
@ -78,6 +67,13 @@ class DataSource extends BaseService {
methods: {}, methods: {},
}); });
/**
* uuid
* *AndGetHistoryId add / update / remove id
* *AndGetHistoryId null
*/
private lastPushedHistoryId: string | null = null;
constructor() { constructor() {
super(canUsePluginMethods.sync.map((methodName) => ({ name: methodName, isAsync: false }))); super(canUsePluginMethods.sync.map((methodName) => ({ name: methodName, isAsync: false })));
} }
@ -141,15 +137,17 @@ class DataSource extends BaseService {
this.get('dataSources').push(newConfig); this.get('dataSources').push(newConfig);
if (!doNotPushHistory) { if (!doNotPushHistory) {
historyService.pushDataSource(newConfig.id, { const step = createStackStep<DataSourceSchema, DataSourceStepValue>(newConfig.id, {
oldSchema: null, oldValue: null,
newSchema: newConfig, newValue: newConfig,
historyDescription, historyDescription,
source: historySource, source: historySource,
}); });
this.lastPushedHistoryId = (step ? historyService.push('dataSource', step, newConfig.id) : null)?.uuid ?? null;
} }
this.emit('add', newConfig); this.emit('add', newConfig);
this.emit('change', this.get('dataSources'));
return newConfig; return newConfig;
} }
@ -181,19 +179,21 @@ class DataSource extends BaseService {
dataSources[index] = newConfig; dataSources[index] = newConfig;
if (!doNotPushHistory) { if (!doNotPushHistory) {
historyService.pushDataSource(newConfig.id, { const step = createStackStep<DataSourceSchema, DataSourceStepValue>(newConfig.id, {
oldSchema: oldConfig ? cloneDeep(oldConfig) : null, oldValue: oldConfig ? cloneDeep(oldConfig) : null,
newSchema: newConfig, newValue: newConfig,
changeRecords, changeRecords,
historyDescription, historyDescription,
source: historySource, source: historySource,
}); });
this.lastPushedHistoryId = (step ? historyService.push('dataSource', step, newConfig.id) : null)?.uuid ?? null;
} }
this.emit('update', newConfig, { this.emit('update', newConfig, {
oldConfig, oldConfig,
changeRecords, changeRecords,
}); });
this.emit('change', this.get('dataSources'));
return newConfig; return newConfig;
} }
@ -212,17 +212,56 @@ class DataSource extends BaseService {
dataSources.splice(index, 1); dataSources.splice(index, 1);
if (oldConfig && !doNotPushHistory) { if (oldConfig && !doNotPushHistory) {
historyService.pushDataSource(id, { const step = createStackStep<DataSourceSchema, DataSourceStepValue>(id, {
oldSchema: cloneDeep(oldConfig), oldValue: cloneDeep(oldConfig),
newSchema: null, newValue: null,
historyDescription, historyDescription,
source: historySource, source: historySource,
}); });
this.lastPushedHistoryId = (step ? historyService.push('dataSource', step, id) : null)?.uuid ?? null;
} }
this.emit('remove', id); this.emit('remove', id);
this.emit('change', this.get('dataSources'));
} }
// #region AndGetHistoryId
/**
* *AndGetHistoryId add / update / remove
* {@link DslOpWithHistoryIdsResult} uuid {@link DataSourceStepValue.uuid}
* / revert
*
* doNotPushHistory true historyIds `[]`
*/
/** 等价于 {@link add},并额外返回本次写入历史记录的 uuid 列表(未入栈时 historyIds 为 `[]`)。 */
public addAndGetHistoryId(
config: DataSourceSchema,
options: HistoryOpOptions = {},
): DslOpWithHistoryIdsResult<DataSourceSchema> {
this.lastPushedHistoryId = null;
const result = this.add(config, options);
return { result, historyIds: getLastPushedHistoryIds(this.lastPushedHistoryId) };
}
/** 等价于 {@link update},并额外返回本次写入历史记录的 uuid 列表(未入栈时 historyIds 为 `[]`)。 */
public updateAndGetHistoryId(
config: DataSourceSchema,
options: HistoryOpOptionsWithChangeRecords = {},
): DslOpWithHistoryIdsResult<DataSourceSchema> {
this.lastPushedHistoryId = null;
const result = this.update(config, options);
return { result, historyIds: getLastPushedHistoryIds(this.lastPushedHistoryId) };
}
/** 等价于 {@link remove},并额外返回本次写入历史记录的 uuid 列表(未入栈时 historyIds 为 `[]`)。 */
public removeAndGetHistoryId(id: string, options: HistoryOpOptions = {}): DslOpWithHistoryIdsResult<void> {
this.lastPushedHistoryId = null;
this.remove(id, options);
return { result: undefined, historyIds: getLastPushedHistoryIds(this.lastPushedHistoryId) };
}
// #endregion AndGetHistoryId
/** /**
* *
* *
@ -234,7 +273,7 @@ class DataSource extends BaseService {
* @returns step null * @returns step null
*/ */
public undo(id: Id) { public undo(id: Id) {
const step = historyService.undoDataSource(id); const step = historyService.undo('dataSource', id);
if (!step) return null; if (!step) return null;
this.applyHistoryStep(step, true); this.applyHistoryStep(step, true);
return step; return step;
@ -246,7 +285,7 @@ class DataSource extends BaseService {
* @returns step null * @returns step null
*/ */
public redo(id: Id) { public redo(id: Id) {
const step = historyService.redoDataSource(id); const step = historyService.redo('dataSource', id);
if (!step) return null; if (!step) return null;
this.applyHistoryStep(step, false); this.applyHistoryStep(step, false);
return step; return step;
@ -254,12 +293,12 @@ class DataSource extends BaseService {
/** 是否可对指定数据源撤销。 */ /** 是否可对指定数据源撤销。 */
public canUndo(id: Id): boolean { public canUndo(id: Id): boolean {
return historyService.canUndoDataSource(id); return historyService.canUndo('dataSource', id);
} }
/** 是否可对指定数据源重做。 */ /** 是否可对指定数据源重做。 */
public canRedo(id: Id): boolean { public canRedo(id: Id): boolean {
return historyService.canRedoDataSource(id); return historyService.canRedo('dataSource', id);
} }
/** /**
@ -270,7 +309,7 @@ class DataSource extends BaseService {
* @returns * @returns
*/ */
public goto(id: Id, targetCursor: number): number { public goto(id: Id, targetCursor: number): number {
let cursor = historyService.getDataSourceCursor(id); let cursor = historyService.getCursor('dataSource', id);
const target = Math.max(0, targetCursor); const target = Math.max(0, targetCursor);
while (cursor > target) { while (cursor > target) {
if (!this.undo(id)) break; if (!this.undo(id)) break;
@ -294,15 +333,31 @@ class DataSource extends BaseService {
* @returns step / null * @returns step / null
*/ */
public revert(id: Id, index: number): DataSourceStepValue | null { public revert(id: Id, index: number): DataSourceStepValue | null {
const list = historyService.getDataSourceStepList(id); const list = historyService.getStepList('dataSource', id);
const entry = list[index]; const entry = list[index];
if (!entry?.applied) return null; if (!entry?.applied) return null;
// 更新类步骤(前后 schema 都存在)必须带 changeRecords 才支持回滚,否则只能整 schema 替换,会冲掉后续无关变更。 // 更新类步骤(前后 schema 都存在)必须带 changeRecords 才支持回滚,否则只能整 schema 替换,会冲掉后续无关变更。
if (entry.step.oldSchema && entry.step.newSchema && !entry.step.changeRecords?.length) return null; const { oldSchema, newSchema, changeRecords } = entry.step.diff?.[0] ?? {};
const description = `回滚 #${index + 1}: ${describeRevertDataSourceStep(entry.step)}`; if (oldSchema && newSchema && !changeRecords?.length) return null;
const description = `回滚 #${index + 1}: ${describeRevertStep<DataSourceSchema>(entry.step.data.id, entry.step.diff?.[0], (s) => s.title)}`;
return this.applyRevertStep(entry.step, description); return this.applyRevertStep(entry.step, description);
} }
/**
* uuid {@link revert}
* dataSourceId index uuid
* `null`
*
* @param uuids uuid {@link addAndGetHistoryId} `historyIds`
*/
public revertById(uuids: string[]): (DataSourceStepValue | null)[] {
return uuids.map((uuid) => {
const location = historyService.findStepLocationByUuid('dataSource', uuid);
if (!location) return null;
return this.revert(location.id, location.index);
});
}
public createId(): string { public createId(): string {
return `ds_${guid()}`; return `ds_${guid()}`;
} }
@ -383,18 +438,19 @@ class DataSource extends BaseService {
* add / update / remove doNotPushHistory * add / update / remove doNotPushHistory
*/ */
private applyRevertStep(step: DataSourceStepValue, historyDescription: string): DataSourceStepValue | null { private applyRevertStep(step: DataSourceStepValue, historyDescription: string): DataSourceStepValue | null {
const { id, oldSchema, newSchema, changeRecords } = step; const { id } = step.data;
const { oldSchema, newSchema, changeRecords } = step.diff?.[0] ?? {};
// 原本是新增 → revert 即删除 // 原本是新增 → revert 即删除
if (oldSchema === null && newSchema) { if (!oldSchema && newSchema) {
this.remove(`${id}`, { historyDescription, historySource: 'rollback' }); this.remove(`${id}`, { historyDescription, historySource: 'rollback' });
return historyService.getDataSourceStepList(id).slice(-1)[0]?.step ?? null; return historyService.getStepList('dataSource', id).slice(-1)[0]?.step ?? null;
} }
// 原本是删除 → revert 即重新加回 // 原本是删除 → revert 即重新加回
if (oldSchema && newSchema === null) { if (oldSchema && !newSchema) {
this.add(cloneDeep(oldSchema), { historyDescription, historySource: 'rollback' }); this.add(cloneDeep(oldSchema), { historyDescription, historySource: 'rollback' });
return historyService.getDataSourceStepList(id).slice(-1)[0]?.step ?? null; return historyService.getStepList('dataSource', id).slice(-1)[0]?.step ?? null;
} }
if (!oldSchema || !newSchema) return null; if (!oldSchema || !newSchema) return null;
@ -418,11 +474,11 @@ class DataSource extends BaseService {
historyDescription, historyDescription,
historySource: 'rollback', historySource: 'rollback',
}); });
return historyService.getDataSourceStepList(id).slice(-1)[0]?.step ?? null; return historyService.getStepList('dataSource', id).slice(-1)[0]?.step ?? null;
} }
this.update(cloneDeep(oldSchema), { historyDescription, historySource: 'rollback' }); this.update(cloneDeep(oldSchema), { historyDescription, historySource: 'rollback' });
return historyService.getDataSourceStepList(id).slice(-1)[0]?.step ?? null; return historyService.getStepList('dataSource', id).slice(-1)[0]?.step ?? null;
} }
/** /**
@ -440,10 +496,11 @@ class DataSource extends BaseService {
* @param reverse true=false= * @param reverse true=false=
*/ */
private applyHistoryStep(step: DataSourceStepValue, reverse: boolean): void { private applyHistoryStep(step: DataSourceStepValue, reverse: boolean): void {
const { id, oldSchema, newSchema, changeRecords } = step; const { id } = step.data;
const { oldSchema, newSchema, changeRecords } = step.diff?.[0] ?? {};
// 新增 / 删除:直接 add 或 remove不走 patch 逻辑 // 新增 / 删除:直接 add 或 remove不走 patch 逻辑
if (oldSchema === null && newSchema) { if (!oldSchema && newSchema) {
if (reverse) { if (reverse) {
this.remove(`${id}`, { doNotPushHistory: true }); this.remove(`${id}`, { doNotPushHistory: true });
} else { } else {
@ -452,7 +509,7 @@ class DataSource extends BaseService {
return; return;
} }
if (oldSchema && newSchema === null) { if (oldSchema && !newSchema) {
if (reverse) { if (reverse) {
this.add(cloneDeep(oldSchema), { doNotPushHistory: true }); this.add(cloneDeep(oldSchema), { doNotPushHistory: true });
} else { } else {

View File

@ -17,13 +17,21 @@
*/ */
import { reactive, toRaw } from 'vue'; import { reactive, toRaw } from 'vue';
import { cloneDeep, isObject, mergeWith, uniq } from 'lodash-es'; import { cloneDeep, isEmpty, isEqual, isObject, mergeWith, uniq } from 'lodash-es';
import type { Id, MApp, MContainer, MNode, MPage, MPageFragment, TargetOptions } from '@tmagic/core'; import type { Id, MApp, MContainer, MNode, MPage, MPageFragment, TargetOptions } from '@tmagic/core';
import { NodeType } from '@tmagic/core'; import { NodeType } from '@tmagic/core';
import type { ChangeRecord } from '@tmagic/form'; import type { ChangeRecord } from '@tmagic/form';
import { isFixed } from '@tmagic/stage'; import { isFixed } from '@tmagic/stage';
import { getNodeInfo, getNodePath, getValueByKeyPath, isPage, isPageFragment, setValueByKeyPath } from '@tmagic/utils'; import {
getNodeInfo,
getNodePath,
getValueByKeyPath,
guid,
isPage,
isPageFragment,
setValueByKeyPath,
} from '@tmagic/utils';
import BaseService from '@editor/services//BaseService'; import BaseService from '@editor/services//BaseService';
import propsService from '@editor/services//props'; import propsService from '@editor/services//props';
@ -34,11 +42,13 @@ import type {
AsyncHookPlugin, AsyncHookPlugin,
AsyncMethodName, AsyncMethodName,
DslOpOptions, DslOpOptions,
DslOpWithHistoryIdsResult,
EditorEvents, EditorEvents,
EditorNodeInfo, EditorNodeInfo,
HistoryOpSource, HistoryOpSource,
HistoryOpType, HistoryOpType,
PastePosition, PastePosition,
StepDiffItem,
StepValue, StepValue,
StoreState, StoreState,
StoreStateKey, StoreStateKey,
@ -51,6 +61,7 @@ import {
classifyDragSources, classifyDragSources,
collectRelatedNodes, collectRelatedNodes,
COPY_STORAGE_KEY, COPY_STORAGE_KEY,
describeStepForRevert,
editorNodeMergeCustomizer, editorNodeMergeCustomizer,
fixNodePosition, fixNodePosition,
getInitPositionStyle, getInitPositionStyle,
@ -63,41 +74,24 @@ import {
setLayout, setLayout,
toggleFixedPosition, toggleFixedPosition,
} from '@editor/utils/editor'; } from '@editor/utils/editor';
import { getLastPushedHistoryIds } from '@editor/utils/history';
import { beforePaste, getAddParent } from '@editor/utils/operator'; import { beforePaste, getAddParent } from '@editor/utils/operator';
type MoveItem = { node: MNode; parent: MContainer; pageForOp: { name: string; id: Id } | null }; type MoveItem = { node: MNode; parent: MContainer; pageForOp: { name: string; id: Id } | null };
/** /**
* step * update {@link StepDiffItem} {@link StepValue.diff} 使
* UI `describePageStep` service layouts/ * `changeRecords` form propPath/value / propPath
* / 退
*/ */
const describeStepForRevert = (step: StepValue): string => { const buildUpdateDiff = (
switch (step.opType) { items: { oldNode: MNode; newNode: MNode; changeRecords?: ChangeRecord[] }[],
case 'add': { ): StepDiffItem<MNode>[] =>
const count = step.nodes?.length ?? 0; items.map(({ oldNode, newNode, changeRecords }) => ({
const node = step.nodes?.[0]; oldSchema: oldNode,
const label = node?.name || node?.type || (node?.id !== undefined ? `${node.id}` : ''); newSchema: newNode,
return `撤回新增 ${count} 个节点${count === 1 && label ? `${label}` : ''}`; ...(changeRecords?.length ? { changeRecords } : {}),
} }));
case 'remove': {
const count = step.removedItems?.length ?? 0;
const node = step.removedItems?.[0]?.node;
const label = node?.name || node?.type || (node?.id !== undefined ? `${node.id}` : '');
return `还原已删除的 ${count} 个节点${count === 1 && label ? `${label}` : ''}`;
}
case 'update':
default: {
const items = step.updatedItems ?? [];
if (items.length === 1) {
const { newNode, oldNode, changeRecords } = items[0];
const target = newNode?.name || newNode?.type || oldNode?.name || oldNode?.type || `${newNode?.id ?? ''}`;
const propPath = changeRecords?.[0]?.propPath;
return propPath ? `还原 ${target} · ${propPath}` : `还原 ${target}`;
}
return `还原 ${items.length} 个节点的修改`;
}
}
};
class Editor extends BaseService { class Editor extends BaseService {
public state: StoreState = reactive({ public state: StoreState = reactive({
@ -116,6 +110,12 @@ class Editor extends BaseService {
alwaysMultiSelect: false, alwaysMultiSelect: false,
}); });
private selectionBeforeOp: Id[] | null = null; private selectionBeforeOp: Id[] | null = null;
/**
* pushOpHistory uuid
* *AndGetHistoryId id
* *AndGetHistoryId null
*/
private lastPushedHistoryId: string | null = null;
constructor() { constructor() {
super( super(
@ -129,8 +129,13 @@ class Editor extends BaseService {
* *
* @param name 'root' | 'page' | 'parent' | 'node' | 'highlightNode' | 'nodes' | 'stage' | 'modifiedNodeIds' | 'pageLength' | 'pageFragmentLength * @param name 'root' | 'page' | 'parent' | 'node' | 'highlightNode' | 'nodes' | 'stage' | 'modifiedNodeIds' | 'pageLength' | 'pageFragmentLength
* @param value MNode * @param value MNode
* @param options.historySource root name === 'root'
*/ */
public set<K extends StoreStateKey, T extends StoreState[K]>(name: K, value: T) { public set<K extends StoreStateKey, T extends StoreState[K]>(
name: K,
value: T,
options: { historySource?: HistoryOpSource } = {},
) {
const preValue = this.state[name]; const preValue = this.state[name];
this.state[name] = value; this.state[name] = value;
@ -149,13 +154,32 @@ class Editor extends BaseService {
this.state.pageLength = getPageList(app).length || 0; this.state.pageLength = getPageList(app).length || 0;
this.state.pageFragmentLength = getPageFragmentList(app).length || 0; this.state.pageFragmentLength = getPageFragmentList(app).length || 0;
this.state.stageLoading = this.state.pageLength !== 0; this.state.stageLoading = this.state.pageLength !== 0;
if (preValue && !isEmpty(preValue)) {
// 编辑期间再次整体设置 root源码保存 / 外部重设 DSL / root 节点更新):与上一次 root
// 做页面级 diff按 update / add / remove 入栈,作为正常历史记录体现整体替换。
this.pushRootDiffHistory(preValue as MApp, app, options.historySource);
} else {
// 首次设置 root仅当该页面 / 页面片尚无基线标记时,才写入「未修改的初始状态」基线。
// 配合「先恢复历史再 set root」若基线已随历史恢复建立恢复后已有基线则此处不再
// 重复创建set root 不额外产生记录,由恢复出的历史栈作为当前状态来源。
// 标记不进入撤销/重做栈,仅作为该页历史列表底部的初始基线展示。
app.items?.forEach((pageNode) => {
if (pageNode?.id !== undefined && !historyService.getMarker('page', pageNode.id)) {
historyService.setMarker('page', pageNode.id, {
name: pageNode.name,
source: options.historySource,
});
}
});
}
} else { } else {
this.state.pageLength = 0; this.state.pageLength = 0;
this.state.pageFragmentLength = 0; this.state.pageFragmentLength = 0;
this.state.stageLoading = false; this.state.stageLoading = false;
} }
this.emit('root-change', value as StoreState['root'], preValue as StoreState['root']); this.emit('root-change', value as StoreState['root'], preValue as StoreState['root'], options);
} }
} }
@ -180,7 +204,30 @@ class Editor extends BaseService {
root = toRaw(root); root = toRaw(root);
} }
return getNodeInfo(id, root); if (!root) {
return { node: null, parent: null, page: null, path: [] };
}
if (id === root.id) {
return { node: root, parent: null, page: null, path: [] };
}
// 大多数查找的目标都在当前页面内,优先在当前页面子树中查找以避免对整棵树做全量遍历。
// 注意:不能直接使用 state.page它可能与当前 root 不同步(指向已脱离的旧页面对象),
// 因此仅借用其 id再从当前 root 中取回真正的页面对象(页面均为 root 的直接子节点,数量很少)。
const pageIdStr = `${this.get('page')?.id || ''}`;
const currentPageNode = root.items?.find((item) => `${item.id}` === pageIdStr);
if (currentPageNode && `${id}` !== pageIdStr) {
// util 仅读取 root.id 与 root.items按容器结构传入当前页面是安全的
const info = getNodeInfo(id, currentPageNode);
if (info.node) {
return info;
}
}
// 回退:在完整 root 上查找;当前页面已搜索过,用 skip 跳过其子树避免重复遍历,
// 同时保留真实的 parent / page 引用id 命中当前页面节点本身时会在跳过子树前先匹配到)
return getNodeInfo(id, root, currentPageNode);
} }
/** /**
@ -250,12 +297,6 @@ class Editor extends BaseService {
this.set('page', page); this.set('page', page);
this.set('parent', parent); this.set('parent', parent);
if (page) {
historyService.changePage(toRaw(page));
} else {
historyService.resetState();
}
if (node?.id) { if (node?.id) {
this.get('stage') this.get('stage')
?.renderer?.runtime?.getApp?.() ?.renderer?.runtime?.getApp?.()
@ -473,17 +514,17 @@ class Editor extends BaseService {
if (!(isPage(newNodes[0]) || isPageFragment(newNodes[0]))) { if (!(isPage(newNodes[0]) || isPageFragment(newNodes[0]))) {
const pageForOp = this.getNodeInfo(newNodes[0].id, false).page; const pageForOp = this.getNodeInfo(newNodes[0].id, false).page;
if (!doNotPushHistory) { if (!doNotPushHistory) {
const parentId = (this.getParentById(newNodes[0].id, false) ?? this.get('root'))!.id;
this.pushOpHistory('add', { this.pushOpHistory('add', {
extra: { diff: newNodes.map((n) => {
nodes: newNodes.map((n) => cloneDeep(toRaw(n))),
parentId: (this.getParentById(newNodes[0].id, false) ?? this.get('root'))!.id,
indexMap: Object.fromEntries(
newNodes.map((n) => {
const p = this.getParentById(n.id, false) as MContainer; const p = this.getParentById(n.id, false) as MContainer;
return [n.id, p ? getNodeIndex(n.id, p) : -1]; const idx = p ? getNodeIndex(n.id, p) : -1;
return {
newSchema: cloneDeep(toRaw(n)),
parentId,
index: typeof idx === 'number' ? idx : -1,
};
}), }),
),
},
pageData: { name: pageForOp?.name || '', id: pageForOp!.id }, pageData: { name: pageForOp?.name || '', id: pageForOp!.id },
historyDescription, historyDescription,
source: historySource, source: historySource,
@ -494,6 +535,10 @@ class Editor extends BaseService {
} }
this.emit('add', newNodes); this.emit('add', newNodes);
this.emit('change', {
type: 'add',
data: newNodes.map((node) => ({ node, page: this.getPageOfNode(node.id) })),
});
return Array.isArray(addNode) ? newNodes : newNodes[0]; return Array.isArray(addNode) ? newNodes : newNodes[0];
} }
@ -539,8 +584,6 @@ class Editor extends BaseService {
stage?.select(pages[0].id); stage?.select(pages[0].id);
} else { } else {
this.selectRoot(); this.selectRoot();
historyService.resetPage();
} }
}; };
@ -596,7 +639,10 @@ class Editor extends BaseService {
const nodes = Array.isArray(nodeOrNodeList) ? nodeOrNodeList : [nodeOrNodeList]; const nodes = Array.isArray(nodeOrNodeList) ? nodeOrNodeList : [nodeOrNodeList];
const removedItems: { node: MNode; parentId: Id; index: number }[] = []; // 删除后节点已从树中移除,无法再反查所属 page这里在删除前先逐个捕获用于 `change` 事件
const changeItems = nodes.map((node) => ({ node, page: this.getPageOfNode(node.id) }));
const removedItems: StepDiffItem<MNode>[] = [];
let pageForOp: { name: string; id: Id } | null = null; let pageForOp: { name: string; id: Id } | null = null;
if (!(isPage(nodes[0]) || isPageFragment(nodes[0]))) { if (!(isPage(nodes[0]) || isPageFragment(nodes[0]))) {
for (const n of nodes) { for (const n of nodes) {
@ -607,7 +653,7 @@ class Editor extends BaseService {
} }
const idx = getNodeIndex(curNode.id, parent); const idx = getNodeIndex(curNode.id, parent);
removedItems.push({ removedItems.push({
node: cloneDeep(toRaw(curNode)), oldSchema: cloneDeep(toRaw(curNode)),
parentId: parent.id, parentId: parent.id,
index: typeof idx === 'number' ? idx : -1, index: typeof idx === 'number' ? idx : -1,
}); });
@ -620,7 +666,7 @@ class Editor extends BaseService {
if (removedItems.length > 0 && pageForOp) { if (removedItems.length > 0 && pageForOp) {
if (!doNotPushHistory) { if (!doNotPushHistory) {
this.pushOpHistory('remove', { this.pushOpHistory('remove', {
extra: { removedItems }, diff: removedItems,
pageData: pageForOp, pageData: pageForOp,
historyDescription, historyDescription,
source: historySource, source: historySource,
@ -631,11 +677,12 @@ class Editor extends BaseService {
} }
this.emit('remove', nodes); this.emit('remove', nodes);
this.emit('change', { type: 'remove', data: changeItems });
} }
public async doUpdate( public async doUpdate(
config: MNode, config: MNode,
{ changeRecords = [] }: { changeRecords?: ChangeRecord[] } = {}, { changeRecords = [], historySource }: { changeRecords?: ChangeRecord[]; historySource?: HistoryOpSource } = {},
): Promise<{ newNode: MNode; oldNode: MNode; changeRecords?: ChangeRecord[] }> { ): Promise<{ newNode: MNode; oldNode: MNode; changeRecords?: ChangeRecord[] }> {
const root = this.get('root'); const root = this.get('root');
if (!root) throw new Error('root为空'); if (!root) throw new Error('root为空');
@ -648,14 +695,14 @@ class Editor extends BaseService {
const node = toRaw(info.node); const node = toRaw(info.node);
let newConfig = await toggleFixedPosition(toRaw(config), node, root, this.getLayout); let newConfig = await toggleFixedPosition(toRaw(config), node, info.path, this.getLayout);
newConfig = mergeWith(cloneDeep(node), newConfig, editorNodeMergeCustomizer); newConfig = mergeWith(cloneDeep(node), newConfig, editorNodeMergeCustomizer);
if (!newConfig.type) throw new Error('配置缺少type值'); if (!newConfig.type) throw new Error('配置缺少type值');
if (newConfig.type === NodeType.ROOT) { if (newConfig.type === NodeType.ROOT) {
this.set('root', newConfig as MApp); this.set('root', newConfig as MApp, { historySource });
return { return {
oldNode: node, oldNode: node,
newNode: newConfig, newNode: newConfig,
@ -736,7 +783,7 @@ class Editor extends BaseService {
const updateData = await Promise.all( const updateData = await Promise.all(
nodes.map((node, index) => { nodes.map((node, index) => {
const recordsForNode = changeRecordList ? (changeRecordList[index] ?? []) : (changeRecords ?? []); const recordsForNode = changeRecordList ? (changeRecordList[index] ?? []) : (changeRecords ?? []);
return this.doUpdate(node, { changeRecords: recordsForNode }); return this.doUpdate(node, { changeRecords: recordsForNode, historySource });
}), }),
); );
@ -746,15 +793,15 @@ class Editor extends BaseService {
if (!doNotPushHistory) { if (!doNotPushHistory) {
const pageForOp = this.getNodeInfo(nodes[0].id, false).page; const pageForOp = this.getNodeInfo(nodes[0].id, false).page;
this.pushOpHistory('update', { this.pushOpHistory('update', {
extra: {
updatedItems: updateData.map((d) => ({
oldNode: cloneDeep(d.oldNode),
newNode: cloneDeep(toRaw(d.newNode)),
// 每个节点单独保留自己的 changeRecords便于撤销/重做时按 propPath 精细化更新; // 每个节点单独保留自己的 changeRecords便于撤销/重做时按 propPath 精细化更新;
// 没有 changeRecords 的(如内部 sort/moveLayer 等整节点替换操作)会退化为全节点替换。 // 没有 changeRecords 的(如内部 sort/moveLayer 等整节点替换操作)会退化为全节点替换。
diff: buildUpdateDiff(
updateData.map((d) => ({
oldNode: cloneDeep(d.oldNode),
newNode: cloneDeep(d.newNode),
changeRecords: d.changeRecords?.length ? cloneDeep(d.changeRecords) : undefined, changeRecords: d.changeRecords?.length ? cloneDeep(d.changeRecords) : undefined,
})), })),
}, ),
pageData: { name: pageForOp?.name || '', id: pageForOp!.id }, pageData: { name: pageForOp?.name || '', id: pageForOp!.id },
historyDescription, historyDescription,
source: historySource, source: historySource,
@ -766,6 +813,10 @@ class Editor extends BaseService {
} }
this.emit('update', updateData); this.emit('update', updateData);
this.emit('change', {
type: 'update',
data: updateData.map((node) => ({ node, page: this.getPageOfNode(node.newNode.id) })),
});
return Array.isArray(config) ? updateData.map((item) => item.newNode) : updateData[0].newNode; return Array.isArray(config) ? updateData.map((item) => item.newNode) : updateData[0].newNode;
} }
@ -996,9 +1047,7 @@ class Editor extends BaseService {
'update', 'update',
{ {
extra: { diff: buildUpdateDiff([{ oldNode: oldParent, newNode: cloneDeep(toRaw(parent)) }]),
updatedItems: [{ oldNode: oldParent, newNode: cloneDeep(toRaw(parent)) }],
},
pageData: { name: pageForOp?.name || '', id: pageForOp!.id }, pageData: { name: pageForOp?.name || '', id: pageForOp!.id },
historyDescription, historyDescription,
source: historySource, source: historySource,
@ -1009,6 +1058,11 @@ class Editor extends BaseService {
} }
this.emit('move-layer', offset); this.emit('move-layer', offset);
this.emit('change', {
type: 'move-layer',
data: [{ node, page: this.getPageOfNode(node.id) }],
offset,
});
} }
/** /**
@ -1098,7 +1152,7 @@ class Editor extends BaseService {
})); }));
const historyPage = moves[0].pageForOp ?? { name: '', id: target.id }; const historyPage = moves[0].pageForOp ?? { name: '', id: target.id };
this.pushOpHistory('update', { this.pushOpHistory('update', {
extra: { updatedItems }, diff: buildUpdateDiff(updatedItems),
pageData: historyPage, pageData: historyPage,
historyDescription, historyDescription,
source: historySource, source: historySource,
@ -1178,7 +1232,7 @@ class Editor extends BaseService {
if (!doNotPushHistory) { if (!doNotPushHistory) {
const pageForOp = this.getNodeInfo(configs[0].id, false).page; const pageForOp = this.getNodeInfo(configs[0].id, false).page;
this.pushOpHistory('update', { this.pushOpHistory('update', {
extra: { updatedItems }, diff: buildUpdateDiff(updatedItems),
pageData: { name: pageForOp?.name || '', id: pageForOp!.id }, pageData: { name: pageForOp?.name || '', id: pageForOp!.id },
historyDescription, historyDescription,
source: historySource, source: historySource,
@ -1188,14 +1242,102 @@ class Editor extends BaseService {
} }
this.emit('drag-to', { targetIndex, configs, targetParent }); this.emit('drag-to', { targetIndex, configs, targetParent });
this.emit('change', {
type: 'drag-to',
data: configs.map((node) => ({ node, page: this.getPageOfNode(node.id) })),
targetIndex,
targetParent,
});
} }
// #region AndGetHistoryId
/**
* *AndGetHistoryId add / remove / update ...
* {@link DslOpWithHistoryIdsResult} uuid {@link StepValue.uuid}
* / revert
*
* doNotPushHistory true / historyIds `[]`
*/
/** 等价于 {@link add},并额外返回本次写入历史记录的 uuid 列表(未入栈时 historyIds 为 `[]`)。 */
public async addAndGetHistoryId(
addNode: AddMNode | MNode[],
parent?: MContainer | null,
options: DslOpOptions = {},
): Promise<DslOpWithHistoryIdsResult<MNode | MNode[]>> {
this.lastPushedHistoryId = null;
const result = await this.add(addNode, parent, options);
return { result, historyIds: getLastPushedHistoryIds(this.lastPushedHistoryId) };
}
/** 等价于 {@link remove},并额外返回本次写入历史记录的 uuid 列表(未入栈时 historyIds 为 `[]`)。 */
public async removeAndGetHistoryId(
nodeOrNodeList: MNode | MNode[],
options: DslOpOptions = {},
): Promise<DslOpWithHistoryIdsResult<void>> {
this.lastPushedHistoryId = null;
await this.remove(nodeOrNodeList, options);
return { result: undefined, historyIds: getLastPushedHistoryIds(this.lastPushedHistoryId) };
}
/** 等价于 {@link update},并额外返回本次写入历史记录的 uuid 列表(未入栈时 historyIds 为 `[]`)。 */
public async updateAndGetHistoryId(
config: MNode | MNode[],
data: {
changeRecords?: ChangeRecord[];
changeRecordList?: ChangeRecord[][];
doNotPushHistory?: boolean;
historyDescription?: string;
historySource?: HistoryOpSource;
} = {},
): Promise<DslOpWithHistoryIdsResult<MNode | MNode[]>> {
this.lastPushedHistoryId = null;
const result = await this.update(config, data);
return { result, historyIds: getLastPushedHistoryIds(this.lastPushedHistoryId) };
}
/** 等价于 {@link moveLayer},并额外返回本次写入历史记录的 uuid 列表(未入栈时 historyIds 为 `[]`)。 */
public async moveLayerAndGetHistoryId(
offset: number | LayerOffset,
options: DslOpOptions = {},
): Promise<DslOpWithHistoryIdsResult<void>> {
this.lastPushedHistoryId = null;
await this.moveLayer(offset, options);
return { result: undefined, historyIds: getLastPushedHistoryIds(this.lastPushedHistoryId) };
}
/** 等价于 {@link moveToContainer},并额外返回本次写入历史记录的 uuid 列表(未入栈时 historyIds 为 `[]`)。 */
public async moveToContainerAndGetHistoryId(
config: MNode | MNode[],
targetId: Id,
options: DslOpOptions = {},
): Promise<DslOpWithHistoryIdsResult<MNode | MNode[]>> {
this.lastPushedHistoryId = null;
const result = await this.moveToContainer(config, targetId, options);
return { result, historyIds: getLastPushedHistoryIds(this.lastPushedHistoryId) };
}
/** 等价于 {@link dragTo},并额外返回本次写入历史记录的 uuid 列表(未入栈时 historyIds 为 `[]`)。 */
public async dragToAndGetHistoryId(
config: MNode | MNode[],
targetParent: MContainer,
targetIndex: number,
options: DslOpOptions = {},
): Promise<DslOpWithHistoryIdsResult<void>> {
this.lastPushedHistoryId = null;
await this.dragTo(config, targetParent, targetIndex, options);
return { result: undefined, historyIds: getLastPushedHistoryIds(this.lastPushedHistoryId) };
}
// #endregion AndGetHistoryId
/** /**
* *
* @returns * @returns
*/ */
public async undo(): Promise<StepValue | null> { public async undo(): Promise<StepValue | null> {
const value = historyService.undo(); const pageId = this.get('page')?.id;
if (pageId === undefined) return null;
const value = historyService.undo('page', pageId);
if (value) { if (value) {
await this.applyHistoryOp(value, true); await this.applyHistoryOp(value, true);
} }
@ -1207,7 +1349,9 @@ class Editor extends BaseService {
* @returns * @returns
*/ */
public async redo(): Promise<StepValue | null> { public async redo(): Promise<StepValue | null> {
const value = historyService.redo(); const pageId = this.get('page')?.id;
if (pageId === undefined) return null;
const value = historyService.redo('page', pageId);
if (value) { if (value) {
await this.applyHistoryOp(value, false); await this.applyHistoryOp(value, false);
} }
@ -1231,23 +1375,26 @@ class Editor extends BaseService {
* @returns step / / null * @returns step / / null
*/ */
public async revertPageStep(index: number): Promise<StepValue | null> { public async revertPageStep(index: number): Promise<StepValue | null> {
const list = historyService.getPageStepList(); const list = historyService.getStepList('page', this.get('page')?.id);
const entry = list[index]; const entry = list[index];
if (!entry?.applied) return null; if (!entry?.applied) return null;
const { step } = entry; const { step } = entry;
// 初始基线index 0 的 initial step是栈底线不可回滚。
if (step.opType === 'initial') return null;
const root = this.get('root'); const root = this.get('root');
if (!root) return null; if (!root) return null;
// 更新类步骤必须带 changeRecords 才支持回滚:缺失时只能整节点替换,会冲掉后续无关变更,故不支持。 // 更新类步骤必须带 changeRecords 才支持回滚:缺失时只能整节点替换,会冲掉后续无关变更,故不支持。
if (step.opType === 'update') { if (step.opType === 'update') {
const items = step.updatedItems ?? []; const items = step.diff ?? [];
if (!items.length || !items.every((item) => item.changeRecords?.length)) return null; if (!items.length || !items.every((item) => item.changeRecords?.length)) return null;
} }
// 反向应用产生的新 step 由内部 pushOpHistory 触发 history `change` 事件,监听一次以拿到引用。 // 反向应用产生的新 step 由内部 pushOpHistory 触发 history `change` 事件,监听一次以拿到引用。
let revertedStep: StepValue | null = null; let revertedStep: StepValue | null = null;
const captureRevert = (s: StepValue) => { // page 的 `change` 事件回调签名为 `(pageId, step)`,这里只关心被回滚产生的新 step。
const captureRevert = (_pageId: Id, s: StepValue) => {
revertedStep = s; revertedStep = s;
}; };
historyService.once('change', captureRevert); historyService.once('change', captureRevert);
@ -1260,9 +1407,9 @@ class Editor extends BaseService {
switch (step.opType) { switch (step.opType) {
case 'add': { case 'add': {
// 原本是新增 → revert 即删除当时被加入的节点 // 原本是新增 → revert 即删除当时被加入的节点
const nodes = step.nodes ?? []; for (const { newSchema } of step.diff ?? []) {
for (const n of nodes) { if (!newSchema) continue;
const existing = this.getNodeById(n.id, false); const existing = this.getNodeById(newSchema.id, false);
if (existing) { if (existing) {
await this.remove(existing, opts); await this.remove(existing, opts);
} }
@ -1272,21 +1419,26 @@ class Editor extends BaseService {
case 'remove': { case 'remove': {
// 原本是删除 → revert 即把节点按原父容器加回来。 // 原本是删除 → revert 即把节点按原父容器加回来。
// 按原 index 升序逐个插回,先小后大避免索引漂移。 // 按原 index 升序逐个插回,先小后大避免索引漂移。
const items = step.removedItems ?? []; const items = step.diff ?? [];
const sorted = [...items].sort((a, b) => a.index - b.index); const sorted = [...items].sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
for (const { node, parentId } of sorted) { for (const { oldSchema, parentId } of sorted) {
if (!oldSchema || parentId === undefined) continue;
const parent = this.getNodeById(parentId, false) as MContainer | null; const parent = this.getNodeById(parentId, false) as MContainer | null;
if (parent) { if (parent) {
await this.add([cloneDeep(node)] as MNode[], parent, opts); await this.add([cloneDeep(oldSchema)] as MNode[], parent, opts);
} }
} }
break; break;
} }
case 'update': { case 'update': {
// 原本是更新 → revert 即把 oldNode 的值写回; // 原本是更新 → revert 即把 oldSchema 的值写回;
// 优先按 changeRecords 局部 patch仅触达 propPath 下的字段,避免冲掉同节点上其它无关变更)。 // 优先按 changeRecords 局部 patch仅触达 propPath 下的字段,避免冲掉同节点上其它无关变更)。
const items = step.updatedItems ?? []; const items = step.diff ?? [];
const configs = items.map(({ oldNode, newNode, changeRecords }) => { const configs = items
.filter((item) => item.oldSchema && item.newSchema)
.map(({ oldSchema, newSchema, changeRecords }) => {
const oldNode = oldSchema!;
const newNode = newSchema!;
if (changeRecords?.length) { if (changeRecords?.length) {
const patch: MNode = { id: newNode.id, type: newNode.type }; const patch: MNode = { id: newNode.id, type: newNode.type };
for (const record of changeRecords) { for (const record of changeRecords) {
@ -1320,6 +1472,23 @@ class Editor extends BaseService {
return revertedStep; return revertedStep;
} }
/**
* uuid {@link revertPageStep}
* index uuid {@link StepValue.uuid}
* `null`
*
* @param uuids uuid *AndGetHistoryId `historyIds`
*/
public async revertPageStepById(uuids: string[]): Promise<(StepValue | null)[]> {
const results: (StepValue | null)[] = [];
const pageId = this.get('page')?.id;
for (const uuid of uuids) {
const location = historyService.findStepLocationByUuid('page', uuid, pageId);
results.push(!location ? null : await this.revertPageStep(location.index));
}
return results;
}
/** /**
* *
* *
@ -1330,8 +1499,9 @@ class Editor extends BaseService {
* @returns * @returns
*/ */
public async gotoPageStep(targetCursor: number): Promise<number> { public async gotoPageStep(targetCursor: number): Promise<number> {
let cursor = historyService.getPageCursor(); const pageId = this.get('page')?.id;
const { length } = historyService.getPageStepList(); let cursor = historyService.getCursor('page', pageId);
const { length } = historyService.getStepList('page', pageId);
const target = Math.max(0, Math.min(targetCursor, length)); const target = Math.max(0, Math.min(targetCursor, length));
while (cursor > target) { while (cursor > target) {
const step = await this.undo(); const step = await this.undo();
@ -1411,39 +1581,142 @@ class Editor extends BaseService {
this.get('modifiedNodeIds').set(id, id); this.get('modifiedNodeIds').set(id, id);
} }
/**
* /
* - page
* - page / pageFragment
* - null
* `change` page
*/
private getPageOfNode(id: Id): MPage | MPageFragment | null {
const { node, page } = this.getNodeInfo(id, false);
if (page) return page;
if (node && (isPage(node) || isPageFragment(node))) return node as MPage | MPageFragment;
return null;
}
private captureSelectionBeforeOp() { private captureSelectionBeforeOp() {
if (this.selectionBeforeOp) return; if (this.selectionBeforeOp) return;
this.selectionBeforeOp = this.get('nodes').map((n) => n.id); this.selectionBeforeOp = this.get('nodes').map((n) => n.id);
} }
/**
* root root /
* - `update` changeRecords
* - root `add`
* - root `remove`
*
* `source`
* DSL
*/
private pushRootDiffHistory(preRoot: MApp, nextRoot: MApp, source?: HistoryOpSource): void {
const prevPages = preRoot?.items || [];
const nextPages = nextRoot?.items || [];
const prevMap = new Map(prevPages.map((p) => [`${p.id}`, p]));
const nextMap = new Map(nextPages.map((p) => [`${p.id}`, p]));
const indexInItems = (root: MApp, id: Id) => (root.items ?? []).findIndex((item) => `${item.id}` === `${id}`);
nextPages.forEach((nextPage) => {
const prevPage = prevMap.get(`${nextPage.id}`);
if (!prevPage) {
this.pushPageDiffStep(
'add',
nextPage,
{ newSchema: cloneDeep(toRaw(nextPage)), parentId: nextRoot.id, index: indexInItems(nextRoot, nextPage.id) },
source,
);
} else if (!isEqual(toRaw(prevPage), toRaw(nextPage))) {
this.pushPageDiffStep(
'update',
nextPage,
{ oldSchema: cloneDeep(toRaw(prevPage)), newSchema: cloneDeep(toRaw(nextPage)) },
source,
);
}
});
prevPages.forEach((prevPage) => {
if (!nextMap.has(`${prevPage.id}`)) {
this.pushPageDiffStep(
'remove',
prevPage,
{ oldSchema: cloneDeep(toRaw(prevPage)), parentId: preRoot.id, index: indexInItems(preRoot, prevPage.id) },
source,
);
}
});
}
/**
* set root / modifiedNodeIds
*
* set root **** set root {@link StepValue.rootStep} `source`
* **** / DSL root
* initial 线 rootStep
*/
private pushPageDiffStep(
opType: HistoryOpType,
page: MPage | MPageFragment,
diffItem: StepDiffItem<MNode>,
source?: HistoryOpSource,
): void {
const step: StepValue = {
uuid: guid(),
data: { name: page.name || '', id: page.id },
opType,
extra: {
selectedBefore: [],
selectedAfter: [],
modifiedNodeIds: new Map(),
},
diff: [diffItem],
rootStep: true,
};
if (source) step.source = source;
const top = historyService.getCurrentPageStep(page.id);
if (top?.rootStep && top.source === source) {
historyService.replaceCurrentStep('page', step, page.id);
} else {
historyService.push('page', step, page.id);
}
}
private pushOpHistory( private pushOpHistory(
opType: HistoryOpType, opType: HistoryOpType,
{ {
extra, diff,
pageData, pageData,
historyDescription, historyDescription,
source, source,
}: { }: {
extra: Partial<StepValue>; diff: StepDiffItem<MNode>[];
pageData: { name: string; id: Id }; pageData: { name: string; id: Id };
historyDescription?: string; historyDescription?: string;
source?: HistoryOpSource; source?: HistoryOpSource;
}, },
) { ): string | null {
const step: StepValue = { const step: StepValue = {
uuid: guid(),
data: pageData, data: pageData,
opType, opType,
extra: {
selectedBefore: this.selectionBeforeOp ?? [], selectedBefore: this.selectionBeforeOp ?? [],
selectedAfter: this.get('nodes').map((n) => n.id), selectedAfter: this.get('nodes').map((n) => n.id),
modifiedNodeIds: new Map(this.get('modifiedNodeIds')), modifiedNodeIds: new Map(this.get('modifiedNodeIds')),
...extra, },
diff,
}; };
if (historyDescription) step.historyDescription = historyDescription; if (historyDescription) step.historyDescription = historyDescription;
if (source) step.source = source; if (source) step.source = source;
// 显式按 step.data.id 入栈:跨页操作(如 moveToContainer 从源页搬到目标页) // 显式按 step.data.id 入栈:跨页操作(如 moveToContainer 从源页搬到目标页)
// 必须落到正确的页面栈,否则会把记录错误地推到当前活动页 / 操作发起页。 // 必须落到正确的页面栈,否则会把记录错误地推到当前活动页 / 操作发起页。
historyService.push(step, pageData.id); const pushed = historyService.push('page', step, pageData.id);
// push 返回 null 表示当前没有可写入的页面栈(未真正入栈),此时不应返回 uuid。
const historyId = pushed ? step.uuid : null;
this.lastPushedHistoryId = historyId;
this.selectionBeforeOp = null; this.selectionBeforeOp = null;
return historyId;
} }
/** /**
@ -1459,6 +1732,8 @@ class Editor extends BaseService {
* @param reverse true = false = * @param reverse true = false =
*/ */
private async applyHistoryOp(step: StepValue, reverse: boolean) { private async applyHistoryOp(step: StepValue, reverse: boolean) {
// 初始基线 step 仅作展示,不承载任何变更,撤销/重做时无需应用(正常流程下也不会被触达)。
if (step.opType === 'initial') return;
const root = this.get('root'); const root = this.get('root');
const stage = this.get('stage'); const stage = this.get('stage');
if (!root) return; if (!root) return;
@ -1467,31 +1742,31 @@ class Editor extends BaseService {
switch (step.opType) { switch (step.opType) {
case 'add': { case 'add': {
const nodes = step.nodes ?? []; const items = step.diff ?? [];
if (reverse) { if (reverse) {
// 撤销 add把当时加入的节点删除 // 撤销 add把当时加入的节点删除
for (const n of nodes) { for (const { newSchema } of items) {
const existing = this.getNodeById(n.id, false); if (!newSchema) continue;
const existing = this.getNodeById(newSchema.id, false);
if (existing) { if (existing) {
await this.remove(existing, commonOpts); await this.remove(existing, commonOpts);
} }
} }
} else { } else {
// 重做 add按记录的 indexMap 把节点重新插回父容器 // 重做 add按记录的 parentId / index 把节点重新插回父容器。
const parent = this.getNodeById(step.parentId!, false) as MContainer | null;
if (parent) {
// 按目标 index 升序逐个插入,先小后大避免索引漂移 // 按目标 index 升序逐个插入,先小后大避免索引漂移
const sorted = [...nodes].sort((a, b) => (step.indexMap?.[a.id] ?? 0) - (step.indexMap?.[b.id] ?? 0)); const sorted = [...items].sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
for (const n of sorted) { for (const { newSchema, parentId, index } of sorted) {
const idx = step.indexMap?.[n.id]; if (!newSchema || parentId === undefined) continue;
if (parent.items) { const parent = this.getNodeById(parentId, false) as MContainer | null;
if (typeof idx === 'number' && idx >= 0 && idx < parent.items.length) { if (parent?.items) {
parent.items.splice(idx, 0, cloneDeep(n)); if (typeof index === 'number' && index >= 0 && index < parent.items.length) {
parent.items.splice(index, 0, cloneDeep(newSchema));
} else { } else {
parent.items.push(cloneDeep(n)); parent.items.push(cloneDeep(newSchema));
} }
await stage?.add({ await stage?.add({
config: cloneDeep(n), config: cloneDeep(newSchema),
parent: cloneDeep(parent), parent: cloneDeep(parent),
parentId: parent.id, parentId: parent.id,
root: cloneDeep(root), root: cloneDeep(root),
@ -1499,20 +1774,20 @@ class Editor extends BaseService {
} }
} }
} }
}
break; break;
} }
case 'remove': { case 'remove': {
const items = step.removedItems ?? []; const items = step.diff ?? [];
if (reverse) { if (reverse) {
// 撤销 remove按原 index 升序逐个插回(先小后大避免索引漂移) // 撤销 remove按原 index 升序逐个插回(先小后大避免索引漂移)
const sorted = [...items].sort((a, b) => a.index - b.index); const sorted = [...items].sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
for (const { node, parentId, index } of sorted) { for (const { oldSchema, parentId, index } of sorted) {
if (!oldSchema || parentId === undefined) continue;
const parent = this.getNodeById(parentId, false) as MContainer | null; const parent = this.getNodeById(parentId, false) as MContainer | null;
if (parent?.items) { if (parent?.items) {
parent.items.splice(index, 0, cloneDeep(node)); parent.items.splice(index ?? parent.items.length, 0, cloneDeep(oldSchema));
await stage?.add({ await stage?.add({
config: cloneDeep(node), config: cloneDeep(oldSchema),
parent: cloneDeep(parent), parent: cloneDeep(parent),
parentId, parentId,
root: cloneDeep(root), root: cloneDeep(root),
@ -1521,8 +1796,9 @@ class Editor extends BaseService {
} }
} else { } else {
// 重做 remove再删一次 // 重做 remove再删一次
for (const { node } of items) { for (const { oldSchema } of items) {
const existing = this.getNodeById(node.id, false); if (!oldSchema) continue;
const existing = this.getNodeById(oldSchema.id, false);
if (existing) { if (existing) {
await this.remove(existing, commonOpts); await this.remove(existing, commonOpts);
} }
@ -1531,10 +1807,14 @@ class Editor extends BaseService {
break; break;
} }
case 'update': { case 'update': {
const items = step.updatedItems ?? []; const items = step.diff ?? [];
// 优先按 changeRecords 局部 patch仅触达 propPath 下的字段,避免整节点替换冲掉同节点上其它无关变更。 // 优先按 changeRecords 局部 patch仅触达 propPath 下的字段,避免整节点替换冲掉同节点上其它无关变更。
// 没有 changeRecords 的(如内部 sort/moveLayer/拖动等整节点快照场景)才退化为整节点替换。 // 没有 changeRecords 的(如内部 sort/moveLayer/拖动等整节点快照场景)才退化为整节点替换。
const configs = items.map(({ oldNode, newNode, changeRecords }) => { const configs = items
.filter((item) => item.oldSchema && item.newSchema)
.map(({ oldSchema, newSchema, changeRecords }) => {
const oldNode = oldSchema!;
const newNode = newSchema!;
if (changeRecords?.length) { if (changeRecords?.length) {
const sourceForValues = reverse ? oldNode : newNode; const sourceForValues = reverse ? oldNode : newNode;
// 仅保留 id / type 作为最小骨架,再按 propPath 写入需要回滚/重做的字段; // 仅保留 id / type 作为最小骨架,再按 propPath 写入需要回滚/重做的字段;
@ -1559,11 +1839,11 @@ class Editor extends BaseService {
} }
} }
this.set('modifiedNodeIds', step.modifiedNodeIds); this.set('modifiedNodeIds', step.extra?.modifiedNodeIds ?? new Map());
const page = toRaw(this.get('page')); const page = toRaw(this.get('page'));
if (page) { if (page) {
const selectIds = reverse ? step.selectedBefore : step.selectedAfter; const selectIds = (reverse ? step.extra?.selectedBefore : step.extra?.selectedAfter) ?? [];
setTimeout(() => { setTimeout(() => {
if (!selectIds.length) return; if (!selectIds.length) return;

File diff suppressed because it is too large Load Diff

View File

@ -27,6 +27,9 @@ import {
DEFAULT_LEFT_COLUMN_WIDTH, DEFAULT_LEFT_COLUMN_WIDTH,
DEFAULT_RIGHT_COLUMN_WIDTH, DEFAULT_RIGHT_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';
@ -54,6 +57,9 @@ const state = shallowReactive<UiState>({
storageService.getItem(RIGHT_COLUMN_WIDTH_STORAGE_KEY, { protocol: Protocol.NUMBER }) || storageService.getItem(RIGHT_COLUMN_WIDTH_STORAGE_KEY, { protocol: Protocol.NUMBER }) ||
DEFAULT_RIGHT_COLUMN_WIDTH, DEFAULT_RIGHT_COLUMN_WIDTH,
}, },
minLeftColumnWidth: MIN_LEFT_COLUMN_WIDTH,
minCenterColumnWidth: MIN_CENTER_COLUMN_WIDTH,
minRightColumnWidth: MIN_RIGHT_COLUMN_WIDTH,
showGuides: true, showGuides: true,
hasGuides: false, hasGuides: false,
showRule: true, showRule: true,
@ -62,6 +68,7 @@ const state = shallowReactive<UiState>({
showPageListButton: true, showPageListButton: true,
hideSlideBar: false, hideSlideBar: false,
sideBarItems: [], sideBarItems: [],
sideBarActiveTabName: '',
navMenuRect: { navMenuRect: {
left: 0, left: 0,
top: 0, top: 0,
@ -104,7 +111,13 @@ class Ui extends BaseService {
mask?.showRule(value as unknown as boolean); mask?.showRule(value as unknown as boolean);
} }
const preValue = state[name];
state[name] = value; state[name] = value;
if (preValue !== value) {
this.emit('state-change', name, value, preValue);
}
} }
public get<K extends keyof UiState>(name: K) { public get<K extends keyof UiState>(name: K) {

View File

@ -25,10 +25,18 @@
.m-editor-float-box-body { .m-editor-float-box-body {
overflow: auto; overflow: auto;
flex: 1; flex: 1;
padding: 0 16px;
} }
} }
.m-editor-floating-box-moveable { .m-editor-floating-box-moveable {
opacity: 0; opacity: 0;
} }
.m-editor-float-box-drag-mask {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: transparent;
}

View File

@ -45,6 +45,28 @@
list-style: none; list-style: none;
} }
// 历史列表工具条放置清空等列表级操作右对齐
.m-editor-history-list-toolbar {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 0 4px 4px;
}
// 清空按钮红色文字按钮强调破坏性操作点击后会二次确认
.m-editor-history-list-clear {
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
color: #f56c6c;
cursor: pointer;
user-select: none;
&:hover {
background-color: rgba(245, 108, 108, 0.12);
}
}
.m-editor-history-list-item { .m-editor-history-list-item {
display: flex; display: flex;
align-items: center; align-items: center;
@ -215,6 +237,10 @@
.m-editor-history-list-item-index { .m-editor-history-list-item-index {
flex: 0 0 auto; flex: 0 0 auto;
// 固定最小宽度并右对齐序号位数不一#6 / #16时右边界仍统一
// 使紧随其后的类型徽标在各行间对齐成整齐的一列
min-width: 30px;
text-align: right;
color: #909399; color: #909399;
font-family: Menlo, Monaco, Consolas, "Courier New", monospace; font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
font-size: 11px; font-size: 11px;
@ -222,6 +248,15 @@
white-space: nowrap; white-space: nowrap;
} }
// 合并组头部展示的是步骤区间 #10-#20宽度本就不定也无需与单步行对齐
// 恢复自然宽度与左对齐避免被强制成固定列后显得突兀
.m-editor-history-list-group.is-merged
> .m-editor-history-list-group-head
> .m-editor-history-list-item-index {
min-width: 0;
text-align: left;
}
// 操作时间弱化展示紧贴在描述之后各操作按钮之前 // 操作时间弱化展示紧贴在描述之后各操作按钮之前
.m-editor-history-list-item-time { .m-editor-history-list-item-time {
flex: 0 0 auto; flex: 0 0 auto;
@ -295,6 +330,34 @@
font-weight: 400; // 防止被合并组头部的粗体继承 font-weight: 400; // 防止被合并组头部的粗体继承
} }
// 操作人徽标浅蓝描边胶囊弱化展示操作人仅在 step.operator 有值时渲染
.m-editor-history-list-item-operator {
flex: 0 0 auto;
padding: 0 6px;
border: 1px solid #c6e2ff;
border-radius: 8px;
font-size: 10px;
line-height: 14px;
color: #409eff;
background-color: #ecf5ff;
white-space: nowrap;
font-weight: 400; // 防止被合并组头部的粗体继承
}
// 已保存徽标绿色实心胶囊标记最近一次保存对应的历史记录 historyService.markSaved 对应
.m-editor-history-list-item-saved {
flex: 0 0 auto;
padding: 0 6px;
border-radius: 8px;
font-size: 10px;
line-height: 16px;
color: #fff;
background-color: #67c23a;
white-space: nowrap;
font-weight: 500;
letter-spacing: 0.2px;
}
// 合并 N 徽标紫色实心胶囊与合并组卡片色系一致醒目区分单步条目 // 合并 N 徽标紫色实心胶囊与合并组卡片色系一致醒目区分单步条目
.m-editor-history-list-item-merge { .m-editor-history-list-item-merge {
flex: 0 0 auto; flex: 0 0 auto;
@ -308,6 +371,24 @@
letter-spacing: 0.2px; letter-spacing: 0.2px;
} }
// 操作区回滚 / 回到 / 查看差异收敛为一个统一容器默认隐藏
// 仅在指针悬停于所在行时显示静止状态下每行最右侧固定为时间
// 各行因此能对齐成整齐的右侧列避免按钮数量不一导致的参差错乱
.m-editor-history-list-item-actions {
display: none;
flex: 0 0 auto;
align-items: center;
gap: 6px;
}
.m-editor-history-list-group-head:hover > .m-editor-history-list-item-actions,
.m-editor-history-list-substeps
> li:hover
> .m-editor-history-list-item-actions,
.m-editor-history-list-initial:hover > .m-editor-history-list-item-actions {
display: flex;
}
.m-editor-history-list-item-diff { .m-editor-history-list-item-diff {
flex: 0 0 auto; flex: 0 0 auto;
padding: 0 6px; padding: 0 6px;
@ -324,21 +405,21 @@
} }
} }
// 跳转按钮将历史游标移动到该 step替代原先点击整行跳转的交互 // 回到按钮将历史游标移动到该 step使用绿色色系
// 使用与组卡片一致的紫色色系查看差异回滚区分开 // 与红色回滚蓝色查看差异区分也避免与紧邻的灰色来源徽标混淆
.m-editor-history-list-item-goto { .m-editor-history-list-item-goto {
flex: 0 0 auto; flex: 0 0 auto;
padding: 0 6px; padding: 0 6px;
border-radius: 2px; border-radius: 2px;
font-size: 10px; font-size: 10px;
line-height: 16px; line-height: 16px;
color: #606266; color: #529b2e;
background-color: rgba(96, 98, 102, 0.1); background-color: rgba(103, 194, 58, 0.12);
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
&:hover { &:hover {
background-color: rgba(96, 98, 102, 0.18); background-color: rgba(103, 194, 58, 0.24);
} }
} }

View File

@ -102,10 +102,6 @@
transition: all 0.2s ease 0s; transition: all 0.2s ease 0s;
padding: 5px 14px; padding: 5px 14px;
.tmagic-design-button {
color: $font-color;
}
&:hover { &:hover {
background-color: $hover-color; background-color: $hover-color;
} }

View File

@ -24,9 +24,12 @@
& + .border-icon { & + .border-icon {
margin-left: 8px; margin-left: 8px;
} }
&.configured {
border-color: var(--el-color-success, var(--td-success-color, #2ba471));
}
&.active { &.active {
border-width: 1px; border-width: 1px;
border-color: var(--el-color-primary); border-color: var(--el-color-primary, var(--td-brand-color, #0052d9));
} }
&.border-icon-top { &.border-icon-top {
border-top-width: 2px; border-top-width: 2px;

View File

@ -16,7 +16,7 @@
* limitations under the License. * limitations under the License.
*/ */
import type { Component } from 'vue'; import type { AppContext, Component } from 'vue';
import type EventEmitter from 'events'; import type EventEmitter from 'events';
import type * as Monaco from 'monaco-editor'; import type * as Monaco from 'monaco-editor';
import type { default as Sortable, Options, SortableEvent } from 'sortablejs'; import type { default as Sortable, Options, SortableEvent } from 'sortablejs';
@ -33,7 +33,8 @@ import type {
MPage, MPage,
MPageFragment, MPageFragment,
} from '@tmagic/core'; } from '@tmagic/core';
import type { ChangeRecord, FormConfig, TableColumnConfig, TypeFunction } from '@tmagic/form'; import type { FieldSize } from '@tmagic/design';
import type { ChangeRecord, FormConfig, FormState, TableColumnConfig, TypeFunction } from '@tmagic/form';
import type StageCore from '@tmagic/stage'; import type StageCore from '@tmagic/stage';
import type { import type {
CanDropIn, CanDropIn,
@ -56,7 +57,7 @@ import type { PropsService } from './services/props';
import type { StageOverlayService } from './services/stageOverlay'; import type { StageOverlayService } from './services/stageOverlay';
import type { StorageService } from './services/storage'; import type { StorageService } from './services/storage';
import type { UiService } from './services/ui'; import type { UiService } from './services/ui';
import type { UndoRedo } from './utils/undo-redo'; import type { SerializedUndoRedo, UndoRedo } from './utils/undo-redo';
export type EditorSlots = FrameworkSlots & export type EditorSlots = FrameworkSlots &
WorkspaceSlots & WorkspaceSlots &
@ -296,6 +297,12 @@ export interface UiState {
stageRect: StageRect; stageRect: StageRect;
/** 编辑器列布局每一列的宽度,分为左中右三列 */ /** 编辑器列布局每一列的宽度,分为左中右三列 */
columnWidth: GetColumnWidth; columnWidth: GetColumnWidth;
/** 编辑器列布局左侧列最小宽度 */
minLeftColumnWidth: number;
/** 编辑器列布局中间列最小宽度 */
minCenterColumnWidth: number;
/** 编辑器列布局右侧列最小宽度 */
minRightColumnWidth: number;
/** 是否显示画布参考线true: 显示false: 不显示默认为true */ /** 是否显示画布参考线true: 显示false: 不显示默认为true */
showGuides: boolean; showGuides: boolean;
/** 画布上是否存在参考线 */ /** 画布上是否存在参考线 */
@ -312,6 +319,8 @@ export interface UiState {
hideSlideBar: boolean; hideSlideBar: boolean;
/** 侧边栏面板配置 */ /** 侧边栏面板配置 */
sideBarItems: SideComponent[]; sideBarItems: SideComponent[];
/** 当前激活的侧边栏面板 */
sideBarActiveTabName: string;
// navMenu 的宽高 // navMenu 的宽高
navMenuRect: { navMenuRect: {
@ -333,6 +342,7 @@ export interface EditorNodeInfo {
node: MNode | null; node: MNode | null;
parent: MContainer | null; parent: MContainer | null;
page: MPage | MPageFragment | null; page: MPage | MPageFragment | null;
path: MNode[];
} }
// #endregion EditorNodeInfo // #endregion EditorNodeInfo
@ -390,6 +400,9 @@ export interface MenuButton {
items?: MenuButton[]; items?: MenuButton[];
/** 唯一标识,用于高亮 */ /** 唯一标识,用于高亮 */
id?: string | number; id?: string | number;
buttonProps?: {
type?: string;
};
} }
// #endregion MenuButton // #endregion MenuButton
@ -680,7 +693,13 @@ export interface CodeParamStatement {
} }
// #region HistoryOpType // #region HistoryOpType
export type HistoryOpType = 'add' | 'remove' | 'update'; /**
*
* - `add` / `remove` / `update`/
* - `initial`线 root index 0 线 step
* step /cursor
*/
export type HistoryOpType = 'add' | 'remove' | 'update' | 'initial';
// #endregion HistoryOpType // #endregion HistoryOpType
// #region HistoryOpSource // #region HistoryOpSource
@ -705,11 +724,13 @@ export type HistoryOpType = 'add' | 'remove' | 'update';
* `(string & {})` * `(string & {})`
*/ */
export type HistoryOpSource = export type HistoryOpSource =
| 'initial'
| 'stage' | 'stage'
| 'tree' | 'tree'
| 'component-panel' | 'component-panel'
| 'props' | 'props'
| 'code' | 'code'
| 'root-code'
| 'stage-contextmenu' | 'stage-contextmenu'
| 'tree-contextmenu' | 'tree-contextmenu'
| 'toolbar' | 'toolbar'
@ -717,35 +738,74 @@ export type HistoryOpSource =
| 'rollback' | 'rollback'
| 'api' | 'api'
| 'ai' | 'ai'
// 同步
| 'sync'
| 'unknown' | 'unknown'
| (string & {}); | (string & {});
// #endregion HistoryOpSource // #endregion HistoryOpSource
// #region StepValue // #region DslOpWithHistoryIdsResult
export interface StepValue { /** *AndGetHistoryId 系列方法返回值:原操作结果 + 本次写入历史记录的 uuid 列表(未入栈时为 `[]`)。 */
/** 页面信息 */ export type DslOpWithHistoryIdsResult<T> = {
data: { name: string; id: Id }; result: T;
opType: HistoryOpType; historyIds: string[];
/** 操作前选中的节点 ID用于撤销后恢复选择状态 */ };
selectedBefore: Id[]; // #endregion DslOpWithHistoryIdsResult
/** 操作后选中的节点 ID用于重做后恢复选择状态 */
selectedAfter: Id[]; // #region StepDiffItem
modifiedNodeIds: Map<Id, Id>; /**
/** opType 'add': 新增的节点 */ * diff / /
nodes?: MNode[]; * {@link StepValue} / {@link CodeBlockStepValue} / {@link DataSourceStepValue} `diff`
/** opType 'add': 父节点 ID */
parentId?: Id;
/** opType 'add': 每个新增节点在父节点 items 中的索引 */
indexMap?: Record<string, number>;
/** opType 'remove': 被删除的节点及其位置信息 */
removedItems?: { node: MNode; parentId: Id; index: number }[];
/**
* opType 'update':
* *
* `changeRecords` form propPath/value / propPath * `opType`
* / 退 * - `add` `newSchema` `parentId` / `index`
* - `remove` `oldSchema` `parentId` / `index`
* - `update``oldSchema` + `newSchema` `changeRecords`
*
* `T` `MNode` `CodeBlockContent` `DataSourceSchema`
*/ */
updatedItems?: { oldNode: MNode; newNode: MNode; changeRecords?: ChangeRecord[] }[]; export interface StepDiffItem<T = unknown> {
/** 变更后的内容快照。`opType` 为 `add` / `update` 时有,`remove` 时无。 */
newSchema?: T;
/** 变更前的内容快照。`opType` 为 `remove` / `update` 时有,`add` 时无。 */
oldSchema?: T;
/** 父节点 id。仅页面节点有数据源 / 代码块没有父节点)。 */
parentId?: Id;
/** 在父节点 items 数组中的索引。仅页面节点有(数据源 / 代码块无需排序)。 */
index?: number;
/**
* form propPath/value `opType` `update`
* / propPath 退
*/
changeRecords?: ChangeRecord[];
}
// #endregion StepDiffItem
// #region BaseStepValue
/**
* {@link StepValue} / {@link CodeBlockStepValue} / {@link DataSourceStepValue}
*
* `T` `diff` `MNode` / `CodeBlockContent` / `DataSourceSchema`
*/
export interface BaseStepValue<T = unknown, U extends Record<string, any> = {}> {
/**
* uuid
* / revert
* `data.id` / / id
*/
uuid: string;
/**
* `id` / / id key
* `name`
*/
data: { name: string; id: Id };
/** 操作类型:新增 / 删除 / 更新(三类历史记录统一携带)。 */
opType: HistoryOpType;
/**
* diff {@link StepDiffItem}
* add/remove update /
*/
diff: StepDiffItem<T>[];
/** /**
* *
* undo/redo / propPath * undo/redo / propPath
@ -758,92 +818,160 @@ export interface StepValue {
*/ */
source?: HistoryOpSource; source?: HistoryOpSource;
/** /**
* historyService.push *
*/ */
timestamp?: number; timestamp?: number;
/**
* DSL / historyService.markSaved
* true IndexedDB
*/
saved?: boolean;
/**
* rootset root {@link Editor.pushRootDiffHistory}
* set root root set root
* / DSL root
*/
rootStep?: boolean;
/** 操作人 */
operator?: string;
/** 扩展信息 */
extra?: U;
} }
// #endregion BaseStepValue
// #region StepExtra
/**
* {@link BaseStepValue.extra}
* `page` /
*/
export interface StepExtra {
/** 操作前选中的节点 ID用于撤销后恢复选择状态page 类型) */
selectedBefore?: Id[];
/** 操作后选中的节点 ID用于重做后恢复选择状态page 类型) */
selectedAfter?: Id[];
/** 本次操作涉及的节点 id 集合page 类型) */
modifiedNodeIds?: Map<Id, Id>;
[key: string]: any;
}
// #endregion StepExtra
// #region StepValue
/**
* `diff` {@link MNode} /
* {@link BaseStepValue} id `data.id` `extra`
*/
export type StepValue = BaseStepValue<MNode, StepExtra>;
// #endregion StepValue // #endregion StepValue
// #region CodeBlockStepValue // #region CodeBlockStepValue
/** /**
* codeBlock.id historyState.codeBlockState * `diff` {@link CodeBlockContent} `data.id`codeBlock.id
* - oldContent = nullnewContent = * historyState.steps.codeBlock {@link StepValue} / {@link DataSourceStepValue}
* - oldContent / newContent * `diff`
* - newContent = nulloldContent =
*/ */
export interface CodeBlockStepValue { export type CodeBlockStepValue = BaseStepValue<CodeBlockContent>;
/** 关联的代码块 id */
id: Id;
/** 变更前的代码块内容,新增时为 null */
oldContent: CodeBlockContent | null;
/** 变更后的代码块内容,删除时为 null */
newContent: CodeBlockContent | null;
/**
* form propPath/value / propPath
* 退/ changeRecords
*/
changeRecords?: ChangeRecord[];
/** 调用方可选传入的人类可读描述,用于历史面板展示;不影响 undo/redo 行为。 */
historyDescription?: string;
/** 操作途径:标记本次变更由哪条交互入口触发,取值见 {@link HistoryOpSource};仅用于历史面板展示与埋点,不影响 undo/redo 行为。 */
source?: HistoryOpSource;
/** 入栈时间戳(毫秒),入栈时自动写入,仅用于历史面板展示。 */
timestamp?: number;
}
// #endregion CodeBlockStepValue // #endregion CodeBlockStepValue
// #region DataSourceStepValue // #region DataSourceStepValue
/** /**
* dataSource.id historyState.dataSourceState * `diff` {@link DataSourceSchema} `data.id`dataSource.id
* - oldSchema = nullnewSchema = schema * historyState.steps.dataSource {@link StepValue} / {@link CodeBlockStepValue}
* - oldSchema / newSchema schema * `diff`
* - newSchema = nulloldSchema = schema
*/ */
export interface DataSourceStepValue { export type DataSourceStepValue = BaseStepValue<DataSourceSchema>;
/** 关联的数据源 id */
id: Id;
/** 变更前的数据源 schema新增时为 null */
oldSchema: DataSourceSchema | null;
/** 变更后的数据源 schema删除时为 null */
newSchema: DataSourceSchema | null;
/**
* form propPath/value / propPath
* 退 schema / changeRecords
*/
changeRecords?: ChangeRecord[];
/** 调用方可选传入的人类可读描述,用于历史面板展示;不影响 undo/redo 行为。 */
historyDescription?: string;
/** 操作途径:标记本次变更由哪条交互入口触发,取值见 {@link HistoryOpSource};仅用于历史面板展示与埋点,不影响 undo/redo 行为。 */
source?: HistoryOpSource;
/** 入栈时间戳(毫秒),入栈时自动写入,仅用于历史面板展示。 */
timestamp?: number;
}
// #endregion DataSourceStepValue // #endregion DataSourceStepValue
export interface HistoryState { // #region HistorySteps
pageId?: Id; /**
pageSteps: Record<Id, UndoRedo<StepValue>>; * `page` / `codeBlock` / `dataSource`
canRedo: boolean; * `(string & {})`
canUndo: boolean;
/**
* codeBlock.id UndoRedo
* / undo/redo
*/ */
codeBlockState: Record<Id, UndoRedo<CodeBlockStepValue>>; export type HistoryStepType = 'page' | 'codeBlock' | 'dataSource' | (string & {});
/**
* dataSource.id UndoRedo /**
* / undo/redo * -> id -> UndoRedo
*
* - `page` page.id UndoRedo
* - `codeBlock` codeBlock.id
* - `dataSource` dataSource.id
* - {@link HistoryService.registerStepType}
*
* id undo/redo
* {@link HistoryService.setMarker} index 0 `initial` 线 / 线
*/ */
dataSourceState: Record<Id, UndoRedo<DataSourceStepValue>>; export interface HistorySteps {
page: Record<Id, UndoRedo<StepValue>>;
codeBlock: Record<Id, UndoRedo<CodeBlockStepValue>>;
dataSource: Record<Id, UndoRedo<DataSourceStepValue>>;
/** 扩展历史类型:按 id 分组的 UndoRedo 栈。 */
[stepType: string]: Record<Id, UndoRedo<any>>;
} }
// #endregion HistorySteps
export interface HistoryState {
/**
* / / / {@link HistorySteps}
* id undo/redo / {@link HistoryService.canUndo} /
* {@link HistoryService.canRedo} stepType + id canUndo / canRedo
*/
steps: HistorySteps;
/**
* {@link HistorySteps} tab /
* `page` / `codeBlock` / `dataSource` / /
* {@link HistoryService.registerStepType} `name`
* {@link HistoryService.setStepName} {@link HistoryService.getStepName}
*/
stepNames: Record<string, string>;
}
// #region PersistedHistoryState
/**
* historyService.saveToIndexedDB IndexedDB
* historyService.restoreFromIndexedDB UndoRedo
*/
export interface PersistedHistoryState {
/** 快照结构版本号,便于后续兼容升级。 */
version: number;
/**
* -> id {@link HistorySteps}
* `page` / `codeBlock` / `dataSource`
*/
steps: {
page: Record<Id, SerializedUndoRedo<StepValue>>;
codeBlock: Record<Id, SerializedUndoRedo<CodeBlockStepValue>>;
dataSource: Record<Id, SerializedUndoRedo<DataSourceStepValue>>;
[stepType: string]: Record<Id, SerializedUndoRedo<any>>;
};
/** 保存时间戳(毫秒)。 */
savedAt: number;
}
// #endregion PersistedHistoryState
// #region HistoryPersistOptions
/** historyService 持久化相关 API 的可选配置。 */
export interface HistoryPersistOptions {
/** IndexedDB 数据库名,默认 `tmagic-editor`(最终库名会拼上当前 DSL app id。 */
dbName?: string;
/** objectStore 名,默认 `history`。 */
storeName?: string;
/** 记录 key用于区分不同活动页 / 项目,默认 `default`。 */
key?: IDBValidKey;
/**
* DSL app id
* 退 editorService `root.id` set root root
* DSL id / app
*/
appId?: Id;
}
// #endregion HistoryPersistOptions
// #region HistoryListEntry // #region HistoryListEntry
/** /**
* *
*/ */
export interface PageHistoryStepEntry { export interface HistoryStepEntry<T> {
/** 步骤内容 */ /** 步骤内容 */
step: StepValue; step: T;
/** 在所属栈中的索引0 为最早) */ /** 在所属栈中的索引0 为最早) */
index: number; index: number;
/** 是否处于"已应用"段(即位于栈游标之前)。撤销后变为 false。 */ /** 是否处于"已应用"段(即位于栈游标之前)。撤销后变为 false。 */
@ -853,63 +981,39 @@ export interface PageHistoryStepEntry {
} }
/** /**
* * / / /
* - updatedItems[0].oldNode.id 'update' *
* - / add / remove * "目标"
* - targetId undefined "无明确目标" add/remove/ update * - updatetargetId
* - add / remove / update
* - targetId undefined "无明确目标"
*
* `kind` step
* - `HistoryGroup<StepValue>``kind: 'page'``id` pageId`targetId` id
* - `HistoryGroup<DataSourceStepValue>``kind: 'data-source'``id` dataSource.id
* - `HistoryGroup<CodeBlockStepValue>``kind: 'code-block'``id` codeBlock.id
*/ */
export interface PageHistoryGroup { export interface HistoryGroup<T extends BaseStepValue = BaseStepValue> {
kind: 'page'; /** 历史类型标识page / code-block / data-source扩展类型同理。 */
/** 所属页面 id */ kind: string;
pageId: Id; /** 所属栈 idpage 为 pageId代码块 / 数据源为对应资源 id。 */
/** 该分组的操作类型 */ id: Id;
/** 该分组的操作类型。 */
opType: HistoryOpType; opType: HistoryOpType;
/** /**
* id"单节点 update" id id update * id"单实体 update" id update
* undefined add / remove / update * undefined add / remove / update
*/ */
targetId?: Id; targetId?: Id;
/** 目标节点的可读名(取最后一步的 newNode.name/type/id */ /** 目标可读名(取最后一步快照的 name/type/id */
targetName?: string; targetName?: string;
/** 组内所有步骤,按时间正序 */ /** 组内所有步骤,按时间正序 */
steps: PageHistoryStepEntry[]; steps: { step: T; index: number; applied: boolean; isCurrent?: boolean }[];
/** 组内最后一步是否已应用 */ /** 组内最后一步是否已应用 */
applied: boolean; applied: boolean;
/** 是否为当前所在的分组(包含栈中最近一次已应用步骤的那一组)。 */ /** 是否为当前所在的分组(包含栈中最近一次已应用步骤的那一组)。 */
isCurrent?: boolean; isCurrent?: boolean;
} }
/**
*
* - codeBlockId 'update' group
* - 'add' / 'remove'
*/
export interface CodeBlockHistoryGroup {
kind: 'code-block';
/** 关联的 codeBlock id */
id: Id;
/** 该分组的操作类型 */
opType: HistoryOpType;
/** 组内所有步骤,按时间正序 */
steps: { step: CodeBlockStepValue; index: number; applied: boolean; isCurrent?: boolean }[];
/** 组内最后一步是否已应用,用于整组的状态展示 */
applied: boolean;
/** 是否为当前所在的分组(包含该栈最近一次已应用步骤的那一组)。 */
isCurrent?: boolean;
}
/**
* CodeBlockHistoryGroup
*/
export interface DataSourceHistoryGroup {
kind: 'data-source';
id: Id;
opType: HistoryOpType;
steps: { step: DataSourceStepValue; index: number; applied: boolean; isCurrent?: boolean }[];
applied: boolean;
/** 是否为当前所在的分组(包含该栈最近一次已应用步骤的那一组)。 */
isCurrent?: boolean;
}
// #endregion HistoryListEntry // #endregion HistoryListEntry
export enum KeyBindingCommand { export enum KeyBindingCommand {
@ -1074,6 +1178,8 @@ export type SyncHookPlugin<
export interface EventBusEvent { export interface EventBusEvent {
'edit-data-source': [id: string]; 'edit-data-source': [id: string];
'edit-data-source-method': [id: string, methodName: string];
'edit-data-source-field': [id: string, fieldPath: string[]];
'remove-data-source': [id: string]; 'remove-data-source': [id: string];
'edit-code': [id: string]; 'edit-code': [id: string];
} }
@ -1107,7 +1213,11 @@ export type CustomContentMenuFunction = (
) => (MenuButton | MenuComponent)[]; ) => (MenuButton | MenuComponent)[];
export interface EditorEvents { export interface EditorEvents {
'root-change': [value: StoreState['root'], preValue?: StoreState['root']]; 'root-change': [
value: StoreState['root'],
preValue?: StoreState['root'],
options?: { historySource?: HistoryOpSource },
];
select: [node: MNode | null]; select: [node: MNode | null];
add: [nodes: MNode[]]; add: [nodes: MNode[]];
remove: [nodes: MNode[]]; remove: [nodes: MNode[]];
@ -1115,6 +1225,54 @@ export interface EditorEvents {
'move-layer': [offset: number | LayerOffset]; 'move-layer': [offset: number | LayerOffset];
'drag-to': [data: { targetIndex: number; configs: MNode | MNode[]; targetParent: MContainer }]; 'drag-to': [data: { targetIndex: number; configs: MNode | MNode[]; targetParent: MContainer }];
'history-change': [data: MPage | MPageFragment]; 'history-change': [data: MPage | MPageFragment];
/**
* DSL add / remove / update / move-layer / drag-to
* {@link EditorChangeEvent} `type` payload
* page null /
* add / remove / update `history-change`
*/
change: [event: EditorChangeEvent];
}
// #region EditorChangeEvent
/** `change` 事件中单个变更项:变更的 node 及其所属的 page可能为 null。 */
export interface EditorChangeItem {
node: MNode;
page: StoreState['page'];
}
/** `update` 类型变更项node 为前后快照及 form 端变更记录page 为其所属页面。 */
export interface EditorUpdateChangeItem {
node: { newNode: MNode; oldNode: MNode; changeRecords?: ChangeRecord[] };
page: StoreState['page'];
}
/**
* {@link EditorEvents.change} `type`
* `data` node page null
* `move-layer` `offset``drag-to` `targetIndex` / `targetParent`
*/
export type EditorChangeEvent =
| { type: 'add'; data: EditorChangeItem[] }
| { type: 'remove'; data: EditorChangeItem[] }
| { type: 'update'; data: EditorUpdateChangeItem[] }
| { type: 'move-layer'; data: EditorChangeItem[]; offset: number | LayerOffset }
| { type: 'drag-to'; data: EditorChangeItem[]; targetIndex: number; targetParent: MContainer };
// #endregion EditorChangeEvent
export interface HistoryEvents {
change: [
state: BaseStepValue | StepValue | CodeBlockStepValue | DataSourceStepValue,
stepType: HistoryStepType,
id: Id,
];
'code-block-history-change': [id: Id, state: CodeBlockStepValue];
'data-source-history-change': [id: Id, state: DataSourceStepValue];
'restore-from-indexed-db': [snapshot: PersistedHistoryState | null];
'save-to-indexed-db': [snapshot: PersistedHistoryState];
'mark-saved': [{ kind: HistoryStepType; id?: Id }];
clear: [{ id: Id; stepType: HistoryStepType }];
'marker-change': [{ id: Id; marker: StepValue; stepType: HistoryStepType }];
} }
export const canUsePluginMethods = { export const canUsePluginMethods = {
@ -1201,4 +1359,121 @@ export interface DiffDialogPayload {
targetLabel?: string; targetLabel?: string;
/** 用于标题展示的目标 id */ /** 用于标题展示的目标 id */
id?: string | number; id?: string | number;
/**
*
* - before vs
* - current vs
* /
* 退
*/
mode?: 'before' | 'current';
}
/**
* + / /
* describe* / isStep* props
*/
export interface HistoryRowDescriptor<T extends BaseStepValue = BaseStepValue> {
/**
* group
* 退 {@link describeStep}/
*/
describeGroup?: (_group: any) => string;
/** 单步描述文案生成器,接收一个 step返回展示文本合并组展开后的子步列表用。 */
describeStep: (_step: T) => string;
/** 判断某个 step 是否可查看差异(前后值都存在)。不传则一律不展示差异入口。 */
isStepDiffable?: (_step: T) => boolean;
/** 判断某个 step 是否支持回滚(如更新需带 changeRecords。不传则已应用即可回滚。 */
isStepRevertable?: (_step: T) => boolean;
}
/**
* bucket / /
* Bucket / BucketTab title / prefix / describe* / isStep* / showInitial / gotoEnabled
* prop
*/
export interface HistoryBucketConfig<T extends BaseStepValue = BaseStepValue> extends HistoryRowDescriptor<T> {
/** bucket 头部标题,例如 "数据源" / "代码块"。 */
title: string;
/** 子项 key 的命名空间前缀(`ds` 数据源 / `cb` 代码块 / 业务自定义如 `mod`)。 */
prefix: string;
/** 是否展示底部「回到初始状态」入口,默认 true。无 undo cursor 语义的自定义历史可传 false。 */
showInitial?: boolean;
/** 是否支持「跳转到该记录」(goto),默认 true。 */
gotoEnabled?: boolean;
/** 是否展示顶部「清空」按钮,默认 true。无需提供清空能力的自定义历史可传 false。 */
showClear?: boolean;
}
export interface UseHistoryRevertOptions {
/**
* / / provide /
* Element Plus@tmagic/form appContext
* `getCurrentInstance()?.appContext` setup `editorApp._context`
*/
appContext?: AppContext | null;
/**
* `extendState` Editor `extendFormState`
* 使 `display` / `disabled` filterFunction
*/
extendState?: (_state: FormState) => Record<string, any> | Promise<Record<string, any>>;
/**
* PropsPanel FormPanel -> MForm formState
* / 使 formState CompareForm
* filterFunction
*/
getPropsPanelFormState?: () => FormState | undefined;
/**
* / / / TMagicDialog `width`
* `'1200px'` / `'80%'`使900px
* `viewDiff` / `confirmAndRevert` `width`
*/
dialogWidth?: string;
}
/**
*
* / / `category`
* `loadConfig`
*/
export interface CustomDiffFormOptions {
/**
* c_type
* CompareForm `category`
*/
loadConfig?: CompareFormLoadConfig;
/** 需要走 self diff 的字段类型(如模块的 mod-cond。 */
selfDiffFieldTypes?: string[];
/**
* formState PropsPanel
* CompareForm
*/
compareFormState?: FormState;
/**
* / HistoryDiffDialog TMagicDialog `width`
* `'1200px'` / `'80%'`使900px
*/
width?: string;
/**
* / form CompareForm `size`
* `'large' | 'default' | 'small'`使
*/
size?: FieldSize;
}
/**
* {@link useHistoryRevert} `confirmAndRevert`
* / / /
*
*/
export interface ConfirmAndRevertOptions<T = unknown> extends CustomDiffFormOptions {
/**
* update
* null / 退add / remove /
*/
diffPayload?: DiffDialogPayload | null;
/** 回滚前置校验:返回 true 表示目标数据已删除等不可回滚,给出统一提示并中止。 */
isTargetMissing?: () => boolean;
/** 用户确认后执行的实际回滚逻辑。 */
revert: () => T | Promise<T>;
} }

View File

@ -10,6 +10,7 @@ const dataSourceFormConfig: TabConfig = {
items: [ items: [
{ {
title: '数据定义', title: '数据定义',
status: 'fields',
items: [ items: [
{ {
name: 'fields', name: 'fields',
@ -20,6 +21,7 @@ const dataSourceFormConfig: TabConfig = {
}, },
{ {
title: '方法定义', title: '方法定义',
status: 'methods',
items: [ items: [
{ {
name: 'methods', name: 'methods',

View File

@ -35,7 +35,7 @@ import {
isValueIncludeDataSource, isValueIncludeDataSource,
} from '@tmagic/utils'; } from '@tmagic/utils';
import type { EditorNodeInfo } from '@editor/type'; import type { EditorNodeInfo, StepValue } from '@editor/type';
import { LayerOffset, Layout } from '@editor/type'; import { LayerOffset, Layout } from '@editor/type';
export const COPY_STORAGE_KEY = '$MagicEditorCopyData'; export const COPY_STORAGE_KEY = '$MagicEditorCopyData';
@ -132,6 +132,34 @@ const getMiddleTop = (node: MNode, parentNode: MNode, stage: StageCore | null) =
return (Math.min(parentHeight, wrapperHeightDeal) - height) / 2; return (Math.min(parentHeight, wrapperHeightDeal) - height) / 2;
}; };
// 同时存在一对相反的定位属性(如 left/right时只保留一个避免元素被拉伸
const removeConflictPosition = (style: Record<string, any>, primary: string, secondary: string) => {
// '' / 0 / undefined / null 视为无效值
const isInvalid = (value: any) => value === '' || value === 0 || value === undefined || value === null;
if (!(primary in style) || !(secondary in style)) {
return;
}
const primaryValue = style[primary];
const secondaryValue = style[secondary];
const primaryInvalid = isInvalid(primaryValue);
const secondaryInvalid = isInvalid(secondaryValue);
if (primaryInvalid && !secondaryInvalid) {
delete style[primary];
} else if (secondaryInvalid && !primaryInvalid) {
delete style[secondary];
} else if (primaryInvalid && secondaryInvalid) {
// 都是无效值时优先保留 0否则保留 primaryleft/top
if (secondaryValue === 0 && primaryValue !== 0) {
delete style[primary];
} else {
delete style[secondary];
}
}
};
export const getInitPositionStyle = (style: Record<string, any> = {}, layout: Layout) => { export const getInitPositionStyle = (style: Record<string, any> = {}, layout: Layout) => {
if (layout === Layout.ABSOLUTE) { if (layout === Layout.ABSOLUTE) {
const newStyle: Record<string, any> = { const newStyle: Record<string, any> = {
@ -139,6 +167,9 @@ export const getInitPositionStyle = (style: Record<string, any> = {}, layout: La
position: 'absolute', position: 'absolute',
}; };
removeConflictPosition(newStyle, 'left', 'right');
removeConflictPosition(newStyle, 'top', 'bottom');
if (typeof newStyle.left === 'undefined' && typeof newStyle.right === 'undefined') { if (typeof newStyle.left === 'undefined' && typeof newStyle.right === 'undefined') {
newStyle.left = 0; newStyle.left = 0;
} }
@ -178,78 +209,98 @@ export const setLayout = (node: MNode, layout: Layout) => {
return node; return node;
}; };
export const change2Fixed = (node: MNode, root: MApp) => { type PositionKey = 'left' | 'top' | 'right' | 'bottom';
// 判断 style 是否显式设置了某个定位属性,使用 typeof 判断以避免将 0如 right: 0误判为未设置
const hasPositionValue = (style: Record<string, any> | undefined, key: PositionKey): boolean =>
typeof style?.[key] !== 'undefined' && style?.[key] !== '';
/**
* 沿
* 使 left right
* 0
* @param path
* @param dir
* @param opposite
* @param sign change2Fixed 1Fixed2Other -1
* @param init
*/
const accumulatePositionOffset = (
path: MNode[],
dir: PositionKey,
opposite: PositionKey,
sign: 1 | -1,
init = 0,
): number => {
let offset = init;
for (const value of path) {
if (hasPositionValue(value.style, opposite) || !isNumber(value.style?.[dir] || 0)) {
return 0;
}
offset = offset + sign * Number(value.style?.[dir] || 0);
}
return offset;
};
export const change2Fixed = (node: MNode, path: MNode[]) => {
const style = { const style = {
...(node.style || {}), ...(node.style || {}),
}; };
const path = getNodePath(node.id, root.items); // 水平方向:以 left 锚定则累加祖先 left以 right 锚定则累加祖先 right
const offset = { if (!hasPositionValue(node.style, 'right') && isNumber(node.style?.left || 0)) {
left: 0, const left = accumulatePositionOffset(path, 'left', 'right', 1);
top: 0, if (left) {
}; style.left = left;
if (!node.style?.right && isNumber(node.style?.left || 0)) {
for (const value of path) {
if (value.style?.right || !isNumber(value.style?.left || 0)) {
offset.left = 0;
break;
} }
offset.left = offset.left + Number(value.style?.left || 0); } else if (hasPositionValue(node.style, 'right') && isNumber(node.style?.right || 0)) {
const right = accumulatePositionOffset(path, 'right', 'left', 1);
if (right) {
style.right = right;
} }
} }
if (!node.style?.bottom && isNumber(node.style?.top || 0)) { // 垂直方向:以 top 锚定则累加祖先 top以 bottom 锚定则累加祖先 bottom
for (const value of path) { if (!hasPositionValue(node.style, 'bottom') && isNumber(node.style?.top || 0)) {
if (value.style?.bottom || !isNumber(value.style?.top || 0)) { const top = accumulatePositionOffset(path, 'top', 'bottom', 1);
offset.top = 0; if (top) {
break; style.top = top;
} }
offset.top = offset.top + Number(value.style?.top || 0); } else if (hasPositionValue(node.style, 'bottom') && isNumber(node.style?.bottom || 0)) {
const bottom = accumulatePositionOffset(path, 'bottom', 'top', 1);
if (bottom) {
style.bottom = bottom;
} }
} }
if (offset.left) {
style.left = offset.left;
}
if (offset.top) {
style.top = offset.top;
}
return style; return style;
}; };
export const Fixed2Other = async ( export const Fixed2Other = async (
node: MNode, node: MNode,
root: MApp, path: MNode[],
getLayout: (parent: MNode, node?: MNode) => Promise<Layout>, getLayout: (parent: MNode, node?: MNode) => Promise<Layout>,
) => { ) => {
const path = getNodePath(node.id, root.items);
const cur = path.pop(); const cur = path.pop();
const offset = { const offset = {
left: cur?.style?.left || 0, left: 0,
top: cur?.style?.top || 0, top: 0,
right: 0,
bottom: 0,
}; };
if (!node.style?.right && isNumber(node.style?.left || 0)) { // 水平方向:抵消祖先在对应锚定方向上的偏移量,初始值为节点自身的偏移
for (const value of path) { if (!hasPositionValue(node.style, 'right') && isNumber(node.style?.left || 0)) {
if (value.style?.right || !isNumber(value.style?.left || 0)) { offset.left = accumulatePositionOffset(path, 'left', 'right', -1, Number(cur?.style?.left || 0));
offset.left = 0; } else if (hasPositionValue(node.style, 'right') && isNumber(node.style?.right || 0)) {
break; offset.right = accumulatePositionOffset(path, 'right', 'left', -1, Number(cur?.style?.right || 0));
}
offset.left = offset.left - Number(value.style?.left || 0);
}
} }
if (!node.style?.bottom && isNumber(node.style?.top || 0)) { // 垂直方向:同上
for (const value of path) { if (!hasPositionValue(node.style, 'bottom') && isNumber(node.style?.top || 0)) {
if (value.style?.bottom || !isNumber(value.style?.top || 0)) { offset.top = accumulatePositionOffset(path, 'top', 'bottom', -1, Number(cur?.style?.top || 0));
offset.top = 0; } else if (hasPositionValue(node.style, 'bottom') && isNumber(node.style?.bottom || 0)) {
break; offset.bottom = accumulatePositionOffset(path, 'bottom', 'top', -1, Number(cur?.style?.bottom || 0));
}
offset.top = offset.top - Number(value.style?.top || 0);
}
} }
const style = node.style || {}; const style = node.style || {};
@ -269,6 +320,14 @@ export const Fixed2Other = async (
style.top = offset.top; style.top = offset.top;
} }
if (offset.right) {
style.right = offset.right;
}
if (offset.bottom) {
style.bottom = offset.bottom;
}
return { return {
...style, ...style,
position: 'absolute', position: 'absolute',
@ -316,11 +375,18 @@ export const fixNodePosition = (config: MNode, parent: MContainer, stage: StageC
return config.style; return config.style;
} }
return { const style = { ...(config.style || {}) };
...(config.style || {}), const baseStyle = config.style || {};
top: getMiddleTop(config, parent, stage),
left: fixNodeLeft(config, parent, stage?.renderer?.contentWindow?.document), if (!('right' in baseStyle)) {
}; style.left = fixNodeLeft(config, parent, stage?.renderer?.contentWindow?.document);
}
if (!('top' in baseStyle) && !('bottom' in baseStyle)) {
style.top = getMiddleTop(config, parent, stage);
}
return style;
}; };
// 序列化配置 // 序列化配置
@ -460,11 +526,11 @@ export const resolveSelectedNode = (
throw new Error('没有ID无法选中'); throw new Error('没有ID无法选中');
} }
const { node, parent, page } = getNodeInfoFn(id); const { node, parent, page, path } = getNodeInfoFn(id);
if (!node) throw new Error('获取不到组件信息'); if (!node) throw new Error('获取不到组件信息');
if (node.id === rootId) throw new Error('不能选根节点'); if (node.id === rootId) throw new Error('不能选根节点');
return { node, parent, page }; return { node, parent, page, path };
}; };
/** /**
@ -479,16 +545,16 @@ export const resolveSelectedNode = (
export const toggleFixedPosition = async ( export const toggleFixedPosition = async (
dist: MNode, dist: MNode,
src: MNode, src: MNode,
root: MApp, path: MNode[],
getLayoutFn: (parent: MNode, node?: MNode | null) => Promise<Layout>, getLayoutFn: (parent: MNode, node?: MNode | null) => Promise<Layout>,
): Promise<MNode> => { ): Promise<MNode> => {
const newConfig = cloneDeep(dist); const newConfig = cloneDeep(dist);
if (!isPop(src) && newConfig.style?.position) { if (!isPop(src) && newConfig.style?.position) {
if (isFixed(newConfig.style) && !isFixed(src.style || {})) { if (isFixed(newConfig.style) && !isFixed(src.style || {})) {
newConfig.style = change2Fixed(newConfig, root); newConfig.style = change2Fixed(newConfig, path);
} else if (!isFixed(newConfig.style) && isFixed(src.style || {})) { } else if (!isFixed(newConfig.style) && isFixed(src.style || {})) {
newConfig.style = await Fixed2Other(newConfig, root, getLayoutFn); newConfig.style = await Fixed2Other(newConfig, path, getLayoutFn);
} }
} }
@ -684,3 +750,43 @@ export const classifyDragSources = (
return { sameParentIndices, crossParentConfigs, aborted: false }; return { sameParentIndices, crossParentConfigs, aborted: false };
}; };
/**
* step
* UI `describePageStep` service layouts/
*/
export const describeStepForRevert = (step: StepValue): string => {
const items = step.diff ?? [];
// 在可读名后拼接组件 id便于在历史面板中精确定位被回滚的组件id 缺失时退化为仅展示名称。
const withId = (node: MNode | undefined, label: string): string => {
const id = node?.id;
if (id === undefined || id === null || `${id}` === '') return label;
return label ? `${label}id: ${id}` : `id: ${id}`;
};
switch (step.opType) {
case 'add': {
const count = items.length;
const node = items[0]?.newSchema;
const label = node?.name || node?.type || '';
return `撤回新增 ${count} 个节点${count === 1 ? `${withId(node, label)}` : ''}`;
}
case 'remove': {
const count = items.length;
const node = items[0]?.oldSchema;
const label = node?.name || node?.type || '';
return `还原已删除的 ${count} 个节点${count === 1 ? `${withId(node, label)}` : ''}`;
}
case 'update':
default: {
if (items.length === 1) {
const { newSchema, oldSchema, changeRecords } = items[0];
const node = newSchema || oldSchema;
const label = newSchema?.name || newSchema?.type || oldSchema?.name || oldSchema?.type || '';
const target = withId(node, label);
const propPath = changeRecords?.[0]?.propPath;
return propPath ? `还原 ${target} · ${propPath}` : `还原 ${target}`;
}
return `还原 ${items.length} 个节点的修改`;
}
}
};

View File

@ -0,0 +1,286 @@
/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2025 Tencent. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { cloneDeep } from 'lodash-es';
import serialize from 'serialize-javascript';
import type { Id } from '@tmagic/core';
import type { ChangeRecord } from '@tmagic/form';
import { guid } from '@tmagic/utils';
import type { BaseStepValue, HistoryGroup, StepDiffItem } from '@editor/type';
import { UndoRedo } from './undo-redo';
/**
* step /
* `name` `title`
* `getLabel`
*
* @param id / id
* @param diff diff
* @param getLabel
*/
export const describeRevertStep = <T extends object>(
id: Id,
{ oldSchema, newSchema, changeRecords }: StepDiffItem<T> = {},
getLabel: (schema: T) => string | undefined,
): string => {
const labelOf = (schema: T) => getLabel(schema) || (schema as { id?: Id }).id;
if (!oldSchema && newSchema) return `撤回新增 ${labelOf(newSchema) || id}`;
if (oldSchema && !newSchema) return `还原已删除的 ${labelOf(oldSchema) || id}`;
const label = (newSchema && getLabel(newSchema)) || (oldSchema && getLabel(oldSchema)) || `${id}`;
const propPath = changeRecords?.[0]?.propPath;
return propPath ? `还原 ${label} · ${propPath}` : `还原 ${label}`;
};
/**
* old/new null opType push
*/
export const detectStackOpType = (oldVal: unknown, newVal: unknown): 'add' | 'remove' | 'update' => {
if (oldVal === null && newVal !== null) return 'add';
if (oldVal !== null && newVal === null) return 'remove';
return 'update';
};
/**
* / id payload
*
* - `add`oldValue = null`remove`newValue = null`update` changeRecords
* - cloneDeep opType old/new null
* - step emit history.push(stepType, step, id)
* - service
*/
export const createStackStep = <T, S extends BaseStepValue<T>>(
id: Id,
// payload 以 {@link BaseStepValue} 为基础透传字段historyDescription / source / operator / rootStep / extra
// 随 BaseStepValue 演进自动同步,原样写入 step自动生成字段uuid / data / opType / diff / timestamp / saved
// 从 payload 中排除由本方法内部构造。oldValue / newValue / changeRecords / name 为构造 diff 与 data 用的输入。
payload: Omit<BaseStepValue<T>, 'uuid' | 'data' | 'opType' | 'diff' | 'timestamp' | 'saved'> & {
oldValue: T | null;
newValue: T | null;
changeRecords?: ChangeRecord[];
/** 展示名(缺省时从快照 name / title 推断)。 */
name?: string;
},
): S | null => {
if (!id) return null;
const oldSchema = payload.oldValue ? cloneDeep(payload.oldValue) : null;
const newSchema = payload.newValue ? cloneDeep(payload.newValue) : null;
const changeRecords = payload.changeRecords?.length ? cloneDeep(payload.changeRecords) : undefined;
const opType = detectStackOpType(payload.oldValue, payload.newValue);
// 展示名:代码块取 name数据源取 title取不到则留空不影响 undo/redo仅用于展示
const schema = (payload.newValue ?? payload.oldValue) as { name?: string; title?: string } | null;
const name = payload.name ?? schema?.name ?? schema?.title ?? '';
const step: BaseStepValue<T> = {
uuid: guid(),
data: { name, id },
opType,
diff: [
{
...(newSchema !== null ? { newSchema } : {}),
...(oldSchema !== null ? { oldSchema } : {}),
...(opType === 'update' && changeRecords ? { changeRecords } : {}),
},
],
historyDescription: payload.historyDescription,
source: payload.source,
operator: payload.operator,
rootStep: payload.rootStep,
timestamp: Date.now(),
extra: payload.extra,
};
return step as S;
};
export const markStackSaved = <S extends { saved?: boolean }>(undoRedo?: UndoRedo<S>): void => {
if (!undoRedo) return;
undoRedo.updateElements((element) => {
element.saved = false;
});
undoRedo.updateCurrentElement((element) => {
element.saved = true;
});
};
/**
* / / / "目标"
* - 'update' targetId targetId update group
* - 'add' / 'remove'
* - 'update'
*
* `kind` step
*/
export const mergeSteps = <T extends BaseStepValue>(
kind: string,
id: Id,
list: T[],
cursor: number,
): HistoryGroup<T>[] => {
const groups: HistoryGroup<T>[] = [];
let current: HistoryGroup<T> | null = null;
const currentIndex = cursor - 1;
list.forEach((step, index) => {
const applied = index < cursor;
const isCurrent = index === currentIndex;
const targetId = detectTargetId(step);
const targetName = detectTargetName(step);
const entry = { step, index, applied, isCurrent };
// 仅"单实体 update"参与合并其它情形add/remove/多实体 update始终独立成组。
const mergeable = step.opType === 'update' && targetId !== undefined;
if (mergeable && current?.opType === 'update' && current.targetId === targetId) {
current.steps.push(entry);
current.applied = applied;
if (isCurrent) current.isCurrent = true;
// 保持目标名为最近一次的(重命名时也能反映)
if (targetName) current.targetName = targetName;
} else {
current = {
kind,
id,
opType: step.opType,
targetId: mergeable ? targetId : undefined,
targetName,
steps: [entry],
applied,
isCurrent,
};
groups.push(current);
}
});
return groups;
};
/**
* step "目标 id"
* - update diff id id CodeBlockContent退 `step.data.id`
* id使 / update
* - update / add / remove undefined
*/
export const detectTargetId = (step: BaseStepValue): Id | undefined => {
if (step.opType !== 'update') return undefined;
const items = step.diff;
if (items?.length !== 1) return undefined;
const newSchema = items[0].newSchema as { id?: Id } | undefined;
const oldSchema = items[0].oldSchema as { id?: Id } | undefined;
return newSchema?.id ?? oldSchema?.id ?? step.data?.id;
};
/** 解析 step 中的目标可读名(用于 UI 展示)。 */
export const detectTargetName = (step: BaseStepValue): string | undefined => {
const items = step.diff;
if (step.opType === 'update') {
if (items?.length === 1) {
const node = (items[0].newSchema || items[0].oldSchema) as { name?: string; type?: string; id?: Id } | undefined;
return (node?.name as string) || (node?.type as string) || (node?.id !== undefined ? `${node.id}` : undefined);
}
return items?.length ? `${items.length} 个节点` : undefined;
}
if (step.opType === 'add') {
if (items?.length === 1) {
const n = items[0].newSchema as { name?: string; type?: string; id?: Id } | undefined;
return (n?.name as string) || (n?.type as string) || `${n?.id}`;
}
return items?.length ? `${items.length} 个节点` : undefined;
}
if (step.opType === 'remove') {
if (items?.length === 1) {
const n = items[0].oldSchema as { name?: string; type?: string; id?: Id } | undefined;
return (n?.name as string) || (n?.type as string) || `${n?.id}`;
}
return items?.length ? `${items.length} 个节点` : undefined;
}
return undefined;
};
/**
* `Record<Id, UndoRedo>` `Record<Id, SerializedUndoRedo>`
*
* step `diff` serialize-javascript
* uuid / opType / timestamp / `modifiedNodeIds` Map IndexedDB
* {@link parseStacksStepDiff} diff
* `diff`
*/
export const serializeStacks = <T extends { diff?: unknown }>(stacks: Record<Id, UndoRedo<T>>) => {
const result: Record<Id, ReturnType<UndoRedo<T>['serialize']>> = {};
Object.entries(stacks).forEach(([id, undoRedo]) => {
if (!undoRedo) return;
const serialized = undoRedo.serialize();
result[id] = {
...serialized,
elementList: serialized.elementList.map((step) =>
step.diff === undefined ? step : Object.assign({}, step, { diff: serialize(step.diff) }),
),
};
});
return result;
};
/**
* `Record<Id, SerializedUndoRedo>` `Record<Id, UndoRedo>`
* `saved === true`
*
* {@link serializeStacks} `parse`parseDSL step `diff`
* `diff`
*/
export const deserializeStacks = <T extends { saved?: boolean }>(
stacks: Record<Id, ReturnType<UndoRedo<T>['serialize']>> = {},
parse?: (serialized: string) => unknown,
): Record<Id, UndoRedo<T>> => {
const result: Record<Id, UndoRedo<T>> = {};
Object.entries(stacks).forEach(([id, serialized]) => {
if (!serialized) return;
const elementList = parse
? serialized.elementList.map((step) => {
const { diff } = step as { diff?: unknown };
return typeof diff === 'string' ? Object.assign({}, step, { diff: parse(`(${diff})`) }) : step;
})
: serialized.elementList;
result[id] = UndoRedo.fromSerialized<T>(
{ ...serialized, elementList },
{ isSavedStep: (element) => element.saved === true },
);
});
return result;
};
/**
* id id / UndoRedo
*/
export const getOrCreateStack = <T>(stacks: Record<Id, UndoRedo<T>>, id: Id): UndoRedo<T> => {
if (!stacks[id]) {
stacks[id] = new UndoRedo<T>();
}
return stacks[id];
};
/**
* index 0 `opType: 'initial'` 线 step 1线 0
* page / codeBlock / dataSource / cursor 线
* undo / canUndo / goto 线
*/
export const undoFloor = (undoRedo: UndoRedo<any>): number => {
return undoRedo.getElementList()[0]?.opType === 'initial' ? 1 : 0;
};
/** 将单次 push 产生的 history uuid或 null转为 *AndGetHistoryId 返回用的 uuid 列表。 */
export const getLastPushedHistoryIds = (historyId: string | null): string[] => (historyId ? [historyId] : []);

View File

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

View File

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

View File

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

View File

@ -0,0 +1,237 @@
/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2025 Tencent.
*/
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { defineComponent, h, nextTick, ref } from 'vue';
import { mount } from '@vue/test-utils';
import { HookType } from '@tmagic/core';
import CompareForm from '@editor/components/CompareForm.vue';
const propsService = {
getPropsConfig: vi.fn(async () => [
{
type: 'tab',
items: [{ title: '样式', items: [{ type: 'text', name: 'color', display: false }] }],
},
{ type: 'text', name: 'name' },
]),
};
const dataSourceService = {
getFormConfig: vi.fn(() => [{ type: 'text', name: 'title' }]),
};
const codeBlockService = {
getParamsColConfig: vi.fn(() => null),
};
const editorService = {
get: vi.fn(() => ({ select: vi.fn() })),
};
const services = {
propsService,
dataSourceService,
codeBlockService,
editorService,
} as any;
let capturedShowDiff: ((args: any) => boolean) | undefined;
let capturedFormProps: Record<string, any> = {};
vi.mock('@editor/utils/code-block', () => ({
getCodeBlockFormConfig: vi.fn(() => [{ type: 'text', name: 'content' }]),
}));
vi.mock('@tmagic/form', () => ({
MForm: defineComponent({
name: 'MForm',
props: ['config', 'initValues', 'lastValues', 'isCompare', 'disabled', 'labelWidth', 'extendState', 'showDiff'],
setup(props, { expose }) {
capturedShowDiff = props.showDiff as (args: any) => boolean;
capturedFormProps = props as Record<string, any>;
expose({ formState: {} });
return () => h('div', { class: 'fake-mform' });
},
}),
}));
beforeEach(() => {
vi.clearAllMocks();
capturedShowDiff = undefined;
capturedFormProps = {};
});
describe('CompareForm.vue', () => {
test('node 类别按 type 加载 props 配置并展示 MForm', async () => {
const wrapper = mount(CompareForm, {
props: {
category: 'node',
type: 'text',
value: { id: 'n1', name: 'new' },
lastValue: { id: 'n1', name: 'old' },
services,
},
global: {
provide: {
codeOptions: { theme: 'vs-dark' },
},
},
});
await nextTick();
await nextTick();
expect(propsService.getPropsConfig).toHaveBeenCalledWith('text', { node: { id: 'n1', name: 'new' } });
expect(wrapper.find('.fake-mform').exists()).toBe(true);
expect(capturedFormProps.initValues).toEqual({ id: 'n1', name: 'new' });
expect(capturedFormProps.lastValues).toEqual({ id: 'n1', name: 'old' });
});
test('node 类别缺少 type 时不渲染 MForm', async () => {
const wrapper = mount(CompareForm, {
props: {
category: 'node',
value: { id: 'n1' },
services,
},
});
await nextTick();
expect(wrapper.find('.fake-mform').exists()).toBe(false);
});
test('data-source 类别加载数据源表单配置', async () => {
mount(CompareForm, {
props: {
category: 'data-source',
type: 'http',
value: { id: 'ds_1', title: 'A' },
lastValue: { id: 'ds_1', title: 'B' },
services,
},
});
await nextTick();
await nextTick();
expect(dataSourceService.getFormConfig).toHaveBeenCalledWith('http');
});
test('code-block 类别会把 content 非字符串值 normalize 成字符串', async () => {
mount(CompareForm, {
props: {
category: 'code-block',
value: { id: 'cb_1', content: { toString: () => 'fn-body' } },
lastValue: { id: 'cb_1', content: '' },
services,
},
});
await nextTick();
await nextTick();
expect(capturedFormProps.initValues.content).toBe('fn-body');
expect(capturedFormProps.lastValues.content).toBe('');
});
test('传入 height 时外层容器启用内部滚动样式', () => {
const wrapper = mount(CompareForm, {
props: {
category: 'node',
type: 'text',
value: { id: 'n1' },
height: '400px',
services,
},
});
const style = wrapper.find('.m-editor-compare-form-wrapper').attributes('style') || '';
expect(style).toContain('height: 400px');
expect(style).toContain('overflow: auto');
});
test('自定义 loadConfig 可接管配置加载', async () => {
const loadConfig = vi.fn(async ({ defaultLoadConfig }) => {
await defaultLoadConfig();
return [{ type: 'text', name: 'custom' }];
});
const wrapper = mount(CompareForm, {
props: {
category: 'node',
type: 'text',
value: { id: 'n1' },
loadConfig,
services,
},
});
await nextTick();
await nextTick();
await nextTick();
expect(loadConfig).toHaveBeenCalled();
expect((wrapper.vm as any).config).toEqual([{ type: 'text', name: 'custom' }]);
});
test('showDiff 对 code-select 的空形态视为相等', async () => {
mount(CompareForm, {
props: {
category: 'node',
type: 'text',
value: { id: 'n1' },
services,
},
});
await nextTick();
await nextTick();
expect(capturedShowDiff).toBeTypeOf('function');
expect(
capturedShowDiff!({
curValue: '',
lastValue: { hookType: HookType.CODE, hookData: [] },
config: { type: 'code-select' },
}),
).toBe(false);
expect(
capturedShowDiff!({
curValue: { hookType: HookType.CODE, hookData: [] },
lastValue: '',
config: { type: 'code-select' },
}),
).toBe(false);
expect(
capturedShowDiff!({
curValue: 'a',
lastValue: 'b',
config: { type: 'code-select' },
}),
).toBe(true);
});
test('reload 暴露方法会重新加载配置', async () => {
const wrapper = mount(CompareForm, {
props: {
category: 'data-source',
type: 'base',
value: { id: 'ds_1' },
services,
},
});
await nextTick();
await nextTick();
dataSourceService.getFormConfig.mockClear();
await (wrapper.vm as any).reload();
expect(dataSourceService.getFormConfig).toHaveBeenCalled();
});
test('watchEffect 会把 stage / services 写入 MForm.formState', async () => {
const stage = ref({ select: vi.fn() });
editorService.get.mockReturnValue(stage.value);
mount(CompareForm, {
props: {
category: 'node',
type: 'text',
value: { id: 'n1' },
services,
},
});
await nextTick();
await nextTick();
stage.value = { select: vi.fn() };
await nextTick();
expect(editorService.get).toHaveBeenCalledWith('stage');
});
});

View File

@ -9,10 +9,13 @@ import { mount } from '@vue/test-utils';
import FloatingBox from '@editor/components/FloatingBox.vue'; import FloatingBox from '@editor/components/FloatingBox.vue';
const moveableHandlers = new Map<string, (...args: any[]) => void>(); const moveableHandlers = new Map<string, ((...args: any[]) => void)[]>();
const destroyMock = vi.fn(); const destroyMock = vi.fn();
let lastInstance: any; let lastInstance: any;
const emitMoveable = (event: string, ...args: any[]) =>
(moveableHandlers.get(event) || []).forEach((fn) => fn(...args));
vi.mock('moveable', () => { vi.mock('moveable', () => {
class FakeMoveable { class FakeMoveable {
public target: any; public target: any;
@ -26,7 +29,9 @@ vi.mock('moveable', () => {
moveableHandlers.clear(); moveableHandlers.clear();
} }
public on(event: string, fn: (...args: any[]) => void) { public on(event: string, fn: (...args: any[]) => void) {
moveableHandlers.set(event, fn); const list = moveableHandlers.get(event) || [];
list.push(fn);
moveableHandlers.set(event, list);
return this; return this;
} }
public destroy() { public destroy() {
@ -64,6 +69,18 @@ const services = {
}, },
}; };
const rect200: DOMRect = {
x: 0,
y: 0,
width: 200,
height: 100,
top: 0,
left: 0,
right: 200,
bottom: 100,
toJSON: () => '',
};
describe('FloatingBox.vue', () => { describe('FloatingBox.vue', () => {
beforeEach(() => { beforeEach(() => {
moveableHandlers.clear(); moveableHandlers.clear();
@ -132,7 +149,7 @@ describe('FloatingBox.vue', () => {
}); });
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
const target = document.createElement('div'); const target = document.createElement('div');
moveableHandlers.get('resize')?.({ emitMoveable('resize', {
width: 200, width: 200,
height: 300, height: 300,
target, target,
@ -151,20 +168,67 @@ describe('FloatingBox.vue', () => {
}); });
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
const target = document.createElement('div'); const target = document.createElement('div');
moveableHandlers.get('drag')?.({ target, transform: 'translate(10px,20px)' }); emitMoveable('drag', { target, transform: 'translate(10px,20px)' });
expect(target.style.transform.replace(/\s+/g, '')).toBe('translate(10px,20px)'); expect(target.style.transform.replace(/\s+/g, '')).toBe('translate(10px,20px)');
}); });
test('left + width 超过 frameworkWidth 时 left 被收敛', async () => { test('left + width 超过 frameworkWidth 时 left 被收敛', async () => {
const wrapper = mount(FloatingBox as any, { const wrapper = mount(FloatingBox as any, {
...services, props: { visible: true, position: { left: 950, top: 0 }, width: 200, frameworkWidth: 1000 },
props: { visible: true, position: { left: 950, top: 0 }, width: 200 },
attachTo: document.body, attachTo: document.body,
}); });
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
const box = document.querySelector('.m-editor-float-box') as HTMLElement; const box = document.querySelector('.m-editor-float-box') as HTMLElement;
expect(box).not.toBeNull(); expect(box).not.toBeNull();
// jsdom 中 getBoundingClientRect 返回 0width 会被重置为 0故不触发收敛left 保持原值
expect(box.style.left).toBe('950px');
wrapper.unmount();
});
test('当实际宽度使 left + width 超过 frameworkWidth 时 left 收敛到右边界内', async () => {
const getBoundingClientRectSpy = vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(rect200);
const wrapper = mount(FloatingBox as any, {
props: { visible: true, position: { left: 950, top: 0 }, frameworkWidth: 1000 },
attachTo: document.body,
});
await new Promise((r) => setTimeout(r, 0));
await wrapper.vm.$nextTick();
const box = document.querySelector('.m-editor-float-box') as HTMLElement;
expect(box).not.toBeNull();
expect(box.style.left).toBe('800px');
getBoundingClientRectSpy.mockRestore();
wrapper.unmount();
});
test('未传入 frameworkWidth 时默认按视窗宽度收敛 left', async () => {
const getBoundingClientRectSpy = vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(rect200);
const innerWidthSpy = vi.spyOn(globalThis, 'window', 'get').mockReturnValue({ innerWidth: 300 } as any);
const wrapper = mount(FloatingBox as any, {
props: { visible: true, position: { left: 250, top: 0 } },
attachTo: document.body,
});
await new Promise((r) => setTimeout(r, 0));
await wrapper.vm.$nextTick();
const box = document.querySelector('.m-editor-float-box') as HTMLElement;
expect(box).not.toBeNull();
// 250 + 200 = 450 > 视窗宽度 300left 收敛为 300 - 200 = 100
expect(box.style.left).toBe('100px');
getBoundingClientRectSpy.mockRestore();
innerWidthSpy.mockRestore();
wrapper.unmount();
});
test('传入 initialStyle 时合并到浮窗样式', async () => {
const wrapper = mount(FloatingBox as any, {
props: { visible: true, initialStyle: { backgroundColor: 'rgb(255, 0, 0)' } },
attachTo: document.body,
});
await new Promise((r) => setTimeout(r, 0));
await wrapper.vm.$nextTick();
const box = document.querySelector('.m-editor-float-box') as HTMLElement;
expect(box).not.toBeNull();
expect(box.style.backgroundColor).toBe('rgb(255, 0, 0)');
wrapper.unmount(); wrapper.unmount();
}); });
@ -178,4 +242,26 @@ describe('FloatingBox.vue', () => {
wrapper.unmount(); wrapper.unmount();
expect(destroyMock).toHaveBeenCalled(); expect(destroyMock).toHaveBeenCalled();
}); });
test('dragStart 时不显示拖拽遮罩,仅在真正 drag 时显示dragEnd 时移除', async () => {
const wrapper = mount(FloatingBox as any, {
props: { visible: true },
attachTo: document.body,
});
await new Promise((r) => setTimeout(r, 0));
await wrapper.vm.$nextTick();
// mousedowndragStart不应立即盖遮罩否则会盖住关闭按钮导致点击失效
emitMoveable('dragStart');
expect(document.querySelector('.m-editor-float-box-drag-mask')).toBeNull();
// 真正发生位移时才显示遮罩
emitMoveable('drag', { target: document.body, transform: 'translate(0,0)' });
expect(document.querySelector('.m-editor-float-box-drag-mask')).not.toBeNull();
// 拖拽结束移除遮罩
emitMoveable('dragEnd');
expect(document.querySelector('.m-editor-float-box-drag-mask')).toBeNull();
wrapper.unmount();
});
}); });

View File

@ -17,6 +17,7 @@ const codeBlockService = {
}; };
const uiService = { const uiService = {
get: vi.fn(() => [{ $key: 'code-block' }]), get: vi.fn(() => [{ $key: 'code-block' }]),
set: vi.fn(),
}; };
vi.mock('@editor/hooks/use-services', () => ({ vi.mock('@editor/hooks/use-services', () => ({
@ -138,13 +139,14 @@ describe('CodeSelectCol', () => {
expect(((evts?.[0]?.[1] as any).changeRecords[0] as any).propPath).toContain('p1'); expect(((evts?.[0]?.[1] as any).changeRecords[0] as any).propPath).toContain('p1');
}); });
test('编辑按钮 emit edit-code', async () => { test('编辑按钮 emit edit-code 并切换到代码块 tab', async () => {
const eventBus = { emit: vi.fn() }; const eventBus = { emit: vi.fn() };
const wrapper = mount(CodeSelectCol, { const wrapper = mount(CodeSelectCol, {
props: baseProps() as any, props: baseProps() as any,
global: { provide: { eventBus } }, global: { provide: { eventBus } },
}); });
await wrapper.find('button').trigger('click'); await wrapper.find('button').trigger('click');
expect(uiService.set).toHaveBeenCalledWith('sideBarActiveTabName', 'code-block');
expect(eventBus.emit).toHaveBeenCalledWith('edit-code', 'c1'); expect(eventBus.emit).toHaveBeenCalledWith('edit-code', 'c1');
}); });

View File

@ -14,7 +14,7 @@ const { messageError } = vi.hoisted(() => ({ messageError: vi.fn() }));
const dataSourceService = { get: vi.fn() }; const dataSourceService = { get: vi.fn() };
const propsService = { getDisabledDataSource: vi.fn() }; const propsService = { getDisabledDataSource: vi.fn() };
const uiService = { get: vi.fn() }; const uiService = { get: vi.fn(), set: vi.fn() };
vi.mock('@editor/hooks/use-services', () => ({ vi.mock('@editor/hooks/use-services', () => ({
useServices: () => ({ dataSourceService, propsService, uiService }), useServices: () => ({ dataSourceService, propsService, uiService }),
@ -158,13 +158,25 @@ describe('FieldSelect', () => {
expect(wrapper.emitted('change')?.[0]?.[0]).toEqual(['ds1', 'a']); expect(wrapper.emitted('change')?.[0]?.[0]).toEqual(['ds1', 'a']);
}); });
test('editHandler emit edit-data-source 到 eventBus', () => { test('editHandler 无字段时 emit edit-data-source 并切换数据源 tab', async () => {
const eventBusEmit = vi.fn(); const eventBusEmit = vi.fn();
const wrapper = mount(FieldSelect, { const wrapper = mount(FieldSelect, {
props: { dataSourceId: 'ds1' } as any, props: { dataSourceId: 'ds1' } as any,
global: { provide: { eventBus: { emit: eventBusEmit, on: vi.fn() } } }, global: { provide: { eventBus: { emit: eventBusEmit, on: vi.fn() } } },
}); });
expect(wrapper).toBeTruthy(); await wrapper.find('.m-fields-select-action-button').trigger('click');
expect(uiService.set).toHaveBeenCalledWith('sideBarActiveTabName', 'data-source');
expect(eventBusEmit).toHaveBeenCalledWith('edit-data-source', 'ds1');
});
test('editHandler 有字段时 emit edit-data-source-field 带字段路径', async () => {
const eventBusEmit = vi.fn();
const wrapper = mount(FieldSelect, {
props: { dataSourceId: 'ds1', modelValue: ['a'] } as any,
global: { provide: { eventBus: { emit: eventBusEmit, on: vi.fn() } } },
});
await wrapper.find('.m-fields-select-action-button').trigger('click');
expect(eventBusEmit).toHaveBeenCalledWith('edit-data-source-field', 'ds1', ['a']);
}); });
}); });

View File

@ -69,6 +69,8 @@ vi.mock('@tmagic/design', async () => {
class: 'select-field-btn', class: 'select-field-btn',
onClick: () => emit('select', { value: 'a', type: 'field' }), onClick: () => emit('select', { value: 'a', type: 'field' }),
}), }),
// 模拟部分实现会以 undefined 触发默认插槽参数
slots.default?.(),
slots.suffix?.(), slots.suffix?.(),
]); ]);
}; };
@ -164,6 +166,10 @@ describe('DataSourceInput', () => {
expect(wrapper.find('.fake-autocomplete').exists()).toBe(true); expect(wrapper.find('.fake-autocomplete').exists()).toBe(true);
}); });
test('autocomplete 默认插槽参数为 undefined 时不应报错', () => {
expect(() => mountIt('text-value', true)).not.toThrow();
});
test('未 focus 时显示文本视图', () => { test('未 focus 时显示文本视图', () => {
const wrapper = mountIt('hello'); const wrapper = mountIt('hello');
expect(wrapper.find('.tmagic-data-source-input-text').exists()).toBe(true); expect(wrapper.find('.tmagic-data-source-input-text').exists()).toBe(true);

View File

@ -22,7 +22,7 @@ const dataSourceService = {
getFormMethod: vi.fn(() => []), getFormMethod: vi.fn(() => []),
}; };
const uiService = { get: vi.fn(() => [{ $key: 'data-source' }]) }; const uiService = { get: vi.fn(() => [{ $key: 'data-source' }]), set: vi.fn() };
vi.mock('@editor/hooks/use-services', () => ({ vi.mock('@editor/hooks/use-services', () => ({
useServices: () => ({ dataSourceService, uiService }), useServices: () => ({ dataSourceService, uiService }),
@ -158,7 +158,7 @@ describe('DataSourceMethodSelect', () => {
expect(evts).toBeTruthy(); expect(evts).toBeTruthy();
}); });
test('编辑按钮 emit edit-data-source', async () => { test('编辑按钮 emit edit-data-source-method 并切换到数据源 tab', async () => {
dataSourceService.getDataSourceById.mockReturnValue({ id: 'ds1', methods: [{ name: 'doFetch' }] }); dataSourceService.getDataSourceById.mockReturnValue({ id: 'ds1', methods: [{ name: 'doFetch' }] });
const eventBus = { emit: vi.fn() }; const eventBus = { emit: vi.fn() };
const wrapper = mount(DataSourceMethodSelect, { const wrapper = mount(DataSourceMethodSelect, {
@ -166,7 +166,8 @@ describe('DataSourceMethodSelect', () => {
global: { provide: { eventBus } }, global: { provide: { eventBus } },
}); });
await wrapper.find('button').trigger('click'); await wrapper.find('button').trigger('click');
expect(eventBus.emit).toHaveBeenCalledWith('edit-data-source', 'ds1'); expect(uiService.set).toHaveBeenCalledWith('sideBarActiveTabName', 'data-source');
expect(eventBus.emit).toHaveBeenCalledWith('edit-data-source-method', 'ds1', 'doFetch');
}); });
test('编辑按钮: 找不到 dataSource 时不触发', async () => { test('编辑按钮: 找不到 dataSource 时不触发', async () => {

View File

@ -179,7 +179,7 @@ describe('initServiceState', () => {
test('modelValue 变化设置 editor root', () => { test('modelValue 变化设置 editor root', () => {
const props = { modelValue: { id: 'a' } } as any; const props = { modelValue: { id: 'a' } } as any;
mount(Wrap(props, services)); mount(Wrap(props, services));
expect(services.editorService.set).toHaveBeenCalledWith('root', { id: 'a' }); expect(services.editorService.set).toHaveBeenCalledWith('root', { id: 'a' }, { historySource: 'initial' });
}); });
test('disabledMultiSelect/alwaysMultiSelect 设置', () => { test('disabledMultiSelect/alwaysMultiSelect 设置', () => {

View File

@ -145,7 +145,7 @@ describe('Framework', () => {
}); });
const wrapper = mount(Framework, { props: { disabledPageFragment: false } as any }); const wrapper = mount(Framework, { props: { disabledPageFragment: false } as any });
await wrapper.find('.fake-code-editor').trigger('click'); await wrapper.find('.fake-code-editor').trigger('click');
expect(editorService.set).toHaveBeenCalledWith('root', { id: 'x' }); expect(editorService.set).toHaveBeenCalledWith('root', { id: 'x' }, { historySource: 'root-code' });
}); });
test('SplitView change 写入 uiService 和 storage', async () => { test('SplitView change 写入 uiService 和 storage', async () => {

View File

@ -15,7 +15,7 @@ const editorService = {
undo: vi.fn(), undo: vi.fn(),
redo: vi.fn(), redo: vi.fn(),
}; };
const historyService = { state: { canUndo: true, canRedo: true } }; const historyService = { state: { steps: {} }, canUndo: vi.fn(() => true), canRedo: vi.fn(() => true) };
const uiService = { const uiService = {
get: vi.fn(), get: vi.fn(),
set: vi.fn(), set: vi.fn(),

View File

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

View File

@ -0,0 +1,84 @@
/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2025 Tencent.
*/
import { describe, expect, test } from 'vitest';
import { mount } from '@vue/test-utils';
import BucketTab from '@editor/layouts/history-list/BucketTab.vue';
import type { HistoryBucketConfig } from '@editor/type';
const buildConfig = (): HistoryBucketConfig<any> => ({
title: '数据源',
prefix: 'ds',
describeGroup: () => 'desc',
describeStep: () => 'sub-desc',
});
const buildGroup = (): any => ({
applied: true,
opType: 'update' as const,
steps: [{ index: 0, applied: true, step: { mark: 's-0' } }],
});
describe('BucketTab.vue', () => {
test('buckets 为空时显示空态', () => {
const wrapper = mount(BucketTab, {
props: {
config: buildConfig(),
buckets: [],
expanded: {},
},
});
expect(wrapper.find('.m-editor-history-list-empty').text()).toBe('暂无操作记录');
});
test('buckets 非空时渲染 toolbar 与 Bucket 列表', () => {
const wrapper = mount(BucketTab, {
props: {
config: buildConfig(),
buckets: [{ id: 'ds_1', groups: [buildGroup()] }],
expanded: {},
},
});
expect(wrapper.find('.m-editor-history-list-toolbar').exists()).toBe(true);
expect(wrapper.find('.m-editor-history-list-bucket-title').exists()).toBe(true);
});
test('点击清空按钮触发 clear 事件', async () => {
const wrapper = mount(BucketTab, {
props: {
config: buildConfig(),
buckets: [{ id: 'ds_1', groups: [buildGroup()] }],
expanded: {},
},
});
await wrapper.find('.m-editor-history-list-clear').trigger('click');
expect(wrapper.emitted('clear')).toBeTruthy();
});
test('config.showClear 为 false 时不渲染清空按钮', () => {
const wrapper = mount(BucketTab, {
props: {
config: { ...buildConfig(), showClear: false },
buckets: [{ id: 'ds_1', groups: [buildGroup()] }],
expanded: {},
},
});
expect(wrapper.find('.m-editor-history-list-toolbar').exists()).toBe(false);
expect(wrapper.find('.m-editor-history-list-clear').exists()).toBe(false);
});
test('透传 Bucket 子组件事件', async () => {
const wrapper = mount(BucketTab, {
props: {
config: buildConfig(),
buckets: [{ id: 'ds_1', groups: [buildGroup()] }],
expanded: {},
},
});
await wrapper.find('.m-editor-history-list-item-goto').trigger('click');
expect(wrapper.emitted('goto')?.[0]).toEqual(['ds_1', 0]);
});
});

View File

@ -8,8 +8,8 @@ import { defineComponent, h } from 'vue';
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import BucketTab from '@editor/layouts/history-list/BucketTab.vue'; import BucketTab from '@editor/layouts/history-list/BucketTab.vue';
import { describeCodeBlockGroup, describeCodeBlockStep } from '@editor/layouts/history-list/composables'; import { describeStep } from '@editor/layouts/history-list/composables';
import type { CodeBlockHistoryGroup, CodeBlockStepValue } from '@editor/type'; import type { CodeBlockStepValue, HistoryBucketConfig, StackHistoryGroup } from '@editor/type';
vi.mock('@tmagic/design', () => ({ vi.mock('@tmagic/design', () => ({
TMagicScrollbar: defineComponent({ TMagicScrollbar: defineComponent({
@ -21,29 +21,45 @@ vi.mock('@tmagic/design', () => ({
}), }),
})); }));
/** 把以 oldContent/newContent/changeRecords 描述的 fixture 归一成统一 diff 形态的 step。 */
const toDiffStep = (s: any, opType: 'add' | 'remove' | 'update') => ({
id: s.id,
opType,
diff: [
{
...(s.newContent !== null && s.newContent !== undefined ? { newSchema: s.newContent } : {}),
...(s.oldContent !== null && s.oldContent !== undefined ? { oldSchema: s.oldContent } : {}),
...(s.changeRecords ? { changeRecords: s.changeRecords } : {}),
},
],
});
const buildGroup = ( const buildGroup = (
id: string, id: string,
opType: 'add' | 'remove' | 'update', opType: 'add' | 'remove' | 'update',
steps: any[], steps: any[],
applied = true, applied = true,
startIndex = 0, startIndex = 0,
): CodeBlockHistoryGroup => ({ ): StackHistoryGroup<CodeBlockStepValue, 'code-block'> => ({
kind: 'code-block', kind: 'code-block',
id, id,
opType, opType,
applied, applied,
steps: steps.map((s, i) => ({ step: s, index: startIndex + i, applied })), steps: steps.map((s, i) => ({ step: toDiffStep(s, opType) as any, index: startIndex + i, applied })),
}); });
/** 代码块 tab 复用通用 BucketTab固定注入代码块的 title/prefix/describe/isStepDiffable。 */ /** 代码块 tab 复用通用 BucketTab固定注入代码块的 configtitle/prefix/describe/isStepDiffable。 */
const codeBlockConfig: HistoryBucketConfig<any> = {
title: '代码块',
prefix: 'cb',
describeStep: (step: CodeBlockStepValue): string => describeStep(step, (content) => content?.name, '代码块'),
isStepDiffable: (step: CodeBlockStepValue) => Boolean(step.diff?.[0]?.oldSchema && step.diff?.[0]?.newSchema),
};
const mountCodeBlockTab = (props: { buckets: any[]; expanded: Record<string, boolean> }) => const mountCodeBlockTab = (props: { buckets: any[]; expanded: Record<string, boolean> }) =>
mount(BucketTab, { mount(BucketTab, {
props: { props: {
title: '代码块', config: codeBlockConfig,
prefix: 'cb',
describeGroup: describeCodeBlockGroup,
describeStep: describeCodeBlockStep,
isStepDiffable: (step: CodeBlockStepValue) => Boolean(step.oldContent && step.newContent),
...props, ...props,
}, },
}); });
@ -68,7 +84,7 @@ describe('CodeBlockTab.vue', () => {
expect(wrapper.find('.m-editor-history-list-bucket-title code').text()).toBe('code_1'); expect(wrapper.find('.m-editor-history-list-bucket-title code').text()).toBe('code_1');
const desc = wrapper.find('.m-editor-history-list-item-desc').text(); const desc = wrapper.find('.m-editor-history-list-item-desc').text();
expect(desc).toBe('创建 fn (id: code_1)'); expect(desc).toBe('fn (id: code_1)');
}); });
test('toggle 透传key 形如 cb-${id}-${idx}', async () => { test('toggle 透传key 形如 cb-${id}-${idx}', async () => {
@ -163,7 +179,7 @@ describe('CodeBlockTab.vue', () => {
const items = wrapper.findAll('.m-editor-history-list-substeps li'); const items = wrapper.findAll('.m-editor-history-list-substeps li');
expect(items).toHaveLength(2); expect(items).toHaveLength(2);
// 子步倒序渲染最新在上params 在前content 在后 // 子步倒序渲染最新在上params 在前content 在后
expect(items[0].text()).toContain('修改 fn (id: code_1) · params'); expect(items[0].text()).toContain('fn (id: code_1) · params');
expect(items[1].text()).toContain('修改 fn (id: code_1) · content'); expect(items[1].text()).toContain('fn (id: code_1) · content');
}); });
}); });

View File

@ -8,8 +8,8 @@ import { defineComponent, h } from 'vue';
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import BucketTab from '@editor/layouts/history-list/BucketTab.vue'; import BucketTab from '@editor/layouts/history-list/BucketTab.vue';
import { describeDataSourceGroup, describeDataSourceStep } from '@editor/layouts/history-list/composables'; import { describeStep } from '@editor/layouts/history-list/composables';
import type { DataSourceHistoryGroup, DataSourceStepValue } from '@editor/type'; import type { DataSourceStepValue, HistoryBucketConfig, StackHistoryGroup } from '@editor/type';
vi.mock('@tmagic/design', () => ({ vi.mock('@tmagic/design', () => ({
TMagicScrollbar: defineComponent({ TMagicScrollbar: defineComponent({
@ -21,29 +21,45 @@ vi.mock('@tmagic/design', () => ({
}), }),
})); }));
/** 把以 oldSchema/newSchema/changeRecords 描述的 fixture 归一成统一 diff 形态的 step。 */
const toDiffStep = (s: any, opType: 'add' | 'remove' | 'update') => ({
id: s.id,
opType,
diff: [
{
...(s.newSchema !== null && s.newSchema !== undefined ? { newSchema: s.newSchema } : {}),
...(s.oldSchema !== null && s.oldSchema !== undefined ? { oldSchema: s.oldSchema } : {}),
...(s.changeRecords ? { changeRecords: s.changeRecords } : {}),
},
],
});
const buildGroup = ( const buildGroup = (
id: string, id: string,
opType: 'add' | 'remove' | 'update', opType: 'add' | 'remove' | 'update',
steps: any[], steps: any[],
applied = true, applied = true,
startIndex = 0, startIndex = 0,
): DataSourceHistoryGroup => ({ ): StackHistoryGroup<DataSourceStepValue, 'data-source'> => ({
kind: 'data-source', kind: 'data-source',
id, id,
opType, opType,
applied, applied,
steps: steps.map((s, i) => ({ step: s, index: startIndex + i, applied })), steps: steps.map((s, i) => ({ step: toDiffStep(s, opType) as any, index: startIndex + i, applied })),
}); });
/** 数据源 tab 复用通用 BucketTab固定注入数据源的 title/prefix/describe/isStepDiffable。 */ /** 数据源 tab 复用通用 BucketTab固定注入数据源的 configtitle/prefix/describe/isStepDiffable。 */
const dataSourceConfig: HistoryBucketConfig<any> = {
title: '数据源',
prefix: 'ds',
describeStep: (step: DataSourceStepValue): string => describeStep(step, (schema) => schema?.title, '数据源'),
isStepDiffable: (step: DataSourceStepValue) => Boolean(step.diff?.[0]?.oldSchema && step.diff?.[0]?.newSchema),
};
const mountDataSourceTab = (props: { buckets: any[]; expanded: Record<string, boolean> }) => const mountDataSourceTab = (props: { buckets: any[]; expanded: Record<string, boolean> }) =>
mount(BucketTab, { mount(BucketTab, {
props: { props: {
title: '数据源', config: dataSourceConfig,
prefix: 'ds',
describeGroup: describeDataSourceGroup,
describeStep: describeDataSourceStep,
isStepDiffable: (step: DataSourceStepValue) => Boolean(step.oldSchema && step.newSchema),
...props, ...props,
}, },
}); });
@ -77,9 +93,9 @@ describe('DataSourceTab.vue', () => {
const rows = wrapper.findAll('.m-editor-history-list-group'); const rows = wrapper.findAll('.m-editor-history-list-group');
expect(rows).toHaveLength(2); expect(rows).toHaveLength(2);
expect(rows[0].find('.m-editor-history-list-item-op').text()).toBe('新增'); expect(rows[0].find('.m-editor-history-list-item-op').text()).toBe('新增');
expect(rows[0].find('.m-editor-history-list-item-desc').text()).toBe('创建 A (id: ds_1)'); expect(rows[0].find('.m-editor-history-list-item-desc').text()).toBe('A (id: ds_1)');
expect(rows[1].find('.m-editor-history-list-item-op').text()).toBe('删除'); expect(rows[1].find('.m-editor-history-list-item-op').text()).toBe('删除');
expect(rows[1].find('.m-editor-history-list-item-desc').text()).toBe('删除 B (id: ds_2)'); expect(rows[1].find('.m-editor-history-list-item-desc').text()).toBe('B (id: ds_2)');
}); });
test('toggle 透传key 形如 ds-${id}-${idx}', async () => { test('toggle 透传key 形如 ds-${id}-${idx}', async () => {

View File

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

View File

@ -82,6 +82,8 @@ vi.mock('@editor/layouts/CodeEditor.vue', () => ({
}), }),
})); }));
const services = {} as any;
const factory = () => const factory = () =>
mount(HistoryDiffDialog, { mount(HistoryDiffDialog, {
// 让 Teleport 内容内联渲染,便于通过 wrapper 查询 // 让 Teleport 内容内联渲染,便于通过 wrapper 查询
@ -164,6 +166,43 @@ describe('HistoryDiffDialog.vue', () => {
expect(currentRadio!.attributes('data-disabled')).toBe('true'); expect(currentRadio!.attributes('data-disabled')).toBe('true');
}); });
test('无「修改后的值」时「与修改前对比」按钮置灰', async () => {
const wrapper = factory();
(wrapper.vm as any).open(basePayload({ value: null }));
await nextTick();
const beforeRadio = wrapper.findAll('.fake-radio-btn').find((b) => b.attributes('data-value') === 'before');
expect(beforeRadio!.attributes('data-disabled')).toBe('true');
});
test('无「修改后的值」但有当前值时默认进入「与当前对比」', async () => {
const wrapper = factory();
(wrapper.vm as any).open(basePayload({ value: null }));
await nextTick();
const form = wrapper.findComponent({ name: 'CompareForm' });
// current 模式:左=该步修改后 value此处为空右=当前 currentValue
expect(form.props('value')).toEqual({ text: 'current' });
});
test('无「修改后的值」且无当前值时仍默认「与修改前对比」', async () => {
const wrapper = factory();
(wrapper.vm as any).open(basePayload({ value: null, currentValue: null }));
await nextTick();
const form = wrapper.findComponent({ name: 'CompareForm' });
expect(form.props('lastValue')).toEqual({ text: 'old' });
});
test('有「修改后的值」时「与修改前对比」按钮可用', async () => {
const wrapper = factory();
(wrapper.vm as any).open(basePayload());
await nextTick();
const beforeRadio = wrapper.findAll('.fake-radio-btn').find((b) => b.attributes('data-value') === 'before');
expect(beforeRadio!.attributes('data-disabled')).toBe('false');
});
test('targetText 按 category 生成前缀', async () => { test('targetText 按 category 生成前缀', async () => {
const wrapper = factory(); const wrapper = factory();
(wrapper.vm as any).open(basePayload({ category: 'data-source', targetLabel: '接口A' })); (wrapper.vm as any).open(basePayload({ category: 'data-source', targetLabel: '接口A' }));
@ -217,7 +256,7 @@ describe('HistoryDiffDialog.vue', () => {
test('有 onConfirm 时标题为「确认回滚」并展示回滚说明', async () => { test('有 onConfirm 时标题为「确认回滚」并展示回滚说明', async () => {
const wrapper = mount(HistoryDiffDialog, { const wrapper = mount(HistoryDiffDialog, {
global: { stubs: { teleport: true } }, global: { stubs: { teleport: true }, provide: { services } },
props: { onConfirm: vi.fn() }, props: { onConfirm: vi.fn() },
}); });
(wrapper.vm as any).open(basePayload()); (wrapper.vm as any).open(basePayload());

View File

@ -8,21 +8,90 @@ import { defineComponent, h, nextTick } from 'vue';
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import historyService from '@editor/services/history'; import historyService from '@editor/services/history';
import { createStackStep } from '@editor/utils/history';
const editorService = { gotoPageStep: vi.fn(async () => 0) }; // pushCodeBlock / pushDataSource 已合入统一的 push(stepType, step, id);用等价小工具沿用既有用例。
const dataSourceService = { goto: vi.fn(() => 0) }; const pushCodeBlock = (id: any, payload: any) => {
const codeBlockService = { goto: vi.fn(async () => 0) }; const step = createStackStep(id, {
oldValue: payload.oldContent,
newValue: payload.newContent,
changeRecords: payload.changeRecords,
});
return step ? historyService.push('codeBlock', step as any, id) : null;
};
const pushDataSource = (id: any, payload: any) => {
const step = createStackStep(id, {
oldValue: payload.oldSchema,
newValue: payload.newSchema,
changeRecords: payload.changeRecords,
});
return step ? historyService.push('dataSource', step as any, id) : null;
};
// 页面 push 现在必须显式传 pageId默认推到 'p1'(与 editorService.get('page') 的 mock 对齐)。
const pushPage = (step: any, id: any = 'p1') => historyService.push('page', step, id);
const { onPageDiff, onDataSourceDiff, onCodeBlockDiff, onPageRevert, onDataSourceRevert, onCodeBlockRevert } =
vi.hoisted(() => ({
onPageDiff: vi.fn(),
onDataSourceDiff: vi.fn(),
onCodeBlockDiff: vi.fn(),
onPageRevert: vi.fn(),
onDataSourceRevert: vi.fn(),
onCodeBlockRevert: vi.fn(),
}));
const stageSelect = vi.fn();
const overlayStageSelect = vi.fn();
const editorService = {
gotoPageStep: vi.fn(async () => 0),
revertPageStep: vi.fn(async () => null),
getNodeById: vi.fn((id: string | number) => ({ id })),
select: vi.fn(async () => {}),
// 历史面板已改为显式按当前页 id 取页面历史,'page' 返回固定 id 'p1'。
get: vi.fn((key?: string) => (key === 'page' ? { id: 'p1' } : { select: stageSelect })),
};
const stageOverlayService = { get: vi.fn(() => ({ select: overlayStageSelect })) };
const dataSourceService = {
goto: vi.fn(() => 0),
revert: vi.fn(async () => null),
getDataSourceById: vi.fn((id: string) => ({ id, title: 'DS' })),
};
const codeBlockService = {
goto: vi.fn(async () => 0),
revert: vi.fn(async () => null),
getCodeContentById: vi.fn((id: string | number) => ({ id, name: 'CB' })),
};
const propsService = { const propsService = {
getDisabledDataSource: vi.fn(() => false), getDisabledDataSource: vi.fn(() => false),
getDisabledCodeBlock: vi.fn(() => false), getDisabledCodeBlock: vi.fn(() => false),
}; };
vi.mock('@editor/hooks/use-services', () => ({ vi.mock('@editor/hooks/use-services', () => ({
useServices: () => ({ historyService, editorService, dataSourceService, codeBlockService, propsService }), useServices: () => ({
historyService,
editorService,
dataSourceService,
codeBlockService,
propsService,
stageOverlayService,
}),
}));
vi.mock('@editor/layouts/history-list/useHistoryRevert', () => ({
useHistoryRevert: vi.fn(() => ({
onPageRevert,
onDataSourceRevert,
onCodeBlockRevert,
onPageDiff,
onDataSourceDiff,
onCodeBlockDiff,
})),
})); }));
vi.mock('@tmagic/design', () => ({ vi.mock('@tmagic/design', () => ({
getDesignConfig: vi.fn(() => undefined), getDesignConfig: vi.fn(() => undefined),
tMagicMessage: { warning: vi.fn(), error: vi.fn(), success: vi.fn() },
tMagicMessageBox: { confirm: vi.fn(async () => undefined) },
TMagicButton: defineComponent({ TMagicButton: defineComponent({
name: 'FakeButton', name: 'FakeButton',
setup(_p, { slots }) { setup(_p, { slots }) {
@ -68,21 +137,11 @@ vi.mock('@editor/components/Icon.vue', () => ({
}), }),
})); }));
// 差异对话框有独立的单测HistoryDiffDialog.spec.ts这里 stub 掉以隔离面板自身逻辑,
// 同时避免其内部依赖monaco CodeEditor / CompareForm / 设计层弹窗组件)在本用例下未被 mock 而报错。
vi.mock('@editor/layouts/history-list/HistoryDiffDialog.vue', () => ({
default: defineComponent({
name: 'FakeHistoryDiffDialog',
setup(_p, { expose }) {
expose({ open: vi.fn(), close: vi.fn() });
return () => h('div', { class: 'fake-history-diff-dialog' });
},
}),
}));
afterEach(() => { afterEach(() => {
historyService.reset(); historyService.reset();
vi.clearAllMocks(); vi.clearAllMocks();
propsService.getDisabledDataSource.mockReturnValue(false);
propsService.getDisabledCodeBlock.mockReturnValue(false);
}); });
const factory = async () => { const factory = async () => {
@ -102,17 +161,16 @@ describe('HistoryListPanel.vue', () => {
}); });
test('页面/数据源/代码块 数据齐全时各 tab 渲染对应内容', async () => { test('页面/数据源/代码块 数据齐全时各 tab 渲染对应内容', async () => {
historyService.changePage({ id: 'p1' } as any); pushPage({
historyService.push({
opType: 'add', opType: 'add',
nodes: [{ id: 'n1', name: 'A' }], diff: [{ newSchema: { id: 'n1', name: 'A' } }],
modifiedNodeIds: new Map(), modifiedNodeIds: new Map(),
} as any); } as any);
historyService.pushDataSource('ds_1', { pushDataSource('ds_1', {
oldSchema: null, oldSchema: null,
newSchema: { id: 'ds_1', title: 'DS' } as any, newSchema: { id: 'ds_1', title: 'DS' } as any,
}); });
historyService.pushCodeBlock('code_1', { pushCodeBlock('code_1', {
oldContent: null, oldContent: null,
newContent: { id: 'code_1', name: 'CB' } as any, newContent: { id: 'code_1', name: 'CB' } as any,
}); });
@ -125,56 +183,54 @@ describe('HistoryListPanel.vue', () => {
expect(rows.length).toBe(3); expect(rows.length).toBe(3);
const descs = rows.map((r) => r.find('.m-editor-history-list-item-desc').text()); const descs = rows.map((r) => r.find('.m-editor-history-list-item-desc').text());
expect(descs.some((t) => t.includes('新增 1 个节点'))).toBe(true); expect(descs.some((t) => t === 'A (id: n1)')).toBe(true);
expect(descs.some((t) => t === '创建 DS (id: ds_1)')).toBe(true); expect(descs.some((t) => t === 'DS (id: ds_1)')).toBe(true);
expect(descs.some((t) => t === '创建 CB (id: code_1)')).toBe(true); expect(descs.some((t) => t === 'CB (id: code_1)')).toBe(true);
}); });
test('点击合并组头部能切换 expanded 状态(不触发 goto', async () => { test('点击合并组头部能切换 expanded 状态(不触发 goto', async () => {
historyService.changePage({ id: 'p1' } as any);
// 推两个修改同一节点的步骤,会合并为一个 group // 推两个修改同一节点的步骤,会合并为一个 group
const mkUpdate = (path: string) => ({ const mkUpdate = (path: string) => ({
opType: 'update', opType: 'update',
modifiedNodeIds: new Map(), modifiedNodeIds: new Map(),
updatedItems: [ diff: [
{ {
newNode: { id: 'btn', name: '按钮' }, newSchema: { id: 'btn', name: '按钮' },
oldNode: { id: 'btn', name: '按钮' }, oldSchema: { id: 'btn', name: '按钮' },
changeRecords: [{ propPath: path }], changeRecords: [{ propPath: path }],
}, },
], ],
}); });
historyService.push(mkUpdate('a') as any); pushPage(mkUpdate('a') as any);
historyService.push(mkUpdate('b') as any); pushPage(mkUpdate('b') as any);
const wrapper = await factory(); const wrapper = await factory();
await nextTick(); await nextTick();
const head = wrapper.find('.m-editor-history-list-group-head'); const head = wrapper.find('.m-editor-history-list-group-head');
expect(head.exists()).toBe(true); expect(head.exists()).toBe(true);
// 默认未展开 // 默认展开
expect(wrapper.find('.m-editor-history-list-substeps').exists()).toBe(false);
// 点击展开
await head.trigger('click');
expect(wrapper.find('.m-editor-history-list-substeps').exists()).toBe(true); expect(wrapper.find('.m-editor-history-list-substeps').exists()).toBe(true);
expect(wrapper.findAll('.m-editor-history-list-substeps li')).toHaveLength(2); expect(wrapper.findAll('.m-editor-history-list-substeps li')).toHaveLength(2);
// 合并组头部点击不应触发 goto // 合并组头部点击不应触发 goto
expect(editorService.gotoPageStep).not.toHaveBeenCalled(); expect(editorService.gotoPageStep).not.toHaveBeenCalled();
// 再点击折叠 // 点击收起
await wrapper.find('.m-editor-history-list-group-head').trigger('click'); await head.trigger('click');
expect(wrapper.find('.m-editor-history-list-substeps').exists()).toBe(false); expect(wrapper.find('.m-editor-history-list-substeps').exists()).toBe(false);
// 再点击展开
await wrapper.find('.m-editor-history-list-group-head').trigger('click');
expect(wrapper.find('.m-editor-history-list-substeps').exists()).toBe(true);
}); });
test('点击页面 group 头部调用 editorService.gotoPageStep', async () => { test('点击页面 group 头部调用 editorService.gotoPageStep', async () => {
historyService.changePage({ id: 'p1' } as any); pushPage({
historyService.push({
opType: 'add', opType: 'add',
nodes: [{ id: 'n1', name: 'A' }], diff: [{ newSchema: { id: 'n1', name: 'A' } }],
modifiedNodeIds: new Map(), modifiedNodeIds: new Map(),
} as any); } as any);
historyService.push({ pushPage({
opType: 'add', opType: 'add',
nodes: [{ id: 'n2', name: 'B' }], diff: [{ newSchema: { id: 'n2', name: 'B' } }],
modifiedNodeIds: new Map(), modifiedNodeIds: new Map(),
} as any); } as any);
@ -196,8 +252,50 @@ describe('HistoryListPanel.vue', () => {
expect(editorService.gotoPageStep).toHaveBeenCalledTimes(1); expect(editorService.gotoPageStep).toHaveBeenCalledTimes(1);
}); });
test('点击页面 group 头部选中对应节点editorService.select + 画布 select 联动)', async () => {
pushPage({
opType: 'add',
diff: [{ newSchema: { id: 'n1', name: 'A' } }],
modifiedNodeIds: new Map(),
} as any);
const wrapper = await factory();
await nextTick();
const head = wrapper.find('.m-editor-history-list-group-head');
await head.trigger('click');
await nextTick();
expect(editorService.getNodeById).toHaveBeenCalledWith('n1', false);
expect(editorService.select).toHaveBeenCalledWith({ id: 'n1' });
expect(stageSelect).toHaveBeenCalledWith('n1');
expect(overlayStageSelect).toHaveBeenCalledWith('n1');
// 选中不应触发跳转
expect(editorService.gotoPageStep).not.toHaveBeenCalled();
});
test('点击页面记录时节点已不存在则提示且不选中', async () => {
pushPage({
opType: 'remove',
diff: [{ oldSchema: { id: 'gone', name: 'G' } }],
modifiedNodeIds: new Map(),
} as any);
editorService.getNodeById.mockReturnValueOnce(null);
const { tMagicMessage } = await import('@tmagic/design');
const wrapper = await factory();
await nextTick();
await wrapper.find('.m-editor-history-list-group-head').trigger('click');
await nextTick();
expect(tMagicMessage.warning).toHaveBeenCalled();
expect(editorService.select).not.toHaveBeenCalled();
expect(stageSelect).not.toHaveBeenCalled();
});
test('点击数据源组头部调用 dataSourceService.goto(id, cursor)', async () => { test('点击数据源组头部调用 dataSourceService.goto(id, cursor)', async () => {
historyService.pushDataSource('ds_1', { pushDataSource('ds_1', {
oldSchema: null, oldSchema: null,
newSchema: { id: 'ds_1', title: 'DS' } as any, newSchema: { id: 'ds_1', title: 'DS' } as any,
}); });
@ -206,19 +304,19 @@ describe('HistoryListPanel.vue', () => {
await nextTick(); await nextTick();
// 当前 ds 组isCurrent点击不触发 goto为了能触发先撤销该步使其变为非当前 // 当前 ds 组isCurrent点击不触发 goto为了能触发先撤销该步使其变为非当前
historyService.undoDataSource('ds_1'); historyService.undo('dataSource', 'ds_1');
await nextTick(); await nextTick();
const heads = wrapper.findAll('.m-editor-history-list-group-head'); const heads = wrapper.findAll('.m-editor-history-list-group-head');
// 找到数据源 tab 那一组 // 找到数据源 tab 那一组
const dsHead = heads.find((h) => h.text().includes('创建 DS')); const dsHead = heads.find((h) => h.text().includes('DS (id: ds_1)'));
expect(dsHead).toBeTruthy(); expect(dsHead).toBeTruthy();
await dsHead!.find('.m-editor-history-list-item-goto').trigger('click'); await dsHead!.find('.m-editor-history-list-item-goto').trigger('click');
expect(dataSourceService.goto).toHaveBeenCalledWith('ds_1', 1); expect(dataSourceService.goto).toHaveBeenCalledWith('ds_1', 1);
}); });
test('点击代码块组头部调用 codeBlockService.goto(id, cursor)', async () => { test('点击代码块组头部调用 codeBlockService.goto(id, cursor)', async () => {
historyService.pushCodeBlock('code_1', { pushCodeBlock('code_1', {
oldContent: null, oldContent: null,
newContent: { id: 'code_1', name: 'CB' } as any, newContent: { id: 'code_1', name: 'CB' } as any,
}); });
@ -226,21 +324,20 @@ describe('HistoryListPanel.vue', () => {
const wrapper = await factory(); const wrapper = await factory();
await nextTick(); await nextTick();
historyService.undoCodeBlock('code_1'); historyService.undo('codeBlock', 'code_1');
await nextTick(); await nextTick();
const heads = wrapper.findAll('.m-editor-history-list-group-head'); const heads = wrapper.findAll('.m-editor-history-list-group-head');
const cbHead = heads.find((h) => h.text().includes('创建 CB')); const cbHead = heads.find((h) => h.text().includes('CB (id: code_1)'));
expect(cbHead).toBeTruthy(); expect(cbHead).toBeTruthy();
await cbHead!.find('.m-editor-history-list-item-goto').trigger('click'); await cbHead!.find('.m-editor-history-list-item-goto').trigger('click');
expect(codeBlockService.goto).toHaveBeenCalledWith('code_1', 1); expect(codeBlockService.goto).toHaveBeenCalledWith('code_1', 1);
}); });
test('点击页面初始项调用 editorService.gotoPageStep(0)', async () => { test('点击页面初始项调用 editorService.gotoPageStep(0)', async () => {
historyService.changePage({ id: 'p1' } as any); pushPage({
historyService.push({
opType: 'add', opType: 'add',
nodes: [{ id: 'n1', name: 'A' }], diff: [{ newSchema: { id: 'n1', name: 'A' } }],
modifiedNodeIds: new Map(), modifiedNodeIds: new Map(),
} as any); } as any);
@ -287,12 +384,103 @@ describe('HistoryListPanel.vue', () => {
expect(custom.text()).toBe('hello-custom'); expect(custom.text()).toBe('hello-custom');
}); });
test('disabledDataSource / disabledCodeBlock 为 true 时不渲染对应 tab', async () => {
propsService.getDisabledDataSource.mockReturnValue(true);
propsService.getDisabledCodeBlock.mockReturnValue(true);
const wrapper = await factory();
await nextTick();
const empties = wrapper.findAll('.m-editor-history-list-empty');
expect(empties).toHaveLength(1);
});
test('点击页面 update 记录的「查看差异」打开 diff 弹窗', async () => {
pushPage({
opType: 'update',
modifiedNodeIds: new Map(),
diff: [
{
newSchema: { id: 'btn', name: '新按钮', type: 'button' },
oldSchema: { id: 'btn', name: '旧按钮', type: 'button' },
changeRecords: [{ propPath: 'name' }],
},
],
} as any);
const wrapper = await factory();
await nextTick();
await wrapper.find('.m-editor-history-list-item-diff').trigger('click');
expect(onPageDiff).toHaveBeenCalledWith(0);
});
test('点击页面 update 记录的「回滚」透传到 onPageRevert', async () => {
pushPage({
opType: 'update',
modifiedNodeIds: new Map(),
diff: [
{
newSchema: { id: 'btn', name: '新按钮', type: 'button' },
oldSchema: { id: 'btn', name: '旧按钮', type: 'button' },
changeRecords: [{ propPath: 'name' }],
},
],
} as any);
const wrapper = await factory();
await nextTick();
await wrapper.find('.m-editor-history-list-item-revert').trigger('click');
expect(onPageRevert).toHaveBeenCalledWith(0);
expect(editorService.revertPageStep).not.toHaveBeenCalled();
});
test('回滚目标校验由 useHistoryRevert 处理,面板仅透传 index', async () => {
pushPage({
opType: 'update',
modifiedNodeIds: new Map(),
diff: [
{
newSchema: { id: 'gone', name: '按钮', type: 'button' },
oldSchema: { id: 'gone', name: '按钮', type: 'button' },
changeRecords: [{ propPath: 'name' }],
},
],
} as any);
const wrapper = await factory();
await nextTick();
await wrapper.find('.m-editor-history-list-item-revert').trigger('click');
expect(onPageRevert).toHaveBeenCalledWith(0);
expect(editorService.revertPageStep).not.toHaveBeenCalled();
});
test('确认清空页面历史后调用 historyService.clear', async () => {
pushPage({
opType: 'add',
diff: [{ newSchema: { id: 'n1', name: 'A' } }],
modifiedNodeIds: new Map(),
} as any);
const saveSpy = vi.spyOn(historyService, 'saveToIndexedDB').mockResolvedValue(undefined);
const wrapper = await factory();
await nextTick();
await wrapper.find('.m-editor-history-list-clear').trigger('click');
await nextTick();
expect(historyService.getHistoryGroups('page', 'p1')).toHaveLength(0);
expect(saveSpy).toHaveBeenCalled();
saveSpy.mockRestore();
});
test('点击数据源/代码块初始项调用对应 service.goto(id, 0)', async () => { test('点击数据源/代码块初始项调用对应 service.goto(id, 0)', async () => {
historyService.pushDataSource('ds_x', { pushDataSource('ds_x', {
oldSchema: null, oldSchema: null,
newSchema: { id: 'ds_x', title: 'DS' } as any, newSchema: { id: 'ds_x', title: 'DS' } as any,
}); });
historyService.pushCodeBlock('code_x', { pushCodeBlock('code_x', {
oldContent: null, oldContent: null,
newContent: { id: 'code_x', name: 'CB' } as any, newContent: { id: 'code_x', name: 'CB' } as any,
}); });
@ -313,4 +501,54 @@ describe('HistoryListPanel.vue', () => {
await initials[1].find('.m-editor-history-list-item-goto').trigger('click'); await initials[1].find('.m-editor-history-list-item-goto').trigger('click');
expect(codeBlockService.goto).toHaveBeenCalledWith('code_x', 0); expect(codeBlockService.goto).toHaveBeenCalledWith('code_x', 0);
}); });
test('点击数据源 update 记录的「查看差异」与「回滚」', async () => {
pushDataSource('ds_1', {
oldSchema: null,
newSchema: { id: 'ds_1', title: 'DS' } as any,
});
pushDataSource('ds_1', {
oldSchema: { id: 'ds_1', title: '旧 DS' } as any,
newSchema: { id: 'ds_1', title: '新 DS' } as any,
changeRecords: [{ propPath: 'title', value: '新 DS' }],
});
const wrapper = await factory();
await nextTick();
const diffBtn = wrapper.find('.m-editor-history-list-item-diff');
await diffBtn.trigger('click');
expect(onDataSourceDiff).toHaveBeenCalledWith('ds_1', 1);
await wrapper.find('.m-editor-history-list-item-revert').trigger('click');
expect(onDataSourceRevert).toHaveBeenCalledWith('ds_1', 1);
expect(dataSourceService.revert).not.toHaveBeenCalled();
});
test('确认清空数据源/代码块历史后调用 clear(undefined, stepType)', async () => {
pushDataSource('ds_1', {
oldSchema: null,
newSchema: { id: 'ds_1', title: 'DS' } as any,
});
pushCodeBlock('code_1', {
oldContent: null,
newContent: { id: 'code_1', name: 'CB' } as any,
});
const saveSpy = vi.spyOn(historyService, 'saveToIndexedDB').mockResolvedValue(undefined);
const wrapper = await factory();
await nextTick();
const clears = wrapper.findAll('.m-editor-history-list-clear');
expect(clears.length).toBeGreaterThanOrEqual(2);
await clears[0].trigger('click');
await nextTick();
await clears[1].trigger('click');
await nextTick();
expect(historyService.getHistoryGroups('dataSource')).toHaveLength(0);
expect(historyService.getHistoryGroups('codeBlock')).toHaveLength(0);
expect(saveSpy).toHaveBeenCalled();
saveSpy.mockRestore();
});
}); });

View File

@ -8,7 +8,7 @@ import { defineComponent, h } from 'vue';
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import PageTab from '@editor/layouts/history-list/PageTab.vue'; import PageTab from '@editor/layouts/history-list/PageTab.vue';
import type { PageHistoryGroup } from '@editor/type'; import type { HistoryGroup, StepValue } from '@editor/type';
vi.mock('@tmagic/design', () => ({ vi.mock('@tmagic/design', () => ({
TMagicScrollbar: defineComponent({ TMagicScrollbar: defineComponent({
@ -27,9 +27,9 @@ const buildPageGroup = (
targetName?: string, targetName?: string,
targetId?: string, targetId?: string,
startIndex = 0, startIndex = 0,
): PageHistoryGroup => ({ ): HistoryGroup<StepValue> => ({
kind: 'page', kind: 'page',
pageId: 'p1', id: 'p1',
opType, opType,
applied, applied,
targetId, targetId,
@ -46,16 +46,16 @@ describe('PageTab.vue', () => {
test('list 非空:每个 group 渲染一行', () => { test('list 非空:每个 group 渲染一行', () => {
const list = [ const list = [
buildPageGroup('add', [{ opType: 'add', nodes: [{ id: 'n1', name: 'A' }] }]), buildPageGroup('add', [{ opType: 'add', diff: [{ newSchema: { id: 'n1', name: 'A' } }] }]),
buildPageGroup( buildPageGroup(
'update', 'update',
[ [
{ {
opType: 'update', opType: 'update',
updatedItems: [ diff: [
{ {
newNode: { id: 'btn', name: '按钮' }, newSchema: { id: 'btn', name: '按钮' },
oldNode: { id: 'btn' }, oldSchema: { id: 'btn' },
changeRecords: [{ propPath: 'style.color' }], changeRecords: [{ propPath: 'style.color' }],
}, },
], ],
@ -71,14 +71,16 @@ describe('PageTab.vue', () => {
expect(rows).toHaveLength(2); expect(rows).toHaveLength(2);
// 第一组 add // 第一组 add
expect(rows[0].find('.m-editor-history-list-item-op').text()).toBe('新增'); expect(rows[0].find('.m-editor-history-list-item-op').text()).toBe('新增');
expect(rows[0].find('.m-editor-history-list-item-desc').text()).toContain('新增 1 个节点'); expect(rows[0].find('.m-editor-history-list-item-desc').text()).toBe('A (id: n1)');
// 第二组 update // 第二组 update
expect(rows[1].find('.m-editor-history-list-item-op').text()).toBe('修改'); expect(rows[1].find('.m-editor-history-list-item-op').text()).toBe('修改');
expect(rows[1].find('.m-editor-history-list-item-desc').text()).toBe('修改 按钮 (id: btn) · style.color'); expect(rows[1].find('.m-editor-history-list-item-desc').text()).toBe('按钮 (id: btn) · style.color');
}); });
test('step 含 timestamp 时渲染时间元素', () => { test('step 含 timestamp 时渲染时间元素', () => {
const list = [buildPageGroup('add', [{ opType: 'add', nodes: [{ id: 'n1', name: 'A' }], timestamp: Date.now() }])]; const list = [
buildPageGroup('add', [{ opType: 'add', diff: [{ newSchema: { id: 'n1', name: 'A' } }], timestamp: Date.now() }]),
];
const wrapper = mount(PageTab, { props: { list, expanded: {} } }); const wrapper = mount(PageTab, { props: { list, expanded: {} } });
const time = wrapper.find('.m-editor-history-list-item-time'); const time = wrapper.find('.m-editor-history-list-item-time');
expect(time.exists()).toBe(true); expect(time.exists()).toBe(true);
@ -87,7 +89,7 @@ describe('PageTab.vue', () => {
}); });
test('step 无 timestamp 时不渲染时间元素', () => { test('step 无 timestamp 时不渲染时间元素', () => {
const list = [buildPageGroup('add', [{ opType: 'add', nodes: [{ id: 'n1', name: 'A' }] }])]; const list = [buildPageGroup('add', [{ opType: 'add', diff: [{ newSchema: { id: 'n1', name: 'A' } }] }])];
const wrapper = mount(PageTab, { props: { list, expanded: {} } }); const wrapper = mount(PageTab, { props: { list, expanded: {} } });
expect(wrapper.find('.m-editor-history-list-item-time').exists()).toBe(false); expect(wrapper.find('.m-editor-history-list-item-time').exists()).toBe(false);
}); });
@ -98,20 +100,20 @@ describe('PageTab.vue', () => {
[ [
{ {
opType: 'update', opType: 'update',
updatedItems: [ diff: [
{ {
newNode: { id: 'btn', name: '按钮' }, newSchema: { id: 'btn', name: '按钮' },
oldNode: { id: 'btn' }, oldSchema: { id: 'btn' },
changeRecords: [{ propPath: 'a' }], changeRecords: [{ propPath: 'a' }],
}, },
], ],
}, },
{ {
opType: 'update', opType: 'update',
updatedItems: [ diff: [
{ {
newNode: { id: 'btn', name: '按钮' }, newSchema: { id: 'btn', name: '按钮' },
oldNode: { id: 'btn' }, oldSchema: { id: 'btn' },
changeRecords: [{ propPath: 'b' }], changeRecords: [{ propPath: 'b' }],
}, },
], ],
@ -122,11 +124,11 @@ describe('PageTab.vue', () => {
'btn', 'btn',
); );
const wrapper = mount(PageTab, { props: { list: [mergedGroup], expanded: { 'pg-0': true } } }); const wrapper = mount(PageTab, { props: { list: [mergedGroup], expanded: {} } });
expect(wrapper.find('.m-editor-history-list-substeps').exists()).toBe(true); expect(wrapper.find('.m-editor-history-list-substeps').exists()).toBe(true);
expect(wrapper.findAll('.m-editor-history-list-substeps li')).toHaveLength(2); expect(wrapper.findAll('.m-editor-history-list-substeps li')).toHaveLength(2);
await wrapper.setProps({ list: [mergedGroup], expanded: {} }); await wrapper.setProps({ list: [mergedGroup], expanded: { 'pg-0': false } });
expect(wrapper.find('.m-editor-history-list-substeps').exists()).toBe(false); expect(wrapper.find('.m-editor-history-list-substeps').exists()).toBe(false);
}); });
@ -138,11 +140,11 @@ describe('PageTab.vue', () => {
[ [
{ {
opType: 'update', opType: 'update',
updatedItems: [{ newNode: { id: 'btn' }, oldNode: { id: 'btn' }, changeRecords: [{ propPath: 'a' }] }], diff: [{ newSchema: { id: 'btn' }, oldSchema: { id: 'btn' }, changeRecords: [{ propPath: 'a' }] }],
}, },
{ {
opType: 'update', opType: 'update',
updatedItems: [{ newNode: { id: 'btn' }, oldNode: { id: 'btn' }, changeRecords: [{ propPath: 'b' }] }], diff: [{ newSchema: { id: 'btn' }, oldSchema: { id: 'btn' }, changeRecords: [{ propPath: 'b' }] }],
}, },
], ],
true, true,
@ -154,11 +156,11 @@ describe('PageTab.vue', () => {
[ [
{ {
opType: 'update', opType: 'update',
updatedItems: [{ newNode: { id: 'btn2' }, oldNode: { id: 'btn2' }, changeRecords: [{ propPath: 'a' }] }], diff: [{ newSchema: { id: 'btn2' }, oldSchema: { id: 'btn2' }, changeRecords: [{ propPath: 'a' }] }],
}, },
{ {
opType: 'update', opType: 'update',
updatedItems: [{ newNode: { id: 'btn2' }, oldNode: { id: 'btn2' }, changeRecords: [{ propPath: 'b' }] }], diff: [{ newSchema: { id: 'btn2' }, oldSchema: { id: 'btn2' }, changeRecords: [{ propPath: 'b' }] }],
}, },
], ],
true, true,
@ -178,7 +180,7 @@ describe('PageTab.vue', () => {
}); });
test('点击单步组「回到」按钮透传 goto 事件,携带该 step 的 index', async () => { test('点击单步组「回到」按钮透传 goto 事件,携带该 step 的 index', async () => {
const list = [buildPageGroup('add', [{ opType: 'add', nodes: [{ id: 'n1', name: 'A' }] }])]; const list = [buildPageGroup('add', [{ opType: 'add', diff: [{ newSchema: { id: 'n1', name: 'A' } }] }])];
const wrapper = mount(PageTab, { props: { list, expanded: {} } }); const wrapper = mount(PageTab, { props: { list, expanded: {} } });
await wrapper.find('.m-editor-history-list-item-goto').trigger('click'); await wrapper.find('.m-editor-history-list-item-goto').trigger('click');
expect(wrapper.emitted('goto')).toBeTruthy(); expect(wrapper.emitted('goto')).toBeTruthy();
@ -187,7 +189,7 @@ describe('PageTab.vue', () => {
}); });
test('已撤销组applied=false附 is-undone 类名', () => { test('已撤销组applied=false附 is-undone 类名', () => {
const list = [buildPageGroup('add', [{ opType: 'add', nodes: [{ id: 'n1', name: 'A' }] }], false)]; const list = [buildPageGroup('add', [{ opType: 'add', diff: [{ newSchema: { id: 'n1', name: 'A' } }] }], false)];
const wrapper = mount(PageTab, { props: { list, expanded: {} } }); const wrapper = mount(PageTab, { props: { list, expanded: {} } });
expect(wrapper.find('.m-editor-history-list-group').classes()).toContain('is-undone'); expect(wrapper.find('.m-editor-history-list-group').classes()).toContain('is-undone');
}); });
@ -198,13 +200,13 @@ describe('PageTab.vue', () => {
expect(empty.find('.m-editor-history-list-initial').exists()).toBe(false); expect(empty.find('.m-editor-history-list-initial').exists()).toBe(false);
// 非空 list底部应有一条初始项 // 非空 list底部应有一条初始项
const list = [buildPageGroup('add', [{ opType: 'add', nodes: [{ id: 'n1', name: 'A' }] }])]; const list = [buildPageGroup('add', [{ opType: 'add', diff: [{ newSchema: { id: 'n1', name: 'A' } }] }])];
const wrapper = mount(PageTab, { props: { list, expanded: {} } }); const wrapper = mount(PageTab, { props: { list, expanded: {} } });
expect(wrapper.find('.m-editor-history-list-initial').exists()).toBe(true); expect(wrapper.find('.m-editor-history-list-initial').exists()).toBe(true);
}); });
test('全部 group 都未 applied 时初始项标记为当前', () => { test('全部 group 都未 applied 时初始项标记为当前', () => {
const list = [buildPageGroup('add', [{ opType: 'add', nodes: [{ id: 'n1', name: 'A' }] }], false)]; const list = [buildPageGroup('add', [{ opType: 'add', diff: [{ newSchema: { id: 'n1', name: 'A' } }] }], false)];
const wrapper = mount(PageTab, { props: { list, expanded: {} } }); const wrapper = mount(PageTab, { props: { list, expanded: {} } });
const initial = wrapper.find('.m-editor-history-list-initial'); const initial = wrapper.find('.m-editor-history-list-initial');
expect(initial.classes()).toContain('is-current'); expect(initial.classes()).toContain('is-current');
@ -212,8 +214,8 @@ describe('PageTab.vue', () => {
test('存在已 applied 的 group 时初始项不为当前', () => { test('存在已 applied 的 group 时初始项不为当前', () => {
const list = [ const list = [
buildPageGroup('add', [{ opType: 'add', nodes: [{ id: 'n1', name: 'A' }] }], true), buildPageGroup('add', [{ opType: 'add', diff: [{ newSchema: { id: 'n1', name: 'A' } }] }], true),
buildPageGroup('add', [{ opType: 'add', nodes: [{ id: 'n2', name: 'B' }] }], false), buildPageGroup('add', [{ opType: 'add', diff: [{ newSchema: { id: 'n2', name: 'B' } }] }], false),
]; ];
const wrapper = mount(PageTab, { props: { list, expanded: {} } }); const wrapper = mount(PageTab, { props: { list, expanded: {} } });
const initial = wrapper.find('.m-editor-history-list-initial'); const initial = wrapper.find('.m-editor-history-list-initial');
@ -221,7 +223,7 @@ describe('PageTab.vue', () => {
}); });
test('点击非当前初始项的「回到」按钮透传 goto-initial 事件', async () => { test('点击非当前初始项的「回到」按钮透传 goto-initial 事件', async () => {
const list = [buildPageGroup('add', [{ opType: 'add', nodes: [{ id: 'n1', name: 'A' }] }], true)]; const list = [buildPageGroup('add', [{ opType: 'add', diff: [{ newSchema: { id: 'n1', name: 'A' } }] }], true)];
const wrapper = mount(PageTab, { props: { list, expanded: {} } }); const wrapper = mount(PageTab, { props: { list, expanded: {} } });
await wrapper.find('.m-editor-history-list-initial .m-editor-history-list-item-goto').trigger('click'); await wrapper.find('.m-editor-history-list-initial .m-editor-history-list-item-goto').trigger('click');
expect(wrapper.emitted('goto-initial')).toBeTruthy(); expect(wrapper.emitted('goto-initial')).toBeTruthy();

View File

@ -8,37 +8,43 @@ import { defineComponent, h } from 'vue';
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import { import {
describeCodeBlockGroup,
describeCodeBlockStep,
describeDataSourceGroup,
describeDataSourceStep,
describePageGroup, describePageGroup,
describePageStep, describePageStep,
formatHistoryFullTime, formatHistoryFullTime,
formatHistoryTime, formatHistoryTime,
groupTimestamp, groupTimestamp,
isCodeBlockStepRevertable,
isDataSourceStepRevertable,
isPageStepRevertable, isPageStepRevertable,
isSingleDiffStepRevertable,
opLabel, opLabel,
useHistoryList,
} from '@editor/layouts/history-list/composables'; } from '@editor/layouts/history-list/composables';
import { useHistoryList } from '@editor/layouts/history-list/useHistoryList';
import historyService from '@editor/services/history'; import historyService from '@editor/services/history';
import type { import type { HistoryGroup, HistoryStepEntry, StepValue } from '@editor/type';
CodeBlockHistoryGroup, import { createStackStep } from '@editor/utils/history';
CodeBlockStepValue,
DataSourceHistoryGroup, // pushCodeBlock / pushDataSource 已合入统一的 push(stepType, step, id);用等价小工具沿用既有用例。
DataSourceStepValue, const pushCodeBlock = (id: any, payload: any) => {
PageHistoryGroup, const step = createStackStep(id, {
PageHistoryStepEntry, oldValue: payload.oldContent,
StepValue, newValue: payload.newContent,
} from '@editor/type'; changeRecords: payload.changeRecords,
});
return step ? historyService.push('codeBlock', step as any, id) : null;
};
const pushDataSource = (id: any, payload: any) => {
const step = createStackStep(id, {
oldValue: payload.oldSchema,
newValue: payload.newSchema,
changeRecords: payload.changeRecords,
});
return step ? historyService.push('dataSource', step as any, id) : null;
};
afterEach(() => { afterEach(() => {
historyService.reset(); historyService.reset();
}); });
const buildPageEntry = (step: StepValue, index = 0, applied = true): PageHistoryStepEntry => ({ const buildPageEntry = (step: StepValue, index = 0, applied = true): HistoryStepEntry<StepValue> => ({
step, step,
index, index,
applied, applied,
@ -109,100 +115,100 @@ describe('describePageStep', () => {
test('add 单个节点:含名称与 id', () => { test('add 单个节点:含名称与 id', () => {
const step = { const step = {
opType: 'add', opType: 'add',
nodes: [{ id: 'btn_1', type: 'button', name: '主按钮' }], diff: [{ newSchema: { id: 'btn_1', type: 'button', name: '主按钮' } }],
} as unknown as StepValue; } as unknown as StepValue;
expect(describePageStep(step)).toBe('新增 1 个节点(主按钮 (id: btn_1)'); expect(describePageStep(step)).toBe('主按钮 (id: btn_1)');
}); });
test('add 节点无 name 但有 type使用 type 作为名称', () => { test('add 节点无 name 但有 type使用 type 作为名称', () => {
const step = { const step = {
opType: 'add', opType: 'add',
nodes: [{ id: 'n1', type: 'text' }], diff: [{ newSchema: { id: 'n1', type: 'text' } }],
} as unknown as StepValue; } as unknown as StepValue;
expect(describePageStep(step)).toBe('新增 1 个节点(text (id: n1)'); expect(describePageStep(step)).toBe('text (id: n1)');
}); });
test('add 节点 name 与 id 相同:仅显示 id', () => { test('add 节点 name 与 id 相同:仅显示 id', () => {
const step = { const step = {
opType: 'add', opType: 'add',
nodes: [{ id: 'n1', name: 'n1' }], diff: [{ newSchema: { id: 'n1', name: 'n1' } }],
} as unknown as StepValue; } as unknown as StepValue;
expect(describePageStep(step)).toBe('新增 1 个节点(n1'); expect(describePageStep(step)).toBe('n1');
}); });
test('add 多个节点:仅给出数量', () => { test('add 多个节点:仅给出数量', () => {
const step = { const step = {
opType: 'add', opType: 'add',
nodes: [{ id: 'a' }, { id: 'b' }], diff: [{ newSchema: { id: 'a' } }, { newSchema: { id: 'b' } }],
} as unknown as StepValue; } as unknown as StepValue;
expect(describePageStep(step)).toBe('新增 2 个节点'); expect(describePageStep(step)).toBe('2 个节点');
}); });
test('add 无 nodescount 为 0 且不附名称', () => { test('add 无 nodescount 为 0 且不附名称', () => {
const step = { opType: 'add' } as unknown as StepValue; const step = { opType: 'add' } as unknown as StepValue;
expect(describePageStep(step)).toBe('新增 0 个节点'); expect(describePageStep(step)).toBe('0 个节点');
}); });
test('remove 单个节点:含名称与 id', () => { test('remove 单个节点:含名称与 id', () => {
const step = { const step = {
opType: 'remove', opType: 'remove',
removedItems: [{ node: { id: 'btn_1', name: '主按钮' } }], diff: [{ oldSchema: { id: 'btn_1', name: '主按钮' } }],
} as unknown as StepValue; } as unknown as StepValue;
expect(describePageStep(step)).toBe('删除 1 个节点(主按钮 (id: btn_1)'); expect(describePageStep(step)).toBe('主按钮 (id: btn_1)');
}); });
test('remove 多个节点', () => { test('remove 多个节点', () => {
const step = { const step = {
opType: 'remove', opType: 'remove',
removedItems: [{ node: { id: 'a' } }, { node: { id: 'b' } }], diff: [{ oldSchema: { id: 'a' } }, { oldSchema: { id: 'b' } }],
} as unknown as StepValue; } as unknown as StepValue;
expect(describePageStep(step)).toBe('删除 2 个节点'); expect(describePageStep(step)).toBe('2 个节点');
}); });
test('update 单节点:附 propPath 与 id', () => { test('update 单节点:附 propPath 与 id', () => {
const step = { const step = {
opType: 'update', opType: 'update',
updatedItems: [ diff: [
{ {
newNode: { id: 'btn_1', name: '按钮' }, newSchema: { id: 'btn_1', name: '按钮' },
oldNode: { id: 'btn_1', name: '按钮' }, oldSchema: { id: 'btn_1', name: '按钮' },
changeRecords: [{ propPath: 'style.color' }], changeRecords: [{ propPath: 'style.color' }],
}, },
], ],
} as unknown as StepValue; } as unknown as StepValue;
expect(describePageStep(step)).toBe('修改 按钮 (id: btn_1) · style.color'); expect(describePageStep(step)).toBe('按钮 (id: btn_1) · style.color');
}); });
test('update 单节点无 propPath仅展示节点', () => { test('update 单节点无 propPath仅展示节点', () => {
const step = { const step = {
opType: 'update', opType: 'update',
updatedItems: [{ newNode: { id: 'btn_1', name: '按钮' }, oldNode: { id: 'btn_1' } }], diff: [{ newSchema: { id: 'btn_1', name: '按钮' }, oldSchema: { id: 'btn_1' } }],
} as unknown as StepValue; } as unknown as StepValue;
expect(describePageStep(step)).toBe('修改 按钮 (id: btn_1)'); expect(describePageStep(step)).toBe('按钮 (id: btn_1)');
}); });
test('update 多节点:返回数量', () => { test('update 多节点:返回数量', () => {
const step = { const step = {
opType: 'update', opType: 'update',
updatedItems: [ diff: [
{ newNode: { id: 'a' }, oldNode: { id: 'a' } }, { newSchema: { id: 'a' }, oldSchema: { id: 'a' } },
{ newNode: { id: 'b' }, oldNode: { id: 'b' } }, { newSchema: { id: 'b' }, oldSchema: { id: 'b' } },
], ],
} as unknown as StepValue; } as unknown as StepValue;
expect(describePageStep(step)).toBe('修改 2 个节点'); expect(describePageStep(step)).toBe('2 个节点');
}); });
test('update updatedItems 缺省:兜底为「修改节点」', () => { test('update diff 缺省:兜底为「节点」', () => {
const step = { opType: 'update' } as unknown as StepValue; const step = { opType: 'update' } as unknown as StepValue;
expect(describePageStep(step)).toBe('修改节点'); expect(describePageStep(step)).toBe('节点');
}); });
}); });
describe('describePageGroup', () => { describe('describePageGroup', () => {
test('historyDescription 取最后一条非空的描述', () => { test('historyDescription 取最后一条非空的描述', () => {
const group: PageHistoryGroup = { const group: HistoryGroup<StepValue> = {
kind: 'page', kind: 'page',
pageId: 'p1', id: 'p1',
opType: 'update', opType: 'update',
targetId: 'btn_1', targetId: 'btn_1',
targetName: '按钮', targetName: '按钮',
@ -219,61 +225,61 @@ describe('describePageGroup', () => {
test('单步 group 复用 describePageStep', () => { test('单步 group 复用 describePageStep', () => {
const step = { const step = {
opType: 'update', opType: 'update',
updatedItems: [{ newNode: { id: 'a', name: 'A' }, oldNode: { id: 'a' } }], diff: [{ newSchema: { id: 'a', name: 'A' }, oldSchema: { id: 'a' } }],
} as unknown as StepValue; } as unknown as StepValue;
const group: PageHistoryGroup = { const group: HistoryGroup<StepValue> = {
kind: 'page', kind: 'page',
pageId: 'p1', id: 'p1',
opType: 'update', opType: 'update',
targetId: 'a', targetId: 'a',
targetName: 'A', targetName: 'A',
applied: true, applied: true,
steps: [buildPageEntry(step)], steps: [buildPageEntry(step)],
}; };
expect(describePageGroup(group)).toBe('修改 A (id: a)'); expect(describePageGroup(group)).toBe('A (id: a)');
}); });
test('多步合并组:聚合 propPath 列表', () => { test('多步合并组:展示目标名称与 id', () => {
const mkStep = (path: string) => const mkStep = (path: string) =>
({ ({
opType: 'update', opType: 'update',
updatedItems: [ diff: [
{ {
newNode: { id: 'btn_1', name: '按钮' }, newSchema: { id: 'btn_1', name: '按钮' },
oldNode: { id: 'btn_1', name: '按钮' }, oldSchema: { id: 'btn_1', name: '按钮' },
changeRecords: [{ propPath: path }], changeRecords: [{ propPath: path }],
}, },
], ],
}) as unknown as StepValue; }) as unknown as StepValue;
const group: PageHistoryGroup = { const group: HistoryGroup<StepValue> = {
kind: 'page', kind: 'page',
pageId: 'p1', id: 'p1',
opType: 'update', opType: 'update',
targetId: 'btn_1', targetId: 'btn_1',
targetName: '按钮', targetName: '按钮',
applied: true, applied: true,
steps: [buildPageEntry(mkStep('style.color'), 0), buildPageEntry(mkStep('style.fontSize'), 1)], steps: [buildPageEntry(mkStep('style.color'), 0), buildPageEntry(mkStep('style.fontSize'), 1)],
}; };
expect(describePageGroup(group)).toBe('修改 按钮 (id: btn_1) · style.color, style.fontSize'); expect(describePageGroup(group)).toBe('按钮 (id: btn_1)');
}); });
test('多步合并组:超过 3 个 propPath 时截断并加省略号', () => { test('多步合并组:多步时仍仅展示目标', () => {
const mkStep = (path: string) => const mkStep = (path: string) =>
({ ({
opType: 'update', opType: 'update',
updatedItems: [ diff: [
{ {
newNode: { id: 'btn_1', name: '按钮' }, newSchema: { id: 'btn_1', name: '按钮' },
oldNode: { id: 'btn_1' }, oldSchema: { id: 'btn_1' },
changeRecords: [{ propPath: path }], changeRecords: [{ propPath: path }],
}, },
], ],
}) as unknown as StepValue; }) as unknown as StepValue;
const group: PageHistoryGroup = { const group: HistoryGroup<StepValue> = {
kind: 'page', kind: 'page',
pageId: 'p1', id: 'p1',
opType: 'update', opType: 'update',
targetId: 'btn_1', targetId: 'btn_1',
targetName: '按钮', targetName: '按钮',
@ -285,233 +291,42 @@ describe('describePageGroup', () => {
buildPageEntry(mkStep('d'), 3), buildPageEntry(mkStep('d'), 3),
], ],
}; };
const desc = describePageGroup(group); expect(describePageGroup(group)).toBe('按钮 (id: btn_1)');
expect(desc).toContain('修改 按钮 (id: btn_1) · a, b, c');
expect(desc.endsWith('…')).toBe(true);
}); });
test('多步合并组无 propPath 时仅展示目标', () => { test('多步合并组无 propPath 时仅展示目标', () => {
const mkStep = () => const mkStep = () =>
({ ({
opType: 'update', opType: 'update',
updatedItems: [{ newNode: { id: 'btn_1', name: '按钮' }, oldNode: { id: 'btn_1' } }], diff: [{ newSchema: { id: 'btn_1', name: '按钮' }, oldSchema: { id: 'btn_1' } }],
}) as unknown as StepValue; }) as unknown as StepValue;
const group: PageHistoryGroup = { const group: HistoryGroup<StepValue> = {
kind: 'page', kind: 'page',
pageId: 'p1', id: 'p1',
opType: 'update', opType: 'update',
targetId: 'btn_1', targetId: 'btn_1',
targetName: '按钮', targetName: '按钮',
applied: true, applied: true,
steps: [buildPageEntry(mkStep(), 0), buildPageEntry(mkStep(), 1)], steps: [buildPageEntry(mkStep(), 0), buildPageEntry(mkStep(), 1)],
}; };
expect(describePageGroup(group)).toBe('修改 按钮 (id: btn_1)'); expect(describePageGroup(group)).toBe('按钮 (id: btn_1)');
}); });
test('多步组 targetName 缺省时使用 targetId 兜底', () => { test('多步组 targetName 缺省时使用 targetId 兜底', () => {
const group: PageHistoryGroup = { const group: HistoryGroup<StepValue> = {
kind: 'page', kind: 'page',
pageId: 'p1', id: 'p1',
opType: 'update', opType: 'update',
targetId: 'btn_1', targetId: 'btn_1',
applied: true, applied: true,
steps: [ steps: [
buildPageEntry({ opType: 'update', updatedItems: [] } as any, 0), buildPageEntry({ opType: 'update', diff: [] } as any, 0),
buildPageEntry({ opType: 'update', updatedItems: [] } as any, 1), buildPageEntry({ opType: 'update', diff: [] } as any, 1),
], ],
}; };
// targetName 为 undefinedlabelWithId 看 label === id 时只展示 id // targetName 为 undefinedlabelWithId 看 label === id 时只展示 id
expect(describePageGroup(group)).toBe('修改 btn_1'); expect(describePageGroup(group)).toBe('btn_1');
});
});
describe('describeDataSourceStep', () => {
test('historyDescription 优先', () => {
const step: DataSourceStepValue = {
id: 'ds_1',
oldSchema: null,
newSchema: null,
historyDescription: '自定义',
};
expect(describeDataSourceStep(step)).toBe('自定义');
});
test('新增oldSchema=null展示 title 与 id', () => {
const step: DataSourceStepValue = {
id: 'ds_1',
oldSchema: null,
newSchema: { id: 'ds_1', title: '用户列表' } as any,
};
expect(describeDataSourceStep(step)).toBe('创建 用户列表 (id: ds_1)');
});
test('删除newSchema=null展示 title 与 id', () => {
const step: DataSourceStepValue = {
id: 'ds_1',
oldSchema: { id: 'ds_1', title: '用户列表' } as any,
newSchema: null,
};
expect(describeDataSourceStep(step)).toBe('删除 用户列表 (id: ds_1)');
});
test('修改:展示 propPath', () => {
const step: DataSourceStepValue = {
id: 'ds_1',
oldSchema: { id: 'ds_1', title: '用户列表' } as any,
newSchema: { id: 'ds_1', title: '用户列表' } as any,
changeRecords: [{ propPath: 'fields.0.name' } as any],
};
expect(describeDataSourceStep(step)).toBe('修改 用户列表 (id: ds_1) · fields.0.name');
});
test('修改无 title 时仅展示 id', () => {
const step: DataSourceStepValue = {
id: 'ds_1',
oldSchema: { id: 'ds_1' } as any,
newSchema: { id: 'ds_1' } as any,
};
expect(describeDataSourceStep(step)).toBe('修改 ds_1');
});
});
describe('describeDataSourceGroup', () => {
test('多步组:聚合 propPath 与目标 id', () => {
const mkStep = (path: string): DataSourceStepValue => ({
id: 'ds_1',
oldSchema: { id: 'ds_1', title: 'T' } as any,
newSchema: { id: 'ds_1', title: 'T' } as any,
changeRecords: [{ propPath: path } as any],
});
const group: DataSourceHistoryGroup = {
kind: 'data-source',
id: 'ds_1',
opType: 'update',
applied: true,
steps: [
{ step: mkStep('a'), index: 0, applied: true },
{ step: mkStep('b'), index: 1, applied: true },
],
};
expect(describeDataSourceGroup(group)).toBe('修改 T (id: ds_1) · a, b');
});
test('单步组:复用 describeDataSourceStep', () => {
const group: DataSourceHistoryGroup = {
kind: 'data-source',
id: 'ds_1',
opType: 'add',
applied: true,
steps: [
{
step: { id: 'ds_1', oldSchema: null, newSchema: { id: 'ds_1', title: 'T' } as any },
index: 0,
applied: true,
},
],
};
expect(describeDataSourceGroup(group)).toBe('创建 T (id: ds_1)');
});
test('historyDescription 优先', () => {
const group: DataSourceHistoryGroup = {
kind: 'data-source',
id: 'ds_1',
opType: 'update',
applied: true,
steps: [
{
step: {
id: 'ds_1',
oldSchema: null,
newSchema: null,
historyDescription: '我的描述',
},
index: 0,
applied: true,
},
],
};
expect(describeDataSourceGroup(group)).toBe('我的描述');
});
});
describe('describeCodeBlockStep', () => {
test('新增', () => {
const step: CodeBlockStepValue = {
id: 'code_1',
oldContent: null,
newContent: { id: 'code_1', name: 'onClick' } as any,
};
expect(describeCodeBlockStep(step)).toBe('创建 onClick (id: code_1)');
});
test('删除', () => {
const step: CodeBlockStepValue = {
id: 'code_1',
oldContent: { id: 'code_1', name: 'onClick' } as any,
newContent: null,
};
expect(describeCodeBlockStep(step)).toBe('删除 onClick (id: code_1)');
});
test('修改 + propPath', () => {
const step: CodeBlockStepValue = {
id: 'code_1',
oldContent: { id: 'code_1', name: 'onClick' } as any,
newContent: { id: 'code_1', name: 'onClick' } as any,
changeRecords: [{ propPath: 'content' } as any],
};
expect(describeCodeBlockStep(step)).toBe('修改 onClick (id: code_1) · content');
});
test('historyDescription 优先', () => {
const step: CodeBlockStepValue = {
id: 'code_1',
oldContent: null,
newContent: null,
historyDescription: '自定义说明',
};
expect(describeCodeBlockStep(step)).toBe('自定义说明');
});
});
describe('describeCodeBlockGroup', () => {
test('多步组:聚合 propPath', () => {
const mkStep = (path: string): CodeBlockStepValue => ({
id: 'code_1',
oldContent: { id: 'code_1', name: 'fn' } as any,
newContent: { id: 'code_1', name: 'fn' } as any,
changeRecords: [{ propPath: path } as any],
});
const group: CodeBlockHistoryGroup = {
kind: 'code-block',
id: 'code_1',
opType: 'update',
applied: true,
steps: [
{ step: mkStep('content'), index: 0, applied: true },
{ step: mkStep('params'), index: 1, applied: true },
],
};
expect(describeCodeBlockGroup(group)).toBe('修改 fn (id: code_1) · content, params');
});
test('单步组:复用 step 描述', () => {
const group: CodeBlockHistoryGroup = {
kind: 'code-block',
id: 'code_1',
opType: 'remove',
applied: false,
steps: [
{
step: { id: 'code_1', oldContent: { id: 'code_1', name: 'fn' } as any, newContent: null },
index: 0,
applied: false,
},
],
};
expect(describeCodeBlockGroup(group)).toBe('删除 fn (id: code_1)');
}); });
}); });
@ -528,16 +343,22 @@ describe('useHistoryList', () => {
const wrapper = mount(host, { const wrapper = mount(host, {
global: { global: {
provide: { provide: {
services: { historyService }, // useHistoryList 现在按当前页 id 取页面历史,提供 editorService.get('page') 返回固定 id 'p1'。
services: {
historyService,
editorService: { get: (k?: string) => (k === 'page' ? { id: 'p1' } : undefined) },
},
}, },
}, },
}); });
return { api, wrapper }; return { api, wrapper };
}; };
test('toggleGroup 切换 expanded[key]', () => { test('toggleGroup 切换 expanded[key](默认展开)', () => {
const { api } = mountWithHost(); const { api } = mountWithHost();
expect(api.expanded.foo).toBeFalsy(); expect(api.expanded.foo).toBeUndefined();
api.toggleGroup('foo');
expect(api.expanded.foo).toBe(false);
api.toggleGroup('foo'); api.toggleGroup('foo');
expect(api.expanded.foo).toBe(true); expect(api.expanded.foo).toBe(true);
api.toggleGroup('foo'); api.toggleGroup('foo');
@ -547,17 +368,24 @@ describe('useHistoryList', () => {
test('pageGroupsDisplay按时间倒序', () => { test('pageGroupsDisplay按时间倒序', () => {
const { api } = mountWithHost(); const { api } = mountWithHost();
historyService.changePage({ id: 'p1' } as any); historyService.push(
historyService.push({ 'page',
{
opType: 'add', opType: 'add',
nodes: [{ id: 'n1', name: 'A' }], diff: [{ newSchema: { id: 'n1', name: 'A' } }],
modifiedNodeIds: new Map(), modifiedNodeIds: new Map(),
} as any); } as any,
historyService.push({ 'p1',
);
historyService.push(
'page',
{
opType: 'remove', opType: 'remove',
removedItems: [{ node: { id: 'n2', name: 'B' } }], diff: [{ oldSchema: { id: 'n2', name: 'B' } }],
modifiedNodeIds: new Map(), modifiedNodeIds: new Map(),
} as any); } as any,
'p1',
);
expect(api.pageGroups.value).toHaveLength(2); expect(api.pageGroups.value).toHaveLength(2);
// 正序:最早的 add 在前;倒序展示:最新的 remove 在前 // 正序:最早的 add 在前;倒序展示:最新的 remove 在前
@ -568,15 +396,15 @@ describe('useHistoryList', () => {
test('dataSourceGroupsByTarget按 id 聚拢,每 bucket 内倒序', () => { test('dataSourceGroupsByTarget按 id 聚拢,每 bucket 内倒序', () => {
const { api } = mountWithHost(); const { api } = mountWithHost();
historyService.pushDataSource('ds_1', { pushDataSource('ds_1', {
oldSchema: null, oldSchema: null,
newSchema: { id: 'ds_1', title: 'A' } as any, newSchema: { id: 'ds_1', title: 'A' } as any,
}); });
historyService.pushDataSource('ds_1', { pushDataSource('ds_1', {
oldSchema: { id: 'ds_1', title: 'A' } as any, oldSchema: { id: 'ds_1', title: 'A' } as any,
newSchema: { id: 'ds_1', title: 'A2' } as any, newSchema: { id: 'ds_1', title: 'A2' } as any,
}); });
historyService.pushDataSource('ds_2', { pushDataSource('ds_2', {
oldSchema: null, oldSchema: null,
newSchema: { id: 'ds_2', title: 'B' } as any, newSchema: { id: 'ds_2', title: 'B' } as any,
}); });
@ -596,11 +424,11 @@ describe('useHistoryList', () => {
test('codeBlockGroupsByTarget按 id 聚拢', () => { test('codeBlockGroupsByTarget按 id 聚拢', () => {
const { api } = mountWithHost(); const { api } = mountWithHost();
historyService.pushCodeBlock('code_1', { pushCodeBlock('code_1', {
oldContent: null, oldContent: null,
newContent: { id: 'code_1', name: 'fn' } as any, newContent: { id: 'code_1', name: 'fn' } as any,
}); });
historyService.pushCodeBlock('code_2', { pushCodeBlock('code_2', {
oldContent: null, oldContent: null,
newContent: { id: 'code_2', name: 'fn2' } as any, newContent: { id: 'code_2', name: 'fn2' } as any,
}); });
@ -613,15 +441,15 @@ describe('useHistoryList', () => {
describe('isPageStepRevertable', () => { describe('isPageStepRevertable', () => {
test('add / remove 始终可回滚', () => { test('add / remove 始终可回滚', () => {
expect(isPageStepRevertable({ opType: 'add', nodes: [{ id: 'n1' }] } as any)).toBe(true); expect(isPageStepRevertable({ opType: 'add', diff: [{ newSchema: { id: 'n1' } }] } as any)).toBe(true);
expect(isPageStepRevertable({ opType: 'remove', removedItems: [{ node: { id: 'n1' } }] } as any)).toBe(true); expect(isPageStepRevertable({ opType: 'remove', diff: [{ oldSchema: { id: 'n1' } }] } as any)).toBe(true);
}); });
test('update 每项都有 changeRecords 才可回滚', () => { test('update 每项都有 changeRecords 才可回滚', () => {
expect( expect(
isPageStepRevertable({ isPageStepRevertable({
opType: 'update', opType: 'update',
updatedItems: [{ oldNode: { id: 'n1' }, newNode: { id: 'n1' }, changeRecords: [{ propPath: 'style.color' }] }], diff: [{ oldSchema: { id: 'n1' }, newSchema: { id: 'n1' }, changeRecords: [{ propPath: 'style.color' }] }],
} as any), } as any),
).toBe(true); ).toBe(true);
}); });
@ -630,7 +458,7 @@ describe('isPageStepRevertable', () => {
expect( expect(
isPageStepRevertable({ isPageStepRevertable({
opType: 'update', opType: 'update',
updatedItems: [{ oldNode: { id: 'n1' }, newNode: { id: 'n1' } }], diff: [{ oldSchema: { id: 'n1' }, newSchema: { id: 'n1' } }],
} as any), } as any),
).toBe(false); ).toBe(false);
}); });
@ -639,53 +467,33 @@ describe('isPageStepRevertable', () => {
expect( expect(
isPageStepRevertable({ isPageStepRevertable({
opType: 'update', opType: 'update',
updatedItems: [ diff: [
{ oldNode: { id: 'n1' }, newNode: { id: 'n1' }, changeRecords: [{ propPath: 'a' }] }, { oldSchema: { id: 'n1' }, newSchema: { id: 'n1' }, changeRecords: [{ propPath: 'a' }] },
{ oldNode: { id: 'n2' }, newNode: { id: 'n2' } }, { oldSchema: { id: 'n2' }, newSchema: { id: 'n2' } },
], ],
} as any), } as any),
).toBe(false); ).toBe(false);
}); });
test('update 无 updatedItems 不可回滚', () => { test('update 无 diff 不可回滚', () => {
expect(isPageStepRevertable({ opType: 'update' } as any)).toBe(false); expect(isPageStepRevertable({ opType: 'update' } as any)).toBe(false);
}); });
}); });
describe('isDataSourceStepRevertable', () => { describe('isSingleDiffStepRevertable', () => {
test('新增 / 删除 始终可回滚', () => { test('新增 / 删除 始终可回滚', () => {
expect(isDataSourceStepRevertable({ oldSchema: null, newSchema: { id: 'ds_1' } } as any)).toBe(true); expect(isSingleDiffStepRevertable({ diff: [{ newSchema: { id: 'ds_1' } }] } as any)).toBe(true);
expect(isDataSourceStepRevertable({ oldSchema: { id: 'ds_1' }, newSchema: null } as any)).toBe(true); expect(isSingleDiffStepRevertable({ diff: [{ oldSchema: { id: 'ds_1' } }] } as any)).toBe(true);
}); });
test('更新有 changeRecords 才可回滚', () => { test('更新有 changeRecords 才可回滚', () => {
expect( expect(
isDataSourceStepRevertable({ isSingleDiffStepRevertable({
oldSchema: { id: 'ds_1' }, diff: [{ oldSchema: { id: 'ds_1' }, newSchema: { id: 'ds_1' }, changeRecords: [{ propPath: 'title' }] }],
newSchema: { id: 'ds_1' },
changeRecords: [{ propPath: 'title' }],
} as any), } as any),
).toBe(true); ).toBe(true);
expect(isDataSourceStepRevertable({ oldSchema: { id: 'ds_1' }, newSchema: { id: 'ds_1' } } as any)).toBe(false);
});
});
describe('isCodeBlockStepRevertable', () => {
test('新增 / 删除 始终可回滚', () => {
expect(isCodeBlockStepRevertable({ oldContent: null, newContent: { id: 'code_1' } } as any)).toBe(true);
expect(isCodeBlockStepRevertable({ oldContent: { id: 'code_1' }, newContent: null } as any)).toBe(true);
});
test('更新有 changeRecords 才可回滚', () => {
expect( expect(
isCodeBlockStepRevertable({ isSingleDiffStepRevertable({ diff: [{ oldSchema: { id: 'ds_1' }, newSchema: { id: 'ds_1' } }] } as any),
oldContent: { id: 'code_1' }, ).toBe(false);
newContent: { id: 'code_1' },
changeRecords: [{ propPath: 'content' }],
} as any),
).toBe(true);
expect(isCodeBlockStepRevertable({ oldContent: { id: 'code_1' }, newContent: { id: 'code_1' } } as any)).toBe(
false,
);
}); });
}); });

View File

@ -0,0 +1,229 @@
/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2025 Tencent.
*/
import { afterEach, describe, expect, test, vi } from 'vitest';
import { tMagicMessage } from '@tmagic/design';
import { confirmHistoryAction } from '@editor/layouts/history-list/composables';
import { useHistoryRevert } from '@editor/layouts/history-list/useHistoryRevert';
vi.mock('@tmagic/design', () => ({
tMagicMessage: {
error: vi.fn(),
},
}));
vi.mock('@editor/layouts/history-list/composables', async () => {
const actual = await vi.importActual<object>('@editor/layouts/history-list/composables');
return {
...actual,
confirmHistoryAction: vi.fn(async () => true),
};
});
// 捕获动态挂载 HistoryDiffDialog 时传入 createApp 的 props用于断言 width 透传),
// 并桩掉弹窗实例的 open / confirm避免真正渲染组件。
const dialogInstance = {
open: vi.fn(),
confirm: vi.fn(async () => true),
};
const appMock = {
_context: {},
mount: vi.fn(() => dialogInstance),
unmount: vi.fn(),
};
const createAppMock = vi.fn((..._args: any[]) => appMock);
vi.mock('vue', async () => {
const actual = await vi.importActual<typeof import('vue')>('vue');
return {
...actual,
createApp: (...args: any[]) => createAppMock(...args),
// setup 之外调用,强制返回 null避免依赖宿主组件上下文
getCurrentInstance: () => null,
};
});
vi.mock('@editor/layouts/history-list/HistoryDiffDialog.vue', () => ({
default: { name: 'MEditorHistoryDiffDialog' },
}));
/** 读取最近一次挂载弹窗时传入的 propscreateApp 第二个参数)。 */
const lastDialogProps = () => createAppMock.mock.calls.at(-1)?.[1] as unknown as Record<string, any>;
const createServices = () =>
({
editorService: {
get: vi.fn(() => ({ id: 'p1' })),
getNodeById: vi.fn(),
revertPageStep: vi.fn(async () => null),
},
dataSourceService: {
getDataSourceById: vi.fn(),
revert: vi.fn(async () => null),
},
codeBlockService: {
getCodeContentById: vi.fn(),
revert: vi.fn(async () => null),
},
historyService: {
getStepList: vi.fn(() => []),
getHistoryGroups: vi.fn(() => []),
},
}) as any;
/** 构造一个可差异对比(单实体 update前后值都在的历史分组用于触发差异弹窗。 */
const diffableGroups = (id: string | number = 'p1') => [
{
id,
steps: [
{
index: 0,
step: { diff: [{ oldSchema: { id: 'n1', name: 'A' }, newSchema: { id: 'n1', name: 'B' } }] },
},
],
},
];
afterEach(() => {
vi.clearAllMocks();
});
describe('useHistoryRevert', () => {
test('页面 update 记录的目标节点已删除时,提示错误且不执行回滚', async () => {
const services = createServices();
services.historyService.getStepList.mockReturnValue([
{
step: {
opType: 'update',
diff: [{ newSchema: { id: 'gone' }, oldSchema: { id: 'gone' } }],
},
},
]);
services.editorService.getNodeById.mockReturnValue(null);
const { onPageRevert } = useHistoryRevert({}, services);
await onPageRevert(0);
expect(tMagicMessage.error).toHaveBeenCalledWith('回滚失败:该记录对应的数据已被删除');
expect(services.editorService.revertPageStep).not.toHaveBeenCalled();
});
test('页面 add 记录回滚时走普通二次确认,并执行 revertPageStep', async () => {
const services = createServices();
services.historyService.getStepList.mockReturnValue([
{
step: {
opType: 'add',
diff: [{ newSchema: { id: 'n1', name: 'A' } }],
},
},
]);
const { onPageRevert } = useHistoryRevert({}, services);
await onPageRevert(0);
expect(confirmHistoryAction).toHaveBeenCalled();
expect(services.editorService.revertPageStep).toHaveBeenCalledWith(0);
});
test('数据源 update 记录对应目标已删除时,提示错误且不执行回滚', async () => {
const services = createServices();
services.historyService.getStepList.mockReturnValue([
{
step: {
opType: 'update',
diff: [{ newSchema: { id: 'ds_1' }, oldSchema: { id: 'ds_1' } }],
},
},
]);
services.dataSourceService.getDataSourceById.mockReturnValue(null);
const { onDataSourceRevert } = useHistoryRevert({}, services);
await onDataSourceRevert('ds_1', 0);
expect(tMagicMessage.error).toHaveBeenCalledWith('回滚失败:该记录对应的数据已被删除');
expect(services.dataSourceService.revert).not.toHaveBeenCalled();
});
describe('弹窗宽度透传', () => {
test('onPageDiff 使用 options.dialogWidth 作为弹窗宽度并打开弹窗', async () => {
const services = createServices();
services.historyService.getHistoryGroups.mockReturnValue(diffableGroups());
const { onPageDiff } = useHistoryRevert({ dialogWidth: '1200px' }, services);
await onPageDiff(0);
expect(createAppMock).toHaveBeenCalledTimes(1);
expect(lastDialogProps().width).toBe('1200px');
expect(lastDialogProps().isConfirm).toBe(false);
expect(dialogInstance.open).toHaveBeenCalled();
});
test('未配置 dialogWidth 时不强制 width由弹窗内置默认值兜底', async () => {
const services = createServices();
services.historyService.getHistoryGroups.mockReturnValue(diffableGroups());
const { onPageDiff } = useHistoryRevert({}, services);
await onPageDiff(0);
expect(createAppMock).toHaveBeenCalledTimes(1);
expect(lastDialogProps().width).toBeUndefined();
});
test('onPageRevert 在可差异步骤上走差异确认弹窗并透传 dialogWidth', async () => {
const services = createServices();
services.historyService.getStepList.mockReturnValue([
{ step: { opType: 'update', diff: [{ newSchema: { id: 'n1' }, oldSchema: { id: 'n1' } }] } },
]);
services.editorService.getNodeById.mockReturnValue({ id: 'n1' });
services.historyService.getHistoryGroups.mockReturnValue(diffableGroups());
const { onPageRevert } = useHistoryRevert({ dialogWidth: '80%' }, services);
await onPageRevert(0);
expect(lastDialogProps().width).toBe('80%');
expect(lastDialogProps().isConfirm).toBe(true);
expect(dialogInstance.confirm).toHaveBeenCalled();
expect(services.editorService.revertPageStep).toHaveBeenCalledWith(0);
});
test('viewDiff 的逐次 width 入参可覆盖 dialogWidth 默认值', async () => {
const services = createServices();
const { viewDiff } = useHistoryRevert({ dialogWidth: '1000px' }, services);
await viewDiff({ category: 'module', lastValue: { a: 1 }, value: { a: 2 } } as any, { width: '600px' });
expect(lastDialogProps().width).toBe('600px');
});
test('viewDiff 未传 width 时回退到 dialogWidth 默认值', async () => {
const services = createServices();
const { viewDiff } = useHistoryRevert({ dialogWidth: '1000px' }, services);
await viewDiff({ category: 'module', lastValue: { a: 1 }, value: { a: 2 } } as any);
expect(lastDialogProps().width).toBe('1000px');
});
test('confirmAndRevert 透传 width 至确认弹窗并在确认后执行 revert', async () => {
const services = createServices();
const revert = vi.fn(async () => 'done');
const { confirmAndRevert } = useHistoryRevert({ dialogWidth: '1000px' }, services);
const result = await confirmAndRevert({
diffPayload: { category: 'module', lastValue: { a: 1 }, value: { a: 2 } } as any,
width: '720px',
revert,
});
expect(lastDialogProps().width).toBe('720px');
expect(lastDialogProps().isConfirm).toBe(true);
expect(revert).toHaveBeenCalled();
expect(result).toBe('done');
});
});
});

View File

@ -9,6 +9,8 @@ import { mount } from '@vue/test-utils';
import PageBar from '@editor/layouts/page-bar/PageBar.vue'; import PageBar from '@editor/layouts/page-bar/PageBar.vue';
const { messageBoxConfirm } = vi.hoisted(() => ({ messageBoxConfirm: vi.fn(async () => undefined) }));
const editorState = { const editorState = {
page: ref<any>({ id: 'p1' }), page: ref<any>({ id: 'p1' }),
root: ref<any>({ root: ref<any>({
@ -120,6 +122,7 @@ vi.mock('@tmagic/design', () => ({
return () => h('div', { class: 'fake-popover' }, [slots.reference?.(), slots.default?.()]); return () => h('div', { class: 'fake-popover' }, [slots.reference?.(), slots.default?.()]);
}, },
}), }),
tMagicMessageBox: { confirm: messageBoxConfirm },
})); }));
beforeEach(() => { beforeEach(() => {
@ -171,6 +174,7 @@ describe('PageBar.vue', () => {
const wrapper = factory(); const wrapper = factory();
const removeBtn = wrapper.findAll('.remove')[0]; const removeBtn = wrapper.findAll('.remove')[0];
await removeBtn.trigger('click'); await removeBtn.trigger('click');
expect(messageBoxConfirm).toHaveBeenCalledWith('确定删除该页面吗?');
expect(editorService.remove).toHaveBeenCalled(); expect(editorService.remove).toHaveBeenCalled();
}); });

Some files were not shown because too many files have changed in this diff Show More