mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-07-22 14:08:12 +00:00
feat(editor): 新增属性配置表单校验联动能力
- 新增 enablePropsFormValidate 开关 - 校验失败时仍更新节点并记录错误 - editorService 增加 invalidNodeIds 状态及错误读写方法 - 新增 invalid-node-change 事件,错误随历史快照还原 - 组件树节点标红并展示错误图标 - playground 保存前拦截校验错误组件 - 补充 API 与进阶指南文档
This commit is contained in:
parent
1c67b5e77b
commit
0e4669261f
1
.gitignore
vendored
1
.gitignore
vendored
@ -20,6 +20,7 @@ pnpm-debug.log*
|
||||
# Editor directories and files
|
||||
.idea
|
||||
.vscode
|
||||
.codebuddy
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
|
||||
@ -111,6 +111,10 @@ export default defineConfig({
|
||||
text: '@tmagic/form',
|
||||
link: '/guide/advanced/tmagic-form.md',
|
||||
},
|
||||
{
|
||||
text: '属性配置表单校验联动',
|
||||
link: '/guide/advanced/prop-form-validate.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<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}
|
||||
:::
|
||||
|
||||
@ -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>`} 当前存在校验错误的节点错误 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
|
||||
|
||||
- **详情:**
|
||||
|
||||
@ -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
|
||||
|
||||
- **详情:**
|
||||
|
||||
121
docs/guide/advanced/prop-form-validate.md
Normal file
121
docs/guide/advanced/prop-form-validate.md
Normal 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 中的失效节点错误记录,避免残留误报;删除节点时也会同步清理其子树相关的错误记录。
|
||||
@ -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 都能拿到一致的扩展状态,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
/** 禁用数据源 */
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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>({});
|
||||
// ts类型应该是FormConfig, 但是打包时会出错,所以暂时用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);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
@ -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';
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
/** 获取当前存在校验错误的节点错误 Map(key 为节点 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) ?? [];
|
||||
|
||||
14
packages/editor/src/theme/layer-node-content.scss
Normal file
14
packages/editor/src/theme/layer-node-content.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
64
packages/editor/src/theme/scroll-bar.scss
Normal file
64
packages/editor/src/theme/scroll-bar.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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";
|
||||
|
||||
9
packages/editor/src/theme/ui-select.scss
Normal file
9
packages/editor/src/theme/ui-select.scss
Normal file
@ -0,0 +1,9 @@
|
||||
.m-fields-ui-select {
|
||||
cursor: pointer;
|
||||
i {
|
||||
margin-right: 3px;
|
||||
}
|
||||
span {
|
||||
color: #2882e0;
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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, {
|
||||
|
||||
@ -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 错误' });
|
||||
});
|
||||
});
|
||||
|
||||
@ -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[] = [];
|
||||
|
||||
@ -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']);
|
||||
|
||||
@ -19,6 +19,7 @@
|
||||
:stage-rect="stageRect"
|
||||
:layerContentMenu="contentMenuData"
|
||||
:stageContentMenu="contentMenuData"
|
||||
:enable-props-form-validate="true"
|
||||
@props-submit-error="propsSubmitErrorHandler"
|
||||
>
|
||||
<template #workspace-content>
|
||||
|
||||
@ -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('保存成功');
|
||||
},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user