feat(editor): 新增属性配置表单校验联动能力

- 新增 enablePropsFormValidate 开关
- 校验失败时仍更新节点并记录错误
- editorService 增加 invalidNodeIds 状态及错误读写方法
- 新增 invalid-node-change 事件,错误随历史快照还原
- 组件树节点标红并展示错误图标
- playground 保存前拦截校验错误组件
- 补充 API 与进阶指南文档
This commit is contained in:
roymondchen 2026-07-14 19:51:21 +08:00
parent 1c67b5e77b
commit 0e4669261f
30 changed files with 1177 additions and 103 deletions

1
.gitignore vendored
View File

@ -20,6 +20,7 @@ pnpm-debug.log*
# Editor directories and files
.idea
.vscode
.codebuddy
*.suo
*.ntvs*
*.njsproj

View File

@ -111,6 +111,10 @@ export default defineConfig({
text: '@tmagic/form',
link: '/guide/advanced/tmagic-form.md',
},
{
text: '属性配置表单校验联动',
link: '/guide/advanced/prop-form-validate.md',
},
],
},
{

View File

@ -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<Id, NodeInvalidInfo>) => void`
::: details 查看 NodeInvalidInfo 及关联类型定义
<<< @/../packages/editor/src/type.ts#NodeInvalidInfo{ts}
<<< @/../packages/editor/src/type.ts#NodeInvalidSource{ts}
<<< @/../packages/schema/src/index.ts#Id{ts}
:::

View File

@ -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<Id, NodeInvalidInfo>`),供组件树标红提示与保存拦截读取
'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 错误文案(可为含 `<br>` 的 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 错误文案(可能为包含 `<br>` 的 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<Id, NodeInvalidInfo>`} 当前存在校验错误的节点错误 Mapkey 为节点 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
- **详情:**

View File

