From 6f0a41f2db8add132943cf7b3e4d796f99106e6f Mon Sep 17 00:00:00 2001 From: roymondchen Date: Tue, 4 Aug 2026 19:13:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(editor):=20=E6=94=AF=E6=8C=81=20update=20r?= =?UTF-8?q?eplace=20=E6=95=B4=E8=8A=82=E7=82=B9=E6=9B=BF=E6=8D=A2=EF=BC=8C?= =?UTF-8?q?=E6=BA=90=E7=A0=81=E7=BC=96=E8=BE=91=E8=B5=B0=20replace=20?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 replace 选项跳过 mergeWith 等变换,PropsPanel 源码保存时使用整节点覆盖,避免已删除字段被 merge 保留。 --- docs/api/editor/editorServiceMethods.md | 11 +++++ .../src/layouts/props-panel/PropsPanel.vue | 44 ++++++++++++------- packages/editor/src/services/editor.ts | 35 +++++++++++---- .../layouts/props-panel/PropsPanel.spec.ts | 43 ++++++++++++++++-- .../editor/tests/unit/services/editor.spec.ts | 15 +++++++ 5 files changed, 122 insertions(+), 26 deletions(-) diff --git a/docs/api/editor/editorServiceMethods.md b/docs/api/editor/editorServiceMethods.md index 51b3df1f..553a9297 100644 --- a/docs/api/editor/editorServiceMethods.md +++ b/docs/api/editor/editorServiceMethods.md @@ -480,6 +480,8 @@ editorService.highlight("text_123"); - {`MNode`} config 新的节点 - `{Object}` data 可选配置 - {`ChangeRecord`[]} changeRecords 变更记录 + - `{boolean}` replace 是否整节点替换(默认 false)。为 `true` 时跳过 `mergeWith` / `toggleFixedPosition` / `setChildrenLayout`,直接用传入配置覆盖现有节点 + - `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options) - **返回:** - `{Promise<{ newNode: MNode; oldNode: MNode; changeRecords?: ChangeRecord[] }>}` 更新前后的节点信息 @@ -498,6 +500,8 @@ editorService.highlight("text_123"); 当被更新节点正好在当前选中列表中时,state 会自动同步到新的节点引用,无需调用方处理 当被更新节点正好是当前页面时,state.page 也会同步到新的节点引用;更新非当前页面(不同 ID)时不会把编辑器切到该页 + + 默认会将传入配置与现有节点深合并(保留未传入的字段)。若需要完整 DSL 覆盖(如源码编辑写回),传入 `replace: true`。 ::: ## update @@ -512,6 +516,7 @@ editorService.highlight("text_123"); - `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false) - `{string}` historyDescription 见[历史记录相关 options](#历史记录相关-options) - `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options) + - `{boolean}` replace 是否整节点替换(默认 false)。为 `true` 时跳过 `mergeWith` / `toggleFixedPosition` / `setChildrenLayout`,直接用传入配置覆盖现有节点;适用于源码编辑、完整 DSL 回写等场景 - `{Object}` invalidInfo 启用 [enablePropsFormValidate](./props.md#enablepropsformvalidate) 时,属性面板提交携带的校验错误信息,在写入历史记录之前落库,使历史快照与本次变更对齐 - `{Id}` id 节点 id - `{'props' | 'style'}` source 错误来源:属性表单 / 样式表单 @@ -546,6 +551,12 @@ editorService.highlight("text_123"); 才退化为整节点替换(如内部 `sort` / `moveLayer` / 拖动等纯快照场景)。 ::: + :::tip + **`replace: true` 整节点替换:** 默认 `update` 会将传入配置与现有节点深合并,未传入的字段会保留。 + 传入 `replace: true` 后跳过合并与布局相关变换,直接用传入配置覆盖,传入对象中不存在的字段会被删除。 + 属性面板局部改动请保持默认;源码编辑 / 完整 DSL 回写等场景使用 `replace: true`。 + ::: + ## sort - **[扩展支持](../../guide/editor-expand#行为扩展):** 是 diff --git a/packages/editor/src/layouts/props-panel/PropsPanel.vue b/packages/editor/src/layouts/props-panel/PropsPanel.vue index d1ab7d62..5925525a 100644 --- a/packages/editor/src/layouts/props-panel/PropsPanel.vue +++ b/packages/editor/src/layouts/props-panel/PropsPanel.vue @@ -158,14 +158,34 @@ const submit = async ( v.id = values.value.id; } - const newValue: MNode = { - ...v, - style: {}, - }; + // 区分操作途径:表单字段编辑(MForm @change)会带上 eventData(含 changeRecords); + // 源码编辑器(CodeEditor @save → saveCode)保存时不带 eventData,据此标记为「源码编辑器」。 + const historySource = eventData ? 'props' : 'code'; + // 源码编辑统一走整节点 replace,避免 merge 保留已删除字段。 + const replace = historySource === 'code'; - if (v.style) { - // 空字符串样式值表示「清除该样式」,需保留才能在 doUpdate 的 mergeWith 中覆盖旧值。 - if (eventData) { + let newValue: MNode; + + if (replace) { + if (source === 'style') { + // 样式面板源码仅提交 { style }:先把新 style 替换到原节点上,再整节点覆盖。 + newValue = { + ...values.value, + id: v.id || values.value.id, + style: { ...(v.style || {}) }, + }; + } else { + // 属性面板源码提交完整节点 DSL + newValue = { ...v }; + } + } else { + newValue = { + ...v, + style: {}, + }; + + if (v.style) { + // 空字符串样式值表示「清除该样式」,需保留才能在 doUpdate 的 mergeWith 中覆盖旧值。 // 表单编辑:先过滤掉空字符串(避免表单默认空值污染 DSL), // 再按 changeRecords 恢复被主动清空的字段。 Object.entries(v.style).forEach(([key, value]) => { @@ -174,24 +194,18 @@ const submit = async ( } }); - eventData.changeRecords?.forEach((record) => { + eventData?.changeRecords?.forEach((record) => { if (record.propPath?.startsWith('style') && record.value === '') { setValueByKeyPath(record.propPath, record.value, newValue); } }); - } else { - // 源码编辑器保存(无 eventData):style 原样保留,其中的空字符串视为用户主动清除该样式。 - newValue.style = { ...v.style }; } } - // 区分操作途径:表单字段编辑(MForm @change)会带上 eventData(含 changeRecords); - // 源码编辑器(CodeEditor @save → saveCode)保存时不带 eventData,据此标记为「源码编辑器」。 - const historySource = eventData ? 'props' : 'code'; - editorService.update(newValue, { changeRecords: eventData?.changeRecords, historySource, + replace, // 启用校验联动时,仅校验失败(error 存在)才把错误信息随更新传入 editorService 记录; // 其余情况(含表单校验成功、CodeEditor 源码保存)不携带 invalidInfo,由 editorService 在执行 update 时统一清除该节点错误。 ...(enablePropsFormValidate && error ? { invalidInfo: { id: newValue.id, source, error: error?.message } } : {}), diff --git a/packages/editor/src/services/editor.ts b/packages/editor/src/services/editor.ts index a3d0a3ef..24045b5b 100644 --- a/packages/editor/src/services/editor.ts +++ b/packages/editor/src/services/editor.ts @@ -720,7 +720,11 @@ class Editor extends BaseService { public async doUpdate( config: MNode, - { changeRecords = [], historySource }: { changeRecords?: ChangeRecord[]; historySource?: HistoryOpSource } = {}, + { + changeRecords = [], + historySource, + replace = false, + }: { changeRecords?: ChangeRecord[]; historySource?: HistoryOpSource; replace?: boolean } = {}, ): Promise<{ newNode: MNode; oldNode: MNode; changeRecords?: ChangeRecord[] }> { const root = this.get('root'); if (!root) throw new Error('root为空'); @@ -733,9 +737,14 @@ class Editor extends BaseService { const node = toRaw(info.node); - let newConfig = await toggleFixedPosition(toRaw(config), node, info.path, this.getLayout); + // replace=true 时跳过 toggleFixedPosition / mergeWith / setChildrenLayout,直接用传入配置整节点替换 + let newConfig = replace + ? cloneDeep(toRaw(config)) + : await toggleFixedPosition(toRaw(config), node, info.path, this.getLayout); - newConfig = mergeWith(cloneDeep(node), newConfig, editorNodeMergeCustomizer); + if (!replace) { + newConfig = mergeWith(cloneDeep(node), newConfig, editorNodeMergeCustomizer); + } if (!newConfig.type) throw new Error('配置缺少type值'); @@ -756,10 +765,12 @@ class Editor extends BaseService { if (!parentNodeItems || typeof index === 'undefined' || index === -1) throw new Error('更新的节点未找到'); - const newLayout = await this.getLayout(newConfig); - const layout = await this.getLayout(node); - if (Array.isArray(newConfig.items) && newLayout !== layout) { - newConfig = setChildrenLayout(newConfig as MContainer, newLayout); + if (!replace) { + const newLayout = await this.getLayout(newConfig); + const layout = await this.getLayout(node); + if (Array.isArray(newConfig.items) && newLayout !== layout) { + newConfig = setChildrenLayout(newConfig as MContainer, newLayout); + } } parentNodeItems[index] = newConfig; @@ -798,6 +809,7 @@ class Editor extends BaseService { * @param data.changeRecordList 多节点 form 端变更记录列表,按 config 数组同序对应每个节点;优先级高于 changeRecords * @param data.doNotPushHistory 是否不写入历史记录(默认 false) * @param data.historyDescription 入栈时附带的人类可读描述,用于历史面板展示(不影响 undo/redo 行为) + * @param data.replace 是否整节点替换:为 true 时跳过 mergeWith / toggleFixedPosition / setChildrenLayout,直接用传入配置覆盖(默认 false) * @returns 更新后的节点配置 */ public async update( @@ -808,6 +820,11 @@ class Editor extends BaseService { doNotPushHistory?: boolean; historyDescription?: string; historySource?: HistoryOpSource; + /** + * 为 true 时不做深合并等变换,直接用传入配置整节点替换现有节点。 + * 适用于源码编辑、历史整节点快照回放等「完整 DSL」场景;默认 false(局部属性更新走 merge)。 + */ + replace?: boolean; /** * 属性面板提交时携带的校验错误信息,在写入历史记录之前落库, * 使历史快照与本次变更对齐,从而 undo/redo 能正确还原错误标记。 @@ -823,6 +840,7 @@ class Editor extends BaseService { changeRecords, historyDescription, historySource, + replace = false, invalidInfo, } = data; @@ -833,7 +851,7 @@ class Editor extends BaseService { const updateData = await Promise.all( nodes.map((node, index) => { const recordsForNode = changeRecordList ? (changeRecordList[index] ?? []) : (changeRecords ?? []); - return this.doUpdate(node, { changeRecords: recordsForNode, historySource }); + return this.doUpdate(node, { changeRecords: recordsForNode, historySource, replace }); }), ); @@ -1348,6 +1366,7 @@ class Editor extends BaseService { doNotPushHistory?: boolean; historyDescription?: string; historySource?: HistoryOpSource; + replace?: boolean; } = {}, ): Promise> { this.lastPushedHistoryId = null; diff --git a/packages/editor/tests/unit/layouts/props-panel/PropsPanel.spec.ts b/packages/editor/tests/unit/layouts/props-panel/PropsPanel.spec.ts index 1ab43ee7..1d310515 100644 --- a/packages/editor/tests/unit/layouts/props-panel/PropsPanel.spec.ts +++ b/packages/editor/tests/unit/layouts/props-panel/PropsPanel.spec.ts @@ -92,10 +92,15 @@ vi.mock('@editor/layouts/props-panel/FormPanel.vue', () => ({ onClick: () => emit('submit', { id: 'n1', style: { color: 'red' } }, { changeRecords: [] }, new Error('校验失败详情')), }), - // 模拟 CodeEditor 源码保存:仅传 values,无 eventData、无 error(对应 saveCode 路径) + // 模拟属性面板 CodeEditor 源码保存:完整节点,无 eventData(对应 saveCode 路径) h('button', { class: 'code-save-btn', - onClick: () => emit('submit', { id: 'n1', style: { color: 'red', width: '' } }), + onClick: () => emit('submit', { id: 'n1', type: 'text', style: { color: 'red', width: '' } }), + }), + // 模拟样式面板 CodeEditor 源码保存:仅 { style }(FormPanel codeValueKey=style) + h('button', { + class: 'style-code-save-btn', + onClick: () => emit('submit', { style: { color: 'blue', width: '' } }), }), h('button', { class: 'submit-err-btn', onClick: () => emit('submit-error', new Error('e')) }), h('button', { class: 'form-err-btn', onClick: () => emit('form-error', new Error('e')) }), @@ -140,7 +145,7 @@ beforeEach(() => { return null; }); editorService.get.mockImplementation((k: string) => { - if (k === 'node') return { id: 'n1', type: 'text' }; + if (k === 'node') return { id: 'n1', type: 'text', text: 'hello', style: { width: 100, color: 'old' } }; if (k === 'nodes') return [{ id: 'n1' }]; return null; }); @@ -237,6 +242,8 @@ describe('PropsPanel', () => { expect(options.invalidInfo).toBeUndefined(); // historySource 应为 code expect(options.historySource).toBe('code'); + // 属性面板源码保存应整节点替换,避免 merge 保留已删除字段 + expect(options.replace).toBe(true); }); test('CodeEditor 源码保存时 style 中的空字符串值被保留(表示清除该样式)', async () => { @@ -249,6 +256,36 @@ describe('PropsPanel', () => { expect(calledNode.style.width).toBe(''); }); + test('样式面板源码保存:将新 style 替换到原节点后整节点 replace', async () => { + showStylePanel.value = true; + const wrapper = mount(PropsPanel, { props: {} as any }); + await new Promise((r) => setTimeout(r, 0)); + + // 第二个 FormPanel 为样式面板 + const styleCodeBtns = wrapper.findAll('.style-code-save-btn'); + expect(styleCodeBtns.length).toBe(2); + await styleCodeBtns[1].trigger('click'); + + const [calledNode, options] = editorService.update.mock.calls[0] as any; + expect(options.replace).toBe(true); + expect(options.historySource).toBe('code'); + // 原节点其它字段保留 + expect(calledNode.id).toBe('n1'); + expect(calledNode.type).toBe('text'); + expect(calledNode.text).toBe('hello'); + // style 被源码内容整段替换(旧 width:100 / color:old 不再 merge 保留) + expect(calledNode.style).toEqual({ color: 'blue', width: '' }); + }); + + test('表单字段编辑不使用 replace', async () => { + const wrapper = mount(PropsPanel, { props: {} as any }); + await new Promise((r) => setTimeout(r, 0)); + await wrapper.find('.submit-btn').trigger('click'); + + const options = (editorService.update.mock.calls[0] as any)[1]; + expect(options.replace).toBe(false); + }); + test('mounted 事件 emit', async () => { const wrapper = mount(PropsPanel, { props: {} as any }); await wrapper.find('.mounted-btn').trigger('click'); diff --git a/packages/editor/tests/unit/services/editor.spec.ts b/packages/editor/tests/unit/services/editor.spec.ts index 988bc895..ac7ed82c 100644 --- a/packages/editor/tests/unit/services/editor.spec.ts +++ b/packages/editor/tests/unit/services/editor.spec.ts @@ -734,6 +734,21 @@ describe('update', () => { expect(node?.text).toBe('text'); }); + test('replace=true 时整节点替换,不 merge 保留旧字段', async () => { + editorService.set('root', cloneDeep(root)); + await editorService.select(NodeId.PAGE_ID); + + // 默认 merge:未传入的 style.width 会保留 + await editorService.update({ id: NodeId.NODE_ID, type: 'text', text: 'merged' }); + expect(editorService.getNodeById(NodeId.NODE_ID)?.style?.width).toBe(270); + + // replace:直接用传入配置覆盖,旧字段(style)被去掉 + await editorService.update({ id: NodeId.NODE_ID, type: 'text', text: 'replaced' }, { replace: true }); + const node = editorService.getNodeById(NodeId.NODE_ID); + expect(node?.text).toBe('replaced'); + expect(node?.style).toBeUndefined(); + }); + test('没有id', async () => { try { await editorService.update({ type: 'text', text: 'text', id: '' });