mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-05-30 20:28:04 +00:00
- CodeSelect/CodeSelectCol/EventSelect/DataSource 等复合字段在对比模式下 按索引对齐前后值,逐项展示新增/删除/修改高亮,并隐藏写操作按钮 - form 容器/列表/表格支持对比模式只读展示 - 新增「历史记录面板」指南文档,完善表单对比文档及 menu props 说明 - 补充相关单元测试 Co-authored-by: Cursor <cursoragent@cursor.com>
400 lines
12 KiB
Vue
400 lines
12 KiB
Vue
<template>
|
||
<TMagicForm
|
||
class="m-form"
|
||
ref="tMagicForm"
|
||
:model="values"
|
||
:label-width="labelWidth"
|
||
:style="`height: ${height}`"
|
||
:inline="inline"
|
||
:label-position="labelPosition"
|
||
@submit="submitHandler"
|
||
>
|
||
<template v-if="initialized && Array.isArray(config)">
|
||
<Container
|
||
v-for="(item, index) in config"
|
||
:disabled="disabled"
|
||
:key="(item as Record<string, any>)[keyProp] ?? index"
|
||
:config="item"
|
||
:model="values"
|
||
:last-values="lastValuesProcessed"
|
||
:is-compare="isCompare"
|
||
:label-width="item.labelWidth || labelWidth"
|
||
:step-active="stepActive"
|
||
:size="size"
|
||
@change="changeHandler"
|
||
>
|
||
<template v-if="$slots.label" #label="labelProps">
|
||
<slot name="label" v-bind="labelProps"></slot>
|
||
</template>
|
||
</Container>
|
||
</template>
|
||
</TMagicForm>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { provide, reactive, ref, shallowRef, toRaw, useTemplateRef, watch, watchEffect } from 'vue';
|
||
import { cloneDeep, isEqual } from 'lodash-es';
|
||
|
||
import { TMagicForm, tMagicMessage, tMagicMessageBox } from '@tmagic/design';
|
||
import { setValueByKeyPath } from '@tmagic/utils';
|
||
|
||
import Container from './containers/Container.vue';
|
||
import { getConfig } from './utils/config';
|
||
import { initValue } from './utils/form';
|
||
import type {
|
||
ChangeRecord,
|
||
ContainerChangeEventData,
|
||
FormConfig,
|
||
FormSlots,
|
||
FormState,
|
||
FormValue,
|
||
ValidateError,
|
||
} from './schema';
|
||
import { FORM_DIFF_CONFIG_KEY } from './schema';
|
||
|
||
defineOptions({
|
||
name: 'MForm',
|
||
});
|
||
|
||
defineSlots<FormSlots>();
|
||
|
||
const props = withDefaults(
|
||
defineProps<{
|
||
/** 表单配置 */
|
||
config: FormConfig;
|
||
/** 表单值 */
|
||
initValues: Record<string, any>;
|
||
/** 需对比的值(开启对比模式时传入) */
|
||
lastValues?: Record<string, any>;
|
||
/** 是否开启对比模式 */
|
||
isCompare?: boolean;
|
||
parentValues?: Record<string, any>;
|
||
labelWidth?: string;
|
||
disabled?: boolean;
|
||
height?: string;
|
||
stepActive?: string | number;
|
||
size?: 'small' | 'default' | 'large';
|
||
inline?: boolean;
|
||
labelPosition?: string;
|
||
keyProp?: string;
|
||
popperClass?: string;
|
||
preventSubmitDefault?: boolean;
|
||
extendState?: (_state: FormState) => Record<string, any> | Promise<Record<string, any>>;
|
||
/**
|
||
* 自定义"是否展示对比内容"的判断函数(仅在 `isCompare === true` 时生效)。
|
||
*
|
||
* - 不传:使用默认逻辑 `!isEqual(curValue, lastValue)`;
|
||
* - 传函数:完全以函数返回值为准,返回 `true` 才展示前后两份对比内容。
|
||
*
|
||
* 通过 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: () => [],
|
||
initValues: () => ({}),
|
||
lastValues: () => ({}),
|
||
isCompare: false,
|
||
parentValues: () => ({}),
|
||
labelWidth: '200px',
|
||
disabled: false,
|
||
height: 'auto',
|
||
stepActive: 1,
|
||
inline: false,
|
||
labelPosition: 'right',
|
||
keyProp: '__key',
|
||
},
|
||
);
|
||
|
||
const emit = defineEmits(['change', 'error', 'field-input', 'field-change', 'update:stepActive']);
|
||
|
||
const tMagicFormRef = useTemplateRef('tMagicForm');
|
||
const initialized = ref(false);
|
||
const values = ref<FormValue>({});
|
||
const lastValuesProcessed = ref<FormValue>({});
|
||
const fields = new Map<string, any>();
|
||
|
||
const requestFuc = getConfig('request') as Function;
|
||
|
||
/**
|
||
* formState 实现说明:
|
||
*
|
||
* 1. 与 props 直接对应的字段(config / initValues / lastValues / isCompare / parentValues /
|
||
* keyProp / popperClass)使用「访问器(getter)」定义,每次读取都会回到 `props.xxx`
|
||
* 取最新值,不存在「props 变了但 formState 还没同步过来」的中间态。
|
||
*
|
||
* 2. `values` / `lastValuesProcessed` 是 ref,Vue 的 `reactive` 会自动解包,因此每次
|
||
* 访问 `formState.values` / `formState.lastValuesProcessed` 也都是当前 ref 值。
|
||
*
|
||
* 3. `extendState` 注入的字段在下方的 `watchEffect` 中合并到 `formState`:
|
||
* - data 描述符(普通字段)通过 `formState[key] = value` 写入,走 reactive proxy 的
|
||
* set,触发依赖通知;`extendState` 同步段读到的响应式数据变化时会自动重跑,
|
||
* 把最新值刷进 formState。
|
||
* - accessor 描述符(`{ get stage() { return ... } }`)按原样写入,调用方可以控制
|
||
* 读时求值,每次读取都会重新执行 getter。
|
||
*/
|
||
const formState: FormState = reactive<FormState>({
|
||
get keyProp() {
|
||
return props.keyProp;
|
||
},
|
||
get popperClass() {
|
||
return props.popperClass;
|
||
},
|
||
get config() {
|
||
return props.config;
|
||
},
|
||
get initValues() {
|
||
return props.initValues;
|
||
},
|
||
get isCompare() {
|
||
return props.isCompare;
|
||
},
|
||
get lastValues() {
|
||
return props.lastValues;
|
||
},
|
||
get parentValues() {
|
||
return props.parentValues;
|
||
},
|
||
values,
|
||
lastValuesProcessed,
|
||
$emit: emit as (_event: string, ..._args: any[]) => void,
|
||
fields,
|
||
setField: (prop: string, field: any) => fields.set(prop, field),
|
||
getField: (prop: string) => fields.get(prop),
|
||
deleteField: (prop: string) => fields.delete(prop),
|
||
$messageBox: tMagicMessageBox,
|
||
$message: tMagicMessage,
|
||
post: (options: any) => {
|
||
if (requestFuc) {
|
||
return requestFuc({
|
||
method: 'POST',
|
||
...options,
|
||
});
|
||
}
|
||
},
|
||
});
|
||
|
||
/**
|
||
* `extendState` 的同步段(直到第一个 `await` 之前)所访问的任何响应式数据,
|
||
* 都会被 `watchEffect` 自动跟踪。这样可以兼容历史用法 ——
|
||
*
|
||
* extendState: (formState) => ({
|
||
* username: store.username, // 同步读 store,会被跟踪
|
||
* env: store.env,
|
||
* })
|
||
*
|
||
* 当 `store.username` 变化时,整个 effect 重跑,新值会被刷进 `formState`。
|
||
*
|
||
* prop 派生字段(initValues / config / ...)已经在上方用 getter 定义,
|
||
* 这里不再重复同步;因此 `props.initValues` 这类高频变化也不会再触发
|
||
* `extendState` 重跑(旧版的性能问题修复点)。
|
||
*
|
||
* 实现细节:
|
||
* - data 描述符:通过 `formState[key] = value` 走 reactive proxy 的 set,
|
||
* 触发依赖通知;与旧版「逐项赋值」语义完全等价。
|
||
* - accessor 描述符(`{ get stage() {...} }`)按原样写入 formState,调用方
|
||
* 可以自行控制读时求值;强制 `configurable: true` 以便下一次重跑可再 define。
|
||
*/
|
||
watchEffect(async (onCleanup) => {
|
||
const { extendState } = props;
|
||
if (typeof extendState !== 'function') return;
|
||
|
||
let stale = false;
|
||
onCleanup(() => {
|
||
stale = true;
|
||
});
|
||
|
||
let state: Record<string, any> = {};
|
||
try {
|
||
state = (await extendState(formState)) || {};
|
||
} catch (e) {
|
||
console.error('[MForm] extendState failed:', e);
|
||
return;
|
||
}
|
||
if (stale) return;
|
||
|
||
for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(state))) {
|
||
if ('value' in descriptor) {
|
||
(formState as any)[key] = (state as any)[key];
|
||
} else {
|
||
descriptor.configurable = true;
|
||
Object.defineProperty(formState, key, descriptor);
|
||
}
|
||
}
|
||
});
|
||
|
||
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(
|
||
[() => props.config, () => props.initValues],
|
||
([config], [preConfig]) => {
|
||
changeRecords.value = [];
|
||
|
||
if (!isEqual(toRaw(config), toRaw(preConfig))) {
|
||
initialized.value = false;
|
||
}
|
||
|
||
initValue(formState, {
|
||
initValues: props.initValues,
|
||
config: props.config,
|
||
}).then((value) => {
|
||
values.value = value;
|
||
// 非对比模式,初始化完成
|
||
initialized.value = !props.isCompare;
|
||
});
|
||
|
||
if (props.isCompare) {
|
||
// 对比模式下初始化待对比的表单值
|
||
initValue(formState, {
|
||
initValues: props.lastValues,
|
||
config: props.config,
|
||
}).then((value) => {
|
||
lastValuesProcessed.value = value;
|
||
initialized.value = true;
|
||
});
|
||
}
|
||
},
|
||
{ immediate: true },
|
||
);
|
||
|
||
const changeHandler = (v: FormValue, eventData: ContainerChangeEventData) => {
|
||
if (eventData.changeRecords?.length) {
|
||
for (const record of eventData.changeRecords) {
|
||
if (record.propPath) {
|
||
const index = changeRecords.value.findIndex((item) => item.propPath === record.propPath);
|
||
if (index > -1) {
|
||
changeRecords.value[index] = record;
|
||
} else {
|
||
changeRecords.value.push(record);
|
||
}
|
||
|
||
setValueByKeyPath(record.propPath, record.value, values.value);
|
||
}
|
||
}
|
||
}
|
||
emit('change', values.value, eventData);
|
||
};
|
||
|
||
const submitHandler = (e: SubmitEvent) => {
|
||
if (props.preventSubmitDefault) {
|
||
e.preventDefault();
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 通过 name 从 config 中查找对应的 text
|
||
* @param name - 字段名,支持点分隔的路径格式,如 'a.b.c'
|
||
* @param config - 表单配置数组
|
||
* @returns 找到的 text 值,如果未找到则返回 undefined
|
||
*/
|
||
const getTextByName = (name: string, config: FormConfig = props.config): string | undefined => {
|
||
if (!name || !Array.isArray(config)) return undefined;
|
||
|
||
const nameParts = name.split('.');
|
||
|
||
const findInConfig = (configs: FormConfig, parts: string[]): string | undefined => {
|
||
if (parts.length === 0) return undefined;
|
||
|
||
const [currentPart, ...remainingParts] = parts;
|
||
|
||
for (const item of configs) {
|
||
if (item.name === currentPart) {
|
||
if (remainingParts.length === 0) {
|
||
return typeof item.text === 'string' ? item.text : undefined;
|
||
}
|
||
|
||
if ('items' in item && Array.isArray(item.items)) {
|
||
const result = findInConfig(item.items, remainingParts);
|
||
if (result !== undefined) return result;
|
||
}
|
||
}
|
||
|
||
if ('items' in item && Array.isArray(item.items)) {
|
||
const result = findInConfig(item.items, parts);
|
||
if (result !== undefined) return result;
|
||
}
|
||
}
|
||
|
||
return undefined;
|
||
};
|
||
|
||
return findInConfig(config, nameParts);
|
||
};
|
||
|
||
defineExpose({
|
||
values,
|
||
lastValuesProcessed,
|
||
formState,
|
||
initialized,
|
||
changeRecords,
|
||
|
||
changeHandler,
|
||
|
||
resetForm: () => {
|
||
tMagicFormRef.value?.resetFields();
|
||
changeRecords.value = [];
|
||
},
|
||
|
||
submitForm: async (native?: boolean): Promise<any> => {
|
||
try {
|
||
const result = await tMagicFormRef.value?.validate();
|
||
// tdesign 错误通过返回值返回
|
||
// element-plus 通过throw error
|
||
if (result !== true) {
|
||
throw result;
|
||
}
|
||
changeRecords.value = [];
|
||
return native ? values.value : cloneDeep(toRaw(values.value));
|
||
} catch (invalidFields: any) {
|
||
emit('error', invalidFields);
|
||
|
||
const error: string[] = [];
|
||
|
||
Object.entries(invalidFields).forEach(([prop, ValidateError]) => {
|
||
(ValidateError as ValidateError[]).forEach(({ field, message }) => {
|
||
const name = field || prop;
|
||
const text = getTextByName(name, props.config) || name;
|
||
|
||
error.push(`${text} -> ${message}`);
|
||
});
|
||
});
|
||
|
||
throw new Error(error.join('<br>'));
|
||
}
|
||
},
|
||
|
||
getTextByName,
|
||
});
|
||
</script>
|