diff --git a/packages/editor/src/layouts/props-panel/FormPanel.vue b/packages/editor/src/layouts/props-panel/FormPanel.vue index 97ed5e00..19911df1 100644 --- a/packages/editor/src/layouts/props-panel/FormPanel.vue +++ b/packages/editor/src/layouts/props-panel/FormPanel.vue @@ -49,14 +49,13 @@ import { Document as DocumentIcon } from '@element-plus/icons-vue'; import { TMagicButton, tMagicMessage, TMagicScrollbar } from '@tmagic/design'; import type { ContainerChangeEventData, FormConfig, FormState, FormValue } from '@tmagic/form'; -import { MForm } 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 { useServices } from '@editor/hooks/use-services'; -import { validatePropsForm } from '@editor/utils/props'; import CodeEditor from '../CodeEditor.vue'; @@ -162,14 +161,19 @@ const saveCode = async (values: any) => { // 做一次静默校验(不复用、也不污染页面上正在展示的表单),并将校验结果(错误信息)随提交 // 一并抛给上层记录,使源码保存的错误状态与表单编辑保持一致。 try { - const error = await validatePropsForm({ + const error = await validateForm({ config: props.config, - values: newValues, - appContext: internalInstance?.appContext ?? null, - services, - stage: stage.value, typeMatchValid: true, - extendState: props.extendState, + initValues: newValues, + // 将当前组件实例的 provides(含 Editor 顶层的 services / codeOptions 等组件级 provide) + // 合入 appContext,使临时 MForm 中的编辑器字段组件(DataSourceInput 等)能正常 inject + appContext: internalInstance?.appContext ? { ...internalInstance?.appContext, provides: { services } } : null, + extendState: (state) => { + if (configFormRef.value?.formState) { + return { ...(configFormRef.value?.formState || {}) }; + } + return props.extendState?.(state) || {}; + }, }); if (error) { diff --git a/packages/editor/src/utils/props.ts b/packages/editor/src/utils/props.ts index 072ea85e..b2547fd0 100644 --- a/packages/editor/src/utils/props.ts +++ b/packages/editor/src/utils/props.ts @@ -16,9 +16,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - -import type { AppContext } from 'vue'; - import { HookType, NODE_CONDS_KEY, @@ -27,18 +24,7 @@ import { NODE_DISABLE_DATA_SOURCE_KEY, } from '@tmagic/core'; import { tMagicMessage } from '@tmagic/design'; -import type { - ChildConfig, - DisplayCondsConfig, - FormConfig, - FormState, - FormValue, - TabConfig, - TabPaneConfig, -} from '@tmagic/form'; -import { validateForm } from '@tmagic/form'; - -import type { Services } from '@editor/type'; +import type { ChildConfig, DisplayCondsConfig, FormConfig, TabConfig, TabPaneConfig } from '@tmagic/form'; export const arrayOptions = [ { text: '包含', value: 'include' }, @@ -403,86 +389,3 @@ export const removeStyleDisplayConfig = (formConfig: FormConfig): FormConfig => }), }; }); - -// #region ValidatePropsFormOptions -/** - * validatePropsForm 参数 - */ -export interface ValidatePropsFormOptions { - /** 组件属性表单配置 */ - config: FormConfig; - /** 待校验的表单值 */ - values: FormValue; - /** - * 当前组件实例的 appContext(通常为 `getCurrentInstance()?.appContext`)。 - * 会与 services 一并合入临时 MForm 的 appContext,使编辑器字段组件(DataSourceInput 等)能正常 inject。 - */ - appContext?: AppContext | null; - /** 编辑器服务集合,注入到临时表单的 formState */ - services?: Services; - /** stage 实例,注入到临时表单的 formState */ - stage?: any; - /** 外部扩展的 formState */ - extendState?: (_state: FormState) => Record | Promise>; - /** - * 调试模式,默认 `true`:以弹层形式可见地渲染表单,点击「确定」才触发校验。 - * 置为 `false` 时静默挂载后自动校验。 - */ - debug?: boolean; - typeMatchValid?: boolean; -} -// #endregion ValidatePropsFormOptions - -/** - * 对一份「组件属性表单配置 + 值」做一次独立的校验,**不复用也不污染页面上正在展示的表单**。 - * - * 内部基于 `@tmagic/form` 的 `validateForm` 另建一个独立的 MForm 实例完成校验,并统一处理 - * 编辑器场景所需的上下文注入:将当前组件实例的 provides 合入 appContext,并向 formState 注入 - * stage / services 及外部扩展状态,保证校验规则依赖的上下文可用。 - * - * 常用于源码编辑器保存后,对最新配置做一次校验,并将校验结果(错误信息)随提交一并抛给上层记录, - * 使源码保存的错误状态与表单编辑保持一致。 - * - * @returns 校验通过返回空字符串 `''`,否则返回以 `
` 拼接的错误文案。 - * 仅在初始化超时或挂载失败等异常情况下才会 reject。 - * - * @example - * ```ts - * const error = await validatePropsForm({ - * config, - * values, - * appContext: getCurrentInstance()?.appContext, - * services, - * stage: editorService.get('stage'), - * extendState, - * }); - * if (error) { - * // 配置不合法,error 为错误文案 - * } - * ``` - */ -export const validatePropsForm = ({ - config, - values, - appContext = null, - services, - stage, - extendState, - debug, - typeMatchValid, -}: ValidatePropsFormOptions): Promise => - validateForm({ - config, - debug, - typeMatchValid, - initValues: values, - // 将当前组件实例的 provides(含 Editor 顶层的 services / codeOptions 等组件级 provide) - // 合入 appContext,使临时 MForm 中的编辑器字段组件(DataSourceInput 等)能正常 inject - appContext: appContext ? { ...appContext, provides: { services } } : null, - // 与页面表单保持一致:注入 stage/services 及外部扩展状态,保证校验规则依赖的上下文可用 - extendState: async (state) => ({ - ...((await extendState?.(state)) || {}), - stage, - services, - }), - }); diff --git a/packages/editor/tests/unit/utils/props-config.spec.ts b/packages/editor/tests/unit/utils/props-config.spec.ts index 5de30fa4..cacd28e5 100644 --- a/packages/editor/tests/unit/utils/props-config.spec.ts +++ b/packages/editor/tests/unit/utils/props-config.spec.ts @@ -3,10 +3,9 @@ * * Copyright (C) 2025 Tencent. */ -import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { describe, expect, test, vi } from 'vitest'; import { NODE_CONDS_RESULT_KEY } from '@tmagic/core'; -import { validateForm } from '@tmagic/form'; import { advancedTabConfig, @@ -20,7 +19,6 @@ import { numberOptions, removeStyleDisplayConfig, styleTabConfig, - validatePropsForm, } from '@editor/utils/props'; vi.mock('@tmagic/design', () => ({ @@ -172,59 +170,6 @@ describe('fillConfig', () => { }); }); -describe('validatePropsForm', () => { - beforeEach(() => { - vi.mocked(validateForm).mockReset(); - }); - - test('合入 appContext.provides、注入 stage/services 并合并外部 extendState,debug 默认 undefined', async () => { - vi.mocked(validateForm).mockResolvedValue('err-text'); - - const services = { uiService: {} } as any; - const stage = { id: 'stage' }; - const appContext = { app: {}, provides: { foo: 1 } } as any; - - const result = await validatePropsForm({ - config: [], - values: { a: 1 }, - appContext, - services, - stage, - extendState: async () => ({ extra: true }), - }); - - // 直接返回 validateForm 的结果 - expect(result).toBe('err-text'); - - const arg = vi.mocked(validateForm).mock.calls[0][0]; - expect(arg.config).toEqual([]); - expect(arg.debug).toBeUndefined(); - expect(arg.initValues).toEqual({ a: 1 }); - // appContext 保留原字段,但 provides 被替换为 { services } - expect(arg.appContext).toEqual({ app: {}, provides: { services } }); - - // extendState 合并外部返回并注入 stage/services - const state = await arg.extendState!({} as any); - expect(state).toEqual({ extra: true, stage, services }); - }); - - test('appContext 缺省为 null,extendState 缺省时仍注入 stage/services,可覆盖 debug', async () => { - vi.mocked(validateForm).mockResolvedValue(''); - - const services = {} as any; - const stage = {}; - - await validatePropsForm({ config: [], values: {}, services, stage, debug: false }); - - const arg = vi.mocked(validateForm).mock.calls[0][0]; - expect(arg.appContext).toBeNull(); - expect(arg.debug).toBe(false); - - const state = await arg.extendState!({} as any); - expect(state).toEqual({ stage, services }); - }); -}); - describe('removeStyleDisplayConfig', () => { test('将 tab 内「样式」pane 的 display 置为 true', () => { const config = [ diff --git a/packages/form/src/Form.vue b/packages/form/src/Form.vue index 4f063253..4d74dc6c 100644 --- a/packages/form/src/Form.vue +++ b/packages/form/src/Form.vue @@ -261,6 +261,18 @@ const formState: FormState = reactive({ }, }); +/** + * formState 的内置 key 快照(keyProp / values / $emit / fields / post 等)。 + * + * 在 `extendState` 首次合并前捕获,`applyExtendState` 会据此禁止 `extendState` + * 覆盖这些已有字段(只能新增字段),避免表单核心状态被外部意外改写。 + * + * 之所以在此处(effect 之外)捕获而不是在 `applyExtendState` 内动态取: + * `watchEffect` 会在依赖变化时重跑,若动态取,`extendState` 自己新增的字段在第二次 + * 合并时也会被当成「已有 key」而拒绝刷新;这里只锁定内置字段即可规避该问题。 + */ +const reservedStateKeys = new Set(Reflect.ownKeys(formState)); + /** * `extendState` 的同步段(直到第一个 `await` 之前)所访问的任何响应式数据, * 都会被 `watchEffect` 自动跟踪。这样可以兼容历史用法 —— @@ -299,7 +311,7 @@ watchEffect(async (onCleanup) => { } if (stale) return; - applyExtendState(formState, state); + applyExtendState(formState, state, reservedStateKeys); }); provide('mForm', formState); diff --git a/packages/form/src/submitForm.ts b/packages/form/src/submitForm.ts index 37b358bd..22649cad 100644 --- a/packages/form/src/submitForm.ts +++ b/packages/form/src/submitForm.ts @@ -204,10 +204,14 @@ const mountFormInstance = (options: MountFormInstanceOptions): Promise 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); + applyExtendState(form.formState, state, reservedStateKeys); if (result && typeof result.then === 'function') { result.then(apply, (e: any) => console.error('[MForm] extendState failed:', e)); } else { diff --git a/packages/form/src/utils/form.ts b/packages/form/src/utils/form.ts index 58e6ca42..c79add8f 100644 --- a/packages/form/src/utils/form.ts +++ b/packages/form/src/utils/form.ts @@ -449,16 +449,27 @@ export const sortChange = (data: any[], { prop, order }: SortProp) => { * - accessor 描述符(`{ get stage() { return ... } }`)按原样 defineProperty,调用方 * 可控制读时求值;强制 `configurable: true` 以便下一次合并可再 define。 * - * 注意:formState 上由 props 派生的字段(keyProp / popperClass / config / initValues / - * isCompare / lastValues / parentValues)是只读 getter(无 setter),extendState 若以 - * 普通字段形式返回同名 key,直接赋值会让 proxy 的 set trap 失败并抛出 - * `TypeError: 'set' on proxy: trap returned falsish`,这里统一跳过并告警; - * 如确需覆盖,可在 extendState 中以 get 访问器形式返回。 + * 注意:extendState 只能向 formState「新增」字段,不允许覆盖其已有 key。 + * 调用方可通过 `reservedKeys` 传入合并前已存在的内置 key 快照(keyProp / popperClass / + * config / initValues / isCompare / lastValues / parentValues / values / $emit / fields / + * post 等),命中这些 key 时统一跳过并告警。 + * + * 兜底:未传 `reservedKeys` 时,仍会拦截 props 派生的只读 getter 字段(无 setter), + * 否则以普通字段形式赋值会让 proxy 的 set trap 抛出 + * `TypeError: 'set' on proxy: trap returned falsish`。 */ -export const applyExtendState = (formState: FormState, state: Record | null | undefined): void => { +export const applyExtendState = ( + formState: FormState, + state: Record | null | undefined, + reservedKeys?: Set, +): void => { if (!state) return; for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(state))) { + if (reservedKeys?.has(key)) { + continue; + } + if (!('value' in descriptor)) { descriptor.configurable = true; Object.defineProperty(formState, key, descriptor); diff --git a/packages/form/tests/unit/Form.extra.spec.ts b/packages/form/tests/unit/Form.extra.spec.ts index 3e7d681b..94439c90 100644 --- a/packages/form/tests/unit/Form.extra.spec.ts +++ b/packages/form/tests/unit/Form.extra.spec.ts @@ -189,9 +189,7 @@ describe('Form.vue —— extendState', () => { expect(v2).toMatch(/^stage-/); }); - test('extendState 以普通字段返回 props 派生的只读字段时跳过并告警,不抛错', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - + test('extendState 以普通字段返回内置保留字段时静默跳过,其余字段正常合并', async () => { const wrapper = mountForm({ keyProp: 'id', extendState: () => ({ keyProp: 'custom', config: [], extra: 'ok' }), @@ -201,18 +199,15 @@ describe('Form.vue —— extendState', () => { await nextTick(); await nextTick(); - // 只读派生字段保持 props 值,未被覆盖 + // 内置保留字段(keyProp / config)未被 extendState 覆盖 expect((wrapper.vm.formState as any).keyProp).toBe('id'); expect(Array.isArray((wrapper.vm.formState as any).config)).toBe(true); - // 其他字段正常合并 + // 非保留字段正常合并 expect((wrapper.vm.formState as any).extra).toBe('ok'); - expect(warnSpy).toHaveBeenCalled(); expect(wrapper.find('.m-form').exists()).toBe(true); - - warnSpy.mockRestore(); }); - test('extendState 以 get 访问器返回只读字段同名 key 时允许显式覆盖', async () => { + test('extendState 以 get 访问器返回内置保留字段同名 key 时仍被拦截,无法覆盖', async () => { const wrapper = mountForm({ keyProp: 'id', extendState: () => @@ -228,7 +223,8 @@ describe('Form.vue —— extendState', () => { await nextTick(); await nextTick(); - expect((wrapper.vm.formState as any).keyProp).toBe('custom-key'); + // keyProp 属于内置保留字段,即使以访问器形式返回也不允许覆盖 + expect((wrapper.vm.formState as any).keyProp).toBe('id'); }); test('extendState 同步段读到的响应式数据变化时会重跑', async () => { diff --git a/packages/form/tests/unit/submitForm.spec.ts b/packages/form/tests/unit/submitForm.spec.ts index 5f2e7c60..942e0bd6 100644 --- a/packages/form/tests/unit/submitForm.spec.ts +++ b/packages/form/tests/unit/submitForm.spec.ts @@ -90,9 +90,7 @@ describe('submitForm', () => { expect(extendState).toHaveBeenCalled(); }); - test('extendState 返回 keyProp 等只读派生字段时不抛错且正常 resolve', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - + test('extendState 返回 keyProp 等内置保留字段时静默跳过且正常 resolve', async () => { const values = await submitForm({ config: [{ type: 'text', name: 'text', text: 'text' }], initValues: { text: 'foo' }, @@ -100,10 +98,8 @@ describe('submitForm', () => { appContext, }); + // keyProp 属于内置保留字段,被静默跳过,不污染最终 values expect(values).toEqual({ text: 'foo' }); - expect(warnSpy).toHaveBeenCalled(); - - warnSpy.mockRestore(); }); test('在嵌套 items 配置下也能正确 resolve', async () => { diff --git a/packages/form/tests/unit/utils/form.spec.ts b/packages/form/tests/unit/utils/form.spec.ts index 2b1ba522..4fc58041 100644 --- a/packages/form/tests/unit/utils/form.spec.ts +++ b/packages/form/tests/unit/utils/form.spec.ts @@ -16,9 +16,10 @@ * limitations under the License. */ -import { describe, expect, test } from 'vitest'; +import { describe, expect, test, vi } from 'vitest'; import type { FormState } from '@form/index'; import { + applyExtendState, createObjectProp, createValues, datetimeFormatter, @@ -1014,3 +1015,130 @@ describe('createObjectProp', () => { expect(createObjectProp('a.b.c', 'newKey', 'c')).toBe('a.b.newKey'); }); }); + +describe('applyExtendState', () => { + test('state 为 null 或 undefined 时不修改 formState', () => { + const formState = { a: 1 } as any; + + applyExtendState(formState, null); + applyExtendState(formState, undefined); + + expect(formState).toEqual({ a: 1 }); + }); + + test('普通字段(data descriptor)以新增字段形式合并进 formState', () => { + const formState = {} as any; + + applyExtendState(formState, { foo: 'bar', num: 1 }); + + expect(formState.foo).toBe('bar'); + expect(formState.num).toBe(1); + }); + + test('accessor descriptor 按原样 define 且读时求值', () => { + const formState = {} as any; + let count = 0; + const state = {}; + Object.defineProperty(state, 'stage', { + get() { + count += 1; + return 'stageValue'; + }, + enumerable: true, + configurable: true, + }); + + applyExtendState(formState, state); + + // 读时才求值 + expect(count).toBe(0); + expect(formState.stage).toBe('stageValue'); + expect(count).toBe(1); + }); + + test('accessor descriptor 强制 configurable=true,可再次 define 不报错', () => { + const formState = {} as any; + const state = {}; + // 原始描述符 configurable 为 false + Object.defineProperty(state, 'stage', { + get: () => 'v', + enumerable: true, + configurable: false, + }); + + applyExtendState(formState, state); + + const descriptor = Object.getOwnPropertyDescriptor(formState, 'stage'); + expect(descriptor?.configurable).toBe(true); + // 再次合并(重新 define)不应抛出 + expect(() => applyExtendState(formState, state)).not.toThrow(); + }); + + test('reservedKeys 命中的 key 被跳过,不覆盖已有内置字段', () => { + const formState = { values: { origin: true } } as any; + const reservedKeys = new Set(['values']); + + applyExtendState(formState, { values: { changed: true }, extra: 1 }, reservedKeys); + + // reserved key 未被覆盖 + expect(formState.values).toEqual({ origin: true }); + // 非 reserved 的新字段正常新增 + expect(formState.extra).toBe(1); + }); + + test('reservedKeys 对 accessor 形式的同名 key 同样跳过', () => { + const formState = { keyProp: 'origin' } as any; + const reservedKeys = new Set(['keyProp']); + const state = {}; + Object.defineProperty(state, 'keyProp', { + get: () => 'override', + enumerable: true, + configurable: true, + }); + + applyExtendState(formState, state, reservedKeys); + + expect(formState.keyProp).toBe('origin'); + }); + + test('未传 reservedKeys 时,只读 getter 字段被跳过并告警', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const formState = {} as any; + Object.defineProperty(formState, 'keyProp', { + get: () => 'readonly', + enumerable: true, + configurable: true, + }); + + applyExtendState(formState, { keyProp: 'newValue' }); + + expect(formState.keyProp).toBe('readonly'); + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + test('未传 reservedKeys 时,存在 setter 的字段可被赋值', () => { + let inner = 'old'; + const formState = {} as any; + Object.defineProperty(formState, 'writable', { + get: () => inner, + set: (v: string) => { + inner = v; + }, + enumerable: true, + configurable: true, + }); + + applyExtendState(formState, { writable: 'new' }); + + expect(formState.writable).toBe('new'); + }); + + test('未传 reservedKeys 时,普通可写字段正常覆盖赋值', () => { + const formState = { editable: 'old' } as any; + + applyExtendState(formState, { editable: 'new' }); + + expect(formState.editable).toBe('new'); + }); +});