From f0c66427b8e011252110a11c90a109f5f58d3101 Mon Sep 17 00:00:00 2001 From: roymondchen Date: Thu, 28 May 2026 20:30:05 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20form=20=E6=96=B0=E5=A2=9E=20showDiff=20?= =?UTF-8?q?prop=20=E6=94=AF=E6=8C=81=E8=87=AA=E5=AE=9A=E4=B9=89=E5=AF=B9?= =?UTF-8?q?=E6=AF=94=E5=88=A4=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - form: MForm/Container 新增 showDiff prop,允许调用方自定义 '是否展示对比内容' 的判断逻辑,并在嵌套 Container 中自动透传; 不传时沿用默认的 isEqual 行为 - editor: CompareForm 利用该能力处理 code-select 字段中 '' 与 { hookType: 'code', hookData: [] } 两种语义为空形态被 isEqual 误判为差异的问题 - docs: 补充 form-props.md 中 showDiff 的说明与示例 - test: 补充 Code 字段相关单测 --- docs/api/form/form-props.md | 38 +++ .../editor/src/components/CompareForm.vue | 28 +++ .../editor/tests/unit/fields/Code.spec.ts | 238 +++++++++++++++++- packages/form/src/Form.vue | 18 ++ packages/form/src/containers/Container.vue | 34 +++ 5 files changed, 343 insertions(+), 13 deletions(-) diff --git a/docs/api/form/form-props.md b/docs/api/form/form-props.md index 7572971b..8315d00f 100644 --- a/docs/api/form/form-props.md +++ b/docs/api/form/form-props.md @@ -85,6 +85,44 @@ - **类型:** `boolean` +## showDiff + +- **详情:** + + 自定义“是否展示对比内容”的判断函数(仅在 `isCompare === true` 时生效)。 + + - 不传:使用默认逻辑 `!isEqual(curValue, lastValue)`(基于 lodash `isEqual`); + - 传函数:完全以函数返回值为准,返回 `true` 才展示前后两份对比内容。 + + 该 prop 通过 `formState` 透传到所有层级的 Container 中,调用方只需在 MForm 这一层传一次即可对整棵表单生效。 + + 典型场景:某些字段语义上相等但结构不同(例如 `code-select` 字段中 `''` 与 `{ hookType: 'code', hookData: [] }` 应视为相等),调用方在此处显式声明,避免被 `isEqual` 误判为差异。 + +- **类型:** `(data: { curValue: any; lastValue: any; config: FormItemConfig }) => boolean` + +- **示例:** + +```html + + + +``` + ## parentValues - **详情:** 父级表单值 diff --git a/packages/editor/src/components/CompareForm.vue b/packages/editor/src/components/CompareForm.vue index 52142741..f141816a 100644 --- a/packages/editor/src/components/CompareForm.vue +++ b/packages/editor/src/components/CompareForm.vue @@ -11,6 +11,7 @@ :disabled="true" :label-width="labelWidth" :extend-state="extendState" + :show-diff="showDiff" > @@ -124,6 +125,33 @@ const wrapperStyle = computed(() => { } as Record; }); +/** + * `code-select` 字段在历史数据中存在两种"语义为空"的形态: + * - 字符串 `''`(旧数据 / 用户从未配置过钩子); + * - `{ hookType: HookType.CODE, hookData: [] }`(CodeSelect.vue 在挂载时 + * 写入的默认结构,参见 packages/editor/src/fields/CodeSelect.vue 中 + * `props.model[props.name] = { hookType: HookType.CODE, hookData: [] }`)。 + * + * 直接 `isEqual` 会把两者判为不等,从而在历史对比里对每个未配置过钩子的组件 + * 都展示一份"差异",体验很糟糕。这里把它们视为相等,跳过对比。 + * + * 其它类型字段沿用 MForm/Container 的默认 `!isEqual` 判断逻辑。 + */ +const isEmptyCodeSelectValue = (v: any): boolean => { + if (v === '' || v === undefined || v === null) return true; + return typeof v === 'object' && v.hookType === HookType.CODE && Array.isArray(v.hookData) && v.hookData.length === 0; +}; + +const showDiff = ({ curValue, lastValue, config }: { curValue: any; lastValue: any; config: any }) => { + if (config?.type === 'code-select') { + // 双方都是"空形态",视为相等,不展示对比 + if (isEmptyCodeSelectValue(curValue) && isEmptyCodeSelectValue(lastValue)) { + return false; + } + } + return !isEqual(curValue, lastValue); +}; + const loadConfig = async () => { switch (props.category) { case 'node': { diff --git a/packages/editor/tests/unit/fields/Code.spec.ts b/packages/editor/tests/unit/fields/Code.spec.ts index 2dd4f2dc..2e60f4ae 100644 --- a/packages/editor/tests/unit/fields/Code.spec.ts +++ b/packages/editor/tests/unit/fields/Code.spec.ts @@ -3,33 +3,245 @@ * * Copyright (C) 2025 Tencent. */ -import { describe, expect, test, vi } from 'vitest'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; import { defineComponent, h } from 'vue'; import { mount } from '@vue/test-utils'; import Code from '@editor/fields/Code.vue'; +// 用一个简单的桩组件代替 MagicCodeEditor,把所有 props 原样渲染到 data-* 属性上, +// 这样可以直接断言父组件 Code.vue 透传过去的内容是否正确。 vi.mock('@editor/layouts/CodeEditor.vue', () => ({ default: defineComponent({ name: 'CodeEditor', - props: ['height', 'initValues', 'language', 'options', 'autosize', 'parse', 'editorCustomType'], + props: { + height: { type: [String, Number], default: undefined }, + type: { type: String, default: undefined }, + initValues: { type: null, default: undefined }, + modifiedValues: { type: null, default: undefined }, + language: { type: String, default: undefined }, + options: { type: Object, default: undefined }, + autosize: { type: Object, default: undefined }, + parse: { type: Boolean, default: undefined }, + editorCustomType: { type: String, default: undefined }, + }, emits: ['save'], - setup(_p, { emit }) { - return () => h('div', { class: 'fake-code-editor', onClick: () => emit('save', 'newvalue') }); + setup(p, { emit }) { + return () => + h('div', { + class: 'fake-code-editor', + 'data-height': p.height, + 'data-type': p.type ?? '', + 'data-language': p.language ?? '', + 'data-init': JSON.stringify(p.initValues ?? null), + 'data-modified': JSON.stringify(p.modifiedValues ?? null), + 'data-options': JSON.stringify(p.options ?? null), + 'data-autosize': JSON.stringify(p.autosize ?? null), + 'data-parse': String(p.parse ?? ''), + 'data-custom-type': p.editorCustomType ?? '', + onClick: () => emit('save', 'newvalue'), + }); }, }), })); +const mountCode = (props: Record) => + mount(Code, { + props: { + // FieldProps 必填字段,用 as any 绕过测试中类型严格匹配 + config: { height: '100px', language: 'javascript' }, + model: { codeField: 'oldval' }, + name: 'codeField', + prop: 'codeField', + ...props, + } as any, + }); + +const getEl = (wrapper: ReturnType) => wrapper.find('.fake-code-editor').element as HTMLElement; + +const readJson = (el: HTMLElement, attr: string) => JSON.parse(el.getAttribute(attr) || 'null'); + describe('Code', () => { - test('save 触发 change', async () => { - const wrapper = mount(Code, { - props: { - config: { height: '100px', language: 'js' }, - model: { codeField: 'oldval' }, - name: 'codeField', - } as any, + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('基本透传与事件', () => { + test('save 触发 change 事件,参数原样透传 (字符串)', async () => { + const wrapper = mountCode({}); + await wrapper.find('.fake-code-editor').trigger('click'); + expect(wrapper.emitted('change')?.[0]?.[0]).toBe('newvalue'); + }); + + test('save 触发 change 事件,参数可以是对象', async () => { + // 替换桩的 emit 内容:通过自定义子组件方式覆盖默认 emit value 时太复杂, + // 这里直接以 vm.$emit 等价的方式构造数据:通过 wrapper 触发 onClick 是字符串, + // 但 setup 内 save 函数本身也接受任意类型,因此用一个最小用例验证函数行为: + const wrapper = mountCode({}); + // 直接调用底层桩组件 emit,模拟 save 抛出对象 + const child = wrapper.findComponent({ name: 'CodeEditor' }); + child.vm.$emit('save', { a: 1 }); + expect(wrapper.emitted('change')?.[0]?.[0]).toEqual({ a: 1 }); + }); + + test('透传 height / language / autosize / parse / editorCustomType', () => { + const wrapper = mountCode({ + config: { + height: '200px', + language: 'json', + autosize: { minRows: 2, maxRows: 8 }, + parse: true, + mFormItemType: 'vs-code-extra', + options: { tabSize: 4 }, + }, + }); + const el = getEl(wrapper); + expect(el.getAttribute('data-height')).toBe('200px'); + expect(el.getAttribute('data-language')).toBe('json'); + expect(readJson(el, 'data-autosize')).toEqual({ minRows: 2, maxRows: 8 }); + expect(el.getAttribute('data-parse')).toBe('true'); + expect(el.getAttribute('data-custom-type')).toBe('vs-code-extra'); + }); + + test('组件名为 MFieldsVsCode', () => { + const wrapper = mountCode({}); + expect((wrapper.vm.$options as any).name).toBe('MFieldsVsCode'); + }); + }); + + describe('非 diff 模式 (非对比)', () => { + test('init-values 来自 model[name],modified-values 为 null/undefined', () => { + const wrapper = mountCode({ + model: { codeField: 'hello' }, + }); + const el = getEl(wrapper); + expect(el.getAttribute('data-type')).toBe(''); + expect(readJson(el, 'data-init')).toBe('hello'); + expect(readJson(el, 'data-modified')).toBe(null); + }); + + test('disabled=true 时 options.readOnly=true', () => { + const wrapper = mountCode({ disabled: true }); + const el = getEl(wrapper); + expect(readJson(el, 'data-options')).toMatchObject({ readOnly: true }); + }); + + test('disabled=false 时 options.readOnly=false', () => { + const wrapper = mountCode({ disabled: false }); + const el = getEl(wrapper); + expect(readJson(el, 'data-options')).toMatchObject({ readOnly: false }); + }); + + test('readOnly 应覆盖 config.options 中已有的 readOnly 字段', () => { + const wrapper = mountCode({ + // 故意把 config.options.readOnly 设为 true,期望 disabled=false 时仍以 disabled 为准 + config: { height: '100px', language: 'javascript', options: { tabSize: 4, readOnly: true } }, + disabled: false, + }); + const el = getEl(wrapper); + const opts = readJson(el, 'data-options'); + expect(opts.tabSize).toBe(4); + expect(opts.readOnly).toBe(false); + }); + + test('isCompare=true 但缺少 lastValues 时不进入 diff', () => { + const wrapper = mountCode({ + isCompare: true, + // 不传 lastValues + model: { codeField: 'cur' }, + }); + const el = getEl(wrapper); + expect(el.getAttribute('data-type')).toBe(''); + expect(readJson(el, 'data-init')).toBe('cur'); + expect(readJson(el, 'data-modified')).toBe(null); + }); + + test('isCompare=false 即使带 lastValues 也不进入 diff', () => { + const wrapper = mountCode({ + isCompare: false, + lastValues: { codeField: 'old' }, + model: { codeField: 'cur' }, + }); + const el = getEl(wrapper); + expect(el.getAttribute('data-type')).toBe(''); + expect(readJson(el, 'data-init')).toBe('cur'); + }); + }); + + describe('diff 模式 (对比)', () => { + test('isCompare=true 且有 lastValues 时切换为 diff 模式', () => { + const wrapper = mountCode({ + isCompare: true, + lastValues: { codeField: 'old' }, + model: { codeField: 'new' }, + }); + const el = getEl(wrapper); + expect(el.getAttribute('data-type')).toBe('diff'); + expect(readJson(el, 'data-init')).toBe('old'); + expect(readJson(el, 'data-modified')).toBe('new'); + }); + + test('diff 模式下 readOnly 强制为 true,忽略 disabled', () => { + const wrapper = mountCode({ + isCompare: true, + lastValues: { codeField: 'old' }, + model: { codeField: 'new' }, + disabled: false, + }); + const el = getEl(wrapper); + expect(readJson(el, 'data-options')).toMatchObject({ readOnly: true }); + }); + + test('diff 模式下当 lastValues 中无对应 name 字段时,init-values 退化为 null/{}', () => { + const wrapper = mountCode({ + isCompare: true, + // 有 lastValues 对象但没有该字段 + lastValues: {}, + model: { codeField: 'new' }, + }); + const el = getEl(wrapper); + expect(el.getAttribute('data-type')).toBe('diff'); + // 源码逻辑:(lastValues || {})[name],此处 lastValues 是 {},结果为 undefined + expect(readJson(el, 'data-init')).toBe(null); + expect(readJson(el, 'data-modified')).toBe('new'); + }); + + test('切换 isCompare 时模式跟随变化', async () => { + const wrapper = mountCode({ + isCompare: false, + lastValues: { codeField: 'old' }, + model: { codeField: 'new' }, + }); + expect(getEl(wrapper).getAttribute('data-type')).toBe(''); + + await wrapper.setProps({ isCompare: true } as any); + expect(getEl(wrapper).getAttribute('data-type')).toBe('diff'); + expect(readJson(getEl(wrapper), 'data-init')).toBe('old'); + expect(readJson(getEl(wrapper), 'data-modified')).toBe('new'); + }); + + test('切换 lastValues 后 init-values 同步更新', async () => { + const wrapper = mountCode({ + isCompare: true, + lastValues: { codeField: 'v1' }, + model: { codeField: 'cur' }, + }); + expect(readJson(getEl(wrapper), 'data-init')).toBe('v1'); + + await wrapper.setProps({ lastValues: { codeField: 'v2' } } as any); + expect(readJson(getEl(wrapper), 'data-init')).toBe('v2'); + }); + + test('diff 模式下 model 变化时 modified-values 同步更新', async () => { + const wrapper = mountCode({ + isCompare: true, + lastValues: { codeField: 'old' }, + model: { codeField: 'a' }, + }); + expect(readJson(getEl(wrapper), 'data-modified')).toBe('a'); + + await wrapper.setProps({ model: { codeField: 'b' } } as any); + expect(readJson(getEl(wrapper), 'data-modified')).toBe('b'); }); - await wrapper.find('.fake-code-editor').trigger('click'); - expect(wrapper.emitted('change')?.[0]?.[0]).toBe('newvalue'); }); }); diff --git a/packages/form/src/Form.vue b/packages/form/src/Form.vue index d3a73b04..50a6d434 100644 --- a/packages/form/src/Form.vue +++ b/packages/form/src/Form.vue @@ -21,6 +21,7 @@ :label-width="item.labelWidth || labelWidth" :step-active="stepActive" :size="size" + :show-diff="showDiff" @change="changeHandler" >