feat(editor): 字段对比模式逐项展示差异并补充历史记录面板文档

- CodeSelect/CodeSelectCol/EventSelect/DataSource 等复合字段在对比模式下
  按索引对齐前后值,逐项展示新增/删除/修改高亮,并隐藏写操作按钮
- form 容器/列表/表格支持对比模式只读展示
- 新增「历史记录面板」指南文档,完善表单对比文档及 menu props 说明
- 补充相关单元测试

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
roymondchen 2026-05-29 15:51:47 +08:00
parent b02aa75ddc
commit cbc4b25072
39 changed files with 938 additions and 106 deletions

View File

@ -102,6 +102,10 @@ export default defineConfig({
text: '数据源',
link: '/guide/advanced/data-source.md'
},
{
text: '历史记录面板',
link: '/guide/advanced/history-list.md',
},
{
text: '@tmagic/form',

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': 缩放以适应
'history-list': 历史记录面板(按 页面 / 数据源 / 代码块 三个 tab 展示操作历史,相邻同目标修改自动合并,支持点击跳转、回到初始状态、单步回滚及差异对比,详见[历史记录面板](/guide/advanced/history-list.md)
- **默认值:**
```js

View File

@ -1,7 +1,30 @@
# 表单对比
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="表单对比"/>

View File

@ -0,0 +1,87 @@
# 历史记录面板
编辑器内置了一个可视化的「历史记录面板」,用于查看与回溯编辑过程中产生的所有操作。相比顶部菜单栏只能「撤销 / 重做」相邻一步,历史记录面板提供了对整条历史栈的全局视角:可以按页面、数据源、代码块分类浏览,点击任意一步直接跳转,查看每一步的前后差异,甚至像 `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)`
### 4. 差异对比
在前后值都存在的 `update` 步骤上提供「查看差异」入口,点击后弹出差异对话框。对话框支持两个维度的切换:
- **对比对象**
- `与修改前对比`:该步骤修改前 vs 修改后(默认,体现这一步带来的变化);
- `与当前对比`:该步骤修改后 vs 编辑器中的最新值(用于确认「这一步之后是否又被改动过」,当前值缺失时禁用)。
- **展示形态**
- `表单对比`:以属性表单形式逐字段对比,可读性更好(基于 [表单对比](/form-config/compare.md) 能力);
- `源码对比`:以 JSON 源码做整体 diff基于 monaco diff 编辑器),可以看到表单未覆盖到的字段。
::: tip
表单对比依赖 `@tmagic/form` 的对比模式(`isCompare` / `lastValues`)。对于 `event-select``code-select``code-select-col` 等由列表或嵌套子表单组成的复合字段,表单会逐项展示新增 / 删除 / 修改的高亮差异,并在对比模式下隐藏「添加 / 删除 / 编辑」等写操作按钮,仅保留只读展示。
:::
## 自定义对比判断
差异对话框中的「表单对比」最终透传到 `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

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

View File

@ -6,7 +6,8 @@
:size="size"
:prop="prop"
:disabled="disabled"
:lastValues="lastValues"
:is-compare="isCompareMode"
:last-values="lastValues?.[name]"
:model="model[name]"
@change="changeHandler"
>
@ -38,6 +39,21 @@ const { dataSourceService, codeBlockService } = useServices();
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>(() => ({
type: 'group-list',
name: 'hookData',

View File

@ -2,7 +2,20 @@
<div class="m-fields-code-select-col">
<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
v-else
class="select"
:config="selectConfig"
:name="name"
@ -12,9 +25,9 @@
@change="onCodeIdChangeHandler"
></MSelect>
<!-- 查看/编辑按钮 -->
<!-- 查看/编辑按钮对比模式为只读不展示 -->
<TMagicButton
v-if="model[name] && hasCodeBlockSidePanel"
v-if="!isCompareMode && model[name] && hasCodeBlockSidePanel"
class="m-fields-select-action-button"
:size="size"
@click="editCode(model[name])"
@ -29,6 +42,8 @@
name="params"
:key="model[name]"
:model="model"
:last-values="lastValues"
:is-compare="isCompareMode"
:size="size"
:disabled="disabled"
:params-config="paramsConfig"
@ -52,6 +67,7 @@ import {
filterFunction,
type FormItemConfig,
type FormState,
MContainer as MFormContainer,
MSelect,
type SelectConfig,
} from '@tmagic/form';
@ -77,6 +93,18 @@ const props = withDefaults(defineProps<FieldProps<CodeSelectColConfig>>(), {
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 hasCodeBlockSidePanel = computed(() =>

View File

@ -72,7 +72,10 @@
@change="onChangeHandler"
></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)"
><MIcon :icon="!notEditable ? Edit : View"></MIcon
></TMagicButton>
@ -133,6 +136,9 @@ const dataSources = computed(() => {
const valueIsKey = computed(() => props.value === 'key');
const notEditable = computed(() => filterFunction(mForm, props.notEditable, props));
/** 对比模式下隐藏查看/编辑操作按钮,仅保留只读展示。 */
const isCompare = computed(() => Boolean(mForm?.isCompare));
const dataSourcesOptions = computed<SelectOption[]>(() =>
dataSources.value.map((ds) => ({
text: ds.title || ds.id,

View File

@ -28,14 +28,14 @@
></component>
<TMagicTooltip
v-if="config.fieldConfig && !disabledDataSource"
v-if="config.fieldConfig && !disabledDataSource && !mForm?.isCompare"
:disabled="showDataSourceFieldSelect"
content="选择数据源"
>
<TMagicButton
:type="showDataSourceFieldSelect ? 'primary' : 'default'"
:size="size"
:disabled="disabled || mForm?.isCompare"
:disabled="disabled"
@click="onToggleDataSourceFieldSelectHandler"
><MIcon :icon="Coin"></MIcon
></TMagicButton>

View File

@ -1,8 +1,8 @@
<template>
<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" type="primary" :disabled="disabled" plain @click="newHandler()">添加</TMagicButton>
</div>
@ -47,7 +47,7 @@
</template>
<script setup lang="ts">
import { inject, Ref, ref } from 'vue';
import { computed, inject, Ref, ref } from 'vue';
import type { DataSchema } from '@tmagic/core';
import { TMagicButton, tMagicMessage, tMagicMessageBox } from '@tmagic/design';
@ -84,6 +84,10 @@ const emit = defineEmits<{
}>();
const { uiService } = useServices();
const mForm = inject<FormState | undefined>('mForm');
/** 对比模式下隐藏新增/编辑/删除等操作按钮,仅保留只读展示。 */
const isCompare = computed(() => Boolean(mForm?.isCompare));
const fieldValues = ref<Record<string, any>>({});
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 = [
{ name: 'index', type: 'hidden', filter: 'number', defaultValue: -1 },
{

View File

@ -13,7 +13,7 @@
></MCascader>
<TMagicTooltip
v-if="model[name] && isCustomMethod && hasDataSourceSidePanel"
v-if="model[name] && isCustomMethod && hasDataSourceSidePanel && !isCompare"
:content="notEditable ? '查看' : '编辑'"
>
<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 isCompare = computed(() => Boolean(mForm?.isCompare));
const dataSources = computed(() => dataSourceService.get('dataSources'));
const isCustomMethod = computed(() => {

View File

@ -1,8 +1,8 @@
<template>
<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
>
@ -21,12 +21,12 @@
</template>
<script setup lang="ts">
import { nextTick, ref, useTemplateRef } from 'vue';
import { computed, inject, nextTick, ref, useTemplateRef } from 'vue';
import { cloneDeep } from 'lodash-es';
import type { CodeBlockContent } from '@tmagic/core';
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 CodeBlockEditor from '@editor/components/CodeBlockEditor.vue';
@ -42,6 +42,11 @@ const props = withDefaults(defineProps<FieldProps<DataSourceMethodsConfig>>(), {
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 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 = () => {
codeConfig.value = {
name: '',

View File

@ -1,8 +1,8 @@
<template>
<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>
</div>
@ -28,7 +28,7 @@
</template>
<script setup lang="ts">
import { inject, Ref, ref } from 'vue';
import { computed, inject, Ref, ref } from 'vue';
import type { MockSchema } from '@tmagic/core';
import { TMagicButton, tMagicMessageBox, TMagicSwitch } from '@tmagic/design';
@ -54,6 +54,11 @@ const props = withDefaults(defineProps<FieldProps<DataSourceMocksConfig>>(), {
const emit = defineEmits(['change']);
const { uiService } = useServices();
const mForm = inject<FormState | undefined>('mForm');
/** 对比模式下隐藏新增/编辑/删除等操作按钮,仅保留只读展示。 */
const isCompare = computed(() => Boolean(mForm?.isCompare));
const width = defineModel<number>('width', { default: 670 });
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 isFirstRow = props.model[props.name].length === 0;
formValues.value = {

View File

@ -11,7 +11,7 @@
@change="changeHandler"
></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"
><MIcon :icon="!notEditable ? Edit : View"></MIcon
></TMagicButton>
@ -56,6 +56,9 @@ const dataSources = computed(() => dataSourceService.get('dataSources'));
const notEditable = computed(() => filterFunction(mForm, props.config.notEditable, props));
/** 对比模式下隐藏查看/编辑操作按钮,仅保留只读展示。 */
const isCompare = computed(() => Boolean(mForm?.isCompare));
const hasDataSourceSidePanel = computed(() =>
uiService.get('sideBarItems').find((item) => item.$key === SideItemKey.DATA_SOURCE),
);

View File

@ -6,22 +6,32 @@
:size="size"
:disabled="disabled"
:model="model"
:last-values="lastValues"
:is-compare="isCompareMode"
:config="tableConfig"
@change="onChangeHandler"
></MTable>
<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
>
<MPanel
v-for="(cardItem, index) in model[name]"
:key="index"
v-for="entry in displayList"
:key="entry.index"
:disabled="disabled"
:size="size"
:prop="`${prop}.${index}`"
:prop="`${prop}.${entry.index}`"
:config="actionsConfig"
:model="cardItem"
:model="entry.cardItem"
:last-values="entry.lastCardItem"
:is-compare="isCompareMode"
:label-width="config.labelWidth || '100px'"
@change="onChangeHandler"
>
@ -29,19 +39,22 @@
<MFormContainer
class="fullWidth"
:config="eventNameConfig"
:model="cardItem"
:model="entry.cardItem"
:last-values="entry.lastCardItem"
:is-compare="isCompareMode"
:disabled="disabled"
:size="size"
:prop="`${prop}.${index}`"
:prop="`${prop}.${entry.index}`"
@change="eventNameChangeHandler"
></MFormContainer>
<TMagicButton
v-if="!isCompareMode"
style="color: #f56c6c"
link
:icon="Delete"
:disabled="disabled"
:size="size"
@click="removeEvent(Number(index))"
@click="removeEvent(Number(entry.index))"
></TMagicButton>
</template>
</MPanel>
@ -374,6 +387,42 @@ const isOldVersion = computed(() => {
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 defaultEvent = {

View File

@ -19,6 +19,7 @@
></TMagicInput>
<TMagicButton
v-if="!isCompare"
class="m-fields-key-value-delete"
type="danger"
:size="size"
@ -30,7 +31,14 @@
></TMagicButton>
</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
>
</div>
@ -52,7 +60,7 @@
></MagicCodeEditor>
<TMagicButton
v-if="config.advanced"
v-if="config.advanced && !isCompare"
size="default"
:disabled="disabled"
link
@ -63,11 +71,11 @@
</template>
<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 { 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 MagicCodeEditor from '@editor/layouts/CodeEditor.vue';
@ -84,6 +92,11 @@ const emit = defineEmits<{
change: [value: Record<string, any>];
}>();
const mForm = inject<FormState | undefined>('mForm');
/** 对比模式下隐藏增删/代码切换等操作按钮,仅保留只读展示。 */
const isCompare = computed(() => Boolean(mForm?.isCompare));
const records = ref<[string, string][]>([]);
const showCode = ref(false);

View File

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

View File

@ -5,16 +5,24 @@
class="m-editor-history-diff-dialog"
title="查看修改差异"
width="900px"
top="5vh"
destroy-on-close
append-to-body
>
<div v-if="payload" class="m-editor-history-diff-dialog-body">
<div class="m-editor-history-diff-dialog-header">
<span class="m-editor-history-diff-dialog-target">{{ targetText }}</span>
<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 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">
@ -27,13 +35,25 @@
</div>
<CompareForm
v-if="viewMode === 'form'"
:category="payload.category"
:type="payload.type"
:data-source-type="payload.dataSourceType"
:value="rightValue"
:last-value="leftValue"
:extend-state="extendState"
height="60vh"
height="70vh"
/>
<CodeEditor
v-else
type="diff"
language="json"
:init-values="leftValue"
:modified-values="rightValue"
:options="codeDiffOptions"
disabled-full-screen
height="70vh"
/>
</div>
@ -52,6 +72,7 @@ import { TMagicButton, TMagicDialog, TMagicRadioButton, TMagicRadioGroup, TMagic
import type { FormState } from '@tmagic/form';
import CompareForm, { type CompareCategory } from '@editor/components/CompareForm.vue';
import CodeEditor from '@editor/layouts/CodeEditor.vue';
defineOptions({
name: 'MEditorHistoryDiffDialog',
@ -82,6 +103,8 @@ export interface DiffDialogPayload {
currentValue?: Record<string, any> | null;
/** 用于标题展示的目标名称 */
targetLabel?: string;
/** 用于标题展示的目标 id */
id?: string | number;
}
/**
@ -91,9 +114,37 @@ export interface DiffDialogPayload {
*/
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 hasCurrent = computed(() => payload.value?.currentValue !== undefined && payload.value?.currentValue !== null);
@ -129,13 +180,17 @@ const targetText = computed(() => {
};
const prefix = categoryText[payload.value.category] || '';
const label = payload.value.targetLabel || payload.value.type || '';
return [prefix, label].filter(Boolean).join('');
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;
};

View File

@ -220,6 +220,7 @@ const onPageDiff = (index: number) => {
value: item.newNode as Record<string, any>,
currentValue: (currentNode as Record<string, any>) || null,
targetLabel: (item.newNode.name as string) || (item.oldNode.name as string) || type,
id: nodeId,
});
return;
}
@ -241,6 +242,7 @@ const onDataSourceDiff = (id: string | number, index: number) => {
value: newSchema as Record<string, any>,
currentValue: (currentSchema as Record<string, any>) || null,
targetLabel: newSchema.title || oldSchema.title || `${id}`,
id,
});
return;
}
@ -261,6 +263,7 @@ const onCodeBlockDiff = (id: string | number, index: number) => {
value: newContent as Record<string, any>,
currentValue: (currentContent as Record<string, any>) || null,
targetLabel: newContent.name || oldContent.name || `${id}`,
id,
});
return;
}

View File

@ -380,6 +380,14 @@
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;
}

View File

@ -148,7 +148,7 @@ export interface EditorInstallOptions {
customCreateMonacoDiffEditor: (
monaco: typeof import('monaco-editor'),
codeEditorEl: HTMLElement,
options: Monaco.editor.IStandaloneEditorConstructionOptions & { editorCustomType?: string },
options: Monaco.editor.IStandaloneDiffEditorConstructionOptions & { editorCustomType?: string },
) => Promise<Monaco.editor.IStandaloneDiffEditor> | Monaco.editor.IStandaloneDiffEditor;
[key: string]: any;
}

View File

@ -12,14 +12,16 @@ import * as utilsMod from '@editor/utils';
const submitMock = vi.fn();
let lastConfig: any;
let lastProps: any;
vi.mock('@tmagic/form', () => ({
MForm: defineComponent({
name: 'MFormStub',
props: ['config', 'initValues', 'disabled', 'size', 'watchProps'],
props: ['config', 'initValues', 'disabled', 'size', 'watchProps', 'lastValues', 'isCompare'],
emits: ['change'],
setup(props, { expose, emit }) {
lastConfig = props.config;
lastProps = props;
expose({ submitForm: submitMock });
return () =>
h('div', {
@ -38,6 +40,7 @@ describe('CodeParams.vue', () => {
beforeEach(() => {
submitMock.mockReset();
lastConfig = null;
lastProps = null;
});
afterEach(() => {
@ -96,6 +99,20 @@ describe('CodeParams.vue', () => {
expect(events?.[0]?.[0]).toEqual({ p: { a: 1 } });
});
test('对比模式 isCompare/lastValues 透传给内部 MForm', () => {
mount(CodeParams as any, {
props: {
model: { p: { a: 'new' } },
name: 'p',
isCompare: true,
lastValues: { p: { a: 'old' } },
paramsConfig: [{ name: 'a', text: 'A', type: 'vs-code' }] as any,
},
});
expect(lastProps.isCompare).toBe(true);
expect(lastProps.lastValues).toEqual({ p: { a: 'old' } });
});
test('submitForm 抛错时调用 error 不抛出', async () => {
submitMock.mockRejectedValueOnce(new Error('bad'));
const wrapper = mount(CodeParams as any, {

View File

@ -28,7 +28,7 @@ vi.mock('@tmagic/form', async (importOriginal) => {
...actual,
MContainer: defineComponent({
name: 'MContainer',
props: ['config', 'size', 'prop', 'disabled', 'lastValues', 'model'],
props: ['config', 'size', 'prop', 'disabled', 'lastValues', 'isCompare', 'model'],
emits: ['change'],
setup() {
return () => h('div', { class: 'fake-container' });
@ -149,4 +149,21 @@ describe('CodeSelect', () => {
codeBlockService.getEditStatus.mockReturnValue(true);
dataSourceService.get.mockReturnValue(true);
});
describe('对比模式', () => {
test('isCompare 但无 lastValues 时不进入对比', () => {
const wrapper = mount(CodeSelect, { props: baseProps({ isCompare: true }) as any });
const container = wrapper.findComponent({ name: 'MContainer' });
expect(container.props('isCompare')).toBe(false);
});
test('对比模式将 isCompare 与同层 lastValues[name] 透传给内部容器', () => {
const lastValues = { cs: { hookType: 'code', hookData: [{ codeType: 'code', codeId: 'c2' }] } };
const wrapper = mount(CodeSelect, { props: baseProps({ isCompare: true, lastValues }) as any });
const container = wrapper.findComponent({ name: 'MContainer' });
expect(container.props('isCompare')).toBe(true);
// 注意model 传入的是 model[name],因此 lastValues 也需取同层的 lastValues[name]
expect(container.props('lastValues')).toEqual(lastValues.cs);
});
});
});

View File

@ -42,6 +42,13 @@ vi.mock('@tmagic/form', async (importOriginal) => {
return () => h('select', { class: 'fake-select' });
},
}),
MContainer: defineComponent({
name: 'MFormContainer',
props: ['model', 'lastValues', 'isCompare', 'size', 'prop', 'config'],
setup() {
return () => h('div', { class: 'fake-diff-select' });
},
}),
};
});
@ -63,7 +70,7 @@ vi.mock('@editor/components/Icon.vue', () => ({
vi.mock('@editor/components/CodeParams.vue', () => ({
default: defineComponent({
name: 'CodeParams',
props: ['name', 'model', 'size', 'disabled', 'paramsConfig'],
props: ['name', 'model', 'size', 'disabled', 'paramsConfig', 'lastValues', 'isCompare'],
emits: ['change'],
setup(_p, { emit }) {
return () =>
@ -148,9 +155,43 @@ describe('CodeSelectCol', () => {
});
test('codeDsl 为空时 selectConfig.options 返回空数组', () => {
codeBlockService.getCodeDsl.mockReturnValue(null);
codeBlockService.getCodeDsl.mockReturnValue(null as any);
const wrapper = mount(CodeSelectCol, { props: baseProps({ model: { codeId: '', params: {} } }) as any });
const select = wrapper.findComponent({ name: 'MSelect' });
expect((select.props('config') as any).options()).toEqual([]);
});
describe('对比模式', () => {
test('isCompare 但无 lastValues 时仍渲染普通 MSelect', () => {
const wrapper = mount(CodeSelectCol, { props: baseProps({ isCompare: true }) as any });
expect(wrapper.findComponent({ name: 'MSelect' }).exists()).toBe(true);
expect(wrapper.find('.fake-diff-select').exists()).toBe(false);
});
test('对比模式下拉框改用 MFormContainer并隐藏编辑按钮', () => {
const wrapper = mount(CodeSelectCol, {
props: baseProps({
isCompare: true,
lastValues: { codeId: 'c2', params: {} },
}) as any,
});
expect(wrapper.find('.fake-diff-select').exists()).toBe(true);
expect(wrapper.findComponent({ name: 'MSelect' }).exists()).toBe(false);
// 对比模式为只读,不展示查看/编辑按钮
expect(wrapper.find('button').exists()).toBe(false);
});
test('对比模式将 isCompare/lastValues 透传给参数表单 CodeParams', () => {
const wrapper = mount(CodeSelectCol, {
props: baseProps({
isCompare: true,
lastValues: { codeId: 'c1', params: { p1: 'old' } },
}) as any,
});
const params = wrapper.findComponent({ name: 'CodeParams' });
expect(params.exists()).toBe(true);
expect(params.props('isCompare')).toBe(true);
expect(params.props('lastValues')).toEqual({ codeId: 'c1', params: { p1: 'old' } });
});
});
});

View File

@ -261,7 +261,7 @@ describe('DataSourceFieldSelect Index', () => {
expect(wrapper.findAll('.fake-cascader').length).toBe(0);
});
test('对比模式mForm.isCompare=true切换按钮被禁用,点击不切换 showDataSourceFieldSelect', async () => {
test('对比模式mForm.isCompare=true不渲染「选择数据源」切换按钮', async () => {
const wrapper = mount(DSFSIndex, {
props: {
config: { fieldConfig: { type: 'text' } },
@ -275,10 +275,8 @@ describe('DataSourceFieldSelect Index', () => {
},
});
const toggleBtn = wrapper.find('.fake-btn');
expect((toggleBtn.element as HTMLButtonElement).hasAttribute('disabled')).toBe(true);
await toggleBtn.trigger('click');
// 对比模式仅做只读展示,切换按钮整体隐藏(不再以禁用态保留)
expect(wrapper.find('.fake-btn').exists()).toBe(false);
expect(wrapper.findAll('.fake-cascader').length).toBe(0);
});

View File

@ -55,7 +55,7 @@ vi.mock('@tmagic/form', async (importOriginal) => {
}),
MPanel: defineComponent({
name: 'MPanel',
props: ['model', 'config', 'prop', 'disabled', 'size', 'labelWidth'],
props: ['model', 'config', 'prop', 'disabled', 'size', 'labelWidth', 'lastValues', 'isCompare'],
emits: ['change'],
setup(_p, { slots }) {
return () => h('div', { class: 'fake-panel' }, slots.header?.());
@ -360,6 +360,50 @@ describe('EventSelect', () => {
expect(methodCol.options(undefined, { model: { to: '1' } })).toEqual([]);
});
describe('对比模式', () => {
test('isCompare 但无 lastValues 时不进入对比,仍显示添加按钮', () => {
const wrapper = mount(EventSelect, {
props: baseProps({ isCompare: true, model: { events: [] } }) as any,
});
expect(wrapper.find('.create-button').exists()).toBe(true);
});
test('对比模式隐藏「添加事件」与删除按钮', () => {
const wrapper = mount(EventSelect, {
props: baseProps({
isCompare: true,
model: { events: [{ name: 'a', actions: [] }] },
lastValues: { events: [{ name: 'a', actions: [] }] },
}) as any,
});
expect(wrapper.find('.create-button').exists()).toBe(false);
// 对比模式 panel header 内不渲染删除按钮(仅 MFormContainer 占位)
expect(wrapper.findAll('button').length).toBe(0);
});
test('对比模式按索引对齐当前值与历史值,取最大长度渲染', () => {
const wrapper = mount(EventSelect, {
props: baseProps({
isCompare: true,
model: { events: [{ name: 'a', actions: [] }] },
lastValues: {
events: [
{ name: 'a', actions: [] },
{ name: 'b', actions: [] },
],
},
}) as any,
});
// 当前 1 项 + 历史 2 项 → 取 max=2渲染 2 个 panel含被删除的事件
expect(wrapper.findAll('.fake-panel').length).toBe(2);
const panels = wrapper.findAllComponents({ name: 'MPanel' });
expect(panels[0].props('isCompare')).toBe(true);
// 缺失一侧用空对象兜底
expect(panels[1].props('model')).toEqual({});
expect(panels[1].props('lastValues')).toEqual({ name: 'b', actions: [] });
});
});
test('removeEvent 通过 panel header 删除按钮调用', async () => {
const m: any = {
events: [

View File

@ -61,8 +61,9 @@ describe('Bucket.vue', () => {
// 第一组展开后渲染的子步描述来自 describeStep
const subItems = rows[0].findAll('.m-editor-history-list-substeps li');
expect(subItems).toHaveLength(2);
expect(subItems[0].text()).toContain('step-s-0');
expect(subItems[1].text()).toContain('step-s-1');
// 子步倒序渲染最新在上s-1 在前s-0 在后
expect(subItems[0].text()).toContain('step-s-1');
expect(subItems[1].text()).toContain('step-s-0');
// 第二组只有 1 步:未合并
expect(rows[1].find('.m-editor-history-list-item-merge').exists()).toBe(false);
@ -121,7 +122,8 @@ describe('Bucket.vue', () => {
});
const subItems = wrapper.findAll('.m-editor-history-list-substeps li');
expect(subItems).toHaveLength(2);
await subItems[1].trigger('click');
// 子步倒序渲染subItems[0] 对应 index=1
await subItems[0].trigger('click');
const events = wrapper.emitted('goto');
expect(events).toBeTruthy();
expect(events![0]).toEqual(['code_1', 1]);

View File

@ -143,7 +143,8 @@ describe('CodeBlockTab.vue', () => {
});
const items = wrapper.findAll('.m-editor-history-list-substeps li');
expect(items).toHaveLength(2);
expect(items[0].text()).toContain('修改 fn (id: code_1) · content');
expect(items[1].text()).toContain('修改 fn (id: code_1) · params');
// 子步倒序渲染最新在上params 在前content 在后
expect(items[0].text()).toContain('修改 fn (id: code_1) · params');
expect(items[1].text()).toContain('修改 fn (id: code_1) · content');
});
});

View File

@ -71,12 +71,13 @@ describe('GroupRow.vue', () => {
});
const items = wrapper.findAll('.m-editor-history-list-substeps li');
expect(items).toHaveLength(2);
expect(items[0].text()).toContain('#1');
expect(items[0].text()).toContain('修改 颜色');
expect(items[1].text()).toContain('#2');
expect(items[1].text()).toContain('修改 字号');
// 第二个子步未应用
expect(items[1].classes()).toContain('is-undone');
// 子步倒序渲染最新在上index=1 在前index=0 在后
expect(items[0].text()).toContain('#2');
expect(items[0].text()).toContain('修改 字号');
// 最新index=1子步未应用
expect(items[0].classes()).toContain('is-undone');
expect(items[1].text()).toContain('#1');
expect(items[1].text()).toContain('修改 颜色');
});
test('merged=true 但 expanded=false 时不渲染子步列表', () => {
@ -161,10 +162,11 @@ describe('GroupRow.vue', () => {
],
},
});
// 子步倒序渲染subItems[0] 为 index=1非当前可点击subItems[1] 为 index=0当前
const subItems = wrapper.findAll('.m-editor-history-list-substeps li');
await subItems[0].trigger('click');
expect(wrapper.emitted('goto')).toBeFalsy();
await subItems[1].trigger('click');
expect(wrapper.emitted('goto')).toBeFalsy();
await subItems[0].trigger('click');
expect(wrapper.emitted('goto')![0]).toEqual([1]);
});
});

View File

@ -0,0 +1,220 @@
/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2025 Tencent.
*/
import { describe, expect, test, vi } from 'vitest';
import { defineComponent, h, inject, nextTick, provide } from 'vue';
import { mount } from '@vue/test-utils';
import HistoryDiffDialog from '@editor/layouts/history-list/HistoryDiffDialog.vue';
vi.mock('@tmagic/design', () => ({
// 受控对话框modelValue 为真时才渲染 body / footer 插槽
TMagicDialog: defineComponent({
name: 'TMagicDialog',
props: ['modelValue'],
setup(props, { slots }) {
return () =>
props.modelValue ? h('div', { class: 'fake-dialog' }, [slots.default?.(), slots.footer?.()]) : null;
},
}),
TMagicButton: defineComponent({
name: 'TMagicButton',
emits: ['click'],
setup(_p, { emit, slots }) {
return () => h('button', { class: 'fake-btn', onClick: () => emit('click') }, slots.default?.());
},
}),
// RadioGroup 通过 provide 把选值函数下发给内部的 RadioButton模拟 v-model 行为
TMagicRadioGroup: defineComponent({
name: 'TMagicRadioGroup',
props: ['modelValue'],
emits: ['update:modelValue'],
setup(_p, { emit, slots }) {
provide('radioSelect', (v: string) => emit('update:modelValue', v));
return () => h('div', { class: 'fake-radio-group' }, slots.default?.());
},
}),
TMagicRadioButton: defineComponent({
name: 'TMagicRadioButton',
props: ['value', 'disabled'],
setup(props, { slots }) {
const select = inject<(v: string) => void>('radioSelect');
return () =>
h(
'button',
{
class: 'fake-radio-btn',
'data-value': props.value,
'data-disabled': props.disabled ? 'true' : 'false',
onClick: () => !props.disabled && select?.(props.value),
},
slots.default?.(),
);
},
}),
TMagicTag: defineComponent({
name: 'TMagicTag',
setup(_p, { slots }) {
return () => h('span', { class: 'fake-tag' }, slots.default?.());
},
}),
}));
vi.mock('@editor/components/CompareForm.vue', () => ({
default: defineComponent({
name: 'CompareForm',
props: ['category', 'type', 'dataSourceType', 'value', 'lastValue', 'extendState', 'height'],
setup() {
return () => h('div', { class: 'fake-compare-form' });
},
}),
}));
vi.mock('@editor/layouts/CodeEditor.vue', () => ({
default: defineComponent({
name: 'CodeEditor',
props: ['type', 'language', 'initValues', 'modifiedValues', 'options', 'disabledFullScreen', 'height'],
setup() {
return () => h('div', { class: 'fake-code-editor' });
},
}),
}));
const factory = () =>
mount(HistoryDiffDialog, {
// 让 Teleport 内容内联渲染,便于通过 wrapper 查询
global: { stubs: { teleport: true } },
});
const basePayload = (extra: any = {}) => ({
category: 'node',
type: 'btn',
lastValue: { text: 'old' },
value: { text: 'new' },
currentValue: { text: 'current' },
targetLabel: '按钮',
...extra,
});
describe('HistoryDiffDialog.vue', () => {
test('初始未打开时不渲染对话框 body', () => {
const wrapper = factory();
expect(wrapper.find('.fake-dialog').exists()).toBe(false);
});
test('open() 默认进入「表单对比 + 与修改前对比」,左右值正确', async () => {
const wrapper = factory();
(wrapper.vm as any).open(basePayload());
await nextTick();
expect(wrapper.find('.fake-dialog').exists()).toBe(true);
const form = wrapper.findComponent({ name: 'CompareForm' });
expect(form.exists()).toBe(true);
expect(wrapper.findComponent({ name: 'CodeEditor' }).exists()).toBe(false);
// before 模式:左=修改前 lastValue右=修改后 value
expect(form.props('lastValue')).toEqual({ text: 'old' });
expect(form.props('value')).toEqual({ text: 'new' });
expect(form.props('category')).toBe('node');
expect(form.props('type')).toBe('btn');
});
test('切换到「源码对比」渲染 CodeEditor 并透传 diff 值', async () => {
const wrapper = factory();
(wrapper.vm as any).open(basePayload());
await nextTick();
const codeRadio = wrapper.findAll('.fake-radio-btn').find((b) => b.attributes('data-value') === 'code');
await codeRadio!.trigger('click');
await nextTick();
expect(wrapper.findComponent({ name: 'CompareForm' }).exists()).toBe(false);
const code = wrapper.findComponent({ name: 'CodeEditor' });
expect(code.exists()).toBe(true);
expect(code.props('type')).toBe('diff');
expect(code.props('language')).toBe('json');
expect(code.props('initValues')).toEqual({ text: 'old' });
expect(code.props('modifiedValues')).toEqual({ text: 'new' });
// 源码对比强制只读、左右并排
expect(code.props('options')).toMatchObject({ readOnly: true, renderSideBySide: true });
});
test('切换到「与当前对比」后左=该步修改后,右=当前值', async () => {
const wrapper = factory();
(wrapper.vm as any).open(basePayload());
await nextTick();
const currentRadio = wrapper.findAll('.fake-radio-btn').find((b) => b.attributes('data-value') === 'current');
await currentRadio!.trigger('click');
await nextTick();
const form = wrapper.findComponent({ name: 'CompareForm' });
expect(form.props('lastValue')).toEqual({ text: 'new' });
expect(form.props('value')).toEqual({ text: 'current' });
});
test('currentValue 为 null 时「与当前对比」按钮禁用', async () => {
const wrapper = factory();
(wrapper.vm as any).open(basePayload({ currentValue: null }));
await nextTick();
const currentRadio = wrapper.findAll('.fake-radio-btn').find((b) => b.attributes('data-value') === 'current');
expect(currentRadio!.attributes('data-disabled')).toBe('true');
});
test('targetText 按 category 生成前缀', async () => {
const wrapper = factory();
(wrapper.vm as any).open(basePayload({ category: 'data-source', targetLabel: '接口A' }));
await nextTick();
expect(wrapper.find('.m-editor-history-diff-dialog-target').text()).toBe('数据源接口A');
});
test('「与当前对比」且当前值等于该步修改后值时展示无差异提示', async () => {
const wrapper = factory();
(wrapper.vm as any).open(basePayload({ value: { text: 'same' }, currentValue: { text: 'same' } }));
await nextTick();
// before 模式下不展示提示
expect(wrapper.find('.m-editor-history-diff-dialog-tip').exists()).toBe(false);
const currentRadio = wrapper.findAll('.fake-radio-btn').find((b) => b.attributes('data-value') === 'current');
await currentRadio!.trigger('click');
await nextTick();
expect(wrapper.find('.m-editor-history-diff-dialog-tip').exists()).toBe(true);
});
test('再次 open() 会重置回表单对比 + 与修改前对比', async () => {
const wrapper = factory();
(wrapper.vm as any).open(basePayload());
await nextTick();
// 先切到源码 + 与当前对比
await wrapper
.findAll('.fake-radio-btn')
.find((b) => b.attributes('data-value') === 'code')!
.trigger('click');
await wrapper
.findAll('.fake-radio-btn')
.find((b) => b.attributes('data-value') === 'current')!
.trigger('click');
await nextTick();
(wrapper.vm as any).open(basePayload());
await nextTick();
expect(wrapper.findComponent({ name: 'CompareForm' }).exists()).toBe(true);
const form = wrapper.findComponent({ name: 'CompareForm' });
expect(form.props('lastValue')).toEqual({ text: 'old' });
});
test('close() 隐藏对话框并清空 payload', async () => {
const wrapper = factory();
(wrapper.vm as any).open(basePayload());
await nextTick();
expect(wrapper.find('.fake-dialog').exists()).toBe(true);
(wrapper.vm as any).close();
await nextTick();
expect(wrapper.find('.fake-dialog').exists()).toBe(false);
});
});

View File

@ -64,6 +64,18 @@ vi.mock('@editor/components/Icon.vue', () => ({
}),
}));
// 差异对话框有独立的单测HistoryDiffDialog.spec.ts这里 stub 掉以隔离面板自身逻辑,
// 同时避免其内部依赖monaco CodeEditor / CompareForm / 设计层弹窗组件)在本用例下未被 mock 而报错。
vi.mock('@editor/layouts/history-list/HistoryDiffDialog.vue', () => ({
default: defineComponent({
name: 'FakeHistoryDiffDialog',
setup(_p, { expose }) {
expose({ open: vi.fn(), close: vi.fn() });
return () => h('div', { class: 'fake-history-diff-dialog' });
},
}),
}));
afterEach(() => {
historyService.reset();
vi.clearAllMocks();

View File

@ -21,7 +21,6 @@
:label-width="item.labelWidth || labelWidth"
:step-active="stepActive"
:size="size"
:show-diff="showDiff"
@change="changeHandler"
>
<template v-if="$slots.label" #label="labelProps">
@ -51,6 +50,7 @@ import type {
FormValue,
ValidateError,
} from './schema';
import { FORM_DIFF_CONFIG_KEY } from './schema';
defineOptions({
name: 'MForm',
@ -86,14 +86,27 @@ const props = withDefaults(
* - 不传使用默认逻辑 `!isEqual(curValue, lastValue)`
* - 传函数完全以函数返回值为准返回 `true` 才展示前后两份对比内容
*
* 透传给所有层级的 Container通过 `formState` 注入调用方只需在 MForm
* 这一层传一次即可对整棵表单生效
* 通过 provide 下发给所有层级的 Container含嵌套在容器组件内部的 Container
* 调用方只需在 MForm 这一层传一次即可对整棵表单生效
*
* 典型场景某些字段语义上相等但结构不同例如 `code-select` 字段中 `''`
* `{ hookType: 'code', hookData: [] }` 应视为相等调用方在此处显式声明
* 避免被 lodash `isEqual` 误判为差异
*/
showDiff?: (_data: { curValue: any; lastValue: any; config: any }) => boolean;
/**
* 自定义自接管对比的字段类型仅在对比模式下生效
*
* 自接管对比的字段不会渲染前后两份独立组件而是只渲染一次并由字段组件内部展示前后差异
* vs-code 使用 monaco diff 编辑器event-select / code-select-col 等复合字段逐项展示差异
*
* 支持两种形式
* - 传数组在内置类型基础上追加这些类型
* - 传函数入参为内置类型数组返回值作为最终完整列表可完全替换内置项
*
* 通过 provide 下发对整棵表单的所有层级 Container 生效只需在 MForm 这一层传一次
*/
selfDiffFieldTypes?: string[] | ((_defaultTypes: string[]) => string[]);
}>(),
{
config: () => [],
@ -160,9 +173,6 @@ const formState: FormState = reactive<FormState>({
get parentValues() {
return props.parentValues;
},
get showDiff() {
return props.showDiff;
},
values,
lastValuesProcessed,
$emit: emit as (_event: string, ..._args: any[]) => void,
@ -233,6 +243,17 @@ watchEffect(async (onCleanup) => {
provide('mForm', formState);
// provide Container inject prop
// getter props
provide(FORM_DIFF_CONFIG_KEY, {
get showDiff() {
return props.showDiff;
},
get selfDiffFieldTypes() {
return props.selfDiffFieldTypes;
},
});
const changeRecords = shallowRef<ChangeRecord[]>([]);
watch(

View File

@ -18,7 +18,6 @@
:expand-more="expand"
:label-width="itemLabelWidth"
:style="config.fieldStyle"
:show-diff="showDiffProp"
@change="onChangeHandler"
@addDiffCount="onAddDiffCount"
></component>
@ -211,7 +210,6 @@
:expand-more="expand"
:label-width="itemLabelWidth"
:prop="itemProp"
:show-diff="showDiffProp"
@change="onChangeHandler"
@addDiffCount="onAddDiffCount"
>
@ -244,12 +242,14 @@ import type {
ComponentConfig,
ContainerChangeEventData,
ContainerCommonConfig,
FormDiffConfig,
FormItemConfig,
FormSlots,
FormState,
FormValue,
ToolTipConfigType,
} from '../schema';
import { FORM_DIFF_CONFIG_KEY } from '../schema';
import { getField } from '../utils/config';
import { createObjectProp, display as displayFunction, filterFunction, getRules } from '../utils/form';
@ -276,19 +276,6 @@ const props = withDefaults(
size?: string;
/** 是否开启对比模式 */
isCompare?: boolean;
/**
* 自定义"是否展示对比内容"的判断函数仅在 `isCompare === true` 时生效
*
* - 不传使用默认逻辑 `!isEqual(curValue, lastValue)`
* - 传函数完全以函数返回值为准返回 `true` 才展示前后两份对比内容
*
* 典型场景某些字段语义上相等但结构不同例如 `''` `{ hookType: 'code', hookData: [] }`
* 业务侧可在此处自定义为相等以避免被误判为差异
*
* 注意 prop 会在嵌套 Container 中自动透传给子级调用方只需在最外层
* MForm传入一次即可
*/
showDiff?: (_data: { curValue: any; lastValue: any; config: FormItemConfig }) => boolean;
}>(),
{
prop: '',
@ -306,29 +293,24 @@ const emit = defineEmits<{
const mForm = inject<FormState | undefined>('mForm');
// MForm provide inject prop
const diffConfig = inject<FormDiffConfig>(FORM_DIFF_CONFIG_KEY, {});
const expand = ref(false);
const name = computed(() => props.config.name || '');
// showDiff prop便 Container
// showDiff
//
// props.showDiff > formState.showDiff MForm provide
// 使 `<MForm :show-diff="...">`
// 使 `<Container>` prop
const showDiffProp = computed<typeof props.showDiff>(() => props.showDiff || mForm?.showDiff);
//
//
// lodash isEqual
// `showDiff` prop / formState
// MForm `showDiff`
// 便"" hook
const showDiff = computed(() => {
if (!props.isCompare) return false;
const curValue = name.value ? props.model[name.value] : props.model;
const lastValue = name.value ? props.lastValues[name.value] : props.lastValues;
const customShowDiff = showDiffProp.value;
const customShowDiff = diffConfig.showDiff;
if (typeof customShowDiff === 'function') {
return Boolean(customShowDiff({ curValue, lastValue, config: props.config }));
}
@ -379,10 +361,34 @@ const tagName = computed(() => {
* 这样做的好处
* 1. 避免重型字段 monaco 编辑器在对比模式下被实例化两次节省资源
* 2. 提供更专业的对比视觉效果 monaco diff 的行级高亮左右滚动同步等
*
* 注意 `event-select` / `code-select-col` 这类内部由列表 / 嵌套子表单组成的复合字段若按默认逻辑
* 渲染前后两份独立组件会出现两套下拉框 + 两份参数表单或两套添加事件按钮两份完整面板
* 体验很差这类字段在内部把 `is-compare`/`lastValues` 透传给子级容器由子级逐项展示差异
* 因此同样归类为自接管对比字段
*/
const SELF_DIFF_FIELD_TYPES = new Set(['vs-code']);
const DEFAULT_SELF_DIFF_FIELD_TYPES = ['vs-code', 'event-select', 'code-select-col', 'code-select'];
const isSelfDiffField = computed(() => SELF_DIFF_FIELD_TYPES.has(type.value));
//
//
// - 使
// -
// -
const effectiveSelfDiffFieldTypes = computed<Set<string>>(() => {
const custom = diffConfig.selfDiffFieldTypes;
if (typeof custom === 'function') {
return new Set(custom([...DEFAULT_SELF_DIFF_FIELD_TYPES]));
}
if (Array.isArray(custom)) {
return new Set([...DEFAULT_SELF_DIFF_FIELD_TYPES, ...custom]);
}
return new Set(DEFAULT_SELF_DIFF_FIELD_TYPES);
});
const isSelfDiffField = computed(() => effectiveSelfDiffFieldTypes.value.has(type.value));
const disabled = computed(() => props.disabled || filterFunction(mForm, props.config.disabled, props));

View File

@ -26,7 +26,7 @@
@addDiffCount="onAddDiffCount()"
></MFieldsGroupListItem>
<div class="m-fields-group-list-footer">
<div class="m-fields-group-list-footer" v-if="!isCompare">
<slot name="toggle-button"></slot>
<div style="display: flex; justify-content: flex-end; flex: 1">
<slot name="add-button"></slot>

View File

@ -7,6 +7,7 @@
</TMagicButton>
<TMagicButton
v-if="!isCompare"
v-show="showDelete"
type="danger"
size="small"
@ -17,7 +18,7 @@
></TMagicButton>
<TMagicButton
v-if="copyable"
v-if="copyable && !isCompare"
link
size="small"
type="primary"
@ -27,7 +28,7 @@
>复制</TMagicButton
>
<template v-if="movable">
<template v-if="movable && !isCompare">
<TMagicButton
v-show="index !== 0"
link
@ -49,7 +50,7 @@
</template>
<TMagicPopover
v-if="config.moveSpecifyLocation"
v-if="config.moveSpecifyLocation && !isCompare"
trigger="click"
placement="top"
width="200"

View File

@ -44,7 +44,7 @@
{{ isFullscreen ? '退出全屏' : '全屏编辑' }}
</TMagicButton>
<TMagicUpload
v-if="importable"
v-if="importable && !isCompare"
style="display: inline-block"
ref="excelBtn"
action="/noop"
@ -54,11 +54,17 @@
>
<TMagicButton size="small" type="success" :disabled="disabled" plain>导入EXCEL</TMagicButton>
</TMagicUpload>
<TMagicButton v-if="importable" size="small" type="warning" :disabled="disabled" plain @click="clearHandler"
<TMagicButton
v-if="importable && !isCompare"
size="small"
type="warning"
:disabled="disabled"
plain
@click="clearHandler"
>清空</TMagicButton
>
</div>
<slot name="add-button"></slot>
<slot name="add-button" v-if="!isCompare"></slot>
</div>
<div class="bottom" style="text-align: right" v-if="config.pagination">

View File

@ -1,7 +1,33 @@
import type { InjectionKey } from 'vue';
import type { FormItemConfig } from '@tmagic/form-schema';
export * from '@tmagic/form-schema';
/**
* `MForm` `provide`
* Container fieldset / panel Container `inject`
* prop
*/
export interface FormDiffConfig {
/**
* "是否展示对比内容"
*
* - 使 `!isEqual(curValue, lastValue)`
* - `true`
*/
showDiff?: (_data: { curValue: any; lastValue: any; config: FormItemConfig }) => boolean;
/**
*
*
* -
* -
*/
selfDiffFieldTypes?: string[] | ((_defaultTypes: string[]) => string[]);
}
export const FORM_DIFF_CONFIG_KEY: InjectionKey<FormDiffConfig> = Symbol('mFormDiffConfig');
export interface ValidateError {
message: string;
field: string;

View File

@ -9,10 +9,10 @@ import MagicForm, { MForm } from '@form/index';
import { mount } from '@vue/test-utils';
import ElementPlus from 'element-plus';
const mountForm = (config: any[], initValues: any = {}) =>
const mountForm = (config: any[], initValues: any = {}, extra: any = {}) =>
mount(MForm, {
global: { plugins: [ElementPlus as any, MagicForm as any] },
props: { config, initValues },
props: { config, initValues, ...extra },
});
describe('GroupList container', () => {
@ -61,4 +61,41 @@ describe('GroupList container', () => {
await nextTick();
expect(wrapper.html()).toContain('<em>tip</em>');
});
describe('对比模式', () => {
const compareConfig = [
{
type: 'group-list',
name: 'list',
copyable: true,
movable: true,
items: [{ name: 'text', type: 'text', text: 'text' }],
},
];
test('非对比模式渲染底部操作栏与复制/移动按钮', async () => {
const wrapper = mountForm(compareConfig, { list: [{ text: 'a' }, { text: 'b' }] });
await nextTick();
expect(wrapper.find('.m-fields-group-list-footer').exists()).toBe(true);
expect(wrapper.text()).toContain('复制');
expect(wrapper.text()).toContain('上移');
});
test('对比模式隐藏底部操作栏与复制/移动按钮', async () => {
const wrapper = mountForm(
compareConfig,
{ list: [{ text: 'a' }, { text: 'b' }] },
{
isCompare: true,
lastValues: { list: [{ text: 'a' }] },
},
);
await nextTick();
await nextTick();
expect(wrapper.find('.m-fields-group-list-footer').exists()).toBe(false);
expect(wrapper.text()).not.toContain('复制');
expect(wrapper.text()).not.toContain('上移');
expect(wrapper.text()).not.toContain('下移');
});
});
});

View File

@ -0,0 +1,43 @@
/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2025 Tencent.
*/
import { describe, expect, test } from 'vitest';
import { nextTick } from 'vue';
import Table from '@form/containers/table/Table.vue';
import MagicForm from '@form/index';
import { mount } from '@vue/test-utils';
import ElementPlus from 'element-plus';
// el-table 在 happy-dom 下的 MutationObserver 会报错,这里直接 stub 掉表格本体;
// 导入 / 清空 / 新增按钮的显隐只取决于 importable & isCompare与表格渲染无关。
const mountTable = (props: any) =>
mount(Table as any, {
global: {
plugins: [ElementPlus as any, MagicForm as any],
// 设计层 TMagicTable 组件名为 TMTable底层渲染 el-table故按真实名 stub
stubs: { TMTable: true, TMagicTable: true, ElTable: true },
},
props: {
name: 'list',
prop: 'list',
config: { type: 'table', name: 'list', importable: true, items: [{ name: 'text', type: 'text' }] },
model: { list: [{ text: 'a' }] },
...props,
},
});
describe('Table container —— 对比模式', () => {
test('非对比模式展示「清空」等导入相关按钮', async () => {
const wrapper = mountTable({ isCompare: false });
await nextTick();
expect(wrapper.text()).toContain('清空');
});
test('对比模式隐藏「清空」等导入相关按钮', async () => {
const wrapper = mountTable({ isCompare: true, lastValues: { list: [{ text: 'a' }] } });
await nextTick();
expect(wrapper.text()).not.toContain('清空');
});
});