@ -1265,7 +1265,7 @@ const guidesOptions = {
## disabledShowSrc
- **详情:**
禁用属性配置面板右下角"显示源码"的按钮
该按钮可以查看和编辑组件的 JSON 配置
@ -1282,6 +1282,45 @@ const guidesOptions = {
</template>
```
## enablePropsFormValidate
- **详情:**
是否启用「属性配置表单校验」联动能力。
开启后(默认 `false` 关闭),当属性面板(属性表单 / 样式表单)校验失败时,编辑器会**仍按当前表单值更新节点**,并把错误信息集中记录到 `editorService``invalidNodeIds` 状态),用于:
- 组件树(图层)中对出错节点标红并显示错误图标,鼠标悬停可查看错误文案;
- 保存前拦截:业务可通过 `editorService.getInvalidNodeIds()` 读取错误节点,存在校验错误时阻止保存(参考 [playground 菜单保存拦截](../../guide/advanced/prop-form-validate.md#保存拦截))。
关闭时保持原行为:属性 / 样式表单校验失败则丢弃本次改动,不写入节点。
:::tip
校验错误以「来源」为维度分别记录 —— 属性表单来源记为 `props`,样式表单来源记为 `style`;两者指向同一节点,互不覆盖。节点只要任一来源存在错误即视为出错。
错误信息会随 DSL 操作写入历史记录快照,因此「撤销 / 重做」能正确还原校验错误状态(撤销一个「校验失败」的改动后错误消失,重做后错误恢复)。
:::
- **默认值:** `false`
- **类型:** `boolean`
- **示例:**
```html
<template>
<!-- 开启属性配置表单校验联动能力 -->
<m-editor :enable-props-form-validate="true"></m-editor>
</template>
```
- **相关 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
- **详情:**

View File

@ -0,0 +1,121 @@
# 属性配置表单校验联动
编辑器在属性面板(属性表单 / 样式表单)中已支持 [表单校验](../../form-config/rules.md)。默认情况下,表单校验失败时本次改动会被丢弃,不会写入节点。
「属性配置表单校验联动」能力(`enablePropsFormValidate`)允许在**校验失败时仍按当前表单值更新节点**,并把错误信息集中记录到 `editorService`,从而实现:
- 组件树(图层)中对出错节点**标红并显示错误图标**,悬停可查看错误文案;
- 业务在**保存前拦截**存在校验错误的组件(见 [保存拦截](#保存拦截)
- 校验错误随 DSL 操作写入历史快照,**撤销 / 重做能正确还原**错误状态。
> 该能力默认关闭,需通过 `<m-editor :enable-props-form-validate="true">` 显式开启。
## 工作原理
### 错误来源维度
属性面板分「属性」与「样式」两个独立的 `FormPanel`,它们指向同一节点。为避免某个面板校验通过时误清另一个面板记录的错误,错误信息**以来源(`source`)为键分别保存**
- `'props'`:属性表单校验错误
- `'style'`:样式表单校验错误
节点只要**任一来源存在非空错误文案**即视为出错。
内部类型定义见 `NodeInvalidInfo`
```ts
interface NodeInvalidInfo {
/** 属性表单校验错误文案(可能为包含 <br> 的 HTML */
props?: string;
/** 样式表单校验错误文案(可能为包含 <br> 的 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
<template>
<m-editor :enable-props-form-validate="true"></m-editor>
</template>
```
开启后无需其它配置,属性 / 样式表单校验失败即会自动记录错误并标红组件树节点。
## 读取与清除错误
业务侧可通过 `editorService` 读取或清除错误标记:
```js
import { editorService } from "@tmagic/editor";
// 读取全部错误节点Map<Id, NodeInvalidInfo>
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 中的失效节点错误记录,避免残留误报;删除节点时也会同步清理其子树相关的错误记录。

View File

@ -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 都能拿到一致的扩展状态

View File

@ -87,70 +87,3 @@ const scrollBy = (delta: number) => {
}
};
</script>
<style lang="scss">
.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;
}
}
}
}
</style>

View File

@ -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<boolean> = Symbol('enablePropsFormValidate');
export interface EditorProps {
/** 页面初始值 */
modelValue?: MApp;
@ -91,6 +99,11 @@ export interface EditorProps {
disabledFlashTip?: boolean;
/** 禁用双击在浮层中单独编辑选中组件 */
disabledStageOverlay?: boolean;
/**
* /
* editorService false
*/
enablePropsFormValidate?: boolean;
/** 禁用属性配置面板右下角显示源码的按钮 */
disabledShowSrc?: boolean;
/** 禁用数据源 */

View File

@ -127,15 +127,3 @@ const unhighlight = () => {
stageOverlayService.get('stage')?.clearHighlight();
};
</script>
<style lang="scss">
.m-fields-ui-select {
cursor: pointer;
i {
margin-right: 3px;
}
span {
color: #2882e0;
}
}
</style>

View File

@ -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';

View File

@ -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);
}
}
};

View File

