From 0e4669261fe50acaa8f72203dbc342e6eff324e6 Mon Sep 17 00:00:00 2001 From: roymondchen Date: Tue, 14 Jul 2026 19:51:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(editor):=20=E6=96=B0=E5=A2=9E=E5=B1=9E?= =?UTF-8?q?=E6=80=A7=E9=85=8D=E7=BD=AE=E8=A1=A8=E5=8D=95=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=E8=81=94=E5=8A=A8=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 enablePropsFormValidate 开关 - 校验失败时仍更新节点并记录错误 - editorService 增加 invalidNodeIds 状态及错误读写方法 - 新增 invalid-node-change 事件,错误随历史快照还原 - 组件树节点标红并展示错误图标 - playground 保存前拦截校验错误组件 - 补充 API 与进阶指南文档 --- .gitignore | 1 + docs/.vitepress/config.ts | 4 + docs/api/editor/editorServiceEvents.md | 16 ++ docs/api/editor/editorServiceMethods.md | 93 ++++++++- docs/api/editor/props.md | 41 +++- docs/guide/advanced/prop-form-validate.md | 121 +++++++++++ packages/editor/src/Editor.vue | 4 +- packages/editor/src/components/ScrollBar.vue | 67 ------ packages/editor/src/editorProps.ts | 13 ++ packages/editor/src/fields/UISelect.vue | 12 -- packages/editor/src/index.ts | 1 + .../src/layouts/props-panel/FormPanel.vue | 14 +- .../src/layouts/props-panel/PropsPanel.vue | 26 ++- .../sidebar/layer/LayerNodeContent.vue | 42 ++++ .../src/layouts/sidebar/layer/LayerPanel.vue | 5 +- packages/editor/src/services/editor.ts | 160 +++++++++++++- .../editor/src/theme/layer-node-content.scss | 14 ++ packages/editor/src/theme/scroll-bar.scss | 64 ++++++ packages/editor/src/theme/theme.scss | 3 + packages/editor/src/theme/ui-select.scss | 9 + packages/editor/src/type.ts | 24 +++ .../layouts/props-panel/FormPanel.spec.ts | 53 ++++- .../layouts/props-panel/PropsPanel.spec.ts | 73 +++++++ .../sidebar/layer/LayerNodeContent.spec.ts | 96 +++++++++ .../layouts/sidebar/layer/LayerPanel.spec.ts | 18 ++ .../editor/tests/unit/services/editor.spec.ts | 195 +++++++++++++++++- packages/utils/src/index.ts | 28 ++- packages/utils/tests/unit/extras.spec.ts | 42 ++++ playground/src/pages/Editor.vue | 1 + .../src/pages/composables/use-editor-menu.ts | 40 +++- 30 files changed, 1177 insertions(+), 103 deletions(-) create mode 100644 docs/guide/advanced/prop-form-validate.md create mode 100644 packages/editor/src/layouts/sidebar/layer/LayerNodeContent.vue create mode 100644 packages/editor/src/theme/layer-node-content.scss create mode 100644 packages/editor/src/theme/scroll-bar.scss create mode 100644 packages/editor/src/theme/ui-select.scss create mode 100644 packages/editor/tests/unit/layouts/sidebar/layer/LayerNodeContent.spec.ts diff --git a/.gitignore b/.gitignore index 91eb1a9e..2e1b7ee6 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ pnpm-debug.log* # Editor directories and files .idea .vscode +.codebuddy *.suo *.ntvs* *.njsproj diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 2a661e8e..1eaca05f 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -111,6 +111,10 @@ export default defineConfig({ text: '@tmagic/form', link: '/guide/advanced/tmagic-form.md', }, + { + text: '属性配置表单校验联动', + link: '/guide/advanced/prop-form-validate.md', + }, ], }, { diff --git a/docs/api/editor/editorServiceEvents.md b/docs/api/editor/editorServiceEvents.md index 6dfb02c7..90c21d7d 100644 --- a/docs/api/editor/editorServiceEvents.md +++ b/docs/api/editor/editorServiceEvents.md @@ -103,3 +103,19 @@ ::: details 查看 EditorChangeEvent 类型定义 <<< @/../packages/editor/src/type.ts#EditorChangeEvent{ts} ::: + +## invalid-node-change + +- **详情:** 节点校验错误状态发生变化时触发(记录 / 清除错误、删除节点、整体替换 root、撤销重做还原错误标记等场景均会触发),在 [editorService.setInvalidNode()](./editorServiceMethods.md#setinvalidnode)、[editorService.deleteInvalidNode()](./editorServiceMethods.md#deleteinvalidnode)、[editorService.resetInvalidNodeId()](./editorServiceMethods.md#resetinvalidnodeid) 及 DSL 操作 / 撤销重做内部调用后触发。 + + 携带当前完整的错误 Map,供非响应式消费方(如自定义工具栏)订阅,实现组件树标红、保存拦截等。需响应式读取(如组件树节点内容)请直接读取 `editorService.get('invalidNodeIds')`。 + +- **事件回调函数:** `(invalidNodeIds: Map) => void` + + ::: details 查看 NodeInvalidInfo 及关联类型定义 + <<< @/../packages/editor/src/type.ts#NodeInvalidInfo{ts} + + <<< @/../packages/editor/src/type.ts#NodeInvalidSource{ts} + + <<< @/../packages/schema/src/index.ts#Id{ts} + ::: diff --git a/docs/api/editor/editorServiceMethods.md b/docs/api/editor/editorServiceMethods.md index 279509aa..51b3df1f 100644 --- a/docs/api/editor/editorServiceMethods.md +++ b/docs/api/editor/editorServiceMethods.md @@ -48,7 +48,7 @@ DSL 操作方法(`add` / `remove` / `update` 等)默认返回操作结果( ## get - **参数:** - - `{'root' | 'page' | 'parent' | 'node' | 'highlightNode' | 'nodes' | 'modifiedNodeIds' | 'pageLength' | 'pageFragmentLength' | 'stage' | 'stageLoading' | 'disabledMultiSelect' | 'alwaysMultiSelect'} name` + - `{'root' | 'page' | 'parent' | 'node' | 'highlightNode' | 'nodes' | 'modifiedNodeIds' | 'invalidNodeIds' | 'pageLength' | 'pageFragmentLength' | 'stage' | 'stageLoading' | 'disabledMultiSelect' | 'alwaysMultiSelect'} name` - **返回:** - `{any} value` @@ -71,6 +71,8 @@ DSL 操作方法(`add` / `remove` / `update` 等)默认返回操作结果( 'modifiedNodeIds': 当前页面所有改动过的节点id + 'invalidNodeIds': 校验失败的节点错误信息(`Map`),供组件树标红提示与保存拦截读取 + 'pageLength': 所以页面个数 'pageFragmentLength': 页面片个数 @@ -93,7 +95,7 @@ const node = editorService.get("node"); ## set -- `{'root' | 'page' | 'parent' | 'node' | 'highlightNode' | 'nodes' | 'modifiedNodeIds' | 'pageLength' | 'pageFragmentLength' | 'stage' | 'stageLoading' | 'disabledMultiSelect' | 'alwaysMultiSelect'} name` +- `{'root' | 'page' | 'parent' | 'node' | 'highlightNode' | 'nodes' | 'modifiedNodeIds' | 'invalidNodeIds' | 'pageLength' | 'pageFragmentLength' | 'stage' | 'stageLoading' | 'disabledMultiSelect' | 'alwaysMultiSelect'} name` - `{any} value` - **详情:** @@ -510,6 +512,10 @@ editorService.highlight("text_123"); - `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false) - `{string}` historyDescription 见[历史记录相关 options](#历史记录相关-options) - `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options) + - `{Object}` invalidInfo 启用 [enablePropsFormValidate](./props.md#enablepropsformvalidate) 时,属性面板提交携带的校验错误信息,在写入历史记录之前落库,使历史快照与本次变更对齐 + - `{Id}` id 节点 id + - `{'props' | 'style'}` source 错误来源:属性表单 / 样式表单 + - `{string}` error 错误文案(可为含 `
` 的 HTML);为空时表示清除该来源的错误记录 ::: details 查看 ChangeRecord 类型定义 <<< @/../packages/form-schema/src/base.ts#ChangeRecord{ts} @@ -927,6 +933,89 @@ await editorService.revertPageStepById(historyIds); 重置当前记录的修改过的节点id记录,通常用于保存之后 +## setInvalidNode + +- **参数:** + - `{Id}` id 节点 id + - `{'props' | 'style'}` source 错误来源:属性表单(`props`)/ 样式表单(`style`) + - `{string}` message 错误文案(可能为包含 `
` 的 HTML) + +- **详情:** + + 记录(或覆盖)某个节点在指定来源上的校验错误信息,写入 `invalidNodeIds` 状态并发出 [invalid-node-change](#invalid-node-change) 事件。 + + 属性表单与样式表单是两个独立的 `FormPanel` 且均指向同一节点,故以来源为键分别保存,避免某个面板校验通过时误清另一个面板记录的错误。节点视为存在错误当且仅当任一来源存在非空文本。 + +- **示例:** + +```js +import { editorService } from "@tmagic/editor"; + +// 标记 text_123 的属性表单校验错误 +editorService.setInvalidNode("text_123", "props", "标题不能为空"); +``` + +## deleteInvalidNode + +- **参数:** + - `{Id}` id 节点 id + - `{'props' | 'style'}` source 可选;指定来源则仅删除该来源错误,不传则删除该节点全部来源的错误 + +- **详情:** + + 删除节点的校验错误记录。仅当该来源被清空且另一来源也无错误时,节点整体错误记录才会被移除;随后发出 [invalid-node-change](#invalid-node-change) 事件。 + +## getInvalidNodeIds + +- **返回:** + - {`Map`} 当前存在校验错误的节点错误 Map(key 为节点 id) + +- **详情:** + + 获取当前存在校验错误的节点错误 Map,供组件树标红提示与保存拦截读取。 + + ::: details 查看 NodeInvalidInfo 及关联类型定义 + <<< @/../packages/editor/src/type.ts#NodeInvalidInfo{ts} + + <<< @/../packages/editor/src/type.ts#NodeInvalidSource{ts} + + <<< @/../packages/schema/src/index.ts#Id{ts} + ::: + +- **示例:** + +```js +import { editorService } from "@tmagic/editor"; + +// 保存前检查是否存在校验错误的组件 +const invalidNodeIds = editorService.getInvalidNodeIds(); +if (invalidNodeIds.size > 0) { + const names = [...invalidNodeIds.keys()].map((id) => { + const node = editorService.getNodeById(id); + return node?.name ? `${node.name}(${id})` : `${id}`; + }); + console.warn(`以下组件存在配置校验错误,请修复后再保存:${names.join("、")}`); +} +``` + +## getInvalidNodeInfo + +- **参数:** + - `{Id}` id 节点 id + +- **返回:** + - {`NodeInvalidInfo` | undefined} 指定节点的校验错误信息(含 `props` / `style` 来源的错误文案) + +- **详情:** + + 获取指定节点的校验错误信息。 + +## resetInvalidNodeId + +- **详情:** + + 清空全部校验错误记录(即 `invalidNodeIds` 状态),随后发出 [invalid-node-change](#invalid-node-change) 事件。 + ## resetState - **详情:** diff --git a/docs/api/editor/props.md b/docs/api/editor/props.md index 175ccc94..4e04872d 100644 --- a/docs/api/editor/props.md +++ b/docs/api/editor/props.md @@ -1265,7 +1265,7 @@ const guidesOptions = { ## disabledShowSrc - **详情:** - + 禁用属性配置面板右下角"显示源码"的按钮 该按钮可以查看和编辑组件的 JSON 配置 @@ -1282,6 +1282,45 @@ const guidesOptions = { ``` +## enablePropsFormValidate + +- **详情:** + + 是否启用「属性配置表单校验」联动能力。 + + 开启后(默认 `false` 关闭),当属性面板(属性表单 / 样式表单)校验失败时,编辑器会**仍按当前表单值更新节点**,并把错误信息集中记录到 `editorService`(`invalidNodeIds` 状态),用于: + + - 组件树(图层)中对出错节点标红并显示错误图标,鼠标悬停可查看错误文案; + - 保存前拦截:业务可通过 `editorService.getInvalidNodeIds()` 读取错误节点,存在校验错误时阻止保存(参考 [playground 菜单保存拦截](../../guide/advanced/prop-form-validate.md#保存拦截))。 + + 关闭时保持原行为:属性 / 样式表单校验失败则丢弃本次改动,不写入节点。 + + :::tip + 校验错误以「来源」为维度分别记录 —— 属性表单来源记为 `props`,样式表单来源记为 `style`;两者指向同一节点,互不覆盖。节点只要任一来源存在错误即视为出错。 + + 错误信息会随 DSL 操作写入历史记录快照,因此「撤销 / 重做」能正确还原校验错误状态(撤销一个「校验失败」的改动后错误消失,重做后错误恢复)。 + ::: + +- **默认值:** `false` + +- **类型:** `boolean` + +- **示例:** + +```html + +``` + +- **相关 API:** + + - `editorService` 错误状态与方法:`get('invalidNodeIds')` / [setInvalidNode](#setinvalidnode) / [deleteInvalidNode](#deleteinvalidnode) / [getInvalidNodeIds](#getinvalidnodeids) / [getInvalidNodeInfo](#getinvalidnodeinfo) / [resetInvalidNodeId](#resetinvalidnodeid) + - 错误状态变化事件:[invalid-node-change](#invalid-node-change) + - 进阶用法见[属性配置表单校验联动](../../guide/advanced/prop-form-validate.md) + + ## disabledDataSource - **详情:** diff --git a/docs/guide/advanced/prop-form-validate.md b/docs/guide/advanced/prop-form-validate.md new file mode 100644 index 00000000..e656a7df --- /dev/null +++ b/docs/guide/advanced/prop-form-validate.md @@ -0,0 +1,121 @@ +# 属性配置表单校验联动 + +编辑器在属性面板(属性表单 / 样式表单)中已支持 [表单校验](../../form-config/rules.md)。默认情况下,表单校验失败时本次改动会被丢弃,不会写入节点。 + +「属性配置表单校验联动」能力(`enablePropsFormValidate`)允许在**校验失败时仍按当前表单值更新节点**,并把错误信息集中记录到 `editorService`,从而实现: + +- 组件树(图层)中对出错节点**标红并显示错误图标**,悬停可查看错误文案; +- 业务在**保存前拦截**存在校验错误的组件(见 [保存拦截](#保存拦截)); +- 校验错误随 DSL 操作写入历史快照,**撤销 / 重做能正确还原**错误状态。 + +> 该能力默认关闭,需通过 `` 显式开启。 + +## 工作原理 + +### 错误来源维度 + +属性面板分「属性」与「样式」两个独立的 `FormPanel`,它们指向同一节点。为避免某个面板校验通过时误清另一个面板记录的错误,错误信息**以来源(`source`)为键分别保存**: + +- `'props'`:属性表单校验错误 +- `'style'`:样式表单校验错误 + +节点只要**任一来源存在非空错误文案**即视为出错。 + +内部类型定义见 `NodeInvalidInfo`: + +```ts +interface NodeInvalidInfo { + /** 属性表单校验错误文案(可能为包含
的 HTML) */ + props?: string; + /** 样式表单校验错误文案(可能为包含
的 HTML) */ + style?: string; +} +``` + +### 数据流 + +1. 表单校验失败时(开启了 `enablePropsFormValidate`),`FormPanel` 仍以当前表单值提交,并把错误随提交事件一并抛出; +2. `PropsPanel` 在调用 `editorService.update()` 时,把 `invalidInfo: { id, source, error }` 一并传入; +3. `editorService` 在**写入历史记录之前**落库错误标记(`applyInvalidInfo`),使历史快照与本次变更对齐; +4. 错误被记录到 `invalidNodeIds` 状态,并触发 `invalid-node-change` 事件; +5. 组件树节点通过读取 `invalidNodeIds` 响应式地展示标红与错误图标。 + +校验成功时(或源码编辑器保存时)不携带 `invalidInfo`,保持已有错误状态不变(即不会清除错误)——只有对应来源的表单再次校验通过才会清除该来源的错误。 + +## 快速开始 + +```html + +``` + +开启后无需其它配置,属性 / 样式表单校验失败即会自动记录错误并标红组件树节点。 + +## 读取与清除错误 + +业务侧可通过 `editorService` 读取或清除错误标记: + +```js +import { editorService } from "@tmagic/editor"; + +// 读取全部错误节点(Map) +const invalidNodeIds = editorService.getInvalidNodeIds(); + +// 读取单个节点的错误 +const info = editorService.getInvalidNodeInfo("text_123"); + +// 手动记录 / 清除错误(一般无需手动调用,属性面板已自动维护) +editorService.setInvalidNode("text_123", "props", "标题不能为空"); +editorService.deleteInvalidNode("text_123", "props"); // 仅清除属性表单错误 +editorService.resetInvalidNodeId(); // 清空全部错误 +``` + +订阅错误状态变化(非响应式消费方,如自定义工具栏): + +```js +editorService.on("invalid-node-change", (invalidNodeIds) => { + console.log("校验错误节点数:", invalidNodeIds.size); +}); +``` + +> 响应式读取(如自定义组件树节点内容)请直接读取 `editorService.get('invalidNodeIds')`,其变化会触发 Vue 响应式更新。 + +## 保存拦截 + +开启校验联动后,业务可读取 `invalidNodeIds`,在存在校验错误时阻止保存并提示: + +```js +import { editorService } from "@tmagic/editor"; +import { tMagicMessage } from "@tmagic/design"; + +const checkInvalidNodes = (services) => { + const invalidNodeIds = services?.editorService.getInvalidNodeIds?.(); + if (!invalidNodeIds || invalidNodeIds.size === 0) { + return true; + } + + const names = [...invalidNodeIds.keys()].map((id) => { + const node = services?.editorService.getNodeById(id); + return node?.name ? `${node.name}(${id})` : `${id}`; + }); + + tMagicMessage.error(`以下组件存在配置校验错误,请修复后再保存:${names.join("、")}`); + return false; +}; + +// 在菜单保存按钮 / 预览保存的 handler 中: +if (!checkInvalidNodes(services)) return; +save(); +``` + +playground 已内置该拦截逻辑,参见 `playground/src/pages/composables/use-editor-menu.ts`。 + +## 与历史记录的关系 + +错误标记会随 DSL 操作(`add` / `update` 等)一并写入历史快照。编辑器内部在「操作前」与「操作后」分别留存错误状态快照: + +- **撤销(undo)**:还原到操作前的错误状态(撤销一个「校验失败」的改动后错误消失); +- **重做(redo)**:还原到操作后的错误状态(重做后错误恢复)。 + +整体替换 `root`(如加载新 DSL)后会自动清理不存在于新 DSL 中的失效节点错误记录,避免残留误报;删除节点时也会同步清理其子树相关的错误记录。 diff --git a/packages/editor/src/Editor.vue b/packages/editor/src/Editor.vue index 3f60f005..021b81b0 100644 --- a/packages/editor/src/Editor.vue +++ b/packages/editor/src/Editor.vue @@ -158,7 +158,7 @@ import stageOverlayService from './services/stageOverlay'; import storageService from './services/storage'; import uiService from './services/ui'; import keybindingConfig from './utils/keybinding-config'; -import { defaultEditorProps, EditorProps } from './editorProps'; +import { defaultEditorProps, EditorProps, ENABLE_PROPS_FORM_VALIDATE } from './editorProps'; import { initServiceEvents, initServiceState } from './initService'; import type { TreeNodeData } from './type'; import type { EditorSlots, EventBus, Services, StageOptions } from './type'; @@ -233,6 +233,8 @@ provide('services', services); provide('codeOptions', props.codeOptions); provide('stageOptions', stageOptions); +/** 是否启用「属性配置表单校验」联动能力,供 PropsPanel / FormPanel 判断校验失败时是否仍更新节点并记录错误 */ +provide(ENABLE_PROPS_FORM_VALIDATE, props.enablePropsFormValidate ?? false); /** * 把顶层 `extendFormState` 提供给非 PropsPanel 链路上的组件使用(例如历史差异对话框 HistoryDiffDialog * 内部的 CompareForm)。这样所有依赖业务上下文的表单 filterFunction 都能拿到一致的扩展状态, diff --git a/packages/editor/src/components/ScrollBar.vue b/packages/editor/src/components/ScrollBar.vue index 18399d62..e059efcd 100644 --- a/packages/editor/src/components/ScrollBar.vue +++ b/packages/editor/src/components/ScrollBar.vue @@ -87,70 +87,3 @@ const scrollBy = (delta: number) => { } }; - - diff --git a/packages/editor/src/editorProps.ts b/packages/editor/src/editorProps.ts index da6cc78e..f375cb44 100644 --- a/packages/editor/src/editorProps.ts +++ b/packages/editor/src/editorProps.ts @@ -1,3 +1,5 @@ +import type { InjectionKey } from 'vue'; + import type { DataSourceSchema, EventOption, Id, MApp, MNode, MPage, MPageFragment } from '@tmagic/core'; import type { FormConfig, FormState } from '@tmagic/form'; import StageCore, { @@ -26,6 +28,12 @@ import type { TreeNodeData, } from './type'; +/** + * 「属性配置表单校验」联动能力的 provide/inject 注入键。 + * 使用 Symbol 避免与其它字符串键冲突,供 PropsPanel / FormPanel 注入判断校验失败时是否仍更新节点并记录错误。 + */ +export const ENABLE_PROPS_FORM_VALIDATE: InjectionKey = Symbol('enablePropsFormValidate'); + export interface EditorProps { /** 页面初始值 */ modelValue?: MApp; @@ -91,6 +99,11 @@ export interface EditorProps { disabledFlashTip?: boolean; /** 禁用双击在浮层中单独编辑选中组件 */ disabledStageOverlay?: boolean; + /** + * 是否启用「属性配置表单校验」联动能力:开启后属性/样式表单校验失败时仍更新节点, + * 并把错误信息集中记录到 editorService,用于组件树标红提示与保存拦截;默认 false(关闭)。 + */ + enablePropsFormValidate?: boolean; /** 禁用属性配置面板右下角显示源码的按钮 */ disabledShowSrc?: boolean; /** 禁用数据源 */ diff --git a/packages/editor/src/fields/UISelect.vue b/packages/editor/src/fields/UISelect.vue index 9d0ad8b6..83cbd76d 100644 --- a/packages/editor/src/fields/UISelect.vue +++ b/packages/editor/src/fields/UISelect.vue @@ -127,15 +127,3 @@ const unhighlight = () => { stageOverlayService.get('stage')?.clearHighlight(); }; - - diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index d9098d6d..dbf543b0 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -48,6 +48,7 @@ export { default as componentListService } from './services/componentList'; export { default as keybindingService } from './services/keybinding'; export { default as ComponentListPanel } from './layouts/sidebar/ComponentListPanel.vue'; export { default as LayerPanel } from './layouts/sidebar/layer/LayerPanel.vue'; +export { default as LayerNodeContent } from './layouts/sidebar/layer/LayerNodeContent.vue'; export { default as CodeSelect } from './fields/CodeSelect.vue'; export { default as CodeSelectCol } from './fields/CodeSelectCol.vue'; export { default as DataSourceFields } from './fields/DataSourceFields.vue'; diff --git a/packages/editor/src/layouts/props-panel/FormPanel.vue b/packages/editor/src/layouts/props-panel/FormPanel.vue index 9a57ed4b..cb98c7dd 100644 --- a/packages/editor/src/layouts/props-panel/FormPanel.vue +++ b/packages/editor/src/layouts/props-panel/FormPanel.vue @@ -51,6 +51,7 @@ import type { ContainerChangeEventData, FormConfig, FormState, FormValue } from import { MForm } from '@tmagic/form'; 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'; @@ -75,13 +76,15 @@ const props = defineProps<{ }>(); const emit = defineEmits<{ - submit: [values: any, eventData?: ContainerChangeEventData]; + 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 { editorService, uiService } = services; @@ -113,9 +116,16 @@ onUnmounted(() => { const submit = async (v: FormValue, eventData: ContainerChangeEventData) => { try { const values = await configFormRef.value?.submitForm(); + // 校验成功:正常更新节点(第三个参数 error 为空,表示清除该来源的错误记录) emit('submit', values, eventData); } catch (e: any) { - emit('submit-error', e); + if (enablePropsFormValidate) { + // 启用校验联动:校验失败时仍以当前表单值更新节点,并把错误信息一并抛给上层记录 + emit('submit', v, eventData, e); + } else { + // 未启用:保持原行为,校验失败丢弃本次改动 + emit('submit-error', e); + } } }; diff --git a/packages/editor/src/layouts/props-panel/PropsPanel.vue b/packages/editor/src/layouts/props-panel/PropsPanel.vue index 197f7170..f9371aec 100644 --- a/packages/editor/src/layouts/props-panel/PropsPanel.vue +++ b/packages/editor/src/layouts/props-panel/PropsPanel.vue @@ -9,7 +9,7 @@ :values="values" :disabledShowSrc="disabledShowSrc" :extendState="extendState" - @submit="submit" + @submit="(v, eventData, error) => submit(v, eventData, error, 'props')" @submit-error="errorHandler" @form-error="errorHandler" @mounted="mountedHandler" @@ -27,7 +27,7 @@ :values="values" :disabledShowSrc="disabledShowSrc" :extendState="extendState" - @submit="submit" + @submit="(v, eventData, error) => submit(v, eventData, error, 'style')" @submit-error="errorHandler" @form-error="errorHandler" > @@ -55,7 +55,7 @@ diff --git a/packages/editor/src/layouts/sidebar/layer/LayerPanel.vue b/packages/editor/src/layouts/sidebar/layer/LayerPanel.vue index 4b1e15d5..e639ed8e 100644 --- a/packages/editor/src/layouts/sidebar/layer/LayerPanel.vue +++ b/packages/editor/src/layouts/sidebar/layer/LayerPanel.vue @@ -33,7 +33,9 @@ @@ -69,6 +71,7 @@ import type { } from '@editor/type'; import LayerMenu from './LayerMenu.vue'; +import LayerNodeContent from './LayerNodeContent.vue'; import LayerNodeTool from './LayerNodeTool.vue'; import { useClick } from './use-click'; import { useDrag } from './use-drag'; diff --git a/packages/editor/src/services/editor.ts b/packages/editor/src/services/editor.ts index 5995087b..ef755989 100644 --- a/packages/editor/src/services/editor.ts +++ b/packages/editor/src/services/editor.ts @@ -31,6 +31,7 @@ import { isPage, isPageFragment, setValueByKeyPath, + traverseNode, } from '@tmagic/utils'; import BaseService from '@editor/services//BaseService'; @@ -47,6 +48,8 @@ import type { EditorNodeInfo, HistoryOpSource, HistoryOpType, + NodeInvalidInfo, + NodeInvalidSource, PastePosition, StepDiffItem, StepValue, @@ -104,12 +107,18 @@ class Editor extends BaseService { stageLoading: true, highlightNode: null, modifiedNodeIds: new Map(), + invalidNodeIds: new Map(), pageLength: 0, pageFragmentLength: 0, disabledMultiSelect: false, alwaysMultiSelect: false, }); private selectionBeforeOp: Id[] | null = null; + /** + * 操作前的节点校验错误快照,与 selectionBeforeOp 同时在 captureSelectionBeforeOp 中捕获, + * 供 pushOpHistory 写入 step.extra.invalidNodeIdsBefore,用于撤销时还原到「操作前」的错误状态。 + */ + private invalidNodeIdsBeforeOp: Map | null = null; /** * 最近一次 pushOpHistory 写入的历史记录 uuid。 * 供 *AndGetHistoryId 系列方法在调用普通操作后取回本次产生的历史记录 id; @@ -127,7 +136,7 @@ class Editor extends BaseService { /** * 设置当前指点节点配置 - * @param name 'root' | 'page' | 'parent' | 'node' | 'highlightNode' | 'nodes' | 'stage' | 'modifiedNodeIds' | 'pageLength' | 'pageFragmentLength + * @param name 'root' | 'page' | 'parent' | 'node' | 'highlightNode' | 'nodes' | 'stage' | 'modifiedNodeIds' | 'invalidNodeIds' | 'pageLength' | 'pageFragmentLength * @param value MNode * @param options.historySource 设置 root 时,本次变更写入历史记录的「操作来源」(仅 name === 'root' 时生效) */ @@ -180,12 +189,15 @@ class Editor extends BaseService { } this.emit('root-change', value as StoreState['root'], preValue as StoreState['root'], options); + + // 整体替换 DSL 后,清理不再存在于新 DSL 中的失效节点错误记录,避免残留误报。 + this.pruneInvalidNodeIds(); } } /** * 获取当前指点节点配置 - * @param name 'root' | 'page' | 'parent' | 'node' | 'highlightNode' | 'nodes' | 'stage' | 'modifiedNodeIds' | 'pageLength' | 'pageFragmentLength' + * @param name 'root' | 'page' | 'parent' | 'node' | 'highlightNode' | 'nodes' | 'stage' | 'modifiedNodeIds' | 'invalidNodeIds' | 'pageLength' | 'pageFragmentLength' * @returns MNode */ public get(name: K): StoreState[K] { @@ -663,6 +675,9 @@ class Editor extends BaseService { await Promise.all(nodes.map((node) => this.doRemove(node, { doNotSelect, doNotSwitchPage }))); + // 删除节点时同步清理其(含子树)的校验错误记录;置于 pushOpHistory 之前,使历史快照与本次删除对齐。 + this.removeInvalidNodesBySubtree(nodes); + if (removedItems.length > 0 && pageForOp) { if (!doNotPushHistory) { this.pushOpHistory('remove', { @@ -770,11 +785,23 @@ class Editor extends BaseService { doNotPushHistory?: boolean; historyDescription?: string; historySource?: HistoryOpSource; + /** + * 属性面板提交时携带的校验错误信息,在写入历史记录之前落库, + * 使历史快照与本次变更对齐,从而 undo/redo 能正确还原错误标记。 + */ + invalidInfo?: { id: Id; source: NodeInvalidSource; error?: string }; } = {}, ): Promise { this.captureSelectionBeforeOp(); - const { doNotPushHistory = false, changeRecordList, changeRecords, historyDescription, historySource } = data; + const { + doNotPushHistory = false, + changeRecordList, + changeRecords, + historyDescription, + historySource, + invalidInfo, + } = data; const nodes = Array.isArray(config) ? config : [config]; @@ -787,6 +814,9 @@ class Editor extends BaseService { }), ); + // 校验错误信息在 pushOpHistory 之前落库,保证历史快照包含本次变更对应的错误状态。 + this.applyInvalidInfo(invalidInfo); + if (updateData[0].oldNode?.type !== NodeType.ROOT) { const curNodes = this.get('nodes'); if (curNodes.length) { @@ -1542,6 +1572,7 @@ class Editor extends BaseService { this.set('stage', null); this.set('highlightNode', null); this.set('modifiedNodeIds', new Map()); + this.set('invalidNodeIds', new Map()); this.set('pageLength', 0); } @@ -1555,6 +1586,62 @@ class Editor extends BaseService { this.get('modifiedNodeIds').clear(); } + /** + * 记录(或覆盖)某个节点在指定来源(属性表单 / 样式表单)上的校验错误信息。 + * @param id 节点 id + * @param source 错误来源:'props'(属性表单)| 'style'(样式表单) + * @param message 错误文案(可能是包含
的 HTML) + */ + public setInvalidNode(id: Id, source: NodeInvalidSource, message: string) { + const map = this.get('invalidNodeIds'); + const info: NodeInvalidInfo = { ...(map.get(id) || {}) }; + info[source] = message; + map.set(id, info); + this.emit('invalid-node-change', map); + } + + /** + * 删除节点的校验错误记录。 + * @param id 节点 id + * @param source 指定来源则仅删除该来源;不传则删除该节点全部来源的错误 + */ + public deleteInvalidNode(id: Id, source?: NodeInvalidSource) { + const map = this.get('invalidNodeIds'); + if (!map.has(id)) return; + + if (!source) { + map.delete(id); + } else { + const info: NodeInvalidInfo = { ...(map.get(id) || {}) }; + delete info[source]; + if (info.props || info.style) { + map.set(id, info); + } else { + map.delete(id); + } + } + + this.emit('invalid-node-change', map); + } + + /** 获取当前存在校验错误的节点错误 Map(key 为节点 id) */ + public getInvalidNodeIds(): Map { + return this.get('invalidNodeIds'); + } + + /** 获取指定节点的校验错误信息 */ + public getInvalidNodeInfo(id: Id): NodeInvalidInfo | undefined { + return this.get('invalidNodeIds').get(id); + } + + /** 清空全部校验错误记录 */ + public resetInvalidNodeId() { + const map = this.get('invalidNodeIds'); + if (map.size === 0) return; + map.clear(); + this.emit('invalid-node-change', map); + } + public usePlugin(options: AsyncHookPlugin): void { super.usePlugin(options); } @@ -1577,6 +1664,57 @@ class Editor extends BaseService { return super.emit(eventName, ...args); } + /** + * 应用一次属性面板提交携带的校验错误信息(在写入历史记录之前调用,使历史快照与本次变更对齐)。 + * error 非空则记录错误,为空则清除对应来源的错误。 + */ + private applyInvalidInfo(invalidInfo?: { id: Id; source: NodeInvalidSource; error?: string }) { + if (!invalidInfo) return; + const { id, source, error } = invalidInfo; + if (error) { + this.setInvalidNode(id, source, error); + } else { + this.deleteInvalidNode(id, source); + } + } + + /** 删除被移除节点(含其子树)的校验错误记录,避免残留误报 */ + private removeInvalidNodesBySubtree(nodes: MNode[]) { + const map = this.get('invalidNodeIds'); + if (map.size === 0) return; + + let changed = false; + nodes.forEach((node) => { + traverseNode(node, (n) => { + if (n.id !== undefined && map.delete(n.id)) { + changed = true; + } + }); + }); + + if (changed) { + this.emit('invalid-node-change', map); + } + } + + /** 清理不在当前 DSL 中的失效节点错误记录(用于整体替换 root 后) */ + private pruneInvalidNodeIds() { + const map = this.get('invalidNodeIds'); + if (map.size === 0) return; + + let changed = false; + for (const id of [...map.keys()]) { + if (!this.getNodeById(id, false)) { + map.delete(id); + changed = true; + } + } + + if (changed) { + this.emit('invalid-node-change', map); + } + } + private addModifiedNodeId(id: Id) { this.get('modifiedNodeIds').set(id, id); } @@ -1598,6 +1736,9 @@ class Editor extends BaseService { private captureSelectionBeforeOp() { if (this.selectionBeforeOp) return; this.selectionBeforeOp = this.get('nodes').map((n) => n.id); + // 与选区快照同步捕获「操作前」的校验错误状态;因 applyInvalidInfo 会在 pushOpHistory 前修改 invalidNodeIds, + // 必须在此(操作最开始)留存操作前快照,供 undo 还原。 + this.invalidNodeIdsBeforeOp = new Map(this.get('invalidNodeIds')); } /** @@ -1704,6 +1845,9 @@ class Editor extends BaseService { selectedBefore: this.selectionBeforeOp ?? [], selectedAfter: this.get('nodes').map((n) => n.id), modifiedNodeIds: new Map(this.get('modifiedNodeIds')), + // 方向性双快照:undo 还原「操作前」错误状态,redo 还原「操作后」错误状态。 + invalidNodeIdsBefore: new Map(this.invalidNodeIdsBeforeOp ?? this.get('invalidNodeIds')), + invalidNodeIdsAfter: new Map(this.get('invalidNodeIds')), }, diff, }; @@ -1716,6 +1860,7 @@ class Editor extends BaseService { const historyId = pushed ? step.uuid : null; this.lastPushedHistoryId = historyId; this.selectionBeforeOp = null; + this.invalidNodeIdsBeforeOp = null; return historyId; } @@ -1841,6 +1986,15 @@ class Editor extends BaseService { this.set('modifiedNodeIds', step.extra?.modifiedNodeIds ?? new Map()); + // 还原校验错误标记:因 undo/redo 复用同一 step,需按方向取「操作前 / 操作后」快照—— + // undo(reverse=true) 还原到操作前的错误状态(撤销一个「校验失败」的改动后错误消失), + // redo(reverse=false) 还原到操作后的错误状态(重做后错误恢复)。 + // 浅拷贝一份以隔离历史快照,避免后续 set/deleteInvalidNode 反向污染 step.extra。 + const invalidToRestore = + (reverse ? step.extra?.invalidNodeIdsBefore : step.extra?.invalidNodeIdsAfter) ?? new Map(); + this.set('invalidNodeIds', new Map(invalidToRestore)); + this.emit('invalid-node-change', this.get('invalidNodeIds')); + const page = toRaw(this.get('page')); if (page) { const selectIds = (reverse ? step.extra?.selectedBefore : step.extra?.selectedAfter) ?? []; diff --git a/packages/editor/src/theme/layer-node-content.scss b/packages/editor/src/theme/layer-node-content.scss new file mode 100644 index 00000000..f0895066 --- /dev/null +++ b/packages/editor/src/theme/layer-node-content.scss @@ -0,0 +1,14 @@ +.m-editor-layer-node-content { + display: inline-flex; + align-items: center; + + &.is-invalid .m-editor-layer-node-label { + color: var(--el-color-danger, #f56c6c); + } + + .m-editor-layer-node-error-icon { + margin-left: 4px; + color: var(--el-color-danger, #f56c6c); + cursor: help; + } +} diff --git a/packages/editor/src/theme/scroll-bar.scss b/packages/editor/src/theme/scroll-bar.scss new file mode 100644 index 00000000..51d0853f --- /dev/null +++ b/packages/editor/src/theme/scroll-bar.scss @@ -0,0 +1,64 @@ +.m-editor-scroll-bar { + position: absolute; + background-color: transparent; + opacity: 0.3; + transition: + background-color 0.2s linear, + opacity 0.2s linear; + + .m-editor-scroll-bar-thumb { + background-color: #aaa; + border-radius: 6px; + position: absolute; + } + + &.horizontal { + width: 100%; + height: 15px; + bottom: 0; + + .m-editor-scroll-bar-thumb { + height: 6px; + transition: + background-color 0.2s linear, + height 0.2s ease-in-out; + bottom: 2px; + } + } + + &.vertical { + height: 100%; + width: 15px; + right: 5px; + + .m-editor-scroll-bar-thumb { + width: 6px; + transition: + background-color 0.2s linear, + width 0.2s ease-in-out; + right: 2px; + } + } + + &:hover, + &:focus { + background-color: #eee; + opacity: 0.9; + + .m-editor-scroll-bar-thumb { + background-color: #999; + } + + &.horizontal { + .m-editor-scroll-bar-thumb { + height: 11px; + } + } + + &.vertical { + .m-editor-scroll-bar-thumb { + width: 11px; + } + } + } +} diff --git a/packages/editor/src/theme/theme.scss b/packages/editor/src/theme/theme.scss index a4741f80..783765bd 100644 --- a/packages/editor/src/theme/theme.scss +++ b/packages/editor/src/theme/theme.scss @@ -27,4 +27,7 @@ @use "./page-fragment-select.scss"; @use "./data-source-field.scss"; @use "./data-source-field-select.scss"; +@use "./scroll-bar.scss"; +@use "./ui-select.scss"; +@use "./layer-node-content.scss"; @use "./style-setter/index.scss"; diff --git a/packages/editor/src/theme/ui-select.scss b/packages/editor/src/theme/ui-select.scss new file mode 100644 index 00000000..666a5e9c --- /dev/null +++ b/packages/editor/src/theme/ui-select.scss @@ -0,0 +1,9 @@ +.m-fields-ui-select { + cursor: pointer; + i { + margin-right: 3px; + } + span { + color: #2882e0; + } +} diff --git a/packages/editor/src/type.ts b/packages/editor/src/type.ts index a43d477c..d5e87611 100644 --- a/packages/editor/src/type.ts +++ b/packages/editor/src/type.ts @@ -210,6 +210,22 @@ export interface StageOptions { beforeDblclick?: (event: MouseEvent) => Promise | boolean | void; } +/** + * 节点校验错误信息,按来源(属性表单 / 样式表单)分别保存错误文案。 + * 属性表单与样式表单是两个独立的 FormPanel,均指向同一节点,故以来源为键, + * 避免某个面板校验通过时误清另一个面板记录的错误。 + * 节点视为存在错误当且仅当任一来源存在非空文本。 + */ +export interface NodeInvalidInfo { + /** 属性表单校验错误文案(可能为包含
的 HTML) */ + props?: string; + /** 样式表单校验错误文案(可能为包含
的 HTML) */ + style?: string; +} + +/** 节点校验错误来源 */ +export type NodeInvalidSource = keyof NodeInvalidInfo; + export interface StoreState { root: MApp | null; page: MPage | MPageFragment | null; @@ -220,6 +236,8 @@ export interface StoreState { stage: StageCore | null; stageLoading: boolean; modifiedNodeIds: Map; + /** 校验失败的节点错误信息,按节点 id 存储,供组件树标记与保存拦截读取 */ + invalidNodeIds: Map; pageLength: number; pageFragmentLength: number; disabledMultiSelect: boolean; @@ -851,6 +869,10 @@ export interface StepExtra { selectedAfter?: Id[]; /** 本次操作涉及的节点 id 集合(page 类型) */ modifiedNodeIds?: Map; + /** 操作前的节点校验错误快照,撤销后还原(使撤销一个「校验失败」的改动后错误消失) */ + invalidNodeIdsBefore?: Map; + /** 操作后的节点校验错误快照,重做后还原(使重做后错误恢复) */ + invalidNodeIdsAfter?: Map; [key: string]: any; } // #endregion StepExtra @@ -1247,6 +1269,8 @@ export interface EditorEvents { * add / remove / update 触发本事件;如需区分「用户操作」与「撤销重做」请配合 `history-change`。 */ change: [event: EditorChangeEvent]; + /** 节点校验错误状态发生变化时触发,携带当前完整的错误 Map(供非响应式消费方订阅) */ + 'invalid-node-change': [invalidNodeIds: Map]; } // #region EditorChangeEvent diff --git a/packages/editor/tests/unit/layouts/props-panel/FormPanel.spec.ts b/packages/editor/tests/unit/layouts/props-panel/FormPanel.spec.ts index 438beead..9c28566c 100644 --- a/packages/editor/tests/unit/layouts/props-panel/FormPanel.spec.ts +++ b/packages/editor/tests/unit/layouts/props-panel/FormPanel.spec.ts @@ -7,6 +7,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; import { defineComponent, h, nextTick } from 'vue'; import { mount } from '@vue/test-utils'; +import { ENABLE_PROPS_FORM_VALIDATE } from '@editor/editorProps'; import FormPanel from '@editor/layouts/props-panel/FormPanel.vue'; const editorService = { get: vi.fn() }; @@ -61,6 +62,9 @@ vi.mock('@tmagic/design', () => ({ }), })); +// 可控的 submitForm 实现:默认校验成功,测试可将其改为 reject 以模拟校验失败 +let submitFormImpl: () => Promise = async () => ({ a: 1 }); + vi.mock('@tmagic/form', async () => { const actual = await vi.importActual('@tmagic/form'); return { @@ -73,7 +77,7 @@ vi.mock('@tmagic/form', async () => { const formState = { stage: null as any, services: null as any }; expose({ formState, - submitForm: vi.fn(async () => ({ a: 1 })), + submitForm: vi.fn(() => submitFormImpl()), }); return () => h('div', { class: 'fake-mform' }, [ @@ -90,6 +94,7 @@ vi.mock('@tmagic/form', async () => { beforeEach(() => { vi.clearAllMocks(); + submitFormImpl = async () => ({ a: 1 }); uiService.get.mockImplementation((k: string) => (k === 'propsPanelSize' ? 'small' : null)); editorService.get.mockImplementation((k: string) => (k === 'stage' ? { id: 'stage' } : null)); }); @@ -119,6 +124,52 @@ describe('FormPanel', () => { expect(wrapper.emitted('form-error')).toBeTruthy(); }); + test('校验失败且未启用 enablePropsFormValidate 时 emit submit-error,不更新节点', async () => { + submitFormImpl = async () => { + throw new Error('校验失败'); + }; + const wrapper = mount(FormPanel, { props: { config: [], values: {} } as any }); + await wrapper.find('.change-btn').trigger('click'); + await new Promise((r) => setTimeout(r, 0)); + + expect(wrapper.emitted('submit-error')).toBeTruthy(); + expect(wrapper.emitted('submit')).toBeFalsy(); + }); + + test('校验失败且启用 enablePropsFormValidate 时仍 emit submit(携带当前值+error)', async () => { + const err = new Error('校验失败'); + submitFormImpl = async () => { + throw err; + }; + const wrapper = mount(FormPanel, { + props: { config: [], values: {} } as any, + global: { provide: { [ENABLE_PROPS_FORM_VALIDATE]: true } }, + }); + await wrapper.find('.change-btn').trigger('click'); + await new Promise((r) => setTimeout(r, 0)); + + const submitEvents = wrapper.emitted('submit'); + expect(submitEvents).toBeTruthy(); + // 第三个参数为错误对象 + expect(submitEvents?.[0]?.[2]).toBe(err); + // 第一个参数为 change 携带的当前表单值 + expect(submitEvents?.[0]?.[0]).toEqual({ a: 1 }); + expect(wrapper.emitted('submit-error')).toBeFalsy(); + }); + + test('校验成功时 emit submit 且不携带 error', async () => { + const wrapper = mount(FormPanel, { + props: { config: [], values: {} } as any, + global: { provide: { [ENABLE_PROPS_FORM_VALIDATE]: true } }, + }); + await wrapper.find('.change-btn').trigger('click'); + await new Promise((r) => setTimeout(r, 0)); + + const submitEvents = wrapper.emitted('submit'); + expect(submitEvents).toBeTruthy(); + expect(submitEvents?.[0]?.[2]).toBeUndefined(); + }); + test('disabledShowSrc 控制源码按钮', () => { const wrapper = mount(FormPanel, { props: { config: [], values: {}, disabledShowSrc: true } as any, 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 5d5283b0..64d2a7fa 100644 --- a/packages/editor/tests/unit/layouts/props-panel/PropsPanel.spec.ts +++ b/packages/editor/tests/unit/layouts/props-panel/PropsPanel.spec.ts @@ -7,6 +7,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; import { defineComponent, h, nextTick, ref } from 'vue'; import { mount } from '@vue/test-utils'; +import { ENABLE_PROPS_FORM_VALIDATE } from '@editor/editorProps'; import PropsPanel from '@editor/layouts/props-panel/PropsPanel.vue'; const editorService = { @@ -86,6 +87,16 @@ vi.mock('@editor/layouts/props-panel/FormPanel.vue', () => ({ { changeRecords: [{ propPath: 'style.bg', value: '' }] }, ), }), + h('button', { + class: 'submit-with-err-btn', + onClick: () => + emit('submit', { id: 'n1', style: { color: 'red' } }, { changeRecords: [] }, new Error('校验失败详情')), + }), + // 模拟 CodeEditor 源码保存:仅传 values,无 eventData、无 error(对应 saveCode 路径) + h('button', { + class: 'code-save-btn', + onClick: () => emit('submit', { id: 'n1', style: { color: 'red' } }), + }), 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')) }), h('button', { class: 'mounted-btn', onClick: () => emit('mounted', { proxy: true }) }), @@ -166,6 +177,68 @@ describe('PropsPanel', () => { expect(calledNode.style.empty).toBeUndefined(); }); + test('未启用 enablePropsFormValidate 时 update 不携带 invalidInfo', async () => { + const wrapper = mount(PropsPanel, { props: {} as any }); + await new Promise((r) => setTimeout(r, 0)); + await wrapper.find('.submit-with-err-btn').trigger('click'); + const options = (editorService.update.mock.calls[0] as any)[1]; + expect(options.invalidInfo).toBeUndefined(); + }); + + test('启用 enablePropsFormValidate 时属性表单校验失败携带 invalidInfo(source=props)', async () => { + const wrapper = mount(PropsPanel, { + props: {} as any, + global: { provide: { [ENABLE_PROPS_FORM_VALIDATE]: true } }, + }); + await new Promise((r) => setTimeout(r, 0)); + await wrapper.find('.submit-with-err-btn').trigger('click'); + + const options = (editorService.update.mock.calls[0] as any)[1]; + expect(options.invalidInfo).toEqual({ id: 'n1', source: 'props', error: '校验失败详情' }); + }); + + test('启用 enablePropsFormValidate 且校验成功时不携带 invalidInfo(保持错误状态不变)', async () => { + const wrapper = mount(PropsPanel, { + props: {} as any, + global: { provide: { [ENABLE_PROPS_FORM_VALIDATE]: true } }, + }); + 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.invalidInfo).toBeUndefined(); + }); + + test('启用 enablePropsFormValidate 时样式表单提交携带 invalidInfo(source=style)', async () => { + showStylePanel.value = true; + const wrapper = mount(PropsPanel, { + props: {} as any, + global: { provide: { [ENABLE_PROPS_FORM_VALIDATE]: true } }, + }); + await new Promise((r) => setTimeout(r, 0)); + // 第二个 FormPanel 为样式面板 + const styleSubmitBtns = wrapper.findAll('.submit-with-err-btn'); + expect(styleSubmitBtns.length).toBe(2); + await styleSubmitBtns[1].trigger('click'); + + const options = (editorService.update.mock.calls[0] as any)[1]; + expect(options.invalidInfo.source).toBe('style'); + }); + + test('CodeEditor 源码保存不携带 invalidInfo(未经表单校验,不应改动错误状态)', async () => { + const wrapper = mount(PropsPanel, { + props: {} as any, + global: { provide: { [ENABLE_PROPS_FORM_VALIDATE]: true } }, + }); + await new Promise((r) => setTimeout(r, 0)); + await wrapper.find('.code-save-btn').trigger('click'); + + const options = (editorService.update.mock.calls[0] as any)[1]; + expect(options.invalidInfo).toBeUndefined(); + // historySource 应为 code + expect(options.historySource).toBe('code'); + }); + 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/layouts/sidebar/layer/LayerNodeContent.spec.ts b/packages/editor/tests/unit/layouts/sidebar/layer/LayerNodeContent.spec.ts new file mode 100644 index 00000000..498003ef --- /dev/null +++ b/packages/editor/tests/unit/layouts/sidebar/layer/LayerNodeContent.spec.ts @@ -0,0 +1,96 @@ +/* + * Tencent is pleased to support the open source community by making TMagicEditor available. + * + * Copyright (C) 2025 Tencent. + */ +import { describe, expect, test, vi } from 'vitest'; +import { defineComponent, h, reactive } from 'vue'; +import { mount } from '@vue/test-utils'; + +import LayerNodeContent from '@editor/layouts/sidebar/layer/LayerNodeContent.vue'; + +// 用 reactive Map 模拟 editorService 中集中存储的校验错误状态 +const invalidNodeIds = reactive(new Map()); + +const editorService = { + get: vi.fn((k: string) => (k === 'invalidNodeIds' ? invalidNodeIds : null)), +}; + +vi.mock('@editor/hooks/use-services', () => ({ + useServices: () => ({ editorService }), +})); + +vi.mock('@editor/components/Icon.vue', () => ({ + default: defineComponent({ + name: 'IconStub', + props: ['icon'], + setup() { + return () => h('i', { class: 'fake-error-icon' }); + }, + }), +})); + +vi.mock('@tmagic/design', () => ({ + TMagicTooltip: defineComponent({ + name: 'TMagicTooltip', + props: ['placement'], + setup(_p, { slots }) { + return () => h('div', { class: 'fake-tooltip' }, [slots.content?.(), slots.default?.()]); + }, + }), +})); + +describe('LayerNodeContent', () => { + test('展示 name (id) 文本', () => { + const wrapper = mount(LayerNodeContent, { + props: { data: { id: 'n1', name: '文本', type: 'text' } as any }, + }); + expect(wrapper.find('.m-editor-layer-node-label').text()).toBe('文本 (n1)'); + }); + + test('无错误时不显示错误图标且不带 is-invalid 样式', () => { + invalidNodeIds.clear(); + const wrapper = mount(LayerNodeContent, { + props: { data: { id: 'n1', name: '文本', type: 'text' } as any }, + }); + expect(wrapper.find('.fake-error-icon').exists()).toBe(false); + expect(wrapper.find('.m-editor-layer-node-content').classes()).not.toContain('is-invalid'); + }); + + test('存在错误时标红并显示错误图标', () => { + invalidNodeIds.clear(); + invalidNodeIds.set('n1', { props: 'name 必填' }); + const wrapper = mount(LayerNodeContent, { + props: { data: { id: 'n1', name: '文本', type: 'text' } as any }, + }); + expect(wrapper.find('.fake-error-icon').exists()).toBe(true); + expect(wrapper.find('.m-editor-layer-node-content').classes()).toContain('is-invalid'); + }); + + test('tooltip 合并展示 props 与 style 错误(HTML)', () => { + invalidNodeIds.clear(); + invalidNodeIds.set('n1', { props: 'name 必填', style: 'width 非法' }); + const wrapper = mount(LayerNodeContent, { + props: { data: { id: 'n1', name: '文本', type: 'text' } as any }, + }); + const tooltip = wrapper.find('.fake-tooltip'); + expect(tooltip.html()).toContain('name 必填'); + expect(tooltip.html()).toContain('width 非法'); + }); + + test('错误状态变化时响应式更新', async () => { + invalidNodeIds.clear(); + const wrapper = mount(LayerNodeContent, { + props: { data: { id: 'n1', name: '文本', type: 'text' } as any }, + }); + expect(wrapper.find('.fake-error-icon').exists()).toBe(false); + + invalidNodeIds.set('n1', { props: 'name 必填' }); + await wrapper.vm.$nextTick(); + expect(wrapper.find('.fake-error-icon').exists()).toBe(true); + + invalidNodeIds.delete('n1'); + await wrapper.vm.$nextTick(); + expect(wrapper.find('.fake-error-icon').exists()).toBe(false); + }); +}); diff --git a/packages/editor/tests/unit/layouts/sidebar/layer/LayerPanel.spec.ts b/packages/editor/tests/unit/layouts/sidebar/layer/LayerPanel.spec.ts index f77786c5..975b2c75 100644 --- a/packages/editor/tests/unit/layouts/sidebar/layer/LayerPanel.spec.ts +++ b/packages/editor/tests/unit/layouts/sidebar/layer/LayerPanel.spec.ts @@ -80,12 +80,23 @@ vi.mock('@editor/components/Tree.vue', () => ({ class: 'dblclick-btn', onClick: () => emit('node-dblclick', new MouseEvent('dblclick'), { id: 'a' }), }), + slots['tree-node-label']?.({ data: { id: 'a', name: 'A', type: 'node' } }), slots['tree-node-tool']?.({ data: { id: 'a', type: 'node' } }), ]); }, }), })); +vi.mock('@editor/layouts/sidebar/layer/LayerNodeContent.vue', () => ({ + default: defineComponent({ + name: 'LayerNodeContent', + props: ['data'], + setup() { + return () => h('div', { class: 'fake-node-content' }); + }, + }), +})); + vi.mock('@editor/layouts/sidebar/layer/LayerMenu.vue', () => ({ default: defineComponent({ name: 'LayerMenu', @@ -134,6 +145,13 @@ describe('LayerPanel', () => { expect(wrapper.findComponent({ name: 'LayerMenu' }).exists()).toBe(true); }); + test('tree-node-label 默认渲染 LayerNodeContent', () => { + const wrapper = mount(LayerPanel, { + props: { layerContentMenu: [], customContentMenu: (m: any) => m } as any, + }); + expect(wrapper.find('.fake-node-content').exists()).toBe(true); + }); + test('page 为空时不渲染 Tree', () => { editorService.get.mockReturnValue(null); const wrapper = mount(LayerPanel, { diff --git a/packages/editor/tests/unit/services/editor.spec.ts b/packages/editor/tests/unit/services/editor.spec.ts index fed3d12d..919443b8 100644 --- a/packages/editor/tests/unit/services/editor.spec.ts +++ b/packages/editor/tests/unit/services/editor.spec.ts @@ -16,7 +16,7 @@ * limitations under the License. */ -import { beforeAll, describe, expect, test, vi } from 'vitest'; +import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; import { cloneDeep } from 'lodash-es'; import type { MApp, MContainer, MNode } from '@tmagic/core'; @@ -1051,3 +1051,196 @@ describe('revertPageStepById', () => { expect(editorService.getNodeById(addedId2)).toBeNull(); }); }); + +describe('invalidNodeIds 校验错误状态', () => { + beforeEach(() => { + editorService.set('root', cloneDeep(root)); + editorService.resetInvalidNodeId(); + historyService.reset(); + }); + + test('初始状态为空 Map', () => { + expect(editorService.getInvalidNodeIds()).toBeInstanceOf(Map); + expect(editorService.getInvalidNodeIds().size).toBe(0); + }); + + test('setInvalidNode 记录错误并触发 invalid-node-change 事件', () => { + const handler = vi.fn(); + editorService.on('invalid-node-change', handler); + + editorService.setInvalidNode(NodeId.NODE_ID, 'props', 'name 必填'); + + expect(handler).toHaveBeenCalledTimes(1); + expect(editorService.getInvalidNodeInfo(NodeId.NODE_ID)).toEqual({ props: 'name 必填' }); + + editorService.off('invalid-node-change', handler); + }); + + test('同一节点不同来源(props/style)合并,互不覆盖', () => { + editorService.setInvalidNode(NodeId.NODE_ID, 'props', 'props 错误'); + editorService.setInvalidNode(NodeId.NODE_ID, 'style', 'style 错误'); + + expect(editorService.getInvalidNodeInfo(NodeId.NODE_ID)).toEqual({ + props: 'props 错误', + style: 'style 错误', + }); + expect(editorService.getInvalidNodeIds().size).toBe(1); + }); + + test('deleteInvalidNode 指定来源仅删除该来源,另一来源保留', () => { + editorService.setInvalidNode(NodeId.NODE_ID, 'props', 'props 错误'); + editorService.setInvalidNode(NodeId.NODE_ID, 'style', 'style 错误'); + + editorService.deleteInvalidNode(NodeId.NODE_ID, 'props'); + + expect(editorService.getInvalidNodeInfo(NodeId.NODE_ID)).toEqual({ style: 'style 错误' }); + }); + + test('deleteInvalidNode 删除最后一个来源时整条记录移除', () => { + editorService.setInvalidNode(NodeId.NODE_ID, 'props', 'props 错误'); + editorService.deleteInvalidNode(NodeId.NODE_ID, 'props'); + + expect(editorService.getInvalidNodeInfo(NodeId.NODE_ID)).toBeUndefined(); + expect(editorService.getInvalidNodeIds().size).toBe(0); + }); + + test('deleteInvalidNode 不传 source 删除整节点全部来源', () => { + editorService.setInvalidNode(NodeId.NODE_ID, 'props', 'props 错误'); + editorService.setInvalidNode(NodeId.NODE_ID, 'style', 'style 错误'); + + editorService.deleteInvalidNode(NodeId.NODE_ID); + + expect(editorService.getInvalidNodeInfo(NodeId.NODE_ID)).toBeUndefined(); + }); + + test('deleteInvalidNode 不存在的节点为空操作,不触发事件', () => { + const handler = vi.fn(); + editorService.on('invalid-node-change', handler); + + editorService.deleteInvalidNode(NodeId.ERROR_NODE_ID); + + expect(handler).not.toHaveBeenCalled(); + editorService.off('invalid-node-change', handler); + }); + + test('resetInvalidNodeId 清空全部并触发事件;已空时不触发', () => { + editorService.setInvalidNode(NodeId.NODE_ID, 'props', 'props 错误'); + + const handler = vi.fn(); + editorService.on('invalid-node-change', handler); + + editorService.resetInvalidNodeId(); + expect(editorService.getInvalidNodeIds().size).toBe(0); + expect(handler).toHaveBeenCalledTimes(1); + + // 已经为空时再次调用不应触发事件 + editorService.resetInvalidNodeId(); + expect(handler).toHaveBeenCalledTimes(1); + + editorService.off('invalid-node-change', handler); + }); + + test('resetState 清空 invalidNodeIds', () => { + editorService.setInvalidNode(NodeId.NODE_ID, 'props', 'props 错误'); + editorService.resetState(); + expect(editorService.getInvalidNodeIds().size).toBe(0); + }); + + test('update 携带 invalidInfo(error) 时在入栈前落库;error 为空时清除', async () => { + await editorService.select(NodeId.PAGE_ID); + + await editorService.update( + { id: NodeId.NODE_ID, type: 'text', text: 'a' }, + { invalidInfo: { id: NodeId.NODE_ID, source: 'props', error: '校验失败' } }, + ); + expect(editorService.getInvalidNodeInfo(NodeId.NODE_ID)).toEqual({ props: '校验失败' }); + + await editorService.update( + { id: NodeId.NODE_ID, type: 'text', text: 'b' }, + { invalidInfo: { id: NodeId.NODE_ID, source: 'props', error: undefined } }, + ); + expect(editorService.getInvalidNodeInfo(NodeId.NODE_ID)).toBeUndefined(); + }); + + test('remove 删除节点及其子树的错误记录', async () => { + await editorService.select(NodeId.NODE_ID); + editorService.setInvalidNode(NodeId.NODE_ID, 'props', 'props 错误'); + + const node = editorService.get('node'); + if (!node) throw new Error('未选中节点'); + await editorService.remove(node); + + expect(editorService.getInvalidNodeInfo(NodeId.NODE_ID)).toBeUndefined(); + }); + + test('整体替换 root 后清理不存在于新 DSL 的错误记录', () => { + editorService.setInvalidNode(NodeId.NODE_ID, 'props', 'props 错误'); + // 新 DSL 不包含 NODE_ID + const newRoot: MApp = { + id: NodeId.ROOT_ID, + type: NodeType.ROOT, + items: [ + { + id: NodeId.PAGE_ID, + type: NodeType.PAGE, + layout: 'absolute', + style: { width: 375 }, + items: [], + }, + ], + }; + editorService.set('root', cloneDeep(newRoot)); + expect(editorService.getInvalidNodeInfo(NodeId.NODE_ID)).toBeUndefined(); + }); + + test('历史同步:update 携带错误后 undo 清错、redo 恢复错', async () => { + await editorService.select(NodeId.PAGE_ID); + + await editorService.update( + { id: NodeId.NODE_ID, type: 'text', text: 'x' }, + { invalidInfo: { id: NodeId.NODE_ID, source: 'props', error: '校验失败' } }, + ); + expect(editorService.getInvalidNodeInfo(NodeId.NODE_ID)).toEqual({ props: '校验失败' }); + + // 撤销:还原到「操作前」——错误应消失 + await editorService.undo(); + expect(editorService.getInvalidNodeInfo(NodeId.NODE_ID)).toBeUndefined(); + + // 重做:还原到「操作后」——错误应恢复 + await editorService.redo(); + expect(editorService.getInvalidNodeInfo(NodeId.NODE_ID)).toEqual({ props: '校验失败' }); + }); + + test('历史同步:修复(清除错误)后 undo 恢复错、redo 再次清错', async () => { + await editorService.select(NodeId.PAGE_ID); + + // step1:产生错误 + await editorService.update( + { id: NodeId.NODE_ID, type: 'text', text: 'x' }, + { invalidInfo: { id: NodeId.NODE_ID, source: 'props', error: '校验失败' } }, + ); + // step2:修复(清除错误) + await editorService.update( + { id: NodeId.NODE_ID, type: 'text', text: 'y' }, + { invalidInfo: { id: NodeId.NODE_ID, source: 'props', error: undefined } }, + ); + expect(editorService.getInvalidNodeInfo(NodeId.NODE_ID)).toBeUndefined(); + + // 撤销 step2:还原到「修复前」——错误恢复 + await editorService.undo(); + expect(editorService.getInvalidNodeInfo(NodeId.NODE_ID)).toEqual({ props: '校验失败' }); + + // 重做 step2:错误再次被清除 + await editorService.redo(); + expect(editorService.getInvalidNodeInfo(NodeId.NODE_ID)).toBeUndefined(); + }); + + test('update 未携带 invalidInfo 时不改动已有错误状态', async () => { + await editorService.select(NodeId.PAGE_ID); + editorService.setInvalidNode(NodeId.NODE_ID, 'props', 'props 错误'); + + await editorService.update({ id: NodeId.NODE_ID, type: 'text', text: 'z' }); + + expect(editorService.getInvalidNodeInfo(NodeId.NODE_ID)).toEqual({ props: 'props 错误' }); + }); +}); diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 428e93c0..92b7b57e 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -17,7 +17,7 @@ * limitations under the License. */ -import { cloneDeep, set as objectSet } from 'lodash-es'; +import { cloneDeep, get as objectGet, set as objectSet } from 'lodash-es'; import type { DataSchema, @@ -308,6 +308,13 @@ export const getKeysArray = (keys: string | number) => // 将 array[0] 转成 array.0 `${keys}`.replace(/\[(\d+)\]/g, '.$1').split('.'); +/** + * 判断某一层 key 是否为数组下标(纯数字) + * @param key 单层 key + * @returns 是否为数组下标 + */ +export const isArrayIndex = (key: string | number): boolean => /^\d+$/.test(`${key}`); + export const getValueByKeyPath = ( keys: number | string | string[] = '', data: Record = {}, @@ -327,8 +334,23 @@ export const getValueByKeyPath = ( }, data); }; -export const setValueByKeyPath = (keys: string | number, value: any, data: Record = {}): any => - objectSet(data, keys, value); +export const setValueByKeyPath = (keys: string | number, value: any, data: Record = {}): any => { + if (typeof value === 'undefined') { + // 仅数组下标场景需要特殊处理:lodash 的 set 会把 undefined 写入数组得到 [undefined], + // 此处改为只创建父级空数组,避免出现 [undefined]。对象属性场景 undefined 会被 JSON 自然丢弃,保持原行为。 + // 注意:这里将「数字型末级 key」统一按数组下标处理,无法区分数字型对象 key,属于预期内的取舍。 + const keyArray = getKeysArray(keys); + const lastKey = keyArray[keyArray.length - 1]; + if (keyArray.length > 1 && isArrayIndex(lastKey)) { + const parentPath = keyArray.slice(0, -1).join('.'); + if (typeof objectGet(data, parentPath) === 'undefined') { + objectSet(data, parentPath, []); + } + return data; + } + } + return objectSet(data, keys, value); +}; export const getNodes = (ids: Id[], data: MNode[] = []): MNode[] => { const nodes: MNode[] = []; diff --git a/packages/utils/tests/unit/extras.spec.ts b/packages/utils/tests/unit/extras.spec.ts index a799a566..43722eb7 100644 --- a/packages/utils/tests/unit/extras.spec.ts +++ b/packages/utils/tests/unit/extras.spec.ts @@ -31,6 +31,7 @@ import { getKeysArray, getNodeInfo, IS_DSL_NODE_KEY, + isArrayIndex, isDslNode, isNumber, isObject, @@ -278,6 +279,47 @@ describe('setValueByKeyPath / getKeys', () => { expect(obj.a.b.c).toBe(1); }); + test('setValueByKeyPath 当 value 为 undefined 时不写入 undefined 到数组', () => { + const obj: any = {}; + setValueByKeyPath('displayConds.0.cond.0', undefined, obj); + expect(obj).toEqual({ displayConds: [{ cond: [] }] }); + }); + + test('setValueByKeyPath 当 value 为 undefined 时保留已有父级数据', () => { + const obj: any = { displayConds: [{ cond: ['x'] }] }; + setValueByKeyPath('displayConds.0.cond.1', undefined, obj); + expect(obj).toEqual({ displayConds: [{ cond: ['x'] }] }); + }); + + test('setValueByKeyPath 对象属性 value 为 undefined 时保持原行为', () => { + const obj: any = {}; + setValueByKeyPath('a.b.c', undefined, obj); + expect(obj.a.b.c).toBeUndefined(); + }); + + test('setValueByKeyPath 末级为数字 key 且父级已存在但非数组时不覆盖父级', () => { + const obj: any = { displayConds: [{ cond: 'not-array' }] }; + setValueByKeyPath('displayConds.0.cond.0', undefined, obj); + // 父级非数组时跳过补建,保留原值 + expect(obj).toEqual({ displayConds: [{ cond: 'not-array' }] }); + }); + + test('setValueByKeyPath 数字型末级 key 统一按数组下标处理(数字型对象 key 歧义)', () => { + const obj: any = { map: { 0: 'a' } }; + setValueByKeyPath('map.0', undefined, obj); + // map 已存在,直接返回,不会把 map['0'] 置为 undefined + expect(obj).toEqual({ map: { 0: 'a' } }); + }); + + test('isArrayIndex 仅对纯数字返回 true', () => { + expect(isArrayIndex(0)).toBe(true); + expect(isArrayIndex('0')).toBe(true); + expect(isArrayIndex('12')).toBe(true); + expect(isArrayIndex('a')).toBe(false); + expect(isArrayIndex('1a')).toBe(false); + expect(isArrayIndex('')).toBe(false); + }); + test('getKeys 返回对象 keys', () => { const obj: { a: number; b: number } = { a: 1, b: 2 }; expect(getKeys(obj)).toEqual(['a', 'b']); diff --git a/playground/src/pages/Editor.vue b/playground/src/pages/Editor.vue index be34cb3a..fa584b16 100644 --- a/playground/src/pages/Editor.vue +++ b/playground/src/pages/Editor.vue @@ -19,6 +19,7 @@ :stage-rect="stageRect" :layerContentMenu="contentMenuData" :stageContentMenu="contentMenuData" + :enable-props-form-validate="true" @props-submit-error="propsSubmitErrorHandler" >