roymondchen a7999f50d5 feat(form): 用 FormContext 与 provide/inject 替代 extendState 钩子
统一表单业务上下文的注入方式,通过读穿 Proxy 保持 mForm 回调签名不变,并移除 extendFormState/extendState 等分散的扩展钩子。
2026-09-01 14:48:42 +08:00

187 lines
6.0 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>
<div class="m-editor-props-form-panel">
<slot name="props-form-panel-header"></slot>
<TMagicScrollbar>
<MForm
ref="configForm"
:class="[propsPanelSize, 'm-editor-props-form-panel-form']"
:popper-class="`m-editor-props-panel-popper ${propsPanelSize}`"
:label-width="labelWidth"
:label-position="labelPosition"
:size="propsPanelSize"
:init-values="values"
:config="config"
:type-match-valid="true"
:context="formContext"
:validate-on-init="true"
@change="submit"
@error="errorHandler"
></MForm>
</TMagicScrollbar>
<TMagicButton
v-if="!disabledShowSrc"
class="m-editor-props-panel-src-icon"
circle
title="源码"
:type="showSrc ? 'primary' : ''"
@click="showSrc = !showSrc"
>
<MIcon :icon="DocumentIcon"></MIcon>
</TMagicButton>
<CodeEditor
v-if="showSrc"
class="m-editor-props-panel-src-code"
editor-custom-type="m-editor-props-panel-src-code"
:height="`${editorContentHeight}px`"
:init-values="codeValueKey ? values[codeValueKey] : values"
:options="codeOptions"
:parse="true"
@save="saveCode"
></CodeEditor>
</div>
</template>
<script setup lang="ts">
import { computed, getCurrentInstance, inject, onMounted, onUnmounted, ref, useTemplateRef } from 'vue';
import { Document as DocumentIcon } from '@element-plus/icons-vue';
import { TMagicButton, tMagicMessage, TMagicScrollbar } from '@tmagic/design';
import type { ContainerChangeEventData, FormConfig, FormValue } from '@tmagic/form';
import { MForm, validateForm } from '@tmagic/form';
import { filterXSS } from '@tmagic/utils';
import MIcon from '@editor/components/Icon.vue';
import { ENABLE_PROPS_FORM_VALIDATE } from '@editor/editorProps';
import { useEditorContentHeight } from '@editor/hooks/use-editor-content-height';
import { useEditorFormContext } from '@editor/hooks/use-form-context';
import { useServices } from '@editor/hooks/use-services';
import CodeEditor from '../CodeEditor.vue';
defineSlots<{
'props-form-panel-header'(_props: {}): any;
}>();
defineOptions({
name: 'MEditorFormPanel',
});
const props = defineProps<{
config: FormConfig;
values: FormValue;
disabledShowSrc?: boolean;
labelWidth?: string;
codeValueKey?: string;
labelPosition?: 'top' | 'left' | 'right';
}>();
const emit = defineEmits<{
submit: [values: any, eventData?: ContainerChangeEventData, error?: any];
'submit-error': [e: any];
'form-error': [e: any];
mounted: [internalInstance: any];
unmounted: [];
}>();
const enablePropsFormValidate = inject(ENABLE_PROPS_FORM_VALIDATE, false);
const services = useServices();
const { uiService } = services;
const codeOptions = inject('codeOptions', {});
const showSrc = ref(false);
const propsPanelSize = computed(() => uiService.get('propsPanelSize') || 'small');
const { height: editorContentHeight } = useEditorContentHeight();
const formContext = useEditorFormContext(() => services);
const configFormRef = useTemplateRef<InstanceType<typeof MForm>>('configForm');
const internalInstance = getCurrentInstance();
onMounted(() => {
emit('mounted', internalInstance?.proxy);
});
onUnmounted(() => {
emit('unmounted');
});
const submit = async (v: FormValue, eventData: ContainerChangeEventData) => {
try {
const values = await configFormRef.value?.submitForm();
// 校验成功:正常更新节点(第三个参数 error 为空,表示清除该来源的错误记录)
emit('submit', values, eventData);
} catch (e: any) {
if (enablePropsFormValidate) {
// 启用校验联动:校验失败时仍以当前表单值更新节点,并把错误信息一并抛给上层记录
emit('submit', v, eventData, e);
} else {
// 未启用:保持原行为,校验失败丢弃本次改动
emit('submit-error', e);
}
}
};
const errorHandler = (e: any) => {
emit('form-error', e);
};
/**
* 将校验错误文案安全地转为用于弹窗展示的 HTML。
*
* 文案形如 `字段 -> 主文案\n\n建议`,多条以结构性 `<br>` 拼接。为在保留换行排版的同时
* 避免 `${value}`/字段名中可能夹带的 HTML 造成 XSS先按结构性 `<br>` 拆分,对每段做 HTML
* 转义后再把段内换行 `\n` 替换为 `<br>`,最后用 `<br>` 拼回。
*/
const formatValidateErrorHtml = (error: string): string =>
error
.split(/<br\s*\/?>/i)
.map((segment) => filterXSS(segment).replaceAll('\n', '<br>'))
.join('<br>');
const saveCode = async (values: any) => {
const newValues = props.codeValueKey ? { [props.codeValueKey]: values } : values;
if (!enablePropsFormValidate) {
// 未启用校验联动:保持原行为,直接提交源码保存的值
emit('submit', newValues);
return;
}
// 启用校验联动:源码编辑器保存的值未经过表单交互,这里对最新配置做一次无渲染的静默校验
// (不挂载任何组件,也不影响页面上正在展示的表单),并将校验结果(错误信息)随提交
// 一并抛给上层记录,使源码保存的错误状态与表单编辑保持一致。
//
// 配置里的 display / rules 回调会从 formState 上读 services因此必须带上 context。
try {
const error = await validateForm({
config: props.config,
typeMatchValid: true,
initValues: newValues,
context: formContext.value,
});
if (error) {
tMagicMessage({
type: 'error',
message: formatValidateErrorHtml(error),
dangerouslyUseHTMLString: true,
});
}
emit('submit', newValues, undefined, error ? new Error(error) : undefined);
} catch (e: any) {
console.error('validateForm error', e);
// 嵌套配置 / initValue 等机制故障不得当成「校验通过」,
// 把 error 交给上层记录,同时仍提交源码,避免阻塞保存。
emit('submit', newValues, undefined, e instanceof Error ? e : new Error(String(e)));
}
};
defineExpose({ configForm: configFormRef, submit });
</script>