@ -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 @@
</template>
<script lang="ts" setup>
import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef, watch, watchEffect } from 'vue';
import { computed, inject, onBeforeUnmount, onMounted, ref, useTemplateRef, watch, watchEffect } from 'vue';
import { Close, Sugar } from '@element-plus/icons-vue';
import type { OnDrag } from 'gesto';
@ -66,9 +66,10 @@ import { setValueByKeyPath } from '@tmagic/utils';
import MIcon from '@editor/components/Icon.vue';
import Resizer from '@editor/components/Resizer.vue';
import { ENABLE_PROPS_FORM_VALIDATE } from '@editor/editorProps';
import { useServices } from '@editor/hooks/use-services';
import { Protocol } from '@editor/services/storage';
import type { PropsPanelSlots } from '@editor/type';
import type { NodeInvalidSource, PropsPanelSlots } from '@editor/type';
import { styleTabConfig } from '@editor/utils';
import { PROPS_PANEL_WIDTH_STORAGE_KEY } from '@editor/utils/const';
@ -95,6 +96,8 @@ const emit = defineEmits<{
const { editorService, uiService, propsService, storageService } = useServices();
const enablePropsFormValidate = inject(ENABLE_PROPS_FORM_VALIDATE, false);
const values = ref<FormValue>({});
// tsFormConfig any
const curFormConfig = ref<any>([]);
@ -126,7 +129,12 @@ onBeforeUnmount(() => {
propsService.off('props-configs-change', init);
});
const submit = async (v: MNode, eventData?: ContainerChangeEventData) => {
const submit = async (
v: MNode,
eventData?: ContainerChangeEventData,
error?: any,
source: NodeInvalidSource = 'props',
) => {
try {
if (!v.id) {
v.id = values.value.id;
@ -155,7 +163,13 @@ const submit = async (v: MNode, eventData?: ContainerChangeEventData) => {
// CodeEditor @save saveCode eventData
const historySource = eventData ? 'props' : 'code';
editorService.update(newValue, { changeRecords: eventData?.changeRecords, historySource });
editorService.update(newValue, {
changeRecords: eventData?.changeRecords,
historySource,
// error editorService
// CodeEditor invalidInfo
...(enablePropsFormValidate && error ? { invalidInfo: { id: newValue.id, source, error: error?.message } } : {}),
});
} catch (e: any) {
emit('submit-error', e);
}

View File

@ -0,0 +1,42 @@
<template>
<span class="m-editor-layer-node-content" :class="{ 'is-invalid': hasError }">
<span class="m-editor-layer-node-label">{{ label }}</span>
<TMagicTooltip v-if="hasError" placement="top">
<template #content>
<span v-html="errorMessage"></span>
</template>
<MIcon class="m-editor-layer-node-error-icon" :icon="WarningFilled"></MIcon>
</TMagicTooltip>
</span>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { WarningFilled } from '@element-plus/icons-vue';
import { TMagicTooltip } from '@tmagic/design';
import MIcon from '@editor/components/Icon.vue';
import { useServices } from '@editor/hooks/use-services';
import type { TreeNodeData } from '@editor/type';
defineOptions({
name: 'MEditorLayerNodeContent',
});
const props = defineProps<{
data: TreeNodeData;
}>();
const { editorService } = useServices();
const label = computed(() => `${props.data.name} (${props.data.id})`);
/** 读取集中存储的校验错误状态,建立响应式依赖 */
const invalidInfo = computed(() => editorService.get('invalidNodeIds').get(props.data.id));
const hasError = computed(() => Boolean(invalidInfo.value?.props || invalidInfo.value?.style));
/** 合并属性表单与样式表单的错误文案(本身可能已是含 <br> 的 HTML */
const errorMessage = computed(() => [invalidInfo.value?.props, invalidInfo.value?.style].filter(Boolean).join('<br>'));
</script>

View File

@ -33,7 +33,9 @@
</template>
<template #tree-node-label="{ data: nodeData }">
<slot name="layer-node-label" :data="nodeData"></slot>
<slot name="layer-node-label" :data="nodeData">
<LayerNodeContent :data="nodeData"></LayerNodeContent>
</slot>
</template>
</Tree>
@ -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';

View File

@ -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<Id, NodeInvalidInfo> | 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<K extends StoreStateKey>(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<MNode | MNode[]> {
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 <br> 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);
}
/** 获取当前存在校验错误的节点错误 Mapkey 为节点 id */
public getInvalidNodeIds(): Map<Id, NodeInvalidInfo> {
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<AsyncMethodName, Editor>): 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) ?? [];

View File

@ -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;
}
}

View File

@ -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;
}
}
}
}

View File

@ -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";

View File

@ -0,0 +1,9 @@
.m-fields-ui-select {
cursor: pointer;
i {
margin-right: 3px;
}
span {
color: #2882e0;
}
}

View File

