Compare commits

...

67 Commits

Author SHA1 Message Date
roymondchen
c4ec2c5c72 perf(editor): 优化节点信息查找性能 2026-06-09 14:23:43 +08:00
roymondchen
48519b0155 fix(editor): 优化历史回滚确认流程 2026-06-09 11:05:26 +08:00
roymondchen
a965dfb06e refactor(editor): 优化历史记录列表复用 2026-06-08 20:09:10 +08:00
roymondchen
614f12adf3 feat(editor): 支持历史记录持久化
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 17:04:39 +08:00
roymondchen
bddc6f343c feat(editor): 支持按历史记录 uuid 回滚 2026-06-05 19:25:50 +08:00
roymondchen
be3a900e6a fix(editor): 修复历史对比属性配置上下文缺失 2026-06-05 17:27:20 +08:00
roymondchen
bc555ebdc0 chore: update lockfile v1.8.0-beta.4 2026-06-04 17:15:03 +08:00
roymondchen
b7d1cea7c1 chore: release v1.8.0-beta.4 2026-06-04 17:13:59 +08:00
roymondchen
3bd0eecb42 fix(editor): 修复合并历史记录信息展示
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 17:10:54 +08:00
roymondchen
cd19dec790 fix(editor): 修复历史对比样式配置显示
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 16:59:08 +08:00
roymondchen
10b70c36bb fix(editor): 禁止缺少变更记录的历史回滚 2026-06-04 16:48:24 +08:00
roymondchen
27b2c2c685 feat(editor): 历史记录支持操作来源 2026-06-04 16:08:52 +08:00
roymondchen
a8a9cf372d chore: update lockfile v1.8.0-beta.3 2026-06-04 14:13:01 +08:00
roymondchen
6253d7ed23 chore: release v1.8.0-beta.3 2026-06-04 14:12:13 +08:00
roymondchen
444d4223a9 feat(stage): 非点击画布选中组件时高亮闪烁选中区域
从图层树、面包屑等外部选中组件时,在画布上对选中区域做一次紫色高亮闪烁,
帮助用户快速定位组件;选中页面不触发。支持通过 editor 的 disabledFlashTip 关闭。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 14:02:57 +08:00
roymondchen
a9e9e65f9c feat(editor): 历史记录列表展示时间并优化回滚差异弹窗
为历史步骤自动写入 timestamp 并按当天/跨天格式化展示;回滚确认弹窗区分标题与说明,关闭时清理确认回调。
2026-06-03 18:09:21 +08:00
roymondchen
42162f2e4a feat(editor): 历史记录差异对比弹窗关闭时派发 close 事件
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 19:56:34 +08:00
roymondchen
7a161cab00 refactor(editor): 历史记录数据源/代码块 tab 复用通用 BucketTab 2026-06-02 19:07:38 +08:00
roymondchen
1cd69b33fe feat(editor): 对比表单支持自定义 loadConfig 加载逻辑
将 CompareCategory 等类型抽取到 type.ts,
新增 CompareFormLoadConfig 支持外部接管表单配置加载,
HistoryDiffDialog 透传 loadConfig 并支持 width 配置及对外导出。
2026-06-02 17:03:27 +08:00
roymondchen
12069e0937 feat(form): submitForm 支持返回 changeRecords
新增 returnChangeRecords 选项,开启后 resolve { values, changeRecords },
便于命令式调用时获取表单变更记录,并同步更新文档与单测。
2026-06-02 16:43:07 +08:00
roymondchen
1b66ab1b88 refactor(editor): 抽取 serializeConfig 工具统一序列化配置
将分散在 CodeLink、CodeEditor 及 playground 中重复的 serialize-javascript
序列化逻辑收敛为 @editor/utils/editor 的 serializeConfig 并对外导出复用。
2026-06-02 16:34:23 +08:00
roymondchen
64d35d5363 fix(form): 对比模式下无 name 字段时不展示差异
避免 name 为空时拿整个 model/lastValues 做对比导致误判

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 16:28:08 +08:00
roymondchen
35fc394199 feat(form): fieldset legend 支持函数动态生成标题 2026-06-02 14:24:09 +08:00
roymondchen
8612311db1 feat(editor): 历史记录面板支持自定义扩展 tab 并开放 Bucket/goto 配置
新增 historyListExtraTabs 配置,可在内置页面/数据源/代码块 tab 后追加业务自定义历史 tab。
导出 HistoryListBucket 供复用,GroupRow 支持配置是否允许跳转,Bucket 支持配置是否展示初始项。
2026-06-01 19:21:36 +08:00
roymondchen
818b41f07f chore: update lockfile v1.8.0-beta.2 2026-05-29 18:56:40 +08:00
roymondchen
9b34124805 chore: release v1.8.0-beta.2 2026-05-29 18:55:38 +08:00
roymondchen
7a61a35664 fix(editor): 显式标注 CompareForm 的 defineExpose 类型以修复 DTS 构建报错
defineExpose 同时暴露 MForm 实例 ref 与递归的 FormConfig ref,导致
vue-tsc 生成声明文件时推断类型过大无法序列化(TS7056)。改为显式标注
暴露类型,使其引用具名别名而非展开完整结构。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 18:53:08 +08:00
roymondchen
025cca365c perf(dep): 依赖收集改为单次遍历批量处理多 target
将 collectItems/removeTargetsDep 改为整棵树只遍历一次、在每个属性上检查所有
target,把结构遍历开销从 ×targets 降到 ×1,收集结果保持一致。

同时修正 dataSourceMethodDeps 字段命名并补充到 MApp schema。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 17:55:13 +08:00
roymondchen
a3333e2b4e feat(editor): 新增 hideSidebar 配置支持隐藏左侧面板
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 16:49:10 +08:00
roymondchen
cbc4b25072 feat(editor): 字段对比模式逐项展示差异并补充历史记录面板文档
- CodeSelect/CodeSelectCol/EventSelect/DataSource 等复合字段在对比模式下
  按索引对齐前后值,逐项展示新增/删除/修改高亮,并隐藏写操作按钮
- form 容器/列表/表格支持对比模式只读展示
- 新增「历史记录面板」指南文档,完善表单对比文档及 menu props 说明
- 补充相关单元测试

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 15:51:47 +08:00
roymondchen
b02aa75ddc feat(editor): 历史记录面板支持单步回滚(类 git revert)
将目标历史步骤的修改作为一次新操作反向应用,不破坏原有栈结构,
page/dataSource/codeBlock 三类 service 均提供 revert 能力;
面板新增关闭按钮、步骤编号展示与合并组卡片样式优化。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 14:19:44 +08:00
roymondchen
f0c66427b8 feat: form 新增 showDiff prop 支持自定义对比判断
- form: MForm/Container 新增 showDiff prop,允许调用方自定义

  '是否展示对比内容' 的判断逻辑,并在嵌套 Container 中自动透传;

  不传时沿用默认的 isEqual 行为

- editor: CompareForm 利用该能力处理 code-select 字段中 '' 与

  { hookType: 'code', hookData: [] } 两种语义为空形态被 isEqual 误判为差异的问题

- docs: 补充 form-props.md 中 showDiff 的说明与示例

- test: 补充 Code 字段相关单测
2026-05-28 20:30:05 +08:00
roymondchen
c854dfa8bf feat(editor): vs-code 字段对比模式改用 monaco diff 编辑器
- Container.vue 新增「自接管对比」字段类型白名单(当前含 vs-code),命中时只渲染一次组件并透传 model/lastValues/isCompare,由字段内部展示差异
- Code.vue 在 isCompare 模式下切换到 type='diff',使用 monaco 内置 diff 视图替代两个独立编辑器实例
- CodeEditor.vue 补充对 modifiedValues 的 watch,避免 diff 模式下右侧值停留在初始快照
2026-05-28 20:12:46 +08:00
roymondchen
59f4e0edac feat(editor): 历史记录面板支持差异对比
- 新增 HistoryDiffDialog 历史差异对比弹窗
- 新增 CompareForm 表单对比组件
- 抽取 code-block 工具函数到 utils/code-block.ts
- 历史列表面板支持选择两个版本进行对比
2026-05-28 19:49:03 +08:00
roymondchen
0f8abf7298 fix: 对比模式下关闭 tab-pane 的 lazy,确保差异数能正确统计 2026-05-28 19:32:54 +08:00
roymondchen
62a2ee6693 feat(editor): 历史记录面板支持点击跳转与回到初始状态
- 单步组头部点击跳转到该步骤;合并组头部点击展开/收起,子步行点击跳转到具体步骤
- 列表底部新增「初始」记录项,可一键回到所有修改之前的状态
- editorService/dataSourceService/codeBlockService 新增 goto API;historyService 暴露 cursor 读取器
2026-05-28 18:52:11 +08:00
roymondchen
0446202ba6 feat(editor): 新增历史记录列表面板
- 新增 history-list 模块(面板、Tab、Bucket、GroupRow 与 composables)
- NavMenu 接入历史记录面板入口
- history/editor/codeBlock/dataSource service 配合面板能力调整
- utils/undo-redo 适配新面板
- 扩展 type.ts 相关类型定义
- 新增 history-list-panel.scss 并在 theme.scss 引入
- 补充 history-list 模块完整单元测试
- playground 同步小幅调整
2026-05-28 17:51:52 +08:00
roymondchen
285434ef3e feat(form): 支持自定义 label slot
在 MForm / Container 上新增具名作用域插槽 label,允许使用方自定义表单项标题渲染。
Slot 作用域参数:config、type、text、prop、disabled。
类型 FormLabelSlotProps / FormSlots 提取到 schema.ts 复用。
2026-05-28 16:45:11 +08:00
roymondchen
8dae67769c feat(editor): 数据源与代码块 service 支持 undo/redo
- dataSourceService / codeBlockService 新增 undo / redo / canUndo / canRedo 方法
- undo/redo 内部复用 add / update / remove / setCodeDslByIdSync / deleteCodeDslByIds 写回,
  并强制 doNotPushHistory,借此自动驱动 initService 中的依赖收集链路
  (DepTargetType.DATA_SOURCE / DATA_SOURCE_COND / DATA_SOURCE_METHOD / CODE_BLOCK)
- 更新场景下若 step 带 changeRecords,按 propPath 局部 patch,不冲掉同节点其它无关变更;
  缺省退化为整 schema / 整内容替换
- 补充对应单测与 API 文档
2026-05-28 16:40:49 +08:00
roymondchen
09558fa027 feat(editor): 历史记录接入 changeRecords,undo/redo 按 propPath 局部更新
- 节点 / 数据源 / 代码块的 history step 增加 changeRecords 字段

- editor.update / dataSource.update / codeBlock.setCodeDslById(Sync) 透传 changeRecords 入历史

- applyHistoryOp 的 update 分支:携带 changeRecords 时,按 propPath 从 oldNode/newNode 取值

  构造最小 patch 走 update,不冲掉同节点上其它无关变更;缺省退化为整节点替换

  (覆盖 sort/moveLayer/拖动等纯快照场景)

- editor.update 增加 changeRecordList 形参,多节点场景每个节点单独保留 records;

  use-stage 多选拖动 / 缩放改用 changeRecordList,避免 records 在多节点间共享

- use-code-block-edit.submitCodeBlockHandler 透传 form changeRecords

- 同步更新 editor / dataSource / codeBlock / history service 文档
2026-05-28 16:28:35 +08:00
roymondchen
4c855ba50b feat(editor): 写操作支持 doNotPushHistory 选项以跳过历史记录
- editor/codeBlock/dataSource 的 add/update/delete 等接口新增 doNotPushHistory 选项
- 移除不再使用的 editor-history 工具及其单测
- 修复 layer 节点状态在重建时丢失已有 status 的问题
- 同步更新 service 方法文档,新增 dragto 复现用例
2026-05-28 16:03:29 +08:00
roymondchen
e2c065f90d feat(editor): 代码块与数据源支持按 id 独立的历史记录
- history service 新增 pushCodeBlock/undoCodeBlock/redoCodeBlock
  /canUndoCodeBlock/canRedoCodeBlock 及数据源对称 API
- 按 id 维度各自维护独立 UndoRedo 栈,与页面/节点历史完全解耦
- type 新增 CodeBlockStepValue / DataSourceStepValue 独立类型
- HistoryState 扩展 codeBlockState / dataSourceState 字段
- codeBlockService.setCodeDslByIdSync / deleteCodeDslByIds 自动入历史
- dataSourceService.add / update / remove 自动入历史
- 入栈成功时 emit code-block-history-change / data-source-history-change
- 补充单测共 21 例,更新 history/codeBlock/dataSource 相关文档
2026-05-27 19:50:17 +08:00
roymondchen
a341c7d73e fix(editor): 多选时对多个节点的操作合并入同一条历史记录
- moveToContainer 支持数组形参,多选移动整批只产生一条历史记录

- use-stage 拖动多选元素入容器 / 多选拖动缩放整批合成一次调用

- 右键移动至改走 moveToContainer,避免 remove+add 切成两条历史

- 跳过选中目标节点的分支清理 state.nodes 残留旧引用

- history.push 新增可选 pageId 参数,跨页操作正确落到目标页栈

- pushOpHistory 显式按 step.data.id 入栈,避免跨页操作错配
2026-05-27 19:09:34 +08:00
roymondchen
de94a75803 refactor(editor): 移除 BaseService 废弃的 use/middleware 机制
- 删除已 @deprecated 的 BaseService.use 方法及其 middleware 通道

- 删除 utils/compose.ts 及对应测试(仅服务于 middleware,无其他引用)

- editor.ts 移除 safeOptions/safeParent 兜底,相关方法 options 改用形参默认值

- props.ts fillConfig 的 labelWidth 改为形参默认值,移除 typeof function 兜底

- 同步更新 5 份 service 方法文档,删除 ## use 章节
2026-05-27 18:55:38 +08:00
roymondchen
d01a28ce76 fix(editor): 修复移动到菜单导致节点引用异常的问题 2026-05-27 17:17:43 +08:00
roymondchen
6c40425d8c chore: update lockfile v1.8.0-beta.1 2026-05-27 11:28:31 +08:00
roymondchen
b8b0490260 chore: release v1.8.0-beta.1 2026-05-27 11:27:14 +08:00
roymondchen
2846f9eb2a fix(core): app.emit 在节点配置事件时不应短路 super.emit
去掉 eventHelper.emit 前的 return,避免节点配置 events 后 app.on 注册的监听器被吞掉,并补充回归测试。
2026-05-27 11:20:48 +08:00
roymondchen
62fc818ae1 refactor(form-schema): style-setter 继承 containercommonconfig 2026-05-26 21:10:22 +08:00
roymondchen
ff810d09e4 feat(editor): 数据源字段选择按钮在对比模式与禁用态下禁止切换
- 按钮新增 disabled 绑定 (props.disabled || mForm?.isCompare)

- 抽取 onToggleDataSourceFieldSelectHandler 增加 guard 防御

- 补充对应单元测试
2026-05-26 21:05:01 +08:00
roymondchen
b1193b909e feat(editor): 样式设置器 StyleSetter 支持表单对比模式
- Index.vue 透传 lastValues/isCompare 给各分类子组件,并冒泡 addDiffCount

- pro 下 6 个分类组件接受新 props 并向 MContainer 传递

- Layout/Border 同时将新 props 传递给内部 Box/Border 组件

- components/Border.vue 接受新 props 并冒泡 MContainer 的 addDiffCount

- components/Box.vue 接受 props 以保持接口一致

- 补充单元测试覆盖透传与事件冒泡
2026-05-26 20:59:43 +08:00
roymondchen
540a2716d8 fix(editor): serializeConfig 只去掉对象 key 的引号,避免破坏字符串 value 内的引号 2026-05-26 20:20:51 +08:00
roymondchen
a1fcb191d2 feat(eslint-config): 禁止匿名 default class/function 导出
新增 no-restricted-syntax 规则,禁止匿名形式的
`export default class {}` 与 `export default function () {}`。

匿名 default 导出在 dts 聚合(rolldown / api-extractor /
vue-tsc 等)时会被命名为 `export_default`,导致跨包继承链在
.vue / .tsx 下解析失败,父类成员(如 EventEmitter 的 on/off)
无法被 ts-plugin 推断。

同时重申 base.mjs 中已有的 ForIn / Labeled / With 选择器,
避免在 .ts/.tsx 下被本规则整体覆盖。
2026-05-26 17:09:37 +08:00
roymondchen
b9a6dd5b84 fix(editor): 修复 root 整体替换时图层面板节点状态残留与组件树闪烁问题 2026-05-26 17:06:45 +08:00
roymondchen
08011efd6d refactor(form): 使用 getter 访问 props 字段并补充单元测试
- formState 中与 props 对应的字段改用 getter,避免 props 与 formState 之间的同步中间态
- 完善 extendState 同步段的响应式追踪说明注释
- 新增 Form.extra.spec.ts 覆盖 isCompare 模式与 config 变化场景
2026-05-26 11:51:34 +08:00
roymondchen
fbbd05e291 chore: update lockfile v1.8.0-beta.0 2026-05-22 16:54:18 +08:00
roymondchen
9b65917371 chore: release v1.8.0-beta.0 2026-05-22 16:53:17 +08:00
roymondchen
3d038513e3 feat(editor): 新增 DSL 修改方法的 doNotSwitchPage 选项
在 add / remove / doRemove / sort / paste / alignCenter / moveToContainer
的 options 对象中新增 doNotSwitchPage,与 doNotSelect 合并为同一配置 DslOpOptions,
用于在 DSL 操作(新增 / 删除 / 跨页移动)会引发当前页面切换时跳过该次切换。

- 抽取共用类型 DslOpOptions 到 type.ts 并对外导出
- 新增 editorService.isOnDifferentPage 辅助方法用于跨页判断
- 修复 doUpdate 同步 state.page 时无条件覆盖的问题:只在被更新页就是当前页时才同步引用,避免「更新非当前页」误把编辑器切到该页
- doRemove 中对已删除节点的引用清理与当前页清空逻辑提升为无条件执行,避免 doNotSelect / doNotSwitchPage 跳过后续 select 时 state 持有已删除节点
- 补充对应单元测试与 API 文档

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 16:49:52 +08:00
roymondchen
eb1c5a3ec1 fix(editor): 属性面板 padding 仅作用于最外层表单
为 PropsPanel 顶层 MForm 增加 .m-editor-props-form-panel-form 专属类名,
将原本挂在通用 .tmagic-design-form 上的 padding 与 tab 样式迁移到该类,
避免子组件中嵌套的 .tmagic-design-form 被错误命中。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 15:57:13 +08:00
roymondchen
7ff590b1b6 chore: update lockfile v1.7.14-beta.3 2026-05-21 16:11:51 +08:00
roymondchen
7eeb9b544e chore: release v1.7.14-beta.3 2026-05-21 16:10:48 +08:00
roymondchen
638c3e9f3c feat(form): 新增 submitForm 命令式提交函数
提供脱离组件树以函数方式完成一次表单校验/提交的能力,类似 ElMessage 用法:
传入 config/initValues 等 props 后内部临时挂载 Form 实例,
初始化完成即调用 submitForm,校验通过 resolve 表单值、失败 reject,
最后自动卸载,并支持 appContext 继承、timeout 与 native 透传。

同步补充单元测试、API 文档及侧边栏入口。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 15:55:28 +08:00
roymondchen
2d31b3812f feat(form): 容器组件新增 extendState 属性
FormBox、FormDialog、FormDrawer 新增 extendState 属性,并透传给内部 MForm,
方便外层注入 $message、$store 等扩展上下文到 formState。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 14:58:43 +08:00
roymondchen
05e512b1fe feat(editor): 新增 DSL 修改方法的 doNotSelect 选项
- add/remove/sort/alignCenter/moveToContainer/paste 新增 doNotSelect 选项,控制操作后是否自动触发选中变化
- doUpdate/doRemove 改为始终同步当前选中列表中的节点引用,避免 state 持有已被替换/已删除的过期节点
- 顺手修复 doUpdate 在 splice(-1) 时误改最后一个选中项的 bug
- 移除 update/doUpdate 的 selectedAfterUpdate 参数(语义已内化),move 不再暴露无意义的 doNotSelect
- 新增 safeOptions / safeParent 辅助函数,兜底插件机制将 dispatch 注入到形参位置的场景

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 17:20:04 +08:00
roymondchen
1e69bc221d refactor(utils): 放宽 isPop/isPage/isPageFragment 入参为仅需 type 字段
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 16:25:42 +08:00
roymondchen
12ce19fb02 fix(form): 修复table-group-list中model属性可能为undefined导致的报错 2026-05-18 20:07:49 +08:00
roymondchen
aa2ee9fd4b fix(form): select 在 model 值变化时补拉 init 选项
配置 config.option 时监听 model 字段变化,若当前 options 缺少对应项则重新 getInitOption,并补充单测覆盖。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 17:49:21 +08:00
197 changed files with 15364 additions and 1942 deletions

View File

@ -1,3 +1,122 @@
# [1.8.0-beta.4](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.3...v1.8.0-beta.4) (2026-06-04)
### Bug Fixes
* **editor:** 修复历史对比样式配置显示 ([cd19dec](https://github.com/Tencent/tmagic-editor/commit/cd19dec7907cac5cff775f1cbde24cb3f384e87b))
* **editor:** 修复合并历史记录信息展示 ([3bd0eec](https://github.com/Tencent/tmagic-editor/commit/3bd0eecb42d06f06f50cc4736ecc31cc07cc1886))
* **editor:** 禁止缺少变更记录的历史回滚 ([10b70c3](https://github.com/Tencent/tmagic-editor/commit/10b70c36bbace6af48bf6fa63f2df0704c6861af))
### Features
* **editor:** 历史记录支持操作来源 ([27b2c2c](https://github.com/Tencent/tmagic-editor/commit/27b2c2c68598264e97a1e1ecc34121829851c85e))
# [1.8.0-beta.3](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.2...v1.8.0-beta.3) (2026-06-04)
### Bug Fixes
* **form:** 对比模式下无 name 字段时不展示差异 ([64d35d5](https://github.com/Tencent/tmagic-editor/commit/64d35d53631698e8d94362765a1621654bd3d1f6))
### Features
* **editor:** 历史记录列表展示时间并优化回滚差异弹窗 ([a9e9e65](https://github.com/Tencent/tmagic-editor/commit/a9e9e65f9c50e47b22de8eab7184cebd87632bc6))
* **editor:** 历史记录差异对比弹窗关闭时派发 close 事件 ([42162f2](https://github.com/Tencent/tmagic-editor/commit/42162f2e4ac651ad78ff2f5291e00639a658a1ae))
* **editor:** 历史记录面板支持自定义扩展 tab 并开放 Bucket/goto 配置 ([8612311](https://github.com/Tencent/tmagic-editor/commit/8612311db12a22adcc30188ae1ead03729fa6a7a))
* **editor:** 对比表单支持自定义 loadConfig 加载逻辑 ([1cd69b3](https://github.com/Tencent/tmagic-editor/commit/1cd69b33fecd75fe8522d9a261e1c03e806ecf69))
* **form:** fieldset legend 支持函数动态生成标题 ([35fc394](https://github.com/Tencent/tmagic-editor/commit/35fc39419902e14e2d5bdf98f99802f05a4b5934))
* **form:** submitForm 支持返回 changeRecords ([12069e0](https://github.com/Tencent/tmagic-editor/commit/12069e0937589cf9b7684e4bd5ed927e15462513))
* **stage:** 非点击画布选中组件时高亮闪烁选中区域 ([444d422](https://github.com/Tencent/tmagic-editor/commit/444d4223a943d763a33b752ffbbfa704591820ca))
# [1.8.0-beta.2](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.1...v1.8.0-beta.2) (2026-05-29)
### Bug Fixes
* **editor:** 修复移动到菜单导致节点引用异常的问题 ([d01a28c](https://github.com/Tencent/tmagic-editor/commit/d01a28ce76203765f333548b30b4ec2954e68d4c))
* **editor:** 多选时对多个节点的操作合并入同一条历史记录 ([a341c7d](https://github.com/Tencent/tmagic-editor/commit/a341c7d73e78f0727c1adffce767b6806d356beb))
* **editor:** 显式标注 CompareForm 的 defineExpose 类型以修复 DTS 构建报错 ([7a61a35](https://github.com/Tencent/tmagic-editor/commit/7a61a356649838531f4f51c45e2e76ab84474107))
* 对比模式下关闭 tab-pane 的 lazy确保差异数能正确统计 ([0f8abf7](https://github.com/Tencent/tmagic-editor/commit/0f8abf729854f5bfc3fbad98153a77e947ead246))
### Features
* **editor:** vs-code 字段对比模式改用 monaco diff 编辑器 ([c854dfa](https://github.com/Tencent/tmagic-editor/commit/c854dfa8bf80bd501534b98c72fa1b2802076cac))
* **editor:** 代码块与数据源支持按 id 独立的历史记录 ([e2c065f](https://github.com/Tencent/tmagic-editor/commit/e2c065f90d12d1234edd3620430262857a014ee9))
* **editor:** 写操作支持 doNotPushHistory 选项以跳过历史记录 ([4c855ba](https://github.com/Tencent/tmagic-editor/commit/4c855ba50b69a2e0ab73f944171c4d5561d5a06a))
* **editor:** 历史记录接入 changeRecordsundo/redo 按 propPath 局部更新 ([09558fa](https://github.com/Tencent/tmagic-editor/commit/09558fa0273af0b7d25b4338a8ea56810b09bb1c))
* **editor:** 历史记录面板支持单步回滚(类 git revert ([b02aa75](https://github.com/Tencent/tmagic-editor/commit/b02aa75ddc2b37a024a8966ddad96cf8d85317bb))
* **editor:** 历史记录面板支持差异对比 ([59f4e0e](https://github.com/Tencent/tmagic-editor/commit/59f4e0edac47e986a83a3f9b7862cf92650b7fee))
* **editor:** 历史记录面板支持点击跳转与回到初始状态 ([62a2ee6](https://github.com/Tencent/tmagic-editor/commit/62a2ee66931caed51f86bf170c3bce96c7e40dea))
* **editor:** 字段对比模式逐项展示差异并补充历史记录面板文档 ([cbc4b25](https://github.com/Tencent/tmagic-editor/commit/cbc4b25072542d98f19707a11b87be0295157216))
* **editor:** 数据源与代码块 service 支持 undo/redo ([8dae677](https://github.com/Tencent/tmagic-editor/commit/8dae67769c32dbf65413d47ac56ca46e65eaeecf))
* **editor:** 新增 hideSidebar 配置支持隐藏左侧面板 ([a3333e2](https://github.com/Tencent/tmagic-editor/commit/a3333e2b4e0f05b2f83c9dc539466ebd31c04250))
* **editor:** 新增历史记录列表面板 ([0446202](https://github.com/Tencent/tmagic-editor/commit/0446202ba6aaf0c99265b367343c7a4d1a8201e9))
* form 新增 showDiff prop 支持自定义对比判断 ([f0c6642](https://github.com/Tencent/tmagic-editor/commit/f0c66427b8e011252110a11c90a109f5f58d3101))
* **form:** 支持自定义 label slot ([285434e](https://github.com/Tencent/tmagic-editor/commit/285434ef3effd94c51d3ed10198842f6e689046a))
### Performance Improvements
* **dep:** 依赖收集改为单次遍历批量处理多 target ([025cca3](https://github.com/Tencent/tmagic-editor/commit/025cca365c87d755abfc047786ac9a75758019f5))
# [1.8.0-beta.1](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.0...v1.8.0-beta.1) (2026-05-27)
### Bug Fixes
* **core:** app.emit 在节点配置事件时不应短路 super.emit ([2846f9e](https://github.com/Tencent/tmagic-editor/commit/2846f9eb2a8655175a024b16eaba22b522e88603))
* **editor:** serializeConfig 只去掉对象 key 的引号,避免破坏字符串 value 内的引号 ([540a271](https://github.com/Tencent/tmagic-editor/commit/540a2716d8e8e7b947ec5aa6352736dff6ee225c))
* **editor:** 修复 root 整体替换时图层面板节点状态残留与组件树闪烁问题 ([b9a6dd5](https://github.com/Tencent/tmagic-editor/commit/b9a6dd5b84d6f043eda94dbc1a07b75aea87e6f2))
### Features
* **editor:** 数据源字段选择按钮在对比模式与禁用态下禁止切换 ([ff810d0](https://github.com/Tencent/tmagic-editor/commit/ff810d09e41163834f0ac9fd2057bd9fb9d53c55))
* **editor:** 样式设置器 StyleSetter 支持表单对比模式 ([b1193b9](https://github.com/Tencent/tmagic-editor/commit/b1193b909e5e15f78783f72eb21959a52128e973))
* **eslint-config:** 禁止匿名 default class/function 导出 ([a1fcb19](https://github.com/Tencent/tmagic-editor/commit/a1fcb191d243b3c7034f31f753757ca4bbd83f5f))
# [1.8.0-beta.0](https://github.com/Tencent/tmagic-editor/compare/v1.7.14-beta.3...v1.8.0-beta.0) (2026-05-22)
### Bug Fixes
* **editor:** 属性面板 padding 仅作用于最外层表单 ([eb1c5a3](https://github.com/Tencent/tmagic-editor/commit/eb1c5a3ec1c5987b50c700dfb9019aad695e042a))
### Features
* **editor:** 新增 DSL 修改方法的 doNotSwitchPage 选项 ([3d03851](https://github.com/Tencent/tmagic-editor/commit/3d038513e3f0d1c303332fd902c1ef83d7dfe860))
## [1.7.14-beta.3](https://github.com/Tencent/tmagic-editor/compare/v1.7.14-beta.2...v1.7.14-beta.3) (2026-05-21)
### Bug Fixes
* **form:** select 在 model 值变化时补拉 init 选项 ([aa2ee9f](https://github.com/Tencent/tmagic-editor/commit/aa2ee9fd4b08a4a2896eead33dfd1d4ba029c501))
* **form:** 修复table-group-list中model属性可能为undefined导致的报错 ([12ce19f](https://github.com/Tencent/tmagic-editor/commit/12ce19fb02af7ac621d220b7e6d0a98859e631de))
### Features
* **editor:** 新增 DSL 修改方法的 doNotSelect 选项 ([05e512b](https://github.com/Tencent/tmagic-editor/commit/05e512b1fe978e26aa3064e7deae9a1aeadcae25))
* **form:** 容器组件新增 extendState 属性 ([2d31b38](https://github.com/Tencent/tmagic-editor/commit/2d31b3812f2195f4afc5f16774e155f00cb0ec20))
* **form:** 新增 submitForm 命令式提交函数 ([638c3e9](https://github.com/Tencent/tmagic-editor/commit/638c3e9f3cb550da2749fd4814c3bec9d518d081))
## [1.7.14-beta.2](https://github.com/Tencent/tmagic-editor/compare/v1.7.14-beta.1...v1.7.14-beta.2) (2026-05-18) ## [1.7.14-beta.2](https://github.com/Tencent/tmagic-editor/compare/v1.7.14-beta.1...v1.7.14-beta.2) (2026-05-18)

View File

@ -102,6 +102,10 @@ export default defineConfig({
text: '数据源', text: '数据源',
link: '/guide/advanced/data-source.md' link: '/guide/advanced/data-source.md'
}, },
{
text: '历史记录面板',
link: '/guide/advanced/history-list.md',
},
{ {
text: '@tmagic/form', text: '@tmagic/form',
@ -250,6 +254,15 @@ export default defineConfig({
}, },
] ]
}, },
{
text: '工具函数',
items: [
{
text: 'submitForm',
link: '/api/form/submit-form'
},
]
},
], ],
}, },
{ {

View File

@ -1,5 +1,8 @@
# codeBlockService方法 # codeBlockService方法
写入历史栈的方法([setCodeDslById](#setcodedslbyid)、[setCodeDslByIdSync](#setcodedslbyidsync)、[deleteCodeDslByIds](#deletecodedslbyids) 等)的 `options` 支持
[historyDescription / historySource](./editorServiceMethods.md#历史记录相关-options),会透传到 `historyService.pushCodeBlock``historyDescription` / `source` 字段。
## setCodeDsl ## setCodeDsl
- **参数:** - **参数:**
@ -48,6 +51,15 @@
- **参数:** - **参数:**
- `{string | number}` id 代码块id - `{string | number}` id 代码块id
- {Partial<`CodeBlockContent`>} codeConfig 代码块内容配置信息 - {Partial<`CodeBlockContent`>} codeConfig 代码块内容配置信息
- `{Object}` options 可选配置
- {`ChangeRecord`[]} changeRecords form 端变更记录,用于历史记录的精细化撤销/重做
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false
- `{string}` historyDescription 见 [editorService 历史记录相关 options](./editorServiceMethods.md#历史记录相关-options)
- `{HistoryOpSource}` historySource 见 [editorService 历史记录相关 options](./editorServiceMethods.md#历史记录相关-options)
::: details 查看 ChangeRecord 类型定义
<<< @/../packages/form-schema/src/base.ts#ChangeRecord{ts}
:::
- **返回:** - **返回:**
- `{Promise<void>}` - `{Promise<void>}`
@ -62,6 +74,11 @@
- `{string | number}` id 代码块id - `{string | number}` id 代码块id
- {Partial<`CodeBlockContent`>} codeConfig 代码块内容配置信息 - {Partial<`CodeBlockContent`>} codeConfig 代码块内容配置信息
- `{boolean}` force 是否强制写入,默认 `true`;为 `false` 时若同 id 已存在则跳过 - `{boolean}` force 是否强制写入,默认 `true`;为 `false` 时若同 id 已存在则跳过
- `{Object}` options 可选配置
- {`ChangeRecord`[]} changeRecords form 端变更记录,用于历史记录的精细化撤销/重做
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false
- `{string}` historyDescription 见 [editorService 历史记录相关 options](./editorServiceMethods.md#历史记录相关-options)
- `{HistoryOpSource}` historySource 见 [editorService 历史记录相关 options](./editorServiceMethods.md#历史记录相关-options)
- **返回:** - **返回:**
- `{void}` - `{void}`
@ -70,6 +87,13 @@
同步版本的 [setCodeDslById](#setcodedslbyid),并会触发 `addOrUpdate` 事件 同步版本的 [setCodeDslById](#setcodedslbyid),并会触发 `addOrUpdate` 事件
::: tip
写入成功时(`force=false` 且同 id 已存在的跳过场景除外)会自动调用 `historyService.pushCodeBlock`
把本次变更入历史栈,参见 [historyService.pushCodeBlock](./historyServiceMethods.md#pushcodeblock)。
传入的 `changeRecords` 会一同写进 step撤销/重做时调用方可据此按 `propPath` 局部回放。
传入 `doNotPushHistory: true` 可跳过写入历史栈,常用于批量导入、外部同步等非用户操作场景。
:::
## getCodeDslByIds ## getCodeDslByIds
- **参数:** - **参数:**
@ -194,6 +218,10 @@
- **参数:** - **参数:**
- `{(string | number)[]}` codeIds 需要删除的代码块id数组 - `{(string | number)[]}` codeIds 需要删除的代码块id数组
- `{Object}` options 可选配置
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false
- `{string}` historyDescription 见 [editorService 历史记录相关 options](./editorServiceMethods.md#历史记录相关-options)
- `{HistoryOpSource}` historySource 见 [editorService 历史记录相关 options](./editorServiceMethods.md#历史记录相关-options)
- **返回:** - **返回:**
- `{Promise<void>}` - `{Promise<void>}`
@ -202,6 +230,157 @@
在dsl数据源中删除指定id的代码块每删除一个会触发一次 `remove` 事件 在dsl数据源中删除指定id的代码块每删除一个会触发一次 `remove` 事件
::: tip
对每个实际存在并被删除的代码块,会自动调用 `historyService.pushCodeBlock` 入栈一条
`newContent=null` 的删除记录;不存在的 id 不会入历史。传入 `doNotPushHistory: true` 也可显式跳过写入历史栈。
:::
## setCodeDslByIdAndGetHistoryId
- **参数:** 同 [setCodeDslById](#setcodedslbyid)
- **返回:**
- {`Promise<string | null>`} 本次写入历史记录的 uuid未写入历史`doNotPushHistory: true` 等)时返回 `null`
- **详情:**
与 [setCodeDslById](#setcodedslbyid) 行为完全一致,仅把返回值换成本次写入历史记录的 `uuid`,可用于精确引用 / 定位该条历史记录。
参见 [editorService 历史记录 uuid 与 \*AndGetHistoryId](./editorServiceMethods.md#历史记录-uuid-与-andgethistoryid)。
- **示例:**
```js
import { codeBlockService } from "@tmagic/editor";
const historyId = await codeBlockService.setCodeDslByIdAndGetHistoryId("code_1234", {
name: "代码块1",
content: "() => {}",
});
console.log(historyId); // 本次变更对应的历史记录 uuid或 null
```
## setCodeDslByIdSyncAndGetHistoryId
- **参数:** 同 [setCodeDslByIdSync](#setcodedslbyidsync)
- **返回:**
- {`string | null`} 本次写入历史记录的 uuid未写入历史`doNotPushHistory: true`、或 `force=false` 跳过等)时返回 `null`
- **详情:**
与 [setCodeDslByIdSync](#setcodedslbyidsync) 行为完全一致(同步),仅把返回值换成本次写入历史记录的 `uuid`
## deleteCodeDslByIdsAndGetHistoryId
- **参数:** 同 [deleteCodeDslByIds](#deletecodedslbyids)
- **返回:**
- {`Promise<string[]>`} 本次写入的全部历史记录 uuid按删除顺序未写入任何历史时返回空数组 `[]`
- **详情:**
与 [deleteCodeDslByIds](#deletecodedslbyids) 行为完全一致。由于一次可删除多个代码块、会产生多条历史记录,因此返回的是 uuid 数组(每条删除记录一个 uuid不存在的 id 不会入历史,也不会出现在返回数组中。
- **示例:**
```js
import { codeBlockService } from "@tmagic/editor";
const historyIds = await codeBlockService.deleteCodeDslByIdsAndGetHistoryId(["code_1", "code_2"]);
console.log(historyIds); // ['xxxx', 'yyyy'],或 []
```
## revertById
- **参数:**
- `{string}` uuid 目标历史记录的 uuid通常由 [setCodeDslByIdAndGetHistoryId](#setcodedslbyidandgethistoryid) 等方法返回)
- **返回:**
- {`Promise<CodeBlockStepValue | null>`} 反向应用后产生的新 step找不到对应 uuid / 该步未应用时返回 `null`
- **详情:**
通过历史记录 uuid「回滚」某条代码块历史步骤类 git revert 语义),语义同按 `(id, index)` 回滚,
仅无需调用方再传 `codeBlockId``index`:内部会按 uuid 在全部代码块栈中定位对应步骤后再回滚。
参见 [editorService 历史记录 uuid 与 \*AndGetHistoryId](./editorServiceMethods.md#历史记录-uuid-与-andgethistoryid)。
- **示例:**
```js
import { codeBlockService } from "@tmagic/editor";
const historyId = await codeBlockService.setCodeDslByIdAndGetHistoryId("code_1234", { name: "代码块1" });
if (historyId) {
await codeBlockService.revertById(historyId);
}
```
## undo
- **参数:**
- `{Id}` id 代码块id
- **返回:**
- `{Promise<CodeBlockStepValue | null>}` 撤销的 step栈不存在或已无可撤销时返回 `null`
- **详情:**
撤销指定代码块的最近一次变更。内部根据 [historyService](./historyServiceMethods.md) 取出 step 后,
复用 [setCodeDslByIdSync](#setcodedslbyidsync) / [deleteCodeDslByIds](#deletecodedslbyids) 写回,
并自动带上 `doNotPushHistory: true`,确保不会再次入栈。
写回会触发对应的 `addOrUpdate` / `remove` 事件,编辑器内部据此重新维护
`DepTargetType.CODE_BLOCK` 的 dep target无需调用方额外处理。
对于带有 `changeRecords` 的更新 step会按 `propPath` 局部 patch 当前代码块内容;缺省才退化为整内容替换,
避免冲掉同代码块上的其它无关变更。
- **示例:**
```js
import { codeBlockService } from "@tmagic/editor";
if (codeBlockService.canUndo("code_1234")) {
await codeBlockService.undo("code_1234");
}
```
## redo
- **参数:**
- `{Id}` id 代码块id
- **返回:**
- `{Promise<CodeBlockStepValue | null>}` 重做的 step栈不存在或已无可重做时返回 `null`
- **详情:**
重做指定代码块的下一次变更。其它行为同 [undo](#undo)。
## canUndo
- **参数:**
- `{Id}` id 代码块id
- **返回:**
- `{boolean}`
- **详情:**
当前指定代码块是否可撤销,等价于 `historyService.canUndoCodeBlock(id)`
## canRedo
- **参数:**
- `{Id}` id 代码块id
- **返回:**
- `{boolean}`
- **详情:**
当前指定代码块是否可重做,等价于 `historyService.canRedoCodeBlock(id)`
## setParamsColConfig ## setParamsColConfig
- **参数:** - **参数:**
@ -288,15 +467,11 @@
销毁 codeBlockService重置状态并移除所有事件监听和插件 销毁 codeBlockService重置状态并移除所有事件监听和插件
## use
使用中间件的方式扩展方法,上述方法中标记有`扩展支持: 是`的方法都支持使用use扩展
## usePlugin ## usePlugin
- **详情:** - **详情:**
相对于[use](#use), usePlugin支持更加灵活更加细致的扩展, 上述方法中标记有`扩展支持: 是`的方法都支持使用usePlugin扩展 usePlugin支持灵活细致的扩展, 上述方法中标记有`扩展支持: 是`的方法都支持使用usePlugin扩展
每个支持扩展的方法都支持定制before、after两个hook来干预原有方法的行为before可以用于修改传入参数after可以用于修改返回的值 每个支持扩展的方法都支持定制before、after两个hook来干预原有方法的行为before可以用于修改传入参数after可以用于修改返回的值

View File

@ -298,6 +298,10 @@ dataSourceService.setFormMethod("http", [
- **参数:** - **参数:**
- {`DataSourceSchema`} config 数据源配置 - {`DataSourceSchema`} config 数据源配置
- `{Object}` options 可选配置
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false
- `{string}` historyDescription 见 [editorService 历史记录相关 options](./editorServiceMethods.md#历史记录相关-options)
- `{HistoryOpSource}` historySource 见 [editorService 历史记录相关 options](./editorServiceMethods.md#历史记录相关-options)
- **返回:** - **返回:**
- {`DataSourceSchema`} 添加后的数据源配置 - {`DataSourceSchema`} 添加后的数据源配置
@ -306,6 +310,12 @@ dataSourceService.setFormMethod("http", [
添加一个数据源如果配置中没有id或id已存在会自动生成新的id 添加一个数据源如果配置中没有id或id已存在会自动生成新的id
::: tip
添加成功会自动调用 `historyService.pushDataSource` 入栈一条 `oldSchema=null` 的新增记录,
参见 [historyService.pushDataSource](./historyServiceMethods.md#pushdatasource)。
传入 `doNotPushHistory: true` 可跳过写入历史栈,常用于批量导入、外部同步等非用户操作场景。
:::
- **示例:** - **示例:**
```js ```js
@ -329,6 +339,9 @@ console.log(newDs.id); // 自动生成的id
- {`DataSourceSchema`} config 数据源配置 - {`DataSourceSchema`} config 数据源配置
- `{Object}` options 可选配置 - `{Object}` options 可选配置
- {`ChangeRecord`[]} changeRecords 变更记录 - {`ChangeRecord`[]} changeRecords 变更记录
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false
- `{string}` historyDescription 见 [editorService 历史记录相关 options](./editorServiceMethods.md#历史记录相关-options)
- `{HistoryOpSource}` historySource 见 [editorService 历史记录相关 options](./editorServiceMethods.md#历史记录相关-options)
::: details 查看 ChangeRecord 类型定义 ::: details 查看 ChangeRecord 类型定义
<<< @/../packages/form-schema/src/base.ts#ChangeRecord{ts} <<< @/../packages/form-schema/src/base.ts#ChangeRecord{ts}
@ -341,6 +354,12 @@ console.log(newDs.id); // 自动生成的id
更新数据源 更新数据源
::: tip
更新成功会自动调用 `historyService.pushDataSource` 入栈一条 `oldSchema` / `newSchema`
均为对应 schema 的更新记录,传入的 `changeRecords` 也会一并写进 step撤销/重做时调用方可据此按
`propPath` 局部回放,缺省才退化为整 schema 替换。传入 `doNotPushHistory: true` 可跳过写入历史栈。
:::
- **示例:** - **示例:**
```js ```js
@ -362,6 +381,10 @@ console.log(updatedDs);
- **参数:** - **参数:**
- `{string}` id 数据源id - `{string}` id 数据源id
- `{Object}` options 可选配置
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false
- `{string}` historyDescription 见 [editorService 历史记录相关 options](./editorServiceMethods.md#历史记录相关-options)
- `{HistoryOpSource}` historySource 见 [editorService 历史记录相关 options](./editorServiceMethods.md#历史记录相关-options)
- **返回:** - **返回:**
- `{void}` - `{void}`
@ -370,6 +393,11 @@ console.log(updatedDs);
删除指定id的数据源 删除指定id的数据源
::: tip
对实际存在的数据源会自动调用 `historyService.pushDataSource` 入栈一条 `newSchema=null`
的删除记录;不存在的 id 不会入历史。传入 `doNotPushHistory: true` 也可显式跳过写入历史栈。
:::
- **示例:** - **示例:**
```js ```js
@ -378,6 +406,78 @@ import { dataSourceService } from "@tmagic/editor";
dataSourceService.remove("ds_123"); dataSourceService.remove("ds_123");
``` ```
## addAndGetHistoryId
- **参数:** 同 [add](#add)
- **返回:**
- {`string` | null} 本次写入历史记录的 uuid未写入历史`doNotPushHistory: true` 等)时返回 `null`
- **详情:**
与 [add](#add) 行为完全一致,仅把返回值换成本次写入历史记录的 `uuid`,可用于精确引用 / 定位该条历史记录。
参见 [editorService 历史记录 uuid 与 \*AndGetHistoryId](./editorServiceMethods.md#历史记录-uuid-与-andgethistoryid)。
- **示例:**
```js
import { dataSourceService } from "@tmagic/editor";
const historyId = dataSourceService.addAndGetHistoryId({
type: "http",
title: "用户信息",
url: "/api/user",
});
console.log(historyId); // 本次新增对应的历史记录 uuid或 null
```
## updateAndGetHistoryId
- **参数:** 同 [update](#update)
- **返回:**
- {`string` | null} 本次写入历史记录的 uuid未写入历史时返回 `null`
- **详情:**
与 [update](#update) 行为完全一致,仅把返回值换成本次写入历史记录的 `uuid`
## removeAndGetHistoryId
- **参数:** 同 [remove](#remove)
- **返回:**
- {`string` | null} 本次写入历史记录的 uuid删除的 id 不存在或未写入历史时返回 `null`
- **详情:**
与 [remove](#remove) 行为完全一致,仅把返回值换成本次写入历史记录的 `uuid`
## revertById
- **参数:**
- `{string}` uuid 目标历史记录的 uuid通常由 [addAndGetHistoryId](#addandgethistoryid) 等方法返回)
- **返回:**
- {`DataSourceStepValue` | null} 反向应用后产生的新 step找不到对应 uuid / 该步未应用时返回 `null`
- **详情:**
通过历史记录 uuid「回滚」某条数据源历史步骤类 git revert 语义),语义同按 `(id, index)` 回滚,
仅无需调用方再传 `dataSourceId``index`:内部会按 uuid 在全部数据源栈中定位对应步骤后再回滚。
参见 [editorService 历史记录 uuid 与 \*AndGetHistoryId](./editorServiceMethods.md#历史记录-uuid-与-andgethistoryid)。
- **示例:**
```js
import { dataSourceService } from "@tmagic/editor";
const historyId = dataSourceService.addAndGetHistoryId({ type: "http", title: "用户信息" });
if (historyId) {
dataSourceService.revertById(historyId);
}
```
## createId ## createId
- **[扩展支持](../../guide/editor-expand#行为扩展)** 是 - **[扩展支持](../../guide/editor-expand#行为扩展)** 是
@ -421,6 +521,72 @@ const ds = dataSourceService.getDataSourceById("ds_123");
console.log(ds); console.log(ds);
``` ```
## undo
- **参数:**
- `{Id}` id 数据源id
- **返回:**
- {`DataSourceStepValue` | null} 撤销的 step栈不存在或已无可撤销时返回 `null`
- **详情:**
撤销指定数据源的最近一次变更。内部根据 [historyService](./historyServiceMethods.md) 取出 step 后,
复用 [add](#add) / [update](#update) / [remove](#remove) 写回,并自动带上 `doNotPushHistory: true`
确保不会再次入栈。
写回会触发对应的 `add` / `update` / `remove` 事件,编辑器内部据此重新维护数据源相关的依赖收集
`DepTargetType.DATA_SOURCE` / `DATA_SOURCE_COND` / `DATA_SOURCE_METHOD`),无需调用方额外处理。
对于带有 `changeRecords` 的更新 step会按 `propPath` 局部 patch 当前数据源;缺省才退化为整 schema 替换,
避免冲掉同节点上的其它无关变更。
- **示例:**
```js
import { dataSourceService } from "@tmagic/editor";
if (dataSourceService.canUndo("ds_123")) {
dataSourceService.undo("ds_123");
}
```
## redo
- **参数:**
- `{Id}` id 数据源id
- **返回:**
- {`DataSourceStepValue` | null} 重做的 step栈不存在或已无可重做时返回 `null`
- **详情:**
重做指定数据源的下一次变更。其它行为同 [undo](#undo)。
## canUndo
- **参数:**
- `{Id}` id 数据源id
- **返回:**
- `{boolean}`
- **详情:**
当前指定数据源是否可撤销,等价于 `historyService.canUndoDataSource(id)`
## canRedo
- **参数:**
- `{Id}` id 数据源id
- **返回:**
- `{boolean}`
- **详情:**
当前指定数据源是否可重做,等价于 `historyService.canRedoDataSource(id)`
## copyWithRelated ## copyWithRelated
- **参数:** - **参数:**

View File

@ -1,5 +1,48 @@
# editorService方法 # editorService方法
## 历史记录相关 options
下列 DSL 操作方法([add](#add)、[remove](#remove)、[update](#update) 等)的 `options` / `data` 参数,以及
[codeBlockService](./codeBlockServiceMethods.md) / [dataSourceService](./dataSourceServiceMethods.md)
`options`,在 `doNotPushHistory` 之外还可传入:
- `{string}` **historyDescription**:入栈时附带的人类可读描述,用于历史面板展示;不影响 undo/redo 行为,缺省时面板会自动生成描述
- `{HistoryOpSource}` **historySource**:操作途径,用于历史面板展示与埋点;不影响 undo/redo 行为,缺省时面板视为「未知」
编辑器内置交互(画布、树面板、配置面板、右键菜单、快捷键等)会自动传入对应的 `historySource`
业务侧程序化调用时建议显式传入(如 `api`),便于历史面板区分来源。
## 历史记录 uuid 与 \*AndGetHistoryId
每条历史记录入栈时都会自动生成一个唯一标识 `uuid`(见 [StepValue](#undo)),可用于精确引用 / 定位某一条历史记录(如埋点、回滚、跨端同步等)。
DSL 操作方法(`add` / `remove` / `update` 等)默认返回操作结果(节点 / 节点集合 / void不会返回 `uuid`。若需要拿到本次写入历史记录的 `uuid`,可改用对应的 `*AndGetHistoryId` 方法:它们与原方法行为完全一致,仅把返回值换成本次写入历史记录的 `uuid``string`)。当本次操作未写入历史(`doNotPushHistory: true`、无实际变更或提前返回)时返回 `null`
| 原方法 | 取 uuid 的方法 | 返回值 |
| --- | --- | --- |
| [add](#add) | [addAndGetHistoryId](#addandgethistoryid) | `Promise<string \| null>` |
| [remove](#remove) | [removeAndGetHistoryId](#removeandgethistoryid) | `Promise<string \| null>` |
| [update](#update) | [updateAndGetHistoryId](#updateandgethistoryid) | `Promise<string \| null>` |
| [moveLayer](#movelayer) | [moveLayerAndGetHistoryId](#movelayerandgethistoryid) | `Promise<string \| null>` |
| [moveToContainer](#movetocontainer) | [moveToContainerAndGetHistoryId](#movetocontainerandgethistoryid) | `Promise<string \| null>` |
| [dragTo](#dragto) | [dragToAndGetHistoryId](#dragtoandgethistoryid) | `Promise<string \| null>` |
[dataSourceService](./dataSourceServiceMethods.md) / [codeBlockService](./codeBlockServiceMethods.md) 也提供了同名约定的 `*AndGetHistoryId` 方法。
拿到 `uuid` 后,可在需要时按 uuid「回滚」对应的历史记录类 git revert 语义,详见[历史记录面板](../../guide/advanced/history-list.md))。相比按 index 回滚uuid 不会随栈内步骤增删而变化,更适合业务侧持有引用后再回滚:
- 页面:[editorService.revertPageStepById(uuid)](#revertpagestepbyid)
- 数据源:[dataSourceService.revertById(uuid)](./dataSourceServiceMethods.md#revertbyid)
- 代码块:[codeBlockService.revertById(uuid)](./codeBlockServiceMethods.md#revertbyid)
::: details 查看 HistoryOpOptions / DslOpOptions / HistoryOpSource 类型定义
<<< @/../packages/editor/src/type.ts#HistoryOpOptions{ts}
<<< @/../packages/editor/src/type.ts#DslOpOptions{ts}
<<< @/../packages/editor/src/type.ts#HistoryOpSource{ts}
:::
## get ## get
- **参数:** - **参数:**
@ -170,6 +213,29 @@ const parent = editorService.getParentById("text_123");
console.log(parent); console.log(parent);
``` ```
## isOnDifferentPage
- **参数:**
- {`MNode`} node 节点配置
- **返回:**
- `{boolean}` true 表示该节点位于非当前页面(即选中该节点将会引起当前页面切换)
- **详情:**
判断给定节点是否位于非当前页面,通常用于配合 `doNotSwitchPage` 选项判断 DSL 操作是否会引起页面切换
- **示例:**
```js
import { editorService } from "@tmagic/editor";
const otherPageNode = editorService.getNodeById("text_456");
if (editorService.isOnDifferentPage(otherPageNode)) {
console.log("该节点在其它页面,操作会触发页面切换");
}
```
## getLayout ## getLayout
- **[扩展支持](../../guide/editor-expand#行为扩展)** 是 - **[扩展支持](../../guide/editor-expand#行为扩展)** 是
@ -332,6 +398,13 @@ editorService.highlight("text_123");
- {`MContainer`} parent 指定的容器组件节点配置,如果不设置,默认为当前选中的组件的父节点 - {`MContainer`} parent 指定的容器组件节点配置,如果不设置,默认为当前选中的组件的父节点
- `{Object}` options 可选配置
- `{boolean}` doNotSelect 添加后是否不更新当前选中节点(默认 false添加后会选中新增的节点
- `{boolean}` doNotSwitchPage 添加后是否不切换当前页面(默认 false新增页面 / 跨页新增时为 true 会跳过会引发页面切换的选中操作)
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false
- `{string}` historyDescription 见[历史记录相关 options](#历史记录相关-options)
- `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options)
- **返回:** - **返回:**
- {Promise<`MNode` | `MNode`[]>} 新增的组件或组件集合 - {Promise<`MNode` | `MNode`[]>} 新增的组件或组件集合
@ -352,6 +425,9 @@ editorService.highlight("text_123");
- **[扩展支持](../../guide/editor-expand#行为扩展)** 是 - **[扩展支持](../../guide/editor-expand#行为扩展)** 是
- **参数:** - **参数:**
- {`MNode`} node 要删除的节点 - {`MNode`} node 要删除的节点
- `{Object}` options 可选配置
- `{boolean}` doNotSelect 删除后是否不更新当前选中节点(默认 false
- `{boolean}` doNotSwitchPage 删除后是否不切换当前页面(默认 false删除页面 / 页面片段时为 true 会跳过自动切换到首个剩余页面)
- **返回:** - **返回:**
- `{Promise<void>}` - `{Promise<void>}`
@ -360,12 +436,22 @@ editorService.highlight("text_123");
删除指定的组件或者页面 删除指定的组件或者页面
:::tip
无论是否传入 `doNotSelect` / `doNotSwitchPage`当被删除节点在当前选中列表中时state 都会自动移除该节点的引用当被删除的正好是当前页面时state.page 也会同步清空,避免持有已删除节点
:::
## remove ## remove
- **[扩展支持](../../guide/editor-expand#行为扩展)** 是 - **[扩展支持](../../guide/editor-expand#行为扩展)** 是
- **参数:** - **参数:**
- {`MNode` | `MNode`[])} node 要删除的节点或节点集合 - {`MNode` | `MNode`[])} node 要删除的节点或节点集合
- `{Object}` options 可选配置
- `{boolean}` doNotSelect 删除后是否不更新当前选中节点(默认 false删除后会选中父节点或首个页面
- `{boolean}` doNotSwitchPage 删除后是否不切换当前页面(默认 false删除页面 / 页面片段时为 true 会跳过自动切换到首个剩余页面)
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false
- `{string}` historyDescription 见[历史记录相关 options](#历史记录相关-options)
- `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options)
- **返回:** - **返回:**
- `{Promise<void>}` - `{Promise<void>}`
@ -390,7 +476,6 @@ editorService.highlight("text_123");
- {`MNode`} config 新的节点 - {`MNode`} config 新的节点
- `{Object}` data 可选配置 - `{Object}` data 可选配置
- {`ChangeRecord`[]} changeRecords 变更记录 - {`ChangeRecord`[]} changeRecords 变更记录
- `{boolean}` selectedAfterUpdate 更新后是否将新节点同步到当前选中节点列表
- **返回:** - **返回:**
- `{Promise<{ newNode: MNode; oldNode: MNode; changeRecords?: ChangeRecord[] }>}` 更新前后的节点信息 - `{Promise<{ newNode: MNode; oldNode: MNode; changeRecords?: ChangeRecord[] }>}` 更新前后的节点信息
@ -405,6 +490,10 @@ editorService.highlight("text_123");
:::tip :::tip
节点中应该要有id不然不知道要更新哪个节点 节点中应该要有id不然不知道要更新哪个节点
当被更新节点正好在当前选中列表中时state 会自动同步到新的节点引用,无需调用方处理
当被更新节点正好是当前页面时state.page 也会同步到新的节点引用;更新非当前页面(不同 ID时不会把编辑器切到该页
::: :::
## update ## update
@ -414,8 +503,15 @@ editorService.highlight("text_123");
- **参数:** - **参数:**
- {`MNode` | `MNode`[]} config 新的节点或节点集合 - {`MNode` | `MNode`[]} config 新的节点或节点集合
- `{Object}` data 可选配置 - `{Object}` data 可选配置
- {`ChangeRecord`[]} changeRecords 变更记录 - {`ChangeRecord`[]} changeRecords 单节点 form 端变更记录(多节点场景下被忽略,使用 `changeRecordList`
- `{boolean}` selectedAfterUpdate 更新后是否同步到当前选中节点列表 - {`ChangeRecord`[][]} changeRecordList 多节点 form 端变更记录列表,按 `config` 数组同序对应每个节点;优先级高于 `changeRecords`
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false
- `{string}` historyDescription 见[历史记录相关 options](#历史记录相关-options)
- `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options)
::: details 查看 ChangeRecord 类型定义
<<< @/../packages/form-schema/src/base.ts#ChangeRecord{ts}
:::
- **返回:** - **返回:**
- {Promise<`MNode` | `MNode`[]>} 新的节点或节点集合 - {Promise<`MNode` | `MNode`[]>} 新的节点或节点集合
@ -432,6 +528,16 @@ editorService.highlight("text_123");
编辑器内部更新组件都是调用update来实现的update除了更新操作外还会记录历史堆还会更新[代码块](../../guide/advanced/code-block.md)关系链。 编辑器内部更新组件都是调用update来实现的update除了更新操作外还会记录历史堆还会更新[代码块](../../guide/advanced/code-block.md)关系链。
::: :::
:::tip
**多节点场景必须使用 `changeRecordList`**:每个节点应保留自己独立的 records不能把多个节点的
records 合并到同一个 `changeRecords` 数组里,否则 `doUpdate` / 依赖收集 / 历史回放都会按错误的
`propPath` 处理。
写入历史时,每个节点的 records 会单独保存到 `updatedItems[i].changeRecords`;撤销/重做时若有
records则仅按 `propPath` 局部更新对应字段,避免整节点替换冲掉同节点上的其它无关变更;缺省
才退化为整节点替换(如内部 `sort` / `moveLayer` / 拖动等纯快照场景)。
:::
## sort ## sort
- **[扩展支持](../../guide/editor-expand#行为扩展)** 是 - **[扩展支持](../../guide/editor-expand#行为扩展)** 是
@ -439,6 +545,11 @@ editorService.highlight("text_123");
- **参数:** - **参数:**
- `{ string | number }` id1 - `{ string | number }` id1
- `{ string | number }` id2 - `{ string | number }` id2
- `{Object}` options 可选配置
- `{boolean}` doNotSelect 排序后是否不更新当前选中节点(默认 false
- `{boolean}` doNotSwitchPage 排序后是否不切换当前页面(排序只发生在同一父节点内,方法内为空操作;保留以与其它 DSL 操作 API 一致)
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false
- `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options)
- **返回:** - **返回:**
- `{Promise<void>}` - `{Promise<void>}`
@ -502,6 +613,14 @@ editorService.highlight("text_123");
<<< @/../packages/editor/src/type.ts#PastePosition{ts} <<< @/../packages/editor/src/type.ts#PastePosition{ts}
::: :::
- `{TargetOptions}` collectorOptions 可选的依赖收集器配置
- `{Object}` options 可选配置
- `{boolean}` doNotSelect 粘贴后是否不更新当前选中节点(默认 false
- `{boolean}` doNotSwitchPage 粘贴后是否不切换当前页面(默认 false跨页粘贴时为 true 会跳过页面切换)
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false
- `{string}` historyDescription 见[历史记录相关 options](#历史记录相关-options)
- `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options)
- **返回:** - **返回:**
- {Promise<`MNode` | `MNode`[]>} 添加后的组件节点配置 - {Promise<`MNode` | `MNode`[]>} 添加后的组件节点配置
@ -535,6 +654,12 @@ editorService.highlight("text_123");
- **参数:** - **参数:**
- {`MNode` | `MNode`[]} config 需要居中的组件或者组件集合 - {`MNode` | `MNode`[]} config 需要居中的组件或者组件集合
- `{Object}` options 可选配置
- `{boolean}` doNotSelect 居中后是否不更新当前选中节点(默认 false
- `{boolean}` doNotSwitchPage 居中后是否不切换当前页面(居中只更新节点 style方法内为空操作保留以与其它 DSL 操作 API 一致)
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false
- `{string}` historyDescription 见[历史记录相关 options](#历史记录相关-options)
- `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options)
- **返回:** - **返回:**
- {Promise<`MNode` | `MNode`[]>} - {Promise<`MNode` | `MNode`[]>}
@ -555,6 +680,10 @@ alignCenter可以支持一次水平居中多个组件alignCenter是通过调
- **参数:** - **参数:**
- `{number | 'top' | 'bottom'}` offset - `{number | 'top' | 'bottom'}` offset
- `{Object}` options 可选配置
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false
- `{string}` historyDescription 见[历史记录相关 options](#历史记录相关-options)
- `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options)
- **返回:** - **返回:**
- `{Promise<void>}` - `{Promise<void>}`
@ -572,6 +701,12 @@ alignCenter可以支持一次水平居中多个组件alignCenter是通过调
- **参数:** - **参数:**
- {`MNode`} config 需要移动的节点 - {`MNode`} config 需要移动的节点
- `{string | number}` targetId 容器ID - `{string | number}` targetId 容器ID
- `{Object}` options 可选配置
- `{boolean}` doNotSelect 移动后是否不更新当前选中节点(默认 false
- `{boolean}` doNotSwitchPage 移动后是否不切换当前页面(默认 false目标容器位于其它页面时为 true 会跳过自动选中以避免页面切换)
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false
- `{string}` historyDescription 见[历史记录相关 options](#历史记录相关-options)
- `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options)
- **返回:** - **返回:**
- Promise<`MNode` | undefined> - Promise<`MNode` | undefined>
@ -586,6 +721,10 @@ alignCenter可以支持一次水平居中多个组件alignCenter是通过调
- {`MNode` | `MNode`[]} config 需要拖拽的节点或节点集合 - {`MNode` | `MNode`[]} config 需要拖拽的节点或节点集合
- {`MContainer`} targetParent 目标父容器 - {`MContainer`} targetParent 目标父容器
- `{number}` targetIndex 目标位置索引 - `{number}` targetIndex 目标位置索引
- `{Object}` options 可选配置
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false
- `{string}` historyDescription 见[历史记录相关 options](#历史记录相关-options)
- `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options)
- **返回:** - **返回:**
- `{Promise<void>}` - `{Promise<void>}`
@ -594,6 +733,115 @@ alignCenter可以支持一次水平居中多个组件alignCenter是通过调
将节点(支持多选)拖拽到目标容器的指定位置,会自动处理跨容器布局切换并记录历史 将节点(支持多选)拖拽到目标容器的指定位置,会自动处理跨容器布局切换并记录历史
## addAndGetHistoryId
- **参数:** 同 [add](#add)
- **返回:**
- {Promise<`string` | null>} 本次写入历史记录的 [uuid](#历史记录-uuid-与-andgethistoryid);未写入历史时返回 `null`
- **详情:**
与 [add](#add) 行为完全一致,仅把返回值换成本次写入历史记录的 `uuid`,见[历史记录 uuid 与 \*AndGetHistoryId](#历史记录-uuid-与-andgethistoryid)
- **示例:**
```js
import { editorService } from "@tmagic/editor";
const historyId = await editorService.addAndGetHistoryId(
{ type: "text", text: "hello" },
parent,
{ historySource: "api" },
);
console.log(historyId); // 本次新增对应的历史记录 uuid或 null
```
## removeAndGetHistoryId
- **参数:** 同 [remove](#remove)
- **返回:**
- {Promise<`string` | null>} 本次写入历史记录的 [uuid](#历史记录-uuid-与-andgethistoryid);未写入历史时返回 `null`
- **详情:**
与 [remove](#remove) 行为完全一致,仅把返回值换成本次写入历史记录的 `uuid`
## updateAndGetHistoryId
- **参数:** 同 [update](#update)
- **返回:**
- {Promise<`string` | null>} 本次写入历史记录的 [uuid](#历史记录-uuid-与-andgethistoryid);未写入历史时返回 `null`
- **详情:**
与 [update](#update) 行为完全一致,仅把返回值换成本次写入历史记录的 `uuid`
## moveLayerAndGetHistoryId
- **参数:** 同 [moveLayer](#movelayer)
- **返回:**
- {Promise<`string` | null>} 本次写入历史记录的 [uuid](#历史记录-uuid-与-andgethistoryid);未写入历史时返回 `null`
- **详情:**
与 [moveLayer](#movelayer) 行为完全一致,仅把返回值换成本次写入历史记录的 `uuid`
## moveToContainerAndGetHistoryId
- **参数:** 同 [moveToContainer](#movetocontainer)
- **返回:**
- {Promise<`string` | null>} 本次写入历史记录的 [uuid](#历史记录-uuid-与-andgethistoryid);未写入历史时返回 `null`
- **详情:**
与 [moveToContainer](#movetocontainer) 行为完全一致,仅把返回值换成本次写入历史记录的 `uuid`
## dragToAndGetHistoryId
- **参数:** 同 [dragTo](#dragto)
- **返回:**
- {Promise<`string` | null>} 本次写入历史记录的 [uuid](#历史记录-uuid-与-andgethistoryid);未写入历史时返回 `null`
- **详情:**
与 [dragTo](#dragto) 行为完全一致,仅把返回值换成本次写入历史记录的 `uuid`
## revertPageStepById
- **参数:**
- `{string}` uuid 目标历史记录的 [uuid](#历史记录-uuid-与-andgethistoryid)(通常由 `*AndGetHistoryId` 方法返回)
- **返回:**
- {Promise<`StepValue` | null>} 反向应用后产生的新 step找不到对应 uuid / 该步未应用 / 反向失败时返回 `null`
- **详情:**
通过历史记录 uuid「回滚」当前页面的某条历史步骤类 git revert 语义):不移动游标、不丢弃任何步骤,而是把目标 step 的修改**反向应用为一条全新的步骤**压入栈顶。语义与按 index 回滚一致,仅入参从 index 改为 uuid更适合业务侧持有引用后再回滚。
::: tip
`opType: 'update'` 的步骤必须携带 `changeRecords` 才支持回滚(否则只能整节点替换,会冲掉后续无关变更);未应用(已被撤销)的步骤无法回滚。
:::
- **示例:**
```js
import { editorService } from "@tmagic/editor";
// 执行操作时拿到本次历史记录 uuid
const historyId = await editorService.addAndGetHistoryId({ type: "text", text: "hello" });
// 之后任意时机按 uuid 回滚该步骤
if (historyId) {
await editorService.revertPageStepById(historyId);
}
```
## undo ## undo
- **[扩展支持](../../guide/editor-expand#行为扩展)** 是 - **[扩展支持](../../guide/editor-expand#行为扩展)** 是
@ -606,6 +854,8 @@ alignCenter可以支持一次水平居中多个组件alignCenter是通过调
<<< @/../packages/editor/src/type.ts#HistoryOpType{ts} <<< @/../packages/editor/src/type.ts#HistoryOpType{ts}
<<< @/../packages/editor/src/type.ts#HistoryOpSource{ts}
<<< @/../packages/schema/src/index.ts#Id{ts} <<< @/../packages/schema/src/index.ts#Id{ts}
::: :::
@ -620,6 +870,16 @@ alignCenter可以支持一次水平居中多个组件alignCenter是通过调
- **返回:** - **返回:**
- {Promise<`StepValue` | null>} - {Promise<`StepValue` | null>}
::: details 查看 StepValue 及关联类型定义
<<< @/../packages/editor/src/type.ts#StepValue{ts}
<<< @/../packages/editor/src/type.ts#HistoryOpType{ts}
<<< @/../packages/editor/src/type.ts#HistoryOpSource{ts}
<<< @/../packages/schema/src/index.ts#Id{ts}
:::
- **详情:** - **详情:**
恢复到下一步 恢复到下一步
@ -631,6 +891,10 @@ alignCenter可以支持一次水平居中多个组件alignCenter是通过调
- **参数:** - **参数:**
- `{number}` left - `{number}` left
- `{number}` top - `{number}` top
- `{Object}` options 可选配置
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false
- `{string}` historyDescription 见[历史记录相关 options](#历史记录相关-options)
- `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options)
- **返回:** - **返回:**
- `{Promise<void>}` - `{Promise<void>}`
@ -659,45 +923,11 @@ alignCenter可以支持一次水平居中多个组件alignCenter是通过调
移除所有事件监听清空state移除所有插件 移除所有事件监听清空state移除所有插件
## use
使用中间件的方式扩展方法,上述方法中标记有`扩展支持: 是`的方法都支持使用use扩展
- **示例:**
```js
import { editorService, getAddParent } from "@tmagic/editor";
import { ElMessageBox } from "element-plus";
editorService.use({
// 添加是否删除节点确认提示
async remove(node, next) {
await ElMessageBox.confirm("是否删除", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
});
next();
},
add(node, next) {
// text组件只能添加到container中
const parentNode = getAddParent(node);
if (node.type === "text" && parentNode?.type !== "container") {
return;
}
next();
},
});
```
## usePlugin ## usePlugin
- **详情:** - **详情:**
相对于[use](#use), usePlugin支持更加灵活更加细致的扩展, 上述方法中标记有`扩展支持: 是`的方法都支持使用usePlugin扩展 usePlugin支持灵活细致的扩展 上述方法中标记有`扩展支持: 是`的方法都支持使用usePlugin扩展
每个支持扩展的方法都支持定制before、after两个hook来干预原有方法的行为before可以用于修改传入参数after可以用于修改返回的值 每个支持扩展的方法都支持定制before、after两个hook来干预原有方法的行为before可以用于修改传入参数after可以用于修改返回的值

View File

@ -21,6 +21,8 @@
<<< @/../packages/editor/src/type.ts#HistoryOpType{ts} <<< @/../packages/editor/src/type.ts#HistoryOpType{ts}
<<< @/../packages/editor/src/type.ts#HistoryOpSource{ts}
<<< @/../packages/schema/src/index.ts#Id{ts} <<< @/../packages/schema/src/index.ts#Id{ts}
<<< @/../packages/schema/src/index.ts#MNode{ts} <<< @/../packages/schema/src/index.ts#MNode{ts}
@ -29,3 +31,78 @@
:::tip :::tip
当游标处于历史栈边界(已经无法继续撤销或重做)时,`UndoRedo.undo()` / `redo()` 返回 `null`,对应 `change` 回调收到的 `state``null` 当游标处于历史栈边界(已经无法继续撤销或重做)时,`UndoRedo.undo()` / `redo()` 返回 `null`,对应 `change` 回调收到的 `state``null`
::: :::
## code-block-history-change
- **详情:** 代码块历史记录发生变化(`pushCodeBlock` / `undoCodeBlock` / `redoCodeBlock` 成功时触发)
- **事件回调函数:** `(codeBlockId: Id, step: CodeBlockStepValue) => void`
::: details 查看 CodeBlockStepValue 及关联类型定义
<<< @/../packages/editor/src/type.ts#CodeBlockStepValue{ts}
<<< @/../packages/editor/src/type.ts#HistoryOpSource{ts}
<<< @/../packages/schema/src/index.ts#CodeBlockContent{ts}
<<< @/../packages/schema/src/index.ts#Id{ts}
:::
:::tip
- 新增触发的 step 中 `oldContent``null`
- 删除触发的 step 中 `newContent``null`
- `undo` / `redo` 返回 `null`(边界状态)时不会触发该事件
:::
## data-source-history-change
- **详情:** 数据源历史记录发生变化(`pushDataSource` / `undoDataSource` / `redoDataSource` 成功时触发)
- **事件回调函数:** `(dataSourceId: Id, step: DataSourceStepValue) => void`
::: details 查看 DataSourceStepValue 及关联类型定义
<<< @/../packages/editor/src/type.ts#DataSourceStepValue{ts}
<<< @/../packages/editor/src/type.ts#HistoryOpSource{ts}
<<< @/../packages/schema/src/index.ts#Id{ts}
:::
:::tip
- 新增触发的 step 中 `oldSchema``null`
- 删除触发的 step 中 `newSchema``null`
- `undo` / `redo` 返回 `null`(边界状态)时不会触发该事件
:::
## mark-saved
- **详情:** 调用 `markSaved` / `markPageSaved` / `markCodeBlockSaved` / `markDataSourceSaved` 标记「已保存」记录时触发
- **事件回调函数:** `(payload: { kind: 'all' | 'page' | 'code-block' | 'data-source'; id?: Id }) => void`
::: tip
- `markSaved` 触发时 `kind``all`,无 `id`
- 细粒度方法触发时 `kind` 对应类别,`id` 为目标页面 / 代码块 / 数据源 id
:::
## save-to-indexed-db
- **详情:** `saveToIndexedDB` 把历史记录写入本地 IndexedDB 成功时触发
- **事件回调函数:** `(snapshot: PersistedHistoryState) => void`
::: details 查看 PersistedHistoryState 类型定义
<<< @/../packages/editor/src/type.ts#PersistedHistoryState{ts}
<<< @/../packages/editor/src/utils/undo-redo.ts#SerializedUndoRedo{ts}
:::
## restore-from-indexed-db
- **详情:** `restoreFromIndexedDB` 从本地 IndexedDB 读取并重建历史记录成功时触发(找不到记录时不触发)
- **事件回调函数:** `(snapshot: PersistedHistoryState) => void`
::: details 查看 PersistedHistoryState 类型定义
<<< @/../packages/editor/src/type.ts#PersistedHistoryState{ts}
:::

View File

@ -4,7 +4,7 @@
- **详情:** - **详情:**
重置记录 重置全部历史记录(包括页面节点栈、代码块栈、数据源栈),并重置当前页面 id / canRedo / canUndo
## resetPage ## resetPage
@ -16,7 +16,7 @@
- **详情:** - **详情:**
重置历史记录全部内部状态(清空 pageId、pageSteps、canRedo、canUndo 重置历史记录全部内部状态(清空 pageId、pageSteps、canRedo、canUndo、codeBlockState、dataSourceState
## changePage ## changePage
@ -43,9 +43,13 @@
<<< @/../packages/editor/src/type.ts#HistoryOpType{ts} <<< @/../packages/editor/src/type.ts#HistoryOpType{ts}
<<< @/../packages/editor/src/type.ts#HistoryOpSource{ts}
<<< @/../packages/schema/src/index.ts#Id{ts} <<< @/../packages/schema/src/index.ts#Id{ts}
<<< @/../packages/schema/src/index.ts#MNode{ts} <<< @/../packages/schema/src/index.ts#MNode{ts}
<<< @/../packages/form-schema/src/base.ts#ChangeRecord{ts}
::: :::
- **返回:** - **返回:**
@ -55,6 +59,20 @@
添加一条历史记录 添加一条历史记录
::: tip
`opType: 'update'` 的每个 `updatedItems[i]` 上可携带 `changeRecords`,用于撤销 / 重做时仅按
`propPath` 局部更新对应字段,避免整节点替换冲掉同节点上的其它无关变更;不带
`changeRecords` 时退化为整节点替换(如 `sort` / `moveLayer` / 拖动等纯快照场景)。
`StepValue` 上的 `historyDescription` / `source` 仅用于历史面板展示与埋点,不影响 undo/redo 行为。
入栈时会为每条记录自动生成唯一标识 `uuid`(调用方未指定时),可用于精确引用 / 定位某一条历史记录。
若需要在执行 DSL 操作后拿到本次写入记录的 `uuid`,可使用 editorService / dataSourceService /
codeBlockService 提供的 `*AndGetHistoryId` 方法,参见
[editorService 历史记录 uuid 与 \*AndGetHistoryId](./editorServiceMethods.md#历史记录-uuid-与-andgethistoryid)。
`pushCodeBlock` / `pushDataSource` 同样会自动写入 `uuid`
:::
## undo ## undo
- **返回:** - **返回:**
@ -62,7 +80,8 @@
- **详情:** - **详情:**
撤销当前操作 撤销当前操作。`opType: 'update'` 时,若 `updatedItems[i].changeRecords` 存在,会按
`propPath``oldNode` 取值做局部回滚;否则用 `oldNode` 整节点替换。
## redo ## redo
@ -71,7 +90,291 @@
- **详情:** - **详情:**
恢复到下一步 恢复到下一步。`opType: 'update'` 时,若 `updatedItems[i].changeRecords` 存在,会按
`propPath``newNode` 取值做局部重做;否则用 `newNode` 整节点替换。
## pushCodeBlock
- **参数:**
- `{Id} codeBlockId` 代码块 id
- `{Object} payload`
- `{CodeBlockContent | null} oldContent` 变更前的代码块内容;新增时为 `null`
- `{CodeBlockContent | null} newContent` 变更后的代码块内容;删除时为 `null`
- `{ChangeRecord[]} changeRecords` 可选form 端 propPath/value 变更列表,撤销/重做时若有则按 propPath 局部更新;缺省(或空数组)才退化为整内容替换
- `{string}` historyDescription 可选;人类可读描述,用于历史面板展示;不影响 undo/redo 行为
- `{HistoryOpSource}` source 可选;操作途径,用于历史面板展示与埋点;不影响 undo/redo 行为
::: details 查看 CodeBlockStepValue 及关联类型定义
<<< @/../packages/editor/src/type.ts#CodeBlockStepValue{ts}
<<< @/../packages/editor/src/type.ts#HistoryOpSource{ts}
<<< @/../packages/schema/src/index.ts#CodeBlockContent{ts}
<<< @/../packages/form-schema/src/base.ts#ChangeRecord{ts}
:::
- **返回:**
- `{CodeBlockStepValue | null}` 入栈失败(未传 id时返回 `null`
- **详情:**
推入一条代码块变更记录。与页面 / 节点完全无关,按 `codeBlockId` 维度独立一份 `UndoRedo` 栈,
栈实例存放在 `historyService.state.codeBlockState[codeBlockId]`
入栈成功后会触发 `code-block-history-change` 事件。
::: tip
`codeBlockService.setCodeDslByIdSync``codeBlockService.deleteCodeDslByIds` 内部已经
自动调用本方法,业务代码通常无需手动调用。
:::
## undoCodeBlock
- **参数:**
- `{Id} codeBlockId`
- **返回:**
- `{CodeBlockStepValue | null}` 栈不存在或已无可撤销记录时返回 `null`
- **详情:**
撤销指定代码块的最近一次变更。成功时会触发 `code-block-history-change` 事件。
拿到 step 后由调用方根据 `step.oldContent` 写回 `codeBlockService`(本方法不会自动回放)。
## redoCodeBlock
- **参数:**
- `{Id} codeBlockId`
- **返回:**
- `{CodeBlockStepValue | null}` 栈不存在或已无可重做记录时返回 `null`
- **详情:**
重做指定代码块的下一次变更。成功时会触发 `code-block-history-change` 事件。
## canUndoCodeBlock
- **参数:**
- `{Id} codeBlockId`
- **返回:**
- `{boolean}`
- **详情:**
指定代码块当前是否可撤销。栈不存在时返回 `false`
## canRedoCodeBlock
- **参数:**
- `{Id} codeBlockId`
- **返回:**
- `{boolean}`
- **详情:**
指定代码块当前是否可重做。栈不存在时返回 `false`
## pushDataSource
- **参数:**
- `{Id} dataSourceId` 数据源 id
- `{Object} payload`
- `{DataSourceSchema | null} oldSchema` 变更前的数据源 schema新增时为 `null`
- `{DataSourceSchema | null} newSchema` 变更后的数据源 schema删除时为 `null`
- `{ChangeRecord[]} changeRecords` 可选form 端 propPath/value 变更列表,撤销/重做时若有则按 propPath 局部更新;缺省(或空数组)才退化为整 schema 替换
- `{string}` historyDescription 可选;人类可读描述,用于历史面板展示;不影响 undo/redo 行为
- `{HistoryOpSource}` source 可选;操作途径,用于历史面板展示与埋点;不影响 undo/redo 行为
::: details 查看 DataSourceStepValue 及关联类型定义
<<< @/../packages/editor/src/type.ts#DataSourceStepValue{ts}
<<< @/../packages/editor/src/type.ts#HistoryOpSource{ts}
<<< @/../packages/form-schema/src/base.ts#ChangeRecord{ts}
:::
- **返回:**
- `{DataSourceStepValue | null}` 入栈失败(未传 id时返回 `null`
- **详情:**
推入一条数据源变更记录。与页面 / 节点完全无关,按 `dataSourceId` 维度独立一份 `UndoRedo` 栈,
栈实例存放在 `historyService.state.dataSourceState[dataSourceId]`
入栈成功后会触发 `data-source-history-change` 事件。
::: tip
`dataSourceService.add` / `update` / `remove` 内部已经自动调用本方法,业务代码通常无需手动调用。
:::
## undoDataSource
- **参数:**
- `{Id} dataSourceId`
- **返回:**
- `{DataSourceStepValue | null}`
- **详情:**
撤销指定数据源的最近一次变更。成功时会触发 `data-source-history-change` 事件。
拿到 step 后由调用方根据 `step.oldSchema` 写回 `dataSourceService`(本方法不会自动回放)。
## redoDataSource
- **参数:**
- `{Id} dataSourceId`
- **返回:**
- `{DataSourceStepValue | null}`
- **详情:**
重做指定数据源的下一次变更。成功时会触发 `data-source-history-change` 事件。
## canUndoDataSource
- **参数:**
- `{Id} dataSourceId`
- **返回:**
- `{boolean}`
- **详情:**
指定数据源当前是否可撤销。栈不存在时返回 `false`
## canRedoDataSource
- **参数:**
- `{Id} dataSourceId`
- **返回:**
- `{boolean}`
- **详情:**
指定数据源当前是否可重做。栈不存在时返回 `false`
## markSaved
- **详情:**
标记「整份 DSL 已保存」:把页面 / 代码块 / 数据源所有栈当前游标所在的记录都标记为已保存(`saved = true`)。
同一栈内任意时刻最多保留一条已保存记录标记前会清除该栈内全部旧标记某个栈处于「全部已撤销」cursor 为 0时不会留下已保存记录从 IndexedDB 恢复时其游标会回到 0。
通常在 DSL 整体落库(保存到后端 / 本地)成功后调用,配合 [`restoreFromIndexedDB`](#restorefromindexeddb) 把游标恢复到此处。仅保存了其中一类时请改用更细粒度的 `markPageSaved` / `markCodeBlockSaved` / `markDataSourceSaved`
调用后会触发 `mark-saved` 事件(`{ kind: 'all' }`)。
## markPageSaved
- **参数:**
- `{Id} pageId` 可选;缺省为当前活动页
- **详情:**
标记指定页面(缺省当前活动页)历史栈的当前记录为已保存,仅影响该页面自己的栈。触发 `mark-saved` 事件(`{ kind: 'page', id }`)。
## markCodeBlockSaved
- **参数:**
- `{Id} codeBlockId`
- **详情:**
标记指定代码块历史栈的当前记录为已保存,仅影响该代码块自己的栈。触发 `mark-saved` 事件(`{ kind: 'code-block', id }`)。
## markDataSourceSaved
- **参数:**
- `{Id} dataSourceId`
- **详情:**
标记指定数据源历史栈的当前记录为已保存,仅影响该数据源自己的栈。触发 `mark-saved` 事件(`{ kind: 'data-source', id }`)。
## clearPage
- **参数:**
- `{Id} pageId` 可选;缺省为当前活动页
- **详情:**
清空指定页面(缺省当前活动页)的历史记录栈。仅删除撤销/重做记录,不会改动当前 DSL清空后该页将无法再撤销/重做之前的操作。清空当前活动页时会同步刷新 `canUndo` / `canRedo` 并触发 `change` 事件。
## clearCodeBlock
- **参数:**
- `{Id} codeBlockId` 可选;缺省清空全部代码块
- **详情:**
清空代码块历史记录栈:传入 `codeBlockId` 仅清空该代码块,缺省清空全部代码块。仅删除撤销/重做记录,不会改动代码块本身。
## clearDataSource
- **参数:**
- `{Id} dataSourceId` 可选;缺省清空全部数据源
- **详情:**
清空数据源历史记录栈:传入 `dataSourceId` 仅清空该数据源,缺省清空全部数据源。仅删除撤销/重做记录,不会改动数据源本身。
## saveToIndexedDB
- **参数:**
- `{HistoryPersistOptions} options` 可选
::: details 查看 HistoryPersistOptions / PersistedHistoryState 类型定义
<<< @/../packages/editor/src/type.ts#HistoryPersistOptions{ts}
<<< @/../packages/editor/src/type.ts#PersistedHistoryState{ts}
<<< @/../packages/editor/src/utils/undo-redo.ts#SerializedUndoRedo{ts}
:::
- **返回:**
- `{Promise<PersistedHistoryState>}` 写入成功的快照对象
- **详情:**
把当前内存中的全部历史栈(页面 / 代码块 / 数据源)连同各自游标、容量序列化后写入本地 IndexedDB。
- 最终库名为 `${dbName}-${当前 DSL app id}`,按应用隔离;
- `key` 用于在同一 store 下区分不同记录,缺省为 `default`
- 历史记录里可能包含函数(代码块内容 / 节点事件等),内部使用 `serialize-javascript` 序列化为字符串后写入,恢复时再用 `parseDSL` 还原,因此可安全持久化函数 / `Map` 等;
- 不支持 IndexedDB 的环境(如 SSR会 reject。
写入成功后触发 `save-to-indexed-db` 事件。
::: warning
`beforeunload` / `pagehide` 阶段浏览器不会等待异步 IndexedDB 事务提交,单纯依赖卸载时写入可能丢失最近一次编辑。建议在历史变更时(防抖)即调用本方法持久化,确保刷新后能完整恢复。
:::
## restoreFromIndexedDB
- **参数:**
- `{HistoryPersistOptions} options` 可选
- **返回:**
- `{Promise<PersistedHistoryState | null>}` 找不到记录时返回 `null`
- **详情:**
从本地 IndexedDB 读取此前保存的历史快照并重建全部撤销/重做栈。
- 每个栈都会按 `listMaxSize` 裁剪并还原游标;
- 若某个栈存在已保存记录(见 `markSaved`),其游标会被定位到「最近一条已保存记录」之后,使恢复后的状态与落库的 DSL 对齐;
- 会整体覆盖当前内存中的历史状态,并把活动页恢复为快照中的 `pageId`
- 找不到对应记录时返回 `null` 且不改动当前状态;不支持 IndexedDB 的环境会 reject。
成功后触发 `restore-from-indexed-db``change` 事件。
## destroy ## destroy

View File

@ -260,7 +260,7 @@ icon使用的是[element-plus icon](https://element-plus.org/zh-CN/component/ico
顶部工具栏 顶部工具栏
系统提供了几个常用功能: `'/' | 'delete' | 'undo' | 'redo' | 'zoom-in' | 'zoom-out' | 'zoom' | 'guides' | 'rule' | 'scale-to-original' | 'scale-to-fit'` 系统提供了几个常用功能: `'/' | 'delete' | 'undo' | 'redo' | 'zoom-in' | 'zoom-out' | 'zoom' | 'guides' | 'rule' | 'scale-to-original' | 'scale-to-fit' | 'history-list'`
'/': 分隔符 '/': 分隔符
@ -284,6 +284,8 @@ icon使用的是[element-plus icon](https://element-plus.org/zh-CN/component/ico
'scale-to-fit': 缩放以适应 'scale-to-fit': 缩放以适应
'history-list': 历史记录面板(按 页面 / 数据源 / 代码块 三个 tab 展示操作历史,相邻同目标修改自动合并,支持点击跳转、回到初始状态、单步回滚及差异对比,详见[历史记录面板](/guide/advanced/history-list.md)
- **默认值:** - **默认值:**
```js ```js
@ -1218,6 +1220,28 @@ const guidesOptions = {
</template> </template>
``` ```
## disabledFlashTip
- **详情:**
禁用「非点击画布选中组件时的高亮闪烁提示」。
当组件不是通过点击画布选中(如从组件树、面包屑等外部方式选中)时,编辑器会在画布上对选中区域做一次高亮闪烁,帮助用户快速定位组件在画布中的位置。设置为 `true` 可关闭该提示。
注:选中页面(`magic-ui-page`)时不会触发闪烁。
- **默认值:** `false`
- **类型:** `boolean`
- **示例:**
```html
<template>
<m-editor :disabled-flash-tip="true"></m-editor>
</template>
```
## disabledStageOverlay ## disabledStageOverlay
- **详情:** - **详情:**
@ -1506,6 +1530,55 @@ const extendFormState = async (state) => {
``` ```
::: :::
## historyListExtraTabs
- **详情:**
[历史记录面板](/guide/advanced/history-list.md) 的自定义扩展 tab。
业务方可借此在历史记录面板内置的「页面 / 数据源 / 代码块」三个 tab 之后追加自定义模块的历史 tab例如某个自定义模块维护自己的操作历史时可在面板中增加一个独立的 tab 来展示与回滚。
- **默认值:** `[]`
- **类型:** `HistoryListExtraTab[]`
::: details 查看 HistoryListExtraTab 类型定义
<<< @/../packages/editor/src/type.ts#HistoryListExtraTab{ts}
:::
- **示例:**
```html
<template>
<m-editor :menu="menu" :history-list-extra-tabs="historyListExtraTabs"></m-editor>
</template>
<script setup>
import { markRaw } from 'vue';
import MyModuleHistoryTab from './MyModuleHistoryTab.vue';
const historyListExtraTabs = [
{
name: 'my-module',
// label 支持字符串或函数,函数形式便于展示动态数量
label: () => '我的模块',
component: markRaw(MyModuleHistoryTab),
// 传入内容组件的 props
props: { foo: 'bar' },
// 内容组件的事件监听
listeners: {
goto: (cursor) => console.log(cursor),
},
},
];
</script>
```
::: tip
内容组件内部可自行通过 `useServices()` 获取 `historyService` 等服务来读取与回滚自定义模块的历史。
:::
## pageBarSortOptions ## pageBarSortOptions
- **详情:** - **详情:**

View File

@ -254,15 +254,11 @@
销毁propsService 销毁propsService
## use
使用中间件的方式扩展方法,上述方法中标记有`扩展支持: 是`的方法都支持使用use扩展
## usePlugin ## usePlugin
- **详情:** - **详情:**
相对于[use](#use), usePlugin支持更加灵活更加细致的扩展, 上述方法中标记有`扩展支持: 是`的方法都支持使用usePlugin扩展 usePlugin支持灵活细致的扩展, 上述方法中标记有`扩展支持: 是`的方法都支持使用usePlugin扩展
每个支持扩展的方法都支持定制before、after两个hook来干预原有方法的行为before可以用于修改传入参数after可以用于修改返回的值 每个支持扩展的方法都支持定制before、after两个hook来干预原有方法的行为before可以用于修改传入参数after可以用于修改返回的值

View File

@ -231,28 +231,11 @@ import { storageService } from '@tmagic/editor';
storageService.destroy(); storageService.destroy();
``` ```
## use
使用中间件的方式扩展方法,上述方法中标记有`扩展支持: 是`的方法都支持使用use扩展
- **示例:**
```js
import { storageService } from '@tmagic/editor';
storageService.use({
getItem(key, options, next) {
console.log('获取存储项:', key);
return next();
},
});
```
## usePlugin ## usePlugin
- **详情:** - **详情:**
相对于[use](#use), usePlugin支持更加灵活更加细致的扩展, 上述方法中标记有`扩展支持: 是`的方法都支持使用usePlugin扩展 usePlugin支持灵活细致的扩展 上述方法中标记有`扩展支持: 是`的方法都支持使用usePlugin扩展
每个支持扩展的方法都支持定制before、after两个hook来干预原有方法的行为before可以用于修改传入参数after可以用于修改返回的值 每个支持扩展的方法都支持定制before、after两个hook来干预原有方法的行为before可以用于修改传入参数after可以用于修改返回的值

View File

@ -179,29 +179,11 @@ import { uiService } from '@tmagic/editor';
uiService.destroy(); uiService.destroy();
``` ```
## use
使用中间件的方式扩展方法,上述方法中标记有`扩展支持: 是`的方法都支持使用use扩展
- **示例:**
```js
import { uiService } from '@tmagic/editor';
uiService.use({
async zoom(value, next) {
console.log('缩放前:', uiService.get('zoom'));
await next();
console.log('缩放后:', uiService.get('zoom'));
},
});
```
## usePlugin ## usePlugin
- **详情:** - **详情:**
相对于[use](#use), usePlugin支持更加灵活更加细致的扩展, 上述方法中标记有`扩展支持: 是`的方法都支持使用usePlugin扩展 usePlugin支持灵活细致的扩展 上述方法中标记有`扩展支持: 是`的方法都支持使用usePlugin扩展
每个支持扩展的方法都支持定制before、after两个hook来干预原有方法的行为before可以用于修改传入参数after可以用于修改返回的值 每个支持扩展的方法都支持定制before、after两个hook来干预原有方法的行为before可以用于修改传入参数after可以用于修改返回的值

View File

@ -18,6 +18,8 @@
- **详情:** 提交表单,先执行校验,校验通过后清空 `changeRecords` 并返回当前表单值 - **详情:** 提交表单,先执行校验,校验通过后清空 `changeRecords` 并返回当前表单值
- **相关:** 如果你想脱离组件树以函数方式完成一次表单提交,参见 [`submitForm` 函数](./submit-form.md)
## changeHandler ## changeHandler
- **签名:** `(prop: string, value: any, eventData?: ContainerChangeEventData) => void` - **签名:** `(prop: string, value: any, eventData?: ContainerChangeEventData) => void`

View File

@ -85,6 +85,44 @@
- **类型:** `boolean` - **类型:** `boolean`
## showDiff
- **详情:**
自定义“是否展示对比内容”的判断函数(仅在 `isCompare === true` 时生效)。
- 不传:使用默认逻辑 `!isEqual(curValue, lastValue)`(基于 lodash `isEqual`
- 传函数:完全以函数返回值为准,返回 `true` 才展示前后两份对比内容。
该 prop 通过 `formState` 透传到所有层级的 Container 中,调用方只需在 MForm 这一层传一次即可对整棵表单生效。
典型场景:某些字段语义上相等但结构不同(例如 `code-select` 字段中 `''``{ hookType: 'code', hookData: [] }` 应视为相等),调用方在此处显式声明,避免被 `isEqual` 误判为差异。
- **类型:** `(data: { curValue: any; lastValue: any; config: FormItemConfig }) => boolean`
- **示例:**
```html
<template>
<m-form :config="config" :is-compare="true" :last-values="lastValues" :show-diff="showDiff"></m-form>
</template>
<script setup>
import { isEqual } from 'lodash-es';
const showDiff = ({ curValue, lastValue, config }) => {
if (config?.type === 'code-select') {
// 业务侧自定义:双方都是“空形态”视为相等,不展示对比
const isEmpty = (v) =>
v === '' || v === undefined || v === null ||
(typeof v === 'object' && v.hookType === 'code' && Array.isArray(v.hookData) && v.hookData.length === 0);
if (isEmpty(curValue) && isEmpty(lastValue)) return false;
}
return !isEqual(curValue, lastValue);
};
</script>
```
## parentValues ## parentValues
- **详情:** 父级表单值 - **详情:** 父级表单值

View File

@ -0,0 +1,218 @@
# submitForm 函数
以命令式方式调用 `MForm` 组件完成一次表单校验/提交,类似 `ElMessage` 的用法。
调用时函数内部会临时挂载一个不可见的 `MForm` 实例,把入参作为 props 透传给它,等待初始化完成后调用其 `submitForm` 方法。校验通过则 `resolve` 表单值,校验失败则 `reject` 错误信息,最后自动卸载实例并清理 DOM。
适用于一些没有合适的容器、但又需要复用 `MForm` 校验逻辑的场景,例如:
- 通过快捷菜单/命令面板触发一次性表单
- 在脚本/服务层完成一次表单值校验后再发请求
- 把 `config` 配置当作"可执行的校验规则"使用
## 签名
```ts
function submitForm(options: SubmitFormOptions): Promise<any>;
```
## 参数
`options``MForm` 组件的 props 基本对齐,额外提供了 `native``returnChangeRecords``appContext``timeout` 等参数。
| 名称 | 类型 | 默认值 | 说明 |
| ---------------------- | ------------------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------- |
| `config` | `FormConfig` | — | 必填,表单配置 |
| `initValues` | `Record<string, any>` | `{}` | 表单初始值 |
| `lastValues` | `Record<string, any>` | `{}` | 需对比的值(开启对比模式时传入) |
| `isCompare` | `boolean` | `false` | 是否开启对比模式 |
| `parentValues` | `Record<string, any>` | `{}` | 父级 values透传给字段的回调 |
| `labelWidth` | `string` | `'200px'` | label 宽度 |
| `disabled` | `boolean` | `false` | 是否禁用 |
| `height` | `string` | `'auto'` | 表单高度 |
| `stepActive` | `string \| number` | `1` | 步骤表单当前激活步骤 |
| `size` | `'small' \| 'default' \| 'large'` | — | 组件尺寸 |
| `inline` | `boolean` | `false` | 是否行内表单 |
| `labelPosition` | `string` | `'right'` | label 对齐方式 |
| `keyProp` | `string` | `'__key'` | 配置项的唯一 key |
| `popperClass` | `string` | — | 弹层 className |
| `preventSubmitDefault` | `boolean` | — | 是否阻止表单原生 submit |
| `extendState` | `(state: FormState) => Record<string, any> \| Promise<Record<string, any>>` | — | 扩展 `formState` |
| `native` | `boolean` | `false` | 透传给 `Form.submitForm``true` 时返回内部响应式 `values`,否则返回 `cloneDeep(toRaw(values))` |
| `returnChangeRecords` | `boolean` | `false` | `true` 时 resolve 结果为 `{ values, changeRecords }`,携带表单变更记录;否则仅 resolve `values` |
| `appContext` | `AppContext \| null` | `null` | 父级 Vue 应用上下文。需要继承全局组件、指令、provide 等时传入,常通过 `app._context``getCurrentInstance()?.appContext` 获取 |
| `timeout` | `number` | `10000` | 等待表单初始化的最长时间(毫秒)。超时将以错误 reject。设为 `<= 0` 时关闭超时兜底 |
## 返回值
- `校验通过``Promise<any>` resolve 当前表单值(`native` 决定是否克隆);当 `returnChangeRecords``true`resolve `{ values, changeRecords }`
- `校验失败``Promise<any>` reject 一个 `Error``message` 中包含逐条字段错误信息(格式 `${text} -> ${message}`,多条用 `<br>` 分隔)
- `初始化超时``Promise<any>` reject `Error('submitForm timeout after ${timeout}ms: form is not initialized.')`
无论成功或失败,函数都会在最后自动 `unmount` 内部 app 并移除挂载用的 DOM 容器,无需调用方手动清理。
::: tip 关于 changeRecords
`changeRecords` 记录的是表单挂载后发生的字段变更(由各字段的 `change` 事件累积而来)。在 `submitForm` 这种命令式、无用户交互的场景下,通常为空数组;只有在 `extendState` 或字段联动等逻辑中触发了变更时才会有内容。`MForm` 内部的 `submitForm` 在校验通过后会清空变更记录,因此本函数会在调用前先对其做快照再返回。
:::
## 基础用法
```ts
import { submitForm } from '@tmagic/form';
try {
const values = await submitForm({
config: [
{
type: 'text',
name: 'username',
text: '用户名',
rules: [{ required: true, message: '请输入用户名' }],
},
],
initValues: { username: '' },
});
console.log('提交成功', values);
} catch (e) {
console.error('校验失败', e);
}
```
## 同时获取变更记录changeRecords
设置 `returnChangeRecords: true`resolve 的结果会从单纯的 `values` 变为 `{ values, changeRecords }`
```ts
import { submitForm } from '@tmagic/form';
const { values, changeRecords } = await submitForm({
config: [{ type: 'text', name: 'username', text: '用户名' }],
initValues: { username: 'foo' },
returnChangeRecords: true,
});
console.log(values); // { username: 'foo' }
console.log(changeRecords); // ChangeRecord[]
```
## 在组件中继承父级应用上下文
`MForm` 内部使用 `@tmagic/design` 的组件(背后可能是 `element-plus``tdesign`),需要宿主应用先完成相应的 `app.use(...)` 安装。在 `submitForm` 这种脱离常规组件树的命令式调用中,可通过 `appContext` 把父级应用上下文带过去:
```vue
<script setup lang="ts">
import { getCurrentInstance } from 'vue';
import { submitForm } from '@tmagic/form';
const { appContext } = getCurrentInstance()!;
const onClick = async () => {
const values = await submitForm({
config: [{ type: 'text', name: 'text', text: '文本' }],
initValues: { text: 'hello' },
appContext,
});
console.log(values);
};
</script>
```
也可以在初始化 app 时把上下文缓存下来,再在任意位置复用:
```ts
import { createApp } from 'vue';
import ElementPlus from 'element-plus';
import MagicForm, { type SubmitFormOptions, submitForm as rawSubmitForm } from '@tmagic/form';
import App from './App.vue';
const app = createApp(App);
app.use(ElementPlus);
app.use(MagicForm);
app.mount('#app');
export const submitForm = (options: Omit<SubmitFormOptions, 'appContext'>) =>
rawSubmitForm({ ...options, appContext: app._context });
```
## 处理校验错误
校验失败时 reject 的 `Error.message` 已经把出错字段拼好,可以直接展示到用户:
```ts
import { tMagicMessage } from '@tmagic/design';
try {
const values = await submitForm({ config, initValues });
await save(values);
} catch (e: any) {
tMagicMessage.error({
dangerouslyUseHTMLString: true,
message: e.message,
});
}
```
## 运行环境
`submitForm` 内部依赖 `document` / `window` 来挂载临时 Vue 实例,因此**只能在浏览器或具备 DOM 环境的运行时中使用**。
| 环境 | 是否可用 | 说明 |
| ----------------------------------------------- | -------- | --------------------------------------------------------------------------------- |
| 浏览器 / Electron 渲染进程 / 浏览器扩展 | ✅ | 直接可用 |
| Vitest / Jest + `happy-dom` / `jsdom` | ✅ | 项目自身的单测就跑在这种环境下 |
| 纯 Node.js / Bun / Deno无 DOM polyfill | ❌ | 模块顶层就会读 `document`,会抛 `document is not defined` |
| Node.js + 手动注入 `happy-dom` / `jsdom` | ⚠️ | 可用,需要在 import `@tmagic/form` **之前**完成全局变量注入;校验行为不一定与浏览器完全一致 |
### 在 Node.js 中使用(需要先准备 DOM
下面是一个在 Node 脚本里调用 `submitForm` 的完整例子,使用 [`happy-dom`](https://github.com/capricorn86/happy-dom) 作为 DOM polyfill
```ts
// scripts/check-form.ts
import { Window } from 'happy-dom';
const window = new Window();
Object.assign(globalThis, {
window,
document: window.document,
navigator: window.navigator,
HTMLElement: window.HTMLElement,
});
// 注意DOM polyfill 必须先注入到 globalThis再用动态 import
// 加载业务模块,否则 @tmagic/design 等模块顶层执行时就会读 document
const { createApp } = await import('vue');
const ElementPlus = (await import('element-plus')).default;
const MagicForm = (await import('@tmagic/form')).default;
const { submitForm } = await import('@tmagic/form');
const parentApp = createApp({ render: () => null });
parentApp.use(ElementPlus);
parentApp.use(MagicForm);
const values = await submitForm({
config: [{ type: 'text', name: 'username', text: '用户名' }],
initValues: { username: 'foo' },
appContext: parentApp._context,
});
console.log(values);
```
::: warning 注意
- DOM polyfill 必须在 **import 业务模块之前** 注入到 `globalThis`,否则模块顶层执行时仍会失败
- 在 `happy-dom` / `jsdom` 中,`element-plus` 的部分 `validate()` 行为不一定能 1:1 复现真实浏览器(例如某些场景下必填规则可能不触发),建议关键校验使用自定义 `validator` 函数确保稳定
- 如果只是想在 Node 端做一次纯校验,更稳妥的做法是直接复用 [`async-validator`](https://github.com/yiminghe/async-validator)element-plus 内部用的就是它),绕开整个 Vue 渲染层
:::
## 类型定义
::: details 查看 `SubmitFormOptions` 类型定义
<<< @/../packages/form/src/submitForm.ts#SubmitFormOptions{ts}
:::
::: details 查看 `SubmitFormResult` 类型定义
<<< @/../packages/form/src/submitForm.ts#SubmitFormResult{ts}
:::

View File

@ -1,7 +1,30 @@
# 表单对比 # 表单对比
tmagic-form可以支持两个版本的表单值对比如果有容器嵌套将在tab标签页展示对应tab下存在的差异数便于在复杂嵌套表单场景下直观的看到差异情况 tmagic-form可以支持两个版本的表单值对比如果有容器嵌套将在tab标签页展示对应tab下存在的差异数便于在复杂嵌套表单场景下直观的看到差异情况
## 使用方法 ## 使用方法
在初始化表单时,需要传入当前版本的表单值,上一版本的表单值,以及表单配置,具体可参见[Form Playground](https://tencent.github.io/tmagic-editor/playground/index.html#/form)的demo演示 在初始化表单时,开启对比模式 `is-compare`,并传入当前版本的表单值(`init-values`)、上一版本的表单值(`last-values`)以及表单配置,具体可参见[Form Playground](https://tencent.github.io/tmagic-editor/playground/index.html#/form)的demo演示。
```html
<m-form
:config="config"
:is-compare="true"
:init-values="curValues"
:last-values="lastValues"
></m-form>
```
相关属性详见 Form 组件 props
- [`isCompare`](/api/form/form-props.html#iscompare):是否开启对比模式;
- [`lastValues`](/api/form/form-props.html#lastvalues):需对比的上一版本表单值;
- [`showDiff`](/api/form/form-props.html#showdiff):自定义「是否展示对比内容」的判断函数,用于规避语义相等但结构不同导致的误判。
## 对比模式下的字段行为
对比模式下,表单仅做只读展示:增删、复制、排序、导入、编辑等写操作按钮会被隐藏。对于由列表或嵌套子表单组成的复合字段(如 `event-select``code-select``code-select-col`),表单会按索引对齐前后值,逐项展示新增 / 删除 / 修改的高亮差异,而不会渲染出两套独立组件。
## 应用场景
编辑器的[历史记录面板](/guide/advanced/history-list.md)即基于该能力,对历史步骤的前后值做表单形式的差异对比。
## 效果展示 ## 效果展示
<img src="https://vip.image.video.qpic.cn/vupload/20230301/c626071677661813135.png" alt="表单对比"/> <img src="https://vip.image.video.qpic.cn/vupload/20230301/c626071677661813135.png" alt="表单对比"/>

View File

@ -135,6 +135,18 @@
}] }]
}]"></demo-block> }]"></demo-block>
`legend` 除了支持字符串,也支持函数,函数返回值作为标题展示,可根据表单数据动态生成:
<demo-block type="form" :config="[{
type: 'fieldset',
labelWidth: '100px',
legend: (mForm, { formValue }) => `当前值:${formValue.text || '空'}`,
items: [{
name: 'text',
text: '配置1',
}]
}]"></demo-block>
### panel ### panel
<demo-block type="form" :config="[{ <demo-block type="form" :config="[{

View File

@ -0,0 +1,134 @@
# 历史记录面板
编辑器内置了一个可视化的「历史记录面板」,用于查看与回溯编辑过程中产生的所有操作。相比顶部菜单栏只能「撤销 / 重做」相邻一步,历史记录面板提供了对整条历史栈的全局视角:可以按页面、数据源、代码块分类浏览,点击任意一步直接跳转,查看每一步的前后差异,甚至像 `git revert` 一样单独回滚某一步而不破坏后续操作。
## 开启面板
历史记录面板以一个内置菜单项 `'history-list'` 的形式提供,将它加入 [`menu`](/api/editor/props.html#menu) 配置即可在顶部工具栏出现一个时钟图标,点击展开面板:
```html
<template>
<m-editor :menu="menu"></m-editor>
</template>
<script setup>
import { ref } from 'vue';
const menu = ref({
left: [],
center: ['delete', 'undo', 'redo', '/', 'history-list'],
right: [],
});
</script>
```
## 面板结构
面板分为三个 tab分别对应三类可被历史记录追踪的对象tab 标题后的数字为各自的分组数量:
| Tab | 内容 | 跳转 API |
| --- | --- | --- |
| 页面 | 当前活动页面的节点操作历史 | `editorService.gotoPageStep(cursor)` |
| 数据源 | 按 `dataSource.id` 分组的数据源变更历史 | `dataSourceService.goto(id, cursor)` |
| 代码块 | 按 `codeBlock.id` 分组的代码块变更历史 | `codeBlockService.goto(id, cursor)` |
### 相邻同目标自动合并
为了避免「连续微调同一个节点 / 数据源 / 代码块」时产生大量碎片化记录,面板会把**相邻的、针对同一目标的连续 `update`** 自动合并成一个分组:
- 页面 tab连续修改同一节点按节点 id 判定)的多步合并为一组,点击组头部可展开查看每一子步;
- 数据源 / 代码块 tab相邻的连续 `update` 按目标 id 合并;`add` / `remove` 始终独立成组(语义上是一次性事件)。
> 合并仅作用于展示与交互,不改变底层 undo/redo 栈的真实结构。
## 交互能力
每个分组 / 步骤支持以下操作:
### 1. 点击跳转
点击任意一条记录编辑器会跳转到「应用至该步完成」的状态。其本质是把对应栈的游标cursor移动到 `step.index + 1`,由 service 层的 undo/redo 链路完成中间步骤的批量正向 / 反向应用。
### 2. 回到初始状态
每个 tab 列表底部提供「回到初始状态」入口,等价于把对应栈游标移到 `0`(所有真实步骤全部撤销)。
### 3. 单步回滚(类 git revert
对于历史中间的某一步,可以单独「回滚」它,而保留它之后的所有操作。该行为不会倒带游标,而是把目标步骤的修改**反向应用为一次全新的操作**并压入栈顶,因此不会破坏既有历史结构:
- 页面:`editorService.revertPageStep(index)`
- 数据源:`dataSourceService.revert(id, index)`
- 代码块:`codeBlockService.revert(id, index)`
如果业务侧在执行操作时已通过 `*AndGetHistoryId` 拿到了该条记录的 [uuid](/api/editor/editorServiceMethods.md#历史记录-uuid-与-andgethistoryid),也可以直接按 uuid 回滚(无需再关心 index / id且 uuid 不会随栈内步骤增删而变化):
- 页面:`editorService.revertPageStepById(uuid)`
- 数据源:`dataSourceService.revertById(uuid)`
- 代码块:`codeBlockService.revertById(uuid)`
### 4. 差异对比
在前后值都存在的 `update` 步骤上提供「查看差异」入口,点击后弹出差异对话框。对话框支持两个维度的切换:
- **对比对象**
- `与修改前对比`:该步骤修改前 vs 修改后(默认,体现这一步带来的变化);
- `与当前对比`:该步骤修改后 vs 编辑器中的最新值(用于确认「这一步之后是否又被改动过」,当前值缺失时禁用)。
- **展示形态**
- `表单对比`:以属性表单形式逐字段对比,可读性更好(基于 [表单对比](/form-config/compare.md) 能力);
- `源码对比`:以 JSON 源码做整体 diff基于 monaco diff 编辑器),可以看到表单未覆盖到的字段。
::: tip
表单对比依赖 `@tmagic/form` 的对比模式(`isCompare` / `lastValues`)。对于 `event-select``code-select``code-select-col` 等由列表或嵌套子表单组成的复合字段,表单会逐项展示新增 / 删除 / 修改的高亮差异,并在对比模式下隐藏「添加 / 删除 / 编辑」等写操作按钮,仅保留只读展示。
:::
## 扩展自定义 tab
内置的三个 tab 之外,业务方可以通过 Editor 的 [`historyListExtraTabs`](/api/editor/props.html#historylistextratabs) 在面板中追加自定义的历史 tab追加在「页面 / 数据源 / 代码块」之后。适用于某个自定义模块维护自己的操作历史,需要在历史记录面板中独立展示与回滚的场景。
```html
<template>
<m-editor :menu="menu" :history-list-extra-tabs="historyListExtraTabs"></m-editor>
</template>
<script setup>
import { markRaw } from 'vue';
import MyModuleHistoryTab from './MyModuleHistoryTab.vue';
const historyListExtraTabs = [
{
name: 'my-module',
// label 支持字符串或函数,函数形式便于展示动态数量
label: () => `我的模块 (${getMyModuleHistory().length})`,
component: markRaw(MyModuleHistoryTab),
props: { foo: 'bar' },
listeners: {
goto: (cursor) => console.log(cursor),
},
},
];
</script>
```
每个扩展 tab 的字段说明:
| 字段 | 必填 | 说明 |
| --- | --- | --- |
| `name` | 是 | tab 唯一标识,作为内部 `TMagicTabs``name` |
| `label` | 是 | tab 显示文案,支持字符串或返回字符串的函数(便于展示动态数量) |
| `component` | 是 | tab 内容区渲染的组件 |
| `props` | 否 | 传入内容组件的 props |
| `listeners` | 否 | 内容组件的事件监听 |
> 内容组件内部可自行通过 `useServices()` 拿到 `historyService` 等服务,读取并回滚自定义模块自己维护的历史。
## 自定义对比判断
差异对话框中的「表单对比」最终透传到 `MForm`,你可以通过 Editor 顶层注入的 `extendFormState` 让对比表单拿到完整业务上下文,从而让依赖上下文的 `display` / `disabled``filterFunction` 正常工作。
若某些字段语义上相等但结构不同(例如 `code-select` 字段中 `''``{ hookType: 'code', hookData: [] }` 应视为相等),可借助 `@tmagic/form` 的 [`showDiff`](/api/form/form-props.html#showdiff) 自定义判断函数避免被误判为差异。
## 相关 API
历史面板的数据均来自 `historyService` 暴露的聚合方法,详见 [historyService 方法](/api/editor/historyServiceMethods.md)。

View File

@ -349,6 +349,43 @@ export default {
lib: 'always', lib: 'always',
}, },
], ],
/**
* 禁止匿名 default class / default function 导出
* @reason 匿名 default 导出在 dts 聚合rolldown / api-extractor / vue-tsc 时会被命名为
* `export_default`导致跨包继承链在 .vue / .tsx 文件下解析失败
* 父类成员 EventEmitter on/off无法被 ts-plugin 推断出来
* 必须使用具名形式 `export class Foo {}` `export default Foo;`
* `export default class Foo {}`确保类型聚合后保留原标识符
*
* 此处需要重申 base.mjs 中已有的 no-restricted-syntax 选择器
* ForIn / Labeled / With否则在 .ts/.tsx 下会被本规则整体覆盖
*/
'no-restricted-syntax': [
'error',
{
selector: 'ExportDefaultDeclaration > ClassDeclaration[id=null]',
message:
'禁止匿名 default class 导出。请改为具名形式(如 `export default class Foo extends Bar {}`),否则聚合 dts 会丢失类型信息导致跨包继承的成员on/off/emit 等)无法被推断。',
},
{
selector: 'ExportDefaultDeclaration > FunctionDeclaration[id=null]',
message:
'禁止匿名 default function 导出。请改为具名形式(如 `export default function foo() {}`),便于 dts 聚合保留原标识符与跨包类型推断。',
},
{
selector: 'ForInStatement',
message:
'for..in loops iterate over the entire prototype chain, which is virtually never what you want. Use Object.{keys,values,entries}, and iterate over the resulting array.',
},
{
selector: 'LabeledStatement',
message: 'Labels are a form of GOTO; using them makes code confusing and hard to maintain and understand.',
},
{
selector: 'WithStatement',
message: '`with` is disallowed in strict mode because it makes code impossible to predict and optimize.',
},
],
/** /**
* 在类型注释周围需要一致的间距 * 在类型注释周围需要一致的间距
*/ */

View File

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

View File

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

View File

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

View File

@ -244,7 +244,7 @@ class App extends EventEmitter {
node.data?.id && node.data?.id &&
node.eventKeys.has(`${String(name)}_${node.data.id}`) node.eventKeys.has(`${String(name)}_${node.data.id}`)
) { ) {
return this.eventHelper.emit(node.eventKeys.get(`${String(name)}_${node.data.id}`)!, node, ...otherArgs); this.eventHelper.emit(node.eventKeys.get(`${String(name)}_${node.data.id}`)!, node, ...otherArgs);
} }
return super.emit(name, ...args); return super.emit(name, ...args);
} }

View File

@ -431,6 +431,58 @@ describe('App 配置/方法/组件注册', () => {
expect(typeof result).toBe('boolean'); expect(typeof result).toBe('boolean');
}); });
// 回归用例:节点配置了 events 时eventHelper 派发不能短路掉 super.emit
// 即 app.on(name, cb) 注册的回调依然要被触发。
test('emit: 节点已绑定 events 时app.on 注册的监听器仍然会被调用', () => {
const app = new App({
config: {
type: NodeType.ROOT,
id: 'app',
items: [
{
type: NodeType.PAGE,
id: 'p1',
items: [{ id: 'btn', type: 'button', events: [{ name: 'click', actions: [] }] }],
},
],
} as any,
});
const node = app.getNode('btn')!;
const cb = vi.fn();
app.on('click', cb);
const result = app.emit('click', node, 'arg1');
expect(cb).toHaveBeenCalledTimes(1);
expect(cb).toHaveBeenCalledWith(node, 'arg1');
// EventEmitter.emit 在有 listener 时返回 true
expect(result).toBe(true);
});
test('emit: 未命中节点 eventKeys 时app.on 注册的监听器正常被调用', () => {
const app = new App({
config: {
type: NodeType.ROOT,
id: 'app',
items: [
{
type: NodeType.PAGE,
id: 'p1',
items: [{ id: 'btn', type: 'button' }],
},
],
} as any,
});
const node = app.getNode('btn')!;
const cb = vi.fn();
app.on('click', cb);
app.emit('click', node, 'arg1');
expect(cb).toHaveBeenCalledTimes(1);
expect(cb).toHaveBeenCalledWith(node, 'arg1');
});
test('destroy 清理所有资源', () => { test('destroy 清理所有资源', () => {
const app = new App({ const app = new App({
config: { config: {

View File

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

View File

@ -241,7 +241,7 @@ export const registerDataSourceOnDemand = async (
dsl: MApp, dsl: MApp,
dataSourceModules: Record<string, () => Promise<AsyncDataSourceResolveResult>>, dataSourceModules: Record<string, () => Promise<AsyncDataSourceResolveResult>>,
) => { ) => {
const { dataSourceMethodsDeps = {}, dataSourceCondDeps = {}, dataSourceDeps = {}, dataSources = [] } = dsl; const { dataSourceMethodDeps = {}, dataSourceCondDeps = {}, dataSourceDeps = {}, dataSources = [] } = dsl;
const dsModuleMap: Record<string, () => Promise<AsyncDataSourceResolveResult>> = {}; const dsModuleMap: Record<string, () => Promise<AsyncDataSourceResolveResult>> = {};
@ -253,7 +253,7 @@ export const registerDataSourceOnDemand = async (
} }
if (!Object.keys(dep).length) { if (!Object.keys(dep).length) {
dep = dataSourceMethodsDeps[ds.id] || {}; dep = dataSourceMethodDeps[ds.id] || {};
} }
if (Object.keys(dep).length && dataSourceModules[ds.type]) { if (Object.keys(dep).length && dataSourceModules[ds.type]) {

View File

@ -377,7 +377,7 @@ describe('registerDataSourceOnDemand', () => {
], ],
dataSourceDeps: { a: { node1: { name: 'n', keys: ['x'] } } }, dataSourceDeps: { a: { node1: { name: 'n', keys: ['x'] } } },
dataSourceCondDeps: { c: { node2: { name: 'n', keys: ['y'] } } }, dataSourceCondDeps: { c: { node2: { name: 'n', keys: ['y'] } } },
dataSourceMethodsDeps: {}, dataSourceMethodDeps: {},
}; };
const httpModule = { default: class HttpDS {} }; const httpModule = { default: class HttpDS {} };
const mockModule = { default: class MockDS {} }; const mockModule = { default: class MockDS {} };

View File

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

View File

@ -127,10 +127,38 @@ export default class Watcher {
deep = false, deep = false,
type?: DepTargetType | string, type?: DepTargetType | string,
) { ) {
this.collectByCallback(nodes, type, ({ node, target }) => { const targets = this.getCollectableTargets(type);
this.removeTargetDep(target, node);
this.collectItem(node, target, depExtendedData, deep); if (!targets.length) {
}); return;
}
// 整棵树只遍历一次、在每个属性上检查所有 target把结构遍历开销从 ×targets 降到 ×1详见 collectItems
for (const node of nodes) {
this.removeTargetsDep(targets, node);
this.collectItems(node, targets, depExtendedData, deep);
}
}
/**
* target collectByCallback
*
* editor dep service / worker public
* @param type
*/
public getCollectableTargets(type?: DepTargetType | string): Target[] {
const targets: Target[] = [];
traverseTarget(
this.targetsList,
(target) => {
if (!type && !target.isCollectByDefault) {
return;
}
targets.push(target);
},
type,
);
return targets;
} }
public collectByCallback( public collectByCallback(
@ -195,53 +223,11 @@ export default class Watcher {
this.clear(nodes, type); this.clear(nodes, type);
} }
/**
* target collectItems(node, [target], ...)
*/
public collectItem(node: TargetNode, target: Target, depExtendedData: DepExtendedData = {}, deep = false) { public collectItem(node: TargetNode, target: Target, depExtendedData: DepExtendedData = {}, deep = false) {
if (node[NODE_DISABLE_DATA_SOURCE_KEY] && DATA_SOURCE_TARGET_TYPES.has(target.type)) { this.collectItems(node, [target], depExtendedData, deep);
return;
}
if (node[NODE_DISABLE_CODE_BLOCK_KEY] && target.type === DepTargetType.CODE_BLOCK) {
return;
}
const collectTarget = (config: Record<string | number, any>, prop = '') => {
const doCollect = (key: string, value: any) => {
const keyIsItems = key === this.childrenProp;
const fullKey = prop ? `${prop}.${key}` : key;
if (target.isTarget(fullKey, value)) {
target.updateDep({
id: node[this.idProp],
name: `${node[this.nameProp] || node[this.idProp]}`,
data: depExtendedData,
key: fullKey,
});
} else if (!keyIsItems && Array.isArray(value)) {
for (let i = 0, l = value.length; i < l; i++) {
const item = value[i];
if (isObject(item)) {
collectTarget(item, `${fullKey}[${i}]`);
}
}
} else if (isObject(value)) {
collectTarget(value, fullKey);
}
if (keyIsItems && deep && Array.isArray(value)) {
for (const child of value) {
this.collectItem(child, target, depExtendedData, deep);
}
}
};
for (const [key, value] of Object.entries(config)) {
if (typeof value === 'undefined' || value === '') continue;
doCollect(key, value);
}
};
collectTarget(node);
} }
public removeTargetDep(target: Target, node: TargetNode, key?: string | number) { public removeTargetDep(target: Target, node: TargetNode, key?: string | number) {
@ -252,4 +238,117 @@ export default class Watcher {
} }
} }
} }
/**
* removeTargetDep target
* ×targets ×1
*
* editor dep service public
*/
public removeTargetsDep(targets: Target[], node: TargetNode, key?: string | number) {
const id = node[this.idProp];
for (const target of targets) {
target.removeDep(id, key);
}
if (typeof key === 'undefined' && Array.isArray(node[this.childrenProp]) && node[this.childrenProp].length) {
for (const item of node[this.childrenProp] as TargetNode[]) {
this.removeTargetsDep(targets, item, key);
}
}
}
/**
* collectItem target
*
* target O(targets × ) +
* Object.entries / / fullKey targets
* target ×targets ×1isTarget
*
* editor dep service / worker public
*/
public collectItems(node: TargetNode, targets: Target[], depExtendedData: DepExtendedData = {}, deep = false) {
// 对应 collectItem 开头的 NODE_DISABLE_* 判断:被禁用的 target 在该节点及其子树都不收集
const activeTargets = this.filterTargetsByNode(node, targets);
if (!activeTargets.length) {
return;
}
this.collectTargetForTargets(node, node, '', activeTargets, depExtendedData, deep);
}
private filterTargetsByNode(node: TargetNode, targets: Target[]): Target[] {
const disableDataSource = Boolean(node[NODE_DISABLE_DATA_SOURCE_KEY]);
const disableCodeBlock = Boolean(node[NODE_DISABLE_CODE_BLOCK_KEY]);
if (!disableDataSource && !disableCodeBlock) {
return targets;
}
return targets.filter((target) => {
if (disableDataSource && DATA_SOURCE_TARGET_TYPES.has(target.type)) {
return false;
}
if (disableCodeBlock && target.type === DepTargetType.CODE_BLOCK) {
return false;
}
return true;
});
}
private collectTargetForTargets(
node: TargetNode,
config: Record<string | number, any>,
prop: string,
targets: Target[],
depExtendedData: DepExtendedData,
deep: boolean,
) {
const id = node[this.idProp];
const name = `${node[this.nameProp] || node[this.idProp]}`;
for (const [key, value] of Object.entries(config)) {
if (typeof value === 'undefined' || value === '') continue;
const keyIsItems = key === this.childrenProp;
const fullKey = prop ? `${prop}.${key}` : key;
// 在该属性上检查所有 target命中的更新依赖未命中的留待递归到更深层
let notMatched: Target[] | null = null;
for (let i = 0, l = targets.length; i < l; i++) {
const target = targets[i];
if (target.isTarget(fullKey, value, config)) {
target.updateDep({
id,
name,
data: depExtendedData,
key: fullKey,
});
} else {
(notMatched || (notMatched = [])).push(target);
}
}
// 对应原 doCollect 的 else-if 分支:仅未命中的 target 才继续往 value 内部递归
if (notMatched) {
if (!keyIsItems && Array.isArray(value)) {
for (let i = 0, l = value.length; i < l; i++) {
const item = value[i];
if (isObject(item)) {
this.collectTargetForTargets(node, item, `${fullKey}[${i}]`, notMatched, depExtendedData, deep);
}
}
} else if (isObject(value)) {
this.collectTargetForTargets(node, value, fullKey, notMatched, depExtendedData, deep);
}
}
// 对应原 doCollect 末尾的无条件子节点递归
if (keyIsItems && deep && Array.isArray(value)) {
for (const child of value) {
this.collectItems(child, targets, depExtendedData, deep);
}
}
}
}
} }

View File

@ -15,7 +15,7 @@ export enum DepTargetType {
DATA_SOURCE_COND = 'data-source-cond', DATA_SOURCE_COND = 'data-source-cond',
} }
export type IsTarget = (key: string | number, value: any) => boolean; export type IsTarget = (key: string | number, value: any, data?: Record<string, any>) => boolean;
export interface TargetOptions { export interface TargetOptions {
isTarget: IsTarget; isTarget: IsTarget;

View File

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

View File

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

View File

@ -3,6 +3,7 @@
:disabled-page-fragment="disabledPageFragment" :disabled-page-fragment="disabledPageFragment"
:page-bar-sort-options="pageBarSortOptions" :page-bar-sort-options="pageBarSortOptions"
:page-filter-function="pageFilterFunction" :page-filter-function="pageFilterFunction"
:hide-sidebar="hideSidebar"
> >
<template #header> <template #header>
<slot name="header"></slot> <slot name="header"></slot>
@ -220,6 +221,7 @@ const stageOptions: StageOptions = {
guidesOptions: props.guidesOptions, guidesOptions: props.guidesOptions,
disabledMultiSelect: props.disabledMultiSelect, disabledMultiSelect: props.disabledMultiSelect,
alwaysMultiSelect: props.alwaysMultiSelect, alwaysMultiSelect: props.alwaysMultiSelect,
disabledFlashTip: props.disabledFlashTip,
beforeDblclick: props.beforeDblclick, beforeDblclick: props.beforeDblclick,
}; };
@ -229,6 +231,19 @@ provide('services', services);
provide('codeOptions', props.codeOptions); provide('codeOptions', props.codeOptions);
provide('stageOptions', stageOptions); provide('stageOptions', stageOptions);
/**
* 把顶层 `extendFormState` 提供给非 PropsPanel 链路上的组件使用例如历史差异对话框 HistoryDiffDialog
* 内部的 CompareForm这样所有依赖业务上下文的表单 filterFunction 都能拿到一致的扩展状态
* PropsPanel 通过 `:extend-state` 显式传入的方式保持等价
*/
provide('extendFormState', props.extendFormState);
/**
* 把历史记录面板的自定义扩展 tab 提供给深层的 HistoryListPanel它挂在 NavMenu
* markRaw component 形式渲染无法直接通过 props 透传业务方可借此在历史记录
* 面板内追加自定义模块的历史 tab
*/
provide('historyListExtraTabs', props.historyListExtraTabs);
provide<EventBus>('eventBus', new EventEmitter()); provide<EventBus>('eventBus', new EventEmitter());

View File

@ -34,7 +34,7 @@
<Teleport to="body"> <Teleport to="body">
<TMagicDialog title="查看修改" v-model="difVisible" fullscreen destroy-on-close> <TMagicDialog title="查看修改" v-model="difVisible" fullscreen destroy-on-close>
<div style="display: flex; margin-bottom: 10px"> <div style="display: flex; margin-bottom: 10px">
<div style="flex: 1"><TMagicTag size="small" type="info">修改前</TMagicTag></div> <div style="flex: 1"><TMagicTag size="small" type="danger">修改前</TMagicTag></div>
<div style="flex: 1"><TMagicTag size="small" type="success">修改后</TMagicTag></div> <div style="flex: 1"><TMagicTag size="small" type="success">修改后</TMagicTag></div>
</div> </div>
@ -63,14 +63,7 @@ import { computed, inject, nextTick, Ref, ref, useTemplateRef, watch } from 'vue
import type { CodeBlockContent } from '@tmagic/core'; import type { CodeBlockContent } from '@tmagic/core';
import { TMagicButton, TMagicDialog, tMagicMessage, tMagicMessageBox, TMagicTag } from '@tmagic/design'; import { TMagicButton, TMagicDialog, tMagicMessage, tMagicMessageBox, TMagicTag } from '@tmagic/design';
import { import { type ContainerChangeEventData, type FormConfig, MFormBox } from '@tmagic/form';
type ContainerChangeEventData,
defineFormConfig,
defineFormItem,
type FormConfig,
MFormBox,
type TableColumnConfig,
} from '@tmagic/form';
import FloatingBox from '@editor/components/FloatingBox.vue'; import FloatingBox from '@editor/components/FloatingBox.vue';
import { useEditorContentHeight } from '@editor/hooks/use-editor-content-height'; import { useEditorContentHeight } from '@editor/hooks/use-editor-content-height';
@ -78,6 +71,7 @@ import { useNextFloatBoxPosition } from '@editor/hooks/use-next-float-box-positi
import { useServices } from '@editor/hooks/use-services'; import { useServices } from '@editor/hooks/use-services';
import { useWindowRect } from '@editor/hooks/use-window-rect'; import { useWindowRect } from '@editor/hooks/use-window-rect';
import CodeEditor from '@editor/layouts/CodeEditor.vue'; import CodeEditor from '@editor/layouts/CodeEditor.vue';
import { getCodeBlockFormConfig } from '@editor/utils/code-block';
import { getEditorConfig } from '@editor/utils/config'; import { getEditorConfig } from '@editor/utils/config';
defineOptions({ defineOptions({
@ -119,106 +113,23 @@ const diffChange = () => {
difVisible.value = false; difVisible.value = false;
}; };
const defaultParamColConfig = defineFormItem<TableColumnConfig>({ const codeOptions = inject<Record<string, any>>('codeOptions', {});
type: 'row',
label: '参数类型',
items: [
{
text: '参数类型',
labelWidth: '70px',
type: 'select',
name: 'type',
options: [
{
text: '数字',
label: '数字',
value: 'number',
},
{
text: '字符串',
label: '字符串',
value: 'text',
},
{
text: '组件',
label: '组件',
value: 'ui-select',
},
],
},
],
});
const functionConfig = computed( /**
() => * 代码块编辑表单配置统一委托到 utils/code-block `getCodeBlockFormConfig`
defineFormConfig([ * CompareForm 等其它使用方共享同一份 schema避免双份维护
{ *
text: '名称', * 这里以 computed 包裹是为了让 `props.isDataSource` / `props.dataSourceType` 变化时
name: 'name', * "执行时机"字段的可见性与可选项实时刷新
rules: [{ required: true, message: '请输入名称', trigger: 'blur' }], */
}, const functionConfig = computed<FormConfig>(() =>
{ getCodeBlockFormConfig({
text: '描述', paramColConfig: codeBlockService.getParamsColConfig(),
name: 'desc', isDataSource: () => Boolean(props.isDataSource),
}, dataSourceType: () => props.dataSourceType,
{ codeOptions,
text: '执行时机', editable: true,
name: 'timing', }),
type: 'select',
options: () => {
const options = [
{ text: '初始化前', value: 'beforeInit' },
{ text: '初始化后', value: 'afterInit' },
];
if (props.dataSourceType !== 'base') {
options.push({ text: '请求前', value: 'beforeRequest' });
options.push({ text: '请求后', value: 'afterRequest' });
}
return options;
},
display: () => props.isDataSource,
},
{
type: 'table',
border: true,
text: '参数',
enableFullscreen: false,
enableToggleMode: false,
name: 'params',
dropSort: false,
items: [
{
type: 'text',
label: '参数名',
name: 'name',
},
{
type: 'text',
label: '描述',
name: 'extra',
},
codeBlockService.getParamsColConfig() || defaultParamColConfig,
],
},
{
name: 'content',
type: 'vs-code',
options: inject('codeOptions', {}),
autosize: { minRows: 10, maxRows: 30 },
onChange: (_formState, code: string) => {
try {
// js
getEditorConfig('parseDSL')(code);
return code;
} catch (error: any) {
tMagicMessage.error(error.message);
throw error;
}
},
},
]) as FormConfig,
); );
const parseContent = (content: any) => { const parseContent = (content: any) => {

View File

@ -3,6 +3,8 @@
ref="form" ref="form"
:config="codeParamsConfig" :config="codeParamsConfig"
:init-values="model" :init-values="model"
:last-values="lastValues"
:is-compare="isCompare"
:disabled="disabled" :disabled="disabled"
:size="size" :size="size"
:watch-props="false" :watch-props="false"
@ -24,6 +26,10 @@ defineOptions({
const props = defineProps<{ const props = defineProps<{
model: any; model: any;
/** 对比模式下的历史值,透传给内部 MForm 用于逐项展示参数差异 */
lastValues?: any;
/** 是否开启对比模式 */
isCompare?: boolean;
size?: 'small' | 'default' | 'large'; size?: 'small' | 'default' | 'large';
disabled?: boolean; disabled?: boolean;
name: string; name: string;

View File

@ -0,0 +1,258 @@
<template>
<div class="m-editor-compare-form-wrapper" :style="wrapperStyle">
<MForm
v-if="config.length"
ref="form"
class="m-editor-compare-form"
:config="config"
:init-values="currentValues"
:last-values="lastValuesProcessed"
:is-compare="true"
:disabled="true"
:label-width="labelWidth"
:extend-state="extendState"
:show-diff="showDiff"
></MForm>
</div>
</template>
<script lang="ts" setup>
import { computed, inject, type Ref, ref, type ShallowRef, useTemplateRef, watch, watchEffect } from 'vue';
import { isEqual } from 'lodash-es';
import { type CodeBlockContent, type DataSourceSchema, HookType, type MNode } from '@tmagic/core';
import { type FormConfig, type FormState, type FormValue, MForm } from '@tmagic/form';
import { useServices } from '@editor/hooks/use-services';
import type { CompareCategory, CompareFormLoadConfig } from '@editor/type';
import { getCodeBlockFormConfig } from '@editor/utils/code-block';
defineOptions({
name: 'MEditorCompareForm',
});
const props = withDefaults(
defineProps<{
/** 当前值(修改后的值) */
value: Partial<MNode> | Partial<DataSourceSchema> | Partial<CodeBlockContent> | Record<string, any>;
/** 用于对比的旧值(修改前的值) */
lastValue?: Partial<MNode> | Partial<DataSourceSchema> | Partial<CodeBlockContent> | Record<string, any>;
/**
* 类型说明
* - `category` `node` `type` 为节点组件的类型例如 'text''button''page''container'
* - `category` `data-source` `type` 为数据源类型例如 'base''http'
* - `category` `code-block` `type` 可不传
*/
type?: string;
/** 表单配置类别,决定从哪里取 FormConfig */
category?: CompareCategory;
/** 数据源代码块场景下的数据源类型base/http用于代码块表单中"执行时机"展示 */
dataSourceType?: string;
labelWidth?: string;
/**
* 外层容器高度设置后表单内容超出时会在 CompareForm 内部出现滚动条
* 避免 dialog / 面板使用方需要自行处理滚动可传任意 CSS 长度例如 `60vh` / `400px` / `100%`
*/
height?: string;
/**
* 用户自定义注入到 MForm.formState 的扩展字段 Editor 顶层的 `extendFormState`
* PropsPanel `extend-state` 语义一致表单 item `display` / `disabled`
* filterFunction 经常依赖这里注入的字段 stage自定义业务上下文等
* 因此在差异对比场景下也需要透传避免出现 `formState.xxx is undefined` 的运行时错误
*/
extendState?: (_state: FormState) => Record<string, any> | Promise<Record<string, any>>;
/**
* 自定义 FormConfig 加载逻辑传入后将接管内置的按 `category`(node/data-source/code-block)
* 取配置逻辑调用方可根据业务自行返回或异步返回表单配置可通过
* `ctx.defaultLoadConfig()` 复用默认结果再做二次加工返回的 config 直接用于对比展示
*/
loadConfig?: CompareFormLoadConfig;
}>(),
{
category: 'node',
labelWidth: '120px',
},
);
const { propsService, dataSourceService, codeBlockService, editorService } = useServices();
const services = useServices();
const config = ref<FormConfig>([]);
/** vs-code 编辑器的 monaco 配置项,沿用 Editor 顶层 provide('codeOptions', ...) 的注入。 */
const codeOptions = inject<Record<string, any>>('codeOptions', {});
/** 将代码块的 content 字段统一成字符串,便于在表单/对比中展示 */
const normalizeCodeBlockValue = (
v: Partial<CodeBlockContent> | Record<string, any> | undefined,
): Record<string, any> => {
if (!v) return {};
const next: Record<string, any> = { ...v };
if (next.content && typeof next.content !== 'string') {
try {
next.content = next.content.toString();
} catch {
next.content = '';
}
}
return next;
};
const currentValues = computed<FormValue>(() => {
if (props.category === 'code-block') {
return normalizeCodeBlockValue(props.value as Partial<CodeBlockContent>);
}
return (props.value || {}) as FormValue;
});
const lastValuesProcessed = computed<FormValue>(() => {
if (props.category === 'code-block') {
return normalizeCodeBlockValue(props.lastValue as Partial<CodeBlockContent>);
}
return (props.lastValue || {}) as FormValue;
});
/**
* 外层包裹层的样式当传入 `height` 时启用固定高度 + 内部滚动
* 这样滚动条会出现在 CompareForm 内部避免父容器 Dialog自身也产生滚动
*/
const wrapperStyle = computed(() => {
if (!props.height) return undefined;
return {
height: props.height,
overflow: 'auto',
} as Record<string, string>;
});
/**
* `code-select` 字段在历史数据中存在两种"语义为空"的形态
* - 字符串 `''`旧数据 / 用户从未配置过钩子
* - `{ hookType: HookType.CODE, hookData: [] }`CodeSelect.vue 在挂载时
* 写入的默认结构参见 packages/editor/src/fields/CodeSelect.vue
* `props.model[props.name] = { hookType: HookType.CODE, hookData: [] }`
*
* 直接 `isEqual` 会把两者判为不等从而在历史对比里对每个未配置过钩子的组件
* 都展示一份"差异"体验很糟糕这里把它们视为相等跳过对比
*
* 其它类型字段沿用 MForm/Container 的默认 `!isEqual` 判断逻辑
*/
const isEmptyCodeSelectValue = (v: any): boolean => {
if (v === '' || v === undefined || v === null) return true;
if (Array.isArray(v) && v.length === 0) return true;
return typeof v === 'object' && v.hookType === HookType.CODE && Array.isArray(v.hookData) && v.hookData.length === 0;
};
const showDiff = ({ curValue, lastValue, config }: { curValue: any; lastValue: any; config: any }) => {
if (config?.type === 'code-select') {
// ""
if (isEmptyCodeSelectValue(curValue) && isEmptyCodeSelectValue(lastValue)) {
return false;
}
}
return !isEqual(curValue, lastValue);
};
const removeStyleDisplayConfig = (formConfig: FormConfig): FormConfig =>
formConfig.map((item) => {
if (!('type' in item)) return item;
if (item?.type !== 'tab' || !Array.isArray(item.items)) return item;
return {
...item,
items: item.items.map((tabPane) => {
if (tabPane?.title !== '样式' || !Array.isArray(tabPane.items)) return tabPane;
return {
...tabPane,
display: true,
};
}),
};
});
/**
* 内置的默认 FormConfig 加载逻辑 `category` 从对应 service / 工具取配置
* 作为 ctx.defaultLoadConfig 透传给自定义 `loadConfig`方便复用与二次加工
*/
const defaultLoadConfig = async (): Promise<FormConfig> => {
switch (props.category) {
case 'node': {
if (!props.type) {
return [];
}
return removeStyleDisplayConfig(
await propsService.getPropsConfig(props.type, { node: props.value as unknown as MNode }),
);
}
case 'data-source': {
return dataSourceService.getFormConfig(props.type || 'base');
}
case 'code-block': {
return getCodeBlockFormConfig({
paramColConfig: codeBlockService.getParamsColConfig(),
// dataSourceType "" props.dataSourceType
// step
isDataSource: () => Boolean(props.dataSourceType),
dataSourceType: () => props.dataSourceType,
codeOptions,
// /
editable: false,
});
}
default:
return [];
}
};
const loadConfig = async () => {
if (props.loadConfig) {
config.value = await props.loadConfig({
category: props.category,
type: props.type,
dataSourceType: props.dataSourceType,
defaultLoadConfig,
});
return;
}
config.value = await defaultLoadConfig();
};
watch(
[() => props.category, () => props.type, () => props.dataSourceType, () => props.loadConfig],
() => {
loadConfig();
},
{ immediate: true },
);
const formRef = useTemplateRef<InstanceType<typeof MForm>>('form');
/**
* services / stage 注入 MForm formState避免 propsService 注入的表单配置中
* 形如 `display: ({ services }) => services.uiService.get(...)` filterFunction
* 在执行时拿不到 `formState.services` 而报错
*
* props-panel/FormPanel.vue 中的注入方式保持一致
* - services整个 useServices() 返回的服务集合
* - stage当前 editorService.get('stage') 的最新值
*/
const stage = computed(() => editorService.get('stage'));
watchEffect(() => {
if (formRef.value) {
formRef.value.formState.stage = stage.value;
formRef.value.formState.services = services;
}
});
defineExpose<{
form: ShallowRef<InstanceType<typeof MForm> | null>;
config: Ref<FormConfig>;
reload: () => Promise<void>;
}>({
form: formRef,
config,
reload: loadConfig,
});
</script>

View File

@ -15,6 +15,7 @@ import type {
ComponentGroup, ComponentGroup,
CustomContentMenuFunction, CustomContentMenuFunction,
DatasourceTypeOption, DatasourceTypeOption,
HistoryListExtraTab,
IsExpandableFunction, IsExpandableFunction,
MenuBarData, MenuBarData,
MenuButton, MenuButton,
@ -34,6 +35,8 @@ export interface EditorProps {
datasourceList?: DatasourceTypeOption[]; datasourceList?: DatasourceTypeOption[];
/** 左侧面板配置 */ /** 左侧面板配置 */
sidebar?: SideBarData; sidebar?: SideBarData;
/** 是否隐藏左侧面板 */
hideSidebar?: boolean;
/** 顶部工具栏配置 */ /** 顶部工具栏配置 */
menu?: MenuBarData; menu?: MenuBarData;
/** 组件树右键菜单 */ /** 组件树右键菜单 */
@ -84,6 +87,8 @@ export interface EditorProps {
alwaysMultiSelect?: boolean; alwaysMultiSelect?: boolean;
/** 禁用页面片 */ /** 禁用页面片 */
disabledPageFragment?: boolean; disabledPageFragment?: boolean;
/** 禁用「非点击画布选中组件时(如从图层树、面包屑等外部选中),对选中区域做高亮闪烁提示」,默认 false即默认开启闪烁 */
disabledFlashTip?: boolean;
/** 禁用双击在浮层中单独编辑选中组件 */ /** 禁用双击在浮层中单独编辑选中组件 */
disabledStageOverlay?: boolean; disabledStageOverlay?: boolean;
/** 禁用属性配置面板右下角显示源码的按钮 */ /** 禁用属性配置面板右下角显示源码的按钮 */
@ -123,6 +128,8 @@ export interface EditorProps {
/** 组件树节点双击前的钩子函数,返回 false 则阻止默认的双击行为 */ /** 组件树节点双击前的钩子函数,返回 false 则阻止默认的双击行为 */
beforeLayerNodeDblclick?: (event: MouseEvent, data: TreeNodeData) => Promise<boolean | void> | boolean | void; beforeLayerNodeDblclick?: (event: MouseEvent, data: TreeNodeData) => Promise<boolean | void> | boolean | void;
extendFormState?: (state: FormState) => Record<string, any> | Promise<Record<string, any>>; extendFormState?: (state: FormState) => Record<string, any> | Promise<Record<string, any>>;
/** 历史记录面板的自定义扩展 tab追加在内置的页面/数据源/代码块 tab 之后 */
historyListExtraTabs?: HistoryListExtraTab[];
/** 页面顺序拖拽配置参数 */ /** 页面顺序拖拽配置参数 */
pageBarSortOptions?: PageBarSortOptions; pageBarSortOptions?: PageBarSortOptions;
/** 页面搜索函数 */ /** 页面搜索函数 */
@ -134,6 +141,7 @@ export const defaultEditorProps = {
disabledMultiSelect: false, disabledMultiSelect: false,
alwaysMultiSelect: false, alwaysMultiSelect: false,
disabledPageFragment: false, disabledPageFragment: false,
disabledFlashTip: false,
disabledStageOverlay: false, disabledStageOverlay: false,
containerHighlightClassName: CONTAINER_HIGHLIGHT_CLASS_NAME, containerHighlightClassName: CONTAINER_HIGHLIGHT_CLASS_NAME,
containerHighlightDuration: 800, containerHighlightDuration: 800,
@ -143,6 +151,7 @@ export const defaultEditorProps = {
disabledCodeBlock: false, disabledCodeBlock: false,
componentGroupList: () => [], componentGroupList: () => [],
datasourceList: () => [], datasourceList: () => [],
historyListExtraTabs: () => [],
menu: () => ({ left: [], right: [] }), menu: () => ({ left: [], right: [] }),
layerContentMenu: () => [], layerContentMenu: () => [],
stageContentMenu: () => [], stageContentMenu: () => [],

View File

@ -1,11 +1,13 @@
<template> <template>
<MagicCodeEditor <MagicCodeEditor
:height="config.height" :height="config.height"
:init-values="model[name]" :type="diffMode ? 'diff' : undefined"
:init-values="diffMode ? (lastValues || {})[name] : model[name]"
:modified-values="diffMode ? model[name] : undefined"
:language="config.language" :language="config.language"
:options="{ :options="{
...config.options, ...config.options,
readOnly: disabled, readOnly: diffMode ? true : disabled,
}" }"
:autosize="config.autosize" :autosize="config.autosize"
:parse="config.parse" :parse="config.parse"
@ -15,6 +17,8 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from 'vue';
import type { CodeConfig, FieldProps } from '@tmagic/form'; import type { CodeConfig, FieldProps } from '@tmagic/form';
import MagicCodeEditor from '@editor/layouts/CodeEditor.vue'; import MagicCodeEditor from '@editor/layouts/CodeEditor.vue';
@ -27,10 +31,22 @@ const emit = defineEmits<{
change: [value: string | any]; change: [value: string | any];
}>(); }>();
withDefaults(defineProps<FieldProps<CodeConfig>>(), { const props = withDefaults(defineProps<FieldProps<CodeConfig>>(), {
disabled: false, disabled: false,
}); });
/**
* 对比模式判定
*
* - `isCompare === true` 由父级 `MFormContainer` 统一渲染一次本字段不再渲染前后两份独立组件
* 并把 `model`当前值 `lastValues`历史值一并传入
* - 此时本字段切换到 monaco 自带的 diff 编辑器左侧旧右侧新相比"两个独立 monaco 实例"更直观
* 也避免了同一表单内重复实例化重型编辑器带来的开销
*
* 仅当存在历史值lastValues且开启了对比模式时启用 diff避免在 lastValues 缺失时退化为空对比
*/
const diffMode = computed(() => Boolean(props.isCompare && props.lastValues));
const save = (v: string | any) => { const save = (v: string | any) => {
emit('change', v); emit('change', v);
}; };

View File

@ -4,11 +4,11 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, reactive, watch } from 'vue'; import { computed, reactive, watch } from 'vue';
import serialize from 'serialize-javascript';
import type { CodeLinkConfig, FieldProps, MLink } from '@tmagic/form'; import type { CodeLinkConfig, FieldProps, MLink } from '@tmagic/form';
import { getEditorConfig } from '@editor/utils/config'; import { getEditorConfig } from '@editor/utils/config';
import { serializeConfig } from '@editor/utils/editor';
defineOptions({ defineOptions({
name: 'MFieldsCodeLink', name: 'MFieldsCodeLink',
@ -47,10 +47,7 @@ watch(
() => props.model[props.name], () => props.model[props.name],
(value) => { (value) => {
modelValue.form = { modelValue.form = {
[props.name]: serialize(value, { [props.name]: serializeConfig(value),
space: 2,
unsafe: true,
}).replace(/"(\w+)":\s/g, '$1: '),
}; };
}, },
{ {

View File

@ -6,7 +6,8 @@
:size="size" :size="size"
:prop="prop" :prop="prop"
:disabled="disabled" :disabled="disabled"
:lastValues="lastValues" :is-compare="isCompareMode"
:last-values="lastValues?.[name]"
:model="model[name]" :model="model[name]"
@change="changeHandler" @change="changeHandler"
> >
@ -38,6 +39,21 @@ const { dataSourceService, codeBlockService } = useServices();
const props = withDefaults(defineProps<FieldProps<CodeSelectConfig>>(), {}); const props = withDefaults(defineProps<FieldProps<CodeSelectConfig>>(), {});
/**
* 对比模式判定
*
* code-select 仅是对内部钩子列表group-list 的包裹本身不渲染叶子字段父级 `MFormContainer`
* 已将其归入自接管对比字段 Container.vue `SELF_DIFF_FIELD_TYPES`即对比时只渲染一次
* 本组件并把当前值 `model` 与历史值 `lastValues` 一并传入由本组件把 `is-compare`/`lastValues`
* 透传给内部 MContainer再由 group-list / code-select-col 等子级逐项展示前后差异
*
* 注意`model` 传入的是 `model[name]`钩子值本身因此 `lastValues` 也必须同层取 `lastValues[name]`
* 否则前后值的嵌套层级不一致会导致对比错位
*
* 仅当存在历史值时才启用对比避免 lastValues 缺失时退化为全部新增的空对比
*/
const isCompareMode = computed(() => Boolean(props.isCompare && props.lastValues));
const codeConfig = computed<GroupListConfig>(() => ({ const codeConfig = computed<GroupListConfig>(() => ({
type: 'group-list', type: 'group-list',
name: 'hookData', name: 'hookData',

View File

@ -2,7 +2,20 @@
<div class="m-fields-code-select-col"> <div class="m-fields-code-select-col">
<div class="code-select-container"> <div class="code-select-container">
<!-- 代码块下拉框 --> <!-- 代码块下拉框 -->
<!-- 对比模式下交由 MFormContainer 展示下拉框的前后差异codeId 变化时高亮新旧代码块名
普通模式仍直接渲染 MSelect 以保留选择 / 写值逻辑 -->
<MFormContainer
v-if="isCompareMode"
class="select"
:config="selectConfig"
:model="model"
:last-values="lastValues"
:is-compare="true"
:size="size"
:prop="prop"
></MFormContainer>
<MSelect <MSelect
v-else
class="select" class="select"
:config="selectConfig" :config="selectConfig"
:name="name" :name="name"
@ -12,9 +25,9 @@
@change="onCodeIdChangeHandler" @change="onCodeIdChangeHandler"
></MSelect> ></MSelect>
<!-- 查看/编辑按钮 --> <!-- 查看/编辑按钮对比模式为只读不展示 -->
<TMagicButton <TMagicButton
v-if="model[name] && hasCodeBlockSidePanel" v-if="!isCompareMode && model[name] && hasCodeBlockSidePanel"
class="m-fields-select-action-button" class="m-fields-select-action-button"
:size="size" :size="size"
@click="editCode(model[name])" @click="editCode(model[name])"
@ -29,6 +42,8 @@
name="params" name="params"
:key="model[name]" :key="model[name]"
:model="model" :model="model"
:last-values="lastValues"
:is-compare="isCompareMode"
:size="size" :size="size"
:disabled="disabled" :disabled="disabled"
:params-config="paramsConfig" :params-config="paramsConfig"
@ -52,6 +67,7 @@ import {
filterFunction, filterFunction,
type FormItemConfig, type FormItemConfig,
type FormState, type FormState,
MContainer as MFormContainer,
MSelect, MSelect,
type SelectConfig, type SelectConfig,
} from '@tmagic/form'; } from '@tmagic/form';
@ -77,6 +93,18 @@ const props = withDefaults(defineProps<FieldProps<CodeSelectColConfig>>(), {
disabled: false, disabled: false,
}); });
/**
* 对比模式判定
*
* code-select-col 代码块下拉框 + 参数子表单组成属于复合字段父级 `MFormContainer` 已将其
* 归入自接管对比字段 Container.vue `SELF_DIFF_FIELD_TYPES`即对比时只渲染一次本组件
* 并把当前值 `model` 与历史值 `lastValues` 一并传入由本组件把 `is-compare`/`lastValues` 透传给
* 内部的下拉框MFormContainer与参数表单CodeParams逐项展示前后差异
*
* 仅当存在历史值时才启用对比避免 lastValues 缺失时退化为全部新增的空对比
*/
const isCompareMode = computed(() => Boolean(props.isCompare && props.lastValues));
const notEditable = computed(() => filterFunction(mForm, props.config.notEditable, props)); const notEditable = computed(() => filterFunction(mForm, props.config.notEditable, props));
const hasCodeBlockSidePanel = computed(() => const hasCodeBlockSidePanel = computed(() =>

View File

@ -72,7 +72,10 @@
@change="onChangeHandler" @change="onChangeHandler"
></TMagicCascader> ></TMagicCascader>
<TMagicTooltip v-if="selectDataSourceId && hasDataSourceSidePanel" :content="notEditable ? '查看' : '编辑'"> <TMagicTooltip
v-if="selectDataSourceId && hasDataSourceSidePanel && !isCompare"
:content="notEditable ? '查看' : '编辑'"
>
<TMagicButton class="m-fields-select-action-button" :size="size" @click="editHandler(selectDataSourceId)" <TMagicButton class="m-fields-select-action-button" :size="size" @click="editHandler(selectDataSourceId)"
><MIcon :icon="!notEditable ? Edit : View"></MIcon ><MIcon :icon="!notEditable ? Edit : View"></MIcon
></TMagicButton> ></TMagicButton>
@ -133,6 +136,9 @@ const dataSources = computed(() => {
const valueIsKey = computed(() => props.value === 'key'); const valueIsKey = computed(() => props.value === 'key');
const notEditable = computed(() => filterFunction(mForm, props.notEditable, props)); const notEditable = computed(() => filterFunction(mForm, props.notEditable, props));
/** 对比模式下隐藏查看/编辑操作按钮,仅保留只读展示。 */
const isCompare = computed(() => Boolean(mForm?.isCompare));
const dataSourcesOptions = computed<SelectOption[]>(() => const dataSourcesOptions = computed<SelectOption[]>(() =>
dataSources.value.map((ds) => ({ dataSources.value.map((ds) => ({
text: ds.title || ds.id, text: ds.title || ds.id,

View File

@ -28,14 +28,15 @@
></component> ></component>
<TMagicTooltip <TMagicTooltip
v-if="config.fieldConfig && !disabledDataSource" v-if="config.fieldConfig && !disabledDataSource && !mForm?.isCompare"
:disabled="showDataSourceFieldSelect" :disabled="showDataSourceFieldSelect"
content="选择数据源" content="选择数据源"
> >
<TMagicButton <TMagicButton
:type="showDataSourceFieldSelect ? 'primary' : 'default'" :type="showDataSourceFieldSelect ? 'primary' : 'default'"
:size="size" :size="size"
@click="showDataSourceFieldSelect = !showDataSourceFieldSelect" :disabled="disabled"
@click="onToggleDataSourceFieldSelectHandler"
><MIcon :icon="Coin"></MIcon ><MIcon :icon="Coin"></MIcon
></TMagicButton> ></TMagicButton>
</TMagicTooltip> </TMagicTooltip>
@ -185,4 +186,10 @@ const onChangeHandler = (value: string[], eventData?: ContainerChangeEventData)
emit('change', [dsId], eventData); emit('change', [dsId], eventData);
} }
}; };
const onToggleDataSourceFieldSelectHandler = () => {
//
if (props.disabled || mForm?.isCompare) return;
showDataSourceFieldSelect.value = !showDataSourceFieldSelect.value;
};
</script> </script>

View File

@ -1,8 +1,8 @@
<template> <template>
<div class="m-editor-data-source-fields"> <div class="m-editor-data-source-fields">
<MagicTable :data="model[name]" :columns="fieldColumns" :border="true"></MagicTable> <MagicTable :data="model[name]" :columns="displayColumns" :border="true"></MagicTable>
<div class="m-editor-data-source-fields-footer"> <div v-if="!isCompare" class="m-editor-data-source-fields-footer">
<TMagicButton size="small" :disabled="disabled" plain @click="newFromJsonHandler()">快速添加</TMagicButton> <TMagicButton size="small" :disabled="disabled" plain @click="newFromJsonHandler()">快速添加</TMagicButton>
<TMagicButton size="small" type="primary" :disabled="disabled" plain @click="newHandler()">添加</TMagicButton> <TMagicButton size="small" type="primary" :disabled="disabled" plain @click="newHandler()">添加</TMagicButton>
</div> </div>
@ -47,7 +47,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { inject, Ref, ref } from 'vue'; import { computed, inject, Ref, ref } from 'vue';
import type { DataSchema } from '@tmagic/core'; import type { DataSchema } from '@tmagic/core';
import { TMagicButton, tMagicMessage, tMagicMessageBox } from '@tmagic/design'; import { TMagicButton, tMagicMessage, tMagicMessageBox } from '@tmagic/design';
@ -84,6 +84,10 @@ const emit = defineEmits<{
}>(); }>();
const { uiService } = useServices(); const { uiService } = useServices();
const mForm = inject<FormState | undefined>('mForm');
/** 对比模式下隐藏新增/编辑/删除等操作按钮,仅保留只读展示。 */
const isCompare = computed(() => Boolean(mForm?.isCompare));
const fieldValues = ref<Record<string, any>>({}); const fieldValues = ref<Record<string, any>>({});
const fieldTitle = ref(''); const fieldTitle = ref('');
@ -176,6 +180,11 @@ const fieldColumns: ColumnConfig[] = [
}, },
]; ];
/** 对比模式下移除「操作」列(编辑/删除按钮),仅保留只读列。 */
const displayColumns = computed<ColumnConfig[]>(() =>
isCompare.value ? fieldColumns.filter((col) => !col.actions) : fieldColumns,
);
const dataSourceFieldsConfig: FormConfig = [ const dataSourceFieldsConfig: FormConfig = [
{ name: 'index', type: 'hidden', filter: 'number', defaultValue: -1 }, { name: 'index', type: 'hidden', filter: 'number', defaultValue: -1 },
{ {

View File

@ -13,7 +13,7 @@
></MCascader> ></MCascader>
<TMagicTooltip <TMagicTooltip
v-if="model[name] && isCustomMethod && hasDataSourceSidePanel" v-if="model[name] && isCustomMethod && hasDataSourceSidePanel && !isCompare"
:content="notEditable ? '查看' : '编辑'" :content="notEditable ? '查看' : '编辑'"
> >
<TMagicButton class="m-fields-select-action-button" :size="size" @click="editCodeHandler"> <TMagicButton class="m-fields-select-action-button" :size="size" @click="editCodeHandler">
@ -81,6 +81,9 @@ const hasDataSourceSidePanel = computed(() =>
const notEditable = computed(() => filterFunction(mForm, props.config.notEditable, props)); const notEditable = computed(() => filterFunction(mForm, props.config.notEditable, props));
/** 对比模式下隐藏查看/编辑操作按钮,仅保留只读展示。 */
const isCompare = computed(() => Boolean(mForm?.isCompare));
const dataSources = computed(() => dataSourceService.get('dataSources')); const dataSources = computed(() => dataSourceService.get('dataSources'));
const isCustomMethod = computed(() => { const isCustomMethod = computed(() => {

View File

@ -1,8 +1,8 @@
<template> <template>
<div class="m-editor-data-source-methods"> <div class="m-editor-data-source-methods">
<MagicTable :data="model[name]" :columns="methodColumns" :border="true"></MagicTable> <MagicTable :data="model[name]" :columns="displayColumns" :border="true"></MagicTable>
<div class="m-editor-data-source-methods-footer"> <div v-if="!isCompare" class="m-editor-data-source-methods-footer">
<TMagicButton size="small" type="primary" :disabled="disabled" plain @click="createCodeHandler" <TMagicButton size="small" type="primary" :disabled="disabled" plain @click="createCodeHandler"
>添加</TMagicButton >添加</TMagicButton
> >
@ -21,12 +21,12 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { nextTick, ref, useTemplateRef } from 'vue'; import { computed, inject, nextTick, ref, useTemplateRef } from 'vue';
import { cloneDeep } from 'lodash-es'; import { cloneDeep } from 'lodash-es';
import type { CodeBlockContent } from '@tmagic/core'; import type { CodeBlockContent } from '@tmagic/core';
import { TMagicButton, tMagicMessageBox } from '@tmagic/design'; import { TMagicButton, tMagicMessageBox } from '@tmagic/design';
import type { ContainerChangeEventData, DataSourceMethodsConfig, FieldProps } from '@tmagic/form'; import type { ContainerChangeEventData, DataSourceMethodsConfig, FieldProps, FormState } from '@tmagic/form';
import { type ColumnConfig, MagicTable } from '@tmagic/table'; import { type ColumnConfig, MagicTable } from '@tmagic/table';
import CodeBlockEditor from '@editor/components/CodeBlockEditor.vue'; import CodeBlockEditor from '@editor/components/CodeBlockEditor.vue';
@ -42,6 +42,11 @@ const props = withDefaults(defineProps<FieldProps<DataSourceMethodsConfig>>(), {
const emit = defineEmits(['change']); const emit = defineEmits(['change']);
const mForm = inject<FormState | undefined>('mForm');
/** 对比模式下隐藏新增/编辑/删除等操作按钮,仅保留只读展示。 */
const isCompare = computed(() => Boolean(mForm?.isCompare));
const codeConfig = ref<Omit<CodeBlockContent, 'content'> & { content: string }>(); const codeConfig = ref<Omit<CodeBlockContent, 'content'> & { content: string }>();
const codeBlockEditorRef = useTemplateRef<InstanceType<typeof CodeBlockEditor>>('codeBlockEditor'); const codeBlockEditorRef = useTemplateRef<InstanceType<typeof CodeBlockEditor>>('codeBlockEditor');
@ -107,6 +112,11 @@ const methodColumns: ColumnConfig[] = [
}, },
]; ];
/** 对比模式下移除「操作」列(编辑/删除按钮),仅保留只读列。 */
const displayColumns = computed<ColumnConfig[]>(() =>
isCompare.value ? methodColumns.filter((col) => !col.actions) : methodColumns,
);
const createCodeHandler = () => { const createCodeHandler = () => {
codeConfig.value = { codeConfig.value = {
name: '', name: '',

View File

@ -1,8 +1,8 @@
<template> <template>
<div class="m-editor-data-source-fields"> <div class="m-editor-data-source-fields">
<MagicTable :data="model[name]" :columns="columns"></MagicTable> <MagicTable :data="model[name]" :columns="displayColumns"></MagicTable>
<div class="m-editor-data-source-fields-footer"> <div v-if="!isCompare" class="m-editor-data-source-fields-footer">
<TMagicButton size="small" type="primary" :disabled="disabled" plain @click="newHandler()">添加</TMagicButton> <TMagicButton size="small" type="primary" :disabled="disabled" plain @click="newHandler()">添加</TMagicButton>
</div> </div>
@ -28,7 +28,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { inject, Ref, ref } from 'vue'; import { computed, inject, Ref, ref } from 'vue';
import type { MockSchema } from '@tmagic/core'; import type { MockSchema } from '@tmagic/core';
import { TMagicButton, tMagicMessageBox, TMagicSwitch } from '@tmagic/design'; import { TMagicButton, tMagicMessageBox, TMagicSwitch } from '@tmagic/design';
@ -54,6 +54,11 @@ const props = withDefaults(defineProps<FieldProps<DataSourceMocksConfig>>(), {
const emit = defineEmits(['change']); const emit = defineEmits(['change']);
const { uiService } = useServices(); const { uiService } = useServices();
const mForm = inject<FormState | undefined>('mForm');
/** 对比模式下隐藏新增/编辑/删除等操作按钮,仅保留只读展示。 */
const isCompare = computed(() => Boolean(mForm?.isCompare));
const width = defineModel<number>('width', { default: 670 }); const width = defineModel<number>('width', { default: 670 });
const drawerTitle = ref(''); const drawerTitle = ref('');
@ -202,6 +207,11 @@ const columns: ColumnConfig[] = [
}, },
]; ];
/** 对比模式下移除「操作」列(编辑/删除按钮),仅保留只读列。 */
const displayColumns = computed<ColumnConfig[]>(() =>
isCompare.value ? columns.filter((col) => !col.actions) : columns,
);
const newHandler = () => { const newHandler = () => {
const isFirstRow = props.model[props.name].length === 0; const isFirstRow = props.model[props.name].length === 0;
formValues.value = { formValues.value = {

View File

@ -11,7 +11,7 @@
@change="changeHandler" @change="changeHandler"
></MSelect> ></MSelect>
<TMagicTooltip v-if="model[name] && hasDataSourceSidePanel" :content="notEditable ? '查看' : '编辑'"> <TMagicTooltip v-if="model[name] && hasDataSourceSidePanel && !isCompare" :content="notEditable ? '查看' : '编辑'">
<TMagicButton class="m-fields-select-action-button" :size="size" @click="editHandler" <TMagicButton class="m-fields-select-action-button" :size="size" @click="editHandler"
><MIcon :icon="!notEditable ? Edit : View"></MIcon ><MIcon :icon="!notEditable ? Edit : View"></MIcon
></TMagicButton> ></TMagicButton>
@ -56,6 +56,9 @@ const dataSources = computed(() => dataSourceService.get('dataSources'));
const notEditable = computed(() => filterFunction(mForm, props.config.notEditable, props)); const notEditable = computed(() => filterFunction(mForm, props.config.notEditable, props));
/** 对比模式下隐藏查看/编辑操作按钮,仅保留只读展示。 */
const isCompare = computed(() => Boolean(mForm?.isCompare));
const hasDataSourceSidePanel = computed(() => const hasDataSourceSidePanel = computed(() =>
uiService.get('sideBarItems').find((item) => item.$key === SideItemKey.DATA_SOURCE), uiService.get('sideBarItems').find((item) => item.$key === SideItemKey.DATA_SOURCE),
); );

View File

@ -6,22 +6,32 @@
:size="size" :size="size"
:disabled="disabled" :disabled="disabled"
:model="model" :model="model"
:last-values="lastValues"
:is-compare="isCompareMode"
:config="tableConfig" :config="tableConfig"
@change="onChangeHandler" @change="onChangeHandler"
></MTable> ></MTable>
<div v-else class="fullWidth"> <div v-else class="fullWidth">
<TMagicButton class="create-button" type="primary" :size="size" :disabled="disabled" @click="addEvent()" <TMagicButton
v-if="!isCompareMode"
class="create-button"
type="primary"
:size="size"
:disabled="disabled"
@click="addEvent()"
>添加事件</TMagicButton >添加事件</TMagicButton
> >
<MPanel <MPanel
v-for="(cardItem, index) in model[name]" v-for="entry in displayList"
:key="index" :key="entry.index"
:disabled="disabled" :disabled="disabled"
:size="size" :size="size"
:prop="`${prop}.${index}`" :prop="`${prop}.${entry.index}`"
:config="actionsConfig" :config="actionsConfig"
:model="cardItem" :model="entry.cardItem"
:last-values="entry.lastCardItem"
:is-compare="isCompareMode"
:label-width="config.labelWidth || '100px'" :label-width="config.labelWidth || '100px'"
@change="onChangeHandler" @change="onChangeHandler"
> >
@ -29,19 +39,22 @@
<MFormContainer <MFormContainer
class="fullWidth" class="fullWidth"
:config="eventNameConfig" :config="eventNameConfig"
:model="cardItem" :model="entry.cardItem"
:last-values="entry.lastCardItem"
:is-compare="isCompareMode"
:disabled="disabled" :disabled="disabled"
:size="size" :size="size"
:prop="`${prop}.${index}`" :prop="`${prop}.${entry.index}`"
@change="eventNameChangeHandler" @change="eventNameChangeHandler"
></MFormContainer> ></MFormContainer>
<TMagicButton <TMagicButton
v-if="!isCompareMode"
style="color: #f56c6c" style="color: #f56c6c"
link link
:icon="Delete" :icon="Delete"
:disabled="disabled" :disabled="disabled"
:size="size" :size="size"
@click="removeEvent(Number(index))" @click="removeEvent(Number(entry.index))"
></TMagicButton> ></TMagicButton>
</template> </template>
</MPanel> </MPanel>
@ -374,6 +387,42 @@ const isOldVersion = computed(() => {
return !has(props.model[props.name][0], 'actions'); return !has(props.model[props.name][0], 'actions');
}); });
/**
* 对比模式判定
*
* event-select 内部由事件列表 + 嵌套子表单组成属于复合字段父级 `MFormContainer` 已将其
* 归入自接管对比字段 Container.vue `SELF_DIFF_FIELD_TYPES`即对比时只渲染一次本组件
* 并把当前值 `model` 与历史值 `lastValues` 一并传入由本组件把 `is-compare`/`lastValues` 透传给
* 内部的 MPanel / MFormContainer逐项事件名动作展示前后差异
*
* 仅当存在历史值时才启用对比避免 lastValues 缺失时退化为全部新增的空对比
*/
const isCompareMode = computed(() => Boolean(props.isCompare && props.lastValues));
/**
* 待渲染的事件卡片列表
*
* - 非对比模式直接映射当前事件列表`lastCardItem` 为空
* - 对比模式按索引对齐当前值与历史值取两者长度的最大值使得新增仅当前有
* 删除仅历史有的事件都能被渲染出来缺失的一侧用空对象兜底从而让子级正确高亮差异
*/
const displayList = computed<{ cardItem: any; lastCardItem: any; index: number }[]>(() => {
const current = props.model[props.name] || [];
if (!isCompareMode.value) {
return current.map((cardItem: any, index: number) => ({ cardItem, lastCardItem: undefined, index }));
}
const last = props.lastValues?.[props.name] || [];
const length = Math.max(current.length, last.length);
return Array.from({ length }, (_, index) => ({
cardItem: current[index] ?? {},
lastCardItem: last[index] ?? {},
index,
}));
});
// //
const addEvent = () => { const addEvent = () => {
const defaultEvent = { const defaultEvent = {

View File

@ -19,6 +19,7 @@
></TMagicInput> ></TMagicInput>
<TMagicButton <TMagicButton
v-if="!isCompare"
class="m-fields-key-value-delete" class="m-fields-key-value-delete"
type="danger" type="danger"
:size="size" :size="size"
@ -30,7 +31,14 @@
></TMagicButton> ></TMagicButton>
</div> </div>
<TMagicButton type="primary" :size="size" :disabled="disabled" plain :icon="Plus" @click="addHandler" <TMagicButton
v-if="!isCompare"
type="primary"
:size="size"
:disabled="disabled"
plain
:icon="Plus"
@click="addHandler"
>添加</TMagicButton >添加</TMagicButton
> >
</div> </div>
@ -52,7 +60,7 @@
></MagicCodeEditor> ></MagicCodeEditor>
<TMagicButton <TMagicButton
v-if="config.advanced" v-if="config.advanced && !isCompare"
size="default" size="default"
:disabled="disabled" :disabled="disabled"
link link
@ -63,11 +71,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watchEffect } from 'vue'; import { computed, inject, ref, watchEffect } from 'vue';
import { Delete, Plus } from '@element-plus/icons-vue'; import { Delete, Plus } from '@element-plus/icons-vue';
import { TMagicButton, TMagicInput } from '@tmagic/design'; import { TMagicButton, TMagicInput } from '@tmagic/design';
import type { FieldProps, KeyValueConfig } from '@tmagic/form'; import type { FieldProps, FormState, KeyValueConfig } from '@tmagic/form';
import CodeIcon from '@editor/icons/CodeIcon.vue'; import CodeIcon from '@editor/icons/CodeIcon.vue';
import MagicCodeEditor from '@editor/layouts/CodeEditor.vue'; import MagicCodeEditor from '@editor/layouts/CodeEditor.vue';
@ -84,6 +92,11 @@ const emit = defineEmits<{
change: [value: Record<string, any>]; change: [value: Record<string, any>];
}>(); }>();
const mForm = inject<FormState | undefined>('mForm');
/** 对比模式下隐藏增删/代码切换等操作按钮,仅保留只读展示。 */
const isCompare = computed(() => Boolean(mForm?.isCompare));
const records = ref<[string, string][]>([]); const records = ref<[string, string][]>([]);
const showCode = ref(false); const showCode = ref(false);

View File

@ -7,9 +7,12 @@
v-if="item.component" v-if="item.component"
:is="item.component" :is="item.component"
:values="model[name]" :values="model[name]"
:last-values="lastValues?.[name]"
:is-compare="isCompare"
:size="size" :size="size"
:disabled="disabled" :disabled="disabled"
@change="change" @change="change"
@add-diff-count="onAddDiffCount"
></component> ></component>
</TMagicCollapseItem> </TMagicCollapseItem>
</template> </template>
@ -36,6 +39,7 @@ const props = defineProps<FieldProps<StyleSchema>>();
const emit = defineEmits<{ const emit = defineEmits<{
change: [v: any, eventData: ContainerChangeEventData]; change: [v: any, eventData: ContainerChangeEventData];
addDiffCount: [];
}>(); }>();
const list = [ const list = [
@ -82,4 +86,6 @@ const change = (v: any, eventData: ContainerChangeEventData) => {
}); });
emit('change', v, eventData); emit('change', v, eventData);
}; };
const onAddDiffCount = () => emit('addDiffCount');
</script> </script>

View File

@ -30,7 +30,16 @@
</div> </div>
</div> </div>
<div class="border-value-container"> <div class="border-value-container">
<MContainer :config="config" :model="model" :size="size" :disabled="disabled" @change="change"></MContainer> <MContainer
:config="config"
:model="model"
:last-values="lastValues"
:is-compare="isCompare"
:size="size"
:disabled="disabled"
@change="change"
@add-diff-count="onAddDiffCount"
></MContainer>
</div> </div>
</div> </div>
</template> </template>
@ -86,11 +95,14 @@ const selectDirection = (d?: string) => (direction.value = d || '');
const emit = defineEmits<{ const emit = defineEmits<{
change: [v: StyleSchema, eventData: ContainerChangeEventData]; change: [v: StyleSchema, eventData: ContainerChangeEventData];
addDiffCount: [];
}>(); }>();
withDefaults( withDefaults(
defineProps<{ defineProps<{
model: FormValue; model: FormValue;
lastValues?: FormValue;
isCompare?: boolean;
disabled?: boolean; disabled?: boolean;
size?: 'large' | 'default' | 'small'; size?: 'large' | 'default' | 'small';
}>(), }>(),
@ -104,4 +116,6 @@ const change = (value: StyleSchema, eventData: ContainerChangeEventData) => {
}); });
}); });
}; };
const onAddDiffCount = () => emit('addDiffCount');
</script> </script>

View File

@ -64,6 +64,8 @@ withDefaults(
disabled?: boolean; disabled?: boolean;
size?: 'large' | 'default' | 'small'; size?: 'large' | 'default' | 'small';
model: FormValue; model: FormValue;
lastValues?: FormValue;
isCompare?: boolean;
}>(), }>(),
{}, {},
); );

View File

@ -1,5 +1,14 @@
<template> <template>
<MContainer :config="config" :model="values" :size="size" :disabled="disabled" @change="change"></MContainer> <MContainer
:config="config"
:model="values"
:last-values="lastValues"
:is-compare="isCompare"
:size="size"
:disabled="disabled"
@change="change"
@add-diff-count="onAddDiffCount"
></MContainer>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
@ -13,12 +22,15 @@ import { BackgroundNoRepeat, BackgroundRepeat, BackgroundRepeatX, BackgroundRepe
defineProps<{ defineProps<{
values: Partial<StyleSchema>; values: Partial<StyleSchema>;
lastValues?: Partial<StyleSchema>;
isCompare?: boolean;
disabled?: boolean; disabled?: boolean;
size?: 'large' | 'default' | 'small'; size?: 'large' | 'default' | 'small';
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
change: [v: StyleSchema, eventData: ContainerChangeEventData]; change: [v: StyleSchema, eventData: ContainerChangeEventData];
addDiffCount: [];
}>(); }>();
const config = defineFormItem({ const config = defineFormItem({
@ -79,4 +91,6 @@ const config = defineFormItem({
const change = (value: StyleSchema, eventData: ContainerChangeEventData) => { const change = (value: StyleSchema, eventData: ContainerChangeEventData) => {
emit('change', value, eventData); emit('change', value, eventData);
}; };
const onAddDiffCount = () => emit('addDiffCount');
</script> </script>

View File

@ -1,6 +1,23 @@
<template> <template>
<MContainer :config="config" :model="values" :size="size" :disabled="disabled" @change="change"></MContainer> <MContainer
<Border :model="values" :size="size" :disabled="disabled" @change="change"></Border> :config="config"
:model="values"
:last-values="lastValues"
:is-compare="isCompare"
:size="size"
:disabled="disabled"
@change="change"
@add-diff-count="onAddDiffCount"
></MContainer>
<Border
:model="values"
:last-values="lastValues"
:is-compare="isCompare"
:size="size"
:disabled="disabled"
@change="change"
@add-diff-count="onAddDiffCount"
></Border>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
@ -11,12 +28,15 @@ import Border from '../components/Border.vue';
defineProps<{ defineProps<{
values: Partial<StyleSchema>; values: Partial<StyleSchema>;
lastValues?: Partial<StyleSchema>;
isCompare?: boolean;
disabled?: boolean; disabled?: boolean;
size?: 'large' | 'default' | 'small'; size?: 'large' | 'default' | 'small';
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
change: [v: StyleSchema, eventData: ContainerChangeEventData]; change: [v: StyleSchema, eventData: ContainerChangeEventData];
addDiffCount: [];
}>(); }>();
const config = defineFormItem({ const config = defineFormItem({
@ -36,4 +56,6 @@ const config = defineFormItem({
const change = (value: StyleSchema, eventData: ContainerChangeEventData) => { const change = (value: StyleSchema, eventData: ContainerChangeEventData) => {
emit('change', value, eventData); emit('change', value, eventData);
}; };
const onAddDiffCount = () => emit('addDiffCount');
</script> </script>

View File

@ -1,5 +1,14 @@
<template> <template>
<MContainer :config="config" :model="values" :size="size" :disabled="disabled" @change="change"></MContainer> <MContainer
:config="config"
:model="values"
:last-values="lastValues"
:is-compare="isCompare"
:size="size"
:disabled="disabled"
@change="change"
@add-diff-count="onAddDiffCount"
></MContainer>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
@ -12,12 +21,15 @@ import { AlignCenter, AlignLeft, AlignRight } from '../icons/text-align';
defineProps<{ defineProps<{
values: Partial<StyleSchema>; values: Partial<StyleSchema>;
lastValues?: Partial<StyleSchema>;
isCompare?: boolean;
disabled?: boolean; disabled?: boolean;
size?: 'large' | 'default' | 'small'; size?: 'large' | 'default' | 'small';
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
change: [v: StyleSchema, eventData: ContainerChangeEventData]; change: [v: StyleSchema, eventData: ContainerChangeEventData];
addDiffCount: [];
}>(); }>();
const config = defineFormItem({ const config = defineFormItem({
@ -91,4 +103,6 @@ const config = defineFormItem({
const change = (value: StyleSchema, eventData: ContainerChangeEventData) => { const change = (value: StyleSchema, eventData: ContainerChangeEventData) => {
emit('change', value, eventData); emit('change', value, eventData);
}; };
const onAddDiffCount = () => emit('addDiffCount');
</script> </script>

View File

@ -1,8 +1,19 @@
<template> <template>
<MContainer :config="config" :model="values" :size="size" :disabled="disabled" @change="change"></MContainer> <MContainer
:config="config"
:model="values"
:last-values="lastValues"
:is-compare="isCompare"
:size="size"
:disabled="disabled"
@change="change"
@add-diff-count="onAddDiffCount"
></MContainer>
<Box <Box
v-show="!['fixed', 'absolute'].includes(values.position)" v-show="!['fixed', 'absolute'].includes(values.position)"
:model="values" :model="values"
:last-values="lastValues"
:is-compare="isCompare"
:size="size" :size="size"
:disabled="disabled" :disabled="disabled"
@change="change" @change="change"
@ -34,12 +45,15 @@ import {
defineProps<{ defineProps<{
values: Partial<StyleSchema>; values: Partial<StyleSchema>;
lastValues?: Partial<StyleSchema>;
isCompare?: boolean;
disabled?: boolean; disabled?: boolean;
size?: 'large' | 'default' | 'small'; size?: 'large' | 'default' | 'small';
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
change: [v: string | StyleSchema, eventData: ContainerChangeEventData]; change: [v: string | StyleSchema, eventData: ContainerChangeEventData];
addDiffCount: [];
}>(); }>();
const config = defineFormItem({ const config = defineFormItem({
@ -185,4 +199,6 @@ const config = defineFormItem({
const change = (value: string | StyleSchema, eventData: ContainerChangeEventData) => { const change = (value: string | StyleSchema, eventData: ContainerChangeEventData) => {
emit('change', value, eventData); emit('change', value, eventData);
}; };
const onAddDiffCount = () => emit('addDiffCount');
</script> </script>

View File

@ -1,5 +1,14 @@
<template> <template>
<MContainer :config="config" :model="values" :size="size" :disabled="disabled" @change="change"></MContainer> <MContainer
:config="config"
:model="values"
:last-values="lastValues"
:is-compare="isCompare"
:size="size"
:disabled="disabled"
@change="change"
@add-diff-count="onAddDiffCount"
></MContainer>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
@ -8,12 +17,15 @@ import type { StyleSchema } from '@tmagic/schema';
const props = defineProps<{ const props = defineProps<{
values: Partial<StyleSchema>; values: Partial<StyleSchema>;
lastValues?: Partial<StyleSchema>;
isCompare?: boolean;
disabled?: boolean; disabled?: boolean;
size?: 'large' | 'default' | 'small'; size?: 'large' | 'default' | 'small';
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
change: [v: string | StyleSchema, eventData: ContainerChangeEventData]; change: [v: string | StyleSchema, eventData: ContainerChangeEventData];
addDiffCount: [];
}>(); }>();
const positionText: Record<string, string> = { const positionText: Record<string, string> = {
@ -100,4 +112,6 @@ const config = defineFormItem({
const change = (value: string | StyleSchema, eventData: ContainerChangeEventData) => { const change = (value: string | StyleSchema, eventData: ContainerChangeEventData) => {
emit('change', value, eventData); emit('change', value, eventData);
}; };
const onAddDiffCount = () => emit('addDiffCount');
</script> </script>

View File

@ -1,5 +1,14 @@
<template> <template>
<MContainer :config="config" :model="values" :size="size" :disabled="disabled" @change="change"></MContainer> <MContainer
:config="config"
:model="values"
:last-values="lastValues"
:is-compare="isCompare"
:size="size"
:disabled="disabled"
@change="change"
@add-diff-count="onAddDiffCount"
></MContainer>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
@ -8,12 +17,15 @@ import type { StyleSchema } from '@tmagic/schema';
defineProps<{ defineProps<{
values: Partial<StyleSchema>; values: Partial<StyleSchema>;
lastValues?: Partial<StyleSchema>;
isCompare?: boolean;
disabled?: boolean; disabled?: boolean;
size?: 'large' | 'default' | 'small'; size?: 'large' | 'default' | 'small';
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
change: [v: StyleSchema, eventData: ContainerChangeEventData]; change: [v: StyleSchema, eventData: ContainerChangeEventData];
addDiffCount: [];
}>(); }>();
const config = defineFormItem({ const config = defineFormItem({
@ -51,4 +63,6 @@ const config = defineFormItem({
const change = (value: StyleSchema, eventData: ContainerChangeEventData) => { const change = (value: StyleSchema, eventData: ContainerChangeEventData) => {
emit('change', value, eventData); emit('change', value, eventData);
}; };
const onAddDiffCount = () => emit('addDiffCount');
</script> </script>

View File

@ -6,7 +6,7 @@
</div> </div>
<div class="m-fields-ui-select" v-else style="display: flex"> <div class="m-fields-ui-select" v-else style="display: flex">
<template v-if="val"> <template v-if="val">
<TMagicTooltip content="清除" placement="top"> <TMagicTooltip v-if="!isCompare" content="清除" placement="top">
<TMagicButton <TMagicButton
style="padding: 0" style="padding: 0"
type="danger" type="danger"
@ -32,7 +32,7 @@
</TMagicTooltip> </TMagicTooltip>
</template> </template>
<TMagicTooltip v-else content="点击此处选择" placement="top"> <TMagicTooltip v-else-if="!isCompare" content="点击此处选择" placement="top">
<TMagicButton link style="padding: 0; margin: 0" :disabled="disabled" :size="size" @click="startSelect" <TMagicButton link style="padding: 0; margin: 0" :disabled="disabled" :size="size" @click="startSelect"
>点击此处选择</TMagicButton >点击此处选择</TMagicButton
> >
@ -67,6 +67,9 @@ const mForm = inject<FormState>('mForm');
const val = computed(() => props.model[props.name]); const val = computed(() => props.model[props.name]);
const uiSelectMode = ref(false); const uiSelectMode = ref(false);
/** 对比模式下隐藏清除/选择等操作按钮,仅保留只读展示。 */
const isCompare = computed(() => Boolean(mForm?.isCompare));
const cancelHandler = () => { const cancelHandler = () => {
uiService.set('uiSelectMode', false); uiService.set('uiSelectMode', false);
uiSelectMode.value = false; uiSelectMode.value = false;

View File

@ -3,9 +3,10 @@ import { cloneDeep } from 'lodash-es';
import type { CodeBlockContent } from '@tmagic/core'; import type { CodeBlockContent } from '@tmagic/core';
import { tMagicMessage } from '@tmagic/design'; import { tMagicMessage } from '@tmagic/design';
import type { ContainerChangeEventData } from '@tmagic/form';
import CodeBlockEditor from '@editor/components/CodeBlockEditor.vue'; import CodeBlockEditor from '@editor/components/CodeBlockEditor.vue';
import type { Services } from '@editor/type'; import type { HistoryOpSource, Services } from '@editor/type';
export const useCodeBlockEdit = (codeBlockService: Services['codeBlockService']) => { export const useCodeBlockEdit = (codeBlockService: Services['codeBlockService']) => {
const codeConfig = ref<Omit<CodeBlockContent, 'content'> & { content: string }>(); const codeConfig = ref<Omit<CodeBlockContent, 'content'> & { content: string }>();
@ -57,14 +58,17 @@ export const useCodeBlockEdit = (codeBlockService: Services['codeBlockService'])
}; };
// 删除代码块 // 删除代码块
const deleteCode = async (key: string) => { const deleteCode = async (key: string, { historySource }: { historySource?: HistoryOpSource } = {}) => {
codeBlockService.deleteCodeDslByIds([key]); codeBlockService.deleteCodeDslByIds([key], { historySource });
}; };
const submitCodeBlockHandler = async (values: CodeBlockContent) => { const submitCodeBlockHandler = async (values: CodeBlockContent, eventData?: ContainerChangeEventData) => {
if (!codeId.value) return; if (!codeId.value) return;
await codeBlockService.setCodeDslById(codeId.value, values); await codeBlockService.setCodeDslById(codeId.value, values, {
changeRecords: eventData?.changeRecords,
historySource: 'props',
});
codeBlockEditorRef.value?.hide(); codeBlockEditorRef.value?.hide();
}; };

View File

@ -27,9 +27,9 @@ export const useDataSourceEdit = (dataSourceService: Services['dataSourceService
const submitDataSourceHandler = (value: DataSourceSchema, eventData: ContainerChangeEventData) => { const submitDataSourceHandler = (value: DataSourceSchema, eventData: ContainerChangeEventData) => {
if (value.id) { if (value.id) {
dataSourceService.update(value, { changeRecords: eventData.changeRecords }); dataSourceService.update(value, { changeRecords: eventData.changeRecords, historySource: 'props' });
} else { } else {
dataSourceService.add(value); dataSourceService.add(value, { historySource: 'props' });
} }
editDialog.value?.hide(); editDialog.value?.hide();

View File

@ -48,6 +48,7 @@ export const useStage = (stageOptions: StageOptions) => {
disabledMultiSelect: stageOptions.disabledMultiSelect, disabledMultiSelect: stageOptions.disabledMultiSelect,
alwaysMultiSelect: stageOptions.alwaysMultiSelect, alwaysMultiSelect: stageOptions.alwaysMultiSelect,
disabledRule: stageOptions.disabledRule, disabledRule: stageOptions.disabledRule,
disabledFlashTip: stageOptions.disabledFlashTip,
}); });
watch( watch(
@ -97,32 +98,48 @@ export const useStage = (stageOptions: StageOptions) => {
stage.on('update', (ev: UpdateEventData) => { stage.on('update', (ev: UpdateEventData) => {
if (ev.parentEl) { if (ev.parentEl) {
for (const data of ev.data) { // 拖动多选元素到一个新容器:整批合成一次 moveToContainer只产生一条历史记录
const id = getIdFromEl()(data.el); const pId = getIdFromEl()(ev.parentEl);
const pId = getIdFromEl()(ev.parentEl); if (!pId) return;
id && pId && editorService.moveToContainer({ id, style: data.style }, pId); const configs = ev.data
.map((data) => {
const id = getIdFromEl()(data.el);
if (!id) return null;
const cfg: MNode = { id, style: data.style };
return cfg;
})
.filter((cfg): cfg is MNode => Boolean(cfg));
if (configs.length > 0) {
editorService.moveToContainer(configs, pId);
} }
return; return;
} }
// 为每个元素单独更新,确保 changeRecords 与对应的元素关联 // 多选拖动 / 多选缩放:所有元素整批走一次 update避免历史栈被切成 N 条
// changeRecordList 与 configs 同序,每个节点保留自己的 records
// 不能把多个节点的 records 合并到同一个数组里,否则 doUpdate / nodeUpdateHandler 会把别的节点的 propPath 当成自己的。
const configs: MNode[] = [];
const changeRecordList: ReturnType<typeof buildChangeRecords>[] = [];
ev.data.forEach((data) => { ev.data.forEach((data) => {
const id = getIdFromEl()(data.el); const id = getIdFromEl()(data.el);
if (!id) return; if (!id) return;
const { style = {} } = data; const { style = {} } = data;
configs.push({ id, style });
editorService.update({ id, style }, { changeRecords: buildChangeRecords(style, 'style') }); changeRecordList.push(buildChangeRecords(style, 'style'));
}); });
if (configs.length === 0) return;
editorService.update(configs, { changeRecordList, historySource: 'stage' });
}); });
stage.on('sort', (ev: SortEventData) => { stage.on('sort', (ev: SortEventData) => {
editorService.sort(ev.src, ev.dist); editorService.sort(ev.src, ev.dist, { historySource: 'stage' });
}); });
stage.on('remove', (ev: RemoveEventData) => { stage.on('remove', (ev: RemoveEventData) => {
const nodes = ev.data.map(({ el }) => editorService.getNodeById(getIdFromEl()(el) || '')); const nodes = ev.data.map(({ el }) => editorService.getNodeById(getIdFromEl()(el) || ''));
editorService.remove(nodes.filter((node) => Boolean(node)) as MNode[]); editorService.remove(nodes.filter((node) => Boolean(node)) as MNode[], { historySource: 'stage' });
}); });
stage.on('select-parent', () => { stage.on('select-parent', () => {

View File

@ -70,6 +70,9 @@ export { default as LayoutContainer } from './components/SplitView.vue';
export { default as SplitView } from './components/SplitView.vue'; export { default as SplitView } from './components/SplitView.vue';
export { default as Resizer } from './components/Resizer.vue'; export { default as Resizer } from './components/Resizer.vue';
export { default as CodeBlockEditor } from './components/CodeBlockEditor.vue'; export { default as CodeBlockEditor } from './components/CodeBlockEditor.vue';
export { default as CompareForm } from './components/CompareForm.vue';
export { default as HistoryListBucket } from './layouts/history-list/Bucket.vue';
export { default as HistoryDiffDialog } from './layouts/history-list/HistoryDiffDialog.vue';
export { default as FloatingBox } from './components/FloatingBox.vue'; export { default as FloatingBox } from './components/FloatingBox.vue';
export { default as Tree } from './components/Tree.vue'; export { default as Tree } from './components/Tree.vue';
export { default as TreeNode } from './components/TreeNode.vue'; export { default as TreeNode } from './components/TreeNode.vue';

View File

@ -21,12 +21,12 @@ import { computed, nextTick, onBeforeUnmount, onMounted, onUnmounted, ref, useTe
import { FullScreen } from '@element-plus/icons-vue'; import { FullScreen } from '@element-plus/icons-vue';
import { throttle } from 'lodash-es'; import { throttle } from 'lodash-es';
import type * as Monaco from 'monaco-editor'; import type * as Monaco from 'monaco-editor';
import serialize from 'serialize-javascript';
import { TMagicButton } from '@tmagic/design'; import { TMagicButton } from '@tmagic/design';
import MIcon from '@editor/components/Icon.vue'; import MIcon from '@editor/components/Icon.vue';
import { getEditorConfig } from '@editor/utils/config'; import { getEditorConfig } from '@editor/utils/config';
import { serializeConfig } from '@editor/utils/editor';
import loadMonaco from '@editor/utils/monaco-editor'; import loadMonaco from '@editor/utils/monaco-editor';
defineOptions({ defineOptions({
@ -163,10 +163,7 @@ const toString = (v: string | any, language: string): string => {
if (language === 'json') { if (language === 'json') {
value = JSON.stringify(v, null, 2); value = JSON.stringify(v, null, 2);
} else { } else {
value = serialize(v, { value = serializeConfig(v);
space: 2,
unsafe: true,
}).replace(/"(\w+)":\s/g, '$1: ');
} }
} else { } else {
value = v; value = v;
@ -330,6 +327,21 @@ watch(
}, },
); );
// diff ""modifiedValues lastValues model
//
watch(
() => props.modifiedValues,
(v, preV) => {
if (props.type !== 'diff') return;
if (v !== preV) {
setEditorValue(props.initValues, props.modifiedValues);
}
},
{
deep: true,
},
);
watch( watch(
() => props.options, () => props.options,
(v) => { (v) => {

View File

@ -23,15 +23,15 @@
left-class="m-editor-framework-left" left-class="m-editor-framework-left"
center-class="m-editor-framework-center" center-class="m-editor-framework-center"
right-class="m-editor-framework-right" right-class="m-editor-framework-right"
:left="columnWidth.left" :left="hideSidebar ? undefined : columnWidth.left"
:right="columnWidth.right" :right="columnWidth.right"
:min-left="MIN_LEFT_COLUMN_WIDTH" :min-left="hideSidebar ? 0 : MIN_LEFT_COLUMN_WIDTH"
:min-right="MIN_RIGHT_COLUMN_WIDTH" :min-right="MIN_RIGHT_COLUMN_WIDTH"
:min-center="MIN_CENTER_COLUMN_WIDTH" :min-center="MIN_CENTER_COLUMN_WIDTH"
:width="frameworkRect.width" :width="frameworkRect.width"
@change="columnWidthChange" @change="columnWidthChange"
> >
<template #left> <template v-if="!hideSidebar" #left>
<slot name="sidebar"></slot> <slot name="sidebar"></slot>
</template> </template>
@ -94,10 +94,12 @@ defineOptions({
name: 'MEditorFramework', name: 'MEditorFramework',
}); });
defineProps<{ const props = defineProps<{
disabledPageFragment: boolean; disabledPageFragment: boolean;
pageBarSortOptions?: PageBarSortOptions; pageBarSortOptions?: PageBarSortOptions;
pageFilterFunction?: (_page: MPage | MPageFragment, _keyword: string) => boolean; pageFilterFunction?: (_page: MPage | MPageFragment, _keyword: string) => boolean;
/** 是否隐藏左侧面板 */
hideSidebar?: boolean;
}>(); }>();
const codeOptions = inject('codeOptions', {}); const codeOptions = inject('codeOptions', {});
@ -132,7 +134,10 @@ watch(
); );
const columnWidthChange = (columnW: GetColumnWidth) => { const columnWidthChange = (columnW: GetColumnWidth) => {
storageService.setItem(LEFT_COLUMN_WIDTH_STORAGE_KEY, columnW.left, { protocol: Protocol.NUMBER }); // left 0
if (!props.hideSidebar) {
storageService.setItem(LEFT_COLUMN_WIDTH_STORAGE_KEY, columnW.left, { protocol: Protocol.NUMBER });
}
storageService.setItem(RIGHT_COLUMN_WIDTH_STORAGE_KEY, columnW.right, { protocol: Protocol.NUMBER }); storageService.setItem(RIGHT_COLUMN_WIDTH_STORAGE_KEY, columnW.right, { protocol: Protocol.NUMBER });
uiService.set('columnWidth', columnW); uiService.set('columnWidth', columnW);

View File

@ -19,6 +19,7 @@ import { NodeType } from '@tmagic/core';
import { useServices } from '@editor/hooks/use-services'; import { useServices } from '@editor/hooks/use-services';
import { ColumnLayout, MenuBarData, MenuButton, MenuComponent, MenuItem } from '@editor/type'; import { ColumnLayout, MenuBarData, MenuButton, MenuComponent, MenuItem } from '@editor/type';
import HistoryListPanel from './history-list/HistoryListPanel.vue';
import NavMenuColumn from './NavMenuColumn.vue'; import NavMenuColumn from './NavMenuColumn.vue';
defineOptions({ defineOptions({
@ -79,7 +80,7 @@ const getConfig = (item: MenuItem): (MenuButton | MenuComponent)[] => {
disabled: () => editorService.get('node')?.type === NodeType.PAGE, disabled: () => editorService.get('node')?.type === NodeType.PAGE,
handler: () => { handler: () => {
const node = editorService.get('node'); const node = editorService.get('node');
node && editorService.remove(node); node && editorService.remove(node, { historySource: 'toolbar' });
}, },
}); });
break; break;
@ -103,6 +104,14 @@ const getConfig = (item: MenuItem): (MenuButton | MenuComponent)[] => {
handler: () => editorService.redo(), handler: () => editorService.redo(),
}); });
break; break;
case 'history-list':
// component popover nav
config.push({
type: 'component',
className: 'history-list',
component: markRaw(HistoryListPanel),
});
break;
case 'zoom-in': case 'zoom-in':
config.push({ config.push({
type: 'button', type: 'button',

View File

@ -0,0 +1,91 @@
<template>
<div class="m-editor-history-list-bucket">
<div class="m-editor-history-list-bucket-title">
<span>{{ config.title }}</span>
<code>{{ String(bucketId) }}</code>
<span class="m-editor-history-list-bucket-count">{{ groups.length }} </span>
</div>
<ul class="m-editor-history-list-ul">
<GroupRow
v-for="group in groups"
:key="rowKey(group)"
:group="toRow(group)"
:expanded="!!expanded[rowKey(group)]"
:goto-enabled="config.gotoEnabled"
@toggle="(key: string) => $emit('toggle', key)"
@goto="(index: number) => $emit('goto', bucketId, index)"
@diff-step="(index: number) => $emit('diff-step', bucketId, index)"
@revert-step="(index: number) => $emit('revert-step', bucketId, index)"
/>
<!--
初始状态项永远位于该 bucket 列表底部同样按倒序展示最底部 = 最早状态
bucket 内所有 group 都未 applied 时即为当前位置
config.showInitial=false 时不展示用于没有"撤销到初始状态"语义的自定义历史如业务模块历史
-->
<InitialRow
v-if="config.showInitial !== false"
:is-current="isInitial"
:goto-enabled="config.gotoEnabled"
@goto-initial="$emit('goto-initial', bucketId)"
/>
</ul>
</div>
</template>
<script lang="ts" setup generic="T extends BaseStepValue = BaseStepValue">
import { computed } from 'vue';
import type { BaseStepValue } from '@editor/type';
import type { HistoryBucketConfig, HistoryBucketGroup, HistoryRowGroup } from './composables';
import { toRowGroup } from './composables';
import GroupRow from './GroupRow.vue';
import InitialRow from './InitialRow.vue';
defineOptions({
name: 'MEditorHistoryListBucket',
});
const props = defineProps<{
/**
* 该类历史的整体渲染配置title / prefix / describe* / isStep* / showInitial / gotoEnabled
* 由父组件按业务类型注入组件内部按需读取避免逐项透传多个 props
*/
config: HistoryBucketConfig<T>;
/** 当前 bucket 对应的目标 iddataSource.id 或 codeBlock.id同时用于组装子项的 key。 */
bucketId: string | number;
/** 当前 bucket 下的所有历史分组,按时间倒序展示(最近的操作在前)。 */
groups: HistoryBucketGroup<T>[];
/** 共享的折叠状态表key -> 是否展开),由顶层 panel 统一维护以便跨 tab 复用。 */
expanded: Record<string, boolean>;
}>();
defineEmits<{
/** 透传子组件 GroupRow 的 toggle由上层 panel 更新 expanded。 */
(_e: 'toggle', _key: string): void;
/**
* 透传子组件 GroupRow goto并附带当前 bucket 对应的 iddataSourceId / codeBlockId
* 上层据此调用对应 service.goto(id, targetCursor)
*/
(_e: 'goto', _bucketId: string | number, _index: number): void;
/** 用户点击初始项希望该 bucket 回到未修改状态;携带 bucketId 用于上层路由到正确的 service。 */
(_e: 'goto-initial', _bucketId: string | number): void;
/** 用户点击"查看差异",携带 bucketId 与 step 索引。 */
(_e: 'diff-step', _bucketId: string | number, _index: number): void;
/** 用户点击"回滚"按钮,携带 bucketId 与 step 索引,类 git revert。 */
(_e: 'revert-step', _bucketId: string | number, _index: number): void;
}>();
/**
* 子项 / 折叠状态 key`${prefix}-${bucketId}-${组内首步 index}`
* 以稳定的 step 索引而非展示位置标识分组历史数据更新后已展开的分组状态仍能正确保持
*/
const rowKey = (group: HistoryBucketGroup<T>) => `${props.config.prefix}-${props.bucketId}-${group.steps[0]?.index}`;
/** 把原始分组派生为 GroupRow 直接消费的视图模型。 */
const toRow = (group: HistoryBucketGroup<T>): HistoryRowGroup => toRowGroup(group, rowKey(group), props.config);
/** 该 bucket 是否处于初始状态(栈 cursor=0等价于全部 group 都未 applied。 */
const isInitial = computed(() => props.groups.length > 0 && props.groups.every((g) => !g.applied));
</script>

View File

@ -0,0 +1,72 @@
<template>
<div v-if="!buckets.length" class="m-editor-history-list-empty">暂无操作记录</div>
<template v-else>
<div class="m-editor-history-list-toolbar">
<span class="m-editor-history-list-clear" :title="`清空${config.title}的历史记录`" @click="$emit('clear')"
>清空</span
>
</div>
<TMagicScrollbar max-height="360px">
<Bucket
v-for="bucket in buckets"
:key="`${config.prefix}-${bucket.id}`"
:config="config"
:bucket-id="bucket.id"
:groups="bucket.groups"
:expanded="expanded"
@toggle="(key: string) => $emit('toggle', key)"
@goto="(id: string | number, index: number) => $emit('goto', id, index)"
@goto-initial="(id: string | number) => $emit('goto-initial', id)"
@diff-step="(id: string | number, index: number) => $emit('diff-step', id, index)"
@revert-step="(id: string | number, index: number) => $emit('revert-step', id, index)"
/>
</TMagicScrollbar>
</template>
</template>
<script lang="ts" setup generic="T extends BaseStepValue = BaseStepValue">
import { TMagicScrollbar } from '@tmagic/design';
import type { BaseStepValue } from '@editor/type';
import Bucket from './Bucket.vue';
import type { HistoryBucketConfig, HistoryBucketGroup } from './composables';
defineOptions({
name: 'MEditorHistoryListBucketTab',
});
defineProps<{
/**
* 该类历史的整体渲染配置title / prefix / describe* / isStep* / showInitial / gotoEnabled
* 由父组件按业务类型注入并整体透传给 Bucket避免逐项透传多个 props
*/
config: HistoryBucketConfig<T>;
/**
* 已按目标 id 聚拢成的 bucket 列表每个 bucket 内部的 groups 已按时间倒序排好
* 空数组时显示空态
*/
buckets: { id: string | number; groups: HistoryBucketGroup<T>[] }[];
/**
* 共享的折叠状态表key -> 是否展开由顶层 panel 统一维护
* key 形如 `${prefix}-${id}-${组内首步 index}`以稳定的 step 索引而非展示位置标识分组
* 这样历史数据更新后已展开的分组状态仍能正确保持
*/
expanded: Record<string, boolean>;
}>();
defineEmits<{
/** 透传子组件 Bucket 的 toggle 事件给上层 panel由其更新 expanded。 */
(_e: 'toggle', _key: string): void;
/** 透传 Bucket 的 goto 事件,携带目标 id 与目标 step 索引。 */
(_e: 'goto', _targetId: string | number, _index: number): void;
/** 透传 Bucket 的 goto-initial 事件,携带目标 id回到该目标未修改时的状态。 */
(_e: 'goto-initial', _targetId: string | number): void;
/** 透传 Bucket 的 diff-step 事件,携带目标 id 与 step 索引。 */
(_e: 'diff-step', _targetId: string | number, _index: number): void;
/** 透传 Bucket 的 revert-step 事件,携带目标 id 与 step 索引(类 git revert。 */
(_e: 'revert-step', _targetId: string | number, _index: number): void;
/** 用户点击"清空"按钮,请求清空该类(数据源 / 代码块)的全部历史记录(由上层弹窗二次确认后执行)。 */
(_e: 'clear'): void;
}>();
</script>

View File

@ -0,0 +1,248 @@
<template>
<li
class="m-editor-history-list-item m-editor-history-list-group"
:class="{ 'is-undone': !group.applied, 'is-merged': merged, 'is-current': group.isCurrent }"
>
<div
class="m-editor-history-list-group-head"
:class="{ 'is-clickable': isHeadClickable }"
:title="headTitle"
@click="onHeadClick"
>
<span class="m-editor-history-list-item-index" :title="headIndexTitle">{{ headIndexLabel }}</span>
<span class="m-editor-history-list-item-op" :class="`op-${group.opType}`">{{ opLabel(group.opType) }}</span>
<span class="m-editor-history-list-item-desc">{{ group.desc }}</span>
<span v-if="headSaved" class="m-editor-history-list-item-saved" title="该记录为最近一次保存的状态">已保存</span>
<span
v-if="!merged && sourceLabel(group.source)"
class="m-editor-history-list-item-source"
:title="`操作途径:${sourceLabel(group.source)}`"
>{{ sourceLabel(group.source) }}</span
>
<span
v-if="!merged && group.time"
class="m-editor-history-list-item-time"
:title="group.timeTitle || group.time"
>{{ group.time }}</span
>
<span v-if="merged" class="m-editor-history-list-item-merge">合并 {{ stepCount }} </span>
<span
v-if="!merged && headRevertable"
class="m-editor-history-list-item-revert"
title="将该步骤的修改作为一次新操作反向应用(不影响后续历史)"
@click.stop="onRevertClick(group.subSteps[0].index)"
>回滚</span
>
<span
v-if="!merged && gotoEnabled && !group.isCurrent && group.subSteps.length"
class="m-editor-history-list-item-goto"
title="回到该记录"
@click.stop="onGotoClick(group.subSteps[0].index)"
>回到</span
>
<span
v-if="!merged && headDiffable"
class="m-editor-history-list-item-diff"
title="查看修改差异"
@click.stop="onDiffClick(group.subSteps[0].index)"
>查看差异</span
>
<span v-if="merged" class="m-editor-history-list-group-toggle" :class="{ 'is-expanded': expanded }"></span>
</div>
<ul v-if="merged && expanded" class="m-editor-history-list-substeps">
<li
v-for="s in subStepsDisplay"
:key="s.index"
:class="{ 'is-undone': !s.applied, 'is-current': s.isCurrent }"
:title="subStepTitle(s)"
>
<span class="m-editor-history-list-item-index">#{{ s.index + 1 }}</span>
<span class="m-editor-history-list-substep-desc">{{ s.desc }}</span>
<span v-if="s.saved" class="m-editor-history-list-item-saved" title="该记录为最近一次保存的状态">已保存</span>
<span
v-if="sourceLabel(s.source)"
class="m-editor-history-list-item-source"
:title="`操作途径:${sourceLabel(s.source)}`"
>{{ sourceLabel(s.source) }}</span
>
<span v-if="s.time" class="m-editor-history-list-item-time" :title="s.timeTitle || s.time">{{ s.time }}</span>
<span
v-if="s.revertable"
class="m-editor-history-list-item-revert"
title="将该步骤的修改作为一次新操作反向应用(不影响后续历史)"
@click.stop="onRevertClick(s.index)"
>回滚</span
>
<span
v-if="gotoEnabled && !s.isCurrent"
class="m-editor-history-list-item-goto"
title="回到该记录"
@click.stop="onGotoClick(s.index)"
>回到</span
>
<span
v-if="s.diffable"
class="m-editor-history-list-item-diff"
title="查看修改差异"
@click.stop="onDiffClick(s.index)"
>查看差异</span
>
</li>
</ul>
</li>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import type { HistoryRowGroup, HistoryRowStep } from './composables';
import { opLabel, sourceLabel } from './composables';
defineOptions({
name: 'MEditorHistoryListGroupRow',
});
const props = withDefaults(
defineProps<{
/**
* 该组的视图模型 `toRowGroup` 统一派生包含 key应用状态操作类型描述
* 来源 / 时间等头部信息以及子步列表原先散落的十余个扁平 props 收敛于此单一对象
*/
group: HistoryRowGroup;
/** 当前组是否处于展开状态。仅在合并组(子步数 > 1时生效控制子步列表是否渲染。 */
expanded: boolean;
/**
* 是否支持跳转到该记录(goto)默认 true
* false 单步组头部与子步条目都不再可点击跳转也不会触发 goto 事件
* 仅保留合并组头部的展开 / 收起能力以及查看差异回滚等其它入口
*/
gotoEnabled?: boolean;
}>(),
{
gotoEnabled: true,
},
);
const emit = defineEmits<{
/**
* 用户点击合并组头部时触发携带 group.key上层用其切换 expanded 状态
* 对单步组非合并头部点击不会发该事件因为单步组没有"展开"的概念
*/
(_e: 'toggle', _key: string): void;
/**
* 用户希望跳转到该记录时触发携带"目标 step 在所属栈中的索引"上层据此计算目标 cursor (= index + 1)
* 触发场景
* - 单步组非合并头部取该唯一 step index
* - 子步条目取该子步的 index
* 合并组头部不再触发 goto避免与展开/收起冲突用户应展开后点具体子步精准跳转
* 当前所在的步骤isCurrent始终不会触发 goto
*/
(_e: 'goto', _index: number): void;
/**
* 用户希望查看该 step 的修改差异旧值 vs 新值
* 只在 step 满足"前后值都存在" update / 数据源代码块的 update时由 `toRowGroup` 标记 `diffable=true`
* payload 为该 step 在所属栈中的索引由上层根据 index step 内容并展示对比
*/
(_e: 'diff-step', _index: number): void;
/**
* 用户希望回滚 step把它的修改作为一次新操作反向应用 git revert
* payload 为该 step 在所属栈中的索引仅在单步组头部headRevertable或合并组的可回滚子步上触发
*/
(_e: 'revert-step', _index: number): void;
}>();
/** 子步数大于 1 即为合并组:决定是否展示合并标记与可展开的子步列表。 */
const merged = computed(() => props.group.subSteps.length > 1);
/** 组内 step 总数,仅在合并组时显示为 "合并 N 步"。 */
const stepCount = computed(() => props.group.subSteps.length);
/**
* 仅合并组头部可点击切换展开 / 收起
* 单步组的跳转改由头部的回到按钮触发整行不再可点击
*/
const isHeadClickable = computed(() => merged.value);
const headTitle = computed(() => {
if (merged.value) return props.expanded ? '点击收起子步' : '点击展开子步';
if (props.group.isCurrent) return '当前所在记录';
return '';
});
/**
* 头部点击行为仅合并组切换展开 / 收起单步组不再响应整行点击
*/
const onHeadClick = () => {
if (merged.value) {
emit('toggle', props.group.key);
}
};
const onGotoClick = (index: number) => {
if (!props.gotoEnabled) return;
emit('goto', index);
};
const subStepTitle = (s: { isCurrent?: boolean }) => {
if (s.isCurrent) return '当前所在记录';
return '';
};
/**
* 头部是否展示已保存标记
* - 单步组取该唯一子步的 saved
* - 合并组组内任一子步为已保存即在头部提示具体落在哪一步可展开查看
*/
const headSaved = computed(() =>
merged.value ? props.group.subSteps.some((s) => s.saved) : Boolean(props.group.subSteps[0]?.saved),
);
/** 单步组头部是否展示"查看差异"入口:要求该唯一子步本身可对比。 */
const headDiffable = computed(() => !merged.value && Boolean(props.group.subSteps[0]?.diffable));
/** 单步组头部是否展示"回滚"入口:要求该唯一子步本身可回滚(已应用)。 */
const headRevertable = computed(() => !merged.value && Boolean(props.group.subSteps[0]?.revertable));
/**
* 合并组展开后的子步渲染顺序与外层分组列表保持一致倒序展示最新的子步在最上方
* 外层 page tab / bucket 都已对 groups 做了 reverse子步沿用同样的视觉规则更直观
* 注意仅用于渲染 `subSteps` 保持时间正序`headIndexLabel` 等基于首尾索引的展示语义不变
*/
const subStepsDisplay = computed<HistoryRowStep[]>(() => props.group.subSteps.slice().reverse());
/**
* 头部索引展示
* - 单步组非合并显示该唯一 step 的编号 `#5`
* - 合并组显示组内 step 的编号范围 `#3-#7`首尾相同则退化为 `#5`
*
* 这里展示的是 step.index + 1与子步列表 `#{{ s.index + 1 }}` 保持一致 1 起编号更符合直觉
*/
const headIndexLabel = computed(() => {
const list = props.group.subSteps;
if (!list.length) return '';
const first = list[0].index + 1;
const last = list[list.length - 1].index + 1;
if (!merged.value || first === last) return `#${first}`;
return `#${first}-#${last}`;
});
const headIndexTitle = computed(() => {
const list = props.group.subSteps;
if (!merged.value) return `历史步骤编号 #${list[0]?.index + 1}`;
return `合并了第 ${list[0]?.index + 1} 至第 ${list[list.length - 1]?.index + 1}${list.length} 条历史步骤`;
});
const onDiffClick = (index: number) => {
emit('diff-step', index);
};
const onRevertClick = (index: number) => {
emit('revert-step', index);
};
</script>

View File

@ -0,0 +1,258 @@
<template>
<TMagicDialog
v-model="visible"
class="m-editor-history-diff-dialog"
:title="dialogTitle"
top="5vh"
destroy-on-close
append-to-body
:width="width"
@close="onClose"
>
<div v-if="payload && visible" class="m-editor-history-diff-dialog-body">
<div v-if="onConfirm" class="m-editor-history-diff-dialog-notice">仅回滚有差异的字段</div>
<div class="m-editor-history-diff-dialog-header">
<span class="m-editor-history-diff-dialog-target">{{ targetText }}</span>
<div class="m-editor-history-diff-dialog-controls">
<TMagicRadioGroup v-model="viewMode" size="small" class="m-editor-history-diff-dialog-view">
<TMagicRadioButton value="form">表单对比</TMagicRadioButton>
<TMagicRadioButton value="code">源码对比</TMagicRadioButton>
</TMagicRadioGroup>
<TMagicRadioGroup v-model="mode" size="small" class="m-editor-history-diff-dialog-mode">
<TMagicRadioButton value="before">与修改前对比</TMagicRadioButton>
<TMagicRadioButton value="current" :disabled="!hasCurrent">与当前对比</TMagicRadioButton>
</TMagicRadioGroup>
</div>
</div>
<div class="m-editor-history-diff-dialog-legend">
<TMagicTag size="small" type="danger">{{ leftLabel }}</TMagicTag>
<span class="m-editor-history-diff-dialog-arrow"></span>
<TMagicTag size="small" type="success">{{ rightLabel }}</TMagicTag>
<span v-if="mode === 'current' && isSameAsCurrent" class="m-editor-history-diff-dialog-tip">
当前值与该步修改后一致无差异
</span>
</div>
<CompareForm
v-if="viewMode === 'form'"
:category="payload.category"
:type="payload.type"
:data-source-type="payload.dataSourceType"
:value="rightValue"
:last-value="leftValue"
:extend-state="extendState"
:load-config="loadConfig"
:self-diff-field-types="selfDiffFieldTypes"
height="70vh"
/>
<CodeEditor
v-else
type="diff"
language="json"
:init-values="leftValue"
:modified-values="rightValue"
:options="codeDiffOptions"
disabled-full-screen
height="70vh"
/>
</div>
<template #footer>
<template v-if="isConfirm">
<TMagicButton size="small" @click="visible = false">取消</TMagicButton>
<TMagicButton size="small" type="primary" @click="onConfirmClick">确定回滚</TMagicButton>
</template>
<TMagicButton v-else size="small" @click="visible = false">关闭</TMagicButton>
</template>
</TMagicDialog>
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { isEqual } from 'lodash-es';
import { TMagicButton, TMagicDialog, TMagicRadioButton, TMagicRadioGroup, TMagicTag } from '@tmagic/design';
import type { FormState } from '@tmagic/form';
import CompareForm from '@editor/components/CompareForm.vue';
import CodeEditor from '@editor/layouts/CodeEditor.vue';
import type { CompareCategory, CompareFormLoadConfig, DiffDialogPayload } from '@editor/type';
defineOptions({
name: 'MEditorHistoryDiffDialog',
});
const props = withDefaults(
defineProps<{
/**
* 来自 Editor 顶层的 `extendFormState`用于扩展 MForm.formState
* 透传给 CompareForm从而让差异对比时表单 item 中依赖业务上下文的
* `display` / `disabled` filterFunction 正常工作
*/
extendState?: (_state: FormState) => Record<string, any> | Promise<Record<string, any>>;
/**
* 自定义 FormConfig 加载逻辑透传给 CompareForm传入后将接管内置的按 `category`
* 取配置逻辑可通过 `ctx.defaultLoadConfig()` 复用默认结果再做二次加工
*/
loadConfig?: CompareFormLoadConfig;
width?: string;
isConfirm?: boolean;
onConfirm?: () => void;
selfDiffFieldTypes?: string[];
}>(),
{
width: '900px',
},
);
const emit = defineEmits(['close']);
/**
* 差异对比模式
* - before该步骤修改前 vs 该步骤修改后默认行为体现这一步带来的变化
* - current该步骤修改后 vs 当前最新值用于查看该步骤之后是否还被改过
*/
type DiffMode = 'before' | 'current';
/**
* 展示形态
* - form以属性表单形式逐字段对比默认可读性更好
* - code JSON 源码形式做整体 diff贴近"看原始数据差异"可看到表单未覆盖的字段
*/
type ViewMode = 'form' | 'code';
const visible = ref(false);
const payload = ref<DiffDialogPayload | null>(null);
const mode = ref<DiffMode>('before');
const viewMode = ref<ViewMode>('form');
/**
* 源码对比始终只读关闭小地图强制左右并排side-by-side展示
*
* monaco diff 编辑器在宽度低于 `renderSideBySideInlineBreakpoint`默认 900px
* 会自动退化为 inline上下/单栏视图本弹窗宽度约 900px去掉内边距后编辑器实际
* 宽度小于该阈值会被切到 inline这里通过 `useInlineViewWhenSpaceIsLimited: false`
* 关闭该自动降级确保始终保持左右两栏对比
*/
const codeDiffOptions = {
readOnly: true,
tabSize: 2,
minimap: { enabled: false },
renderSideBySide: true,
useInlineViewWhenSpaceIsLimited: false,
scrollBeyondLastLine: false,
hideUnchangedRegions: {
enabled: true,
},
};
const dialogTitle = computed(() => (props.onConfirm ? '确认回滚' : '查看修改差异'));
const hasCurrent = computed(() => payload.value?.currentValue !== undefined && payload.value?.currentValue !== null);
/** 左侧(旧/参照)值 */
const leftValue = computed<Record<string, any>>(() => {
if (!payload.value) return {};
if (mode.value === 'current') return payload.value.value;
return payload.value.lastValue;
});
/** 右侧(新/对比)值 */
const rightValue = computed<Record<string, any>>(() => {
if (!payload.value) return {};
if (mode.value === 'current') return payload.value.currentValue || {};
return payload.value.value;
});
const leftLabel = computed(() => (mode.value === 'current' ? '该步修改后' : '修改前'));
const rightLabel = computed(() => (mode.value === 'current' ? '当前' : '修改后'));
/** 「与当前对比」模式下,若当前值与该步修改后值相等,则展示提示 */
const isSameAsCurrent = computed(() => {
if (mode.value !== 'current' || !payload.value) return false;
return isEqual(payload.value.value, payload.value.currentValue);
});
/** confirm() 的 resolve仅在「等待用户确认回滚」期间存在 */
let confirmResolve: ((_value: boolean) => void) | null = null;
const onConfirmClick = () => {
props.onConfirm?.();
// resolve(true) visible=false resolve(false)
confirmResolve?.(true);
confirmResolve = null;
visible.value = false;
};
const targetText = computed(() => {
if (!payload.value) return '';
const categoryText: Record<CompareCategory, string> = {
node: '节点',
'data-source': '数据源',
'code-block': '代码块',
};
const { category } = payload.value;
const prefix = category ? categoryText[category] : '';
const label = payload.value.targetLabel || payload.value.type || '';
const { id } = payload.value;
const labelWithId = id !== undefined && id !== '' ? `${label}${id}` : label;
return [prefix, labelWithId].filter(Boolean).join('');
});
const open = (p: DiffDialogPayload) => {
payload.value = p;
// ""退
mode.value = 'before';
//
viewMode.value = 'form';
visible.value = true;
};
/**
* Promise 形式打开确认回滚弹窗
* - 用户点击确定回滚 resolve(true)
* - 取消 / 关闭 / Esc 等其他方式关闭弹窗时 resolve(false)
*
* 同一时刻只允许一个待确认流程重复调用会先 resolve(false) 掉上一个
*/
const confirm = (p: DiffDialogPayload): Promise<boolean> => {
// Promise
confirmResolve?.(false);
confirmResolve = null;
return new Promise<boolean>((resolve) => {
confirmResolve = resolve;
open(p);
});
};
const close = () => {
visible.value = false;
};
// payload
watch(visible, (v) => {
if (!v) {
payload.value = null;
// / Esc / resolve(false)
confirmResolve?.(false);
confirmResolve = null;
}
});
const onClose = () => {
emit('close');
};
defineExpose({
open,
confirm,
close,
});
</script>

View File

@ -0,0 +1,527 @@
<template>
<TMagicPopover
popper-class="m-editor-history-list-popover"
placement="bottom"
trigger="click"
:visible="visible"
:width="660"
>
<div class="m-editor-history-list">
<TMagicTooltip effect="dark" placement="top" content="关闭">
<TMagicButton class="m-editor-history-list-close" size="small" link @click="visible = false">
<template #icon>
<MIcon :icon="CloseIcon"></MIcon>
</template>
</TMagicButton>
</TMagicTooltip>
<TMagicTabs v-model="activeTab" class="m-editor-history-list-tabs">
<component
:is="tabPaneComponent?.component || 'el-tab-pane'"
v-bind="tabPaneComponent?.props({ name: 'page', label: `页面 (${pageGroups.length})` }) || {}"
>
<PageTab
:list="pageGroupsDisplay"
:expanded="expanded"
@toggle="toggleGroup"
@goto="onPageGoto"
@goto-initial="onPageGotoInitial"
@diff-step="onPageDiff"
@revert-step="onPageRevert"
@clear="onPageClear"
/>
</component>
<component
v-if="!disabledDataSource"
:is="tabPaneComponent?.component || 'el-tab-pane'"
v-bind="tabPaneComponent?.props({ name: 'data-source', label: `数据源 (${dataSourceGroups.length})` }) || {}"
>
<BucketTab
:config="dataSourceConfig"
:buckets="dataSourceGroupsByTarget"
:expanded="expanded"
@toggle="toggleGroup"
@goto="onDataSourceGoto"
@goto-initial="onDataSourceGotoInitial"
@diff-step="onDataSourceDiff"
@revert-step="onDataSourceRevert"
@clear="onDataSourceClear"
/>
</component>
<component
v-if="!disabledCodeBlock"
:is="tabPaneComponent?.component || 'el-tab-pane'"
v-bind="tabPaneComponent?.props({ name: 'code-block', label: `代码块 (${codeBlockGroups.length})` }) || {}"
>
<BucketTab
:config="codeBlockConfig"
:buckets="codeBlockGroupsByTarget"
:expanded="expanded"
@toggle="toggleGroup"
@goto="onCodeBlockGoto"
@goto-initial="onCodeBlockGotoInitial"
@diff-step="onCodeBlockDiff"
@revert-step="onCodeBlockRevert"
@clear="onCodeBlockClear"
/>
</component>
<component
v-for="tab in extraTabs"
:key="tab.name"
:is="tabPaneComponent?.component || 'el-tab-pane'"
v-bind="tabPaneComponent?.props({ name: tab.name, label: resolveTabLabel(tab) }) || {}"
>
<component :is="tab.component" v-bind="tab.props || {}" v-on="tab.listeners || {}" />
</component>
</TMagicTabs>
</div>
<template #reference>
<TMagicTooltip effect="dark" placement="bottom" content="历史记录">
<TMagicButton size="small" link @click="visible = !visible">
<template #icon>
<MIcon :icon="ClockIcon"></MIcon>
</template>
</TMagicButton>
</TMagicTooltip>
</template>
</TMagicPopover>
<HistoryDiffDialog ref="diffDialog" :extend-state="extendFormState" />
<HistoryDiffDialog ref="confirmDialog" :is-confirm="true" :extend-state="extendFormState" />
</template>
<script lang="ts" setup>
/**
* 历史记录面板在顶部 NavMenu 上点击图标打开 popover分三个 tab
* - 页面当前活动页面的历史栈连续修改同一节点的多步会被合并成一组
* - 数据源 dataSource.id 分组每组内部相邻的连续 update 自动合并
* - 代码块同上 codeBlock.id 分组并合并相邻 update
*
* 数据通过 historyService 暴露的聚合 API 读取UI 仅用于只读展示
* 同时支持点击任意一条记录跳转至该状态
* - 页面 tab调用 editorService.gotoPageStep(targetCursor)
* - 数据源 tab调用 dataSourceService.goto(id, targetCursor)
* - 代码块 tab调用 codeBlockService.goto(id, targetCursor)
*
* 这里的 targetCursor = 用户点击的 step.index + 1"应用至此步完成的状态"
*
* 此外每条 step 上提供"查看差异"入口仅在前后值都存在的 update 步骤显示
* 点击后弹出 HistoryDiffDialog使用 CompareForm 组件以表单形式展示新旧值差异
*
* tab 的内容拆分为独立的 SFC页面用 PageTab数据源 / 代码块复用通用的 BucketTab
* 通过 title / prefix / describe* / isStepDiffable
* 共享的描述生成与折叠状态在 composables.ts 中维护
*/
import { computed, inject, markRaw, ref, useTemplateRef, watch } from 'vue';
import { Clock, Close } from '@element-plus/icons-vue';
import {
getDesignConfig,
TMagicButton,
tMagicMessage,
tMagicMessageBox,
TMagicPopover,
TMagicTabs,
TMagicTooltip,
} from '@tmagic/design';
import type { FormState } from '@tmagic/form';
import MIcon from '@editor/components/Icon.vue';
import { useServices } from '@editor/hooks/use-services';
import type { CodeBlockStepValue, DataSourceStepValue, DiffDialogPayload, HistoryListExtraTab } from '@editor/type';
import BucketTab from './BucketTab.vue';
import type { HistoryBucketConfig } from './composables';
import {
describeCodeBlockGroup,
describeCodeBlockStep,
describeDataSourceGroup,
describeDataSourceStep,
isCodeBlockStepRevertable,
isDataSourceStepRevertable,
useHistoryList,
} from './composables';
import HistoryDiffDialog from './HistoryDiffDialog.vue';
import PageTab from './PageTab.vue';
defineOptions({
name: 'MEditorHistoryListPanel',
});
const ClockIcon = markRaw(Clock);
const CloseIcon = markRaw(Close);
const activeTab = ref<string>('page');
/** 面板显隐受控reference 图标点击切换,右上角关闭按钮置为 false。 */
const visible = ref(false);
const tabPaneComponent = getDesignConfig('components')?.tabPane;
/**
* 业务方自定义的扩展 tab Editor 顶层通过 `historyListExtraTabs` 注入
* 追加在内置页面 / 数据源 / 代码块三个 tab 之后未提供时为空数组
*/
const extraTabs = inject<HistoryListExtraTab[]>('historyListExtraTabs', []);
/** label 支持字符串或函数,函数形式便于展示动态数量等内容。 */
const resolveTabLabel = (tab: HistoryListExtraTab) => (typeof tab.label === 'function' ? tab.label() : tab.label);
const { editorService, dataSourceService, codeBlockService, historyService, propsService } = useServices();
/**
* 数据源 / 代码块功能可被业务方通过 `disabledDataSource` / `disabledCodeBlock` 禁用
* 禁用后对应的历史记录 tab 不再展示若当前激活的 tab 恰好被禁用则回退到页面tab
*/
const disabledDataSource = computed(() => propsService.getDisabledDataSource());
const disabledCodeBlock = computed(() => propsService.getDisabledCodeBlock());
watch([disabledDataSource, disabledCodeBlock], ([dsDisabled, cbDisabled]) => {
if ((activeTab.value === 'data-source' && dsDisabled) || (activeTab.value === 'code-block' && cbDisabled)) {
activeTab.value = 'page';
}
});
/**
* 通过 inject 拿到 Editor 顶层注入的 `extendFormState`转交给 HistoryDiffDialog
* 内部的 CompareForm使差异对比表单的 filterFunction 能拿到完整的业务上下文
* 未提供时为 undefinedCompareForm/MForm 会跳过 extendState 处理
*/
const extendFormState = inject<((_state: FormState) => Record<string, any> | Promise<Record<string, any>>) | undefined>(
'extendFormState',
undefined,
);
const {
expanded,
toggleGroup,
pageGroups,
dataSourceGroups,
codeBlockGroups,
pageGroupsDisplay,
dataSourceGroupsByTarget,
codeBlockGroupsByTarget,
} = useHistoryList();
/** 数据源 step 仅 update前后 schema 都存在)时可查看差异。 */
const isDataSourceStepDiffable = (step: DataSourceStepValue) =>
Boolean(step.diff?.[0]?.oldSchema && step.diff?.[0]?.newSchema);
/** 代码块 step 仅 update前后 content 都存在)时可查看差异。 */
const isCodeBlockStepDiffable = (step: CodeBlockStepValue) =>
Boolean(step.diff?.[0]?.oldSchema && step.diff?.[0]?.newSchema);
/**
* 数据源 / 代码块两类 bucket 历史的整体渲染配置 title / prefix 与各自的描述
* 可差异可回滚判定收敛为单一对象整体注入 BucketTab组件内部按需读取
*/
const dataSourceConfig: HistoryBucketConfig<DataSourceStepValue> = {
title: '数据源',
prefix: 'ds',
describeGroup: describeDataSourceGroup,
describeStep: describeDataSourceStep,
isStepDiffable: isDataSourceStepDiffable,
isStepRevertable: isDataSourceStepRevertable,
};
const codeBlockConfig: HistoryBucketConfig<CodeBlockStepValue> = {
title: '代码块',
prefix: 'cb',
describeGroup: describeCodeBlockGroup,
describeStep: describeCodeBlockStep,
isStepDiffable: isCodeBlockStepDiffable,
isStepRevertable: isCodeBlockStepRevertable,
};
/** 把"目标 step 索引"翻译成"目标 cursor"(已应用步骤数量)。 */
const indexToCursor = (index: number) => index + 1;
const onPageGoto = (index: number) => {
editorService.gotoPageStep(indexToCursor(index));
};
const onDataSourceGoto = (id: string | number, index: number) => {
dataSourceService.goto(id, indexToCursor(index));
};
const onCodeBlockGoto = (id: string | number, index: number) => {
codeBlockService.goto(id, indexToCursor(index));
};
/**
* "回到初始状态" = 把对应栈 cursor 移到 0全部已撤销
* 复用 service.goto*(0) 即可所有真实 step 的反向应用由 service 层的 undo 链路完成
*/
const onPageGotoInitial = () => {
editorService.gotoPageStep(0);
};
const onDataSourceGotoInitial = (id: string | number) => {
dataSourceService.goto(id, 0);
};
const onCodeBlockGotoInitial = (id: string | number) => {
codeBlockService.goto(id, 0);
};
const diffDialogRef = useTemplateRef<InstanceType<typeof HistoryDiffDialog>>('diffDialog');
const confirmDialogRef = useTemplateRef<InstanceType<typeof HistoryDiffDialog>>('confirmDialog');
/**
* 三类历史页面 / 数据源 / 代码块差异弹窗入参的构造差异收敛为一份配置
* 分组来源当前值读取类型 / 展示名提取不同定位 step校验前后值组装 payload 的流程共用
*/
interface DiffPayloadSource {
/** 表单类别:节点 / 数据源 / 代码块。 */
category: DiffDialogPayload['category'];
/** 该类别按时间正序的历史分组列表(含已撤销)。 */
groups: () => { id?: string | number; steps: { index: number; step: { diff?: any[] } }[] }[];
/** 读取目标当前实际值,用于「与当前对比」;不存在时返回空即禁用对比。 */
getCurrent: (_id: string | number) => Record<string, any> | null | undefined;
/** 由新/旧快照提取展示名(含各自的兜底,如节点回退 type、数据源 / 代码块回退 id。 */
resolveLabel: (_newSchema: Record<string, any>, _oldSchema: Record<string, any>, _id: string | number) => string;
/** 由新/旧快照提取类型;代码块无 type 字段则不传。 */
resolveType?: (_newSchema: Record<string, any>, _oldSchema: Record<string, any>) => string;
}
/**
* 构造差异弹窗入参 update前后值都存在可对比
* - 页面 id在全部分组中按 index 定位 step目标 id 取自快照
* - 数据源 / 代码块 id先匹配分组 id 再按 index 定位
* 无可对比内容多节点 / add / remove或定位不到时返回 null
*/
const buildDiffPayload = (source: DiffPayloadSource, index: number, id?: string | number): DiffDialogPayload | null => {
for (const group of source.groups()) {
if (id !== undefined && group.id !== id) continue;
const step = group.steps.find((s) => s.index === index)?.step;
if (!step) continue;
const oldSchema = step.diff?.[0]?.oldSchema as Record<string, any> | undefined;
const newSchema = step.diff?.[0]?.newSchema as Record<string, any> | undefined;
if (!oldSchema || !newSchema) return null;
const targetId = id ?? newSchema.id ?? oldSchema.id;
const type = source.resolveType?.(newSchema, oldSchema);
return {
category: source.category,
...(type !== undefined ? { type } : {}),
lastValue: oldSchema,
value: newSchema,
currentValue: (targetId !== undefined ? source.getCurrent(targetId) : null) || null,
targetLabel: source.resolveLabel(newSchema, oldSchema, targetId),
id: targetId,
};
}
return null;
};
const buildPageDiffPayload = (index: number): DiffDialogPayload | null =>
buildDiffPayload(
{
category: 'node',
groups: () => historyService.getPageHistoryGroups(),
getCurrent: (id) => editorService.getNodeById(id) as Record<string, any> | null,
resolveType: (n, o) => n.type || o.type || '',
resolveLabel: (n, o) => n.name || o.name || n.type || o.type || '',
},
index,
);
const buildDataSourceDiffPayload = (id: string | number, index: number): DiffDialogPayload | null =>
buildDiffPayload(
{
category: 'data-source',
groups: () => historyService.getDataSourceHistoryGroups(),
getCurrent: (id) => dataSourceService.getDataSourceById(`${id}`) as Record<string, any> | null,
resolveType: (n, o) => n.type || o.type || 'base',
resolveLabel: (n, o, id) => n.title || o.title || `${id}`,
},
index,
id,
);
const buildCodeBlockDiffPayload = (id: string | number, index: number): DiffDialogPayload | null =>
buildDiffPayload(
{
category: 'code-block',
groups: () => historyService.getCodeBlockHistoryGroups(),
getCurrent: (id) => codeBlockService.getCodeContentById(id) as Record<string, any> | null,
resolveLabel: (n, o, id) => n.name || o.name || `${id}`,
},
index,
id,
);
const onPageDiff = (index: number) => {
const payload = buildPageDiffPayload(index);
if (payload) diffDialogRef.value?.open(payload);
};
const onDataSourceDiff = (id: string | number, index: number) => {
const payload = buildDataSourceDiffPayload(id, index);
if (payload) diffDialogRef.value?.open(payload);
};
const onCodeBlockDiff = (id: string | number, index: number) => {
const payload = buildCodeBlockDiffPayload(id, index);
if (payload) diffDialogRef.value?.open(payload);
};
/**
* 回滚统一入口把目标历史步骤的修改作为一次新操作反向应用 git revert
* 不破坏原有栈结构 service 内部完成反向 + 入栈并自带描述用于面板展示
*
* 交互
* - 可差异对比的步骤单节点 / 单实体 update弹出差异弹窗供用户确认确定回滚再执行
* - 无法对比的步骤add / remove / 多节点更新payload null弹出普通二次确认框确认后执行
*
* 页面 / 数据源 / 代码块三类回滚仅差异入参构造实际 revert 调用不同
* 由调用方分别传入 payload revert公共的弹窗 / 确认流程在此收敛
*/
const runRevert = (payload: DiffDialogPayload | null): Promise<boolean> => {
if (payload && confirmDialogRef.value) {
return confirmDialogRef.value.confirm(payload);
}
return confirmRevert();
};
/**
* 回滚前置校验若该历史步骤回滚所依赖的目标数据已被删除则无法回滚
* - update把旧值写回被修改的目标必须仍存在
* - 页面 remove还原被删节点被删节点的原父容器必须仍存在否则无处插回
* add回滚即删除即使目标已不在也已达成删除目的不视为失败
*
* 命中时弹出回滚失败提示并返回 true调用方据此中止本次回滚
*/
const isPageRevertTargetMissing = (index: number): boolean => {
const step = historyService.getPageStepList()[index]?.step;
if (!step) return false;
if (step.opType === 'update') {
return (step.diff ?? []).some((item) => {
const id = item.newSchema?.id ?? item.oldSchema?.id;
return id !== undefined && !editorService.getNodeById(id, false);
});
}
if (step.opType === 'remove') {
return (step.diff ?? []).some(
(item) => item.parentId !== undefined && !editorService.getNodeById(item.parentId, false),
);
}
return false;
};
/** 数据源 update 步骤回滚时,对应数据源必须仍存在(已删除则无处写回旧值)。 */
const isDataSourceRevertTargetMissing = (id: string | number, index: number): boolean => {
const step = historyService.getDataSourceStepList(id)[index]?.step;
return Boolean(step && step.opType === 'update' && !dataSourceService.getDataSourceById(`${id}`));
};
/** 代码块 update 步骤回滚时,对应代码块必须仍存在(已删除则无处写回旧值)。 */
const isCodeBlockRevertTargetMissing = (id: string | number, index: number): boolean => {
const step = historyService.getCodeBlockStepList(id)[index]?.step;
return Boolean(step && step.opType === 'update' && !codeBlockService.getCodeContentById(id));
};
/** 目标数据已被删除、无法回滚时的统一提示。 */
const showRevertTargetMissing = () => {
tMagicMessage.error('回滚失败:该记录对应的数据已被删除');
};
const onPageRevert = (index: number) => {
if (isPageRevertTargetMissing(index)) {
showRevertTargetMissing();
return Promise.resolve(null);
}
return runRevert(buildPageDiffPayload(index)).then((result) => (result ? editorService.revertPageStep(index) : null));
};
const onDataSourceRevert = (id: string | number, index: number) => {
if (isDataSourceRevertTargetMissing(id, index)) {
showRevertTargetMissing();
return Promise.resolve(null);
}
return runRevert(buildDataSourceDiffPayload(id, index)).then((result) =>
result ? dataSourceService.revert(id, index) : null,
);
};
const onCodeBlockRevert = (id: string | number, index: number) => {
if (isCodeBlockRevertTargetMissing(id, index)) {
showRevertTargetMissing();
return Promise.resolve(null);
}
return runRevert(buildCodeBlockDiffPayload(id, index)).then((result) =>
result ? codeBlockService.revert(id, index) : null,
);
};
/**
* 回滚二次确认新增 / 删除 / 多节点更新等无法做差异对比的步骤
* 不弹差异弹窗改用一个普通确认框替代确定回滚按钮避免点击后无任何提示直接执行
* 用户取消时返回 false调用方据此中止回滚
*/
const confirmRevert = (): Promise<boolean> =>
confirmDialog(
'确定回滚该步骤吗?回滚会将该操作作为一条新记录反向应用(新增将被删除、删除将被还原),不影响后续历史记录。',
);
/**
* 通用二次确认弹窗清空历史 / 无法差异对比的回滚等会改变状态的操作先弹出确认框
* 用户点击确定返回 true取消confirm reject时返回 false 并静默忽略
*/
const confirmDialog = async (message: string): Promise<boolean> => {
try {
await tMagicMessageBox.confirm(message, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
});
return true;
// eslint-disable-next-line no-unused-vars
} catch (e) {
return false;
}
};
/**
* 把内存中已清空对应类别后的历史状态重新写回 IndexedDB
* 使本地持久化的那份与内存保持一致连同本地保存的一并删除
* 不支持 IndexedDB 或写入失败时静默忽略内存清空已生效
*/
const syncIndexedDB = async () => {
try {
await historyService.saveToIndexedDB();
// eslint-disable-next-line no-unused-vars
} catch (e) {
// ignore:
}
};
const onPageClear = async () => {
if (
await confirmDialog('确定清空当前页面的历史记录吗?清空后将无法撤销/重做之前的操作,本地保存的记录也会一并删除。')
) {
historyService.clearPage();
await syncIndexedDB();
}
};
const onDataSourceClear = async () => {
if (
await confirmDialog('确定清空数据源的历史记录吗?清空后将无法撤销/重做之前的操作,本地保存的记录也会一并删除。')
) {
historyService.clearDataSource();
await syncIndexedDB();
}
};
const onCodeBlockClear = async () => {
if (
await confirmDialog('确定清空代码块的历史记录吗?清空后将无法撤销/重做之前的操作,本地保存的记录也会一并删除。')
) {
historyService.clearCodeBlock();
await syncIndexedDB();
}
};
</script>

View File

@ -0,0 +1,52 @@
<template>
<li
class="m-editor-history-list-item m-editor-history-list-initial"
:class="{ 'is-current': isCurrent, 'is-clickable': !isCurrent }"
:title="isCurrent ? '当前已回到未修改的初始状态' : '点击回到未修改的初始状态'"
>
<span class="m-editor-history-list-item-index" title="历史步骤编号 #0未修改的初始状态">#0</span>
<span class="m-editor-history-list-item-op op-initial">初始</span>
<span class="m-editor-history-list-item-desc">未修改的初始状态</span>
<span
v-if="gotoEnabled && !isCurrent"
class="m-editor-history-list-item-goto"
title="回到该记录"
@click.stop="onClick"
>回到</span
>
</li>
</template>
<script lang="ts" setup>
/**
* 初始状态记录行渲染于历史列表底部作为整个栈的"零点"
* - 点击该行会把对应栈撤销到 cursor === 0即没有任何已应用步骤等同于回到所有修改之前
* - 当对应栈本身已处于 cursor === 0 isCurrent=true用户已在初始状态点击不再触发动作
*
* 该行不是真实 step仅作为 UI 入口上层负责把"点击"翻译为 `service.goto*(0)`
*/
defineOptions({
name: 'MEditorHistoryListInitialRow',
});
const props = withDefaults(
defineProps<{
/** 当前对应栈是否已经处于初始状态 (cursor === 0)。true 时用蓝条高亮并禁用点击。 */
isCurrent: boolean;
gotoEnabled?: boolean;
}>(),
{
gotoEnabled: true,
},
);
const emit = defineEmits<{
/** 点击非当前的初始项时触发,由上层调用对应 service 的 goto 把 cursor 移到 0。 */
(_e: 'goto-initial'): void;
}>();
const onClick = () => {
if (props.isCurrent) return;
emit('goto-initial');
};
</script>

View File

@ -0,0 +1,102 @@
<template>
<div v-if="!list.length" class="m-editor-history-list-empty">暂无操作记录</div>
<template v-else>
<div class="m-editor-history-list-toolbar">
<span class="m-editor-history-list-clear" title="清空当前页面的历史记录" @click="$emit('clear')">清空</span>
</div>
<TMagicScrollbar max-height="360px">
<ul class="m-editor-history-list-ul">
<GroupRow
v-for="group in list"
:key="rowKey(group)"
:group="toRow(group)"
:expanded="!!expanded[rowKey(group)]"
@toggle="(key: string) => $emit('toggle', key)"
@goto="(index: number) => $emit('goto', index)"
@diff-step="(index: number) => $emit('diff-step', index)"
@revert-step="(index: number) => $emit('revert-step', index)"
/>
<!--
初始状态项永远位于列表底部页面 tab 倒序展示最底部=最早
作为"未修改"零点当所有 group 都未 applied 时它即为当前位置
-->
<InitialRow :is-current="isInitial" @goto-initial="$emit('goto-initial')" />
</ul>
</TMagicScrollbar>
</template>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { TMagicScrollbar } from '@tmagic/design';
import type { PageHistoryGroup, StepValue } from '@editor/type';
import type { HistoryRowDescriptor, HistoryRowGroup } from './composables';
import { describePageGroup, describePageStep, isPageStepRevertable, toRowGroup } from './composables';
import GroupRow from './GroupRow.vue';
import InitialRow from './InitialRow.vue';
defineOptions({
name: 'MEditorHistoryListPageTab',
});
const props = defineProps<{
/** 当前活动页面的历史分组列表,已按时间倒序排好(最新一组在最前)。空数组时显示空态。 */
list: PageHistoryGroup[];
/**
* 共享的折叠状态表key -> 是否展开由顶层 panel 统一维护
* tab 使用 `pg-${组内首步 index}` 作为 key以稳定的 step 索引而非展示位置标识分组
* 这样历史数据更新新增 / 撤销重做导致列表顺序变化已展开的分组状态仍能正确保持
*/
expanded: Record<string, boolean>;
}>();
defineEmits<{
/** 透传 GroupRow 的 toggle 事件给上层 panel由其更新 expanded。 */
(_e: 'toggle', _key: string): void;
/** 透传 GroupRow 的 goto 事件,携带目标 step 在栈中的索引。 */
(_e: 'goto', _index: number): void;
/** 用户点击初始项希望回到未修改的状态cursor=0。 */
(_e: 'goto-initial'): void;
/** 用户点击"查看差异"按钮,携带目标 step 在栈中的索引。 */
(_e: 'diff-step', _index: number): void;
/** 用户点击"回滚"按钮,携带目标 step 在栈中的索引,类 git revert。 */
(_e: 'revert-step', _index: number): void;
/** 用户点击"清空"按钮,请求清空当前页面的历史记录(由上层弹窗二次确认后执行)。 */
(_e: 'clear'): void;
}>();
/**
* 当前 step 是否可查看差异
* - update 操作
* - 单节点更新diff.length === 1 oldSchema / newSchema 都存在
* 多节点更新难以选定单一对比目标统一不展示差异入口
*/
const isPageStepDiffable = (step: StepValue): boolean => {
if (step.opType !== 'update') return false;
const items = step.diff ?? [];
if (items.length !== 1) return false;
return Boolean(items[0]?.oldSchema && items[0]?.newSchema);
};
/** 页面历史的描述 / 可操作性判定集合,注入给统一的 `toRowGroup`。 */
const descriptor: HistoryRowDescriptor<StepValue> = {
describeGroup: describePageGroup,
describeStep: describePageStep,
isStepDiffable: isPageStepDiffable,
isStepRevertable: isPageStepRevertable,
};
const rowKey = (group: PageHistoryGroup) => `pg-${group.steps[0]?.index}`;
const toRow = (group: PageHistoryGroup): HistoryRowGroup => toRowGroup(group, rowKey(group), descriptor);
/**
* 是否处于"初始状态"即对应页面历史栈 cursor===0
* list 中所有 group applied 都为 false 时即为该状态
* 没有任何 group 的情况由外层"暂无操作记录"分支兜底本计算可以不考虑
*/
const isInitial = computed(() => props.list.length > 0 && props.list.every((g) => !g.applied));
</script>

View File

@ -0,0 +1,430 @@
import { computed, reactive } from 'vue';
import { datetimeFormatter } from '@tmagic/form';
import { useServices } from '@editor/hooks/use-services';
import type {
BaseStepValue,
CodeBlockHistoryGroup,
CodeBlockStepValue,
DataSourceHistoryGroup,
DataSourceStepValue,
HistoryOpSource,
HistoryOpType,
PageHistoryGroup,
StepValue,
} from '@editor/type';
/**
* bucket /
* Bucket / BucketTab step T {@link BaseStepValue}
*/
export interface HistoryBucketGroup<T extends BaseStepValue = BaseStepValue> {
/** 组内最后一步是否已应用 */
applied: boolean;
/** 是否为当前所在的分组 */
isCurrent?: boolean;
/** 该分组的操作类型 */
opType: HistoryOpType;
/** 组内所有步骤 */
steps: { index: number; applied: boolean; isCurrent?: boolean; step: T }[];
}
/**
* + / /
* describe* / isStep* props
*/
export interface HistoryRowDescriptor<T extends BaseStepValue = BaseStepValue> {
/** 组级描述文案生成器,接收一个 group返回展示文本。 */
describeGroup: (_group: any) => string;
/** 单步描述文案生成器,接收一个 step返回展示文本合并组展开后的子步列表用。 */
describeStep: (_step: T) => string;
/** 判断某个 step 是否可查看差异(前后值都存在)。不传则一律不展示差异入口。 */
isStepDiffable?: (_step: T) => boolean;
/** 判断某个 step 是否支持回滚(如更新需带 changeRecords。不传则已应用即可回滚。 */
isStepRevertable?: (_step: T) => boolean;
}
/**
* bucket / /
* Bucket / BucketTab title / prefix / describe* / isStep* / showInitial / gotoEnabled
* prop
*/
export interface HistoryBucketConfig<T extends BaseStepValue = BaseStepValue> extends HistoryRowDescriptor<T> {
/** bucket 头部标题,例如 "数据源" / "代码块"。 */
title: string;
/** 子项 key 的命名空间前缀(`ds` 数据源 / `cb` 代码块 / 业务自定义如 `mod`)。 */
prefix: string;
/** 是否展示底部「回到初始状态」入口,默认 true。无 undo cursor 语义的自定义历史可传 false。 */
showInitial?: boolean;
/** 是否支持「跳转到该记录」(goto),默认 true。 */
gotoEnabled?: boolean;
}
/** GroupRow 渲染所需的单个子步视图模型(已由 {@link toRowGroup} 预先派生,组件内部不再触碰原始 step。 */
export interface HistoryRowStep {
/** 该子步在所属栈中的稳定索引。 */
index: number;
/** 是否已应用false 表示已被 undoUI 灰态)。 */
applied: boolean;
/** 是否为当前所在步骤。 */
isCurrent?: boolean;
/** 是否为最近一次保存的记录。 */
saved?: boolean;
/** 子步描述文案。 */
desc: string;
/** 是否可查看差异。 */
diffable?: boolean;
/** 是否可回滚。 */
revertable?: boolean;
/** 操作途径。 */
source?: HistoryOpSource;
/** 时间文案。 */
time?: string;
/** 时间的完整 title 提示。 */
timeTitle?: string;
}
/**
* GroupRow {@link toRowGroup}
* GroupRow props header
*/
export interface HistoryRowGroup {
/** 分组的稳定 key作为 toggle 事件 payload 与折叠状态的索引。 */
key: string;
/** 组内最后一步是否已应用。 */
applied: boolean;
/** 是否为当前所在分组。 */
isCurrent: boolean;
/** 操作类型,用于徽标颜色与文案。 */
opType: HistoryOpType;
/** 组整体描述文案。 */
desc: string;
/** 组的操作途径(取组内最近一步)。 */
source?: HistoryOpSource;
/** 组头部时间文案(取组内最近一步)。 */
time?: string;
/** 组头部时间的完整 title 提示。 */
timeTitle?: string;
/** 子步列表时间正序其长度即合并步数length > 1 即为合并组。 */
subSteps: HistoryRowStep[];
}
/**
*
* - / /
* -
* -
*
* historyService reactive state
*/
export const useHistoryList = () => {
const { historyService } = useServices();
/**
* key `pg-${ index}` / `ds-${id}-${ index}` / `cb-${id}-${ index}`
* index key
*/
const expanded = reactive<Record<string, boolean>>({});
const toggleGroup = (key: string) => {
expanded[key] = !expanded[key];
};
const pageGroups = computed(() => historyService.getPageHistoryGroups());
const dataSourceGroups = computed(() => historyService.getDataSourceHistoryGroups());
const codeBlockGroups = computed(() => historyService.getCodeBlockHistoryGroups());
/** 页面 tab 倒序展示(最新一组在最上面)。 */
const pageGroupsDisplay = computed(() => pageGroups.value.slice().reverse());
/**
* group id bucket id
* bucket
*/
const groupByTarget = <G extends { id: string | number }>(groups: G[]) => {
const map = new Map<string | number, G[]>();
groups.forEach((g) => {
const list = map.get(g.id) ?? [];
list.push(g);
map.set(g.id, list);
});
return Array.from(map.entries()).map(([id, gs]) => ({ id, groups: gs.slice().reverse() }));
};
const dataSourceGroupsByTarget = computed(() => groupByTarget(dataSourceGroups.value));
const codeBlockGroupsByTarget = computed(() => groupByTarget(codeBlockGroups.value));
return {
expanded,
toggleGroup,
pageGroups,
dataSourceGroups,
codeBlockGroups,
pageGroupsDisplay,
dataSourceGroupsByTarget,
codeBlockGroupsByTarget,
};
};
/**
*
* - `HH:mm:ss`
* - `MM-DD HH:mm:ss`
* / UI
*/
export const formatHistoryTime = (timestamp?: number): string => {
if (!timestamp) return '';
const isToday =
datetimeFormatter(new Date(timestamp), '', 'YYYY-MM-DD') ===
(datetimeFormatter(new Date(), '', 'YYYY-MM-DD') as string);
return `${
isToday
? datetimeFormatter(new Date(timestamp), '', 'HH:mm:ss')
: datetimeFormatter(new Date(timestamp), '', 'MM-DD HH:mm:ss')
}`;
};
/** 完整时间(含年份与秒),用于 title 悬浮提示。无时间戳时返回空串。 */
export const formatHistoryFullTime = (timestamp?: number): string =>
timestamp ? `${datetimeFormatter(new Date(timestamp), '', 'YYYY-MM-DD HH:mm:ss')}` : '';
/** 取一组历史步骤里最后一步(最近一次)的时间戳,用于组头部展示。 */
export const groupTimestamp = (group: { steps: { step: { timestamp?: number } }[] }): number | undefined =>
group.steps[group.steps.length - 1]?.step.timestamp;
export const opLabel = (op: HistoryOpType) => {
switch (op) {
case 'add':
return '新增';
case 'remove':
return '删除';
case 'update':
default:
return '修改';
}
};
/** 内置操作途径的中文文案;自定义来源直接回显原值,未知 / 缺省返回空串UI 据此不渲染)。 */
const HISTORY_SOURCE_LABELS: Record<string, string> = {
stage: '画布',
tree: '树面板',
'component-panel': '组件面板',
props: '配置面板',
code: '源码',
'stage-contextmenu': '画布菜单',
'tree-contextmenu': '树菜单',
toolbar: '工具栏',
shortcut: '快捷键',
rollback: '回滚',
api: '接口',
ai: 'AI',
unknown: '未知',
};
/** 操作途径文案:用于历史面板展示「画布 / 树面板 / 配置面板…」标签。 */
export const sourceLabel = (source: HistoryOpSource = 'unknown'): string => {
return HISTORY_SOURCE_LABELS[source] ?? `${source}`;
};
/** 取一组历史步骤里最后一步(最近一次)的操作途径,用于组头部展示。 */
export const groupSource = (group: { steps: { step: { source?: HistoryOpSource } }[] }): HistoryOpSource | undefined =>
group.steps[group.steps.length - 1]?.step.source;
/** {@link toRowGroup} 接受的最小分组结构PageHistoryGroup 与 HistoryBucketGroup 均满足。 */
interface RowGroupInput<T extends BaseStepValue = BaseStepValue> {
applied: boolean;
isCurrent?: boolean;
opType: HistoryOpType;
steps: { index: number; applied: boolean; isCurrent?: boolean; step: T }[];
}
/**
* / bucket GroupRow {@link HistoryRowGroup}
* PageTab / Bucket sub-steps
*
*/
export const toRowGroup = <T extends BaseStepValue = BaseStepValue>(
group: RowGroupInput<T>,
key: string,
descriptor: HistoryRowDescriptor<T>,
): HistoryRowGroup => {
const { describeGroup, describeStep, isStepDiffable, isStepRevertable } = descriptor;
const timestamp = groupTimestamp(group);
return {
key,
applied: group.applied,
isCurrent: Boolean(group.isCurrent),
opType: group.opType,
desc: describeGroup(group),
source: groupSource(group),
time: formatHistoryTime(timestamp),
timeTitle: formatHistoryFullTime(timestamp),
subSteps: group.steps.map((s) => ({
index: s.index,
applied: s.applied,
isCurrent: s.isCurrent,
saved: s.step.saved,
desc: describeStep(s.step),
diffable: isStepDiffable ? isStepDiffable(s.step) : false,
revertable: s.applied && (isStepRevertable ? isStepRevertable(s.step) : true),
source: s.step.source,
time: formatHistoryTime(s.step.timestamp),
timeTitle: formatHistoryFullTime(s.step.timestamp),
})),
};
};
const nameOf = (node?: { name?: string; id?: string | number; type?: string }) =>
node?.name || node?.type || `${node?.id ?? ''}`;
/**
* (id: xxx)便
* - id label id name/type/title label123 (id: 123)
*/
const labelWithId = (label: string | number | undefined, id: string | number | undefined): string => {
const labelStr = label === undefined || label === null ? '' : `${label}`;
if (id === undefined || id === null || id === '') return labelStr;
if (labelStr === '' || labelStr === `${id}`) return `${id}`;
return `${labelStr} (id: ${id})`;
};
/** 从一组可选 historyDescription 中取最后一条非空值;都为空时返回 undefined。 */
const pickLastDescription = (descs: (string | undefined)[]): string | undefined => {
for (let i = descs.length - 1; i >= 0; i--) {
if (descs[i]) return descs[i];
}
return undefined;
};
export const describePageStep = (step: StepValue) => {
if (step.historyDescription) return step.historyDescription;
const { opType } = step;
const items = step.diff ?? [];
if (opType === 'add') {
const count = items.length;
const node = items[0]?.newSchema;
return `新增 ${count} 个节点${count === 1 && node ? `${labelWithId(nameOf(node), node.id)}` : ''}`;
}
if (opType === 'remove') {
const count = items.length;
const node = items[0]?.oldSchema;
return `删除 ${count} 个节点${count === 1 && node ? `${labelWithId(nameOf(node), node.id)}` : ''}`;
}
if (!items.length) return '修改节点';
if (items.length === 1) {
const { newSchema, changeRecords } = items[0];
const propPath = changeRecords?.[0]?.propPath;
const target = labelWithId(nameOf(newSchema), newSchema?.id);
return `修改 ${target}${propPath ? ` · ${propPath}` : ''}`;
}
return `修改 ${items.length} 个节点`;
};
/**
*
* - historyDescription historyDescription
* - describePageStep
* - + propPath
*/
export const describePageGroup = (group: PageHistoryGroup) => {
const lastDesc = pickLastDescription(group.steps.map((s) => s.step.historyDescription));
if (lastDesc) return lastDesc;
if (group.steps.length === 1) return describePageStep(group.steps[0].step);
const paths = new Set<string>();
group.steps.forEach((s) => {
s.step.diff?.[0]?.changeRecords?.forEach((r) => r.propPath && paths.add(r.propPath));
});
const pathList = Array.from(paths).slice(0, 3).join(', ');
const target = labelWithId(
group.targetName ?? (group.targetId !== undefined ? `${group.targetId}` : '节点'),
group.targetId,
);
return pathList ? `修改 ${target} · ${pathList}${paths.size > 3 ? '…' : ''}` : `修改 ${target}`;
};
export const describeDataSourceStep = (step: DataSourceStepValue) => {
if (step.historyDescription) return step.historyDescription;
const { oldSchema: oldSchema, newSchema: newSchema, changeRecords } = step.diff?.[0] ?? {};
if (!oldSchema && newSchema) return `创建 ${labelWithId(newSchema.title, newSchema.id ?? step.id)}`;
if (!newSchema && oldSchema) return `删除 ${labelWithId(oldSchema.title, oldSchema.id ?? step.id)}`;
const propPath = changeRecords?.[0]?.propPath;
const title = labelWithId(newSchema?.title || oldSchema?.title, step.id);
return propPath ? `修改 ${title} · ${propPath}` : `修改 ${title}`;
};
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.diff?.[0]?.changeRecords?.forEach((r) => r.propPath && paths.add(r.propPath));
});
const pathList = Array.from(paths).slice(0, 3).join(', ');
const rawTitle =
group.steps[group.steps.length - 1].step.diff?.[0]?.newSchema?.title ||
group.steps[0].step.diff?.[0]?.oldSchema?.title;
const target = labelWithId(rawTitle, group.id);
return pathList ? `修改 ${target} · ${pathList}${paths.size > 3 ? '…' : ''}` : `修改 ${target}`;
};
export const describeCodeBlockStep = (step: CodeBlockStepValue) => {
if (step.historyDescription) return step.historyDescription;
const { oldSchema: oldContent, newSchema: newContent, changeRecords } = step.diff?.[0] ?? {};
if (!oldContent && newContent) return `创建 ${labelWithId(newContent.name, newContent.id ?? step.id)}`;
if (!newContent && oldContent) return `删除 ${labelWithId(oldContent.name, oldContent.id ?? step.id)}`;
const propPath = changeRecords?.[0]?.propPath;
const title = labelWithId(newContent?.name || oldContent?.name, step.id);
return propPath ? `修改 ${title} · ${propPath}` : `修改 ${title}`;
};
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.diff?.[0]?.changeRecords?.forEach((r) => r.propPath && paths.add(r.propPath));
});
const pathList = Array.from(paths).slice(0, 3).join(', ');
const rawName =
group.steps[group.steps.length - 1].step.diff?.[0]?.newSchema?.name ||
group.steps[0].step.diff?.[0]?.oldSchema?.name;
const target = labelWithId(rawName, group.id);
return pathList ? `修改 ${target} · ${pathList}${paths.size > 3 ? '…' : ''}` : `修改 ${target}`;
};
/**
* step git revert
* - / changeRecords /
* - changeRecords propPath patch
* changeRecords
*/
export const isPageStepRevertable = (step: StepValue): boolean => {
if (step.opType !== 'update') return true;
const items = step.diff ?? [];
if (!items.length) return false;
return items.every((item) => Boolean(item.changeRecords?.length));
};
/**
* step
* - oldSchema/ newSchema changeRecords
* - schema changeRecords patch
*/
export const isDataSourceStepRevertable = (step: DataSourceStepValue): boolean => {
const item = step.diff?.[0];
if (!item?.oldSchema || !item?.newSchema) return true;
return Boolean(item.changeRecords?.length);
};
/**
* step
* - oldSchema/ newSchema changeRecords
* - content changeRecords patch
*/
export const isCodeBlockStepRevertable = (step: CodeBlockStepValue): boolean => {
const item = step.diff?.[0];
if (!item?.oldSchema || !item?.newSchema) return true;
return Boolean(item.changeRecords?.length);
};

View File

@ -5,7 +5,7 @@
<TMagicScrollbar> <TMagicScrollbar>
<MForm <MForm
ref="configForm" ref="configForm"
:class="propsPanelSize" :class="[propsPanelSize, 'm-editor-props-form-panel-form']"
:popper-class="`m-editor-props-panel-popper ${propsPanelSize}`" :popper-class="`m-editor-props-panel-popper ${propsPanelSize}`"
:label-width="labelWidth" :label-width="labelWidth"
:label-position="labelPosition" :label-position="labelPosition"

View File

@ -151,7 +151,11 @@ const submit = async (v: MNode, eventData?: ContainerChangeEventData) => {
}); });
} }
editorService.update(newValue, { changeRecords: eventData?.changeRecords }); // MForm @change eventData changeRecords
// CodeEditor @save saveCode eventData
const historySource = eventData ? 'props' : 'code';
editorService.update(newValue, { changeRecords: eventData?.changeRecords, historySource });
} catch (e: any) { } catch (e: any) {
emit('submit-error', e); emit('submit-error', e);
} }

View File

@ -94,11 +94,15 @@ let clientX: number;
let clientY: number; let clientY: number;
const appendComponent = ({ text, type, data = {} }: ComponentItem): void => { const appendComponent = ({ text, type, data = {} }: ComponentItem): void => {
editorService.add({ editorService.add(
name: text, {
type, name: text,
...data, type,
}); ...data,
},
undefined,
{ historySource: 'component-panel' },
);
}; };
const dragstartHandler = ({ text, type, data = {} }: ComponentItem, e: DragEvent) => { const dragstartHandler = ({ text, type, data = {} }: ComponentItem, e: DragEvent) => {

View File

@ -24,7 +24,7 @@
<Icon :icon="editable ? Edit : View" class="edit-icon" @click.stop="editCode(`${data.key}`)"></Icon> <Icon :icon="editable ? Edit : View" class="edit-icon" @click.stop="editCode(`${data.key}`)"></Icon>
</TMagicTooltip> </TMagicTooltip>
<TMagicTooltip v-if="data.type === 'code' && editable" effect="dark" content="删除" placement="bottom"> <TMagicTooltip v-if="data.type === 'code' && editable" effect="dark" content="删除" placement="bottom">
<Icon :icon="Close" class="edit-icon" @click.stop="deleteCode(`${data.key}`)"></Icon> <Icon :icon="Close" class="edit-icon" @click.stop="deleteCode(`${data.key}`, { historySource: 'tree' })"></Icon>
</TMagicTooltip> </TMagicTooltip>
<slot name="code-block-panel-tool" :id="data.key" :data="data"></slot> <slot name="code-block-panel-tool" :id="data.key" :data="data"></slot>
</template> </template>
@ -44,7 +44,7 @@ import Tree from '@editor/components/Tree.vue';
import { useFilter } from '@editor/hooks/use-filter'; import { useFilter } from '@editor/hooks/use-filter';
import { useNodeStatus } from '@editor/hooks/use-node-status'; import { useNodeStatus } from '@editor/hooks/use-node-status';
import { useServices } from '@editor/hooks/use-services'; import { useServices } from '@editor/hooks/use-services';
import { type CodeBlockListSlots, CodeDeleteErrorType, type TreeNodeData } from '@editor/type'; import { type CodeBlockListSlots, CodeDeleteErrorType, HistoryOpSource, type TreeNodeData } from '@editor/type';
defineSlots<CodeBlockListSlots>(); defineSlots<CodeBlockListSlots>();
@ -60,7 +60,7 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
edit: [id: string]; edit: [id: string];
remove: [id: string]; remove: [id: string, { historySource?: HistoryOpSource }];
'node-contextmenu': [event: MouseEvent, data: TreeNodeData]; 'node-contextmenu': [event: MouseEvent, data: TreeNodeData];
}>(); }>();
@ -142,7 +142,7 @@ const editCode = (id: string) => {
emit('edit', id); emit('edit', id);
}; };
const deleteCode = async (id: string) => { const deleteCode = async (id: string, { historySource }: { historySource?: HistoryOpSource } = {}) => {
const currentCode = codeList.value.find((codeItem) => codeItem.id === id); const currentCode = codeList.value.find((codeItem) => codeItem.id === id);
const existBinds = Boolean(currentCode?.items?.length); const existBinds = Boolean(currentCode?.items?.length);
const undeleteableList = codeBlockService.getUndeletableList() || []; const undeleteableList = codeBlockService.getUndeletableList() || [];
@ -154,7 +154,7 @@ const deleteCode = async (id: string) => {
}); });
// //
emit('remove', id); emit('remove', id, { historySource });
} else { } else {
if (typeof props.customError === 'function') { if (typeof props.customError === 'function') {
props.customError(id, existBinds ? CodeDeleteErrorType.BIND : CodeDeleteErrorType.UNDELETEABLE); props.customError(id, existBinds ? CodeDeleteErrorType.BIND : CodeDeleteErrorType.UNDELETEABLE);

View File

@ -122,7 +122,7 @@ const {
menuData: contentMenuData, menuData: contentMenuData,
contentMenuHideHandler, contentMenuHideHandler,
} = useContentMenu((id: string) => { } = useContentMenu((id: string) => {
codeBlockListRef.value?.deleteCode(id); codeBlockListRef.value?.deleteCode(id, { historySource: 'tree-contextmenu' });
}); });
const menuData = computed<(MenuButton | MenuComponent)[]>(() => props.customContentMenu(contentMenuData, 'code-block')); const menuData = computed<(MenuButton | MenuComponent)[]>(() => props.customContentMenu(contentMenuData, 'code-block'));
</script> </script>

View File

@ -41,7 +41,7 @@ export const useContentMenu = (deleteCode: (id: string) => void) => {
const newCodeId = await codeBlockService.getUniqueId(); const newCodeId = await codeBlockService.getUniqueId();
codeBlockService.setCodeDslById(newCodeId, cloneDeep(codeBlock)); codeBlockService.setCodeDslById(newCodeId, cloneDeep(codeBlock), { historySource: 'tree-contextmenu' });
}, },
}, },
{ {

View File

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

View File

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

View File

@ -41,11 +41,15 @@ const createMenuItems = (group: ComponentGroup): MenuButton[] =>
type: 'button', type: 'button',
icon: component.icon, icon: component.icon,
handler: () => { handler: () => {
editorService.add({ editorService.add(
name: component.text, {
type: component.type, name: component.text,
...(component.data || {}), type: component.type,
}); ...(component.data || {}),
},
undefined,
{ historySource: 'tree-contextmenu' },
);
}, },
})); }));
@ -57,9 +61,13 @@ const getSubMenuData = computed<MenuButton[]>(() => {
type: 'button', type: 'button',
icon: Files, icon: Files,
handler: () => { handler: () => {
editorService.add({ editorService.add(
type: 'tab-pane', {
}); type: 'tab-pane',
},
undefined,
{ historySource: 'tree-contextmenu' },
);
}, },
}, },
]; ];
@ -106,9 +114,9 @@ const menuData = computed<(MenuButton | MenuComponent)[]>(() =>
items: getSubMenuData.value, items: getSubMenuData.value,
}, },
useCopyMenu(), useCopyMenu(),
usePasteMenu(), usePasteMenu('tree-contextmenu'),
useDeleteMenu(), useDeleteMenu('tree-contextmenu'),
useMoveToMenu(services), useMoveToMenu(services, 'tree-contextmenu'),
...props.layerContentMenu, ...props.layerContentMenu,
], ],
'layer', 'layer',

View File

@ -25,9 +25,12 @@ const props = defineProps<{
const { editorService } = useServices(); const { editorService } = useServices();
const setNodeVisible = (visible: boolean) => { const setNodeVisible = (visible: boolean) => {
editorService.update({ editorService.update(
id: props.data.id, {
visible, id: props.data.id,
}); visible,
},
{ historySource: 'tree' },
);
}; };
</script> </script>

View File

@ -1,6 +1,6 @@
import { computed, onBeforeUnmount, ref, watch } from 'vue'; import { computed, onBeforeUnmount, ref, watch } from 'vue';
import type { Id, MNode, MPage, MPageFragment } from '@tmagic/core'; import type { Id, MApp, MNode, MPage, MPageFragment } from '@tmagic/core';
import { getNodePath, isPage, isPageFragment, traverseNode } from '@tmagic/utils'; import { getNodePath, isPage, isPageFragment, traverseNode } from '@tmagic/utils';
import type { LayerNodeStatus, Services } from '@editor/type'; import type { LayerNodeStatus, Services } from '@editor/type';
@ -9,12 +9,15 @@ import { updateStatus } from '@editor/utils/tree';
const createPageNodeStatus = (page: MPage | MPageFragment, initialLayerNodeStatus?: Map<Id, LayerNodeStatus>) => { const createPageNodeStatus = (page: MPage | MPageFragment, initialLayerNodeStatus?: Map<Id, LayerNodeStatus>) => {
const map = new Map<Id, LayerNodeStatus>(); const map = new Map<Id, LayerNodeStatus>();
map.set(page.id, { map.set(
visible: true, page.id,
expand: true, initialLayerNodeStatus?.get(page.id) || {
selected: true, visible: true,
draggable: false, expand: true,
}); selected: true,
draggable: false,
},
);
page.items.forEach((node: MNode) => page.items.forEach((node: MNode) =>
traverseNode<MNode>(node, (node) => { traverseNode<MNode>(node, (node) => {
@ -45,22 +48,70 @@ export const useNodeStatus = ({ editorService }: Services) => {
page.value ? nodeStatusMaps.value.get(page.value.id) : new Map<Id, LayerNodeStatus>(), page.value ? nodeStatusMaps.value.get(page.value.id) : new Map<Id, LayerNodeStatus>(),
); );
// 切换页面或者新增页面,重新生成节点状态 // 切换页面 / 新增页面 / 整体替换 dsl 后 page 引用变化时,重新生成节点状态。
//
// 注意这里 watch 的是 page 引用而不是 page.id
// 历史版本恢复 / 外部 modelValue 整体覆盖等场景,新旧 dsl 的 page.id 通常完全
// 一致,但 page 对象引用是新的、items 也是新的。仅监听 id 会漏掉这类「同 id
// 不同内容」的替换,导致 nodeStatusMaps 残留旧节点 status组件树渲染滞留在
// 旧版本。监听引用可以覆盖普通切页(不同 id和整体替换同 id 新引用)两种
// 情况;同时配合下方 root-change 时清空缓存,避免拿到污染的 initial status。
watch( watch(
() => page.value?.id, page,
(pageId) => { (newPage) => {
if (!pageId) { if (!newPage?.id) {
return; return;
} }
// 生成节点状态 // 生成节点状态
nodeStatusMaps.value.set(pageId, createPageNodeStatus(page.value!, nodeStatusMaps.value.get(pageId))); nodeStatusMaps.value.set(newPage.id, createPageNodeStatus(newPage, nodeStatusMaps.value.get(newPage.id)));
}, },
{ {
immediate: true, immediate: true,
}, },
); );
/**
* root modelValue /退
* - watch page root-change page
* initService IIFE editorService.select(...) page
* dsl page page watch
* - nodeStatusMaps nodeStatusMap (computed)
* undefined LayerPanel `v-if="page && nodeStatusMap"`
* select 退
* page / watch(page)
*
* - createPageNodeStatus status
* initial root page watch(page)
* page.items map id id 便
* initialLayerNodeStatus map page
* id dsl id
* id uuid
*
* root-change root page id
* page status v-if page
* page id watch(page)
*/
const rootChangeHandler = (value: MApp | null) => {
if (!value) {
nodeStatusMaps.value = new Map();
return;
}
const validPageIds = new Set<Id>();
(value.items || []).forEach((p) => {
if (p?.id !== undefined) validPageIds.add(p.id);
});
for (const cachedPageId of Array.from(nodeStatusMaps.value.keys())) {
if (!validPageIds.has(cachedPageId)) {
nodeStatusMaps.value.delete(cachedPageId);
}
}
};
editorService.on('root-change', rootChangeHandler);
// 选中状态变化,更新节点状态 // 选中状态变化,更新节点状态
watch( watch(
nodes, nodes,
@ -111,6 +162,7 @@ export const useNodeStatus = ({ editorService }: Services) => {
editorService.on('remove', removeHandler); editorService.on('remove', removeHandler);
onBeforeUnmount(() => { onBeforeUnmount(() => {
editorService.off('root-change', rootChangeHandler);
editorService.off('remove', removeHandler); editorService.off('remove', removeHandler);
editorService.off('add', addHandler); editorService.off('add', addHandler);
}); });

View File

@ -380,7 +380,7 @@ const dropHandler = async (e: DragEvent) => {
config.data.inputEvent = e; config.data.inputEvent = e;
editorService.add(config.data, parent); editorService.add(config.data, parent, { historySource: 'component-panel' });
} }
}; };
</script> </script>

View File

@ -49,11 +49,11 @@ const menuData = computed<(MenuButton | MenuComponent)[]>(() =>
display: () => canCenter.value, display: () => canCenter.value,
handler: () => { handler: () => {
if (!nodes.value) return; if (!nodes.value) return;
editorService.alignCenter(nodes.value); editorService.alignCenter(nodes.value, { historySource: 'stage-contextmenu' });
}, },
}, },
useCopyMenu(), useCopyMenu(),
usePasteMenu(menuRef), usePasteMenu('stage-contextmenu', menuRef),
{ {
type: 'divider', type: 'divider',
direction: 'horizontal', direction: 'horizontal',
@ -68,7 +68,7 @@ const menuData = computed<(MenuButton | MenuComponent)[]>(() =>
icon: markRaw(Top), icon: markRaw(Top),
display: () => !isPage(node.value) && !isPageFragment(node.value) && !props.isMultiSelect, display: () => !isPage(node.value) && !isPageFragment(node.value) && !props.isMultiSelect,
handler: () => { handler: () => {
editorService.moveLayer(1); editorService.moveLayer(1, { historySource: 'stage-contextmenu' });
}, },
}, },
{ {
@ -77,7 +77,7 @@ const menuData = computed<(MenuButton | MenuComponent)[]>(() =>
icon: markRaw(Bottom), icon: markRaw(Bottom),
display: () => !isPage(node.value) && !isPageFragment(node.value) && !props.isMultiSelect, display: () => !isPage(node.value) && !isPageFragment(node.value) && !props.isMultiSelect,
handler: () => { handler: () => {
editorService.moveLayer(-1); editorService.moveLayer(-1, { historySource: 'stage-contextmenu' });
}, },
}, },
{ {
@ -86,7 +86,7 @@ const menuData = computed<(MenuButton | MenuComponent)[]>(() =>
icon: markRaw(Top), icon: markRaw(Top),
display: () => !isPage(node.value) && !isPageFragment(node.value) && !props.isMultiSelect, display: () => !isPage(node.value) && !isPageFragment(node.value) && !props.isMultiSelect,
handler: () => { handler: () => {
editorService.moveLayer(LayerOffset.TOP); editorService.moveLayer(LayerOffset.TOP, { historySource: 'stage-contextmenu' });
}, },
}, },
{ {
@ -95,16 +95,16 @@ const menuData = computed<(MenuButton | MenuComponent)[]>(() =>
icon: markRaw(Bottom), icon: markRaw(Bottom),
display: () => !isPage(node.value) && !isPageFragment(node.value) && !props.isMultiSelect, display: () => !isPage(node.value) && !isPageFragment(node.value) && !props.isMultiSelect,
handler: () => { handler: () => {
editorService.moveLayer(LayerOffset.BOTTOM); editorService.moveLayer(LayerOffset.BOTTOM, { historySource: 'stage-contextmenu' });
}, },
}, },
useMoveToMenu(services), useMoveToMenu(services, 'stage-contextmenu'),
{ {
type: 'divider', type: 'divider',
direction: 'horizontal', direction: 'horizontal',
display: () => !isPage(node.value) && !isPageFragment(node.value) && !props.isMultiSelect, display: () => !isPage(node.value) && !isPageFragment(node.value) && !props.isMultiSelect,
}, },
useDeleteMenu(), useDeleteMenu('stage-contextmenu'),
{ {
type: 'divider', type: 'divider',
direction: 'horizontal', direction: 'horizontal',

View File

@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/no-misused-promises */
/* /*
* Tencent is pleased to support the open source community by making TMagicEditor available. * Tencent is pleased to support the open source community by making TMagicEditor available.
* *
@ -19,45 +18,32 @@
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
import { compose } from '@editor/utils/compose';
const methodName = (prefix: string, name: string) => `${prefix}${name[0].toUpperCase()}${name.substring(1)}`; const methodName = (prefix: string, name: string) => `${prefix}${name[0].toUpperCase()}${name.substring(1)}`;
const isError = (error: any): boolean => Object.prototype.toString.call(error) === '[object Error]'; const isError = (error: any): boolean => Object.prototype.toString.call(error) === '[object Error]';
const doAction = ( const doAction = (args: any[], scope: any, sourceMethod: any, beforeMethodName: string, afterMethodName: string) => {
args: any[], let beforeArgs = args;
scope: any,
sourceMethod: any,
beforeMethodName: string,
afterMethodName: string,
fn: (args: any[], next?: Function | undefined) => void,
) => {
try {
let beforeArgs = args;
for (const beforeMethod of scope.pluginOptionsList[beforeMethodName]) { for (const beforeMethod of scope.pluginOptionsList[beforeMethodName]) {
beforeArgs = beforeMethod(...beforeArgs) || []; beforeArgs = beforeMethod(...beforeArgs) || [];
if (isError(beforeArgs)) throw beforeArgs; if (isError(beforeArgs)) throw beforeArgs;
if (!Array.isArray(beforeArgs)) { if (!Array.isArray(beforeArgs)) {
beforeArgs = [beforeArgs]; beforeArgs = [beforeArgs];
}
} }
let returnValue: any = fn(beforeArgs, sourceMethod.bind(scope));
for (const afterMethod of scope.pluginOptionsList[afterMethodName]) {
returnValue = afterMethod(returnValue, ...beforeArgs);
if (isError(returnValue)) throw returnValue;
}
return returnValue;
} catch (error) {
throw error;
} }
let returnValue: any = sourceMethod.apply(scope, beforeArgs);
for (const afterMethod of scope.pluginOptionsList[afterMethodName]) {
returnValue = afterMethod(returnValue, ...beforeArgs);
if (isError(returnValue)) throw returnValue;
}
return returnValue;
}; };
const doAsyncAction = async ( const doAsyncAction = async (
@ -66,40 +52,34 @@ const doAsyncAction = async (
sourceMethod: any, sourceMethod: any,
beforeMethodName: string, beforeMethodName: string,
afterMethodName: string, afterMethodName: string,
fn: (args: any[], next?: Function | undefined) => Promise<void> | void,
) => { ) => {
try { let beforeArgs = args;
let beforeArgs = args;
for (const beforeMethod of scope.pluginOptionsList[beforeMethodName]) { for (const beforeMethod of scope.pluginOptionsList[beforeMethodName]) {
beforeArgs = (await beforeMethod(...beforeArgs)) || []; beforeArgs = (await beforeMethod(...beforeArgs)) || [];
if (isError(beforeArgs)) throw beforeArgs; if (isError(beforeArgs)) throw beforeArgs;
if (!Array.isArray(beforeArgs)) { if (!Array.isArray(beforeArgs)) {
beforeArgs = [beforeArgs]; beforeArgs = [beforeArgs];
}
} }
let returnValue: any = await fn(beforeArgs, sourceMethod.bind(scope));
for (const afterMethod of scope.pluginOptionsList[afterMethodName]) {
returnValue = await afterMethod(returnValue, ...beforeArgs);
if (isError(returnValue)) throw returnValue;
}
return returnValue;
} catch (error) {
throw error;
} }
let returnValue: any = await sourceMethod.apply(scope, beforeArgs);
for (const afterMethod of scope.pluginOptionsList[afterMethodName]) {
returnValue = await afterMethod(returnValue, ...beforeArgs);
if (isError(returnValue)) throw returnValue;
}
return returnValue;
}; };
/** /**
* Class进行扩展 * Class进行扩展
* 1
* Class中的每个方法都添加before after两个钩子 * Class中的每个方法都添加before after两个钩子
* Class添加一个usePlugin方法use方法可以传入一个包含before或者after方法的对象 * Class添加一个usePlugin方法usePlugin方法可以传入一个包含before或者after方法的对象
* *
* *
* Class EditorService extends BaseService { * Class EditorService extends BaseService {
@ -124,27 +104,9 @@ const doAsyncAction = async (
* *
* before方法的参数中, before的return after的参数after第一个参数则是原方法的return值; * before方法的参数中, before的return after的参数after第一个参数则是原方法的return值;
* return new Error(); * return new Error();
*
* 2
* Class中的每个方法都添加中间件
* Class添加一个use方法use方法可以传入一个包含源对象方法名作为key值的对象
*
*
* Class EditorService extends BaseService {
* constructor() {
* super([ { name: 'add', isAsync: true },]);
* }
* add(value) { return result; }
* };
*
* const editorService = new EditorService();
* editorService.use({
* add(value, next) { console.log(value); next() },
* });
*/ */
export default class extends EventEmitter { class BaseService extends EventEmitter {
private pluginOptionsList: Record<string, Function[]> = {}; private pluginOptionsList: Record<string, Function[]> = {};
private middleware: Record<string, Function[]> = {};
private taskList: (() => Promise<void>)[] = []; private taskList: (() => Promise<void>)[] = [];
private doingTask = false; private doingTask = false;
@ -161,14 +123,12 @@ export default class extends EventEmitter {
this.pluginOptionsList[beforeMethodName] = []; this.pluginOptionsList[beforeMethodName] = [];
this.pluginOptionsList[afterMethodName] = []; this.pluginOptionsList[afterMethodName] = [];
this.middleware[propertyName] = [];
const fn = compose(this.middleware[propertyName], isAsync);
Object.defineProperty(scope, propertyName, { Object.defineProperty(scope, propertyName, {
value: isAsync value: isAsync
? async (...args: any[]) => { ? async (...args: any[]) => {
if (!serialMethods.includes(propertyName)) { if (!serialMethods.includes(propertyName)) {
return doAsyncAction(args, scope, sourceMethod, beforeMethodName, afterMethodName, fn); return doAsyncAction(args, scope, sourceMethod, beforeMethodName, afterMethodName);
} }
// 由于async await所以会出现函数执行到await时让出线程导致执行顺序出错例如调用了select(1) -> update -> select(2)这个时候就有可能出现update了2 // 由于async await所以会出现函数执行到await时让出线程导致执行顺序出错例如调用了select(1) -> update -> select(2)这个时候就有可能出现update了2
@ -176,7 +136,7 @@ export default class extends EventEmitter {
const promise = new Promise<any>((resolve, reject) => { const promise = new Promise<any>((resolve, reject) => {
this.taskList.push(async () => { this.taskList.push(async () => {
try { try {
const value = await doAsyncAction(args, scope, sourceMethod, beforeMethodName, afterMethodName, fn); const value = await doAsyncAction(args, scope, sourceMethod, beforeMethodName, afterMethodName);
resolve(value); resolve(value);
} catch (e) { } catch (e) {
reject(e); reject(e);
@ -190,20 +150,11 @@ export default class extends EventEmitter {
return promise; return promise;
} }
: (...args: any[]) => doAction(args, scope, sourceMethod, beforeMethodName, afterMethodName, fn), : (...args: any[]) => doAction(args, scope, sourceMethod, beforeMethodName, afterMethodName),
}); });
}); });
} }
/**
* @deprecated 使usePlugin代替
*/
public use(options: Record<string, Function>) {
for (const [methodName, method] of Object.entries(options)) {
if (typeof method === 'function') this.middleware[methodName].push(method);
}
}
public usePlugin(options: Record<string, Function>) { public usePlugin(options: Record<string, Function>) {
for (const [methodName, method] of Object.entries(options)) { for (const [methodName, method] of Object.entries(options)) {
if (typeof method === 'function' && !this.pluginOptionsList[methodName].includes(method)) { if (typeof method === 'function' && !this.pluginOptionsList[methodName].includes(method)) {
@ -224,10 +175,6 @@ export default class extends EventEmitter {
for (const key of Object.keys(this.pluginOptionsList)) { for (const key of Object.keys(this.pluginOptionsList)) {
this.pluginOptionsList[key] = []; this.pluginOptionsList[key] = [];
} }
for (const key of Object.keys(this.middleware)) {
this.middleware[key] = [];
}
} }
private async doTask() { private async doTask() {
@ -240,3 +187,5 @@ export default class extends EventEmitter {
this.doingTask = false; this.doingTask = false;
} }
} }
export default BaseService;

View File

@ -23,13 +23,22 @@ import type { Writable } from 'type-fest';
import type { CodeBlockContent, CodeBlockDSL, Id, MNode, TargetOptions } from '@tmagic/core'; import type { CodeBlockContent, CodeBlockDSL, Id, MNode, TargetOptions } from '@tmagic/core';
import { Target, Watcher } from '@tmagic/core'; import { Target, Watcher } from '@tmagic/core';
import type { TableColumnConfig } from '@tmagic/form'; import type { TableColumnConfig } from '@tmagic/form';
import { getValueByKeyPath, setValueByKeyPath } from '@tmagic/utils';
import editorService from '@editor/services/editor'; import editorService from '@editor/services/editor';
import historyService from '@editor/services/history';
import storageService, { Protocol } from '@editor/services/storage'; import storageService, { Protocol } from '@editor/services/storage';
import type { AsyncHookPlugin, CodeState } from '@editor/type'; import type {
AsyncHookPlugin,
CodeBlockStepValue,
CodeState,
HistoryOpOptions,
HistoryOpOptionsWithChangeRecords,
} from '@editor/type';
import { CODE_DRAFT_STORAGE_KEY } from '@editor/type'; import { CODE_DRAFT_STORAGE_KEY } from '@editor/type';
import { getEditorConfig } from '@editor/utils/config'; import { getEditorConfig } from '@editor/utils/config';
import { COPY_CODE_STORAGE_KEY } from '@editor/utils/editor'; import { COPY_CODE_STORAGE_KEY } from '@editor/utils/editor';
import { describeRevertStep } from '@editor/utils/history';
import BaseService from './BaseService'; import BaseService from './BaseService';
@ -49,6 +58,17 @@ class CodeBlock extends BaseService {
paramsColConfig: undefined, paramsColConfig: undefined,
}); });
/**
* uuid /
* setCodeDslById(Sync)AndGetHistoryId
*/
private lastPushedHistoryId: string | null = null;
/**
* deleteCodeDslByIds uuid
* deleteCodeDslByIds deleteCodeDslByIdsAndGetHistoryId
*/
private lastDeletedHistoryIds: string[] = [];
constructor() { constructor() {
super([ super([
...canUsePluginMethods.async.map((methodName) => ({ name: methodName, isAsync: true })), ...canUsePluginMethods.async.map((methodName) => ({ name: methodName, isAsync: true })),
@ -92,10 +112,27 @@ class CodeBlock extends BaseService {
* ID和代码内容到源dsl * ID和代码内容到源dsl
* @param {Id} id id * @param {Id} id id
* @param {CodeBlockContent} codeConfig * @param {CodeBlockContent} codeConfig
* @param options
* @param options.changeRecords form propPath/value /
* @param options.doNotPushHistory false
* @returns {void} * @returns {void}
*/ */
public async setCodeDslById(id: Id, codeConfig: Partial<CodeBlockContent>): Promise<void> { public async setCodeDslById(
this.setCodeDslByIdSync(id, codeConfig, true); id: Id,
codeConfig: Partial<CodeBlockContent>,
{
changeRecords,
doNotPushHistory = false,
historyDescription,
historySource,
}: HistoryOpOptionsWithChangeRecords = {},
): Promise<void> {
this.setCodeDslByIdSync(id, codeConfig, true, {
changeRecords,
doNotPushHistory,
historyDescription,
historySource,
});
} }
/** /**
@ -104,9 +141,23 @@ class CodeBlock extends BaseService {
* @param {Id} id id * @param {Id} id id
* @param {CodeBlockContent} codeConfig * @param {CodeBlockContent} codeConfig
* @param {boolean} force true * @param {boolean} force true
* @param options
* @param options.changeRecords form propPath/value /
* @param options.doNotPushHistory false
* @param options.historyDescription
* @returns {void} * @returns {void}
*/ */
public setCodeDslByIdSync(id: Id, codeConfig: Partial<CodeBlockContent>, force = true): void { public setCodeDslByIdSync(
id: Id,
codeConfig: Partial<CodeBlockContent>,
force = true,
{
changeRecords,
doNotPushHistory = false,
historyDescription,
historySource,
}: HistoryOpOptionsWithChangeRecords = {},
): void {
const codeDsl = this.getCodeDsl(); const codeDsl = this.getCodeDsl();
if (!codeDsl) { if (!codeDsl) {
@ -123,6 +174,9 @@ class CodeBlock extends BaseService {
} }
} }
// 历史记录:在写入前快照旧内容,区分新增/更新
const oldContent: CodeBlockContent | null = codeDsl[id] ? cloneDeep(codeDsl[id]) : null;
const existContent = codeDsl[id] || {}; const existContent = codeDsl[id] || {};
codeDsl[id] = { codeDsl[id] = {
@ -130,6 +184,19 @@ class CodeBlock extends BaseService {
...codeConfigProcessed, ...codeConfigProcessed,
}; };
const newContent = cloneDeep(codeDsl[id]);
if (!doNotPushHistory) {
this.lastPushedHistoryId =
historyService.pushCodeBlock(id, {
oldContent,
newContent,
changeRecords,
historyDescription,
source: historySource,
})?.uuid ?? null;
}
this.emit('addOrUpdate', id, codeDsl[id]); this.emit('addOrUpdate', id, codeDsl[id]);
} }
@ -218,19 +285,82 @@ class CodeBlock extends BaseService {
/** /**
* dsl数据源中删除指定id的代码块 * dsl数据源中删除指定id的代码块
* @param {Id[]} codeIds id数组 * @param {Id[]} codeIds id数组
* @param options
* @param options.doNotPushHistory false
*/ */
public async deleteCodeDslByIds(codeIds: Id[]): Promise<void> { public async deleteCodeDslByIds(
codeIds: Id[],
{ doNotPushHistory = false, historyDescription, historySource }: HistoryOpOptions = {},
): Promise<void> {
const currentDsl = await this.getCodeDsl(); const currentDsl = await this.getCodeDsl();
if (!currentDsl) return; if (!currentDsl) return;
this.lastDeletedHistoryIds = [];
codeIds.forEach((id) => { codeIds.forEach((id) => {
// 历史记录:删除前快照内容;不存在的 id 直接跳过历史推入
const oldContent: CodeBlockContent | null = currentDsl[id] ? cloneDeep(currentDsl[id]) : null;
delete currentDsl[id]; delete currentDsl[id];
if (oldContent && !doNotPushHistory) {
const uuid = historyService.pushCodeBlock(id, {
oldContent,
newContent: null,
historyDescription,
source: historySource,
})?.uuid;
if (uuid) this.lastDeletedHistoryIds.push(uuid);
}
this.emit('remove', id); this.emit('remove', id);
}); });
} }
// #region AndGetHistoryId
/**
* *AndGetHistoryId
* uuid{@link CodeBlockStepValue.uuid}
* / revert
*
* doNotPushHistory true null
*/
/** 等价于 {@link setCodeDslById},但返回本次写入历史记录的 uuid未入栈时返回 null。 */
public async setCodeDslByIdAndGetHistoryId(
id: Id,
codeConfig: Partial<CodeBlockContent>,
options: HistoryOpOptionsWithChangeRecords = {},
): Promise<string | null> {
this.lastPushedHistoryId = null;
await this.setCodeDslById(id, codeConfig, options);
return this.lastPushedHistoryId;
}
/** 等价于 {@link setCodeDslByIdSync},但返回本次写入历史记录的 uuid未入栈时返回 null。 */
public setCodeDslByIdSyncAndGetHistoryId(
id: Id,
codeConfig: Partial<CodeBlockContent>,
force = true,
options: HistoryOpOptionsWithChangeRecords = {},
): string | null {
this.lastPushedHistoryId = null;
this.setCodeDslByIdSync(id, codeConfig, force, options);
return this.lastPushedHistoryId;
}
/**
* {@link deleteCodeDslByIds} uuid
*
*/
public async deleteCodeDslByIdsAndGetHistoryId(codeIds: Id[], options: HistoryOpOptions = {}): Promise<string[]> {
this.lastDeletedHistoryIds = [];
await this.deleteCodeDslByIds(codeIds, options);
return [...this.lastDeletedHistoryIds];
}
// #endregion AndGetHistoryId
public setParamsColConfig(config: TableColumnConfig): void { public setParamsColConfig(config: TableColumnConfig): void {
this.state.paramsColConfig = config; this.state.paramsColConfig = config;
} }
@ -239,6 +369,105 @@ class CodeBlock extends BaseService {
return this.state.paramsColConfig; return this.state.paramsColConfig;
} }
/**
*
*
* setCodeDslByIdSync / deleteCodeDslByIds codeBlockService
* `addOrUpdate` / `remove` initService handler dep target
* DepTargetType.CODE_BLOCK add / remove `doNotPushHistory: true`
*
*
* @param id id
* @returns step null
*/
public async undo(id: Id): Promise<CodeBlockStepValue | null> {
const step = historyService.undoCodeBlock(id);
if (!step) return null;
await this.applyHistoryStep(step, true);
return step;
}
/**
*
* @param id id
* @returns step null
*/
public async redo(id: Id): Promise<CodeBlockStepValue | null> {
const step = historyService.redoCodeBlock(id);
if (!step) return null;
await this.applyHistoryStep(step, false);
return step;
}
/** 是否可对指定代码块撤销。 */
public canUndo(id: Id): boolean {
return historyService.canUndoCodeBlock(id);
}
/** 是否可对指定代码块重做。 */
public canRedo(id: Id): boolean {
return historyService.canRedoCodeBlock(id);
}
/**
* editor.gotoPageStep
*
* @param id id
* @param targetCursor
* @returns
*/
public async goto(id: Id, targetCursor: number): Promise<number> {
let cursor = historyService.getCodeBlockCursor(id);
const target = Math.max(0, targetCursor);
while (cursor > target) {
const step = await this.undo(id);
if (!step) break;
cursor -= 1;
}
while (cursor < target) {
const step = await this.redo(id);
if (!step) break;
cursor += 1;
}
return cursor;
}
/**
* git revert
* - cursor
* - step ********
* -
*
* @param id id
* @param index step 0
* @returns step / null
*/
public async revert(id: Id, index: number): Promise<CodeBlockStepValue | null> {
const list = historyService.getCodeBlockStepList(id);
const entry = list[index];
if (!entry?.applied) return null;
// 更新类步骤(前后 content 都存在)必须带 changeRecords 才支持回滚,否则只能整内容替换,会冲掉后续无关变更。
const { oldSchema, newSchema, changeRecords } = entry.step.diff?.[0] ?? {};
if (oldSchema && newSchema && !changeRecords?.length) return null;
const description = `回滚 #${index + 1}: ${describeRevertStep<CodeBlockContent>(entry.step.id, entry.step.diff?.[0], (s) => s.name)}`;
return await this.applyRevertStep(entry.step, description);
}
/**
* uuid {@link revert}
* codeBlockId index uuid{@link CodeBlockStepValue.uuid}
*
*
* @param uuid uuid {@link setCodeDslByIdAndGetHistoryId}
* @returns step uuid / null
*/
public async revertById(uuid: string): Promise<CodeBlockStepValue | null> {
const location = historyService.findCodeBlockStepLocationByUuid(uuid);
if (!location) return null;
return await this.revert(location.id, location.index);
}
/** /**
* id * id
* @returns {Id} id * @returns {Id} id
@ -320,6 +549,122 @@ class CodeBlock extends BaseService {
public usePlugin(options: AsyncHookPlugin<AsyncMethodName, CodeBlock>): void { public usePlugin(options: AsyncHookPlugin<AsyncMethodName, CodeBlock>): void {
super.usePlugin(options); super.usePlugin(options);
} }
/**
* step step applyHistoryStep(reverse=true)
* setCodeDslByIdSync / deleteCodeDslByIds push
*/
private async applyRevertStep(
step: CodeBlockStepValue,
historyDescription: string,
): Promise<CodeBlockStepValue | null> {
const { id } = step;
const { oldSchema, newSchema, changeRecords } = step.diff?.[0] ?? {};
// 原本是新增 → revert 即删除
if (!oldSchema && newSchema) {
await this.deleteCodeDslByIds([id], { historyDescription, historySource: 'rollback' });
return historyService.getCodeBlockStepList(id).slice(-1)[0]?.step ?? null;
}
// 原本是删除 → revert 即写回
if (oldSchema && !newSchema) {
this.setCodeDslByIdSync(id, cloneDeep(oldSchema), true, { historyDescription, historySource: 'rollback' });
return historyService.getCodeBlockStepList(id).slice(-1)[0]?.step ?? null;
}
if (!oldSchema || !newSchema) return null;
// 原本是更新 → 把 oldContent 写回;优先按 changeRecords 局部 patch
if (changeRecords?.length) {
const current = this.getCodeContentById(id);
if (!current) return null;
const patched = cloneDeep(current) as CodeBlockContent;
let fallbackToFullReplace = false;
for (const record of changeRecords) {
if (!record.propPath) {
fallbackToFullReplace = true;
break;
}
const value = cloneDeep(getValueByKeyPath(record.propPath, oldSchema));
setValueByKeyPath(record.propPath, value, patched);
}
this.setCodeDslByIdSync(id, fallbackToFullReplace ? cloneDeep(oldSchema) : patched, true, {
changeRecords,
historyDescription,
historySource: 'rollback',
});
return historyService.getCodeBlockStepList(id).slice(-1)[0]?.step ?? null;
}
this.setCodeDslByIdSync(id, cloneDeep(oldSchema), true, { historyDescription, historySource: 'rollback' });
return historyService.getCodeBlockStepList(id).slice(-1)[0]?.step ?? null;
}
/**
* step
*
* setCodeDslByIdSync / deleteCodeDslByIds
* initService dep target CODE_BLOCK add / remove
* `doNotPushHistory: true`
*
* - oldContent=null, newContentnull undo redo setCodeDslByIdSync
* - oldContentnull, newContent=null undo redo
* - changeRecords patch退
*
* @param step step
* @param reverse true=false=
*/
private async applyHistoryStep(step: CodeBlockStepValue, reverse: boolean): Promise<void> {
const { id } = step;
const { oldSchema, newSchema, changeRecords } = step.diff?.[0] ?? {};
// 新增 / 删除:直接 set 或 delete不走 patch 逻辑
if (!oldSchema && newSchema) {
if (reverse) {
await this.deleteCodeDslByIds([id], { doNotPushHistory: true });
} else {
this.setCodeDslByIdSync(id, cloneDeep(newSchema), true, { doNotPushHistory: true });
}
return;
}
if (oldSchema && !newSchema) {
if (reverse) {
this.setCodeDslByIdSync(id, cloneDeep(oldSchema), true, { doNotPushHistory: true });
} else {
await this.deleteCodeDslByIds([id], { doNotPushHistory: true });
}
return;
}
if (!oldSchema || !newSchema) return;
// 更新场景:优先按 changeRecords 局部 patch缺省退化为整内容替换
const sourceForValues = reverse ? oldSchema : newSchema;
if (changeRecords?.length) {
const current = this.getCodeContentById(id);
if (!current) return;
const patched = cloneDeep(current) as CodeBlockContent;
let fallbackToFullReplace = false;
for (const record of changeRecords) {
if (!record.propPath) {
fallbackToFullReplace = true;
break;
}
const value = cloneDeep(getValueByKeyPath(record.propPath, sourceForValues));
setValueByKeyPath(record.propPath, value, patched);
}
this.setCodeDslByIdSync(id, fallbackToFullReplace ? cloneDeep(sourceForValues) : patched, true, {
changeRecords,
doNotPushHistory: true,
});
return;
}
this.setCodeDslByIdSync(id, cloneDeep(sourceForValues), true, { doNotPushHistory: true });
}
} }
export type CodeBlockService = CodeBlock; export type CodeBlockService = CodeBlock;

View File

@ -4,14 +4,22 @@ import type { Writable } from 'type-fest';
import type { DataSourceSchema, EventOption, Id, MNode, TargetOptions } from '@tmagic/core'; import type { DataSourceSchema, EventOption, Id, MNode, TargetOptions } from '@tmagic/core';
import { Target, Watcher } from '@tmagic/core'; import { Target, Watcher } from '@tmagic/core';
import type { ChangeRecord, FormConfig } from '@tmagic/form'; import type { FormConfig } from '@tmagic/form';
import { guid, toLine } from '@tmagic/utils'; import { getValueByKeyPath, guid, setValueByKeyPath, toLine } from '@tmagic/utils';
import editorService from '@editor/services/editor'; import editorService from '@editor/services/editor';
import historyService from '@editor/services/history';
import storageService, { Protocol } from '@editor/services/storage'; import storageService, { Protocol } from '@editor/services/storage';
import type { DatasourceTypeOption, SyncHookPlugin } from '@editor/type'; import type {
DataSourceStepValue,
DatasourceTypeOption,
HistoryOpOptions,
HistoryOpOptionsWithChangeRecords,
SyncHookPlugin,
} from '@editor/type';
import { getFormConfig, getFormValue } from '@editor/utils/data-source'; import { getFormConfig, getFormValue } from '@editor/utils/data-source';
import { COPY_DS_STORAGE_KEY } from '@editor/utils/editor'; import { COPY_DS_STORAGE_KEY } from '@editor/utils/editor';
import { describeRevertStep } from '@editor/utils/history';
import BaseService from './BaseService'; import BaseService from './BaseService';
@ -58,6 +66,13 @@ class DataSource extends BaseService {
methods: {}, methods: {},
}); });
/**
* uuid
* *AndGetHistoryId add / update / remove id
* *AndGetHistoryId null
*/
private lastPushedHistoryId: string | null = null;
constructor() { constructor() {
super(canUsePluginMethods.sync.map((methodName) => ({ name: methodName, isAsync: false }))); super(canUsePluginMethods.sync.map((methodName) => ({ name: methodName, isAsync: false })));
} }
@ -102,7 +117,17 @@ class DataSource extends BaseService {
this.get('methods')[toLine(type)] = value; this.get('methods')[toLine(type)] = value;
} }
public add(config: DataSourceSchema) { /**
*
* @param config
* @param options
* @param options.doNotPushHistory false
* @param options.historyDescription
*/
public add(
config: DataSourceSchema,
{ doNotPushHistory = false, historyDescription, historySource }: HistoryOpOptions = {},
) {
const newConfig = { const newConfig = {
...config, ...config,
id: config.id && !this.getDataSourceById(config.id) ? config.id : this.createId(), id: config.id && !this.getDataSourceById(config.id) ? config.id : this.createId(),
@ -110,12 +135,38 @@ class DataSource extends BaseService {
this.get('dataSources').push(newConfig); this.get('dataSources').push(newConfig);
if (!doNotPushHistory) {
this.lastPushedHistoryId =
historyService.pushDataSource(newConfig.id, {
oldSchema: null,
newSchema: newConfig,
historyDescription,
source: historySource,
})?.uuid ?? null;
}
this.emit('add', newConfig); this.emit('add', newConfig);
return newConfig; return newConfig;
} }
public update(config: DataSourceSchema, { changeRecords = [] }: { changeRecords?: ChangeRecord[] } = {}) { /**
*
* @param config
* @param data
* @param data.changeRecords form
* @param data.doNotPushHistory false
* @param data.historyDescription
*/
public update(
config: DataSourceSchema,
{
changeRecords = [],
doNotPushHistory = false,
historyDescription,
historySource,
}: HistoryOpOptionsWithChangeRecords = {},
) {
const dataSources = this.get('dataSources'); const dataSources = this.get('dataSources');
const index = dataSources.findIndex((ds) => ds.id === config.id); const index = dataSources.findIndex((ds) => ds.id === config.id);
@ -125,6 +176,17 @@ class DataSource extends BaseService {
dataSources[index] = newConfig; dataSources[index] = newConfig;
if (!doNotPushHistory) {
this.lastPushedHistoryId =
historyService.pushDataSource(newConfig.id, {
oldSchema: oldConfig ? cloneDeep(oldConfig) : null,
newSchema: newConfig,
changeRecords,
historyDescription,
source: historySource,
})?.uuid ?? null;
}
this.emit('update', newConfig, { this.emit('update', newConfig, {
oldConfig, oldConfig,
changeRecords, changeRecords,
@ -133,14 +195,161 @@ class DataSource extends BaseService {
return newConfig; return newConfig;
} }
public remove(id: string) { /**
*
* @param id id
* @param options
* @param options.doNotPushHistory false
* @param options.historyDescription
*/
public remove(id: string, { doNotPushHistory = false, historyDescription, historySource }: HistoryOpOptions = {}) {
const dataSources = this.get('dataSources'); const dataSources = this.get('dataSources');
const index = dataSources.findIndex((ds) => ds.id === id); const index = dataSources.findIndex((ds) => ds.id === id);
const oldConfig = index !== -1 ? dataSources[index] : null;
dataSources.splice(index, 1); dataSources.splice(index, 1);
if (oldConfig && !doNotPushHistory) {
this.lastPushedHistoryId =
historyService.pushDataSource(id, {
oldSchema: cloneDeep(oldConfig),
newSchema: null,
historyDescription,
source: historySource,
})?.uuid ?? null;
}
this.emit('remove', id); this.emit('remove', id);
} }
// #region AndGetHistoryId
/**
* *AndGetHistoryId add / update / remove
* uuid{@link DataSourceStepValue.uuid}
* / revert
*
* doNotPushHistory true null
*/
/** 等价于 {@link add},但返回本次写入历史记录的 uuid未入栈时返回 null。 */
public addAndGetHistoryId(config: DataSourceSchema, options: HistoryOpOptions = {}): string | null {
this.lastPushedHistoryId = null;
this.add(config, options);
return this.lastPushedHistoryId;
}
/** 等价于 {@link update},但返回本次写入历史记录的 uuid未入栈时返回 null。 */
public updateAndGetHistoryId(
config: DataSourceSchema,
options: HistoryOpOptionsWithChangeRecords = {},
): string | null {
this.lastPushedHistoryId = null;
this.update(config, options);
return this.lastPushedHistoryId;
}
/** 等价于 {@link remove},但返回本次写入历史记录的 uuid未入栈时返回 null。 */
public removeAndGetHistoryId(id: string, options: HistoryOpOptions = {}): string | null {
this.lastPushedHistoryId = null;
this.remove(id, options);
return this.lastPushedHistoryId;
}
// #endregion AndGetHistoryId
/**
*
*
* add / update / remove dataSourceService
* initService handler depDATA_SOURCE / DATA_SOURCE_COND / DATA_SOURCE_METHOD
* `doNotPushHistory: true`
*
* @param id id
* @returns step null
*/
public undo(id: Id) {
const step = historyService.undoDataSource(id);
if (!step) return null;
this.applyHistoryStep(step, true);
return step;
}
/**
*
* @param id id
* @returns step null
*/
public redo(id: Id) {
const step = historyService.redoDataSource(id);
if (!step) return null;
this.applyHistoryStep(step, false);
return step;
}
/** 是否可对指定数据源撤销。 */
public canUndo(id: Id): boolean {
return historyService.canUndoDataSource(id);
}
/** 是否可对指定数据源重做。 */
public canRedo(id: Id): boolean {
return historyService.canRedoDataSource(id);
}
/**
* editor.gotoPageStep
*
* @param id id
* @param targetCursor
* @returns
*/
public goto(id: Id, targetCursor: number): number {
let cursor = historyService.getDataSourceCursor(id);
const target = Math.max(0, targetCursor);
while (cursor > target) {
if (!this.undo(id)) break;
cursor -= 1;
}
while (cursor < target) {
if (!this.redo(id)) break;
cursor += 1;
}
return cursor;
}
/**
* git revert
* - cursor
* - step ********
* - schema
*
* @param id id
* @param index step 0
* @returns step / null
*/
public revert(id: Id, index: number): DataSourceStepValue | null {
const list = historyService.getDataSourceStepList(id);
const entry = list[index];
if (!entry?.applied) return null;
// 更新类步骤(前后 schema 都存在)必须带 changeRecords 才支持回滚,否则只能整 schema 替换,会冲掉后续无关变更。
const { oldSchema, newSchema, changeRecords } = entry.step.diff?.[0] ?? {};
if (oldSchema && newSchema && !changeRecords?.length) return null;
const description = `回滚 #${index + 1}: ${describeRevertStep<DataSourceSchema>(entry.step.id, entry.step.diff?.[0], (s) => s.title)}`;
return this.applyRevertStep(entry.step, description);
}
/**
* uuid {@link revert}
* dataSourceId index uuid{@link DataSourceStepValue.uuid}
*
*
* @param uuid uuid {@link addAndGetHistoryId}
* @returns step uuid / null
*/
public revertById(uuid: string): DataSourceStepValue | null {
const location = historyService.findDataSourceStepLocationByUuid(uuid);
if (!location) return null;
return this.revert(location.id, location.index);
}
public createId(): string { public createId(): string {
return `ds_${guid()}`; return `ds_${guid()}`;
} }
@ -215,6 +424,119 @@ class DataSource extends BaseService {
} }
}); });
} }
/**
* step step doNotPushHistory applyHistoryStep(reverse=true)
* add / update / remove doNotPushHistory
*/
private applyRevertStep(step: DataSourceStepValue, historyDescription: string): DataSourceStepValue | null {
const { id } = step;
const { oldSchema, newSchema, changeRecords } = step.diff?.[0] ?? {};
// 原本是新增 → revert 即删除
if (!oldSchema && newSchema) {
this.remove(`${id}`, { historyDescription, historySource: 'rollback' });
return historyService.getDataSourceStepList(id).slice(-1)[0]?.step ?? null;
}
// 原本是删除 → revert 即重新加回
if (oldSchema && !newSchema) {
this.add(cloneDeep(oldSchema), { historyDescription, historySource: 'rollback' });
return historyService.getDataSourceStepList(id).slice(-1)[0]?.step ?? null;
}
if (!oldSchema || !newSchema) return null;
// 原本是更新 → 把 oldSchema 写回;优先按 changeRecords 局部 patch
if (changeRecords?.length) {
const current = this.getDataSourceById(`${id}`);
if (!current) return null;
const patched = cloneDeep(current) as DataSourceSchema;
let fallbackToFullReplace = false;
for (const record of changeRecords) {
if (!record.propPath) {
fallbackToFullReplace = true;
break;
}
const value = cloneDeep(getValueByKeyPath(record.propPath, oldSchema));
setValueByKeyPath(record.propPath, value, patched);
}
this.update(fallbackToFullReplace ? cloneDeep(oldSchema) : patched, {
changeRecords,
historyDescription,
historySource: 'rollback',
});
return historyService.getDataSourceStepList(id).slice(-1)[0]?.step ?? null;
}
this.update(cloneDeep(oldSchema), { historyDescription, historySource: 'rollback' });
return historyService.getDataSourceStepList(id).slice(-1)[0]?.step ?? null;
}
/**
* step
*
* add / update / remove initService
* DATA_SOURCE / DATA_SOURCE_COND / DATA_SOURCE_METHOD
* `doNotPushHistory: true`
*
* - oldSchema=null, newSchemanull undo redo add
* - oldSchemanull, newSchema=null undo addredo
* - changeRecords patch退 schema
*
* @param step step
* @param reverse true=false=
*/
private applyHistoryStep(step: DataSourceStepValue, reverse: boolean): void {
const { id } = step;
const { oldSchema, newSchema, changeRecords } = step.diff?.[0] ?? {};
// 新增 / 删除:直接 add 或 remove不走 patch 逻辑
if (!oldSchema && newSchema) {
if (reverse) {
this.remove(`${id}`, { doNotPushHistory: true });
} else {
this.add(cloneDeep(newSchema), { doNotPushHistory: true });
}
return;
}
if (oldSchema && !newSchema) {
if (reverse) {
this.add(cloneDeep(oldSchema), { doNotPushHistory: true });
} else {
this.remove(`${id}`, { doNotPushHistory: true });
}
return;
}
if (!oldSchema || !newSchema) return;
// 更新场景:优先按 changeRecords 局部 patch缺省退化为整 schema 替换
const sourceForValues = reverse ? oldSchema : newSchema;
if (changeRecords?.length) {
const current = this.getDataSourceById(`${id}`);
if (!current) return;
const patched = cloneDeep(current) as DataSourceSchema;
let fallbackToFullReplace = false;
for (const record of changeRecords) {
if (!record.propPath) {
fallbackToFullReplace = true;
break;
}
const value = cloneDeep(getValueByKeyPath(record.propPath, sourceForValues));
setValueByKeyPath(record.propPath, value, patched);
}
this.update(fallbackToFullReplace ? cloneDeep(sourceForValues) : patched, {
changeRecords,
doNotPushHistory: true,
});
return;
}
this.update(cloneDeep(sourceForValues), { doNotPushHistory: true });
}
} }
export type DataSourceService = DataSource; export type DataSourceService = DataSource;

View File

@ -109,9 +109,23 @@ class Dep extends BaseService {
public collect(nodes: MNode[], depExtendedData: DepExtendedData = {}, deep = false, type?: DepTargetType) { public collect(nodes: MNode[], depExtendedData: DepExtendedData = {}, deep = false, type?: DepTargetType) {
this.set('collecting', true); this.set('collecting', true);
this.watcher.collectByCallback(nodes, type, ({ node, target }) => {
this.collectNode(node, target, depExtendedData, deep); const targets = this.watcher.getCollectableTargets(type);
});
if (targets.length) {
for (const node of nodes) {
// 先删除原有依赖,再批量收集
if (isPage(node)) {
for (const target of targets) {
this.removePageDep(target, depExtendedData);
}
} else {
this.watcher.removeTargetsDep(targets, node);
}
this.watcher.collectItems(node, targets, depExtendedData, deep);
}
}
this.set('collecting', false); this.set('collecting', false);
this.emit('collected', nodes, deep); this.emit('collected', nodes, deep);
@ -176,7 +190,7 @@ class Dep extends BaseService {
dsl.dataSourceDeps[target.id] = target.deps; dsl.dataSourceDeps[target.id] = target.deps;
} else if (target.type === DepTargetType.DATA_SOURCE_COND && dsl.dataSourceCondDeps) { } else if (target.type === DepTargetType.DATA_SOURCE_COND && dsl.dataSourceCondDeps) {
dsl.dataSourceCondDeps[target.id] = target.deps; dsl.dataSourceCondDeps[target.id] = target.deps;
} else if (target.type === DepTargetType.DATA_SOURCE_METHOD) { } else if (target.type === DepTargetType.DATA_SOURCE_METHOD && dsl.dataSourceMethodDeps) {
dsl.dataSourceMethodDeps[target.id] = target.deps; dsl.dataSourceMethodDeps[target.id] = target.deps;
} }
} }
@ -194,11 +208,7 @@ class Dep extends BaseService {
public collectNode(node: MNode, target: Target, depExtendedData: DepExtendedData = {}, deep = false) { public collectNode(node: MNode, target: Target, depExtendedData: DepExtendedData = {}, deep = false) {
// 先删除原有依赖,重新收集 // 先删除原有依赖,重新收集
if (isPage(node)) { if (isPage(node)) {
for (const [depKey, dep] of Object.entries(target.deps)) { this.removePageDep(target, depExtendedData);
if (dep.data?.pageId && dep.data.pageId === depExtendedData.pageId) {
delete target.deps[depKey];
}
}
} else { } else {
this.watcher.removeTargetDep(target, node); this.watcher.removeTargetDep(target, node);
} }
@ -263,6 +273,19 @@ class Dep extends BaseService {
return super.emit(eventName, ...args); return super.emit(eventName, ...args);
} }
/**
* page target
* pageId
*/
private removePageDep(target: Target, depExtendedData: DepExtendedData = {}) {
for (const depKey of Object.keys(target.deps)) {
const dep = target.deps[depKey];
if (dep.data?.pageId && dep.data.pageId === depExtendedData.pageId) {
delete target.deps[depKey];
}
}
}
private enqueueTask(node: MNode, target: Target, depExtendedData: DepExtendedData, deep: boolean) { private enqueueTask(node: MNode, target: Target, depExtendedData: DepExtendedData, deep: boolean) {
this.idleTask.enqueueTask( this.idleTask.enqueueTask(
({ node, deep, target }) => { ({ node, deep, target }) => {

File diff suppressed because it is too large Load Diff

View File

@ -17,20 +17,242 @@
*/ */
import { reactive } from 'vue'; import { reactive } from 'vue';
import { cloneDeep } from 'lodash-es';
import serialize from 'serialize-javascript';
import type { MPage, MPageFragment } from '@tmagic/core'; import type { CodeBlockContent, DataSourceSchema, Id, MPage, MPageFragment } from '@tmagic/core';
import type { ChangeRecord } from '@tmagic/form';
import { guid } from '@tmagic/utils';
import type { HistoryState, StepValue } from '@editor/type'; import type {
BaseStepValue,
CodeBlockHistoryGroup,
CodeBlockStepValue,
DataSourceHistoryGroup,
DataSourceStepValue,
HistoryOpSource,
HistoryOpType,
HistoryPersistOptions,
HistoryState,
PageHistoryGroup,
PageHistoryStepEntry,
PersistedHistoryState,
StepValue,
} from '@editor/type';
import { getEditorConfig } from '@editor/utils/config';
import { idbGet, idbSet } from '@editor/utils/indexed-db';
import { UndoRedo } from '@editor/utils/undo-redo'; import { UndoRedo } from '@editor/utils/undo-redo';
import BaseService from './BaseService'; import BaseService from './BaseService';
import editorService from './editor';
/** 历史记录持久化快照的默认存储位置与结构版本。 */
const DEFAULT_DB_NAME = 'tmagic-editor';
const DEFAULT_STORE_NAME = 'history';
const DEFAULT_KEY: IDBValidKey = 'default';
const PERSIST_VERSION = 1;
class History extends BaseService { class History extends BaseService {
/**
* id / group
* - "新增/删除" update
* - 'update' steps
*
* `kind` `kind`
*/
private static mergeStackSteps<S extends BaseStepValue, K extends 'code-block' | 'data-source'>(
kind: K,
id: Id,
list: S[],
cursor: number,
): {
kind: K;
id: Id;
opType: HistoryOpType;
steps: { step: S; index: number; applied: boolean; isCurrent?: boolean }[];
applied: boolean;
isCurrent?: boolean;
}[] {
type Group = {
kind: K;
id: Id;
opType: HistoryOpType;
steps: { step: S; index: number; applied: boolean; isCurrent?: boolean }[];
applied: boolean;
isCurrent?: boolean;
};
const groups: Group[] = [];
let current: Group | null = null;
const currentIndex = cursor - 1;
list.forEach((step, index) => {
const { opType } = step;
const applied = index < cursor;
const isCurrent = index === currentIndex;
if (opType === 'update' && current?.opType === 'update') {
current.steps.push({ step, index, applied, isCurrent });
current.applied = applied;
if (isCurrent) current.isCurrent = true;
} else {
current = {
kind,
id,
opType,
steps: [{ step, index, applied, isCurrent }],
applied,
isCurrent,
};
groups.push(current);
}
});
return groups;
}
/**
* old/new null opType push
*/
private static detectOpType(oldVal: unknown, newVal: unknown): 'add' | 'remove' | 'update' {
if (oldVal === null && newVal !== null) return 'add';
if (oldVal !== null && newVal === null) return 'remove';
return 'update';
}
/**
* group
* - 'update' targetId targetId update group
* - 'add' / 'remove'
* - 'update'
*/
private static mergePageSteps(pageId: Id, list: StepValue[], cursor: number): PageHistoryGroup[] {
const groups: PageHistoryGroup[] = [];
let current: PageHistoryGroup | null = null;
const currentIndex = cursor - 1;
list.forEach((step, index) => {
const applied = index < cursor;
const isCurrent = index === currentIndex;
const targetId = History.detectPageTargetId(step);
const targetName = History.detectPageTargetName(step);
const entry: PageHistoryStepEntry = { step, index, applied, isCurrent };
// 仅"单节点 update"参与合并其它情形add/remove/多节点 update始终独立成组。
const mergeable = step.opType === 'update' && targetId !== undefined;
if (mergeable && current?.opType === 'update' && current.targetId === targetId) {
current.steps.push(entry);
current.applied = applied;
if (isCurrent) current.isCurrent = true;
// 保持目标名为最近一次的(节点重命名时也能反映)
if (targetName) current.targetName = targetName;
} else {
current = {
kind: 'page',
pageId,
opType: step.opType,
targetId: mergeable ? targetId : undefined,
targetName,
steps: [entry],
applied,
isCurrent,
};
groups.push(current);
}
});
return groups;
}
/**
* StepValue "目标节点 id"
* - update updatedItems id
* - update / add / remove undefined
*/
private static detectPageTargetId(step: StepValue): Id | undefined {
if (step.opType !== 'update') return undefined;
const items = step.diff;
if (items?.length !== 1) return undefined;
return items[0].newSchema?.id ?? items[0].oldSchema?.id;
}
/** 解析 StepValue 中的目标节点可读名(用于 UI 展示)。 */
private static detectPageTargetName(step: StepValue): string | undefined {
const items = step.diff;
if (step.opType === 'update') {
if (items?.length === 1) {
const node = items[0].newSchema || items[0].oldSchema;
return (node?.name as string) || (node?.type as string) || (node?.id !== undefined ? `${node.id}` : undefined);
}
return items?.length ? `${items.length} 个节点` : undefined;
}
if (step.opType === 'add') {
if (items?.length === 1) {
const n = items[0].newSchema;
return (n?.name as string) || (n?.type as string) || `${n?.id}`;
}
return items?.length ? `${items.length} 个节点` : undefined;
}
if (step.opType === 'remove') {
if (items?.length === 1) {
const n = items[0].oldSchema;
return (n?.name as string) || (n?.type as string) || `${n?.id}`;
}
return items?.length ? `${items.length} 个节点` : undefined;
}
return undefined;
}
/**
* `saved`
* cursor 0 0
*/
private static markStackSaved<S extends { saved?: boolean }>(undoRedo?: UndoRedo<S>): void {
if (!undoRedo) return;
undoRedo.updateElements((element) => {
element.saved = false;
});
undoRedo.updateCurrentElement((element) => {
element.saved = true;
});
}
/** 把 `Record<Id, UndoRedo>` 整体序列化为 `Record<Id, SerializedUndoRedo>`。 */
private static serializeStacks<T>(stacks: Record<Id, UndoRedo<T>>) {
const result: Record<Id, ReturnType<UndoRedo<T>['serialize']>> = {};
Object.entries(stacks).forEach(([id, undoRedo]) => {
if (undoRedo) result[id] = undoRedo.serialize();
});
return result;
}
/**
* `Record<Id, SerializedUndoRedo>` `Record<Id, UndoRedo>`
* `saved === true`
*/
private static deserializeStacks<T extends { saved?: boolean }>(
stacks: Record<Id, ReturnType<UndoRedo<T>['serialize']>> = {},
): Record<Id, UndoRedo<T>> {
const result: Record<Id, UndoRedo<T>> = {};
Object.entries(stacks).forEach(([id, serialized]) => {
if (serialized) {
result[id] = UndoRedo.fromSerialized<T>(serialized, { isSavedStep: (element) => element.saved === true });
}
});
return result;
}
/**
* id id / UndoRedo
*/
private static getOrCreateStack<T>(stacks: Record<Id, UndoRedo<T>>, id: Id): UndoRedo<T> {
if (!stacks[id]) {
stacks[id] = new UndoRedo<T>();
}
return stacks[id];
}
public state = reactive<HistoryState>({ public state = reactive<HistoryState>({
pageSteps: {}, pageSteps: {},
pageId: undefined, pageId: undefined,
canRedo: false, canRedo: false,
canUndo: false, canUndo: false,
codeBlockState: {},
dataSourceState: {},
}); });
constructor() { constructor() {
@ -41,6 +263,8 @@ class History extends BaseService {
public reset() { public reset() {
this.state.pageSteps = {}; this.state.pageSteps = {};
this.state.codeBlockState = {};
this.state.dataSourceState = {};
this.resetPage(); this.resetPage();
} }
@ -69,16 +293,148 @@ class History extends BaseService {
this.state.pageSteps = {}; this.state.pageSteps = {};
this.state.canRedo = false; this.state.canRedo = false;
this.state.canUndo = false; this.state.canUndo = false;
this.state.codeBlockState = {};
this.state.dataSourceState = {};
} }
public push(state: StepValue): StepValue | null { /**
const undoRedo = this.getUndoRedo(); * pageId
*
* `moveToContainer` `pageId`
* / /
*/
public push(state: StepValue, pageId?: Id): StepValue | null {
const undoRedo = this.getUndoRedo(pageId);
if (!undoRedo) return null; if (!undoRedo) return null;
if (state.uuid === undefined) state.uuid = guid();
if (state.timestamp === undefined) state.timestamp = Date.now();
undoRedo.pushElement(state); undoRedo.pushElement(state);
this.emit('change', state); // 仅当推入的是当前活动页时才需要刷新 canUndo/canRedo —— 其它页栈对当前 UI 状态没影响。
if (pageId === undefined || `${pageId}` === `${this.state.pageId}`) {
this.emit('change', state);
}
return state; return state;
} }
/**
* / `codeBlockId` UndoRedo
*
* - oldContent = nullnewContent =
* - oldContent / newContent
* - newContent = nulloldContent =
* - `changeRecords` form / propPath 退
* - codeBlockService
*/
public pushCodeBlock(
codeBlockId: Id,
payload: {
oldContent: CodeBlockContent | null;
newContent: CodeBlockContent | null;
changeRecords?: ChangeRecord[];
/** 可选的人类可读描述(如「修改按钮颜色」),仅用于历史面板展示。 */
historyDescription?: string;
/** 可选的操作途径(配置面板 / 菜单 / 接口等),仅用于历史面板展示与埋点。 */
source?: HistoryOpSource;
},
): CodeBlockStepValue | null {
const step = this.createStackStep<CodeBlockContent, CodeBlockStepValue>(codeBlockId, {
oldValue: payload.oldContent,
newValue: payload.newContent,
changeRecords: payload.changeRecords,
historyDescription: payload.historyDescription,
source: payload.source,
});
if (!step) return null;
History.getOrCreateStack(this.state.codeBlockState, codeBlockId).pushElement(step);
this.emit('code-block-history-change', codeBlockId, step);
return step;
}
/**
* / `dataSourceId` UndoRedo
* pushCodeBlock oldSchema=null newSchema=null
*/
public pushDataSource(
dataSourceId: Id,
payload: {
oldSchema: DataSourceSchema | null;
newSchema: DataSourceSchema | null;
changeRecords?: ChangeRecord[];
/** 可选的人类可读描述,仅用于历史面板展示。 */
historyDescription?: string;
/** 可选的操作途径(配置面板 / 菜单 / 接口等),仅用于历史面板展示与埋点。 */
source?: HistoryOpSource;
},
): DataSourceStepValue | null {
const step = this.createStackStep<DataSourceSchema, DataSourceStepValue>(dataSourceId, {
oldValue: payload.oldSchema,
newValue: payload.newSchema,
changeRecords: payload.changeRecords,
historyDescription: payload.historyDescription,
source: payload.source,
});
if (!step) return null;
History.getOrCreateStack(this.state.dataSourceState, dataSourceId).pushElement(step);
this.emit('data-source-history-change', dataSourceId, step);
return step;
}
/** 撤销指定代码块的最近一次变更。 */
public undoCodeBlock(codeBlockId: Id): CodeBlockStepValue | null {
const undoRedo = this.state.codeBlockState[codeBlockId];
if (!undoRedo) return null;
const step = undoRedo.undo();
if (step) this.emit('code-block-history-change', codeBlockId, step);
return step;
}
/** 重做指定代码块的下一次变更。 */
public redoCodeBlock(codeBlockId: Id): CodeBlockStepValue | null {
const undoRedo = this.state.codeBlockState[codeBlockId];
if (!undoRedo) return null;
const step = undoRedo.redo();
if (step) this.emit('code-block-history-change', codeBlockId, step);
return step;
}
/** 是否可对指定代码块撤销。 */
public canUndoCodeBlock(codeBlockId: Id): boolean {
return this.state.codeBlockState[codeBlockId]?.canUndo() ?? false;
}
/** 是否可对指定代码块重做。 */
public canRedoCodeBlock(codeBlockId: Id): boolean {
return this.state.codeBlockState[codeBlockId]?.canRedo() ?? false;
}
/** 撤销指定数据源的最近一次变更。 */
public undoDataSource(dataSourceId: Id): DataSourceStepValue | null {
const undoRedo = this.state.dataSourceState[dataSourceId];
if (!undoRedo) return null;
const step = undoRedo.undo();
if (step) this.emit('data-source-history-change', dataSourceId, step);
return step;
}
/** 重做指定数据源的下一次变更。 */
public redoDataSource(dataSourceId: Id): DataSourceStepValue | null {
const undoRedo = this.state.dataSourceState[dataSourceId];
if (!undoRedo) return null;
const step = undoRedo.redo();
if (step) this.emit('data-source-history-change', dataSourceId, step);
return step;
}
/** 是否可对指定数据源撤销。 */
public canUndoDataSource(dataSourceId: Id): boolean {
return this.state.dataSourceState[dataSourceId]?.canUndo() ?? false;
}
/** 是否可对指定数据源重做。 */
public canRedoDataSource(dataSourceId: Id): boolean {
return this.state.dataSourceState[dataSourceId]?.canRedo() ?? false;
}
public undo(): StepValue | null { public undo(): StepValue | null {
const undoRedo = this.getUndoRedo(); const undoRedo = this.getUndoRedo();
if (!undoRedo) return null; if (!undoRedo) return null;
@ -101,9 +457,306 @@ class History extends BaseService {
this.removeAllPlugins(); this.removeAllPlugins();
} }
private getUndoRedo() { /**
if (!this.state.pageId) return null; *
return this.state.pageSteps[this.state.pageId]; * / DSL/
*/
public clearPage(pageId?: Id): void {
const targetPageId = pageId ?? this.state.pageId;
if (!targetPageId) return;
this.state.pageSteps[targetPageId] = new UndoRedo<StepValue>();
if (`${targetPageId}` === `${this.state.pageId}`) {
this.setCanUndoRedo();
this.emit('change', null);
}
}
/**
* `dataSourceId`
* /
*/
public clearDataSource(dataSourceId?: Id): void {
if (dataSourceId !== undefined) {
delete this.state.dataSourceState[dataSourceId];
} else {
this.state.dataSourceState = {};
}
}
/**
* `codeBlockId`
* /
*/
public clearCodeBlock(codeBlockId?: Id): void {
if (codeBlockId !== undefined) {
delete this.state.codeBlockState[codeBlockId];
} else {
this.state.codeBlockState = {};
}
}
/**
* DSL / / `saved`
*
* {@link markPageSaved} / {@link markCodeBlockSaved} / {@link markDataSourceSaved}
*/
public markSaved(): void {
Object.values(this.state.pageSteps).forEach(History.markStackSaved);
Object.values(this.state.codeBlockState).forEach(History.markStackSaved);
Object.values(this.state.dataSourceState).forEach(History.markStackSaved);
this.emit('mark-saved', { kind: 'all' });
}
/**
*
* / /
*/
public markPageSaved(pageId?: Id): void {
const targetPageId = pageId ?? this.state.pageId;
if (!targetPageId) return;
History.markStackSaved(this.state.pageSteps[targetPageId]);
this.emit('mark-saved', { kind: 'page', id: targetPageId });
}
/** 标记指定代码块的历史栈当前记录为已保存,仅影响该代码块自己的栈。 */
public markCodeBlockSaved(codeBlockId: Id): void {
if (!codeBlockId) return;
History.markStackSaved(this.state.codeBlockState[codeBlockId]);
this.emit('mark-saved', { kind: 'code-block', id: codeBlockId });
}
/** 标记指定数据源的历史栈当前记录为已保存,仅影响该数据源自己的栈。 */
public markDataSourceSaved(dataSourceId: Id): void {
if (!dataSourceId) return;
History.markStackSaved(this.state.dataSourceState[dataSourceId]);
this.emit('mark-saved', { kind: 'data-source', id: dataSourceId });
}
/**
* / / IndexedDB
*
* - UndoRedo undo/redo
* - `key` / store `default`
* - 便 savedAt
* - IndexedDB SSR reject
*/
public async saveToIndexedDB(options: HistoryPersistOptions = {}): Promise<PersistedHistoryState> {
const { dbName = DEFAULT_DB_NAME, storeName = DEFAULT_STORE_NAME, key = DEFAULT_KEY } = options;
const snapshot: PersistedHistoryState = {
version: PERSIST_VERSION,
pageId: this.state.pageId,
pageSteps: History.serializeStacks(this.state.pageSteps),
codeBlockState: History.serializeStacks(this.state.codeBlockState),
dataSourceState: History.serializeStacks(this.state.dataSourceState),
savedAt: Date.now(),
};
// 历史记录里可能包含函数(如代码块内容 / 节点事件 / 数据源方法IndexedDB 的结构化克隆无法写入函数,
// 因此用 serialize-javascript 序列化成字符串后再写入(支持函数 / Map 等),读取时用 parseDSL 还原。
await idbSet(this.resolveDbName(dbName), storeName, key, serialize(snapshot));
this.emit('save-to-indexed-db', snapshot);
return snapshot;
}
/**
* IndexedDB /
*
* - {@link UndoRedo.fromSerialized} undo/redo
* - pageId
* - null
* - IndexedDB SSR reject
*/
public async restoreFromIndexedDB(options: HistoryPersistOptions = {}): Promise<PersistedHistoryState | null> {
const { dbName = DEFAULT_DB_NAME, storeName = DEFAULT_STORE_NAME, key = DEFAULT_KEY } = options;
const raw = await idbGet<string | PersistedHistoryState>(this.resolveDbName(dbName), storeName, key);
if (!raw) return null;
// 新版以序列化字符串存储(含函数),用 parseDSL 还原;兼容历史上以对象形式存入的旧数据。
const snapshot = (typeof raw === 'string' ? getEditorConfig('parseDSL')(`(${raw})`) : raw) as PersistedHistoryState;
if (!snapshot) return null;
this.state.pageSteps = History.deserializeStacks(snapshot.pageSteps);
this.state.codeBlockState = History.deserializeStacks(snapshot.codeBlockState);
this.state.dataSourceState = History.deserializeStacks(snapshot.dataSourceState);
this.state.pageId = snapshot.pageId;
this.setCanUndoRedo();
this.emit('restore-from-indexed-db', snapshot);
this.emit('change', null);
return snapshot;
}
/**
* +
*
* UI 使 `getPageHistoryGroups` /
*/
public getPageStepList(pageId?: Id): PageHistoryStepEntry[] {
const targetPageId = pageId ?? this.state.pageId;
if (!targetPageId) return [];
const undoRedo = this.state.pageSteps[targetPageId];
if (!undoRedo) return [];
const list = undoRedo.getElementList();
const cursor = undoRedo.getCursor();
return list.map((step, index) => ({
step,
index,
applied: index < cursor,
}));
}
/**
* "目标节点"
* - update group
* - add / remove / update
* "页面"tab
*/
public getPageHistoryGroups(pageId?: Id): PageHistoryGroup[] {
const targetPageId = pageId ?? this.state.pageId;
if (!targetPageId) return [];
const undoRedo = this.state.pageSteps[targetPageId];
if (!undoRedo) return [];
const list = undoRedo.getElementList();
if (!list.length) return [];
const cursor = undoRedo.getCursor();
return History.mergePageSteps(targetPageId, list, cursor);
}
/**
* codeBlockId
* opType id group
* - "代码块/数据源各自按 id 分栈""连续修改同目标的相邻步骤合并展示"
* - group UI changeRecords
* - applied
*/
public getCodeBlockHistoryGroups(): CodeBlockHistoryGroup[] {
const groups: CodeBlockHistoryGroup[] = [];
Object.entries(this.state.codeBlockState).forEach(([id, undoRedo]) => {
if (!undoRedo) return;
const list = undoRedo.getElementList();
if (!list.length) return;
const cursor = undoRedo.getCursor();
groups.push(...History.mergeStackSteps('code-block', id, list, cursor));
});
return groups;
}
/**
*
* 0
*/
public getPageCursor(pageId?: Id): number {
const targetPageId = pageId ?? this.state.pageId;
if (!targetPageId) return 0;
return this.state.pageSteps[targetPageId]?.getCursor() ?? 0;
}
/** 读取指定代码块历史栈的当前游标。 */
public getCodeBlockCursor(codeBlockId: Id): number {
return this.state.codeBlockState[codeBlockId]?.getCursor() ?? 0;
}
/** 读取指定数据源历史栈的当前游标。 */
public getDataSourceCursor(dataSourceId: Id): number {
return this.state.dataSourceState[dataSourceId]?.getCursor() ?? 0;
}
/**
* applied revert index 使
*/
public getCodeBlockStepList(codeBlockId: Id): { step: CodeBlockStepValue; index: number; applied: boolean }[] {
const undoRedo = this.state.codeBlockState[codeBlockId];
if (!undoRedo) return [];
const list = undoRedo.getElementList();
const cursor = undoRedo.getCursor();
return list.map((step, index) => ({ step, index, applied: index < cursor }));
}
/**
* applied revert index 使
*/
public getDataSourceStepList(dataSourceId: Id): { step: DataSourceStepValue; index: number; applied: boolean }[] {
const undoRedo = this.state.dataSourceState[dataSourceId];
if (!undoRedo) return [];
const list = undoRedo.getElementList();
const cursor = undoRedo.getCursor();
return list.map((step, index) => ({ step, index, applied: index < cursor }));
}
/**
* uuid
* -1 uuid uuid index 使
*/
public getPageStepIndexByUuid(uuid: string, pageId?: Id): number {
if (!uuid) return -1;
return this.getPageStepList(pageId).findIndex((entry) => entry.step.uuid === uuid);
}
/**
* uuid codeBlockId
* null
*/
public findCodeBlockStepLocationByUuid(uuid: string): { id: Id; index: number } | null {
if (!uuid) return null;
for (const id of Object.keys(this.state.codeBlockState)) {
const index = this.getCodeBlockStepList(id).findIndex((entry) => entry.step.uuid === uuid);
if (index >= 0) return { id, index };
}
return null;
}
/**
* uuid dataSourceId
* null
*/
public findDataSourceStepLocationByUuid(uuid: string): { id: Id; index: number } | null {
if (!uuid) return null;
for (const id of Object.keys(this.state.dataSourceState)) {
const index = this.getDataSourceStepList(id).findIndex((entry) => entry.step.uuid === uuid);
if (index >= 0) return { id, index };
}
return null;
}
/**
* dataSourceId
*/
public getDataSourceHistoryGroups(): DataSourceHistoryGroup[] {
const groups: DataSourceHistoryGroup[] = [];
Object.entries(this.state.dataSourceState).forEach(([id, undoRedo]) => {
if (!undoRedo) return;
const list = undoRedo.getElementList();
if (!list.length) return;
const cursor = undoRedo.getCursor();
groups.push(...History.mergeStackSteps('data-source', id, list, cursor));
});
return groups;
}
/**
* pageId
*
* push
* Ctrl+Z
*/
private getUndoRedo(pageId?: Id) {
const targetPageId = pageId ?? this.state.pageId;
if (!targetPageId) return null;
if (!this.state.pageSteps[targetPageId]) {
this.state.pageSteps[targetPageId] = new UndoRedo<StepValue>();
}
return this.state.pageSteps[targetPageId];
}
/**
* dbName DSLroot app id
* app id DSL退 dbName
*/
private resolveDbName(dbName: string): string {
const appId = editorService.get('root')?.id;
return appId ? `${dbName}-${appId}` : dbName;
} }
private setCanUndoRedo(): void { private setCanUndoRedo(): void {
@ -111,6 +764,50 @@ class History extends BaseService {
this.state.canRedo = undoRedo?.canRedo() || false; this.state.canRedo = undoRedo?.canRedo() || false;
this.state.canUndo = undoRedo?.canUndo() || false; this.state.canUndo = undoRedo?.canUndo() || false;
} }
/**
* / id payload
*
* - `add`oldValue = null`remove`newValue = null`update` changeRecords
* - cloneDeep opType old/new null
* - step emit pushCodeBlock / pushDataSource
* - service
*/
private createStackStep<T, S extends BaseStepValue<T> & { id: Id }>(
id: Id,
payload: {
oldValue: T | null;
newValue: T | null;
changeRecords?: ChangeRecord[];
historyDescription?: string;
source?: HistoryOpSource;
},
): S | null {
if (!id) return null;
const oldSchema = payload.oldValue ? cloneDeep(payload.oldValue) : null;
const newSchema = payload.newValue ? cloneDeep(payload.newValue) : null;
const changeRecords = payload.changeRecords?.length ? cloneDeep(payload.changeRecords) : undefined;
const opType = History.detectOpType(payload.oldValue, payload.newValue);
const step: BaseStepValue<T> & { id: Id } = {
uuid: guid(),
id,
opType,
diff: [
{
...(newSchema !== null ? { newSchema } : {}),
...(oldSchema !== null ? { oldSchema } : {}),
...(opType === 'update' && changeRecords ? { changeRecords } : {}),
},
],
historyDescription: payload.historyDescription,
source: payload.source,
timestamp: Date.now(),
};
return step as S;
}
} }
export type HistoryService = History; export type HistoryService = History;

View File

@ -20,7 +20,7 @@ class Keybinding extends BaseService {
const nodes = editorService.get('nodes'); const nodes = editorService.get('nodes');
if (!nodes || isPage(nodes[0]) || isPageFragment(nodes[0])) return; if (!nodes || isPage(nodes[0]) || isPageFragment(nodes[0])) return;
editorService.remove(nodes); editorService.remove(nodes, { historySource: 'shortcut' });
}, },
[KeyBindingCommand.COPY_NODE]: () => { [KeyBindingCommand.COPY_NODE]: () => {
const nodes = editorService.get('nodes'); const nodes = editorService.get('nodes');
@ -31,11 +31,11 @@ class Keybinding extends BaseService {
if (!nodes || isPage(nodes[0]) || isPageFragment(nodes[0])) return; if (!nodes || isPage(nodes[0]) || isPageFragment(nodes[0])) return;
editorService.copy(nodes); editorService.copy(nodes);
editorService.remove(nodes); editorService.remove(nodes, { historySource: 'shortcut' });
}, },
[KeyBindingCommand.PASTE_NODE]: () => { [KeyBindingCommand.PASTE_NODE]: () => {
const nodes = editorService.get('nodes'); const nodes = editorService.get('nodes');
nodes && editorService.paste({ offsetX: 10, offsetY: 10 }); nodes && editorService.paste({ offsetX: 10, offsetY: 10 }, undefined, { historySource: 'shortcut' });
}, },
[KeyBindingCommand.UNDO]: () => { [KeyBindingCommand.UNDO]: () => {
editorService.undo(); editorService.undo();

View File

@ -97,9 +97,9 @@ class Props extends BaseService {
return this.state.propsConfigMap; return this.state.propsConfigMap;
} }
public async fillConfig(config: FormConfig, labelWidth?: string) { public async fillConfig(config: FormConfig, labelWidth = '80px') {
return fillConfig(config, { return fillConfig(config, {
labelWidth: typeof labelWidth !== 'function' ? labelWidth : '80px', labelWidth,
disabledDataSource: this.getDisabledDataSource(), disabledDataSource: this.getDisabledDataSource(),
disabledCodeBlock: this.getDisabledCodeBlock(), disabledCodeBlock: this.getDisabledCodeBlock(),
}); });

View File

@ -0,0 +1,512 @@
.m-editor-history-list-popover {
padding: 0 !important;
.m-editor-history-list {
position: relative;
padding: 4px 8px 8px;
}
// 关闭按钮悬浮在 tab 标题同一行绝对定位不占布局空间
// top 对齐容器顶部内边距height el-tabs/t-tabs 头部默认高度一致
// 再用 flex 居中让图标与 tab 标题视觉对齐
.m-editor-history-list-close {
position: absolute;
top: 4px;
right: 4px;
z-index: 2;
display: flex;
align-items: center;
height: 40px;
margin: 0;
color: #909399;
&:hover {
color: #409eff;
}
}
.m-editor-history-list-tabs {
.el-tabs__header,
.t-tabs__header {
margin-bottom: 4px;
}
}
.m-editor-history-list-empty {
padding: 24px 0;
text-align: center;
color: #909399;
font-size: 12px;
}
.m-editor-history-list-ul {
margin: 0;
padding: 0;
list-style: none;
}
// 历史列表工具条放置清空等列表级操作右对齐
.m-editor-history-list-toolbar {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 0 4px 4px;
}
// 清空按钮红色文字按钮强调破坏性操作点击后会二次确认
.m-editor-history-list-clear {
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
color: #f56c6c;
cursor: pointer;
user-select: none;
&:hover {
background-color: rgba(245, 108, 108, 0.12);
}
}
.m-editor-history-list-item {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
border-radius: 4px;
font-size: 12px;
line-height: 1.4;
color: #303133;
cursor: default;
// 合并组卡片自己定义了 hover/active 视觉跳过通用单步 hover 避免色彩叠加
&:not(.m-editor-history-list-group.is-merged):hover {
background-color: rgba(0, 0, 0, 0.04);
}
&.is-undone {
color: #c0c4cc;
.m-editor-history-list-item-op {
opacity: 0.5;
}
}
// 当前所在的单步记录左侧蓝条 + 浅蓝底
// 合并组的当前态由 `.is-merged.is-current` 单独覆盖border-left + 卡片背景
// 故这里仅作用于非合并组的单步条目避免与卡片样式互相干扰
&.is-current:not(.m-editor-history-list-group.is-merged) {
background-color: rgba(64, 158, 255, 0.1);
box-shadow: inset 2px 0 0 #409eff;
&:hover {
background-color: rgba(64, 158, 255, 0.16);
}
.m-editor-history-list-item-desc {
font-weight: 600;
color: #409eff;
}
}
}
.m-editor-history-list-group {
flex-direction: column;
align-items: stretch;
gap: 0;
.m-editor-history-list-group-head {
display: flex;
align-items: center;
gap: 6px;
cursor: default;
&.is-clickable {
cursor: pointer;
}
}
.m-editor-history-list-group-toggle {
flex: 0 0 auto;
width: 16px;
text-align: center;
color: #909399;
font-size: 12px;
user-select: none;
transition: transform 0.15s ease;
pointer-events: none;
&.is-expanded {
transform: rotate(180deg);
}
}
// 合并组2 个连续同节点的修改被聚合需要与单步条目明显区分
// - 左侧 3px 紫色色条暗示这是一组
// - 浅紫背景 + 边框把整个组视觉化为一张卡片
// - 头部加 padding避免和单步条目混在一起难以辨认
&.is-merged {
margin: 4px 0;
padding: 4px 8px 6px;
background-color: rgba(47, 84, 235, 0.06);
border: 1px solid rgba(47, 84, 235, 0.18);
border-left: 3px solid #2f54eb;
border-radius: 4px;
// 卡片本体已经有背景色hover 状态以更深的同色提示交互
&:hover {
background-color: rgba(47, 84, 235, 0.1);
}
.m-editor-history-list-group-head {
font-weight: 600;
color: #1d39c4;
}
// 已撤销态整张卡片去色
&.is-undone {
background-color: rgba(192, 196, 204, 0.08);
border-color: rgba(192, 196, 204, 0.4);
border-left-color: #c0c4cc;
.m-editor-history-list-group-head {
color: #c0c4cc;
}
}
// 当前组卡片左条变蓝与单步当前高亮一致
&.is-current {
background-color: rgba(64, 158, 255, 0.08);
border-color: rgba(64, 158, 255, 0.3);
border-left-color: #409eff;
box-shadow: none; // 覆盖 .is-current 公共的 inset 阴影
.m-editor-history-list-group-head {
color: #409eff;
}
}
}
}
.m-editor-history-list-substeps {
margin: 6px 0 0 6px;
padding: 0;
list-style: none;
border-left: 1px dashed rgba(47, 84, 235, 0.45);
li {
display: flex;
align-items: center;
gap: 6px;
padding: 3px 8px;
font-size: 11px;
color: #606266;
cursor: default;
border-radius: 3px;
&.is-clickable {
cursor: pointer;
&:hover {
background-color: rgba(47, 84, 235, 0.1);
}
}
&.is-undone {
color: #c0c4cc;
}
&.is-current {
color: #409eff;
font-weight: 600;
background-color: rgba(64, 158, 255, 0.08);
border-radius: 3px;
}
}
}
.m-editor-history-list-item-current {
flex: 0 0 auto;
padding: 0 6px;
border-radius: 2px;
font-size: 10px;
line-height: 16px;
color: #fff;
background-color: #409eff;
font-weight: 500;
}
.m-editor-history-list-item-index {
flex: 0 0 auto;
color: #909399;
font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
font-size: 11px;
font-weight: 400; // 防止被合并组头部的粗体继承
white-space: nowrap;
}
// 操作时间弱化展示紧贴在描述之后各操作按钮之前
.m-editor-history-list-item-time {
flex: 0 0 auto;
color: #a8abb2;
font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
font-size: 11px;
font-weight: 400; // 防止被合并组头部的粗体继承
white-space: nowrap;
}
.m-editor-history-list-item-op {
flex: 0 0 auto;
padding: 0 6px;
border-radius: 2px;
font-size: 11px;
line-height: 18px;
color: #fff;
background-color: #909399;
&.op-add {
background-color: #67c23a;
}
&.op-remove {
background-color: #f56c6c;
}
&.op-update {
background-color: #e6a23c;
}
&.op-initial {
background-color: #909399;
}
}
.m-editor-history-list-initial {
cursor: default;
color: #606266;
border-top: 1px dashed #dcdfe6;
margin-top: 4px;
padding-top: 8px;
&.is-clickable {
cursor: pointer;
}
.m-editor-history-list-item-desc {
font-style: italic;
}
}
.m-editor-history-list-item-desc {
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
// 操作途径徽标浅灰描边胶囊弱化展示来源画布 / 图层 / 配置面板不抢占描述焦点
.m-editor-history-list-item-source {
flex: 0 0 auto;
padding: 0 6px;
border: 1px solid #dcdfe6;
border-radius: 8px;
font-size: 10px;
line-height: 14px;
color: #909399;
background-color: #f4f4f5;
white-space: nowrap;
font-weight: 400; // 防止被合并组头部的粗体继承
}
// 已保存徽标绿色实心胶囊标记最近一次保存对应的历史记录 historyService.markSaved 对应
.m-editor-history-list-item-saved {
flex: 0 0 auto;
padding: 0 6px;
border-radius: 8px;
font-size: 10px;
line-height: 16px;
color: #fff;
background-color: #67c23a;
white-space: nowrap;
font-weight: 500;
letter-spacing: 0.2px;
}
// 合并 N 徽标紫色实心胶囊与合并组卡片色系一致醒目区分单步条目
.m-editor-history-list-item-merge {
flex: 0 0 auto;
padding: 0 8px;
border-radius: 8px;
font-size: 10px;
line-height: 16px;
color: #fff;
background-color: #2f54eb;
font-weight: 500;
letter-spacing: 0.2px;
}
.m-editor-history-list-item-diff {
flex: 0 0 auto;
padding: 0 6px;
border-radius: 2px;
font-size: 10px;
line-height: 16px;
color: #409eff;
background-color: rgba(64, 158, 255, 0.1);
cursor: pointer;
user-select: none;
&:hover {
background-color: rgba(64, 158, 255, 0.2);
}
}
// 跳转按钮将历史游标移动到该 step替代原先点击整行跳转的交互
// 使用与组卡片一致的紫色色系查看差异回滚区分开
.m-editor-history-list-item-goto {
flex: 0 0 auto;
padding: 0 6px;
border-radius: 2px;
font-size: 10px;
line-height: 16px;
color: #606266;
background-color: rgba(96, 98, 102, 0.1);
cursor: pointer;
user-select: none;
&:hover {
background-color: rgba(96, 98, 102, 0.18);
}
}
// 回滚按钮 git revert把目标 step 反向应用一次作为新提交
// 使用红色色调强调其为"破坏性/可逆操作"查看差异跳转区分开
.m-editor-history-list-item-revert {
flex: 0 0 auto;
padding: 0 6px;
border-radius: 2px;
font-size: 10px;
line-height: 16px;
color: #f56c6c;
background-color: rgba(245, 108, 108, 0.12);
cursor: pointer;
user-select: none;
&:hover {
background-color: rgba(245, 108, 108, 0.25);
}
}
.m-editor-history-list-substep-desc {
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.m-editor-history-list-bucket {
margin-bottom: 8px;
&:last-child {
margin-bottom: 0;
}
}
.m-editor-history-list-bucket-title {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
font-size: 12px;
font-weight: 500;
color: #606266;
background-color: #f5f7fa;
border-radius: 4px;
code {
flex: 1 1 auto;
font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
font-size: 11px;
color: #409eff;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.m-editor-history-list-bucket-count {
flex: 0 0 auto;
color: #909399;
font-weight: 400;
font-size: 11px;
}
}
.m-editor-history-diff-dialog {
.m-editor-history-diff-dialog-body {
display: flex;
flex-direction: column;
}
.m-editor-history-diff-dialog-notice {
margin-bottom: 8px;
padding: 8px 12px;
background-color: #fdf6ec;
border: 1px solid #faecd8;
border-radius: 4px;
color: #e6a23c;
font-size: 13px;
line-height: 1.5;
}
.m-editor-history-diff-dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
padding: 8px 12px;
background-color: #f5f7fa;
border-radius: 4px;
gap: 12px;
}
.m-editor-history-diff-dialog-controls {
display: flex;
align-items: center;
flex: 0 0 auto;
gap: 8px;
}
.m-editor-history-diff-dialog-view,
.m-editor-history-diff-dialog-mode {
flex: 0 0 auto;
}
.m-editor-history-diff-dialog-target {
flex: 1 1 auto;
font-size: 13px;
font-weight: 500;
color: #303133;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.m-editor-history-diff-dialog-legend {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 12px;
padding: 0 4px;
}
.m-editor-history-diff-dialog-arrow {
color: #909399;
font-size: 12px;
}
.m-editor-history-diff-dialog-tip {
margin-left: 8px;
color: #e6a23c;
font-size: 12px;
}
}

View File

@ -25,26 +25,6 @@
right: calc(15px + var(--props-style-panel-width)); right: calc(15px + var(--props-style-panel-width));
} }
} }
.tmagic-design-form {
padding-right: 10px;
padding-left: 10px;
> .m-container-tab {
> .tmagic-design-tabs {
> .el-tabs__content {
padding-top: 55px;
}
> .el-tabs__header.is-top {
position: absolute;
top: 0;
width: 100%;
background: #fff;
z-index: 3;
}
}
}
}
} }
.m-editor-props-style-panel { .m-editor-props-style-panel {
@ -78,7 +58,7 @@
position: absolute; position: absolute;
right: 15px; right: 15px;
bottom: 15px; bottom: 15px;
z-index: 30; z-index: 32;
opacity: 0.5; opacity: 0.5;
&:hover { &:hover {
@ -90,7 +70,7 @@
position: absolute; position: absolute;
right: 15px; right: 15px;
bottom: 60px; bottom: 60px;
z-index: 30; z-index: 31;
opacity: 0.5; opacity: 0.5;
&:hover { &:hover {
@ -102,7 +82,7 @@
position: absolute; position: absolute;
left: 0; left: 0;
top: 0; top: 0;
z-index: 10; z-index: 31;
} }
.m-editor-resizer { .m-editor-resizer {
@ -142,6 +122,26 @@
} }
} }
.m-editor-props-form-panel-form {
padding-right: 10px;
padding-left: 10px;
> .m-container-tab {
> .tmagic-design-tabs {
> .el-tabs__content {
padding-top: 55px;
}
> .el-tabs__header.is-top {
position: absolute;
top: 0;
width: 100%;
background: #fff;
z-index: 3;
}
}
}
}
.m-editor-props-panel-popper { .m-editor-props-panel-popper {
&.small { &.small {
span, span,

View File

@ -1,5 +1,6 @@
@use "./search-input.scss"; @use "./search-input.scss";
@use "./nav-menu.scss"; @use "./nav-menu.scss";
@use "./history-list-panel.scss";
@use "./framework.scss"; @use "./framework.scss";
@use "./sidebar.scss"; @use "./sidebar.scss";
@use "./layer-panel.scss"; @use "./layer-panel.scss";

View File

@ -22,7 +22,17 @@ import type * as Monaco from 'monaco-editor';
import type { default as Sortable, Options, SortableEvent } from 'sortablejs'; import type { default as Sortable, Options, SortableEvent } from 'sortablejs';
import type { PascalCasedProperties, Writable } from 'type-fest'; import type { PascalCasedProperties, Writable } from 'type-fest';
import type { CodeBlockContent, CodeBlockDSL, Id, MApp, MContainer, MNode, MPage, MPageFragment } from '@tmagic/core'; import type {
CodeBlockContent,
CodeBlockDSL,
DataSourceSchema,
Id,
MApp,
MContainer,
MNode,
MPage,
MPageFragment,
} from '@tmagic/core';
import type { ChangeRecord, FormConfig, TableColumnConfig, TypeFunction } from '@tmagic/form'; import type { ChangeRecord, FormConfig, TableColumnConfig, TypeFunction } from '@tmagic/form';
import type StageCore from '@tmagic/stage'; import type StageCore from '@tmagic/stage';
import type { import type {
@ -46,7 +56,7 @@ import type { PropsService } from './services/props';
import type { StageOverlayService } from './services/stageOverlay'; import type { StageOverlayService } from './services/stageOverlay';
import type { StorageService } from './services/storage'; import type { StorageService } from './services/storage';
import type { UiService } from './services/ui'; import type { UiService } from './services/ui';
import type { UndoRedo } from './utils/undo-redo'; import type { SerializedUndoRedo, UndoRedo } from './utils/undo-redo';
export type EditorSlots = FrameworkSlots & export type EditorSlots = FrameworkSlots &
WorkspaceSlots & WorkspaceSlots &
@ -138,7 +148,7 @@ export interface EditorInstallOptions {
customCreateMonacoDiffEditor: ( customCreateMonacoDiffEditor: (
monaco: typeof import('monaco-editor'), monaco: typeof import('monaco-editor'),
codeEditorEl: HTMLElement, codeEditorEl: HTMLElement,
options: Monaco.editor.IStandaloneEditorConstructionOptions & { editorCustomType?: string }, options: Monaco.editor.IStandaloneDiffEditorConstructionOptions & { editorCustomType?: string },
) => Promise<Monaco.editor.IStandaloneDiffEditor> | Monaco.editor.IStandaloneDiffEditor; ) => Promise<Monaco.editor.IStandaloneDiffEditor> | Monaco.editor.IStandaloneDiffEditor;
[key: string]: any; [key: string]: any;
} }
@ -189,6 +199,11 @@ export interface StageOptions {
*/ */
alwaysMultiSelect?: boolean; alwaysMultiSelect?: boolean;
disabledRule?: boolean; disabledRule?: boolean;
/**
*
* false
*/
disabledFlashTip?: boolean;
zoom?: number; zoom?: number;
/** 画布双击前的钩子函数,返回 false 则阻止默认的双击行为 */ /** 画布双击前的钩子函数,返回 false 则阻止默认的双击行为 */
beforeDblclick?: (event: MouseEvent) => Promise<boolean | void> | boolean | void; beforeDblclick?: (event: MouseEvent) => Promise<boolean | void> | boolean | void;
@ -407,6 +422,7 @@ export interface MenuComponent {
* 'rule': * 'rule':
* 'scale-to-original': * 'scale-to-original':
* 'scale-to-fit': * 'scale-to-fit':
* 'history-list': / / tab
*/ */
// #region MenuItem // #region MenuItem
export type MenuItem = export type MenuItem =
@ -421,6 +437,7 @@ export type MenuItem =
| 'rule' | 'rule'
| 'scale-to-original' | 'scale-to-original'
| 'scale-to-fit' | 'scale-to-fit'
| 'history-list'
| MenuButton | MenuButton
| MenuComponent | MenuComponent
| string; | string;
@ -463,6 +480,63 @@ export interface SideComponent extends MenuComponent {
} }
// #endregion SideComponent // #endregion SideComponent
// #region HistoryListExtraTab
/**
* HistoryListPanel tab
*
* Editor `historyListExtraTabs` tab
*
* tab / / tab
* tab
*/
export interface HistoryListExtraTab {
/** tab 唯一标识,作为 TMagicTabs 的 name */
name: string;
/** tab 显示文案,支持传入函数以展示动态内容(如记录数量) */
label: string | (() => string);
/** tab 内容区渲染的组件Vue 组件或字符串标签) */
component: any;
/** 传入内容组件的 props */
props?: Record<string, any>;
/** 内容组件的事件监听 */
listeners?: Record<string, (..._args: any[]) => any>;
}
// #endregion HistoryListExtraTab
// #region CompareForm
/**
* CompareForm
* - node: 节点组件 `type` propsService
* - data-source: 数据源 `type`(base/http/...) dataSourceService
* - code-block: 数据源代码块使
*/
export type CompareCategory = 'node' | 'data-source' | 'code-block' | string;
/**
* `loadConfig`
* 便 FormConfig
*/
export interface CompareFormLoadConfigContext {
/** 对比类型,见 CompareCategory */
category: string;
/** 节点 / 数据源类型 */
type?: string;
/** 数据源代码块场景下的数据源类型 */
dataSourceType?: string;
/**
* FormConfig `category` propsService / dataSourceService /
* `loadConfig`
*/
defaultLoadConfig: () => Promise<FormConfig>;
}
/**
* FormConfig `category`
* `ctx.defaultLoadConfig()`
*/
export type CompareFormLoadConfig = (ctx: CompareFormLoadConfigContext) => FormConfig | Promise<FormConfig>;
// #endregion CompareForm
// #region SideItemKey // #region SideItemKey
export enum SideItemKey { export enum SideItemKey {
COMPONENT_LIST = 'component-list', COMPONENT_LIST = 'component-list',
@ -609,36 +683,281 @@ export interface CodeParamStatement {
export type HistoryOpType = 'add' | 'remove' | 'update'; export type HistoryOpType = 'add' | 'remove' | 'update';
// #endregion HistoryOpType // #endregion HistoryOpType
// #region HistoryOpSource
/**
* /
* undo/redo UI
*
* - `stage` / /
* - `tree` / / /
* - `component-panel` /
* - `props`
* - `code` JSON/
* - `stage-contextmenu`
* - `tree-contextmenu` / /
* - `toolbar`
* - `shortcut`
* - `rollback` git revert
* - `api` /
* - `ai`AI /
* - `unknown`
*
* `(string & {})`
*/
export type HistoryOpSource =
| 'stage'
| 'tree'
| 'component-panel'
| 'props'
| 'code'
| 'stage-contextmenu'
| 'tree-contextmenu'
| 'toolbar'
| 'shortcut'
| 'rollback'
| 'api'
| 'ai'
| 'unknown'
| (string & {});
// #endregion HistoryOpSource
// #region StepDiffItem
/**
* diff / /
* {@link StepValue} / {@link CodeBlockStepValue} / {@link DataSourceStepValue} `diff`
*
* `opType`
* - `add` `newSchema` `parentId` / `index`
* - `remove` `oldSchema` `parentId` / `index`
* - `update``oldSchema` + `newSchema` `changeRecords`
*
* `T` `MNode` `CodeBlockContent` `DataSourceSchema`
*/
export interface StepDiffItem<T = unknown> {
/** 变更后的内容快照。`opType` 为 `add` / `update` 时有,`remove` 时无。 */
newSchema?: T;
/** 变更前的内容快照。`opType` 为 `remove` / `update` 时有,`add` 时无。 */
oldSchema?: T;
/** 父节点 id。仅页面节点有数据源 / 代码块没有父节点)。 */
parentId?: Id;
/** 在父节点 items 数组中的索引。仅页面节点有(数据源 / 代码块无需排序)。 */
index?: number;
/**
* form propPath/value `opType` `update`
* / propPath 退
*/
changeRecords?: ChangeRecord[];
}
// #endregion StepDiffItem
// #region BaseStepValue
/**
* {@link StepValue} / {@link CodeBlockStepValue} / {@link DataSourceStepValue}
*
* `T` `diff` `MNode` / `CodeBlockContent` / `DataSourceSchema`
*/
export interface BaseStepValue<T = unknown> {
/**
* uuid
* / revert
* `id` / / id
*/
uuid: string;
/** 操作类型:新增 / 删除 / 更新(三类历史记录统一携带)。 */
opType: HistoryOpType;
/**
* diff {@link StepDiffItem}
* add/remove update /
*/
diff: StepDiffItem<T>[];
/**
*
* undo/redo / propPath
*/
historyDescription?: string;
/**
* {@link HistoryOpSource}
* / / / / / / / / /
* undo/redo
*/
source?: HistoryOpSource;
/**
*
*/
timestamp?: number;
/**
* DSL / historyService.markSaved
* true IndexedDB
*/
saved?: boolean;
}
// #endregion BaseStepValue
// #region StepValue // #region StepValue
export interface StepValue { export interface StepValue extends BaseStepValue<MNode> {
/** 页面信息 */ /** 页面信息 */
data: { name: string; id: Id }; data: { name: string; id: Id };
opType: HistoryOpType;
/** 操作前选中的节点 ID用于撤销后恢复选择状态 */ /** 操作前选中的节点 ID用于撤销后恢复选择状态 */
selectedBefore: Id[]; selectedBefore: Id[];
/** 操作后选中的节点 ID用于重做后恢复选择状态 */ /** 操作后选中的节点 ID用于重做后恢复选择状态 */
selectedAfter: Id[]; selectedAfter: Id[];
modifiedNodeIds: Map<Id, Id>; modifiedNodeIds: Map<Id, Id>;
/** opType 'add': 新增的节点 */
nodes?: MNode[];
/** opType 'add': 父节点 ID */
parentId?: Id;
/** opType 'add': 每个新增节点在父节点 items 中的索引 */
indexMap?: Record<string, number>;
/** opType 'remove': 被删除的节点及其位置信息 */
removedItems?: { node: MNode; parentId: Id; index: number }[];
/** opType 'update': 变更前后的节点快照 */
updatedItems?: { oldNode: MNode; newNode: MNode }[];
} }
// #endregion StepValue // #endregion StepValue
// #region CodeBlockStepValue
/**
* codeBlock.id historyState.codeBlockState
* `diff` {@link StepDiffItem}
* - opType 'add' `newSchema`
* - opType 'update'`oldSchema` + `newSchema` `changeRecords`
* - opType 'remove' `oldSchema`
*/
export interface CodeBlockStepValue extends BaseStepValue<CodeBlockContent> {
/** 关联的代码块 id */
id: Id;
}
// #endregion CodeBlockStepValue
// #region DataSourceStepValue
/**
* dataSource.id historyState.dataSourceState
* `diff` {@link StepDiffItem}
* - opType 'add' `newSchema` schema
* - opType 'update'`oldSchema` + `newSchema` `changeRecords`
* - opType 'remove' `oldSchema` schema
*/
export interface DataSourceStepValue extends BaseStepValue<DataSourceSchema> {
/** 关联的数据源 id */
id: Id;
}
// #endregion DataSourceStepValue
export interface HistoryState { export interface HistoryState {
pageId?: Id; pageId?: Id;
pageSteps: Record<Id, UndoRedo<StepValue>>; pageSteps: Record<Id, UndoRedo<StepValue>>;
canRedo: boolean; canRedo: boolean;
canUndo: boolean; canUndo: boolean;
/**
* codeBlock.id UndoRedo
* / undo/redo
*/
codeBlockState: Record<Id, UndoRedo<CodeBlockStepValue>>;
/**
* dataSource.id UndoRedo
* / undo/redo
*/
dataSourceState: Record<Id, UndoRedo<DataSourceStepValue>>;
} }
// #region PersistedHistoryState
/**
* historyService.saveToIndexedDB IndexedDB
* historyService.restoreFromIndexedDB UndoRedo
*/
export interface PersistedHistoryState {
/** 快照结构版本号,便于后续兼容升级。 */
version: number;
/** 保存时的活动页 id。 */
pageId?: Id;
/** 各页面历史栈的序列化快照,按 pageId 分组。 */
pageSteps: Record<Id, SerializedUndoRedo<StepValue>>;
/** 各代码块历史栈的序列化快照,按 codeBlockId 分组。 */
codeBlockState: Record<Id, SerializedUndoRedo<CodeBlockStepValue>>;
/** 各数据源历史栈的序列化快照,按 dataSourceId 分组。 */
dataSourceState: Record<Id, SerializedUndoRedo<DataSourceStepValue>>;
/** 保存时间戳(毫秒)。 */
savedAt: number;
}
// #endregion PersistedHistoryState
// #region HistoryPersistOptions
/** historyService 持久化相关 API 的可选配置。 */
export interface HistoryPersistOptions {
/** IndexedDB 数据库名,默认 `tmagic-editor`(最终库名会拼上当前 DSL app id。 */
dbName?: string;
/** objectStore 名,默认 `history`。 */
storeName?: string;
/** 记录 key用于区分不同活动页 / 项目,默认 `default`。 */
key?: IDBValidKey;
}
// #endregion HistoryPersistOptions
// #region HistoryListEntry
/**
*
*/
export interface PageHistoryStepEntry {
/** 步骤内容 */
step: StepValue;
/** 在所属栈中的索引0 为最早) */
index: number;
/** 是否处于"已应用"段(即位于栈游标之前)。撤销后变为 false。 */
applied: boolean;
/** 是否为当前所在的步骤(栈中最近一次已应用的那一步,即 index === cursor - 1。 */
isCurrent?: boolean;
}
/**
*
* - updatedItems[0].oldNode.id 'update'
* - / add / remove
* - targetId undefined "无明确目标" add/remove/ update
*/
export interface PageHistoryGroup {
kind: 'page';
/** 所属页面 id */
pageId: Id;
/** 该分组的操作类型 */
opType: HistoryOpType;
/**
* id"单节点 update" id id update
* undefined add / remove / update
*/
targetId?: Id;
/** 目标节点的可读名(取最后一步的 newNode.name/type/id */
targetName?: string;
/** 组内所有步骤,按时间正序 */
steps: PageHistoryStepEntry[];
/** 组内最后一步是否已应用 */
applied: boolean;
/** 是否为当前所在的分组(包含栈中最近一次已应用步骤的那一组)。 */
isCurrent?: boolean;
}
/**
*
* - codeBlockId 'update' group
* - 'add' / 'remove'
*/
export interface CodeBlockHistoryGroup {
kind: 'code-block';
/** 关联的 codeBlock id */
id: Id;
/** 该分组的操作类型 */
opType: HistoryOpType;
/** 组内所有步骤,按时间正序 */
steps: { step: CodeBlockStepValue; index: number; applied: boolean; isCurrent?: boolean }[];
/** 组内最后一步是否已应用,用于整组的状态展示 */
applied: boolean;
/** 是否为当前所在的分组(包含该栈最近一次已应用步骤的那一组)。 */
isCurrent?: boolean;
}
/**
* CodeBlockHistoryGroup
*/
export interface DataSourceHistoryGroup {
kind: 'data-source';
id: Id;
opType: HistoryOpType;
steps: { step: DataSourceStepValue; index: number; applied: boolean; isCurrent?: boolean }[];
applied: boolean;
/** 是否为当前所在的分组(包含该栈最近一次已应用步骤的那一组)。 */
isCurrent?: boolean;
}
// #endregion HistoryListEntry
export enum KeyBindingCommand { export enum KeyBindingCommand {
/** 复制 */ /** 复制 */
COPY_NODE = 'tmagic-system-copy-node', COPY_NODE = 'tmagic-system-copy-node',
@ -873,3 +1192,59 @@ export const canUsePluginMethods = {
}; };
export type AsyncMethodName = Writable<(typeof canUsePluginMethods)['async']>; export type AsyncMethodName = Writable<(typeof canUsePluginMethods)['async']>;
// #region HistoryOpOptions
/**
* codeBlock / dataSource / editor
* - doNotPushHistory: 操作完成后是否不要将本次操作压入历史栈/ false
* - historyDescription: 入栈时附带的人类可读描述 undo/redo
* - historySource: 操作途径 {@link HistoryOpSource} / / / / / / / / / undo/redo
*/
export interface HistoryOpOptions {
doNotPushHistory?: boolean;
historyDescription?: string;
historySource?: HistoryOpSource;
}
// #endregion HistoryOpOptions
// #region HistoryOpOptionsWithChangeRecords
/**
* HistoryOpOptions form propPath/value
* / propPath patch
*/
export interface HistoryOpOptionsWithChangeRecords extends HistoryOpOptions {
changeRecords?: ChangeRecord[];
}
// #endregion HistoryOpOptionsWithChangeRecords
// #region DslOpOptions
/**
* DSL
* - doNotSelect: 操作后是否不要自动触发选中 this.select / this.multiSelect / stage.select / stage.multiSelect
* - doNotSwitchPage: 操作若会引发当前页面切换 / /
*/
export interface DslOpOptions extends HistoryOpOptions {
doNotSelect?: boolean;
doNotSwitchPage?: boolean;
}
// #endregion DslOpOptions
/** 差异对话框的入参 */
export interface DiffDialogPayload {
/** 表单类别 */
category?: CompareCategory;
/** 节点类型 / 数据源类型 */
type?: string;
/** 代码块场景下的数据源类型 */
dataSourceType?: string;
/** 该 step 修改前的值oldNode / oldSchema / oldContent */
lastValue: Record<string, any>;
/** 该 step 修改后的值newNode / newSchema / newContent */
value: Record<string, any>;
/** 当前编辑器中实际的最新值;不传或为 null 时禁用「与当前对比」 */
currentValue?: Record<string, any> | null;
/** 用于标题展示的目标名称 */
targetLabel?: string;
/** 用于标题展示的目标 id */
id?: string | number;
}

View File

@ -0,0 +1,150 @@
/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2025 Tencent. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { tMagicMessage } from '@tmagic/design';
import { defineFormConfig, defineFormItem, type FormConfig, type TableColumnConfig } from '@tmagic/form';
import { getEditorConfig } from './config';
/**
*
*
*
* - `CodeBlockEditor.vue`/
* - `CompareForm.vue`"代码块"
*
* / vs-code onChange / dataSource
* schema
*/
export interface GetCodeBlockFormConfigOptions {
/**
* `params` "参数类型"
* `codeBlockService.getParamsColConfig()`
*/
paramColConfig?: TableColumnConfig;
/**
* 使
* - true`执行时机` `请求前 / 请求后` `dataSourceType !== 'base'`
* - false`执行时机`
*
* `display` / `options`
* `props.isDataSource` `dataSourceType`
*/
isDataSource?: () => boolean;
/** 当 isDataSource 为 true 时使用:返回当前数据源类型(`base` / `http` / ...),决定时机选项是否包含请求前后。 */
dataSourceType?: () => string | undefined;
/** vs-code 编辑器的额外 monaco options。一般传 `inject('codeOptions', {})` 的结果。 */
codeOptions?: Record<string, any>;
/**
*
* - true`name` vs-code `onChange` `parseDSL`
* - false/
*/
editable?: boolean;
}
/** 默认的"参数类型"列配置:数字 / 字符串 / 组件 三选一。 */
const defaultParamColConfig = () =>
defineFormItem<TableColumnConfig>({
type: 'row',
label: '参数类型',
items: [
{
text: '参数类型',
labelWidth: '70px',
type: 'select',
name: 'type',
options: [
{ text: '数字', label: '数字', value: 'number' },
{ text: '字符串', label: '字符串', value: 'text' },
{ text: '组件', label: '组件', value: 'ui-select' },
],
},
],
});
/**
* `<MForm :config>`
* props dataSourceType `computed`
*/
export const getCodeBlockFormConfig = (options: GetCodeBlockFormConfigOptions = {}): FormConfig => {
const { paramColConfig, isDataSource, dataSourceType, codeOptions = {}, editable = true } = options;
return defineFormConfig([
{
text: '名称',
name: 'name',
...(editable ? { rules: [{ required: true, message: '请输入名称', trigger: 'blur' }] } : {}),
},
{
text: '描述',
name: 'desc',
},
{
text: '执行时机',
name: 'timing',
type: 'select',
options: () => {
const list = [
{ text: '初始化前', value: 'beforeInit' },
{ text: '初始化后', value: 'afterInit' },
];
if (dataSourceType?.() !== 'base') {
list.push({ text: '请求前', value: 'beforeRequest' });
list.push({ text: '请求后', value: 'afterRequest' });
}
return list;
},
display: () => Boolean(isDataSource?.()),
},
{
type: 'table',
border: true,
text: '参数',
enableFullscreen: false,
enableToggleMode: false,
name: 'params',
dropSort: false,
items: [
{ type: 'text', label: '参数名', name: 'name' },
{ type: 'text', label: '描述', name: 'extra' },
paramColConfig || defaultParamColConfig(),
],
},
{
name: 'content',
type: 'vs-code',
options: codeOptions,
autosize: { minRows: 10, maxRows: 30 },
...(editable
? {
onChange: (_formState: any, code: string) => {
try {
// 检测 js 代码是否存在语法错误
getEditorConfig('parseDSL')(code);
return code;
} catch (error: any) {
tMagicMessage.error(error.message);
throw error;
}
},
}
: {}),
},
]) as FormConfig;
};

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