roymondchen 02eeb2e87f feat(editor): 统一列表字段添加按钮样式并支持新增后自动滚动
CodeSelect、EventSelect、DisplayConds 使用吸底全宽添加按钮;
group-list 新增后滚到最后一项,避开吸顶标题与吸底按钮。
2026-09-01 12:02:09 +08:00

472 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<TMagicForm
:class="[
'm-form',
effectiveTheme ? `m-form--${effectiveTheme}` : '',
effectiveTheme ? `m-theme--${effectiveTheme}` : '',
]"
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"
:label-position="item.labelPosition || labelPosition"
: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 {
computed,
inject,
nextTick,
provide,
reactive,
ref,
shallowRef,
toRaw,
useTemplateRef,
watch,
watchEffect,
} from 'vue';
import { cloneDeep, isEqualWith } from 'lodash-es';
import { M_THEME_KEY, TMagicForm, tMagicMessage, tMagicMessageBox } from '@tmagic/design';
import { setValueByKeyPath } from '@tmagic/utils';
import Container from './containers/Container.vue';
import { applyExtendState, createFormStateBase, initValue } from './utils/form';
import { formatValidateError as formatError, getTextByName as findTextByName } from './utils/validateError';
import type { ChangeRecord, ContainerChangeEventData, FormConfig, FormSlots, FormState, FormValue } from './schema';
import { FORM_DIFF_CONFIG_KEY, FORM_TYPE_MATCH_VALID_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;
/** 是否开启类型匹配校验 */
typeMatchValid?: boolean;
/**
* 初始化(`config` / `initValues` 就绪)后是否立即执行一次表单校验。
*
* - `false`(默认):不自动校验,避免打开表单时就展示错误态;
* - `true`:初始化完成后在 `nextTick` 中调用 `validate()`。
*/
validateOnInit?: boolean;
disabled?: boolean;
height?: string;
stepActive?: string | number;
size?: 'small' | 'default' | 'large';
inline?: boolean;
labelPosition?: 'top' | 'left' | 'right';
keyProp?: string;
popperClass?: string;
preventSubmitDefault?: boolean;
/**
* 表单校验失败时,错误提示前缀是否使用字段的 text 文案(通过 `getTextByName` 从 config 中查找)。
*
* - `true`(默认):错误提示形如 `字段文案 -> 错误信息`,找不到 text 时回退为字段 name
* - `false`:跳过查找,直接使用字段 name 作为错误提示前缀(形如 `字段name -> 错误信息`)。
*/
useFieldTextInError?: 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[]);
/**
* 主题名称:对应 `packages/form/src/theme/themes/<theme>/index.scss` 的目录名。
*
* 设置后会在表单根元素上追加 `m-form--<theme>` 修饰类,配合按需引入
* `@tmagic/form/dist/themes/<theme>.css` 即可启用主题样式。
*
* 例如:`<MForm theme="magic-admin" />` + `import '@tmagic/form/dist/themes/magic-admin.css'`。
*/
theme?: string;
}>(),
{
config: () => [],
initValues: () => ({}),
lastValues: () => ({}),
isCompare: false,
parentValues: () => ({}),
labelWidth: '200px',
disabled: false,
height: 'auto',
stepActive: 1,
inline: false,
labelPosition: 'right',
keyProp: '__key',
useFieldTextInError: true,
validateOnInit: false,
},
);
const emit = defineEmits(['change', 'error', 'field-input', 'field-change', 'update:stepActive']);
provide(
FORM_TYPE_MATCH_VALID_KEY,
computed(() => props.typeMatchValid),
);
const tMagicFormRef = useTemplateRef('tMagicForm');
const initialized = ref(false);
const values = ref<FormValue>({});
const lastValuesProcessed = ref<FormValue>({});
/**
* 当前表单生效的主题名称:
* - 优先用本组件自己的 `props.theme`
* - 没设置时回退到最近祖先 `<MEditor>` / `<MForm>` provide 的主题,便于内嵌于编辑器
* 时自动跟随外层主题,无需在每个 `MForm` 上重复传 `theme`。
*
* 同时把合并后的值再 provide 出去(见下方 `provide(M_THEME_KEY, ...)`),让 form 子树
* 里再嵌套的 portal 组件(`TMagicPopover` 等)依然能拿到非空主题。
*/
const ancestorTheme = inject(M_THEME_KEY, null);
const effectiveTheme = computed(() => props.theme || ancestorTheme?.value || '');
/**
* 拼到 `formState.popperClass` 上的主题修饰类(仅 `m-theme--<theme>`
* 不带 `m-form` / `m-editor` 前缀,因为 Element Plus 弹层节点本身既不是 form 也不是 editor
*
* 这条类会随所有读 `mForm.popperClass` 的字段Select / DateTime / Cascader 等)下发到
* Element Plus 的 `popper-class`,让 portal 节点也命中 `m-theme--<theme>` 上的 CSS 变量。
*/
const themeClass = computed(() => (effectiveTheme.value ? `m-theme--${effectiveTheme.value}` : ''));
/**
* formState 实现说明:
*
* 1. 与 props 直接对应的字段config / initValues / lastValues / isCompare / parentValues /
* keyProp / popperClass使用「访问器getter」定义每次读取都会回到 `props.xxx`
* 取最新值不存在「props 变了但 formState 还没同步过来」的中间态。
*
* 2. `values` / `lastValuesProcessed` 是 refVue 的 `reactive` 会自动解包,因此每次
* 访问 `formState.values` / `formState.lastValuesProcessed` 也都是当前 ref 值。
*
* 3. `extendState` 注入的字段在下方的 `watchEffect` 中合并到 `formState`
* - data 描述符(普通字段)通过 `formState[key] = value` 写入,走 reactive proxy 的
* set触发依赖通知`extendState` 同步段读到的响应式数据变化时会自动重跑,
* 把最新值刷进 formState。
* - accessor 描述符(`{ get stage() { return ... } }`)按原样写入,调用方可以控制
* 读时求值,每次读取都会重新执行 getter。
*
* 4. `popperClass` 会自动拼接 `themeClass`:调用方传入的 `popperClass` + 当前主题
* 修饰类(含祖先 `<MEditor>` provide 的主题)。这样所有透传到 Element Plus 弹层
* `popper-class` 的字段Select / DateTime / Cascader 等)能自带主题作用域。
*/
const formState: FormState = reactive<FormState>({
get keyProp() {
return props.keyProp;
},
get popperClass() {
const userClass = props.popperClass ?? '';
const tc = themeClass.value;
if (!userClass) return tc;
if (!tc) return userClass;
return `${userClass} ${tc}`;
},
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,
...createFormStateBase({ $message: tMagicMessage, $messageBox: tMagicMessageBox }),
});
/**
* formState 的内置 key 快照keyProp / values / $emit / fields / post 等)。
*
* 在 `extendState` 首次合并前捕获,`applyExtendState` 会据此禁止 `extendState`
* 覆盖这些已有字段(只能新增字段),避免表单核心状态被外部意外改写。
*
* 之所以在此处effect 之外)捕获而不是在 `applyExtendState` 内动态取:
* `watchEffect` 会在依赖变化时重跑,若动态取,`extendState` 自己新增的字段在第二次
* 合并时也会被当成「已有 key」而拒绝刷新这里只锁定内置字段即可规避该问题。
*/
const reservedStateKeys = new Set<string | symbol>(Reflect.ownKeys(formState));
/**
* `extendState` 的同步段(直到第一个 `await` 之前)所访问的任何响应式数据,
* 都会被 `watchEffect` 自动跟踪。这样可以兼容历史用法 ——
*
* extendState: (formState) => ({
* username: store.username, // 同步读 store会被跟踪
* env: store.env,
* })
*
* 当 `store.username` 变化时,整个 effect 重跑,新值会被刷进 `formState`。
*
* prop 派生字段initValues / config / ...)已经在上方用 getter 定义,
* 这里不再重复同步;因此 `props.initValues` 这类高频变化也不会再触发
* `extendState` 重跑(旧版的性能问题修复点)。
*
* 实现细节:合并逻辑统一收口在 `applyExtendState`utils/form——
* data 描述符走 reactive proxy 的 set 触发依赖通知(与旧版「逐项赋值」语义等价),
* accessor 描述符按原样 defineProperty 支持读时求值;
* props 派生的只读 getter 字段keyProp 等)以普通字段形式返回时会被跳过并告警。
*/
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;
applyExtendState(formState, state, reservedStateKeys);
});
provide('mForm', formState);
/**
* 把生效主题(自身或祖先)再 provide 出去,供 form 子树内含 `Teleport` 的组件
* (如 `TMagicPopover`)在传送目标上挂 `m-theme--<theme>` 类。
* 详见 `@tmagic/design/theme.ts`。
*/
provide(M_THEME_KEY, effectiveTheme);
provide('formInline', props.inline);
// 对比相关配置单独通过 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[]>([]);
/**
* 两份配置的结构是否一致;函数一律视为相等。
*
* 宿主(如编辑器属性面板)往往在每次节点更新后整份重新生成配置,其中的
* `display` / `options` / `onChange` 都是新闭包,深比较必然判不等。若据此把 `initialized`
* 置 false整棵表单会卸载重挂滚动位置、展开态、输入焦点全部丢失。
* 配置是响应式 prop闭包换了照样生效只有结构变化增删字段、换组件类型才需要重挂。
*/
const isSameConfigShape = (config: unknown, preConfig: unknown) =>
isEqualWith(config, preConfig, (a, b) => (typeof a === 'function' && typeof b === 'function' ? true : undefined));
watch(
[() => props.config, () => props.initValues],
([config], [preConfig]) => {
changeRecords.value = [];
if (!isSameConfigShape(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.validateOnInit) {
nextTick(() => {
tMagicFormRef.value?.validate();
});
}
});
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();
}
};
const getTextByName = (name: string, config: FormConfig = props.config): string | undefined =>
findTextByName(name, config);
/**
* 将校验返回的 invalidFields 汇总为可读的错误文案(多条以 `<br>` 拼接)。
*
* 实现收口在 `utils/validateError`,供渲染式校验(本组件的 `submitForm` / `validate`)与
* 无渲染校验(`validateValues`)共用,保证两条链路产出的错误文案格式完全一致。
*/
const formatValidateError = (invalidFields: Record<string, any>): string =>
formatError(invalidFields, { config: props.config, useFieldTextInError: props.useFieldTextInError });
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);
throw new Error(formatValidateError(invalidFields));
}
},
/**
* 校验:对表单当前值执行校验,返回汇总后的错误文案。
*
* 与 `submitForm` 的区别:
* - 校验失败时不抛异常、不触发 `error` 事件,而是以返回值形式给出错误文案;
* - 不重置 `changeRecords`,不改变提交语义,仅用于「探测」当前配置是否合法。
*
* 注意:本方法只改变「校验结果的返回方式」,并不负责「不污染页面表单状态」——
* 若需对一份独立的「配置 + 值」做完全不影响页面上已渲染表单的校验,请使用 `validateForm`
* (内部会新建一个隐藏的 MForm 实例,通过 `initValues` 传入待校验值,用完即卸载)。
*
* 典型用途:作为 `validateForm` 内部复用的校验实现;也可在已渲染的表单实例上主动调用,
* 根据返回的错误文案自行决定后续处理(如记录节点错误状态)。
*
* @returns 校验通过返回空字符串 `''`,否则返回以 `<br>` 拼接的错误文案。
*/
validate: async (): Promise<string> => {
try {
const result = await tMagicFormRef.value?.validate();
// tdesign 通过返回值返回校验结果element-plus 通过 throw error
if (result !== true) {
throw result;
}
return '';
} catch (invalidFields: any) {
return formatValidateError(invalidFields);
}
},
getTextByName,
});
</script>