@ -210,6 +210,22 @@ export interface StageOptions {
beforeDblclick?: (event: MouseEvent) => Promise<boolean | void> | boolean | void;
}
/**
* /
* FormPanel
*
*
*/
export interface NodeInvalidInfo {
/** 属性表单校验错误文案(可能为包含 <br> 的 HTML */
props?: string;
/** 样式表单校验错误文案(可能为包含 <br> 的 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, Id>;
/** 校验失败的节点错误信息,按节点 id 存储,供组件树标记与保存拦截读取 */
invalidNodeIds: Map<Id, NodeInvalidInfo>;
pageLength: number;
pageFragmentLength: number;
disabledMultiSelect: boolean;
@ -851,6 +869,10 @@ export interface StepExtra {
selectedAfter?: Id[];
/** 本次操作涉及的节点 id 集合page 类型) */
modifiedNodeIds?: Map<Id, Id>;
/** 操作前的节点校验错误快照,撤销后还原(使撤销一个「校验失败」的改动后错误消失) */
invalidNodeIdsBefore?: Map<Id, NodeInvalidInfo>;
/** 操作后的节点校验错误快照,重做后还原(使重做后错误恢复) */
invalidNodeIdsAfter?: Map<Id, NodeInvalidInfo>;
[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<Id, NodeInvalidInfo>];
}
// #region EditorChangeEvent

View File

@ -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<any> = async () => ({ a: 1 });
vi.mock('@tmagic/form', async () => {
const actual = await vi.importActual<any>('@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,

View File

@ -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');

View File

@ -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<any, any>());
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);
});
});

View File

@ -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, {

View File

@ -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 错误' });
});
});

View File

@ -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<string | number, any> = {},
@ -327,8 +334,23 @@ export const getValueByKeyPath = (
}, data);
};
export const setValueByKeyPath = (keys: string | number, value: any, data: Record<string | number, any> = {}): any =>
objectSet(data, keys, value);
export const setValueByKeyPath = (keys: string | number, value: any, data: Record<string | number, any> = {}): 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[] = [];

View File

@ -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']);

View File

@ -19,6 +19,7 @@
:stage-rect="stageRect"
:layerContentMenu="contentMenuData"
:stageContentMenu="contentMenuData"
:enable-props-form-validate="true"
@props-submit-error="propsSubmitErrorHandler"
>
<template #workspace-content>

View File

@ -16,6 +16,36 @@ export const useEditorMenu = (value: Ref<MApp | undefined>, save: () => void) =>
const iframe = shallowRef<HTMLIFrameElement>();
const previewVisible = ref(false);
/**
* false
* enablePropsFormValidate invalidNodeIds
*/
const checkInvalidNodes = (services?: any): boolean => {
const invalidNodeIds: Map<any, any> | undefined = services?.editorService.getInvalidNodeIds?.();
if (!invalidNodeIds || invalidNodeIds.size === 0) {
return true;
}
// 按页面分组,提示信息需指明问题组件所属页面
const pageMap = new Map<string, string[]>();
[...invalidNodeIds.keys()].forEach((id) => {
const info = services?.editorService.getNodeInfo?.(id);
const page = info?.page;
const pageKey = page?.name ? `${page.name}(${page.id})` : '未知页面';
const node = info?.node;
const nodeName = node?.name ? `${node.name}(${id})` : `${id}`;
if (!pageMap.has(pageKey)) pageMap.set(pageKey, []);
pageMap.get(pageKey)!.push(nodeName);
});
const details = [...pageMap.entries()]
.map(([pageName, nodeNames]) => `${pageName}${nodeNames.join('、')}`)
.join('');
tMagicMessage.error(`以下组件存在配置校验错误,请修复后再保存:${details}`);
return false;
};
const menu: MenuBarData = {
left: [
{
@ -57,8 +87,11 @@ export const useEditorMenu = (value: Ref<MApp | undefined>, save: () => void) =>
cancelButtonText: '预览',
type: 'warning',
});
save();
tMagicMessage.success('保存成功');
// 存在校验错误时中断保存(预览仍使用当前内存中的 DSL
if (checkInvalidNodes(services)) {
save();
tMagicMessage.success('保存成功');
}
} catch (e) {
console.error(e);
}
@ -78,7 +111,8 @@ export const useEditorMenu = (value: Ref<MApp | undefined>, save: () => void) =>
type: 'button',
text: '保存',
icon: Coin,
handler: () => {
handler: (services) => {
if (!checkInvalidNodes(services)) return;
save();
tMagicMessage.success('保存成功');
},