/* * Tencent is pleased to support the open source community by making TMagicEditor available. * * Copyright (C) 2025 Tencent. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { type AppContext, type Component, createApp, defineComponent, h, nextTick, provide, type Ref, ref, watch, } from 'vue'; import { applyExtendState } from './utils/form'; import Form from './Form.vue'; import { type ChangeRecord, FORM_SILENT_MODE_KEY, type FormConfig, type FormState } from './schema'; // #region SubmitFormOptions /** * submitForm 函数参数(与 Form.vue 组件 props 对齐) */ export interface SubmitFormOptions { /** 表单配置 */ config: FormConfig; /** 表单初始值 */ initValues?: Record; /** 需对比的值(开启对比模式时传入) */ lastValues?: Record; /** 是否开启对比模式 */ isCompare?: boolean; parentValues?: Record; labelWidth?: string; disabled?: boolean; height?: string; stepActive?: string | number; size?: 'small' | 'default' | 'large'; inline?: boolean; labelPosition?: string; keyProp?: string; popperClass?: string; preventSubmitDefault?: boolean; /** * 表单校验失败时,错误提示前缀是否使用字段的 text 文案(通过 `getTextByName` 从 config 中查找)。 * 默认 `true`,置为 `false` 时直接使用字段 name。 */ useFieldTextInError?: boolean; extendState?: (_state: FormState) => Record | Promise>; /** 透传给 Form.submitForm 的参数:是否直接返回原始响应式 values */ native?: boolean; /** * 是否在 resolve 结果中携带 changeRecords(变更记录)。 * 开启后 resolve 的结果为 `{ values, changeRecords }`,否则仅 resolve values。 */ returnChangeRecords?: boolean; /** * 父级应用上下文,用于继承全局组件、指令、provide 等。 * 通常通过 `app._context` 或 `getCurrentInstance()?.appContext` 获取。 */ appContext?: AppContext | null; /** 等待表单初始化的最长时间(毫秒),超时将以错误 reject。默认 10000ms */ timeout?: number; /** * 调试模式。默认 `false`。 * * - `false`:表单以隐藏方式挂载,初始化完成后自动提交(原有行为)。 * - `true`:将表单以弹层形式可见地渲染在页面上,需手动点击「确定」才会触发校验/提交, * 点击「取消」则以 reject 中断;校验失败时保留弹层并展示错误信息,便于修正后重试。 * 调试模式下 `timeout` 不生效(等待人工操作)。 */ debug?: boolean; typeMatchValid?: boolean; /** * 外部中断信号。abort 时会立即以 `signal.reason` reject 并卸载临时表单实例、移除容器。 * 主要用于 `debug` 模式(无超时兜底)下取消一个被放弃的表单弹层,避免其无限驻留在页面上。 */ signal?: AbortSignal; } // #endregion SubmitFormOptions // #region SubmitFormResult /** * 开启 `returnChangeNodes` 时 submitForm 的返回结果 */ export interface SubmitFormResult { /** 校验通过后的表单值 */ values: any; /** 表单变更记录 */ changeRecords: ChangeRecord[]; } // #endregion SubmitFormResult // #region mountFormInstance /** * 构造 wrapper 组件的工厂:在合适时机调用 MForm 实例方法并 resolve/reject、清理实例。 * * 由调用方决定「等待 `initialized` 后自动执行」还是「等待人工触发」(debug 模式), * 以及「调用 `submitForm` 还是 `validate`」「结果如何包装」,从而复用公共脚手架。 */ type FormWrapperFactory = (ctx: { /** 透传给 Form 组件的 props(由工厂统一注入,避免调用方重复闭包) */ formProps: Record; /** 指向挂载的 MForm 实例 */ formRef: Ref; /** 卸载实例并移除容器,resolve/reject 后必须调用以避免泄漏 */ cleanup: () => void; /** resolve 外层 Promise */ resolve: (value: T | PromiseLike) => void; /** reject 外层 Promise */ reject: (reason?: any) => void; }) => Component; interface MountFormInstanceOptions { /** 透传给 Form 组件的 props */ formProps: Record; /** 父级应用上下文,用于继承全局组件、指令、provide 等 */ appContext?: AppContext | null; /** 等待表单初始化的最长时间(毫秒),<=0 时回退到默认超时以保证兜底清理生效 */ timeout: number; /** 超时 reject 的错误文案 */ timeoutMessage: string; /** 是否以 `display:none` 隐藏容器。调试模式需可见,应传 `false` */ hidden?: boolean; /** 是否跳过超时注册。调试模式等待人工操作,应传 `true` */ skipTimeout?: boolean; /** 外部中断信号:abort 时会 reject 并卸载实例、移除容器,用于取消无超时(如 debug)的挂载 */ signal?: AbortSignal; /** 构造 wrapper 组件 */ createWrapper: FormWrapperFactory; } /** 未指定或传入非正数 timeout 时的兜底超时(毫秒),保证非 debug 挂载始终能被清理 */ const DEFAULT_MOUNT_TIMEOUT = 10000; /** * submitForm / validateForm 的公共脚手架: * * 创建临时容器 → 挂载一个 wrapper 组件(内含 MForm)→ 提供统一的 cleanup / timeout / appContext 继承 → * 由 `createWrapper` 决定「何时调用 MForm 的哪个方法、如何 resolve/reject」。 * * 调用方只需关注差异逻辑(调用 `submitForm` 还是 `validate`、结果如何包装、是否需要调试弹层), * 容器创建、卸载、超时、上下文注入等模板代码在此统一收口。 */ const mountFormInstance = (options: MountFormInstanceOptions): Promise => { const { formProps, appContext, timeout, timeoutMessage, hidden = true, skipTimeout = false, signal, createWrapper, } = options; return new Promise((resolve, reject) => { // 已中断则直接 reject,不创建任何容器/实例 if (signal?.aborted) { reject(signal.reason ?? new Error('mountFormInstance aborted')); return; } let cleaned = false; let timer: ReturnType | null = null; let onAbort: (() => void) | null = null; // 用 holder 持有 app,使 cleanup 可在 app 创建之前定义(const app + 无 TDZ / 无 use-before-define) const instance: { app: ReturnType | null } = { app: null }; const container = document.createElement('div'); if (hidden) { container.style.display = 'none'; } document.body.appendChild(container); const cleanup = () => { if (cleaned) return; cleaned = true; if (timer) { clearTimeout(timer); timer = null; } if (signal && onAbort) { signal.removeEventListener('abort', onAbort); onAbort = null; } try { instance.app?.unmount(); } catch { // ignore } container.parentNode?.removeChild(container); }; // 支持外部通过 AbortSignal 主动中断:debug 模式无超时兜底,若调用方放弃了该 Promise, // 可通过 abort 卸载实例、移除遮罩/容器,避免无限驻留在 DOM 中。 if (signal) { onAbort = () => { if (cleaned) return; reject(signal.reason ?? new Error('mountFormInstance aborted')); cleanup(); }; signal.addEventListener('abort', onAbort); } // 从容器创建到 mount 的全流程统一 try/catch:任一步骤(createWrapper/createApp/上下文合并/mount) // 抛错都会走到 cleanup,避免已插入 body 的 container 及未挂载的 app 残留导致泄漏。 try { const formRef = ref(null); // 将 extendState 从 formProps 中剥离:不由 Form.vue 的 async watchEffect 异步应用, // 而是在 wrapper 中通过 sync watch 在 formRef 就绪后直接写入 formState, // 避免 display 等 filterFunction 在首次渲染时读到 undefined。 // 与 CompareForm / FormPanel 中「formRef.value.formState.services = ...」的做法一致。 const { extendState, ...restFormProps } = formProps; const userWrapper = createWrapper({ formRef, formProps: restFormProps, cleanup, resolve, reject }); const wrapperComponent = typeof extendState === 'function' ? defineComponent({ name: 'MFormExtendStateInjector', setup() { watch( () => formRef.value, (form) => { if (!form) return; let result: any; try { result = extendState(form.formState); } catch (e) { console.error('[MForm] extendState failed:', e); return; } // formState 的内置 key 快照:在 extendState 合并前捕获, // 供 applyExtendState 禁止 extendState 覆盖这些已有字段(只能新增), // 与 Form.vue 中 reservedStateKeys 的语义保持一致。 const reservedStateKeys = new Set(Reflect.ownKeys(form.formState)); // 合并逻辑收口在 applyExtendState:props 派生的只读 getter 字段 // (keyProp 等)以普通字段形式返回时会被跳过并告警,避免 proxy set 抛错 const apply = (state: Record | null | undefined) => applyExtendState(form.formState, state, reservedStateKeys); if (result && typeof result.then === 'function') { result.then(apply, (e: any) => console.error('[MForm] extendState failed:', e)); } else { apply(result); } }, { flush: 'sync', immediate: true }, ); return () => h(userWrapper); }, }) : userWrapper; // 静默(隐藏挂载)模式下注入静默标记:vs-code 等重型字段组件可据此跳过自身渲染, // 校验/取值依赖 FormItem 与 model 值,与叶子 UI 组件无关(见 FORM_SILENT_MODE_KEY 注释)。 // 用组件级 provide 而非 app.provide:appContext 合并后 app._context.provides 与父级应用 // 共享引用,app.provide 会把标记泄漏到父级应用。 const rootComponent = hidden ? defineComponent({ name: 'MFormSilentProvider', setup() { provide(FORM_SILENT_MODE_KEY, true); return () => h(wrapperComponent); }, }) : wrapperComponent; const app = createApp(rootComponent); instance.app = app; // 继承父级应用上下文(components / directives / provides / config 等) if (appContext) { Object.assign(app._context, appContext); } // 非 debug(未跳过超时)场景始终注册超时兜底:timeout 为非正数时回退到默认值, // 避免表单永不初始化时实例/容器/watcher 无限驻留而泄漏。 if (!skipTimeout) { const effectiveTimeout = timeout > 0 ? timeout : DEFAULT_MOUNT_TIMEOUT; timer = setTimeout(() => { if (!cleaned) { reject(new Error(timeoutMessage)); cleanup(); } }, effectiveTimeout); } app.mount(container); } catch (err) { reject(err); cleanup(); } }); }; // #endregion mountFormInstance // #region createDebugWrapper interface DebugWrapperOptions { /** 指向挂载的 MForm 实例 */ formRef: Ref; /** 透传给 Form 组件的 props */ formProps: Record; /** 弹层标题 */ title: string; /** wrapper 组件名 */ name: string; /** * 点击「确定」触发;接收 `setError`,校验失败时可调用以在弹层展示错误并保留弹层供修正后重试, * 校验通过则由调用方自行 resolve + cleanup。 */ onConfirm: (setError: (msg: string) => void) => void | Promise; /** 点击「取消」触发(通常 reject + cleanup) */ onCancel: () => void; } /** * 构造调试模式下的可见弹层 wrapper:以 fixed 遮罩居中渲染 MForm,提供「确定 / 取消」按钮与错误展示区。 * * submitForm 与 validateForm 的调试弹层 UI 完全一致,仅「确定」时调用的实例方法、错误处理与取消文案不同, * 这些差异通过 `onConfirm` / `onCancel` 注入,弹层结构在此统一收口。 */ const createDebugWrapper = (options: DebugWrapperOptions): Component => { const { formRef, formProps, title, name, onConfirm, onCancel } = options; const btnBase = { padding: '8px 20px', fontSize: '14px', lineHeight: '1', border: '1px solid #dcdfe6', borderRadius: '4px', cursor: 'pointer', }; return defineComponent({ name, setup() { // 校验失败信息展示区 const errorMsg = ref(''); const setError = (msg: string) => { errorMsg.value = msg; }; return () => h( 'div', { style: { position: 'fixed', inset: '0', zIndex: '10000', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0, 0, 0, 0.5)', }, }, [ h( 'div', { style: { display: 'flex', flexDirection: 'column', width: '600px', maxWidth: '90vw', maxHeight: '85vh', background: '#fff', borderRadius: '8px', boxShadow: '0 8px 24px rgba(0, 0, 0, 0.2)', overflow: 'hidden', }, }, [ h( 'div', { style: { padding: '16px 20px', fontSize: '16px', fontWeight: '600', borderBottom: '1px solid #ebeef5', }, }, title, ), h( 'div', { style: { flex: '1', padding: '20px', overflow: 'auto', }, }, [ h(Form as Component, { ...formProps, ref: formRef }), errorMsg.value ? h('div', { style: { marginTop: '12px', color: '#f56c6c', fontSize: '13px', lineHeight: '1.5', }, innerHTML: errorMsg.value, }) : null, ], ), h( 'div', { style: { display: 'flex', justifyContent: 'flex-end', gap: '12px', padding: '12px 20px', borderTop: '1px solid #ebeef5', }, }, [ h('button', { type: 'button', onClick: onCancel, style: { ...btnBase } }, '取消'), h( 'button', { type: 'button', onClick: () => onConfirm(setError), style: { ...btnBase, color: '#fff', background: '#409eff', borderColor: '#409eff', }, }, '确定', ), ], ), ], ), ], ); }, }); }; // #endregion createDebugWrapper /** * 以命令式方式调用 Form.vue 完成一次表单校验/提交。 * * 类似 ElMessage 的用法:传入 props(包含 `config`/`initValues` 等),函数内部会临时挂载 * 一个不可见的 Form 组件实例,等待初始化完成后调用其 `submitForm` 方法, * 校验通过则 resolve 表单值,校验失败则 reject 错误信息,最后自动卸载实例。 * * @example * ```ts * import { submitForm } from '@tmagic/form'; * * try { * const values = await submitForm({ * config: [...], * initValues: { name: 'foo' }, * }); * console.log(values); * } catch (e) { * console.error(e); * } * * // 需要同时获取变更记录时: * const { values, changeRecords } = await submitForm({ * config: [...], * initValues: { name: 'foo' }, * returnChangeRecords: true, * }); * * // 调试模式:可见地渲染表单,点击「确定」才提交: * const values = await submitForm({ * config: [...], * initValues: { name: 'foo' }, * debug: true, * }); * ``` */ export const submitForm = (options: SubmitFormOptions): Promise => { const { native, appContext, timeout = 10000, returnChangeRecords, debug = false, signal, ...formProps } = options; return mountFormInstance({ formProps, appContext, timeout, signal, // 调试模式需把表单展示出来;普通模式隐藏挂载 hidden: !debug, // 调试模式等待人工操作,不应用超时 skipTimeout: debug, timeoutMessage: `submitForm timeout after ${timeout}ms: form is not initialized.`, createWrapper: ({ formRef, formProps, cleanup, resolve, reject }) => { /** * 执行一次提交:nextTick 等待子组件渲染 → 快照 changeRecords → 调用实例 submitForm → resolve。 * 校验失败时交给 `onValidateError` 决定「保留弹层展示错误(debug)」还是「reject 并清理(普通)」, * 从而让 debug 与普通模式共享同一份提交逻辑。 */ const doSubmit = async (onValidateError: (err: any) => void) => { try { // 等待子组件(FormItem 等)完成首次渲染,确保 validate 能拿到所有字段 await nextTick(); // submitForm 校验通过后会清空 changeRecords,需在调用前先做快照 const changeRecords: ChangeRecord[] = [...(formRef.value?.changeRecords ?? [])]; const result = await formRef.value.submitForm(native); resolve(returnChangeRecords ? { values: result, changeRecords } : result); cleanup(); } catch (err) { onValidateError(err); } }; // 调试模式:可见地渲染表单,点击「确定」才提交,点击「取消」则中断 if (debug) { return createDebugWrapper({ formRef, formProps, name: 'MFormSubmitWrapper', title: 'submitForm 调试', onConfirm: (setError) => doSubmit((err) => { // 校验失败时保留弹层并展示错误,便于修正后重新提交 setError(err instanceof Error ? err.message : String(err)); }), onCancel: () => { reject(new Error('submitForm canceled in debug mode.')); cleanup(); }, }); } // 普通模式:表单初始化完成后自动提交 return defineComponent({ name: 'MFormSubmitWrapper', setup() { const stop = watch( () => formRef.value?.initialized, (initialized) => { if (!initialized) return; stop(); doSubmit((err) => { reject(err); cleanup(); }); }, { flush: 'post', immediate: true }, ); return () => h(Form as Component, { ...formProps, ref: formRef }); }, }); }, }); }; // #region ValidateFormOptions /** * validateForm 函数参数(与 Form.vue 组件 props 对齐,取校验所需子集) */ export interface ValidateFormOptions { /** 表单配置 */ config: FormConfig; /** 待校验的表单值 */ initValues?: Record; parentValues?: Record; labelWidth?: string; keyProp?: string; /** * 校验失败时,错误提示前缀是否使用字段的 text 文案(通过 `getTextByName` 从 config 中查找)。 * 默认 `true`,置为 `false` 时直接使用字段 name。 */ useFieldTextInError?: boolean; extendState?: (_state: FormState) => Record | Promise>; /** * 父级应用上下文,用于继承全局组件、指令、provide 等。 * 通常通过 `app._context` 或 `getCurrentInstance()?.appContext` 获取。 */ appContext?: AppContext | null; /** 等待表单初始化的最长时间(毫秒),超时将以错误 reject。默认 10000ms */ timeout?: number; /** * 调试模式。默认 `false`。 * * - `false`:以隐藏方式挂载,初始化完成后自动校验并 resolve 错误文案(原有静默行为)。 * - `true`:将表单以弹层形式可见地渲染在页面上,需手动点击「确定」才会触发校验, * 点击「取消」则以 reject 中断;校验失败时在弹层内展示错误信息并保留弹层,便于修正后重试, * 校验通过则 resolve 空字符串。调试模式下 `timeout` 不生效(等待人工操作)。 */ debug?: boolean; typeMatchValid?: boolean; /** * 外部中断信号。abort 时会立即以 `signal.reason` reject 并卸载临时表单实例、移除容器。 * 主要用于 `debug` 模式(无超时兜底)下取消一个被放弃的表单弹层,避免其无限驻留在页面上。 */ signal?: AbortSignal; } // #endregion ValidateFormOptions // #region stripTabLazy /** * 深拷贝配置值,保留函数(display / onTabClick 等回调)引用,避免破坏配置中的回调。 */ const cloneConfigValue = (value: any): any => { if (Array.isArray(value)) { return value.map(cloneConfigValue); } if (value && typeof value === 'object') { return Object.keys(value).reduce>((acc, key) => { acc[key] = cloneConfigValue(value[key]); return acc; }, {}); } return value; }; /** * 深度遍历配置,去掉所有 type 为 'tab' 的容器中各标签页(items)的 lazy 配置。 */ const removeTabItemsLazy = (node: any): void => { if (Array.isArray(node)) { node.forEach(removeTabItemsLazy); return; } if (!node || typeof node !== 'object') return; if (node.type === 'tab' && Array.isArray(node.items)) { node.items.forEach((pane: any) => { if (pane && typeof pane === 'object') { delete pane.lazy; } }); } Object.keys(node).forEach((key) => { removeTabItemsLazy(node[key]); }); }; /** * 返回一份去除了 tab 标签页 lazy 的配置副本。 * * tab 容器开启 lazy 时,非激活标签页的内容不会渲染,导致 validateForm 静默挂载后 * 无法校验到这些标签页内的字段。校验场景需要一次性渲染全部字段,故在此统一去除 lazy。 * 处理基于深拷贝,不会污染调用方传入的原始 config。 */ export const stripTabItemsLazy = (config: FormConfig): FormConfig => { const cloned = cloneConfigValue(config); removeTabItemsLazy(cloned); return cloned; }; // #endregion stripTabLazy /** * 以命令式方式对一份「表单配置 + 值」做一次静默校验,**不依赖也不影响任何已渲染的表单**。 * * 与 `submitForm` 类似,内部会临时挂载一个不可见的 MForm 实例,等待其初始化完成后调用 * 实例的 `validate` 方法,返回汇总后的错误文案,随后自动卸载实例。 * * 与 `submitForm` 的区别: * - 「静默」:校验失败不抛异常、不触发 `error` 事件、不返回表单值; * - 仅用于「探测」配置是否合法,适合源码保存后校验、批量校验组件配置等场景。 * * 由于每次都新建一个独立的 MForm 实例,调用方无需持有任何表单 ref,也不会污染 * 页面上正在展示的表单状态。 * * @returns 校验通过返回空字符串 `''`,否则返回以 `
` 拼接的错误文案。 * 仅在初始化超时或挂载失败等异常情况下才会 reject。 * * @example * ```ts * import { validateForm } from '@tmagic/form'; * * const error = await validateForm({ * config: [...], * initValues: { name: 'foo' }, * appContext: getCurrentInstance()?.appContext, * }); * if (error) { * // 配置不合法,error 为错误文案 * } * * // 调试模式:可见地渲染表单,点击「确定」才校验,校验失败保留弹层可修正重试: * const error = await validateForm({ * config: [...], * initValues: { name: 'foo' }, * debug: true, * }); * ``` */ export const validateForm = (options: ValidateFormOptions): Promise => { const { appContext, timeout = 10000, debug = false, config, signal, ...rest } = options; // 去掉 tab 容器各标签页的 lazy,确保懒加载标签页内的字段也参与校验 const formProps = { ...rest, config: stripTabItemsLazy(config) }; return mountFormInstance({ formProps, appContext, timeout, signal, // 调试模式需把表单展示出来;普通模式隐藏挂载 hidden: !debug, // 调试模式等待人工操作,不应用超时 skipTimeout: debug, timeoutMessage: `validateForm timeout after ${timeout}ms: form is not initialized.`, createWrapper: ({ formRef, formProps, cleanup, resolve, reject }) => { /** * 执行一次校验:nextTick 等待子组件渲染 → 调用实例 validate → 通过则 resolve ''。 * 校验失败(返回非空错误文案)时交给 `onInvalid` 决定「静默 resolve 错误文案(普通)」 * 还是「在弹层展示错误并保留供重试(debug)」,从而让两种模式共享同一份校验逻辑。 */ const doValidate = async (onInvalid: (error: string) => void) => { try { // 等待子组件(FormItem 等)完成首次渲染,确保 validate 能拿到所有字段 await nextTick(); // 复用 Form.vue 实例的静默校验方法:校验通过返回 '',失败返回错误文案,均不抛异常 const error = await formRef.value.validate(); if (error) { onInvalid(error); } else { resolve(''); cleanup(); } } catch (err) { reject(err); cleanup(); } }; // 调试模式:可见地渲染表单,点击「确定」才校验,点击「取消」则中断 if (debug) { return createDebugWrapper({ formRef, formProps, name: 'MFormValidateWrapper', title: 'validateForm 调试', onConfirm: (setError) => doValidate((error) => { // 校验失败时保留弹层并展示错误,便于修正后重新校验 setError(error); }), onCancel: () => { reject(new Error('validateForm canceled in debug mode.')); cleanup(); }, }); } // 普通模式:表单初始化完成后自动校验(静默 resolve 错误文案) return defineComponent({ name: 'MFormValidateWrapper', setup() { const stop = watch( () => formRef.value?.initialized, (initialized) => { if (!initialized) return; stop(); doValidate((error) => { // 静默:校验失败也以错误文案 resolve(不抛异常) resolve(error); cleanup(); }); }, { flush: 'post', immediate: true }, ); return () => h(Form as Component, { ...formProps, ref: formRef }); }, }); }, }); };