Compare commits

..

No commits in common. "master" and "v1.8.0-beta.14" have entirely different histories.

122 changed files with 858 additions and 3875 deletions

View File

@ -1,71 +1,3 @@
# [1.8.0-beta.20](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.19...v1.8.0-beta.20) (2026-07-30)
### Bug Fixes
* **editor:** 修复删除页面/页面片时画布被误清空的问题 ([aa42144](https://github.com/Tencent/tmagic-editor/commit/aa4214473858200c62b6efab79c0671e06cc3917))
### Features
* **editor,dep:** collectIdle 使用常驻 Web Worker 做依赖收集 ([4bbda35](https://github.com/Tencent/tmagic-editor/commit/4bbda354a987b0a409a01184e8511822a0384a13))
# [1.8.0-beta.19](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.18...v1.8.0-beta.19) (2026-07-28)
### Features
* **form:** 新增 validateOnInit默认关闭初始化时自动校验 ([b7c04ed](https://github.com/Tencent/tmagic-editor/commit/b7c04edcd518554d5117c2212bbfe8ef632a4b37))
# [1.8.0-beta.18](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.17...v1.8.0-beta.18) (2026-07-28)
### Bug Fixes
* **editor:** 修复 IdleTask 任务异常或 clearTasks 时队列卡住的问题 ([3fe9cf9](https://github.com/Tencent/tmagic-editor/commit/3fe9cf9b664dc5b39c17b40efe1d3935c89f7553))
### Features
* **form:** 支持 typeMatch 异步校验与 validator 返回值约定 ([a123747](https://github.com/Tencent/tmagic-editor/commit/a123747e9d250166c2bb0f1d3ce3a39dd5baea9f))
# [1.8.0-beta.17](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.16...v1.8.0-beta.17) (2026-07-27)
### Bug Fixes
* **editor:** 修复 collectIdle 批次结算与中断时 Promise 挂起问题 ([b5abf31](https://github.com/Tencent/tmagic-editor/commit/b5abf310663f0bc34bd2c51c2e1a28de0bdf6ab9))
* **editor:** 切换动作类型时清空关联配置字段 ([b564c9e](https://github.com/Tencent/tmagic-editor/commit/b564c9e4aacd215dd46c4b839cf0033ceedc8047))
* **form,table:** groupList分界线、空状态、actions只存在一个 ([dec4ebf](https://github.com/Tencent/tmagic-editor/commit/dec4ebfa20483f699a676e41e0541d23ebe24d81))
* **form:** 完善 mountFormInstance 清理逻辑,避免临时表单实例泄漏 ([bf8df74](https://github.com/Tencent/tmagic-editor/commit/bf8df74864b78891a8820462062624702a1d33d3))
* **table:** 列操作点击后关闭popover ([596d86f](https://github.com/Tencent/tmagic-editor/commit/596d86f2ab08d3d4e6548b5e4bd5759d18e4493d))
### Features
* **editor,stage:** 新增 containerHighlightAddOnly 配置 ([e6cf694](https://github.com/Tencent/tmagic-editor/commit/e6cf694a032652e4f2c05efbab4a07a252e2ffa1))
# [1.8.0-beta.16](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.15...v1.8.0-beta.16) (2026-07-23)
### Features
* 参考建议可选值最多展示个数由 5 调整为 20 ([0e1986c](https://github.com/Tencent/tmagic-editor/commit/0e1986c81998e50461c294c1a27a8139e8ae9a13))
# [1.8.0-beta.15](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.14...v1.8.0-beta.15) (2026-07-23)
### Bug Fixes
* **core:** 为背景图片 URL 添加引号 ([97e0cac](https://github.com/Tencent/tmagic-editor/commit/97e0cac44a2d6ceafaf95fbc4ae716bfe5d47f70))
* **editor:** 使用属性面板中的源码删除样式无效 ([cde0805](https://github.com/Tencent/tmagic-editor/commit/cde08052924ddef1c51faeb68ce15d3195827adb))
* **editor:** 右键菜单动态变化后修复菜单位置防止截断 ([6919a0a](https://github.com/Tencent/tmagic-editor/commit/6919a0a0b19efff92821fc8d52b61b2f7382a817))
* 跳过无字段名称配置的类型校验 ([11bf037](https://github.com/Tencent/tmagic-editor/commit/11bf037054774937132743a40006364e49ee8fd5))
### Features
* **design:** 优化 FormItem 和 colorPicker 样式 ([2e031cc](https://github.com/Tencent/tmagic-editor/commit/2e031cc1346fb6ea739f0d45a89213b6577dc7ee))
* **editor:** 数据源字段选择器校验提示细化并支持 fieldConfig 类型校验 ([fe9e754](https://github.com/Tencent/tmagic-editor/commit/fe9e754b50f14e6034170b2284553ec1c4536150))
* **form,design:** 一行多个表单默认右靠齐label ([48abab7](https://github.com/Tencent/tmagic-editor/commit/48abab7051b93b7ed336172c21ab3b908733f2e7))
* **form,editor:** submitForm/validateForm 静默挂载时注入静默标记vs-code 字段跳过 monaco 渲染 ([3b9d5da](https://github.com/Tencent/tmagic-editor/commit/3b9d5da0bc9f323e944c0e2adb4fc88642ce1915))
* **form:** text 类型校验允许数字值,兼容数据源字段配置 ([1f389ff](https://github.com/Tencent/tmagic-editor/commit/1f389ff9fd77fb93ad749f3fdbbc8aa0fe8725ac))
* 新增 page-structure-change 事件,支持页面增删统一通知 ([745f449](https://github.com/Tencent/tmagic-editor/commit/745f44939605528e8e2ccd0639478db98293a441))
# [1.8.0-beta.14](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.13...v1.8.0-beta.14) (2026-07-23)

View File

@ -1020,18 +1020,6 @@ alt: 按住alt键启动识别
- **类型:** `'default' | 'alt' | ''`
## containerHighlightAddOnly
- **详情:**
是否只有新增组件(从组件列表拖入新组件)时才启用识别容器
开启后,在画布中拖动已有组件时不再识别容器,即已有组件不能通过拖动加入其他容器;新增组件仍然按 [containerHighlightType](#containerHighlightType) 配置的方式识别
- **默认值:** `false`
- **类型:** `boolean`
## stageRect
- **详情:**

View File

@ -69,11 +69,10 @@
## delayedMarkContainer
- **类型**`(event: MouseEvent, excludeElList?: Element[], isAdd?: boolean) => NodeJS.Timeout | undefined`
- **类型**`(event: MouseEvent, excludeElList?: Element[]) => NodeJS.Timeout | undefined`
- **参数**
- `event`:鼠标事件
- `excludeElList`:计算鼠标所在容器时要排除的元素列表
- `isAdd`:当前操作是否为新增组件(从组件列表拖入新组件),`containerHighlightType``addOnly` 时只有新增才会标记容器
- **详情**
鼠标拖拽着元素在容器上方悬停延迟一段时间后对容器进行标记如果悬停时间够长将标记成功悬停时间短调用方通过返回的timeoutId取消标记

View File

@ -69,8 +69,6 @@
}
```
`validator` 除了调用 `callback` 外,也兼容 async-validator 的返回值约定:返回 `false``Error` / 错误数组表示失败,返回 `true` 表示通过,返回 `Promise` 则以 resolve / reject 为结果,内部抛出的异常会转成校验失败。同步与异步 `typeMatch` 下这些约定行为一致,且只会上报一次结果(既调 `callback` 又返回 `Promise` 时以先到的为准)。
## 扩展自定义 type 规则
业务可覆盖内置规则,或为自定义字段 type 注册校验。自定义规则优先于内置规则。
@ -109,26 +107,7 @@ deleteTypeMatchRule('foo');
clearTypeMatchRules();
```
自定义校验器签名:`(value, context) => string | undefined | Promise<string | undefined>`。返回错误文案表示失败,返回 `undefined` 表示通过。`context` 包含 `fieldType``mForm``props``message`
### 异步校验
自定义校验器可以返回 `Promise`,用于需要异步确认取值是否合法的场景(如请求接口校验 id 是否存在)。内置规则均为同步。
```ts
registerTypeMatchRule('mod-select', async (value, { message }) => {
const exists = await checkModExists(value);
if (!exists) {
return message || `模块(${value})不存在`;
}
});
```
异步校验器通过后才会执行同一条 rule 上的自定义 `validator`。需要注意几点:
- **校验器自身失败不算校验失败。** Promise 被 reject如接口异常时只打印错误、该字段按通过处理避免网络故障阻塞操作。需要把失败暴露给用户时请在校验器内部 catch 并返回错误文案。
- **建议自行缓存请求结果。** 每次校验都会执行校验器,无缓存会导致逐次输入都发请求;也可以给 rule 配 `trigger: 'blur'` 降低触发频率。
- **只有最新一轮校验的结论会被采用。** 新一轮校验开始后,上一轮针对旧值、尚未返回的校验不再使用自己的结论,而是等最新一轮出结论后一起结算,因此旧结论不会晚到覆盖新结论,`form.validate()` 也不会对一个尚未校验完的值返回成功。
自定义校验器签名:`(value, context) => string | undefined`。返回错误文案表示失败,返回 `undefined` 表示通过。`context` 包含 `fieldType``mForm``props``message`
### 安装时注册

View File

@ -1,5 +1,5 @@
{
"version": "1.8.0-beta.20",
"version": "1.8.0-beta.14",
"name": "tmagic",
"private": true,
"type": "module",

View File

@ -1,5 +1,5 @@
{
"version": "1.8.0-beta.20",
"version": "1.8.0-beta.14",
"name": "@tmagic/cli",
"main": "lib/index.js",
"types": "lib/index.d.ts",

View File

@ -1,5 +1,5 @@
{
"version": "1.8.0-beta.20",
"version": "1.8.0-beta.14",
"name": "@tmagic/core",
"type": "module",
"sideEffects": false,

View File

@ -51,7 +51,7 @@ export const style2Obj = (style: string) => {
export const fillBackgroundImage = (value: string) => {
if (value && !/^url/.test(value) && !/^linear-gradient/.test(value)) {
return `url("${value}")`;
return `url(${value})`;
}
return value;
};

View File

@ -341,20 +341,6 @@ describe('App 配置/方法/组件注册', () => {
expect(app.page).toBeUndefined();
});
test('deletePage 销毁当前 page', () => {
const app = new App({
config: {
type: NodeType.ROOT,
id: 'app',
items: [{ type: NodeType.PAGE, id: 'p1', items: [] }],
},
});
expect(app.page?.data.id).toBe('p1');
app.deletePage();
expect(app.page).toBeUndefined();
});
test('runCode 执行代码块', async () => {
const fn = vi.fn();
const app = new App({

View File

@ -50,7 +50,7 @@ describe('style2Obj', () => {
describe('fillBackgroundImage', () => {
test('裸路径会包裹 url()', () => {
expect(fillBackgroundImage('a.png')).toBe('url("a.png")');
expect(fillBackgroundImage('a.png')).toBe('url(a.png)');
});
test('已经是 url() 不重复包裹', () => {
@ -127,7 +127,7 @@ describe('transformStyle', () => {
test('backgroundImage: browser 下补全 url()', () => {
expect(transformStyle({ backgroundImage: 'a.png' }, 'browser')).toEqual({
backgroundImage: 'url("a.png")',
backgroundImage: 'url(a.png)',
});
});

View File

@ -1,5 +1,5 @@
{
"version": "1.8.0-beta.20",
"version": "1.8.0-beta.14",
"name": "@tmagic/data-source",
"type": "module",
"sideEffects": false,

View File

@ -7,7 +7,8 @@ import {
DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX,
dataSourceTemplateRegExp,
getValueByKeyPath,
isPageOrFragment,
isPage,
isPageFragment,
NODE_CONDS_KEY,
replaceChildNode,
} from '@tmagic/core';
@ -67,7 +68,7 @@ export const compliedConditions = (node: { [NODE_CONDS_KEY]?: DisplayCond[] }, d
};
export const updateNode = (node: MNode, dsl: MApp) => {
if (isPageOrFragment(node)) {
if (isPage(node) || isPageFragment(node)) {
const index = dsl.items?.findIndex((child: MNode) => child.id === node.id);
dsl.items.splice(index, 1, node as MPage | MPageFragment);
} else {

View File

@ -1,5 +1,5 @@
{
"version": "1.8.0-beta.20",
"version": "1.8.0-beta.14",
"name": "@tmagic/dep",
"type": "module",
"sideEffects": false,

View File

@ -1,6 +1,6 @@
import type { DepData } from '@tmagic/schema';
import { DepTargetType, type IsTarget, type TargetDescriptor, type TargetOptions } from './types';
import { DepTargetType, type IsTarget, type TargetOptions } from './types';
export interface DepUpdateOptions {
id: string | number;
@ -40,17 +40,12 @@ export default class Target {
* truefalse时需要传入type参数给collect方法才会被收集
*/
public isCollectByDefault?: boolean;
/**
* target worker 线
*/
public descriptor?: TargetDescriptor;
constructor(options: TargetOptions) {
this.isTarget = options.isTarget;
this.id = options.id;
this.name = options.name;
this.isCollectByDefault = options.isCollectByDefault ?? true;
this.descriptor = options.descriptor;
if (options.type) {
this.type = options.type;
}

View File

@ -1,4 +1,4 @@
import type { CodeBlockContent, DataSourceSchema, DepData, Id } from '@tmagic/schema';
import type { DepData } from '@tmagic/schema';
import type Target from './Target';
@ -17,30 +17,6 @@ export enum DepTargetType {
export type IsTarget = (key: string | number, value: any, data?: Record<string, any>) => boolean;
/** 创建代码块 target 只需要名称Record 用于兼容直接传入完整代码块 */
export type CodeBlockName = Pick<CodeBlockContent, 'name'> & Record<string, any>;
/**
* target
*
* isTarget 线
* / target worker
* target 线
*
* isTarget
* - CODE_BLOCK: id + name
* - DATA_SOURCE / DATA_SOURCE_COND: id + fields type /
* - DATA_SOURCE_METHOD: id + / content
*/
export type TargetDescriptor =
| { type: DepTargetType.CODE_BLOCK; id: Id; codeBlock: CodeBlockName }
| { type: DepTargetType.DATA_SOURCE; ds: Pick<DataSourceSchema, 'id' | 'fields'> }
| { type: DepTargetType.DATA_SOURCE_COND; ds: Pick<DataSourceSchema, 'id' | 'fields'> }
| {
type: DepTargetType.DATA_SOURCE_METHOD;
ds: { id: Id; methods: Array<{ name?: string }>; fields: Array<{ name?: string }> };
};
export interface TargetOptions {
isTarget: IsTarget;
id: string | number;
@ -50,8 +26,6 @@ export interface TargetOptions {
initialDeps?: DepData;
/** 是否默认收集默认为true当值为false时需要传入type参数给collect方法才会被收集 */
isCollectByDefault?: boolean;
/** 可序列化描述,用于在 worker 中重建该 target */
descriptor?: TargetDescriptor;
}
export interface TargetList {

View File

@ -1,4 +1,5 @@
import {
type CodeBlockContent,
type DataSchema,
type DataSourceSchema,
type DepData,
@ -15,17 +16,16 @@ import {
} from '@tmagic/utils';
import Target from './Target';
import { type CodeBlockName, DepTargetType, type TargetDescriptor, type TargetList } from './types';
import { DepTargetType, type TargetList } from './types';
const INTEGER_REGEXP = /^\d+$/;
export const createCodeBlockTarget = (id: Id, codeBlock: CodeBlockName, initialDeps: DepData = {}) =>
export const createCodeBlockTarget = (id: Id, codeBlock: CodeBlockContent, initialDeps: DepData = {}) =>
new Target({
type: DepTargetType.CODE_BLOCK,
id,
initialDeps,
name: codeBlock.name,
descriptor: { type: DepTargetType.CODE_BLOCK, id, codeBlock: { name: codeBlock.name } },
isTarget: (_key: string | number, value: any) => {
if (id === value) {
return true;
@ -238,8 +238,6 @@ export const createDataSourceTarget = (ds: Pick<DataSourceSchema, 'id' | 'fields
type: DepTargetType.DATA_SOURCE,
id: ds.id,
initialDeps,
// isTarget 需要完整 fields 结构(含 type / 嵌套 fields但不必带上 methods 等无关字段
descriptor: { type: DepTargetType.DATA_SOURCE, ds: { id: ds.id, fields: ds.fields || [] } },
isTarget: (key: string | number, value: any) => isDataSourceTarget(ds, key, value),
});
@ -248,8 +246,6 @@ export const createDataSourceCondTarget = (ds: Pick<DataSourceSchema, 'id' | 'fi
type: DepTargetType.DATA_SOURCE_COND,
id: ds.id,
initialDeps,
// 与 DATA_SOURCE 相同,只序列化 isTarget 所需的 id + fields
descriptor: { type: DepTargetType.DATA_SOURCE_COND, ds: { id: ds.id, fields: ds.fields || [] } },
isTarget: (key: string | number, value: any) => isDataSourceCondTarget(ds, key, value),
});
@ -261,15 +257,6 @@ export const createDataSourceMethodTarget = (
type: DepTargetType.DATA_SOURCE_METHOD,
id: ds.id,
initialDeps,
// isTarget 只用方法名/字段名,不序列化 content 等函数,降低 idle 收集通信成本
descriptor: {
type: DepTargetType.DATA_SOURCE_METHOD,
ds: {
id: ds.id,
methods: (ds.methods || []).map((method) => ({ name: method.name })),
fields: (ds.fields || []).map((field) => ({ name: field.name })),
},
},
isTarget: (_key: string | number, value: any) => {
// 使用data-source-method-select 可以配置出来
if (!Array.isArray(value)) {
@ -295,31 +282,6 @@ export const createDataSourceMethodTarget = (
},
});
/**
* target使 worker
* @param descriptor target
* @param initialDeps
* @returns Target
*/
export const createTargetByDescriptor = (descriptor: TargetDescriptor, initialDeps: DepData = {}): Target => {
switch (descriptor.type) {
case DepTargetType.CODE_BLOCK:
return createCodeBlockTarget(descriptor.id, descriptor.codeBlock, initialDeps);
case DepTargetType.DATA_SOURCE:
return createDataSourceTarget(descriptor.ds, initialDeps);
case DepTargetType.DATA_SOURCE_COND:
return createDataSourceCondTarget(descriptor.ds, initialDeps);
case DepTargetType.DATA_SOURCE_METHOD:
// descriptor 只保留名称,对 isTarget 已足够;重建时按同样结构传入
return createDataSourceMethodTarget(
descriptor.ds as Pick<DataSourceSchema, 'id' | 'methods' | 'fields'>,
initialDeps,
);
default:
throw new Error(`unknown target descriptor: ${JSON.stringify(descriptor)}`);
}
};
export const traverseTarget = (
targetsList: TargetList,
cb: (target: Target) => void,

View File

@ -266,65 +266,6 @@ describe('utils', () => {
expect(t3.isTarget('k', ['ds_1', 'unknown'])).toBe(true);
});
test('内置 target 带可序列化描述,可在 worker 中重建', () => {
const ds = { id: 'ds_1', fields: [{ name: 'name' }] as DataSchema[] };
expect(utils.createCodeBlockTarget('code_1', { name: 'fn', content: () => false, params: [] }).descriptor).toEqual({
type: DepTargetType.CODE_BLOCK,
id: 'code_1',
codeBlock: { name: 'fn' },
});
const dsWithMethods = {
...ds,
methods: [{ name: 'load', content: () => undefined, params: [] } as any],
};
// DATA_SOURCE / COND 只带 id + fields不透传完整数据源对象
expect(utils.createDataSourceTarget(dsWithMethods).descriptor).toEqual({
type: DepTargetType.DATA_SOURCE,
ds: { id: 'ds_1', fields: ds.fields },
});
expect(utils.createDataSourceCondTarget(dsWithMethods).descriptor).toEqual({
type: DepTargetType.DATA_SOURCE_COND,
ds: { id: 'ds_1', fields: ds.fields },
});
// METHOD 描述只保留名称,不带 content 等函数
expect(utils.createDataSourceMethodTarget(dsWithMethods).descriptor).toEqual({
type: DepTargetType.DATA_SOURCE_METHOD,
ds: { id: 'ds_1', methods: [{ name: 'load' }], fields: [{ name: 'name' }] },
});
// 自定义 target 无法序列化 isTarget因此没有描述
expect(new Target({ id: 'custom', isTarget: () => true }).descriptor).toBeUndefined();
});
test('createTargetByDescriptor 用描述重建出等价的 target', () => {
const ds = { id: 'ds_1', fields: [{ name: 'name' }] as DataSchema[] };
const codeBlockTarget = utils.createTargetByDescriptor({
type: DepTargetType.CODE_BLOCK,
id: 'code_1',
codeBlock: { name: 'fn' },
});
expect(codeBlockTarget.type).toBe(DepTargetType.CODE_BLOCK);
expect(codeBlockTarget.name).toBe('fn');
expect(codeBlockTarget.isTarget('created', 'code_1')).toBe(true);
const dsTarget = utils.createTargetByDescriptor({ type: DepTargetType.DATA_SOURCE, ds });
expect(dsTarget.type).toBe(DepTargetType.DATA_SOURCE);
expect(dsTarget.isTarget('text', '${ds_1.name}')).toBe(true);
const condTarget = utils.createTargetByDescriptor({ type: DepTargetType.DATA_SOURCE_COND, ds });
expect(condTarget.isTarget(`${NODE_CONDS_KEY}_x`, ['ds_1', 'name'])).toBe(true);
const methodTarget = utils.createTargetByDescriptor(
{ type: DepTargetType.DATA_SOURCE_METHOD, ds: { ...ds, methods: [] } },
{ n1: { name: 'n1', keys: ['k'] } },
);
expect(methodTarget.isTarget('k', ['ds_1', 'load'])).toBe(true);
expect(methodTarget.deps.n1.keys).toEqual(['k']);
expect(() => utils.createTargetByDescriptor({ type: 'unknown' } as any)).toThrow();
});
test('traverseTarget 遍历所有 / 指定 type', () => {
const t1 = new Target({ id: '1', isTarget: () => true, type: 'a' });
const t2 = new Target({ id: '2', isTarget: () => true, type: 'b' });

View File

@ -1,5 +1,5 @@
{
"version": "1.8.0-beta.20",
"version": "1.8.0-beta.14",
"name": "@tmagic/design",
"type": "module",
"sideEffects": [

View File

@ -1,9 +1,5 @@
<template>
<component
:is="uiComponent"
:class="['tmagic-design-card', { 'tmagic-design-card--flat': isFlat, 'm-flat-hide-body': isHideBody }]"
v-bind="uiProps"
>
<component :is="uiComponent" :class="['tmagic-design-card', { 'tmagic-design-card--flat': isFlat }]" v-bind="uiProps">
<template #header v-if="$slots.header">
<slot name="header" class="header"></slot>
</template>
@ -34,7 +30,7 @@ const uiComponent = ui?.component || 'el-card';
// `<MEditor>` / `<MForm>` theme `magic-admin`
// `flat=true` `flat`
const isFlat = computed(() => !!props.flat || isGlobalFlat.value);
const isHideBody = computed(() => isFlat.value && props.bodyStyle?.display === 'none');
// `flat` UI el-card / t-card props
// / DOM
const uiProps = computed<CardProps>(() => {

View File

@ -47,12 +47,12 @@ const uiComponent = ui?.component || 'el-form-item';
const adapterType = getDesignConfig('adapterType');
const formInline = inject<boolean>('formInline', false);
const formInRow = inject<boolean>('isInRow', false);
const formInline = inject<boolean>('formInline');
const uiProps = computed<FormItemProps>(() => {
const { extra, extraTips, ...rest } = ui?.props(props) || props;
if (isGlobalFlat.value && rest.labelPosition === undefined) {
return { ...rest, labelPosition: formInline || formInRow ? 'right' : 'left' };
return { ...rest, labelPosition: formInline ? 'right' : 'left' };
}
return rest;
});

View File

@ -11,11 +11,7 @@
border-right: 0;
border-radius: 0;
border-bottom: 0;
&.m-flat-hide-body {
.el-card__header {
border-bottom: 0 !important;
}
}
&.m-fields-group-list-item {
margin-bottom: 0;
}
@ -33,10 +29,6 @@
}
}
.m-fields-group-list-footer {
margin-bottom: 4px;
margin-top: 4px;
}
.el-card__body {
padding: 0 16px 0 16px;
// 这里每个form-item应该要有一个16px间距但是除了tbody中的form-item
@ -69,7 +61,7 @@
}
> .el-card__body {
background-color: #fff;
padding-top: 8px;
padding-top: 16px;
}
}
}

View File

@ -2,7 +2,3 @@
margin-left: 8px;
width: calc(var(--el-input-width, 100%) - 40px);
}
.tmagic-design-color-picker {
flex: 0 0 auto;
}

View File

@ -1,5 +1,5 @@
{
"version": "1.8.0-beta.20",
"version": "1.8.0-beta.14",
"name": "@tmagic/editor",
"type": "module",
"sideEffects": [

View File

@ -218,7 +218,6 @@ const stageOptions: StageOptions = {
containerHighlightClassName: props.containerHighlightClassName,
containerHighlightDuration: props.containerHighlightDuration,
containerHighlightType: props.containerHighlightType,
containerHighlightAddOnly: props.containerHighlightAddOnly,
disabledDragStart: props.disabledDragStart,
renderType: props.renderType,
guidesOptions: props.guidesOptions,

View File

@ -128,42 +128,20 @@ const outsideClickHideHandler = (e: MouseEvent) => {
hide();
};
//
const fixPosition = () => {
const menu = menuEl.value;
if (!menu || !visible.value) return;
const menuHeight = menu.clientHeight;
const menuWidth = menu.clientWidth;
let { top, left } = menuPosition.value;
if (top + menuHeight > document.body.clientHeight) {
top = Math.max(0, document.body.clientHeight - menuHeight);
}
if (left + menuWidth > document.body.clientWidth) {
left = Math.max(0, document.body.clientWidth - menuWidth);
}
if (top !== menuPosition.value.top || left !== menuPosition.value.left) {
menuPosition.value = { top, left };
}
};
const setPosition = (e: { clientY: number; clientX: number }) => {
const menuHeight = menuEl.value?.clientHeight || 0;
let top = e.clientY;
if (menuHeight + e.clientY > document.body.clientHeight) {
top = document.body.clientHeight - menuHeight;
}
menuPosition.value = {
top: e.clientY,
top,
left: e.clientX,
};
fixPosition();
};
//
const resizeObserver = new ResizeObserver(() => {
fixPosition();
});
const show = (e?: { clientY: number; clientX: number }) => {
visible.value = true;
@ -208,18 +186,12 @@ const mouseenterHandler = () => {
};
onMounted(() => {
if (menuEl.value) {
resizeObserver.observe(menuEl.value);
}
if (props.isSubMenu) return;
globalThis.addEventListener('mousedown', outsideClickHideHandler, true);
});
onBeforeUnmount(() => {
resizeObserver.disconnect();
if (props.isSubMenu) return;
globalThis.removeEventListener('mousedown', outsideClickHideHandler, true);

View File

@ -82,11 +82,6 @@ export interface EditorProps {
containerHighlightDuration?: number;
/** 拖入画布中容器时,识别容器的操作类型 */
containerHighlightType?: ContainerHighlightType;
/**
*
* false
*/
containerHighlightAddOnly?: boolean;
/** 画布大小 */
stageRect?: StageRect;
/** monaco editor 的配置 */
@ -168,7 +163,6 @@ export const defaultEditorProps = {
containerHighlightClassName: CONTAINER_HIGHLIGHT_CLASS_NAME,
containerHighlightDuration: 800,
containerHighlightType: ContainerHighlightType.DEFAULT,
containerHighlightAddOnly: false,
disabledShowSrc: false,
disabledDataSource: false,
disabledCodeBlock: false,

View File

@ -1,6 +1,5 @@
<template>
<MagicCodeEditor
v-if="!silentMode"
:height="config.height"
:type="diffMode ? 'diff' : undefined"
:init-values="diffMode ? (lastValues || {})[name] : model[name]"
@ -18,9 +17,9 @@
</template>
<script lang="ts" setup>
import { computed, inject } from 'vue';
import { computed } from 'vue';
import { type CodeConfig, type FieldProps, FORM_SILENT_MODE_KEY } from '@tmagic/form';
import type { CodeConfig, FieldProps } from '@tmagic/form';
import MagicCodeEditor from '@editor/layouts/CodeEditor.vue';
@ -28,13 +27,6 @@ defineOptions({
name: 'MFieldsVsCode',
});
/**
* 静默模式submitForm/validateForm 隐藏挂载下跳过 monaco 渲染
* 校验由 FormItem 针对 model 中的值完成与编辑器实例无关本组件挂载无值副作用
* save 仅由用户操作/编辑器内容变更触发跳过可省去 monaco worker/model 的无谓实例化
*/
const silentMode = inject(FORM_SILENT_MODE_KEY, false);
const emit = defineEmits<{
change: [value: string | any];
}>();

View File

@ -37,12 +37,13 @@
:model="entry.cardItem"
:last-values="entry.lastCardItem"
:is-compare="isCompareMode"
:hide-expand="false"
:hide-expand="true"
:label-width="config.labelWidth || '100px'"
@change="onChangeHandler"
>
<template #header>
<div class="event-item-header">
<div class="event-item-title">事件{{ Number(entry.index) + 1 }}</div>
<MFormContainer
class="fullWidth"
:config="eventNameConfig"
@ -209,12 +210,6 @@ const actionTypeConfig = computed(() => {
trigger: 'blur',
},
],
onChange: (_mForm: FormState, _v: string, { setModel }: any) => {
setModel('to', '');
setModel('method', '');
setModel('codeId', '');
setModel('dataSourceMethod', []);
},
};
return { ...defaultActionTypeConfig, ...props.config.actionTypeConfig };
});
@ -227,7 +222,7 @@ const targetCompConfig = computed(() => {
type: 'ui-select',
labelPosition: 'left',
display: (_mForm, { model }) => model.actionType === ActionType.COMP,
onChange: (_mForm, _v, { setModel }) => {
onChange: (_MForm, _v, { setModel }) => {
setModel('method', '');
},
rules: [

View File

@ -28,7 +28,6 @@ export const useStage = (stageOptions: StageOptions) => {
containerHighlightClassName: stageOptions.containerHighlightClassName,
containerHighlightDuration: stageOptions.containerHighlightDuration,
containerHighlightType: stageOptions.containerHighlightType,
containerHighlightAddOnly: stageOptions.containerHighlightAddOnly,
disabledDragStart: stageOptions.disabledDragStart,
renderType: stageOptions.renderType,
canSelect: (el, event, stop) => {

View File

@ -333,11 +333,11 @@ export const initServiceEvents = (
Promise.all(
nodes.map((node) => {
if (node.type === NodeType.ROOT) {
return Promise.resolve(true);
return Promise.resolve();
}
return depService.collectIdle([node], { pageId: getPageIdByNode(node) }, deep, type);
}),
).then((results) => results.every(Boolean));
);
watch(
() => editorService.get('stage'),
@ -351,7 +351,7 @@ export const initServiceEvents = (
if (!node) return;
if (!(await collectIdle([node], true, DepTargetType.DATA_SOURCE))) return;
await collectIdle([node], true, DepTargetType.DATA_SOURCE);
updateStageNode(node);
});
},
@ -494,10 +494,8 @@ export const initServiceEvents = (
// 新增节点,收集依赖
const nodeAddHandler = (nodes: MComponent[]) => {
collectIdle(nodes, true).then((completed) => {
if (completed) {
updateStageNodes(nodes);
}
collectIdle(nodes, true).then(() => {
updateStageNodes(nodes);
});
};
@ -551,8 +549,8 @@ export const initServiceEvents = (
if (needRecollectNodes.length) {
// 有数据源依赖需要等依赖重新收集完才更新stage
const handler = async () => {
if (!(await collectIdle(needRecollectNodes, true, DepTargetType.DATA_SOURCE))) return;
if (!(await collectIdle(needRecollectNodes, true, DepTargetType.DATA_SOURCE_COND))) return;
await collectIdle(needRecollectNodes, true, DepTargetType.DATA_SOURCE);
await collectIdle(needRecollectNodes, true, DepTargetType.DATA_SOURCE_COND);
updateStageNodes(needRecollectNodes);
};
handler();
@ -574,10 +572,8 @@ export const initServiceEvents = (
// 历史记录变化时,需要重新收集依赖
const historyChangeHandler = (page: MPage | MPageFragment) => {
collectIdle([page], true).then((completed) => {
if (completed) {
updateStageNode(page);
}
collectIdle([page], true).then(() => {
updateStageNode(page);
});
};
@ -658,7 +654,7 @@ export const initServiceEvents = (
if (Array.isArray(root?.items)) {
depService.clearIdleTasks();
let collectIdlePromises: Promise<boolean>[] = [];
let collectIdlePromises: Promise<void[]>[] = [];
if (isModifyField) {
depService.removeTarget(config.id, DepTargetType.DATA_SOURCE);
depService.removeTarget(config.id, DepTargetType.DATA_SOURCE_COND);
@ -683,15 +679,10 @@ export const initServiceEvents = (
collectIdlePromises = [collectIdle(root.items, true, DepTargetType.DATA_SOURCE_METHOD)];
}
const handler = async () => {
const results = await Promise.all(collectIdlePromises);
if (!results.every(Boolean)) return;
updateDataSourceSchema();
await updateDsData();
updateStageNodes(root.items);
};
handler();
Promise.all(collectIdlePromises)
.then(() => updateDataSourceSchema())
.then(() => updateDsData())
.then(() => updateStageNodes(root.items));
}
} else if (root?.dataSources) {
updateDsData();
@ -715,12 +706,11 @@ export const initServiceEvents = (
const nodeIds = Object.keys(root.dataSourceDeps?.[id] || {});
const nodes = getNodes(nodeIds, root.items);
const results = await Promise.all([
await Promise.all([
collectIdle(nodes, false, DepTargetType.DATA_SOURCE),
collectIdle(nodes, false, DepTargetType.DATA_SOURCE_COND),
collectIdle(nodes, false, DepTargetType.DATA_SOURCE_METHOD),
]);
if (!results.every(Boolean)) return;
updateDataSourceSchema();

View File

@ -14,7 +14,6 @@
:config="config"
:type-match-valid="true"
:extend-state="extendState"
:validate-on-init="true"
@change="submit"
@error="errorHandler"
></MForm>
@ -50,13 +49,14 @@ import { Document as DocumentIcon } from '@element-plus/icons-vue';
import { TMagicButton, tMagicMessage, TMagicScrollbar } from '@tmagic/design';
import type { ContainerChangeEventData, FormConfig, FormState, FormValue } from '@tmagic/form';
import { MForm, validateForm } from '@tmagic/form';
import { MForm } from '@tmagic/form';
import { filterXSS } from '@tmagic/utils';
import MIcon from '@editor/components/Icon.vue';
import { ENABLE_PROPS_FORM_VALIDATE } from '@editor/editorProps';
import { useEditorContentHeight } from '@editor/hooks/use-editor-content-height';
import { useServices } from '@editor/hooks/use-services';
import { validatePropsForm } from '@editor/utils/props';
import CodeEditor from '../CodeEditor.vue';
@ -162,19 +162,14 @@ const saveCode = async (values: any) => {
//
// 使
try {
const error = await validateForm({
const error = await validatePropsForm({
config: props.config,
values: newValues,
appContext: internalInstance?.appContext ?? null,
services,
stage: stage.value,
typeMatchValid: true,
initValues: newValues,
// provides Editor services / codeOptions provide
// appContext使 MForm DataSourceInput inject
appContext: internalInstance?.appContext ? { ...internalInstance?.appContext, provides: { services } } : null,
extendState: (state) => {
if (configFormRef.value?.formState) {
return { ...(configFormRef.value?.formState || {}) };
}
return props.extendState?.(state) || {};
},
extendState: props.extendState,
});
if (error) {

View File

@ -164,25 +164,17 @@ const submit = async (
};
if (v.style) {
// doUpdate mergeWith
if (eventData) {
// DSL
// changeRecords
Object.entries(v.style).forEach(([key, value]) => {
if (value !== '' && newValue.style) {
newValue.style[key] = value;
}
});
Object.entries(v.style).forEach(([key, value]) => {
if (value !== '' && newValue.style) {
newValue.style[key] = value;
}
});
eventData.changeRecords?.forEach((record) => {
if (record.propPath?.startsWith('style') && record.value === '') {
setValueByKeyPath(record.propPath, record.value, newValue);
}
});
} else {
// eventDatastyle
newValue.style = { ...v.style };
}
eventData?.changeRecords?.forEach((record) => {
if (record.propPath?.startsWith('style') && record.value === '') {
setValueByKeyPath(record.propPath, record.value, newValue);
}
});
}
// MForm @change eventData changeRecords

View File

@ -145,6 +145,6 @@ const dragHandler = (e: DragEvent) => {
if (timeout || !stage.value) return;
timeout = stage.value.delayedMarkContainer(e, [], true);
timeout = stage.value.delayedMarkContainer(e);
};
</script>

View File

@ -6,7 +6,7 @@
import { computed, markRaw, useTemplateRef } from 'vue';
import { Files, Plus } from '@element-plus/icons-vue';
import { isPageOrFragment } from '@tmagic/utils';
import { isPage, isPageFragment } from '@tmagic/utils';
import ContentMenu from '@editor/components/ContentMenu.vue';
import { useServices } from '@editor/hooks/use-services';
@ -101,7 +101,7 @@ const menuData = computed<(MenuButton | MenuComponent)[]>(() =>
type: 'button',
text: '全部折叠',
icon: FolderMinusIcon,
display: () => isPageOrFragment(node.value),
display: () => isPage(node.value) || isPageFragment(node.value),
handler: () => {
emit('collapse-all');
},

View File

@ -2,7 +2,7 @@ import { computed, type ComputedRef, nextTick, type Ref, type ShallowRef } from
import { throttle } from 'lodash-es';
import { Id, MNode } from '@tmagic/core';
import { getElById, isPageOrFragment } from '@tmagic/utils';
import { getElById, isPage, isPageFragment } from '@tmagic/utils';
import type { LayerNodeStatus, Services, TreeNodeData } from '@editor/type';
import { UI_SELECT_MODE_EVENT_NAME } from '@editor/utils/const';
@ -36,7 +36,7 @@ export const useClick = (
};
const multiSelect = async (data: MNode) => {
if (isPageOrFragment(data)) {
if (isPage(data) || isPageFragment(data)) {
return;
}
@ -50,7 +50,7 @@ export const useClick = (
return;
}
if (isPageOrFragment(node)) {
if (isPage(node) || isPageFragment(node)) {
return;
}

View File

@ -1,7 +1,7 @@
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import type { Id, MApp, MNode, MPage, MPageFragment } from '@tmagic/core';
import { getNodePath, isPageOrFragment, traverseNode } from '@tmagic/utils';
import { getNodePath, isPage, isPageFragment, traverseNode } from '@tmagic/utils';
import type { LayerNodeStatus, Services } from '@editor/type';
import { updateStatus } from '@editor/utils/tree';
@ -136,7 +136,7 @@ export const useNodeStatus = ({ editorService }: Services) => {
const addHandler = (newNodes: MNode[]) => {
newNodes.forEach((node) => {
if (isPageOrFragment(node)) return;
if (isPage(node) || isPageFragment(node)) return;
traverseNode(node, (node: MNode) => {
nodeStatusMap.value?.set(node.id, {

View File

@ -7,7 +7,7 @@ import { computed, markRaw, ref, useTemplateRef, watch } from 'vue';
import { Bottom, Top } from '@element-plus/icons-vue';
import { NodeType } from '@tmagic/core';
import { isPageOrFragment } from '@tmagic/utils';
import { isPage, isPageFragment } from '@tmagic/utils';
import ContentMenu from '@editor/components/ContentMenu.vue';
import { useServices } from '@editor/hooks/use-services';
@ -59,14 +59,14 @@ const menuData = computed<(MenuButton | MenuComponent)[]>(() =>
direction: 'horizontal',
display: () => {
if (!node.value) return false;
return !isPageOrFragment(node.value);
return !isPage(node.value) && !isPageFragment(node.value);
},
},
{
type: 'button',
text: '上移一层',
icon: markRaw(Top),
display: () => !isPageOrFragment(node.value) && !props.isMultiSelect,
display: () => !isPage(node.value) && !isPageFragment(node.value) && !props.isMultiSelect,
handler: () => {
editorService.moveLayer(1, { historySource: 'stage-contextmenu' });
},
@ -75,7 +75,7 @@ const menuData = computed<(MenuButton | MenuComponent)[]>(() =>
type: 'button',
text: '下移一层',
icon: markRaw(Bottom),
display: () => !isPageOrFragment(node.value) && !props.isMultiSelect,
display: () => !isPage(node.value) && !isPageFragment(node.value) && !props.isMultiSelect,
handler: () => {
editorService.moveLayer(-1, { historySource: 'stage-contextmenu' });
},
@ -84,7 +84,7 @@ const menuData = computed<(MenuButton | MenuComponent)[]>(() =>
type: 'button',
text: '置顶',
icon: markRaw(Top),
display: () => !isPageOrFragment(node.value) && !props.isMultiSelect,
display: () => !isPage(node.value) && !isPageFragment(node.value) && !props.isMultiSelect,
handler: () => {
editorService.moveLayer(LayerOffset.TOP, { historySource: 'stage-contextmenu' });
},
@ -93,7 +93,7 @@ const menuData = computed<(MenuButton | MenuComponent)[]>(() =>
type: 'button',
text: '置底',
icon: markRaw(Bottom),
display: () => !isPageOrFragment(node.value) && !props.isMultiSelect,
display: () => !isPage(node.value) && !isPageFragment(node.value) && !props.isMultiSelect,
handler: () => {
editorService.moveLayer(LayerOffset.BOTTOM, { historySource: 'stage-contextmenu' });
},
@ -102,7 +102,7 @@ const menuData = computed<(MenuButton | MenuComponent)[]>(() =>
{
type: 'divider',
direction: 'horizontal',
display: () => !isPageOrFragment(node.value) && !props.isMultiSelect,
display: () => !isPage(node.value) && !isPageFragment(node.value) && !props.isMultiSelect,
},
useDeleteMenu('stage-contextmenu'),
{

View File

@ -17,14 +17,14 @@
*/
import { reactive, shallowReactive } from 'vue';
import { throttle } from 'lodash-es';
import serialize from 'serialize-javascript';
import type { DepData, DepExtendedData, Id, MApp, MNode, Target } from '@tmagic/core';
import type { DepData, DepExtendedData, Id, MApp, MNode, Target, TargetNode } from '@tmagic/core';
import { DepTargetType, traverseTarget, Watcher } from '@tmagic/core';
import { isPage } from '@tmagic/utils';
import { CollectWorkerClient } from '@editor/utils/dep/collect-worker-client';
import { IdleTask } from '@editor/utils/dep/idle-task';
import type { DepsData } from '@editor/utils/dep/worker';
import Work from '@editor/utils/dep/worker.ts?worker&inline';
import BaseService from './BaseService';
@ -42,42 +42,18 @@ interface State {
type StateKey = keyof State;
/**
* collectIdle /
* Promise idleTask finish resolve
* clearTasks Promise resolvecollecting
*/
interface CollectBatch {
nodes: MNode[];
deep: boolean;
pending: number;
dsPending: number;
collectedEmitted: boolean;
dsSettled: boolean;
aborted: boolean;
resolve: (completed: boolean) => void;
}
class Dep extends BaseService {
private state = shallowReactive<State>({
collecting: false,
taskLength: 0,
});
private idleTask = new IdleTask<void>();
private collectWorker = new CollectWorkerClient();
private idleTask = new IdleTask<{ node: TargetNode; deep: boolean; target: Target }>();
private watcher = new Watcher({ initialTargets: reactive({}) });
private waitingWorker?: Promise<void>;
private resolveWaitingWorker?: () => void;
private workerGeneration = 0;
private activeBatches = new Set<CollectBatch>();
constructor() {
super();
@ -156,77 +132,56 @@ class Dep extends BaseService {
this.emit('ds-collected', nodes, deep);
}
/**
*
*
* target
* /线 worker 线 target
* target isTarget 线 descriptor线
* worker SSR退线
*/
public async collectIdle(nodes: MNode[], depExtendedData: DepExtendedData = {}, deep = false, type?: DepTargetType) {
if (this.waitingWorker) {
await this.waitingWorker;
}
const batch: CollectBatch = {
nodes,
deep,
pending: 0,
dsPending: 0,
collectedEmitted: false,
dsSettled: false,
aborted: false,
resolve: () => {},
};
const workerTargets: Target[] = [];
for (const target of this.watcher.getCollectableTargets(type)) {
if (target.descriptor && this.collectWorker.isSupported) {
workerTargets.push(target);
} else {
this.enqueueTasks(nodes, target, depExtendedData, deep, batch);
}
}
if (workerTargets.length && nodes.length) {
this.collectByCollectWorker(nodes, workerTargets, depExtendedData, deep, batch);
}
// 没有命中任何 target无需收集直接完成
if (batch.pending === 0) {
this.emit('collected', nodes, deep);
this.updateCollectingState();
return true;
}
this.activeBatches.add(batch);
this.set('collecting', true);
let startTask = false;
this.watcher.collectByCallback(nodes, type, ({ node, target }) => {
startTask = true;
return new Promise<boolean>((resolve) => {
batch.resolve = resolve;
this.enqueueTask(node, target, depExtendedData, deep);
});
return new Promise<void>((resolve) => {
if (!startTask) {
this.emit('collected', nodes, deep);
this.set('collecting', false);
resolve();
return;
}
this.idleTask.once('finish', () => {
this.emit('collected', nodes, deep);
this.set('collecting', false);
});
this.idleTask.once('hight-level-finish', () => {
this.emit('ds-collected', nodes, deep);
resolve();
});
});
}
public collectByWorker(dsl: MApp) {
this.set('collecting', true);
this.workerGeneration += 1;
const generation = this.workerGeneration;
const { promise, resolve: waitingResolve } = Promise.withResolvers<void>();
this.waitingWorker = promise;
this.resolveWaitingWorker = waitingResolve;
return this.collectWorker.collectDsl(dsl).then((deps) => {
const depsData: DepsData = deps || {};
if (generation !== this.workerGeneration) {
waitingResolve();
return depsData;
}
return new Promise<Record<string, Record<string, DepData>>>((resolve) => {
const worker = new Work();
worker.postMessage({
dsl: serialize(dsl),
});
worker.onmessage = (e) => {
resolve(e.data);
};
worker.onerror = () => {
resolve({});
};
}).then((depsData) => {
traverseTarget(this.watcher.getTargetsList(), (target) => {
if (depsData[target.type]?.[target.id]) {
target.deps = reactive(depsData[target.type][target.id]);
@ -245,10 +200,6 @@ class Dep extends BaseService {
this.emit('collected', dsl.items, true);
this.emit('ds-collected', dsl.items, true);
waitingResolve();
if (this.waitingWorker === promise) {
this.waitingWorker = undefined;
this.resolveWaitingWorker = undefined;
}
return depsData;
});
@ -258,12 +209,8 @@ class Dep extends BaseService {
// 先删除原有依赖,重新收集
if (isPage(node)) {
this.removePageDep(target, depExtendedData);
} else if (deep) {
// deep 收集会覆盖整棵子树,删除也要同步清掉子孙旧依赖
this.watcher.removeTargetDep(target, node);
} else {
// 非 deep 只动当前节点,避免误清子节点上其他尚未重收的依赖
target.removeDep(node.id);
this.watcher.removeTargetDep(target, node);
}
this.watcher.collectItem(node, target, depExtendedData, deep);
@ -286,10 +233,7 @@ class Dep extends BaseService {
}
public clearIdleTasks() {
this.abortActiveBatches();
this.idleTask.clearTasks();
// 丢掉 worker 在途任务,避免 abort 后的长任务继续占着常驻 worker
this.collectWorker.abort();
}
public on<Name extends keyof DepEvents, Param extends DepEvents[Name]>(
@ -307,13 +251,7 @@ class Dep extends BaseService {
}
public reset() {
this.abortActiveBatches();
this.workerGeneration += 1;
this.resolveWaitingWorker?.();
this.resolveWaitingWorker = undefined;
this.waitingWorker = undefined;
this.idleTask.clearTasks();
this.collectWorker.abort();
for (const type of Object.keys(this.watcher.getTargetsList())) {
this.removeTargets(type);
@ -329,7 +267,6 @@ class Dep extends BaseService {
this.reset();
this.removeAllPlugins();
this.idleTask.removeAllListeners();
this.collectWorker.terminate();
}
public emit<Name extends keyof DepEvents, Param extends DepEvents[Name]>(eventName: Name, ...args: Param) {
@ -349,207 +286,25 @@ class Dep extends BaseService {
}
}
/**
* worker target 线
*/
private collectByCollectWorker(
nodes: MNode[],
targets: Target[],
depExtendedData: DepExtendedData,
deep: boolean,
batch: CollectBatch,
) {
const hasDataSource = targets.some((target) => target.type === DepTargetType.DATA_SOURCE);
// 先占位,避免 worker 返回前批次被误判为已完成(提前 resolve、collecting 提前复位)
this.beginBatchTask(batch, hasDataSource);
this.collectWorker
.collect({
nodes,
targets: targets.map((target) => target.descriptor!),
depExtendedData,
deep,
})
.then((result) => {
if (batch.aborted) return;
for (const target of targets) {
if (result) {
// 写回是纯字典操作,但数据量大时同样会占用主线程,因此按 target 拆成空闲任务
this.enqueueApplyTask(target, result.deps[target.type]?.[target.id] || {}, result.nodeIds, batch, {
nodes,
depExtendedData,
deep,
});
} else {
this.enqueueTasks(nodes, target, depExtendedData, deep, batch);
}
}
})
.finally(() => {
// 释放占位:必须在新任务入队之后,否则批次会在中途被判定为完成
this.onBatchTaskDone(batch, hasDataSource);
});
}
/**
* worker target
*/
private enqueueApplyTask(
target: Target,
deps: DepData,
nodeIds: Id[],
batch: CollectBatch,
{ nodes, depExtendedData, deep }: { nodes: MNode[]; depExtendedData: DepExtendedData; deep: boolean },
) {
const isDataSource = target.type === DepTargetType.DATA_SOURCE;
this.beginBatchTask(batch, isDataSource);
private enqueueTask(node: MNode, target: Target, depExtendedData: DepExtendedData, deep: boolean) {
this.idleTask.enqueueTask(
() => {
try {
// target 可能在 worker 收集期间被移除或重建(如修改数据源字段)
const current = this.watcher.getTarget(target.id, target.type);
if (current !== target) {
// 已被同 id 的新 target 替换时,用当前 target 在主线程补收,避免依赖空窗
if (current) {
this.enqueueTasks(nodes, current, depExtendedData, deep, batch);
}
return;
}
// 页面按 pageId 匹配删除,可清掉页面内已被删除节点的残留依赖
if (nodes.some((node) => isPage(node))) {
this.removePageDep(target, depExtendedData);
}
for (const id of nodeIds) {
target.removeDep(id);
}
for (const [id, dep] of Object.entries(deps)) {
target.deps[id] = dep;
}
} finally {
this.onBatchTaskDone(batch, isDataSource);
}
({ node, deep, target }) => {
this.collectNode(node, target, depExtendedData, deep);
},
undefined,
isDataSource,
);
}
private enqueueTasks(
nodes: MNode[],
target: Target,
depExtendedData: DepExtendedData,
deep: boolean,
batch: CollectBatch,
) {
for (const node of nodes) {
this.enqueueTask(node, target, depExtendedData, deep, batch);
}
}
private enqueueTask(
node: MNode,
target: Target,
depExtendedData: DepExtendedData,
deep: boolean,
batch: CollectBatch,
) {
const isDataSource = target.type === DepTargetType.DATA_SOURCE;
this.beginBatchTask(batch, isDataSource);
this.idleTask.enqueueTask(
() => {
try {
this.collectNode(node, target, depExtendedData);
} finally {
this.onBatchTaskDone(batch, isDataSource);
}
{
node,
deep: false,
target,
},
undefined,
isDataSource,
target.type === DepTargetType.DATA_SOURCE,
);
if (deep && Array.isArray(node.items) && node.items.length) {
node.items.forEach((item) => {
this.enqueueTask(item, target, depExtendedData, deep, batch);
this.enqueueTask(item, target, depExtendedData, deep);
});
}
}
private beginBatchTask(batch: CollectBatch, isDataSource: boolean) {
batch.pending += 1;
if (isDataSource) {
batch.dsPending += 1;
}
}
private onBatchTaskDone(batch: CollectBatch, isDataSource: boolean) {
if (batch.aborted) return;
if (isDataSource) {
batch.dsPending -= 1;
// 数据源依赖收集完先 resolve让 stage 尽快更新,其余依赖继续在后台收集
if (batch.dsPending === 0) {
this.settleBatchDs(batch);
}
}
batch.pending -= 1;
if (batch.pending === 0) {
this.finishBatch(batch);
}
}
private settleBatchDs(batch: CollectBatch) {
if (batch.dsSettled) return;
batch.dsSettled = true;
this.emit('ds-collected', batch.nodes, batch.deep);
batch.resolve(true);
}
private finishBatch(batch: CollectBatch) {
if (!batch.collectedEmitted) {
batch.collectedEmitted = true;
this.emit('collected', batch.nodes, batch.deep);
}
// 没有数据源任务的批次在此结算 Promise
this.settleBatchDs(batch);
this.activeBatches.delete(batch);
this.updateCollectingState();
}
/**
* idleTask
* collectIdle Promise resolvecollecting trueonce
* emit collected/ds-collected Promise
*/
private abortActiveBatches() {
if (!this.activeBatches.size) {
return;
}
const batches = [...this.activeBatches];
this.activeBatches.clear();
for (const batch of batches) {
// 标记中断worker 结果可能在之后才返回,此时不能再写回依赖,也不能再结算批次
batch.aborted = true;
batch.resolve(false);
}
this.updateCollectingState();
}
private updateCollectingState() {
this.set('collecting', this.activeBatches.size > 0);
}
}
export type DepService = Dep;

View File

@ -16,7 +16,7 @@
* limitations under the License.
*/
import { nextTick, reactive, toRaw } from 'vue';
import { reactive, toRaw } from 'vue';
import { cloneDeep, isEmpty, isEqual, isObject, mergeWith, uniq } from 'lodash-es';
import type { Id, MApp, MContainer, MNode, MPage, MPageFragment, TargetOptions } from '@tmagic/core';
@ -30,7 +30,6 @@ import {
guid,
isPage,
isPageFragment,
isPageOrFragment,
setValueByKeyPath,
traverseNode,
} from '@tmagic/utils';
@ -273,7 +272,7 @@ class Editor extends BaseService {
public isOnDifferentPage(node: MNode): boolean {
const currentPageId = this.get('page')?.id;
if (currentPageId === undefined || currentPageId === null) return false;
if (isPageOrFragment(node)) {
if (isPage(node) || isPageFragment(node)) {
return `${node.id}` !== `${currentPageId}`;
}
const nodePage = this.getNodeInfo(node.id, false).page;
@ -414,11 +413,11 @@ class Editor extends BaseService {
if (!curNode) throw new Error('当前选中节点为空');
if ((parent.type === NodeType.ROOT || curNode?.type === NodeType.ROOT) && !isPageOrFragment(node)) {
if ((parent.type === NodeType.ROOT || curNode?.type === NodeType.ROOT) && !(isPage(node) || isPageFragment(node))) {
throw new Error('app下不能添加组件');
}
if (parent.id !== curNode.id && !isPageOrFragment(node)) {
if (parent.id !== curNode.id && !(isPage(node) || isPageFragment(node))) {
const index = parent.items.indexOf(curNode);
parent.items?.splice(index + 1, 0, node);
} else {
@ -488,7 +487,7 @@ class Editor extends BaseService {
const newNodes = await Promise.all(
addNodes.map((node) => {
const root = this.get('root');
if (isPageOrFragment(node) && root) {
if ((isPage(node) || isPageFragment(node)) && root) {
return this.doAdd(node, root);
}
const parentNode = parent ?? getAddParent(node);
@ -524,7 +523,7 @@ class Editor extends BaseService {
}
}
if (!isPageOrFragment(newNodes[0])) {
if (!(isPage(newNodes[0]) || isPageFragment(newNodes[0]))) {
const pageForOp = this.getNodeInfo(newNodes[0].id, false).page;
if (!doNotPushHistory) {
const parentId = (this.getParentById(newNodes[0].id, false) ?? this.get('root'))!.id;
@ -555,12 +554,6 @@ class Editor extends BaseService {
doNotPushHistory,
});
// 页面 / 页面片新增不入历史栈(见上方 isPageOrFragment 分支),这里合并补发一次结构变更通知
const addedPages = newNodes.filter((node) => isPageOrFragment(node)) as (MPage | MPageFragment)[];
if (addedPages.length) {
historyService.notifyPageStructureChange({ add: addedPages, remove: [] });
}
return Array.isArray(addNode) ? newNodes : newNodes[0];
}
@ -579,14 +572,12 @@ class Editor extends BaseService {
if (typeof index !== 'number' || index === -1) throw new Error('找不要删除的节点');
const stage = this.get('stage');
const currentPage = this.get('page');
const isDeletingCurrentPage = isPageOrFragment(node) && !!currentPage && `${currentPage.id}` === `${node.id}`;
parent.items?.splice(index, 1);
const stage = this.get('stage');
stage?.remove({ id: node.id, parentId: parent.id, root: cloneDeep(root) });
// 始终清理已删除节点在 state 中的残留引用:
// - 即使后续会调用 select 覆盖跳过这些调用doNotSelect / doNotSwitchPage时也不能让 state 持有已删除节点
// - 即使后续会调用 selectDefault / select(parent) 覆盖跳过这些调用doNotSelect / doNotSwitchPage时也不能让 state 持有已删除节点
const selectedNodes = this.get('nodes');
const removedSelectedIndex = selectedNodes.findIndex((n: MNode) => `${n.id}` === `${node.id}`);
if (removedSelectedIndex !== -1) {
@ -594,44 +585,38 @@ class Editor extends BaseService {
nextSelected.splice(removedSelectedIndex, 1);
this.set('nodes', nextSelected);
}
const removeData = { id: node.id, parentId: parent.id, root: cloneDeep(root) };
const rootItems = root.items || [];
// 删非当前页时画布不应该变动,所以只有删的是当前页才重新选中
const shouldReselect = isDeletingCurrentPage && !doNotSelect && !doNotSwitchPage;
// 还有剩余页面才能切过去,否则退回选中 root
const shouldSwitchPage = shouldReselect && rootItems.length > 0;
if (isPageOrFragment(node)) {
if (isPage(node)) {
this.state.pageLength -= 1;
} else {
this.state.pageFragmentLength -= 1;
if (isPage(node) || isPageFragment(node)) {
const currentPage = this.get('page');
if (currentPage && `${currentPage.id}` === `${node.id}`) {
this.set('page', null);
}
}
if (shouldSwitchPage) {
// runtime 收到页面删除时会销毁当前渲染的 page 实例,必须先把画布切到剩余页面再通知删除,
// 否则画布会被清空,且要等下一次 updatePageId 才能恢复。
// nextTick 用于等 Stage 的 page watch 把新页面 id 同步给 runtime。
await this.select(rootItems[0]);
stage?.select(rootItems[0].id);
await nextTick();
stage?.remove(removeData);
const selectDefault = async (pages: MNode[]) => {
if (pages[0]) {
await this.select(pages[0]);
stage?.select(pages[0].id);
} else {
// page 置空会让 Workspace 卸载 Stage 并销毁 renderer删除通知必须在卸载前发出
// 且不能 awaitruntime 未 ready 时 getRuntime 的监听会随 destroy 一起被移除,永远不会 resolve
stage?.remove(removeData);
this.selectRoot();
}
};
if (shouldReselect) {
// 页面已全部删完
this.selectRoot();
} else if (isDeletingCurrentPage) {
this.set('page', null);
}
const rootItems = root.items || [];
if (isPage(node)) {
this.state.pageLength -= 1;
// 删除页面后默认会切到首个剩余页面selectDefaultdoNotSwitchPage 时跳过这次自动切换
if (!doNotSelect && !doNotSwitchPage) {
await selectDefault(rootItems);
}
} else if (isPageFragment(node)) {
this.state.pageFragmentLength -= 1;
if (!doNotSelect && !doNotSwitchPage) {
await selectDefault(rootItems);
}
} else {
stage?.remove(removeData);
if (!doNotSelect) {
await this.select(parent);
stage?.select(parent.id);
@ -673,7 +658,7 @@ class Editor extends BaseService {
const removedItems: StepDiffItem<MNode>[] = [];
let pageForOp: { name: string; id: Id } | null = null;
if (!isPageOrFragment(nodes[0])) {
if (!(isPage(nodes[0]) || isPageFragment(nodes[0]))) {
for (const n of nodes) {
const { parent, node: curNode, page } = this.getNodeInfo(n.id, false);
if (parent && curNode) {
@ -710,12 +695,6 @@ class Editor extends BaseService {
this.emit('remove', nodes);
this.emit('change', { type: 'remove', data: changeItems, historySource, doNotPushHistory });
// 页面 / 页面片删除不入历史栈(见上方 isPageOrFragment 分支),这里合并补发一次结构变更通知
const removedPages = nodes.filter((node) => isPageOrFragment(node)) as (MPage | MPageFragment)[];
if (removedPages.length) {
historyService.notifyPageStructureChange({ add: [], remove: removedPages });
}
}
public async doUpdate(
@ -773,7 +752,7 @@ class Editor extends BaseService {
}
// 只有被更新节点正好是当前选中页面时才同步 state.page避免「更新非当前页」误将编辑器切到该页
if (isPageOrFragment(newConfig)) {
if (isPage(newConfig) || isPageFragment(newConfig)) {
const currentPage = this.get('page');
if (currentPage && `${currentPage.id}` === `${newConfig.id}`) {
this.set('page', newConfig as MPage | MPageFragment);
@ -1148,7 +1127,7 @@ class Editor extends BaseService {
}: DslOpOptions = {},
): Promise<MNode | MNode[]> {
const isBatch = Array.isArray(config);
const configs = (isBatch ? config : [config]).filter((item) => !isPageOrFragment(item));
const configs = (isBatch ? config : [config]).filter((item) => !(isPage(item) || isPageFragment(item)));
if (configs.length === 0) {
throw new Error('没有可移动的节点');
@ -1769,7 +1748,7 @@ class Editor extends BaseService {
private getPageOfNode(id: Id): MPage | MPageFragment | null {
const { node, page } = this.getNodeInfo(id, false);
if (page) return page;
if (node && isPageOrFragment(node)) return node as MPage | MPageFragment;
if (node && (isPage(node) || isPageFragment(node))) return node as MPage | MPageFragment;
return null;
}
@ -1797,10 +1776,6 @@ class Editor extends BaseService {
const nextMap = new Map(nextPages.map((p) => [`${p.id}`, p]));
const indexInItems = (root: MApp, id: Id) => (root.items ?? []).findIndex((item) => `${item.id}` === `${id}`);
// 收集本次整体替换中增删的页面,循环结束后合并为一次结构变更通知(避免逐页派发多个事件)
const addedPages: (MPage | MPageFragment)[] = [];
const removedPages: (MPage | MPageFragment)[] = [];
nextPages.forEach((nextPage) => {
const prevPage = prevMap.get(`${nextPage.id}`);
if (!prevPage) {
@ -1810,7 +1785,6 @@ class Editor extends BaseService {
{ newSchema: cloneDeep(toRaw(nextPage)), parentId: nextRoot.id, index: indexInItems(nextRoot, nextPage.id) },
source,
);
addedPages.push(nextPage);
} else if (!isEqual(toRaw(prevPage), toRaw(nextPage))) {
this.pushPageDiffStep(
'update',
@ -1829,13 +1803,8 @@ class Editor extends BaseService {
{ oldSchema: cloneDeep(toRaw(prevPage)), parentId: preRoot.id, index: indexInItems(preRoot, prevPage.id) },
source,
);
removedPages.push(prevPage);
}
});
if (addedPages.length || removedPages.length) {
historyService.notifyPageStructureChange({ add: addedPages, remove: removedPages });
}
}
/**

View File

@ -19,7 +19,7 @@
import { reactive } from 'vue';
import type { Writable } from 'type-fest';
import type { Id, MPage, MPageFragment } from '@tmagic/core';
import type { Id } from '@tmagic/core';
import { guid } from '@tmagic/utils';
import type {
@ -353,27 +353,6 @@ class History extends BaseService {
this.emit('mark-saved', { kind: stepType, id });
}
/**
* / `page-structure-change`
*
* `editorService.add` / `remove` `page` editor.add / remove
* isPageOrFragment historyService
* setRoot DSL
*
* ** change **
* 使`add` / `remove`
*
* @param change { add: 新增的页面列表, remove: 删除的页面列表 }
*/
public notifyPageStructureChange(change: {
add: (MPage | MPageFragment)[];
remove: (MPage | MPageFragment)[];
}): void {
// 增删均为空时不派发,避免无意义通知
if (!change.add.length && !change.remove.length) return;
this.emit('page-structure-change', change);
}
/**
* / / / IndexedDB
*

View File

@ -1,6 +1,6 @@
import KeyController, { KeyControllerEvent } from 'keycon';
import { isPageOrFragment } from '@tmagic/utils';
import { isPage, isPageFragment } from '@tmagic/utils';
import { KeyBindingCacheItem, KeyBindingCommand, KeyBindingItem } from '@editor/type';
@ -19,7 +19,7 @@ class Keybinding extends BaseService {
[KeyBindingCommand.DELETE_NODE]: () => {
const nodes = editorService.get('nodes');
if (!nodes || isPageOrFragment(nodes[0])) return;
if (!nodes || isPage(nodes[0]) || isPageFragment(nodes[0])) return;
editorService.remove(nodes, { historySource: 'shortcut' });
},
[KeyBindingCommand.COPY_NODE]: () => {
@ -29,7 +29,7 @@ class Keybinding extends BaseService {
[KeyBindingCommand.CUT_NODE]: () => {
const nodes = editorService.get('nodes');
if (!nodes || isPageOrFragment(nodes[0])) return;
if (!nodes || isPage(nodes[0]) || isPageFragment(nodes[0])) return;
editorService.copy(nodes);
editorService.remove(nodes, { historySource: 'shortcut' });
},

View File

@ -19,7 +19,7 @@
}
.el-card__body {
.tmagic-design-form-item {
margin-bottom: 16px;
margin-bottom: 18px;
}
}
.code-select-content {

View File

@ -3,12 +3,6 @@
.fullWidth {
width: 100%;
box-sizing: border-box;
&.create-button {
width: 100%;
}
&.m-container-ui-event {
width: calc(100% - 32px);
}
}
.event-select-container {
padding: 0 16px;
@ -82,18 +76,17 @@
.event-item-header {
position: relative;
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
.event-item-title {
color: #0f1113;
font-size: 16px;
font-weight: 500;
line-height: 24px;
margin-bottom: 8px;
margin-bottom: 16px;
}
.event-item-delete-button {
color: #0f1113;
position: absolute;
right: 0;
top: 0;
}
.el-form-item {

View File

@ -178,11 +178,6 @@ export interface StageOptions {
containerHighlightClassName?: string;
containerHighlightDuration?: number;
containerHighlightType?: ContainerHighlightType;
/**
*
* false
*/
containerHighlightAddOnly?: boolean;
disabledDragStart?: boolean;
render?: (stage: StageCore) => HTMLDivElement | void | Promise<HTMLDivElement | void>;
moveableOptions?: CustomizeMoveableOptions;
@ -1390,12 +1385,6 @@ export interface HistoryEvents {
'mark-saved': [{ kind: HistoryStepType; id?: Id }];
clear: [{ id: Id; stepType: HistoryStepType }];
'marker-change': [{ id: Id; marker: StepValue; stepType: HistoryStepType }];
/**
* / / {@link HistoryService.notifyPageStructureChange}
* add / remove / setRoot ****
* `add` / `remove`
*/
'page-structure-change': [change: { add: (MPage | MPageFragment)[]; remove: (MPage | MPageFragment)[] }];
}
export const canUsePluginMethods = {

View File

@ -2,7 +2,7 @@ import { computed, markRaw, type ShallowRef } from 'vue';
import { CopyDocument, Delete, DocumentCopy } from '@element-plus/icons-vue';
import { cloneDeep, Id, MContainer, NodeType } from '@tmagic/core';
import { calcValueByFontsize, isPage, isPageOrFragment } from '@tmagic/utils';
import { calcValueByFontsize, isPage, isPageFragment } from '@tmagic/utils';
import ContentMenu from '@editor/components/ContentMenu.vue';
import type { HistoryOpSource, MenuButton, Services } from '@editor/type';
@ -20,7 +20,7 @@ export const useDeleteMenu = (historySource?: HistoryOpSource): MenuButton => ({
icon: Delete,
display: ({ editorService }) => {
const node = editorService.get('node');
return node?.type !== NodeType.ROOT && !isPageOrFragment(node);
return node?.type !== NodeType.ROOT && !isPage(node) && !isPageFragment(node);
},
handler: ({ editorService }) => {
const nodes = editorService.get('nodes');

View File

@ -243,7 +243,7 @@ export const resolveFieldByPath = (
fields: DataSchema[] | undefined,
fieldNames: string[],
options: { skipNumberIndices?: boolean } = {},
): { ok: boolean; field?: DataSchema; fields: DataSchema[]; failedName?: string } => {
): { ok: boolean; field?: DataSchema; fields: DataSchema[] } => {
let currentFields = fields || [];
let field: DataSchema | undefined;
@ -252,11 +252,11 @@ export const resolveFieldByPath = (
continue;
}
if (!currentFields.length) {
return { ok: false, fields: currentFields, failedName: name };
return { ok: false, fields: currentFields };
}
field = currentFields.find((item) => item.name === name);
if (!field) {
return { ok: false, fields: currentFields, failedName: name };
return { ok: false, fields: currentFields };
}
currentFields = field.fields || [];
}

View File

@ -1,149 +0,0 @@
import serialize from 'serialize-javascript';
import type { Id, MApp } from '@tmagic/core';
import { error } from '../logger';
import type { CollectWorkerPayload, CollectWorkerRequest, CollectWorkerResponse, DepsData } from './worker';
import CollectWorker from './worker.ts?worker&inline';
export interface CollectWorkerResult {
deps: DepsData;
nodeIds: Id[];
}
/**
* worker 线
*
* /线
* worker 线 target
*
* worker
* worker 线
*/
export class CollectWorkerClient {
private worker: Worker | null = null;
private seed = 0;
private pending = new Map<number, (response: CollectWorkerResponse | null) => void>();
public get isSupported() {
return typeof Worker !== 'undefined';
}
/**
* worker DSL target worker DSL
* @returns null worker
*/
public collectDsl(dsl: MApp): Promise<DepsData | null> {
return this.request((id) => ({ id, dsl: serialize(dsl) })).then((response) => response?.deps || null);
}
/**
* worker target
* @returns null worker 退线
*/
public collect(payload: CollectWorkerPayload): Promise<CollectWorkerResult | null> {
return this.request((id) => ({ id, payload: serialize(payload) })).then((response) =>
response ? { deps: response.deps || {}, nodeIds: response.nodeIds || [] } : null,
);
}
public terminate() {
this.worker?.terminate();
this.worker = null;
this.settleAllPending();
}
/**
* worker
* clearIdleTasks / reset abort worker
*/
public abort() {
this.terminate();
}
/**
* worker id
*/
private request(createRequest: (id: number) => CollectWorkerRequest): Promise<CollectWorkerResponse | null> {
const worker = this.getWorker();
if (!worker) {
return Promise.resolve(null);
}
this.seed += 1;
const id = this.seed;
return new Promise<CollectWorkerResponse | null>((resolve) => {
this.pending.set(id, resolve);
try {
// 节点配置中可能存在函数等无法结构化克隆的值,需要先序列化再传递
worker.postMessage(createRequest(id));
} catch (e) {
error('magic editor: 依赖收集 worker 通信失败', e);
this.pending.delete(id);
resolve(null);
}
});
}
private getWorker() {
if (!this.isSupported) {
return null;
}
if (this.worker) {
return this.worker;
}
try {
const worker: Worker = new CollectWorker();
worker.onmessage = (e: MessageEvent<CollectWorkerResponse>) => {
this.handleResponse(e.data);
};
// worker 整体异常时无法定位到具体请求,只能结算全部在途请求并重建 worker
worker.onerror = () => {
this.handleFatalError();
};
worker.onmessageerror = () => {
this.handleFatalError();
};
this.worker = worker;
} catch (e) {
error('magic editor: 依赖收集 worker 创建失败', e);
return null;
}
return this.worker;
}
private handleResponse(response: CollectWorkerResponse) {
const resolve = this.pending.get(response?.id);
if (!resolve) {
return;
}
this.pending.delete(response.id);
resolve(response.failed ? null : response);
}
private handleFatalError() {
this.worker?.terminate();
this.worker = null;
this.settleAllPending();
}
private settleAllPending() {
const resolvers = [...this.pending.values()];
this.pending.clear();
for (const resolve of resolvers) {
resolve(null);
}
}
}

View File

@ -1,7 +1,5 @@
import { EventEmitter } from 'events';
import { error } from '../logger';
export interface IdleTaskEvents {
finish: [];
'hight-level-finish': [];
@ -27,12 +25,6 @@ globalThis.requestIdleCallback =
}, 1);
};
globalThis.cancelIdleCallback =
globalThis.cancelIdleCallback ||
function (handle) {
clearTimeout(handle as unknown as ReturnType<typeof setTimeout>);
};
export class IdleTask<T = any> extends EventEmitter {
private taskList: TaskList<T> = [];
@ -66,7 +58,7 @@ export class IdleTask<T = any> extends EventEmitter {
this.taskHandle = null;
this.emit('update-task-length', {
length: this.getTaskLength(),
length: this.taskList.length + this.hightLevelTaskList.length,
hightLevelLength: this.hightLevelTaskList.length,
});
}
@ -90,80 +82,51 @@ export class IdleTask<T = any> extends EventEmitter {
}
private runTaskQueue(deadline: IdleDeadline) {
// 本次回调已触发,句柄随之失效;置空后 clearTasks / enqueueTask 才能正确判断是否需要重新调度
this.taskHandle = null;
const { hightLevelTaskList, taskList } = this;
try {
// 动画会占用空闲时间,当任务一直无法执行时,看看是否有动画正在播放
// 根据空闲时间的多少来决定执行的任务数,保证页面不卡死的情况下尽量多执行任务,不然当任务数巨大时,执行时间会很久
// 执行不完不会影响配置,但是会影响画布渲染
while (deadline.timeRemaining() > 0 && this.getTaskLength()) {
const timeRemaining = deadline.timeRemaining();
let times = 0;
if (timeRemaining <= 5) {
times = 10;
} else if (timeRemaining <= 10) {
times = 100;
} else if (timeRemaining <= 15) {
times = 300;
} else {
times = 600;
}
for (let i = 0; i < times; i++) {
// 每次都从实例上取队列,任务执行过程中调用 clearTasks 能立即生效,不会继续消费已被清空的旧队列
const task = this.hightLevelTaskList.length > 0 ? this.hightLevelTaskList.shift() : this.taskList.shift();
if (task) {
this.runTask(task);
}
if (!this.getTaskLength()) {
break;
}
}
}
} finally {
// 必须放在 finally 中一旦这里被跳过taskHandle 会一直是真值,
// enqueueTask 也就不会再重新调度,剩余任务与任务数将永久停在当前状态
this.finishRun();
}
}
/**
*
*
*/
private runTask(task: TaskList<T>[number]) {
try {
task.handler(task.data);
} catch (e) {
error('magic editor: 空闲任务执行失败', e);
}
}
private finishRun() {
try {
if (!this.hightLevelTaskList.length) {
this.emit('hight-level-finish');
}
} finally {
if (this.getTaskLength()) {
// 任务执行或事件监听中可能已经重新调度过
if (!this.taskHandle) {
this.taskHandle = globalThis.requestIdleCallback(this.runTaskQueue.bind(this), { timeout: 300 });
}
// 动画会占用空闲时间,当任务一直无法执行时,看看是否有动画正在播放
// 根据空闲时间的多少来决定执行的任务数,保证页面不卡死的情况下尽量多执行任务,不然当任务数巨大时,执行时间会很久
// 执行不完不会影响配置,但是会影响画布渲染
while (deadline.timeRemaining() > 0 && (taskList.length || hightLevelTaskList.length)) {
const timeRemaining = deadline.timeRemaining();
let times = 0;
if (timeRemaining <= 5) {
times = 10;
} else if (timeRemaining <= 10) {
times = 100;
} else if (timeRemaining <= 15) {
times = 300;
} else {
this.emit('finish');
times = 600;
}
this.emit('update-task-length', {
length: this.getTaskLength(),
hightLevelLength: this.hightLevelTaskList.length,
});
}
}
for (let i = 0; i < times; i++) {
const task = hightLevelTaskList.length > 0 ? hightLevelTaskList.shift() : taskList.shift();
if (task) {
task.handler(task.data);
}
private getTaskLength() {
return this.taskList.length + this.hightLevelTaskList.length;
if (hightLevelTaskList.length === 0 && taskList.length === 0) {
break;
}
}
}
if (!hightLevelTaskList.length) {
this.emit('hight-level-finish');
}
if (hightLevelTaskList.length || taskList.length) {
this.taskHandle = globalThis.requestIdleCallback(this.runTaskQueue.bind(this), { timeout: 300 });
} else {
this.taskHandle = 0;
this.emit('finish');
}
this.emit('update-task-length', {
length: taskList.length + hightLevelTaskList.length,
hightLevelLength: hightLevelTaskList.length,
});
}
}

View File

@ -3,85 +3,30 @@ import {
createDataSourceCondTarget,
createDataSourceMethodTarget,
createDataSourceTarget,
createTargetByDescriptor,
type DepData,
type DepExtendedData,
DepTargetType,
type Id,
type MApp,
type TargetDescriptor,
type TargetNode,
traverseTarget,
Watcher,
} from '@tmagic/core';
import { error } from '../logger';
export type DepsData = Record<string, Record<Id, DepData>>;
onmessage = (e) => {
const watcher = new Watcher({ initialTargets: {} });
/** 全量收集请求worker 中根据 DSL 重建代码块/数据源 target */
export interface DslCollectRequest {
/** 请求 id用于把结果与调用方对应起来 */
id: number;
/** serialize-javascript 序列化后的 MApp */
dsl: string;
}
const { dsl } = e.data;
export interface CollectWorkerPayload {
/** 待收集的节点 */
nodes: TargetNode[];
/** 参与本次收集的 target 描述worker 中据此重建 target */
targets: TargetDescriptor[];
depExtendedData: DepExtendedData;
deep: boolean;
}
/** 增量收集请求target 由主线程以可序列化描述的形式传入 */
export interface NodesCollectRequest {
id: number;
/** serialize-javascript 序列化后的 CollectWorkerPayload */
payload: string;
}
export type CollectWorkerRequest = DslCollectRequest | NodesCollectRequest;
export interface CollectWorkerResponse {
id: number;
/** 收集结果,结构为 { [target.type]: { [target.id]: deps } } */
deps: DepsData;
/** 增量收集时本次覆盖到的节点 iddeep=true 时含子孙),主线程据此删除旧依赖 */
nodeIds?: Id[];
/** 收集失败,调用方需要回退到主线程收集 */
failed?: boolean;
}
/**
* id
* deep deep=false id removeDep
*/
const getNodeIds = (nodes: TargetNode[], deep: boolean, ids: Id[] = []) => {
for (const node of nodes) {
ids.push(node.id);
if (deep && Array.isArray(node.items) && node.items.length) {
getNodeIds(node.items, deep, ids);
}
}
return ids;
};
const collectDsl = ({ id, dsl }: DslCollectRequest) => {
try {
// eslint-disable-next-line no-eval
const mApp: MApp = eval(`(${dsl})`);
if (!mApp) {
postMessage({ id, deps: {} });
return;
postMessage({});
}
const watcher = new Watcher({ initialTargets: {} });
watcher.clearTargets();
if (mApp.codeBlocks) {
for (const [id, code] of Object.entries(mApp.codeBlocks)) {
@ -103,7 +48,7 @@ const collectDsl = ({ id, dsl }: DslCollectRequest) => {
watcher.collectItems(page, targets, { pageId: page.id }, true);
}
const deps: DepsData = {
const data: Record<string, Record<Id, DepData>> = {
[DepTargetType.DATA_SOURCE]: {},
[DepTargetType.DATA_SOURCE_METHOD]: {},
[DepTargetType.DATA_SOURCE_COND]: {},
@ -111,61 +56,12 @@ const collectDsl = ({ id, dsl }: DslCollectRequest) => {
};
traverseTarget(watcher.getTargetsList(), (target) => {
deps[target.type][target.id] = target.deps;
data[target.type][target.id] = target.deps;
});
const response: CollectWorkerResponse = { id, deps };
postMessage(response);
postMessage(data);
} catch (e: any) {
error(e);
const response: CollectWorkerResponse = { id, deps: {}, failed: true };
postMessage(response);
postMessage({});
}
};
const collectNodes = ({ id, payload }: NodesCollectRequest) => {
try {
// eslint-disable-next-line no-eval
const { nodes, targets: descriptors, depExtendedData, deep }: CollectWorkerPayload = eval(`(${payload})`);
const watcher = new Watcher({ initialTargets: {} });
// worker 中 target 均为新建deps 为空),无需删除阶段,直接批量收集
const targets = descriptors.map((descriptor) => createTargetByDescriptor(descriptor));
for (const node of nodes) {
watcher.collectItems(node, targets, depExtendedData, deep);
}
const deps: DepsData = {};
for (const target of targets) {
if (!deps[target.type]) {
deps[target.type] = {};
}
deps[target.type][target.id] = target.deps;
}
const response: CollectWorkerResponse = { id, deps, nodeIds: getNodeIds(nodes, deep) };
postMessage(response);
} catch (e: any) {
error(e);
const response: CollectWorkerResponse = { id, deps: {}, nodeIds: [], failed: true };
postMessage(response);
}
};
/**
* worker
*
* target /线
* worker
* worker inline worker
* 线 id
*/
onmessage = (e: MessageEvent<CollectWorkerRequest>) => {
if ('dsl' in e.data) {
collectDsl(e.data);
return;
}
collectNodes(e.data);
};

View File

@ -3,7 +3,7 @@ import { isEmpty } from 'lodash-es';
import type { Id, MContainer, MNode } from '@tmagic/core';
import { NodeType } from '@tmagic/core';
import { calcValueByFontsize, getElById, isPage, isPageOrFragment } from '@tmagic/utils';
import { calcValueByFontsize, getElById, isPage, isPageFragment } from '@tmagic/utils';
import editorService from '@editor/services/editor';
import propsService from '@editor/services/props';
@ -58,7 +58,7 @@ export const beforePaste = (position: PastePosition, config: MNode[], doc?: Docu
};
}
const root = editorService.get('root');
if (isPageOrFragment(pasteConfig) && root) {
if ((isPage(pasteConfig) || isPageFragment(pasteConfig)) && root) {
pasteConfig.name = generatePageNameByApp(root, isPage(pasteConfig) ? NodeType.PAGE : NodeType.PAGE_FRAGMENT);
}
return pasteConfig as MNode;

View File

@ -16,6 +16,9 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { AppContext } from 'vue';
import {
HookType,
NODE_CONDS_KEY,
@ -24,7 +27,18 @@ import {
NODE_DISABLE_DATA_SOURCE_KEY,
} from '@tmagic/core';
import { tMagicMessage } from '@tmagic/design';
import type { ChildConfig, DisplayCondsConfig, FormConfig, TabConfig, TabPaneConfig } from '@tmagic/form';
import type {
ChildConfig,
DisplayCondsConfig,
FormConfig,
FormState,
FormValue,
TabConfig,
TabPaneConfig,
} from '@tmagic/form';
import { validateForm } from '@tmagic/form';
import type { Services } from '@editor/type';
export const arrayOptions = [
{ text: '包含', value: 'include' },
@ -389,3 +403,86 @@ export const removeStyleDisplayConfig = (formConfig: FormConfig): FormConfig =>
}),
};
});
// #region ValidatePropsFormOptions
/**
* validatePropsForm
*/
export interface ValidatePropsFormOptions {
/** 组件属性表单配置 */
config: FormConfig;
/** 待校验的表单值 */
values: FormValue;
/**
* appContext `getCurrentInstance()?.appContext`
* services MForm appContext使DataSourceInput inject
*/
appContext?: AppContext | null;
/** 编辑器服务集合,注入到临时表单的 formState */
services?: Services;
/** stage 实例,注入到临时表单的 formState */
stage?: any;
/** 外部扩展的 formState */
extendState?: (_state: FormState) => Record<string, any> | Promise<Record<string, any>>;
/**
* `true`
* `false`
*/
debug?: boolean;
typeMatchValid?: boolean;
}
// #endregion ValidatePropsFormOptions
/**
* + ****
*
* `@tmagic/form` `validateForm` MForm
* provides appContext formState
* stage / services
*
*
* 使
*
* @returns `''` `<br>`
* reject
*
* @example
* ```ts
* const error = await validatePropsForm({
* config,
* values,
* appContext: getCurrentInstance()?.appContext,
* services,
* stage: editorService.get('stage'),
* extendState,
* });
* if (error) {
* // 配置不合法error 为错误文案
* }
* ```
*/
export const validatePropsForm = ({
config,
values,
appContext = null,
services,
stage,
extendState,
debug,
typeMatchValid,
}: ValidatePropsFormOptions): Promise<string> =>
validateForm({
config,
debug,
typeMatchValid,
initValues: values,
// 将当前组件实例的 provides含 Editor 顶层的 services / codeOptions 等组件级 provide
// 合入 appContext使临时 MForm 中的编辑器字段组件DataSourceInput 等)能正常 inject
appContext: appContext ? { ...appContext, provides: { services } } : null,
// 与页面表单保持一致:注入 stage/services 及外部扩展状态,保证校验规则依赖的上下文可用
extendState: async (state) => ({
...((await extendState?.(state)) || {}),
stage,
services,
}),
});

View File

@ -20,7 +20,6 @@ import type { DataSourceFieldType, DataSourceSchema, Id } from '@tmagic/core';
import { NodeType } from '@tmagic/core';
import { appendValidateSuggestion } from '@tmagic/design';
import type { TypeMatchValidateContext, TypeMatchValidator } from '@tmagic/form';
import { validateTypeMatch } from '@tmagic/form';
import {
DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX,
DATA_SOURCE_SET_DATA_METHOD_NAME,
@ -58,7 +57,7 @@ const stringifyExampleValue = (value: any): string => {
};
// 参考建议中最多展示的可选值个数,超出以「等」省略。
const MAX_SUGGESTION_OPTIONS = 20;
const MAX_SUGGESTION_OPTIONS = 5;
/**
* 使xxxxxx
@ -262,20 +261,16 @@ const validateDataSourceFieldPath = (
const ds = findDataSource(`${dsId}`);
if (!ds) {
return defaultMessage(options.message, `数据源(${dsId})不存在`, dataSourceIdSuggestion());
return defaultMessage(options.message, '值不在可选项中', dataSourceIdSuggestion());
}
if (!fieldNames.length) {
return undefined;
}
const { field, ok, fields, failedName } = resolveFieldByPath(ds.fields, fieldNames);
const { field, ok } = resolveFieldByPath(ds.fields, fieldNames);
if (!ok) {
return defaultMessage(
options.message,
`数据源字段(${failedName})不存在`,
listSuggestion(fields.map((item) => item.name)),
);
return defaultMessage(options.message, '值不在可选项中', dataSourceIdSuggestion());
}
const allowedTypes = options.dataSourceFieldType || ['any'];
@ -476,12 +471,11 @@ const isDataSourceFieldPathValue = (value: any, config: any): value is string[]
return `${value[0]}`.startsWith(DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX);
};
const validateDataSourceFieldSelect: TypeMatchValidator = (value, { mForm, message, props }) => {
const validateDataSourceFieldSelect: TypeMatchValidator = (value, { message, props }) => {
const config = props.config || {};
if (config.fieldConfig && !isDataSourceFieldPathValue(value, config)) {
// 值不是数据源字段路径时,按 fieldConfig 的类型校验(与表单项自身 typeMatch 行为一致)
return validateTypeMatch(value, mForm, { ...props, config: { name: config.name, ...config.fieldConfig } }, message);
return undefined;
}
if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {

View File

@ -4,27 +4,10 @@
* Copyright (C) 2025 Tencent.
*/
import { afterEach, describe, expect, test, vi } from 'vitest';
import { nextTick } from 'vue';
import { mount } from '@vue/test-utils';
import ContentMenu from '@editor/components/ContentMenu.vue';
const roCallbacks: Array<() => void> = [];
class FakeResizeObserver {
public static disconnectSpy = vi.fn();
public cb: () => void;
constructor(cb: () => void) {
this.cb = cb;
roCallbacks.push(cb);
}
public observe() {}
public unobserve() {}
public disconnect() {
FakeResizeObserver.disconnectSpy();
}
}
(globalThis as any).ResizeObserver = FakeResizeObserver;
const provideServices = () => ({
global: {
provide: {
@ -39,8 +22,6 @@ const provideServices = () => ({
describe('ContentMenu.vue', () => {
afterEach(() => {
vi.useRealTimers();
roCallbacks.length = 0;
FakeResizeObserver.disconnectSpy.mockClear();
});
test('show 后触发 show 事件', async () => {
@ -86,62 +67,6 @@ describe('ContentMenu.vue', () => {
expect((wrapper.vm as any).menuPosition.top).toBeLessThanOrEqual(100);
});
test('菜单大小动态变化后修正位置避免超出可视范围', async () => {
// jsdom 中 clientWidth/clientHeight 默认为 0先 mock 一个足够大的视口,避免 show 时位置被修正
Object.defineProperty(document.body, 'clientHeight', { value: 1000, configurable: true });
Object.defineProperty(document.body, 'clientWidth', { value: 1000, configurable: true });
const wrapper = mount(ContentMenu as any, {
...provideServices(),
props: { menuData: [{ id: '1', type: 'button', text: 'a' }] as any },
attachTo: document.body,
});
(wrapper.vm as any).show({ clientX: 90, clientY: 90 });
await new Promise((r) => setTimeout(r, 0));
expect((wrapper.vm as any).menuPosition.top).toBe(90);
expect((wrapper.vm as any).menuPosition.left).toBe(90);
// 视口缩小为 100x100
Object.defineProperty(document.body, 'clientHeight', { value: 100, configurable: true });
Object.defineProperty(document.body, 'clientWidth', { value: 100, configurable: true });
const menuEl = wrapper.find('.magic-editor-content-menu').element as HTMLElement;
// 模拟菜单内容增多后尺寸变为 50x50
Object.defineProperty(menuEl, 'clientHeight', { value: 50, configurable: true });
Object.defineProperty(menuEl, 'clientWidth', { value: 50, configurable: true });
roCallbacks.forEach((cb) => cb());
await nextTick();
expect((wrapper.vm as any).menuPosition.top).toBe(50);
expect((wrapper.vm as any).menuPosition.left).toBe(50);
wrapper.unmount();
});
test('菜单未显示时尺寸变化不修正位置', async () => {
const wrapper = mount(ContentMenu as any, {
...provideServices(),
props: { menuData: [{ id: '1', type: 'button', text: 'a' }] as any },
});
Object.defineProperty(document.body, 'clientHeight', { value: 100, configurable: true });
roCallbacks.forEach((cb) => cb());
await nextTick();
expect((wrapper.vm as any).menuPosition.top).toBe(0);
expect((wrapper.vm as any).menuPosition.left).toBe(0);
wrapper.unmount();
});
test('卸载时断开 ResizeObserver', () => {
const wrapper = mount(ContentMenu as any, {
...provideServices(),
props: { menuData: [] as any },
});
wrapper.unmount();
expect(FakeResizeObserver.disconnectSpy).toHaveBeenCalled();
});
test('contains 判断 DOM 是否在菜单内部', async () => {
const wrapper = mount(ContentMenu as any, {
...provideServices(),

View File

@ -21,7 +21,6 @@ describe('defaultEditorProps', () => {
test('containerHighlight 默认值', () => {
expect(defaultEditorProps.containerHighlightDuration).toBe(800);
expect(typeof defaultEditorProps.containerHighlightClassName).toBe('string');
expect(defaultEditorProps.containerHighlightAddOnly).toBe(false);
});
test('数组/对象工厂函数返回空值', () => {

View File

@ -7,8 +7,6 @@ import { beforeEach, describe, expect, test, vi } from 'vitest';
import { defineComponent, h } from 'vue';
import { mount } from '@vue/test-utils';
import { FORM_SILENT_MODE_KEY } from '@tmagic/form';
import Code from '@editor/fields/Code.vue';
// 用一个简单的桩组件代替 MagicCodeEditor把所有 props 原样渲染到 data-* 属性上,
@ -47,7 +45,7 @@ vi.mock('@editor/layouts/CodeEditor.vue', () => ({
}),
}));
const mountCode = (props: Record<string, any>, global?: Record<string, any>) =>
const mountCode = (props: Record<string, any>) =>
mount(Code, {
props: {
// FieldProps 必填字段,用 as any 绕过测试中类型严格匹配
@ -57,7 +55,6 @@ const mountCode = (props: Record<string, any>, global?: Record<string, any>) =>
prop: 'codeField',
...props,
} as any,
global,
});
const getEl = (wrapper: ReturnType<typeof mountCode>) => wrapper.find('.fake-code-editor').element as HTMLElement;
@ -171,28 +168,6 @@ describe('Code', () => {
});
});
describe('静默模式', () => {
test('注入 FORM_SILENT_MODE_KEY=true 时不渲染 CodeEditor', () => {
const wrapper = mountCode(
{},
{
provide: { [FORM_SILENT_MODE_KEY as symbol]: true },
},
);
expect(wrapper.find('.fake-code-editor').exists()).toBe(false);
});
test('注入 FORM_SILENT_MODE_KEY=false 时正常渲染 CodeEditor', () => {
const wrapper = mountCode(
{},
{
provide: { [FORM_SILENT_MODE_KEY as symbol]: false },
},
);
expect(wrapper.find('.fake-code-editor').exists()).toBe(true);
});
});
describe('diff 模式 (对比)', () => {
test('isCompare=true 且有 lastValues 时切换为 diff 模式', () => {
const wrapper = mountCode({

View File

@ -24,6 +24,11 @@ vi.mock('@editor/hooks/use-services', () => ({
useServices: () => ({ codeBlockService, uiService }),
}));
vi.mock('@editor/type', async () => {
const actual = await vi.importActual<any>('@editor/type');
return { ...actual, SideItemKey: { CODE_BLOCK: 'code-block' } };
});
vi.mock('@tmagic/form', async (importOriginal) => {
const actual = await importOriginal<any>();
return {

View File

@ -115,6 +115,15 @@ vi.mock('@tmagic/design', async () => {
};
});
vi.mock('@tmagic/utils', async () => {
const actual = await vi.importActual<any>('@tmagic/utils');
return {
...actual,
getKeysArray: vi.fn((s: string) => s.split('.').filter(Boolean)),
isNumber: (v: any) => /^\d+$/.test(String(v)),
};
});
beforeEach(() => {
vi.clearAllMocks();
inputRef.input = null;

View File

@ -33,6 +33,11 @@ vi.mock('@editor/utils', async () => {
return { ...actual, getFieldType: vi.fn(() => 'string') };
});
vi.mock('@editor/type', async () => {
const actual = await vi.importActual<any>('@editor/type');
return { ...actual, SideItemKey: { DATA_SOURCE: 'data-source' } };
});
vi.mock('@tmagic/utils', async () => {
const actual = await vi.importActual<any>('@tmagic/utils');
return { ...actual, DATA_SOURCE_SET_DATA_METHOD_NAME: '__set_data__' };

View File

@ -21,6 +21,11 @@ vi.mock('@editor/hooks/use-services', () => ({
useServices: () => ({ dataSourceService, uiService }),
}));
vi.mock('@editor/type', async () => {
const actual = await vi.importActual<any>('@editor/type');
return { ...actual, SideItemKey: { DATA_SOURCE: 'data-source' } };
});
vi.mock('@tmagic/form', async (importOriginal) => {
const actual = await importOriginal<any>();
return {

View File

@ -96,6 +96,10 @@ vi.mock('@tmagic/utils', async () => {
return {
...actual,
DATA_SOURCE_FIELDS_CHANGE_EVENT_PREFIX: 'ds_change_',
traverseNode: (node: any, fn: any) => {
fn(node);
node.items?.forEach((c: any) => fn(c));
},
};
});

View File

@ -44,6 +44,11 @@ vi.mock('@editor/components/Icon.vue', () => ({
}),
}));
vi.mock('@tmagic/core', async () => {
const actual = await vi.importActual<any>('@tmagic/core');
return { ...actual, NodeType: { PAGE: 'page', PAGE_FRAGMENT: 'page-fragment' } };
});
describe('PageFragmentSelect', () => {
test('model[name] 不为空时显示编辑图标', () => {
editorService.get.mockReturnValue({ items: [{ id: 'p1', type: 'page-fragment', name: 'A' }] });

View File

@ -27,6 +27,11 @@ vi.mock('@editor/hooks/use-services', () => ({
useServices: () => ({ editorService, uiService, stageOverlayService }),
}));
vi.mock('@tmagic/utils', async () => {
const actual = await vi.importActual<any>('@tmagic/utils');
return { ...actual, getIdFromEl: () => (el: any) => el?.dataset?.tmagicId };
});
vi.mock('@tmagic/design', () => ({
TMagicButton: defineComponent({
name: 'TMagicButton',

View File

@ -91,7 +91,7 @@ const mkServices = () => {
clear: vi.fn(),
clearTargets: vi.fn(),
clearIdleTasks: vi.fn(),
collectIdle: vi.fn(async () => true),
collectIdle: vi.fn(async () => undefined),
collectByWorker: vi.fn(async () => undefined),
reset: vi.fn(),
};
@ -144,6 +144,8 @@ vi.mock('@tmagic/utils', async () => {
...actual,
getDepNodeIds: vi.fn(() => []),
getNodes: vi.fn(() => []),
isPage: vi.fn((n: any) => n?.type === 'page'),
isValueIncludeDataSource: vi.fn((v: any) => /\$\{/.test(String(v))),
};
});

View File

@ -8,6 +8,14 @@ import { mount } from '@vue/test-utils';
import AddPageBox from '@editor/layouts/AddPageBox.vue';
vi.mock('@tmagic/core', async () => {
const actual = await vi.importActual<any>('@tmagic/core');
return {
...actual,
NodeType: { PAGE: 'page', PAGE_FRAGMENT: 'page-fragment' },
};
});
const editorService = {
get: vi.fn((key: string) => (key === 'root' ? { items: [] } : undefined)),
add: vi.fn(),

View File

@ -48,6 +48,11 @@ vi.mock('@editor/components/ToolButton.vue', () => ({
}),
}));
vi.mock('@editor/type', async () => {
const actual = await vi.importActual<any>('@editor/type');
return { ...actual, ColumnLayout: { LEFT: 'left', CENTER: 'center', RIGHT: 'right' } };
});
class FakeResizeObserver {
cb: any;
constructor(cb: any) {

View File

@ -95,7 +95,7 @@ vi.mock('@editor/layouts/props-panel/FormPanel.vue', () => ({
// 模拟 CodeEditor 源码保存:仅传 values无 eventData、无 error对应 saveCode 路径)
h('button', {
class: 'code-save-btn',
onClick: () => emit('submit', { id: 'n1', style: { color: 'red', width: '' } }),
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')) }),
@ -239,16 +239,6 @@ describe('PropsPanel', () => {
expect(options.historySource).toBe('code');
});
test('CodeEditor 源码保存时 style 中的空字符串值被保留(表示清除该样式)', async () => {
const wrapper = mount(PropsPanel, { props: {} as any });
await new Promise((r) => setTimeout(r, 0));
await wrapper.find('.code-save-btn').trigger('click');
const calledNode = (editorService.update.mock.calls[0] as any)[0];
expect(calledNode.style.color).toBe('red');
expect(calledNode.style.width).toBe('');
});
test('mounted 事件 emit', async () => {
const wrapper = mount(PropsPanel, { props: {} as any });
await wrapper.find('.mounted-btn').trigger('click');

View File

@ -135,6 +135,6 @@ describe('ComponentListPanel', () => {
const item = wrapper.find('.component-item');
await item.trigger('drag', { clientX: 0, clientY: 0 });
await item.trigger('drag', { clientX: 0, clientY: 0 });
expect(stage.delayedMarkContainer).toHaveBeenCalledWith(expect.anything(), [], true);
expect(stage.delayedMarkContainer).toHaveBeenCalled();
});
});

View File

@ -82,6 +82,20 @@ vi.mock('@editor/components/FloatingBox.vue', () => ({
}),
}));
vi.mock('@editor/type', async () => {
const actual = await vi.importActual<any>('@editor/type');
return {
...actual,
SideItemKey: {
COMPONENT_LIST: 'component-list',
LAYER: 'layer',
CODE_BLOCK: 'code-block',
DATA_SOURCE: 'data-source',
},
ColumnLayout: { LEFT: 'left', CENTER: 'center', RIGHT: 'right' },
};
});
beforeEach(() => {
vi.clearAllMocks();
propsService.getDisabledDataSource.mockReturnValue(false);

View File

@ -35,6 +35,11 @@ vi.mock('@editor/hooks/use-filter', () => ({
useFilter: () => ({ filterTextChangeHandler: vi.fn() }),
}));
vi.mock('@tmagic/core', async () => {
const actual = await vi.importActual<any>('@tmagic/core');
return { ...actual, DepTargetType: { CODE_BLOCK: 'code-block' } };
});
const { messageBoxConfirm, messageError } = vi.hoisted(() => ({
messageBoxConfirm: vi.fn(async () => 'confirm'),
messageError: vi.fn(),

View File

@ -32,6 +32,18 @@ vi.mock('@editor/hooks/use-filter', () => ({
useFilter: () => ({ filterTextChangeHandler: vi.fn() }),
}));
vi.mock('@tmagic/core', async () => {
const actual = await vi.importActual<any>('@tmagic/core');
return {
...actual,
DepTargetType: {
DATA_SOURCE: 'data-source',
DATA_SOURCE_METHOD: 'data-source-method',
DATA_SOURCE_COND: 'data-source-cond',
},
};
});
vi.mock('@editor/components/Tree.vue', () => ({
default: defineComponent({
name: 'TreeStub',

View File

@ -28,6 +28,15 @@ vi.mock('@editor/utils/content-menu', () => ({
useMoveToMenu: () => ({ type: 'button', text: 'moveto' }),
}));
vi.mock('@tmagic/utils', async () => {
const actual = await vi.importActual<any>('@tmagic/utils');
return {
...actual,
isPage: (n: any) => n?.type === 'page',
isPageFragment: (n: any) => n?.type === 'page-fragment',
};
});
const showMock = vi.fn();
vi.mock('@editor/components/ContentMenu.vue', () => ({
default: defineComponent({

View File

@ -17,6 +17,8 @@ vi.mock('@tmagic/utils', async () => {
const actual = await vi.importActual<any>('@tmagic/utils');
return {
...actual,
isPage: (n: any) => n.type === 'page',
isPageFragment: (n: any) => n.type === 'page-fragment',
getElById: () => (_doc: any, id: any) => (id === 'no-el' ? null : { id }),
};
});

View File

@ -15,6 +15,17 @@ vi.mock('@editor/utils', async () => {
};
});
vi.mock('@tmagic/utils', async () => {
const actual = await vi.importActual<any>('@tmagic/utils');
return {
...actual,
addClassName: (el: any, _doc: any, className: string) => el?.classList?.add(className),
removeClassName: (el: any, ...classNames: string[]) => {
classNames.forEach((c) => el?.classList?.remove(c));
},
};
});
const makeEditorService = () => ({
getNodeInfo: vi.fn(),
get: vi.fn(() => []),

View File

@ -12,7 +12,13 @@ vi.mock('@tmagic/utils', async () => {
const actual = await vi.importActual<any>('@tmagic/utils');
return {
...actual,
isPage: (n: any) => n.type === 'page',
isPageFragment: (n: any) => n.type === 'page-fragment',
getNodePath: vi.fn(() => []),
traverseNode: (node: any, fn: any) => {
fn(node);
node.items?.forEach((c: any) => fn(c));
},
};
});

View File

@ -24,6 +24,11 @@ vi.mock('@editor/hooks/use-services', () => ({
useServices: () => ({ editorService }),
}));
vi.mock('@tmagic/utils', () => ({
isPage: (n: any) => n?.type === 'page',
isPageFragment: (n: any) => n?.type === 'page-fragment',
}));
vi.mock('@editor/utils/content-menu', () => ({
useCopyMenu: () => ({ type: 'button', text: '复制', handler: vi.fn() }),
usePasteMenu: () => ({ type: 'button', text: '粘贴', handler: vi.fn() }),

View File

@ -4,57 +4,26 @@
* Copyright (C) 2025 Tencent.
*/
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { reactive } from 'vue';
import { createDataSourceTarget, DepTargetType, Target } from '@tmagic/core';
import { DepTargetType, Target } from '@tmagic/core';
import depService from '@editor/services/dep';
// 全量收集({ id, dsl })与增量收集({ id, payload })共用一个常驻 worker
vi.mock('@editor/utils/dep/worker.ts?worker&inline', () => ({
default: class FakeWorker {
public static instances: FakeWorker[] = [];
/** 全量收集返回的 deps */
public static nextData: Record<string, any> = {};
public static nextError = false;
public static nextDelay = 0;
/** 收到的全部请求(全量 + 增量),用于断言测例真正走到了 worker */
public static requests: any[] = [];
/** 增量收集返回的数据 */
public static response: any = { deps: {}, nodeIds: [] };
public static failed = false;
public static delay = 0;
public onmessage: ((e: any) => void) | null = null;
public onerror: (() => void) | null = null;
public onmessageerror: (() => void) | null = null;
constructor() {
FakeWorker.instances.push(this);
}
public postMessage(request: any) {
FakeWorker.requests.push(request);
if (!('dsl' in request)) {
setTimeout(() => {
this.onmessage?.({
data: { id: request.id, failed: FakeWorker.failed, ...FakeWorker.response },
});
}, FakeWorker.delay);
return;
}
public postMessage() {
setTimeout(() => {
if (FakeWorker.nextError) {
this.onerror?.();
this.onerror?.(new Event('error'));
return;
}
this.onmessage?.({ data: { id: request.id, deps: FakeWorker.nextData } });
}, FakeWorker.nextDelay);
this.onmessage?.({ data: FakeWorker.nextData });
}, 0);
}
public terminate() {}
},
}));
@ -65,31 +34,11 @@ const makeTarget = (id = 't1', type: string = DepTargetType.DEFAULT) =>
isTarget: () => false,
});
const getFakeWorker = async () => (await import('@editor/utils/dep/worker.ts?worker&inline')).default as any;
/** 环境不支持 Worker 时会走主线程 / 返回空结果,这里显式打开 worker 路径并重置 mock 状态 */
const enableCollectWorker = async () => {
const fakeCollectWorker = await getFakeWorker();
fakeCollectWorker.instances = [];
fakeCollectWorker.requests = [];
fakeCollectWorker.response = { deps: {}, nodeIds: [] };
fakeCollectWorker.failed = false;
fakeCollectWorker.delay = 0;
fakeCollectWorker.nextData = {};
fakeCollectWorker.nextError = false;
fakeCollectWorker.nextDelay = 0;
(globalThis as any).Worker = class {};
return fakeCollectWorker;
};
beforeEach(async () => {
beforeEach(() => {
depService.reset();
// collectByWorker / collectIdle 共用常驻 CollectWorkerClienthappy-dom 默认没有 Worker
await enableCollectWorker();
});
afterEach(() => {
delete (globalThis as any).Worker;
vi.clearAllMocks();
});
@ -172,20 +121,17 @@ describe('Dep service', () => {
test('collectIdle - 没有命中时立即 resolve 并 emit collected', async () => {
const fn = vi.fn();
depService.on('collected', fn);
await expect(depService.collectIdle([{ id: 'n1', type: 'text' }] as any)).resolves.toBe(true);
await depService.collectIdle([{ id: 'n1', type: 'text' }] as any);
expect(fn).toHaveBeenCalled();
depService.off('collected', fn);
});
test('collectByWorker 完成后触发 collected 与 ds-collected', async () => {
const fakeWorker = await enableCollectWorker();
const fn = vi.fn();
const dsFn = vi.fn();
depService.on('collected', fn);
depService.on('ds-collected', dsFn);
await depService.collectByWorker({ items: [], id: 'app', type: 'app' } as any);
// 必须真正 postMessage 到 worker避免 isSupported=false 时假通过
expect(fakeWorker.requests.some((request: any) => 'dsl' in request)).toBe(true);
expect(fn).toHaveBeenCalled();
expect(dsFn).toHaveBeenCalled();
depService.off('collected', fn);
@ -236,19 +182,20 @@ describe('Dep service', () => {
});
test('collectByWorker worker 报错时返回空对象并完成 collected', async () => {
const fakeWorker = await enableCollectWorker();
const fakeWorker = (await import('@editor/utils/dep/worker.ts?worker&inline')).default as any;
fakeWorker.nextError = true;
fakeWorker.nextData = {};
const collected = vi.fn();
depService.on('collected', collected);
const result = await depService.collectByWorker({ items: [], id: 'app', type: 'app' } as any);
expect(fakeWorker.requests.some((request: any) => 'dsl' in request)).toBe(true);
expect(result).toEqual({});
expect(collected).toHaveBeenCalled();
fakeWorker.nextError = false;
depService.off('collected', collected);
});
test('collectByWorker 会把 worker 返回的 deps 写回 target 与 dsl', async () => {
const fakeWorker = await enableCollectWorker();
const fakeWorker = (await import('@editor/utils/dep/worker.ts?worker&inline')).default as any;
depService.addTarget(makeTarget('ds1', DepTargetType.DATA_SOURCE));
depService.addTarget(makeTarget('cond1', DepTargetType.DATA_SOURCE_COND));
depService.addTarget(makeTarget('method1', DepTargetType.DATA_SOURCE_METHOD));
@ -266,311 +213,12 @@ describe('Dep service', () => {
dataSourceMethodDeps: {},
};
await depService.collectByWorker(dsl);
expect(fakeWorker.requests.some((request: any) => 'dsl' in request)).toBe(true);
expect(dsl.dataSourceDeps.ds1).toBeDefined();
expect(dsl.dataSourceCondDeps.cond1).toBeDefined();
expect(dsl.dataSourceMethodDeps.method1).toBeDefined();
});
test('collectIdle 命中 target 时最终 resolve 并按批次 emit collected/ds-collected', async () => {
depService.addTarget(makeTarget('ds1', DepTargetType.DATA_SOURCE));
const collected = vi.fn();
const dsCollected = vi.fn();
depService.on('collected', collected);
depService.on('ds-collected', dsCollected);
const nodes = [{ id: 'n1', type: 'text' }] as any;
await expect(depService.collectIdle(nodes, {}, false, DepTargetType.DATA_SOURCE)).resolves.toBe(true);
expect(dsCollected).toHaveBeenCalledWith(nodes, false);
expect(collected).toHaveBeenCalledWith(nodes, false);
expect(depService.get('collecting')).toBe(false);
depService.off('collected', collected);
depService.off('ds-collected', dsCollected);
});
test('clearIdleTasks 会结算在途 collectIdle避免 Promise 永久挂起且 collecting 复位', async () => {
depService.addTarget(makeTarget('ds1', DepTargetType.DATA_SOURCE));
const promise = depService.collectIdle([{ id: 'n1', type: 'text' }] as any, {}, false, DepTargetType.DATA_SOURCE);
expect(depService.get('collecting')).toBe(true);
// 快速触发:任务尚未执行就清空队列,批次应被主动结算而不是永久挂起
depService.clearIdleTasks();
await expect(promise).resolves.toBe(false);
expect(depService.get('collecting')).toBe(false);
});
test('reset 会结算在途 collectIdle', async () => {
depService.addTarget(makeTarget('ds1', DepTargetType.DATA_SOURCE));
const promise = depService.collectIdle([{ id: 'n1', type: 'text' }] as any, {}, false, DepTargetType.DATA_SOURCE);
depService.reset();
await expect(promise).resolves.toBe(false);
expect(depService.get('collecting')).toBe(false);
});
test('reset 会忽略在途 worker 的过期结果,避免覆盖新依赖', async () => {
const fakeWorker = (await import('@editor/utils/dep/worker.ts?worker&inline')).default as any;
fakeWorker.nextDelay = 20;
fakeWorker.nextData = {
[DepTargetType.DATA_SOURCE]: { ds1: { n1: { data: {} } } },
};
const workerPromise = depService.collectByWorker({ items: [], id: 'app', type: 'app' } as any);
depService.reset();
const target = makeTarget('ds1', DepTargetType.DATA_SOURCE);
depService.addTarget(target);
const idlePromise = depService.collectIdle(
[{ id: 'n1', type: 'text' }] as any,
{},
false,
DepTargetType.DATA_SOURCE,
);
await Promise.all([workerPromise, idlePromise]);
expect(target.deps.n1).toBeUndefined();
fakeWorker.nextDelay = 0;
fakeWorker.nextData = {};
});
test('多个批次并发时各自独立 resolve全部完成后 collecting 复位', async () => {
depService.addTarget(makeTarget('ds1', DepTargetType.DATA_SOURCE));
const p1 = depService.collectIdle([{ id: 'n1', type: 'text' }] as any, {}, false, DepTargetType.DATA_SOURCE);
const p2 = depService.collectIdle([{ id: 'n2', type: 'text' }] as any, {}, false, DepTargetType.DATA_SOURCE);
await Promise.all([p1, p2]);
expect(depService.get('collecting')).toBe(false);
});
test('target 收集抛错时批次仍会结算collecting 与 taskLength 复位', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
depService.addTarget(
new Target({
id: 'throw-target',
type: DepTargetType.DATA_SOURCE,
isTarget: () => {
throw new Error('boom');
},
}),
);
const nodes = Array.from({ length: 50 }, (_, i) => ({ id: `n${i}`, type: 'text', text: 'x' })) as any;
await expect(depService.collectIdle(nodes, {}, false, DepTargetType.DATA_SOURCE)).resolves.toBe(true);
expect(depService.get('collecting')).toBe(false);
// taskLength 的更新做了 1s 节流,等节流窗口结束后才会同步到 0
await new Promise((resolve) => setTimeout(resolve, 1100));
expect(depService.get('taskLength')).toBe(0);
errorSpy.mockRestore();
});
test('collectIdle 优先用 worker 收集,主线程只把结果写回 target', async () => {
const fakeCollectWorker = await enableCollectWorker();
fakeCollectWorker.response = {
deps: { [DepTargetType.DATA_SOURCE]: { ds_1: { n1: { name: 'n1', keys: ['text'] } } } },
nodeIds: ['n1'],
};
const target = createDataSourceTarget({ id: 'ds_1', fields: [] }, reactive({}));
depService.addTarget(target);
// 旧依赖应被本次收集结果覆盖,而不是与之合并
target.deps.n1 = { name: 'n1', keys: ['stale'] };
const collected = vi.fn();
const dsCollected = vi.fn();
depService.on('collected', collected);
depService.on('ds-collected', dsCollected);
const nodes = [{ id: 'n1', type: 'text', text: '${ds_1.name}' }] as any;
await expect(depService.collectIdle(nodes, {}, true, DepTargetType.DATA_SOURCE)).resolves.toBe(true);
expect(fakeCollectWorker.requests).toHaveLength(1);
// isTarget 无法跨线程传递worker 中用 descriptor 重建 target
expect(fakeCollectWorker.requests[0].payload).toContain('ds_1');
expect(target.deps.n1.keys).toEqual(['text']);
expect(dsCollected).toHaveBeenCalledWith(nodes, true);
expect(collected).toHaveBeenCalledWith(nodes, true);
expect(depService.get('collecting')).toBe(false);
depService.off('collected', collected);
depService.off('ds-collected', dsCollected);
});
test('collectIdle 会删除 worker 收集范围内节点的旧依赖', async () => {
const fakeCollectWorker = await enableCollectWorker();
fakeCollectWorker.response = { deps: {}, nodeIds: ['n1', 'n1_1'] };
const target = createDataSourceTarget({ id: 'ds_1', fields: [] }, reactive({}));
depService.addTarget(target);
target.deps.n1 = { name: 'n1', keys: ['text'] };
target.deps.n1_1 = { name: 'n1_1', keys: ['text'] };
target.deps.other = { name: 'other', keys: ['text'] };
await depService.collectIdle([{ id: 'n1', type: 'text' }] as any, {}, true, DepTargetType.DATA_SOURCE);
expect(target.deps.n1).toBeUndefined();
expect(target.deps.n1_1).toBeUndefined();
// 不在本次收集范围内的节点依赖不受影响
expect(target.deps.other).toBeDefined();
});
test('collectIdle 对 page 节点按 pageId 清理旧依赖', async () => {
const fakeCollectWorker = await enableCollectWorker();
fakeCollectWorker.response = { deps: {}, nodeIds: ['p1'] };
const target = createDataSourceTarget({ id: 'ds_1', fields: [] }, reactive({}));
depService.addTarget(target);
// 已被删除的节点残留依赖,只能按 pageId 匹配清理
target.deps.removed = { name: 'removed', keys: ['text'], data: { pageId: 'p1' } };
target.deps.otherPage = { name: 'otherPage', keys: ['text'], data: { pageId: 'p2' } };
await depService.collectIdle(
[{ id: 'p1', type: 'page', items: [] }] as any,
{ pageId: 'p1' },
true,
DepTargetType.DATA_SOURCE,
);
expect(target.deps.removed).toBeUndefined();
expect(target.deps.otherPage).toBeDefined();
});
test('worker 收集失败时回退到主线程收集,结果一致', async () => {
const fakeCollectWorker = await enableCollectWorker();
fakeCollectWorker.failed = true;
const target = createDataSourceTarget({ id: 'ds_1', fields: [{ name: 'name', type: 'string' }] }, reactive({}));
depService.addTarget(target);
const nodes = [{ id: 'n1', type: 'text', text: '${ds_1.name}' }] as any;
await expect(depService.collectIdle(nodes, {}, true, DepTargetType.DATA_SOURCE)).resolves.toBe(true);
expect(target.deps.n1.keys).toEqual(['text']);
});
test('没有 descriptor 的自定义 target 仍在主线程收集', async () => {
const fakeCollectWorker = await enableCollectWorker();
const target = new Target({
id: 'custom',
type: DepTargetType.DEFAULT,
isTarget: (key) => key === 'text',
});
depService.addTarget(target);
await depService.collectIdle([{ id: 'n1', type: 'text', text: 'abc' }] as any);
expect(fakeCollectWorker.requests).toHaveLength(0);
expect(target.deps.n1.keys).toEqual(['text']);
});
test('批次被中断后不再写回 worker 结果', async () => {
const fakeCollectWorker = await enableCollectWorker();
fakeCollectWorker.delay = 20;
fakeCollectWorker.response = {
deps: { [DepTargetType.DATA_SOURCE]: { ds_1: { n1: { name: 'n1', keys: ['text'] } } } },
nodeIds: ['n1'],
};
const target = createDataSourceTarget({ id: 'ds_1', fields: [] }, reactive({}));
depService.addTarget(target);
const promise = depService.collectIdle([{ id: 'n1', type: 'text' }] as any, {}, true, DepTargetType.DATA_SOURCE);
depService.clearIdleTasks();
await expect(promise).resolves.toBe(false);
await new Promise((resolve) => setTimeout(resolve, 40));
expect(target.deps.n1).toBeUndefined();
expect(depService.get('collecting')).toBe(false);
});
test('collectIdle deep=false 不会清掉未参与重收的子孙依赖', async () => {
const fakeCollectWorker = await enableCollectWorker();
// worker 按 deep=false 只返回容器自身 id
fakeCollectWorker.response = { deps: {}, nodeIds: ['container'] };
const target = createDataSourceTarget({ id: 'ds_1', fields: [] }, reactive({}));
depService.addTarget(target);
target.deps.container = { name: 'container', keys: ['text'] };
target.deps.child = { name: 'child', keys: ['text'] };
await depService.collectIdle(
[{ id: 'container', type: 'container', items: [{ id: 'child', type: 'text', text: '${ds_1.name}' }] }] as any,
{},
false,
DepTargetType.DATA_SOURCE,
);
expect(target.deps.container).toBeUndefined();
// 子孙未重收,旧依赖必须保留
expect(target.deps.child).toBeDefined();
});
test('target 在 worker 收集期间被重建时丢弃过期结果并主线程补收', async () => {
const fakeCollectWorker = await enableCollectWorker();
fakeCollectWorker.response = {
deps: { [DepTargetType.DATA_SOURCE]: { ds_1: { n1: { name: 'n1', keys: ['stale'] } } } },
nodeIds: ['n1'],
};
depService.addTarget(
createDataSourceTarget({ id: 'ds_1', fields: [{ name: 'name', type: 'string' }] }, reactive({})),
);
const promise = depService.collectIdle(
[{ id: 'n1', type: 'text', text: '${ds_1.name}' }] as any,
{},
true,
DepTargetType.DATA_SOURCE,
);
// 模拟修改数据源字段target 被移除并重建
depService.removeTarget('ds_1', DepTargetType.DATA_SOURCE);
const newTarget = createDataSourceTarget({ id: 'ds_1', fields: [{ name: 'name', type: 'string' }] }, reactive({}));
depService.addTarget(newTarget);
await promise;
// 过期的 stale 结果未写入;对新 target 主线程补收得到正确依赖
expect(newTarget.deps.n1.keys).toEqual(['text']);
expect(depService.get('collecting')).toBe(false);
});
test('clearIdleTasks 会 abort 常驻 worker避免在途任务继续占用', async () => {
const fakeCollectWorker = await enableCollectWorker();
fakeCollectWorker.delay = 50;
depService.addTarget(createDataSourceTarget({ id: 'ds_1', fields: [] }, reactive({})));
const promise = depService.collectIdle([{ id: 'n1', type: 'text' }] as any, {}, true, DepTargetType.DATA_SOURCE);
expect(fakeCollectWorker.instances).toHaveLength(1);
depService.clearIdleTasks();
await expect(promise).resolves.toBe(false);
// abort 后再次收集会新建 worker而不是排队在已 abort 的旧任务后面
fakeCollectWorker.delay = 0;
await depService.collectIdle([{ id: 'n2', type: 'text' }] as any, {}, true, DepTargetType.DATA_SOURCE);
expect(fakeCollectWorker.instances.length).toBeGreaterThan(1);
});
test('collectIdle 节点为空时不会启动 worker', async () => {
const fakeCollectWorker = await enableCollectWorker();
depService.addTarget(createDataSourceTarget({ id: 'ds_1', fields: [] }, reactive({})));
await expect(depService.collectIdle([], {}, true, DepTargetType.DATA_SOURCE)).resolves.toBe(true);
expect(fakeCollectWorker.requests).toHaveLength(0);
});
test('destroy 会 reset 并移除监听', () => {
depService.addTarget(makeTarget('destroy-me'));
expect(() => depService.destroy()).not.toThrow();

View File

@ -16,7 +16,7 @@
* limitations under the License.
*/
import { afterEach, beforeAll, beforeEach, 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';
@ -552,153 +552,6 @@ describe('remove', () => {
// 自动切到剩余首个页面
expect(editorService.get('page')?.id).toBe(addedId);
});
test('默认删除当前页面并自动切页时,不会先把 page 置为 null', async () => {
editorService.set('root', cloneDeep(root));
const rootNode = editorService.get('root');
await editorService.select(NodeId.PAGE_ID);
const newPage = await editorService.add({ type: NodeType.PAGE }, rootNode);
const addedId = Array.isArray(newPage) ? newPage[0].id : newPage.id;
await editorService.select(NodeId.PAGE_ID);
const pageValues: Array<MNode | null> = [];
const originalSet = editorService.set.bind(editorService);
const setSpy = vi.spyOn(editorService, 'set').mockImplementation((name, value, options) => {
if (name === 'page') {
pageValues.push(value as MNode | null);
}
return originalSet(name, value, options);
});
try {
await editorService.remove({ id: NodeId.PAGE_ID, type: NodeType.PAGE });
} finally {
setSpy.mockRestore();
}
expect(pageValues.some((page) => page === null)).toBe(false);
expect(editorService.get('page')?.id).toBe(addedId);
});
test('删除非当前页面时保持当前页选中,不强制切到首个页面', async () => {
editorService.set('root', cloneDeep(root));
const rootNode = editorService.get('root');
await editorService.select(NodeId.PAGE_ID);
const newPage = await editorService.add({ type: NodeType.PAGE }, rootNode);
const addedId = Array.isArray(newPage) ? newPage[0].id : newPage.id;
await editorService.select(NodeId.PAGE_ID);
expect(editorService.get('page')?.id).toBe(NodeId.PAGE_ID);
await editorService.remove({ id: addedId, type: NodeType.PAGE });
expect(editorService.getNodeById(addedId)).toBeNull();
expect(editorService.get('page')?.id).toBe(NodeId.PAGE_ID);
});
test('删除最后一个页面后退回选中 root', async () => {
editorService.set('root', cloneDeep(root));
const rootNode = editorService.get('root');
await editorService.select(NodeId.PAGE_ID);
expect(rootNode?.items.length).toBe(1);
await editorService.remove({ id: NodeId.PAGE_ID, type: NodeType.PAGE });
expect(rootNode?.items.length).toBe(0);
expect(editorService.get('page')).toBeNull();
// root 下已无页面,编辑器应回到「选中 root」状态而不是什么都没选中
expect(editorService.get('nodes')).toEqual([rootNode]);
expect(editorService.get('parent')).toBeNull();
});
describe('与画布的调用顺序', () => {
const createStageStub = () => {
const calls: string[] = [];
return {
calls,
stage: {
remove: vi.fn(() => {
calls.push('remove');
return Promise.resolve();
}),
select: vi.fn(() => {
calls.push('select');
return Promise.resolve();
}),
},
};
};
afterEach(() => {
editorService.set('stage', null);
});
test('删除当前页面时先把画布切到剩余页面,再通知画布删除', async () => {
editorService.set('root', cloneDeep(root));
const rootNode = editorService.get('root');
await editorService.select(NodeId.PAGE_ID);
const newPage = await editorService.add({ type: NodeType.PAGE }, rootNode);
const addedId = Array.isArray(newPage) ? newPage[0].id : newPage.id;
await editorService.select(NodeId.PAGE_ID);
const { calls, stage } = createStageStub();
editorService.set('stage', stage as any);
await editorService.remove({ id: NodeId.PAGE_ID, type: NodeType.PAGE });
// runtime 删除 page 时会销毁当前渲染的实例,必须切页在前,否则画布会被清空
expect(calls).toEqual(['select', 'remove']);
expect(stage.select).toHaveBeenCalledWith(addedId);
expect(editorService.get('page')?.id).toBe(addedId);
});
test('删除当前页面片时同样先切页再通知画布删除', async () => {
editorService.set('root', cloneDeep(root));
const rootNode = editorService.get('root');
await editorService.select(NodeId.PAGE_ID);
const fragment = await editorService.add({ type: NodeType.PAGE_FRAGMENT }, rootNode);
const fragmentId = Array.isArray(fragment) ? fragment[0].id : fragment.id;
// 新增页面片后当前页已切到页面片
expect(editorService.get('page')?.id).toBe(fragmentId);
const { calls, stage } = createStageStub();
editorService.set('stage', stage as any);
await editorService.remove({ id: fragmentId, type: NodeType.PAGE_FRAGMENT });
expect(calls).toEqual(['select', 'remove']);
expect(editorService.get('page')?.id).toBe(NodeId.PAGE_ID);
});
test('删除普通节点时先通知画布删除,再选中父节点', async () => {
editorService.set('root', cloneDeep(root));
await editorService.select(NodeId.NODE_ID);
const { calls, stage } = createStageStub();
editorService.set('stage', stage as any);
await editorService.remove({ id: NodeId.NODE_ID, type: 'text' });
expect(calls).toEqual(['remove', 'select']);
expect(stage.select).toHaveBeenCalledWith(NodeId.PAGE_ID);
});
test('删除最后一个页面时在 Stage 卸载前发出删除通知', async () => {
editorService.set('root', cloneDeep(root));
await editorService.select(NodeId.PAGE_ID);
const { calls, stage } = createStageStub();
editorService.set('stage', stage as any);
await editorService.remove({ id: NodeId.PAGE_ID, type: NodeType.PAGE });
// page 置空会让 Workspace 卸载 Stage删除通知必须在这之前发出
expect(calls).toEqual(['remove']);
// selectRoot 会顺带清掉 stage 引用
expect(editorService.get('stage')).toBeNull();
});
});
});
describe('update', () => {

View File

@ -105,8 +105,8 @@ describe('data-source utils', () => {
fields: [],
});
expect(resolveFieldByPath(fields, ['obj']).field?.name).toBe('obj');
expect(resolveFieldByPath(fields, ['unknown'])).toEqual({ ok: false, fields, failedName: 'unknown' });
expect(resolveFieldByPath(undefined, ['x'])).toEqual({ ok: false, fields: [], failedName: 'x' });
expect(resolveFieldByPath(fields, ['unknown'])).toEqual({ ok: false, fields });
expect(resolveFieldByPath(undefined, ['x'])).toEqual({ ok: false, fields: [] });
expect(resolveFieldByPath(fields, []).ok).toBe(true);
});
@ -124,9 +124,7 @@ describe('data-source utils', () => {
expect(result.field?.name).toBe('item');
expect(result.fields).toEqual([{ name: 'n', type: 'string' }]);
const failed = resolveFieldByPath(fields, ['arr', '0', 'missing'], { skipNumberIndices: true });
expect(failed.ok).toBe(false);
expect(failed.failedName).toBe('missing');
expect(resolveFieldByPath(fields, ['arr', '0', 'missing'], { skipNumberIndices: true }).ok).toBe(false);
});
test('getFieldType 沿 path 取最终类型', () => {

View File

@ -1,257 +0,0 @@
/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2025 Tencent.
*/
import { afterAll, beforeEach, describe, expect, test, vi } from 'vitest';
import { DepTargetType } from '@tmagic/core';
import { CollectWorkerClient } from '@editor/utils/dep/collect-worker-client';
vi.mock('@editor/utils/dep/worker.ts?worker&inline', () => ({
default: class FakeCollectWorker {
public static instances: FakeCollectWorker[] = [];
/** 收到的请求 */
public static requests: any[] = [];
public static response: any = { deps: {}, nodeIds: [] };
/** worker 内部执行失败 */
public static failed = false;
/** worker 整体异常 */
public static fatal = false;
/** 构造时抛错 */
public static throwOnCreate = false;
/** postMessage 抛错,如结构化克隆失败 */
public static throwOnPost = false;
public onmessage: ((e: any) => void) | null = null;
public onerror: (() => void) | null = null;
public onmessageerror: (() => void) | null = null;
public terminated = false;
constructor() {
if (FakeCollectWorker.throwOnCreate) {
throw new Error('create failed');
}
FakeCollectWorker.instances.push(this);
}
public postMessage(request: any) {
if (FakeCollectWorker.throwOnPost) {
throw new Error('post failed');
}
FakeCollectWorker.requests.push(request);
setTimeout(() => {
if (FakeCollectWorker.fatal) {
this.onerror?.();
return;
}
this.onmessage?.({
data: { id: request.id, failed: FakeCollectWorker.failed, ...FakeCollectWorker.response },
});
});
}
public terminate() {
this.terminated = true;
}
},
}));
const getFakeWorker = async () => (await import('@editor/utils/dep/worker.ts?worker&inline')).default as any;
const payload = {
nodes: [{ id: 'text_1', type: 'text' }] as any,
targets: [{ type: DepTargetType.DATA_SOURCE, ds: { id: 'ds_1', fields: [] } }] as any,
depExtendedData: {},
deep: false,
};
const dsl = { id: 'app', type: 'app', items: [{ id: 'text_1', type: 'text' }] } as any;
beforeEach(async () => {
const fakeCollectWorker = await getFakeWorker();
fakeCollectWorker.instances = [];
fakeCollectWorker.requests = [];
fakeCollectWorker.response = { deps: {}, nodeIds: [] };
fakeCollectWorker.failed = false;
fakeCollectWorker.fatal = false;
fakeCollectWorker.throwOnCreate = false;
fakeCollectWorker.throwOnPost = false;
(globalThis as any).Worker = class {};
});
afterAll(() => {
delete (globalThis as any).Worker;
});
describe('dep/collect-worker-client', () => {
test('环境不支持 Worker 时返回 null由调用方回退主线程收集', async () => {
delete (globalThis as any).Worker;
const client = new CollectWorkerClient();
expect(client.isSupported).toBe(false);
await expect(client.collect(payload)).resolves.toBeNull();
});
test('收集结果按请求 id 返回', async () => {
const fakeCollectWorker = await getFakeWorker();
fakeCollectWorker.response = {
deps: { [DepTargetType.DATA_SOURCE]: { ds_1: { text_1: { name: 'text', keys: ['text'] } } } },
nodeIds: ['text_1'],
};
const client = new CollectWorkerClient();
const result = await client.collect(payload);
expect(result?.nodeIds).toEqual(['text_1']);
expect(result?.deps[DepTargetType.DATA_SOURCE].ds_1).toEqual({ text_1: { name: 'text', keys: ['text'] } });
// 节点配置可能存在无法结构化克隆的值,需要先序列化
expect(typeof fakeCollectWorker.requests[0].payload).toBe('string');
});
test('collectDsl 全量收集返回 deps', async () => {
const fakeCollectWorker = await getFakeWorker();
fakeCollectWorker.response = {
deps: { [DepTargetType.DATA_SOURCE]: { ds_1: { text_1: { name: 'text', keys: ['text'] } } } },
};
const client = new CollectWorkerClient();
const deps = await client.collectDsl(dsl);
expect(deps?.[DepTargetType.DATA_SOURCE].ds_1).toEqual({ text_1: { name: 'text', keys: ['text'] } });
expect(typeof fakeCollectWorker.requests[0].dsl).toBe('string');
});
test('collectDsl 执行失败时返回 null', async () => {
const fakeCollectWorker = await getFakeWorker();
fakeCollectWorker.failed = true;
const client = new CollectWorkerClient();
await expect(client.collectDsl(dsl)).resolves.toBeNull();
});
test('全量与增量收集共用一个常驻 worker各请求按 id 对应结果', async () => {
const fakeCollectWorker = await getFakeWorker();
const client = new CollectWorkerClient();
await Promise.all([client.collectDsl(dsl), client.collect(payload), client.collect(payload)]);
expect(fakeCollectWorker.instances).toHaveLength(1);
expect(fakeCollectWorker.requests.map((request: any) => request.id)).toEqual([1, 2, 3]);
expect(fakeCollectWorker.requests.map((request: any) => 'dsl' in request)).toEqual([true, false, false]);
});
test('worker 执行失败时返回 null', async () => {
const fakeCollectWorker = await getFakeWorker();
fakeCollectWorker.failed = true;
const client = new CollectWorkerClient();
await expect(client.collect(payload)).resolves.toBeNull();
});
test('worker 整体异常时结算所有在途请求并重建 worker', async () => {
const fakeCollectWorker = await getFakeWorker();
fakeCollectWorker.fatal = true;
const client = new CollectWorkerClient();
const results = await Promise.all([client.collect(payload), client.collect(payload)]);
expect(results).toEqual([null, null]);
expect(fakeCollectWorker.instances[0].terminated).toBe(true);
// 异常后重建 worker后续收集仍可用
fakeCollectWorker.fatal = false;
await expect(client.collect(payload)).resolves.not.toBeNull();
expect(fakeCollectWorker.instances).toHaveLength(2);
});
test('onmessageerror 同样结算在途请求', async () => {
const fakeCollectWorker = await getFakeWorker();
const client = new CollectWorkerClient();
const promise = client.collect(payload);
fakeCollectWorker.instances[0].onmessageerror?.();
await expect(promise).resolves.toBeNull();
});
test('worker 创建失败时返回 null', async () => {
const fakeCollectWorker = await getFakeWorker();
fakeCollectWorker.throwOnCreate = true;
const client = new CollectWorkerClient();
await expect(client.collect(payload)).resolves.toBeNull();
});
test('postMessage 抛错时返回 null', async () => {
const fakeCollectWorker = await getFakeWorker();
fakeCollectWorker.throwOnPost = true;
const client = new CollectWorkerClient();
await expect(client.collect(payload)).resolves.toBeNull();
});
test('响应缺少字段时按空结果处理', async () => {
const fakeCollectWorker = await getFakeWorker();
const client = new CollectWorkerClient();
const promise = client.collect(payload);
fakeCollectWorker.instances[0].onmessage?.({ data: { id: 1 } });
await expect(promise).resolves.toEqual({ deps: {}, nodeIds: [] });
});
test('非法响应被忽略', async () => {
const fakeCollectWorker = await getFakeWorker();
const client = new CollectWorkerClient();
const promise = client.collect(payload);
expect(() => fakeCollectWorker.instances[0].onmessage?.({ data: undefined })).not.toThrow();
await expect(promise).resolves.not.toBeNull();
});
test('未知请求 id 的响应被忽略', async () => {
const fakeCollectWorker = await getFakeWorker();
const client = new CollectWorkerClient();
const promise = client.collect(payload);
fakeCollectWorker.instances[0].onmessage?.({ data: { id: 999, deps: {}, nodeIds: [] } });
await expect(promise).resolves.not.toBeNull();
});
test('terminate 结算在途请求并销毁 worker', async () => {
const fakeCollectWorker = await getFakeWorker();
const client = new CollectWorkerClient();
const promise = client.collect(payload);
client.terminate();
await expect(promise).resolves.toBeNull();
expect(fakeCollectWorker.instances[0].terminated).toBe(true);
});
test('abort 与 terminate 一样丢弃在途请求,后续收集会重建 worker', async () => {
const fakeCollectWorker = await getFakeWorker();
const client = new CollectWorkerClient();
const promise = client.collect(payload);
client.abort();
await expect(promise).resolves.toBeNull();
expect(fakeCollectWorker.instances[0].terminated).toBe(true);
await expect(client.collect(payload)).resolves.not.toBeNull();
expect(fakeCollectWorker.instances).toHaveLength(2);
});
});

View File

@ -6,7 +6,6 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { IdleTask } from '@editor/utils/dep/idle-task';
import * as logger from '@editor/utils/logger';
const fakeIdleDeadline = (timeRemaining: number, callsBeforeZero = 1): IdleDeadline => {
let remainingCalls = callsBeforeZero;
@ -128,77 +127,6 @@ describe('IdleTask', () => {
expect(scheduled.length).toBeGreaterThan(1);
});
test('单个任务抛错不会中断整个队列', () => {
const errorSpy = vi.spyOn(logger, 'error').mockImplementation(() => undefined);
const task = new IdleTask<number>();
const done: number[] = [];
const finishHandler = vi.fn();
task.on('finish', finishHandler);
for (let i = 0; i < 10; i++) {
task.enqueueTask((n) => {
if (n === 3) throw new Error('boom');
done.push(n);
}, i);
}
expect(() => scheduled[0].cb(fakeIdleDeadline(50))).not.toThrow();
expect(done).toEqual([0, 1, 2, 4, 5, 6, 7, 8, 9]);
expect(finishHandler).toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalled();
errorSpy.mockRestore();
});
test('任务抛错且仍有剩余任务时继续调度下一轮并同步任务数', () => {
const errorSpy = vi.spyOn(logger, 'error').mockImplementation(() => undefined);
const task = new IdleTask<number>();
const updateHandler = vi.fn();
task.on('update-task-length', updateHandler);
for (let i = 0; i < 200; i++) {
task.enqueueTask((n) => {
if (n === 0) throw new Error('boom');
}, i);
}
// 单批最多执行 10 个,执行完仍有剩余任务
scheduled[0].cb(fakeIdleDeadline(3, 1));
expect(scheduled.length).toBeGreaterThan(1);
expect(updateHandler).toHaveBeenCalledWith({ length: 190, hightLevelLength: 0 });
errorSpy.mockRestore();
});
test('任务执行过程中 clearTasks 会立即停止消费已清空的队列', () => {
const task = new IdleTask<number>();
const handler = vi.fn((n: number) => {
if (n === 0) {
task.clearTasks();
}
});
for (let i = 0; i < 200; i++) task.enqueueTask(handler, i);
scheduled[0].cb(fakeIdleDeadline(50));
expect(handler).toHaveBeenCalledTimes(1);
});
test('任务执行过程中清空并重新入队,新任务仍会被执行', () => {
const task = new IdleTask<number>();
const afterHandler = vi.fn();
task.enqueueTask((n) => {
if (n === 0) {
task.clearTasks();
task.enqueueTask(afterHandler, 999);
}
}, 0);
for (let i = 1; i < 200; i++) task.enqueueTask(() => undefined, i);
scheduled[0].cb(fakeIdleDeadline(50));
expect(afterHandler).toHaveBeenCalled();
});
test('clearTasks - 取消挂起任务并重置队列', () => {
const task = new IdleTask<number>();
task.enqueueTask(() => undefined, 1);

View File

@ -4,7 +4,6 @@
* Copyright (C) 2025 Tencent.
*/
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import serialize from 'serialize-javascript';
import { DepTargetType } from '@tmagic/core';
@ -27,18 +26,6 @@ afterEach(() => {
const loadWorker = () => import('@editor/utils/dep/worker');
/** 全量收集worker 中根据 dsl 重建 target */
const postDslRequest = (dsl: string, id = 1) => {
(globalThis as any).onmessage({ data: { id, dsl } });
return postedMessages[0];
};
/** 增量收集target 由主线程以可序列化描述传入 */
const postCollectRequest = (payload: any, id = 1) => {
(globalThis as any).onmessage({ data: { id, payload: typeof payload === 'string' ? payload : serialize(payload) } });
return postedMessages[0];
};
describe('dep/worker', () => {
test('注册 onmessage 处理器', async () => {
await loadWorker();
@ -47,138 +34,36 @@ describe('dep/worker', () => {
test('正常 dsl - 收集 codeBlocks/dataSources/items 并 postMessage', async () => {
await loadWorker();
const response = postDslRequest(
JSON.stringify({
id: 'app',
type: 'app',
codeBlocks: { cb_1: { name: 'fn1', content: 'function (){}' } },
dataSources: [{ id: 'ds_1', type: 'base', fields: [] }],
items: [{ id: 'page_1', type: 'page', items: [] }],
}),
);
const dsl = JSON.stringify({
id: 'app',
type: 'app',
codeBlocks: { cb_1: { name: 'fn1', content: 'function (){}' } },
dataSources: [{ id: 'ds_1', type: 'base', fields: [] }],
items: [{ id: 'page_1', type: 'page', items: [] }],
});
(globalThis as any).onmessage({ data: { dsl } });
expect(postedMessages).toHaveLength(1);
// 全量与增量收集共用一个 worker响应必须带上请求 id
expect(response.id).toBe(1);
expect(response.deps).toHaveProperty(DepTargetType.DATA_SOURCE);
expect(response.deps).toHaveProperty(DepTargetType.CODE_BLOCK);
const data = postedMessages[0];
expect(data).toHaveProperty(DepTargetType.DATA_SOURCE);
expect(data).toHaveProperty(DepTargetType.CODE_BLOCK);
});
test('eval dsl 抛错时返回 failed', async () => {
test('eval dsl 抛错时 postMessage({})', async () => {
await loadWorker();
expect(postDslRequest('!@#invalid')).toEqual({ id: 1, deps: {}, failed: true });
(globalThis as any).onmessage({ data: { dsl: '!@#invalid' } });
expect(postedMessages[0]).toEqual({});
});
test('mApp 为空时只返回一次空结果', async () => {
test('mApp 为空时也会调用 postMessage', async () => {
await loadWorker();
expect(postDslRequest('null')).toEqual({ id: 1, deps: {} });
expect(postedMessages).toHaveLength(1);
(globalThis as any).onmessage({ data: { dsl: 'null' } });
expect(postedMessages.length).toBeGreaterThanOrEqual(1);
});
test('mApp 没有 codeBlocks/dataSources 时也能完成', async () => {
await loadWorker();
expect(postDslRequest(JSON.stringify({ id: 'app', type: 'app', items: [] })).deps).toBeDefined();
const dsl = JSON.stringify({ id: 'app', type: 'app', items: [] });
(globalThis as any).onmessage({ data: { dsl } });
expect(postedMessages).toHaveLength(1);
});
test('增量收集 - 收集数据源依赖并返回覆盖到的节点 id', async () => {
await loadWorker();
const response = postCollectRequest({
nodes: [
{
id: 'page_1',
type: 'page',
items: [{ id: 'text_1', type: 'text', text: '${ds_1.name}' }],
},
],
targets: [{ type: DepTargetType.DATA_SOURCE, ds: { id: 'ds_1', fields: [{ name: 'name', type: 'string' }] } }],
depExtendedData: { pageId: 'page_1' },
deep: true,
});
expect(response.id).toBe(1);
expect(response.failed).toBeUndefined();
expect(response.deps[DepTargetType.DATA_SOURCE].ds_1.text_1.keys).toEqual(['text']);
expect(response.deps[DepTargetType.DATA_SOURCE].ds_1.text_1.data).toEqual({ pageId: 'page_1' });
// 主线程据此删除旧依赖,必须包含子孙节点
expect(response.nodeIds).toEqual(['page_1', 'text_1']);
});
test('增量收集 - deep 为 false 时不收集子节点依赖,也不返回子节点 id', async () => {
await loadWorker();
const response = postCollectRequest({
nodes: [
{
id: 'page_1',
type: 'page',
items: [{ id: 'text_1', type: 'text', text: '${ds_1.name}' }],
},
],
targets: [{ type: DepTargetType.DATA_SOURCE, ds: { id: 'ds_1', fields: [{ name: 'name', type: 'string' }] } }],
depExtendedData: {},
deep: false,
});
expect(response.deps[DepTargetType.DATA_SOURCE].ds_1).toEqual({});
// deep=false 时 nodeIds 不能含子孙,否则写回会误清子节点依赖
expect(response.nodeIds).toEqual(['page_1']);
});
test('增量收集 - 支持代码块 target', async () => {
await loadWorker();
const response = postCollectRequest({
nodes: [{ id: 'text_1', type: 'text', created: 'code_1' }],
targets: [{ type: DepTargetType.CODE_BLOCK, id: 'code_1', codeBlock: { name: 'fn' } }],
depExtendedData: {},
deep: false,
});
expect(response.deps[DepTargetType.CODE_BLOCK].code_1.text_1.keys).toEqual(['created']);
});
test('增量收集 - 多个 target 一次收集,结果按 type/id 分组', async () => {
await loadWorker();
const response = postCollectRequest({
nodes: [{ id: 'text_1', type: 'text', created: 'code_1', text: '${ds_1.name}' }],
targets: [
{ type: DepTargetType.CODE_BLOCK, id: 'code_1', codeBlock: { name: 'fn' } },
{ type: DepTargetType.DATA_SOURCE, ds: { id: 'ds_1', fields: [{ name: 'name', type: 'string' }] } },
{ type: DepTargetType.DATA_SOURCE_COND, ds: { id: 'ds_1', fields: [{ name: 'name', type: 'string' }] } },
{ type: DepTargetType.DATA_SOURCE_METHOD, ds: { id: 'ds_1', fields: [], methods: [] } },
],
depExtendedData: {},
deep: false,
});
expect(Object.keys(response.deps).sort()).toEqual(
[
DepTargetType.CODE_BLOCK,
DepTargetType.DATA_SOURCE,
DepTargetType.DATA_SOURCE_COND,
DepTargetType.DATA_SOURCE_METHOD,
].sort(),
);
});
test('增量收集 - payload 非法时返回 failed调用方据此回退主线程收集', async () => {
await loadWorker();
expect(postCollectRequest('!@#invalid')).toEqual({ id: 1, deps: {}, nodeIds: [], failed: true });
});
test('增量收集 - target 描述非法时返回 failed', async () => {
await loadWorker();
const response = postCollectRequest({
nodes: [{ id: 'text_1', type: 'text' }],
targets: [{ type: 'unknown-type' }],
depExtendedData: {},
deep: false,
});
expect(response.failed).toBe(true);
});
});

View File

@ -3,9 +3,10 @@
*
* Copyright (C) 2025 Tencent.
*/
import { describe, expect, test, vi } from 'vitest';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { NODE_CONDS_RESULT_KEY } from '@tmagic/core';
import { validateForm } from '@tmagic/form';
import {
advancedTabConfig,
@ -19,6 +20,7 @@ import {
numberOptions,
removeStyleDisplayConfig,
styleTabConfig,
validatePropsForm,
} from '@editor/utils/props';
vi.mock('@tmagic/design', () => ({
@ -170,6 +172,59 @@ describe('fillConfig', () => {
});
});
describe('validatePropsForm', () => {
beforeEach(() => {
vi.mocked(validateForm).mockReset();
});
test('合入 appContext.provides、注入 stage/services 并合并外部 extendStatedebug 默认 undefined', async () => {
vi.mocked(validateForm).mockResolvedValue('err-text');
const services = { uiService: {} } as any;
const stage = { id: 'stage' };
const appContext = { app: {}, provides: { foo: 1 } } as any;
const result = await validatePropsForm({
config: [],
values: { a: 1 },
appContext,
services,
stage,
extendState: async () => ({ extra: true }),
});
// 直接返回 validateForm 的结果
expect(result).toBe('err-text');
const arg = vi.mocked(validateForm).mock.calls[0][0];
expect(arg.config).toEqual([]);
expect(arg.debug).toBeUndefined();
expect(arg.initValues).toEqual({ a: 1 });
// appContext 保留原字段,但 provides 被替换为 { services }
expect(arg.appContext).toEqual({ app: {}, provides: { services } });
// extendState 合并外部返回并注入 stage/services
const state = await arg.extendState!({} as any);
expect(state).toEqual({ extra: true, stage, services });
});
test('appContext 缺省为 nullextendState 缺省时仍注入 stage/services可覆盖 debug', async () => {
vi.mocked(validateForm).mockResolvedValue('');
const services = {} as any;
const stage = {};
await validatePropsForm({ config: [], values: {}, services, stage, debug: false });
const arg = vi.mocked(validateForm).mock.calls[0][0];
expect(arg.appContext).toBeNull();
expect(arg.debug).toBe(false);
const state = await arg.extendState!({} as any);
expect(state).toEqual({ stage, services });
});
});
describe('removeStyleDisplayConfig', () => {
test('将 tab 内「样式」pane 的 display 置为 true', () => {
const config = [

View File

@ -207,18 +207,9 @@ describe('editorTypeMatchRules', () => {
expect(firstLine(run('data-source-method-select', ['missing', 'custom']))).toBe('数据源(missing)不存在');
});
test('data-source-field-select 校验路径与 fieldConfig 类型校验', () => {
test('data-source-field-select 校验路径与 fieldConfig 跳过', () => {
expect(firstLine(run('data-source-field-select', 'x'))).toBe('x类型应为字符串数组');
// 有 fieldConfig 且值不是数据源字段路径时,按 fieldConfig 的类型校验text 允许数字)
expect(run('data-source-field-select', 'text-value', { name: 'f', fieldConfig: { type: 'text' } })).toBeUndefined();
expect(run('data-source-field-select', 123, { name: 'f', fieldConfig: { type: 'text' } })).toBeUndefined();
expect(firstLine(run('data-source-field-select', { a: 1 }, { name: 'f', fieldConfig: { type: 'text' } }))).toBe(
'[object Object] 类型应为字符串',
);
expect(run('data-source-field-select', 123, { name: 'f', fieldConfig: { type: 'number' } })).toBeUndefined();
// fieldConfig 未声明 type 时不做类型校验
expect(run('data-source-field-select', 123, { name: 'f', fieldConfig: {} })).toBeUndefined();
expect(run('data-source-field-select', 'text-value', { fieldConfig: { type: 'text' } })).toBeUndefined();
dataSourcesState.value = [
{
@ -234,26 +225,7 @@ describe('editorTypeMatchRules', () => {
expect(run('data-source-field-select', ['ds1', 'title'], { value: 'key' })).toBeUndefined();
expect(run('data-source-field-select', [`${DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX}ds1`, 'title'])).toBeUndefined();
// 数据源不存在时直出具体的数据源 id
expect(firstLine(run('data-source-field-select', ['missing', 'title'], { value: 'key' }))).toBe(
'数据源(missing)不存在',
);
expect(firstLine(run('data-source-field-select', ['title'], { dataSourceId: 'missing' }))).toBe(
'数据源(missing)不存在',
);
// 字段路径解析失败时直出具体失败的字段名
expect(firstLine(run('data-source-field-select', ['ds1', 'missing'], { value: 'key' }))).toBe(
'数据源字段(missing)不存在',
);
// 字段路径解析失败时,建议列出失败层级的可用字段名而非数据源 id
expect(run('data-source-field-select', ['ds1', 'missing'], { value: 'key' })).toBe(
'数据源字段(missing)不存在\n\n请使用以下某一个值"title""obj"',
);
expect(run('data-source-field-select', ['ds1', 'obj', 'missing'], { value: 'key' })).toBe(
'数据源字段(missing)不存在\n\n请使用以下某一个值"a"',
);
// 父字段无子字段时无建议
expect(run('data-source-field-select', ['ds1', 'title', 'x'], { value: 'key' })).toBe('数据源字段(x)不存在');
expect(firstLine(run('data-source-field-select', ['ds1', 'missing'], { value: 'key' }))).toBe('值不在可选项中');
expect(
firstLine(
run('data-source-field-select', ['title'], {

View File

@ -1,5 +1,5 @@
{
"version": "1.8.0-beta.20",
"version": "1.8.0-beta.14",
"name": "@tmagic/element-plus-adapter",
"type": "module",
"sideEffects": false,

View File

@ -1,5 +1,5 @@
{
"version": "1.8.0-beta.20",
"version": "1.8.0-beta.14",
"name": "@tmagic/form-schema",
"type": "module",
"sideEffects": false,

View File

@ -1,5 +1,5 @@
{
"version": "1.8.0-beta.20",
"version": "1.8.0-beta.14",
"name": "@tmagic/form",
"type": "module",
"sideEffects": [

View File

@ -88,13 +88,6 @@ const props = withDefaults(
labelWidth?: string;
/** 是否开启类型匹配校验 */
typeMatchValid?: boolean;
/**
* 初始化`config` / `initValues` 就绪后是否立即执行一次表单校验
*
* - `false`默认不自动校验避免打开表单时就展示错误态
* - `true`初始化完成后在 `nextTick` 中调用 `validate()`
*/
validateOnInit?: boolean;
disabled?: boolean;
height?: string;
stepActive?: string | number;
@ -163,7 +156,6 @@ const props = withDefaults(
labelPosition: 'right',
keyProp: '__key',
useFieldTextInError: true,
validateOnInit: false,
},
);
@ -269,18 +261,6 @@ const formState: FormState = reactive<FormState>({
},
});
/**
* formState 的内置 key 快照keyProp / values / $emit / fields / post
*
* `extendState` 首次合并前捕获`applyExtendState` 会据此禁止 `extendState`
* 覆盖这些已有字段只能新增字段避免表单核心状态被外部意外改写
*
* 之所以在此处effect 之外捕获而不是在 `applyExtendState` 内动态取
* `watchEffect` 会在依赖变化时重跑若动态取`extendState` 自己新增的字段在第二次
* 合并时也会被当成已有 key而拒绝刷新这里只锁定内置字段即可规避该问题
*/
const reservedStateKeys = new Set<string | symbol>(Reflect.ownKeys(formState));
/**
* `extendState` 的同步段直到第一个 `await` 之前所访问的任何响应式数据
* 都会被 `watchEffect` 自动跟踪这样可以兼容历史用法
@ -319,7 +299,7 @@ watchEffect(async (onCleanup) => {
}
if (stale) return;
applyExtendState(formState, state, reservedStateKeys);
applyExtendState(formState, state);
});
provide('mForm', formState);
@ -361,11 +341,9 @@ watch(
//
initialized.value = !props.isCompare;
if (props.validateOnInit) {
nextTick(() => {
tMagicFormRef.value?.validate();
});
}
nextTick(() => {
tMagicFormRef.value?.validate();
});
});
if (props.isCompare) {

View File

@ -16,7 +16,6 @@
:use-field-text-in-error="useFieldTextInError"
:extend-state="extendState"
:type-match-valid="typeMatchValid"
:validate-on-init="validateOnInit"
@change="changeHandler"
></Form>
<slot></slot>
@ -60,8 +59,6 @@ const props = withDefaults(
labelWidth?: string;
/** 是否开启类型匹配校验 */
typeMatchValid?: boolean;
/** 透传给内部 `MForm`,初始化完成后是否立即校验(默认 `false` */
validateOnInit?: boolean;
disabled?: boolean;
size?: 'small' | 'default' | 'large';
confirmText?: string;
@ -77,7 +74,6 @@ const props = withDefaults(
values: () => ({}),
confirmText: '确定',
useFieldTextInError: true,
validateOnInit: false,
},
);

View File

@ -33,7 +33,6 @@
:prevent-submit-default="preventSubmitDefault"
:use-field-text-in-error="useFieldTextInError"
:type-match-valid="typeMatchValid"
:validate-on-init="validateOnInit"
:extend-state="extendState"
:theme="effectiveTheme"
@change="changeHandler"
@ -88,8 +87,6 @@ const props = withDefaults(
labelWidth?: string;
/** 是否开启类型匹配校验 */
typeMatchValid?: boolean;
/** 透传给内部 `MForm`,初始化完成后是否立即校验(默认 `false` */
validateOnInit?: boolean;
fullscreen?: boolean;
disabled?: boolean;
title?: string;
@ -125,7 +122,6 @@ const props = withDefaults(
showClose: true,
showCancel: true,
useFieldTextInError: true,
validateOnInit: false,
},
);

View File

@ -30,7 +30,6 @@
:prevent-submit-default="preventSubmitDefault"
:use-field-text-in-error="useFieldTextInError"
:type-match-valid="typeMatchValid"
:validate-on-init="validateOnInit"
:extend-state="extendState"
:theme="effectiveTheme"
@change="changeHandler"
@ -79,8 +78,6 @@ const props = withDefaults(
labelWidth?: string;
/** 是否开启类型匹配校验 */
typeMatchValid?: boolean;
/** 透传给内部 `MForm`,初始化完成后是否立即校验(默认 `false` */
validateOnInit?: boolean;
disabled?: boolean;
closeOnPressEscape?: boolean;
title?: string;
@ -109,7 +106,6 @@ const props = withDefaults(
values: () => ({}),
confirmText: '确定',
useFieldTextInError: true,
validateOnInit: false,
},
);

View File

@ -12,7 +12,6 @@
:disabled="disabled"
@change="changeHandler"
@add-diff-count="onAddDiffCount"
:is-in-row="true"
></Container>
</TMagicCol>
</template>

View File

@ -240,7 +240,7 @@
</template>
<script setup lang="ts">
import { computed, inject, provide, readonly, ref, toRaw, watch, watchEffect } from 'vue';
import { computed, inject, readonly, ref, toRaw, watch, watchEffect } from 'vue';
import { ArrowDown, ArrowUp, WarningFilled } from '@element-plus/icons-vue';
import { isEqual } from 'lodash-es';
@ -287,8 +287,6 @@ const props = withDefaults(
size?: string;
/** 是否开启对比模式 */
isCompare?: boolean;
/** 是否在行容器中 */
isInRow?: boolean;
}>(),
{
prop: '',
@ -478,8 +476,6 @@ watch(
},
);
provide('isInRow', props.isInRow ?? false);
const expandHandler = () => (expand.value = !expand.value);
const key = (config: any) => config[mForm?.keyProps];

View File

@ -29,17 +29,6 @@ export interface FormDiffConfig {
export const FORM_DIFF_CONFIG_KEY: InjectionKey<FormDiffConfig> = Symbol('mFormDiffConfig');
export const FORM_TYPE_MATCH_VALID_KEY: InjectionKey<ComputedRef<boolean>> = Symbol('mFormTypeMatchValid');
/**
* `submitForm` / `validateForm` providedebug
*
* vs-code inject FormItem model
* UI monaco worker / model
*
* onMounted/watch immediate emit change DOM
*
*/
export const FORM_SILENT_MODE_KEY: InjectionKey<boolean> = Symbol('mFormSilentMode');
export interface ValidateError {
message: string;
field: string;

View File

@ -16,22 +16,11 @@
* limitations under the License.
*/
import {
type AppContext,
type Component,
createApp,
defineComponent,
h,
nextTick,
provide,
type Ref,
ref,
watch,
} from 'vue';
import { type AppContext, type Component, createApp, defineComponent, h, nextTick, type Ref, ref, watch } from 'vue';
import { applyExtendState } from './utils/form';
import Form from './Form.vue';
import { type ChangeRecord, FORM_SILENT_MODE_KEY, type FormConfig, type FormState } from './schema';
import type { ChangeRecord, FormConfig, FormState } from './schema';
// #region SubmitFormOptions
/**
@ -87,11 +76,6 @@ export interface SubmitFormOptions {
*/
debug?: boolean;
typeMatchValid?: boolean;
/**
* abort `signal.reason` reject
* `debug`
*/
signal?: AbortSignal;
}
// #endregion SubmitFormOptions
@ -132,7 +116,7 @@ interface MountFormInstanceOptions<T> {
formProps: Record<string, any>;
/** 父级应用上下文用于继承全局组件、指令、provide 等 */
appContext?: AppContext | null;
/** 等待表单初始化的最长时间(毫秒),<=0 时回退到默认超时以保证兜底清理生效 */
/** 等待表单初始化的最长时间(毫秒),<=0 表示不注册超时 */
timeout: number;
/** 超时 reject 的错误文案 */
timeoutMessage: string;
@ -140,15 +124,10 @@ interface MountFormInstanceOptions<T> {
hidden?: boolean;
/** 是否跳过超时注册。调试模式等待人工操作,应传 `true` */
skipTimeout?: boolean;
/** 外部中断信号abort 时会 reject 并卸载实例、移除容器,用于取消无超时(如 debug的挂载 */
signal?: AbortSignal;
/** 构造 wrapper 组件 */
createWrapper: FormWrapperFactory<T>;
}
/** 未指定或传入非正数 timeout 时的兜底超时(毫秒),保证非 debug 挂载始终能被清理 */
const DEFAULT_MOUNT_TIMEOUT = 10000;
/**
* submitForm / validateForm
*
@ -159,36 +138,20 @@ const DEFAULT_MOUNT_TIMEOUT = 10000;
*
*/
const mountFormInstance = <T>(options: MountFormInstanceOptions<T>): Promise<T> => {
const {
formProps,
appContext,
timeout,
timeoutMessage,
hidden = true,
skipTimeout = false,
signal,
createWrapper,
} = options;
const { formProps, appContext, timeout, timeoutMessage, hidden = true, skipTimeout = false, createWrapper } = options;
return new Promise<T>((resolve, reject) => {
// 已中断则直接 reject不创建任何容器/实例
if (signal?.aborted) {
reject(signal.reason ?? new Error('mountFormInstance aborted'));
return;
}
let cleaned = false;
let timer: ReturnType<typeof setTimeout> | null = null;
let onAbort: (() => void) | null = null;
// 用 holder 持有 app使 cleanup 可在 app 创建之前定义const app + 无 TDZ / 无 use-before-define
const instance: { app: ReturnType<typeof createApp> | null } = { app: null };
const container = document.createElement('div');
if (hidden) {
container.style.display = 'none';
}
document.body.appendChild(container);
let cleaned = false;
let timer: ReturnType<typeof setTimeout> | null = null;
// 用 holder 持有 app使 cleanup 可在 app 创建之前定义const app + 无 TDZ / 无 use-before-define
const instance: { app: ReturnType<typeof createApp> | null } = { app: null };
const cleanup = () => {
if (cleaned) return;
cleaned = true;
@ -196,10 +159,6 @@ const mountFormInstance = <T>(options: MountFormInstanceOptions<T>): Promise<T>
clearTimeout(timer);
timer = null;
}
if (signal && onAbort) {
signal.removeEventListener('abort', onAbort);
onAbort = null;
}
try {
instance.app?.unmount();
} catch {
@ -208,101 +167,67 @@ const mountFormInstance = <T>(options: MountFormInstanceOptions<T>): Promise<T>
container.parentNode?.removeChild(container);
};
// 支持外部通过 AbortSignal 主动中断debug 模式无超时兜底,若调用方放弃了该 Promise
// 可通过 abort 卸载实例、移除遮罩/容器,避免无限驻留在 DOM 中。
if (signal) {
onAbort = () => {
if (cleaned) return;
reject(signal.reason ?? new Error('mountFormInstance aborted'));
cleanup();
};
signal.addEventListener('abort', onAbort);
}
const formRef = ref<any>(null);
// 从容器创建到 mount 的全流程统一 try/catch任一步骤createWrapper/createApp/上下文合并/mount
// 抛错都会走到 cleanup避免已插入 body 的 container 及未挂载的 app 残留导致泄漏。
try {
const formRef = ref<any>(null);
// 将 extendState 从 formProps 中剥离:不由 Form.vue 的 async watchEffect 异步应用,
// 而是在 wrapper 中通过 sync watch 在 formRef 就绪后直接写入 formState
// 避免 display 等 filterFunction 在首次渲染时读到 undefined。
// 与 CompareForm / FormPanel 中「formRef.value.formState.services = ...」的做法一致。
const { extendState, ...restFormProps } = formProps;
// 将 extendState 从 formProps 中剥离:不由 Form.vue 的 async watchEffect 异步应用,
// 而是在 wrapper 中通过 sync watch 在 formRef 就绪后直接写入 formState
// 避免 display 等 filterFunction 在首次渲染时读到 undefined。
// 与 CompareForm / FormPanel 中「formRef.value.formState.services = ...」的做法一致。
const { extendState, ...restFormProps } = formProps;
const userWrapper = createWrapper({ formRef, formProps: restFormProps, cleanup, resolve, reject });
const userWrapper = createWrapper({ formRef, formProps: restFormProps, cleanup, resolve, reject });
const wrapperComponent =
typeof extendState === 'function'
? defineComponent({
name: 'MFormExtendStateInjector',
setup() {
watch(
() => formRef.value,
(form) => {
if (!form) return;
let result: any;
try {
result = extendState(form.formState);
} catch (e) {
console.error('[MForm] extendState failed:', e);
return;
}
// formState 的内置 key 快照:在 extendState 合并前捕获,
// 供 applyExtendState 禁止 extendState 覆盖这些已有字段(只能新增),
// 与 Form.vue 中 reservedStateKeys 的语义保持一致。
const reservedStateKeys = new Set<string | symbol>(Reflect.ownKeys(form.formState));
// 合并逻辑收口在 applyExtendStateprops 派生的只读 getter 字段
// keyProp 等)以普通字段形式返回时会被跳过并告警,避免 proxy set 抛错
const apply = (state: Record<string, any> | null | undefined) =>
applyExtendState(form.formState, state, reservedStateKeys);
if (result && typeof result.then === 'function') {
result.then(apply, (e: any) => console.error('[MForm] extendState failed:', e));
} else {
apply(result);
}
},
{ flush: 'sync', immediate: true },
);
return () => h(userWrapper);
},
})
: userWrapper;
// 静默隐藏挂载模式下注入静默标记vs-code 等重型字段组件可据此跳过自身渲染,
// 校验/取值依赖 FormItem 与 model 值,与叶子 UI 组件无关(见 FORM_SILENT_MODE_KEY 注释)。
// 用组件级 provide 而非 app.provideappContext 合并后 app._context.provides 与父级应用
// 共享引用app.provide 会把标记泄漏到父级应用。
const rootComponent = hidden
const wrapperComponent =
typeof extendState === 'function'
? defineComponent({
name: 'MFormSilentProvider',
name: 'MFormExtendStateInjector',
setup() {
provide(FORM_SILENT_MODE_KEY, true);
return () => h(wrapperComponent);
watch(
() => formRef.value,
(form) => {
if (!form) return;
let result: any;
try {
result = extendState(form.formState);
} catch (e) {
console.error('[MForm] extendState failed:', e);
return;
}
// 合并逻辑收口在 applyExtendStateprops 派生的只读 getter 字段
// keyProp 等)以普通字段形式返回时会被跳过并告警,避免 proxy set 抛错
const apply = (state: Record<string, any> | null | undefined) =>
applyExtendState(form.formState, state);
if (result && typeof result.then === 'function') {
result.then(apply, (e: any) => console.error('[MForm] extendState failed:', e));
} else {
apply(result);
}
},
{ flush: 'sync', immediate: true },
);
return () => h(userWrapper);
},
})
: wrapperComponent;
: userWrapper;
const app = createApp(rootComponent);
instance.app = app;
const app = createApp(wrapperComponent);
instance.app = app;
// 继承父级应用上下文components / directives / provides / config 等)
if (appContext) {
Object.assign(app._context, appContext);
}
// 继承父级应用上下文components / directives / provides / config 等)
if (appContext) {
Object.assign(app._context, appContext);
}
// 非 debug未跳过超时场景始终注册超时兜底timeout 为非正数时回退到默认值,
// 避免表单永不初始化时实例/容器/watcher 无限驻留而泄漏。
if (!skipTimeout) {
const effectiveTimeout = timeout > 0 ? timeout : DEFAULT_MOUNT_TIMEOUT;
timer = setTimeout(() => {
if (!cleaned) {
reject(new Error(timeoutMessage));
cleanup();
}
}, effectiveTimeout);
}
if (timeout > 0 && !skipTimeout) {
timer = setTimeout(() => {
if (!cleaned) {
reject(new Error(timeoutMessage));
cleanup();
}
}, timeout);
}
try {
app.mount(container);
} catch (err) {
reject(err);
@ -500,13 +425,12 @@ const createDebugWrapper = (options: DebugWrapperOptions): Component => {
* ```
*/
export const submitForm = (options: SubmitFormOptions): Promise<any> => {
const { native, appContext, timeout = 10000, returnChangeRecords, debug = false, signal, ...formProps } = options;
const { native, appContext, timeout = 10000, returnChangeRecords, debug = false, ...formProps } = options;
return mountFormInstance<any>({
formProps,
appContext,
timeout,
signal,
// 调试模式需把表单展示出来;普通模式隐藏挂载
hidden: !debug,
// 调试模式等待人工操作,不应用超时
@ -610,11 +534,6 @@ export interface ValidateFormOptions {
*/
debug?: boolean;
typeMatchValid?: boolean;
/**
* abort `signal.reason` reject
* `debug`
*/
signal?: AbortSignal;
}
// #endregion ValidateFormOptions
@ -710,7 +629,7 @@ export const stripTabItemsLazy = (config: FormConfig): FormConfig => {
* ```
*/
export const validateForm = (options: ValidateFormOptions): Promise<string> => {
const { appContext, timeout = 10000, debug = false, config, signal, ...rest } = options;
const { appContext, timeout = 10000, debug = false, config, ...rest } = options;
// 去掉 tab 容器各标签页的 lazy确保懒加载标签页内的字段也参与校验
const formProps = { ...rest, config: stripTabItemsLazy(config) };
@ -719,7 +638,6 @@ export const validateForm = (options: ValidateFormOptions): Promise<string> => {
formProps,
appContext,
timeout,
signal,
// 调试模式需把表单展示出来;普通模式隐藏挂载
hidden: !debug,
// 调试模式等待人工操作,不应用超时

View File

@ -2,6 +2,7 @@
.m-fields-group-list-item.tmagic-design-card--flat:last-child {
border: 0;
}
.el-button--text {
padding: 0;
margin-bottom: 7px;
@ -31,19 +32,9 @@
}
.m-fields-group-list-footer {
display: flex;
justify-content: space-between;
margin-top: 10px;
margin-bottom: 16px;
}
}
/** 最外层的groupList需要每个item需要增加空白区域界限时增加outer-gorup_list可以实现 */
.m-container-group-list {
&.outer-gorup_list {
> .m-fields-group-list {
> .m-fields-group-list-item {
margin-bottom: 10px;
}
}
}
}

View File

@ -92,15 +92,4 @@
text-align: left;
}
}
.m-fields-group-list {
.el-table__empty-block {
.el-table__empty-text {
width: 100%;
background-color: rgba(0, 0, 0, 0.03);
margin-top: 8px;
border-radius: 4px;
}
}
}
}

View File

@ -60,15 +60,13 @@ export const adaptFormValidator = (validator: AsyncValidatorFn): AsyncValidatorF
const value = arg1;
return new Promise((resolve) => {
let settled = false;
const callback = (error?: Error | string | (Error | string)[]) => {
const callback = (error?: Error | string) => {
if (settled) return;
settled = true;
// async-validator 约定 callback 也可接收错误数组TDesign 只能展示一条,取首条
const first = Array.isArray(error) ? error[0] : error;
if (first) {
if (error) {
resolve({
result: false,
message: typeof first === 'string' ? first : first.message,
message: typeof error === 'string' ? error : error.message,
});
} else {
resolve(true);
@ -76,9 +74,8 @@ export const adaptFormValidator = (validator: AsyncValidatorFn): AsyncValidatorF
};
try {
// 异步 validator 会先返回 undefined稍后再调 callback这里不能当成 thenable 取值
const result = validator(undefined, value, callback);
if (result && typeof (result as PromiseLike<unknown>).then === 'function') {
if (result !== null && typeof (result as PromiseLike<unknown>).then === 'function') {
Promise.resolve(result).then(
() => {
if (!settled) callback();
@ -452,27 +449,16 @@ export const sortChange = (data: any[], { prop, order }: SortProp) => {
* - accessor `{ get stage() { return ... } }` defineProperty
* `configurable: true` 便 define
*
* extendState formState key
* `reservedKeys` key keyProp / popperClass /
* config / initValues / isCompare / lastValues / parentValues / values / $emit / fields /
* post key
*
* `reservedKeys` props getter setter
* proxy set trap
* `TypeError: 'set' on proxy: trap returned falsish`
* formState props keyProp / popperClass / config / initValues /
* isCompare / lastValues / parentValues getter setterextendState
* key proxy set trap
* `TypeError: 'set' on proxy: trap returned falsish`
* extendState get 访
*/
export const applyExtendState = (
formState: FormState,
state: Record<string, any> | null | undefined,
reservedKeys?: Set<string | symbol>,
): void => {
export const applyExtendState = (formState: FormState, state: Record<string, any> | null | undefined): void => {
if (!state) return;
for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(state))) {
if (reservedKeys?.has(key)) {
continue;
}
if (!('value' in descriptor)) {
descriptor.configurable = true;
Object.defineProperty(formState, key, descriptor);

View File

@ -38,15 +38,8 @@ export interface TypeMatchValidateContext {
// #endregion TypeMatchValidateContext
// #region TypeMatchValidator
/**
* type undefined
*
* Promise id
*/
export type TypeMatchValidator = (
value: any,
context: TypeMatchValidateContext,
) => string | undefined | Promise<string | undefined>;
/** 自定义 type 校验器:返回错误文案;通过则返回 undefined */
export type TypeMatchValidator = (value: any, context: TypeMatchValidateContext) => string | undefined;
// #endregion TypeMatchValidator
const typeMatchRuleRegistry = new Map<string, TypeMatchValidator>();
@ -149,7 +142,7 @@ const stringifyExampleValue = (value: any): string => {
};
// 参考建议中最多展示的可选值个数,超出以「等」省略。
const MAX_SUGGESTION_OPTIONS = 20;
const MAX_SUGGESTION_OPTIONS = 5;
/**
* 使xxxxxx
@ -565,11 +558,6 @@ const validateBuiltinTypeMatch = (
return undefined;
}
// text 常与数字(如 id、数量绑定值允许为数字错误建议仍按字符串给出
if (fieldType === 'text' && isNumberValue(value)) {
return undefined;
}
if (typeof value !== 'string') {
return defaultMessage(
message,
@ -699,28 +687,19 @@ const validateBuiltinTypeMatch = (
return undefined;
};
/**
* type undefined
*
* Promise
*/
export const validateTypeMatch = (
value: any,
mForm: FormState | undefined,
props: any,
message?: string,
): string | undefined | Promise<string | undefined> => {
): string | undefined => {
if (isEmptyValue(value) || isEmptyArray(value)) {
return undefined;
}
if (!props.config?.name) {
return undefined;
}
const rawFieldType = 'type' in (props.config || {}) ? props.config.type : '';
if (typeof rawFieldType !== 'string' || !rawFieldType) {
return undefined;
return;
}
// 统一将驼峰形式(如 radioGroup归一化为连字符形式radio-group与内置规则的 key 保持一致
@ -736,135 +715,43 @@ export const validateTypeMatch = (
return validateBuiltinTypeMatch(value, fieldType, mForm, props, message);
};
const toError = (error: any): Error => (error instanceof Error ? error : new Error(`${error}`));
export const createTypeMatchValidator = (mForm: FormState | undefined, props: any, rule: Rule) => {
const originalValidator = typeof rule.validator === 'function' ? rule.validator : undefined;
/**
*
*
*
*
* `form.validate()`
* pendingSettlers
*
* names /
*/
let generation = 0;
let pendingSettlers: ((args: any[]) => void)[] = [];
return (asyncValidatorRule: any, value: any, callback: Function, source: any, options: any) => {
const actualValue = props.config?.names ? props.model : value;
generation += 1;
const currentGeneration = generation;
/** 已有更新的一轮校验开始,本轮结论作废 */
const isStale = () => currentGeneration !== generation;
pendingSettlers.push((args: any[]) => callback(...args));
/**
*
*
* async-validator callback
* validator async callback Promise
* callback
*/
const conclude = (...args: any[]) => {
if (isStale()) {
return;
}
const settlers = pendingSettlers;
pendingSettlers = [];
for (const settle of settlers) {
settle(args);
}
};
/**
* validator callback
*
* async-validator true / false / Error / / Promise
*
*/
const settleWithOriginalValidator = () => {
// 本轮结论已作废,不必再跑一遍原始 validator 触发副作用
if (isStale()) {
return;
}
if (!originalValidator) {
conclude();
return;
}
let result: any;
try {
result = originalValidator(
{ rule: asyncValidatorRule, value: actualValue, callback: conclude, source, options },
{
values: mForm?.initValues || {},
model: props.model,
parent: mForm?.parentValues || {},
formValue: mForm?.values || props.model,
prop: props.prop,
config: props.config,
},
mForm,
);
} catch (err) {
conclude(toError(err));
return;
}
if (isPromise(result)) {
result.then(
() => conclude(),
(err: any) => conclude(toError(err)),
);
} else if (result === true) {
conclude();
} else if (result === false) {
const field = asyncValidatorRule?.fullField || asyncValidatorRule?.field || props.prop;
conclude(new Error(rule.message || `${field} fails`));
} else if (result instanceof Error || Array.isArray(result)) {
conclude(result);
}
// 其余返回值undefined / void按约定由 validator 自行调用 callback
};
// 校验器自身失败(如接口异常)不应阻塞用户,记录后按通过处理
const skipFailedTypeMatch = (err: any) => {
console.error(err);
settleWithOriginalValidator();
};
let error: string | undefined | Promise<string | undefined>;
try {
error = validateTypeMatch(actualValue, mForm, props, rule.message);
} catch (err) {
skipFailedTypeMatch(err);
return;
const error = validateTypeMatch(actualValue, mForm, props, rule.message);
if (error) {
callback(new Error(error));
return;
}
} catch (error) {
console.error(error);
}
if (isPromise(error)) {
error.then((asyncMessage) => {
if (asyncMessage) {
conclude(new Error(asyncMessage));
return;
}
settleWithOriginalValidator();
}, skipFailedTypeMatch);
return;
if (originalValidator) {
return originalValidator(
{
rule: asyncValidatorRule,
value: actualValue,
callback,
source,
options,
},
{
values: mForm?.initValues || {},
model: props.model,
parent: mForm?.parentValues || {},
formValue: mForm?.values || props.model,
prop: props.prop,
config: props.config,
},
mForm,
);
}
if (error) {
conclude(new Error(error));
return;
}
settleWithOriginalValidator();
callback();
};
};

View File

@ -189,7 +189,9 @@ describe('Form.vue —— extendState', () => {
expect(v2).toMatch(/^stage-/);
});
test('extendState 以普通字段返回内置保留字段时静默跳过,其余字段正常合并', async () => {
test('extendState 以普通字段返回 props 派生的只读字段时跳过并告警,不抛错', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const wrapper = mountForm({
keyProp: 'id',
extendState: () => ({ keyProp: 'custom', config: [], extra: 'ok' }),
@ -199,15 +201,18 @@ describe('Form.vue —— extendState', () => {
await nextTick();
await nextTick();
// 内置保留字段keyProp / config未被 extendState 覆盖
// 只读派生字段保持 props 值,未被覆盖
expect((wrapper.vm.formState as any).keyProp).toBe('id');
expect(Array.isArray((wrapper.vm.formState as any).config)).toBe(true);
// 非保留字段正常合并
// 其他字段正常合并
expect((wrapper.vm.formState as any).extra).toBe('ok');
expect(warnSpy).toHaveBeenCalled();
expect(wrapper.find('.m-form').exists()).toBe(true);
warnSpy.mockRestore();
});
test('extendState 以 get 访问器返回内置保留字段同名 key 时仍被拦截,无法覆盖', async () => {
test('extendState 以 get 访问器返回只读字段同名 key 时允许显式覆盖', async () => {
const wrapper = mountForm({
keyProp: 'id',
extendState: () =>
@ -223,8 +228,7 @@ describe('Form.vue —— extendState', () => {
await nextTick();
await nextTick();
// keyProp 属于内置保留字段,即使以访问器形式返回也不允许覆盖
expect((wrapper.vm.formState as any).keyProp).toBe('id');
expect((wrapper.vm.formState as any).keyProp).toBe('custom-key');
});
test('extendState 同步段读到的响应式数据变化时会重跑', async () => {

View File

@ -16,29 +16,18 @@
* limitations under the License.
*/
import { afterEach, beforeAll, describe, expect, test, vi } from 'vitest';
import { type AppContext, createApp, defineComponent, h, inject, nextTick } from 'vue';
import MagicForm, { FORM_SILENT_MODE_KEY, submitForm, validateForm } from '@form/index';
import { type AppContext, createApp, defineComponent, h, nextTick } from 'vue';
import MagicForm, { submitForm } from '@form/index';
import ElementPlus from 'element-plus';
let appContext: AppContext;
// 探针字段:挂载时注入静默标记并记录,用于验证 submitForm/validateForm 的 provide 行为
const silentProbeValues: (boolean | undefined)[] = [];
const SilentProbe = defineComponent({
name: 'MFieldsSilentProbe',
setup() {
silentProbeValues.push(inject(FORM_SILENT_MODE_KEY, undefined));
return () => h('div');
},
});
beforeAll(() => {
// 构造一个父级 app把 element-plus 与 m-form 插件装上,
// 之后通过 appContext 传给 submitForm 复用全局注册
const parentApp = createApp(defineComponent({ render: () => h('div') }));
parentApp.use(ElementPlus);
parentApp.use(MagicForm);
parentApp.component('m-fields-silent-probe', SilentProbe);
appContext = parentApp._context;
});
@ -90,7 +79,9 @@ describe('submitForm', () => {
expect(extendState).toHaveBeenCalled();
});
test('extendState 返回 keyProp 等内置保留字段时静默跳过且正常 resolve', async () => {
test('extendState 返回 keyProp 等只读派生字段时不抛错且正常 resolve', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const values = await submitForm({
config: [{ type: 'text', name: 'text', text: 'text' }],
initValues: { text: 'foo' },
@ -98,8 +89,10 @@ describe('submitForm', () => {
appContext,
});
// keyProp 属于内置保留字段,被静默跳过,不污染最终 values
expect(values).toEqual({ text: 'foo' });
expect(warnSpy).toHaveBeenCalled();
warnSpy.mockRestore();
});
test('在嵌套 items 配置下也能正确 resolve', async () => {
@ -223,44 +216,6 @@ describe('submitForm', () => {
expect(caught).toBeInstanceOf(Error);
});
test('静默(隐藏挂载)模式下向字段 provide FORM_SILENT_MODE_KEY=true', async () => {
silentProbeValues.length = 0;
const values = await submitForm({
config: [{ type: 'silent-probe', name: 'text', text: 'text' }],
initValues: { text: 'hello' },
appContext,
});
expect(values).toEqual({ text: 'hello' });
expect(silentProbeValues).toEqual([true]);
});
test('静默标记不会泄漏到父级应用的 provides', async () => {
silentProbeValues.length = 0;
await submitForm({
config: [{ type: 'silent-probe', name: 'text', text: 'text' }],
initValues: { text: 'hello' },
appContext,
});
expect((appContext as any).provides[FORM_SILENT_MODE_KEY as symbol]).toBeUndefined();
});
test('validateForm 静默模式下同样 provide FORM_SILENT_MODE_KEY=true', async () => {
silentProbeValues.length = 0;
const error = await validateForm({
config: [{ type: 'silent-probe', name: 'text', text: 'text' }],
initValues: { text: 'hello' },
appContext,
});
expect(error).toBe('');
expect(silentProbeValues).toEqual([true]);
});
test('timeout > 0 时会注册定时器timeout <= 0 时不注册', async () => {
const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout');
@ -399,24 +354,6 @@ describe('submitForm —— debug 模式', () => {
expect(document.body.querySelector('.m-form')).toBeNull();
});
test('debug 模式不提供静默标记(表单可见,字段应正常渲染)', async () => {
silentProbeValues.length = 0;
const pending = submitForm({
config: [{ type: 'silent-probe', name: 'text', text: 'text' }],
initValues: { text: 'hello' },
debug: true,
appContext,
});
await nextTick();
await nextTick();
expect(silentProbeValues).toEqual([undefined]);
findButton('确定').click();
await pending;
});
test('debug 模式不注册超时定时器(等待人工操作)', async () => {
const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout');

Some files were not shown because too many files have changed in this diff Show More