mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-05-30 04:08:04 +00:00
feat(editor): 字段对比模式逐项展示差异并补充历史记录面板文档
- CodeSelect/CodeSelectCol/EventSelect/DataSource 等复合字段在对比模式下 按索引对齐前后值,逐项展示新增/删除/修改高亮,并隐藏写操作按钮 - form 容器/列表/表格支持对比模式只读展示 - 新增「历史记录面板」指南文档,完善表单对比文档及 menu props 说明 - 补充相关单元测试 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
b02aa75ddc
commit
cbc4b25072
@ -102,6 +102,10 @@ export default defineConfig({
|
||||
text: '数据源',
|
||||
link: '/guide/advanced/data-source.md'
|
||||
},
|
||||
{
|
||||
text: '历史记录面板',
|
||||
link: '/guide/advanced/history-list.md',
|
||||
},
|
||||
|
||||
{
|
||||
text: '@tmagic/form',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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="表单对比"/>
|
||||
|
||||
|
||||
87
docs/guide/advanced/history-list.md
Normal file
87
docs/guide/advanced/history-list.md
Normal 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)。
|
||||
@ -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;
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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(() =>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 },
|
||||
{
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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: '',
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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),
|
||||
);
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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, {
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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' } });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
|
||||
@ -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: [
|
||||
|
||||
@ -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]);
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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));
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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('下移');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
43
packages/form/tests/unit/containers/Table.spec.ts
Normal file
43
packages/form/tests/unit/containers/Table.spec.ts
Normal 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('清空');
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user