mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-07-30 09:55:52 +00:00
Compare commits
27 Commits
v1.8.0-bet
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc34891bb2 | ||
|
|
0ea5568fec | ||
|
|
4bbda354a9 | ||
|
|
aa42144738 | ||
|
|
c4b98e3257 | ||
|
|
b85eaa9452 | ||
|
|
21baf58cda | ||
|
|
4923a34835 | ||
|
|
b7c04edcd5 | ||
|
|
2116408bd4 | ||
|
|
a342a78313 | ||
|
|
3fe9cf9b66 | ||
|
|
a123747e9d | ||
|
|
e4d4a11240 | ||
|
|
4ffda3ddd5 | ||
|
|
a0b5702330 | ||
|
|
e6cf694a03 | ||
|
|
be7b38f97e | ||
|
|
dec4ebfa20 | ||
|
|
596d86f2ab | ||
|
|
b564c9e4aa | ||
|
|
b5abf31066 | ||
|
|
bf8df74864 | ||
|
|
a37e0487c6 | ||
|
|
329cd947e1 | ||
|
|
0e1986c819 | ||
|
|
d0a22315c0 |
49
CHANGELOG.md
49
CHANGELOG.md
@ -1,3 +1,52 @@
|
|||||||
|
# [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)
|
# [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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1020,6 +1020,18 @@ alt: 按住alt键启动识别
|
|||||||
|
|
||||||
- **类型:** `'default' | 'alt' | ''`
|
- **类型:** `'default' | 'alt' | ''`
|
||||||
|
|
||||||
|
## containerHighlightAddOnly
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
是否只有新增组件(从组件列表拖入新组件)时才启用识别容器
|
||||||
|
|
||||||
|
开启后,在画布中拖动已有组件时不再识别容器,即已有组件不能通过拖动加入其他容器;新增组件仍然按 [containerHighlightType](#containerHighlightType) 配置的方式识别
|
||||||
|
|
||||||
|
- **默认值:** `false`
|
||||||
|
|
||||||
|
- **类型:** `boolean`
|
||||||
|
|
||||||
## stageRect
|
## stageRect
|
||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|||||||
@ -69,10 +69,11 @@
|
|||||||
|
|
||||||
## delayedMarkContainer
|
## delayedMarkContainer
|
||||||
|
|
||||||
- **类型**:`(event: MouseEvent, excludeElList?: Element[]) => NodeJS.Timeout | undefined`
|
- **类型**:`(event: MouseEvent, excludeElList?: Element[], isAdd?: boolean) => NodeJS.Timeout | undefined`
|
||||||
- **参数**:
|
- **参数**:
|
||||||
- `event`:鼠标事件
|
- `event`:鼠标事件
|
||||||
- `excludeElList`:计算鼠标所在容器时要排除的元素列表
|
- `excludeElList`:计算鼠标所在容器时要排除的元素列表
|
||||||
|
- `isAdd`:当前操作是否为新增组件(从组件列表拖入新组件),`containerHighlightType` 为 `addOnly` 时只有新增才会标记容器
|
||||||
- **详情**:
|
- **详情**:
|
||||||
|
|
||||||
鼠标拖拽着元素,在容器上方悬停,延迟一段时间后,对容器进行标记,如果悬停时间够长将标记成功,悬停时间短,调用方通过返回的timeoutId取消标记
|
鼠标拖拽着元素,在容器上方悬停,延迟一段时间后,对容器进行标记,如果悬停时间够长将标记成功,悬停时间短,调用方通过返回的timeoutId取消标记
|
||||||
|
|||||||
@ -69,6 +69,8 @@
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`validator` 除了调用 `callback` 外,也兼容 async-validator 的返回值约定:返回 `false` 或 `Error` / 错误数组表示失败,返回 `true` 表示通过,返回 `Promise` 则以 resolve / reject 为结果,内部抛出的异常会转成校验失败。同步与异步 `typeMatch` 下这些约定行为一致,且只会上报一次结果(既调 `callback` 又返回 `Promise` 时以先到的为准)。
|
||||||
|
|
||||||
## 扩展自定义 type 规则
|
## 扩展自定义 type 规则
|
||||||
|
|
||||||
业务可覆盖内置规则,或为自定义字段 type 注册校验。自定义规则优先于内置规则。
|
业务可覆盖内置规则,或为自定义字段 type 注册校验。自定义规则优先于内置规则。
|
||||||
@ -107,7 +109,26 @@ deleteTypeMatchRule('foo');
|
|||||||
clearTypeMatchRules();
|
clearTypeMatchRules();
|
||||||
```
|
```
|
||||||
|
|
||||||
自定义校验器签名:`(value, context) => string | undefined`。返回错误文案表示失败,返回 `undefined` 表示通过。`context` 包含 `fieldType`、`mForm`、`props`、`message`。
|
自定义校验器签名:`(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()` 也不会对一个尚未校验完的值返回成功。
|
||||||
|
|
||||||
### 安装时注册
|
### 安装时注册
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.0-beta.15",
|
"version": "1.8.0-beta.20",
|
||||||
"name": "tmagic",
|
"name": "tmagic",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.0-beta.15",
|
"version": "1.8.0-beta.20",
|
||||||
"name": "@tmagic/cli",
|
"name": "@tmagic/cli",
|
||||||
"main": "lib/index.js",
|
"main": "lib/index.js",
|
||||||
"types": "lib/index.d.ts",
|
"types": "lib/index.d.ts",
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.0-beta.15",
|
"version": "1.8.0-beta.20",
|
||||||
"name": "@tmagic/core",
|
"name": "@tmagic/core",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -341,6 +341,20 @@ describe('App 配置/方法/组件注册', () => {
|
|||||||
expect(app.page).toBeUndefined();
|
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 () => {
|
test('runCode 执行代码块', async () => {
|
||||||
const fn = vi.fn();
|
const fn = vi.fn();
|
||||||
const app = new App({
|
const app = new App({
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.0-beta.15",
|
"version": "1.8.0-beta.20",
|
||||||
"name": "@tmagic/data-source",
|
"name": "@tmagic/data-source",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -7,8 +7,7 @@ import {
|
|||||||
DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX,
|
DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX,
|
||||||
dataSourceTemplateRegExp,
|
dataSourceTemplateRegExp,
|
||||||
getValueByKeyPath,
|
getValueByKeyPath,
|
||||||
isPage,
|
isPageOrFragment,
|
||||||
isPageFragment,
|
|
||||||
NODE_CONDS_KEY,
|
NODE_CONDS_KEY,
|
||||||
replaceChildNode,
|
replaceChildNode,
|
||||||
} from '@tmagic/core';
|
} from '@tmagic/core';
|
||||||
@ -68,7 +67,7 @@ export const compliedConditions = (node: { [NODE_CONDS_KEY]?: DisplayCond[] }, d
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const updateNode = (node: MNode, dsl: MApp) => {
|
export const updateNode = (node: MNode, dsl: MApp) => {
|
||||||
if (isPage(node) || isPageFragment(node)) {
|
if (isPageOrFragment(node)) {
|
||||||
const index = dsl.items?.findIndex((child: MNode) => child.id === node.id);
|
const index = dsl.items?.findIndex((child: MNode) => child.id === node.id);
|
||||||
dsl.items.splice(index, 1, node as MPage | MPageFragment);
|
dsl.items.splice(index, 1, node as MPage | MPageFragment);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.0-beta.15",
|
"version": "1.8.0-beta.20",
|
||||||
"name": "@tmagic/dep",
|
"name": "@tmagic/dep",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import type { DepData } from '@tmagic/schema';
|
import type { DepData } from '@tmagic/schema';
|
||||||
|
|
||||||
import { DepTargetType, type IsTarget, type TargetOptions } from './types';
|
import { DepTargetType, type IsTarget, type TargetDescriptor, type TargetOptions } from './types';
|
||||||
|
|
||||||
export interface DepUpdateOptions {
|
export interface DepUpdateOptions {
|
||||||
id: string | number;
|
id: string | number;
|
||||||
@ -40,12 +40,17 @@ export default class Target {
|
|||||||
* 是否默认收集,默认为true,当值为false时需要传入type参数给collect方法才会被收集
|
* 是否默认收集,默认为true,当值为false时需要传入type参数给collect方法才会被收集
|
||||||
*/
|
*/
|
||||||
public isCollectByDefault?: boolean;
|
public isCollectByDefault?: boolean;
|
||||||
|
/**
|
||||||
|
* 可序列化描述,有该描述的 target 可以在 worker 中重建,从而把依赖收集放到子线程执行
|
||||||
|
*/
|
||||||
|
public descriptor?: TargetDescriptor;
|
||||||
|
|
||||||
constructor(options: TargetOptions) {
|
constructor(options: TargetOptions) {
|
||||||
this.isTarget = options.isTarget;
|
this.isTarget = options.isTarget;
|
||||||
this.id = options.id;
|
this.id = options.id;
|
||||||
this.name = options.name;
|
this.name = options.name;
|
||||||
this.isCollectByDefault = options.isCollectByDefault ?? true;
|
this.isCollectByDefault = options.isCollectByDefault ?? true;
|
||||||
|
this.descriptor = options.descriptor;
|
||||||
if (options.type) {
|
if (options.type) {
|
||||||
this.type = options.type;
|
this.type = options.type;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import type { DepData } from '@tmagic/schema';
|
import type { CodeBlockContent, DataSourceSchema, DepData, Id } from '@tmagic/schema';
|
||||||
|
|
||||||
import type Target from './Target';
|
import type Target from './Target';
|
||||||
|
|
||||||
@ -17,6 +17,30 @@ export enum DepTargetType {
|
|||||||
|
|
||||||
export type IsTarget = (key: string | number, value: any, data?: Record<string, any>) => boolean;
|
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 {
|
export interface TargetOptions {
|
||||||
isTarget: IsTarget;
|
isTarget: IsTarget;
|
||||||
id: string | number;
|
id: string | number;
|
||||||
@ -26,6 +50,8 @@ export interface TargetOptions {
|
|||||||
initialDeps?: DepData;
|
initialDeps?: DepData;
|
||||||
/** 是否默认收集,默认为true,当值为false时需要传入type参数给collect方法才会被收集 */
|
/** 是否默认收集,默认为true,当值为false时需要传入type参数给collect方法才会被收集 */
|
||||||
isCollectByDefault?: boolean;
|
isCollectByDefault?: boolean;
|
||||||
|
/** 可序列化描述,用于在 worker 中重建该 target */
|
||||||
|
descriptor?: TargetDescriptor;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TargetList {
|
export interface TargetList {
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
type CodeBlockContent,
|
|
||||||
type DataSchema,
|
type DataSchema,
|
||||||
type DataSourceSchema,
|
type DataSourceSchema,
|
||||||
type DepData,
|
type DepData,
|
||||||
@ -16,16 +15,17 @@ import {
|
|||||||
} from '@tmagic/utils';
|
} from '@tmagic/utils';
|
||||||
|
|
||||||
import Target from './Target';
|
import Target from './Target';
|
||||||
import { DepTargetType, type TargetList } from './types';
|
import { type CodeBlockName, DepTargetType, type TargetDescriptor, type TargetList } from './types';
|
||||||
|
|
||||||
const INTEGER_REGEXP = /^\d+$/;
|
const INTEGER_REGEXP = /^\d+$/;
|
||||||
|
|
||||||
export const createCodeBlockTarget = (id: Id, codeBlock: CodeBlockContent, initialDeps: DepData = {}) =>
|
export const createCodeBlockTarget = (id: Id, codeBlock: CodeBlockName, initialDeps: DepData = {}) =>
|
||||||
new Target({
|
new Target({
|
||||||
type: DepTargetType.CODE_BLOCK,
|
type: DepTargetType.CODE_BLOCK,
|
||||||
id,
|
id,
|
||||||
initialDeps,
|
initialDeps,
|
||||||
name: codeBlock.name,
|
name: codeBlock.name,
|
||||||
|
descriptor: { type: DepTargetType.CODE_BLOCK, id, codeBlock: { name: codeBlock.name } },
|
||||||
isTarget: (_key: string | number, value: any) => {
|
isTarget: (_key: string | number, value: any) => {
|
||||||
if (id === value) {
|
if (id === value) {
|
||||||
return true;
|
return true;
|
||||||
@ -238,6 +238,8 @@ export const createDataSourceTarget = (ds: Pick<DataSourceSchema, 'id' | 'fields
|
|||||||
type: DepTargetType.DATA_SOURCE,
|
type: DepTargetType.DATA_SOURCE,
|
||||||
id: ds.id,
|
id: ds.id,
|
||||||
initialDeps,
|
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),
|
isTarget: (key: string | number, value: any) => isDataSourceTarget(ds, key, value),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -246,6 +248,8 @@ export const createDataSourceCondTarget = (ds: Pick<DataSourceSchema, 'id' | 'fi
|
|||||||
type: DepTargetType.DATA_SOURCE_COND,
|
type: DepTargetType.DATA_SOURCE_COND,
|
||||||
id: ds.id,
|
id: ds.id,
|
||||||
initialDeps,
|
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),
|
isTarget: (key: string | number, value: any) => isDataSourceCondTarget(ds, key, value),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -257,6 +261,15 @@ export const createDataSourceMethodTarget = (
|
|||||||
type: DepTargetType.DATA_SOURCE_METHOD,
|
type: DepTargetType.DATA_SOURCE_METHOD,
|
||||||
id: ds.id,
|
id: ds.id,
|
||||||
initialDeps,
|
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) => {
|
isTarget: (_key: string | number, value: any) => {
|
||||||
// 使用data-source-method-select 可以配置出来
|
// 使用data-source-method-select 可以配置出来
|
||||||
if (!Array.isArray(value)) {
|
if (!Array.isArray(value)) {
|
||||||
@ -282,6 +295,31 @@ 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 = (
|
export const traverseTarget = (
|
||||||
targetsList: TargetList,
|
targetsList: TargetList,
|
||||||
cb: (target: Target) => void,
|
cb: (target: Target) => void,
|
||||||
|
|||||||
@ -266,6 +266,65 @@ describe('utils', () => {
|
|||||||
expect(t3.isTarget('k', ['ds_1', 'unknown'])).toBe(true);
|
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', () => {
|
test('traverseTarget 遍历所有 / 指定 type', () => {
|
||||||
const t1 = new Target({ id: '1', isTarget: () => true, type: 'a' });
|
const t1 = new Target({ id: '1', isTarget: () => true, type: 'a' });
|
||||||
const t2 = new Target({ id: '2', isTarget: () => true, type: 'b' });
|
const t2 = new Target({ id: '2', isTarget: () => true, type: 'b' });
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.0-beta.15",
|
"version": "1.8.0-beta.20",
|
||||||
"name": "@tmagic/design",
|
"name": "@tmagic/design",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": [
|
"sideEffects": [
|
||||||
|
|||||||
@ -1,5 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<component :is="uiComponent" :class="['tmagic-design-card', { 'tmagic-design-card--flat': isFlat }]" v-bind="uiProps">
|
<component
|
||||||
|
:is="uiComponent"
|
||||||
|
:class="['tmagic-design-card', { 'tmagic-design-card--flat': isFlat, 'm-flat-hide-body': isHideBody }]"
|
||||||
|
v-bind="uiProps"
|
||||||
|
>
|
||||||
<template #header v-if="$slots.header">
|
<template #header v-if="$slots.header">
|
||||||
<slot name="header" class="header"></slot>
|
<slot name="header" class="header"></slot>
|
||||||
</template>
|
</template>
|
||||||
@ -30,7 +34,7 @@ const uiComponent = ui?.component || 'el-card';
|
|||||||
// 当祖先 `<MEditor>` / `<MForm>` 的 theme 是 `magic-admin` 时,整套设计语言默认走「无卡片感」,
|
// 当祖先 `<MEditor>` / `<MForm>` 的 theme 是 `magic-admin` 时,整套设计语言默认走「无卡片感」,
|
||||||
// 因此这里自动等价于 `flat=true`;调用方仍可显式传 `flat` 强制开启。
|
// 因此这里自动等价于 `flat=true`;调用方仍可显式传 `flat` 强制开启。
|
||||||
const isFlat = computed(() => !!props.flat || isGlobalFlat.value);
|
const isFlat = computed(() => !!props.flat || isGlobalFlat.value);
|
||||||
|
const isHideBody = computed(() => isFlat.value && props.bodyStyle?.display === 'none');
|
||||||
// 把 `flat` 从转发给底层 UI 库(el-card / t-card 等)的 props 中剥离:
|
// 把 `flat` 从转发给底层 UI 库(el-card / t-card 等)的 props 中剥离:
|
||||||
// 它仅作用于本组件的视觉修饰类,不需要也不应该作为属性落到底层组件 / DOM 上。
|
// 它仅作用于本组件的视觉修饰类,不需要也不应该作为属性落到底层组件 / DOM 上。
|
||||||
const uiProps = computed<CardProps>(() => {
|
const uiProps = computed<CardProps>(() => {
|
||||||
|
|||||||
@ -11,7 +11,11 @@
|
|||||||
border-right: 0;
|
border-right: 0;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
border-bottom: 0;
|
border-bottom: 0;
|
||||||
|
&.m-flat-hide-body {
|
||||||
|
.el-card__header {
|
||||||
|
border-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
&.m-fields-group-list-item {
|
&.m-fields-group-list-item {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
@ -29,6 +33,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.m-fields-group-list-footer {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
.el-card__body {
|
.el-card__body {
|
||||||
padding: 0 16px 0 16px;
|
padding: 0 16px 0 16px;
|
||||||
// 这里每个form-item应该要有一个16px间距,但是除了tbody中的form-item
|
// 这里每个form-item应该要有一个16px间距,但是除了tbody中的form-item
|
||||||
@ -61,7 +69,7 @@
|
|||||||
}
|
}
|
||||||
> .el-card__body {
|
> .el-card__body {
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
padding-top: 16px;
|
padding-top: 8px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.0-beta.15",
|
"version": "1.8.0-beta.20",
|
||||||
"name": "@tmagic/editor",
|
"name": "@tmagic/editor",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": [
|
"sideEffects": [
|
||||||
|
|||||||
@ -218,6 +218,7 @@ const stageOptions: StageOptions = {
|
|||||||
containerHighlightClassName: props.containerHighlightClassName,
|
containerHighlightClassName: props.containerHighlightClassName,
|
||||||
containerHighlightDuration: props.containerHighlightDuration,
|
containerHighlightDuration: props.containerHighlightDuration,
|
||||||
containerHighlightType: props.containerHighlightType,
|
containerHighlightType: props.containerHighlightType,
|
||||||
|
containerHighlightAddOnly: props.containerHighlightAddOnly,
|
||||||
disabledDragStart: props.disabledDragStart,
|
disabledDragStart: props.disabledDragStart,
|
||||||
renderType: props.renderType,
|
renderType: props.renderType,
|
||||||
guidesOptions: props.guidesOptions,
|
guidesOptions: props.guidesOptions,
|
||||||
|
|||||||
@ -82,6 +82,11 @@ export interface EditorProps {
|
|||||||
containerHighlightDuration?: number;
|
containerHighlightDuration?: number;
|
||||||
/** 拖入画布中容器时,识别容器的操作类型 */
|
/** 拖入画布中容器时,识别容器的操作类型 */
|
||||||
containerHighlightType?: ContainerHighlightType;
|
containerHighlightType?: ContainerHighlightType;
|
||||||
|
/**
|
||||||
|
* 是否仅在新增组件(从组件列表拖入新组件)时才启用识别容器,
|
||||||
|
* 开启后在画布中拖动已有组件不会识别容器,默认 false
|
||||||
|
*/
|
||||||
|
containerHighlightAddOnly?: boolean;
|
||||||
/** 画布大小 */
|
/** 画布大小 */
|
||||||
stageRect?: StageRect;
|
stageRect?: StageRect;
|
||||||
/** monaco editor 的配置 */
|
/** monaco editor 的配置 */
|
||||||
@ -163,6 +168,7 @@ export const defaultEditorProps = {
|
|||||||
containerHighlightClassName: CONTAINER_HIGHLIGHT_CLASS_NAME,
|
containerHighlightClassName: CONTAINER_HIGHLIGHT_CLASS_NAME,
|
||||||
containerHighlightDuration: 800,
|
containerHighlightDuration: 800,
|
||||||
containerHighlightType: ContainerHighlightType.DEFAULT,
|
containerHighlightType: ContainerHighlightType.DEFAULT,
|
||||||
|
containerHighlightAddOnly: false,
|
||||||
disabledShowSrc: false,
|
disabledShowSrc: false,
|
||||||
disabledDataSource: false,
|
disabledDataSource: false,
|
||||||
disabledCodeBlock: false,
|
disabledCodeBlock: false,
|
||||||
|
|||||||
@ -37,13 +37,12 @@
|
|||||||
:model="entry.cardItem"
|
:model="entry.cardItem"
|
||||||
:last-values="entry.lastCardItem"
|
:last-values="entry.lastCardItem"
|
||||||
:is-compare="isCompareMode"
|
:is-compare="isCompareMode"
|
||||||
:hide-expand="true"
|
:hide-expand="false"
|
||||||
:label-width="config.labelWidth || '100px'"
|
:label-width="config.labelWidth || '100px'"
|
||||||
@change="onChangeHandler"
|
@change="onChangeHandler"
|
||||||
>
|
>
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="event-item-header">
|
<div class="event-item-header">
|
||||||
<div class="event-item-title">事件{{ Number(entry.index) + 1 }}</div>
|
|
||||||
<MFormContainer
|
<MFormContainer
|
||||||
class="fullWidth"
|
class="fullWidth"
|
||||||
:config="eventNameConfig"
|
:config="eventNameConfig"
|
||||||
@ -210,6 +209,12 @@ const actionTypeConfig = computed(() => {
|
|||||||
trigger: 'blur',
|
trigger: 'blur',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
onChange: (_mForm: FormState, _v: string, { setModel }: any) => {
|
||||||
|
setModel('to', '');
|
||||||
|
setModel('method', '');
|
||||||
|
setModel('codeId', '');
|
||||||
|
setModel('dataSourceMethod', []);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
return { ...defaultActionTypeConfig, ...props.config.actionTypeConfig };
|
return { ...defaultActionTypeConfig, ...props.config.actionTypeConfig };
|
||||||
});
|
});
|
||||||
@ -222,7 +227,7 @@ const targetCompConfig = computed(() => {
|
|||||||
type: 'ui-select',
|
type: 'ui-select',
|
||||||
labelPosition: 'left',
|
labelPosition: 'left',
|
||||||
display: (_mForm, { model }) => model.actionType === ActionType.COMP,
|
display: (_mForm, { model }) => model.actionType === ActionType.COMP,
|
||||||
onChange: (_MForm, _v, { setModel }) => {
|
onChange: (_mForm, _v, { setModel }) => {
|
||||||
setModel('method', '');
|
setModel('method', '');
|
||||||
},
|
},
|
||||||
rules: [
|
rules: [
|
||||||
|
|||||||
@ -28,6 +28,7 @@ export const useStage = (stageOptions: StageOptions) => {
|
|||||||
containerHighlightClassName: stageOptions.containerHighlightClassName,
|
containerHighlightClassName: stageOptions.containerHighlightClassName,
|
||||||
containerHighlightDuration: stageOptions.containerHighlightDuration,
|
containerHighlightDuration: stageOptions.containerHighlightDuration,
|
||||||
containerHighlightType: stageOptions.containerHighlightType,
|
containerHighlightType: stageOptions.containerHighlightType,
|
||||||
|
containerHighlightAddOnly: stageOptions.containerHighlightAddOnly,
|
||||||
disabledDragStart: stageOptions.disabledDragStart,
|
disabledDragStart: stageOptions.disabledDragStart,
|
||||||
renderType: stageOptions.renderType,
|
renderType: stageOptions.renderType,
|
||||||
canSelect: (el, event, stop) => {
|
canSelect: (el, event, stop) => {
|
||||||
|
|||||||
@ -333,11 +333,11 @@ export const initServiceEvents = (
|
|||||||
Promise.all(
|
Promise.all(
|
||||||
nodes.map((node) => {
|
nodes.map((node) => {
|
||||||
if (node.type === NodeType.ROOT) {
|
if (node.type === NodeType.ROOT) {
|
||||||
return Promise.resolve();
|
return Promise.resolve(true);
|
||||||
}
|
}
|
||||||
return depService.collectIdle([node], { pageId: getPageIdByNode(node) }, deep, type);
|
return depService.collectIdle([node], { pageId: getPageIdByNode(node) }, deep, type);
|
||||||
}),
|
}),
|
||||||
);
|
).then((results) => results.every(Boolean));
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => editorService.get('stage'),
|
() => editorService.get('stage'),
|
||||||
@ -351,7 +351,7 @@ export const initServiceEvents = (
|
|||||||
|
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
|
|
||||||
await collectIdle([node], true, DepTargetType.DATA_SOURCE);
|
if (!(await collectIdle([node], true, DepTargetType.DATA_SOURCE))) return;
|
||||||
updateStageNode(node);
|
updateStageNode(node);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@ -494,8 +494,10 @@ export const initServiceEvents = (
|
|||||||
|
|
||||||
// 新增节点,收集依赖
|
// 新增节点,收集依赖
|
||||||
const nodeAddHandler = (nodes: MComponent[]) => {
|
const nodeAddHandler = (nodes: MComponent[]) => {
|
||||||
collectIdle(nodes, true).then(() => {
|
collectIdle(nodes, true).then((completed) => {
|
||||||
|
if (completed) {
|
||||||
updateStageNodes(nodes);
|
updateStageNodes(nodes);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -549,8 +551,8 @@ export const initServiceEvents = (
|
|||||||
if (needRecollectNodes.length) {
|
if (needRecollectNodes.length) {
|
||||||
// 有数据源依赖,需要等依赖重新收集完才更新stage
|
// 有数据源依赖,需要等依赖重新收集完才更新stage
|
||||||
const handler = async () => {
|
const handler = async () => {
|
||||||
await collectIdle(needRecollectNodes, true, DepTargetType.DATA_SOURCE);
|
if (!(await collectIdle(needRecollectNodes, true, DepTargetType.DATA_SOURCE))) return;
|
||||||
await collectIdle(needRecollectNodes, true, DepTargetType.DATA_SOURCE_COND);
|
if (!(await collectIdle(needRecollectNodes, true, DepTargetType.DATA_SOURCE_COND))) return;
|
||||||
updateStageNodes(needRecollectNodes);
|
updateStageNodes(needRecollectNodes);
|
||||||
};
|
};
|
||||||
handler();
|
handler();
|
||||||
@ -572,8 +574,10 @@ export const initServiceEvents = (
|
|||||||
|
|
||||||
// 历史记录变化时,需要重新收集依赖
|
// 历史记录变化时,需要重新收集依赖
|
||||||
const historyChangeHandler = (page: MPage | MPageFragment) => {
|
const historyChangeHandler = (page: MPage | MPageFragment) => {
|
||||||
collectIdle([page], true).then(() => {
|
collectIdle([page], true).then((completed) => {
|
||||||
|
if (completed) {
|
||||||
updateStageNode(page);
|
updateStageNode(page);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -654,7 +658,7 @@ export const initServiceEvents = (
|
|||||||
if (Array.isArray(root?.items)) {
|
if (Array.isArray(root?.items)) {
|
||||||
depService.clearIdleTasks();
|
depService.clearIdleTasks();
|
||||||
|
|
||||||
let collectIdlePromises: Promise<void[]>[] = [];
|
let collectIdlePromises: Promise<boolean>[] = [];
|
||||||
if (isModifyField) {
|
if (isModifyField) {
|
||||||
depService.removeTarget(config.id, DepTargetType.DATA_SOURCE);
|
depService.removeTarget(config.id, DepTargetType.DATA_SOURCE);
|
||||||
depService.removeTarget(config.id, DepTargetType.DATA_SOURCE_COND);
|
depService.removeTarget(config.id, DepTargetType.DATA_SOURCE_COND);
|
||||||
@ -679,10 +683,15 @@ export const initServiceEvents = (
|
|||||||
|
|
||||||
collectIdlePromises = [collectIdle(root.items, true, DepTargetType.DATA_SOURCE_METHOD)];
|
collectIdlePromises = [collectIdle(root.items, true, DepTargetType.DATA_SOURCE_METHOD)];
|
||||||
}
|
}
|
||||||
Promise.all(collectIdlePromises)
|
const handler = async () => {
|
||||||
.then(() => updateDataSourceSchema())
|
const results = await Promise.all(collectIdlePromises);
|
||||||
.then(() => updateDsData())
|
if (!results.every(Boolean)) return;
|
||||||
.then(() => updateStageNodes(root.items));
|
|
||||||
|
updateDataSourceSchema();
|
||||||
|
await updateDsData();
|
||||||
|
updateStageNodes(root.items);
|
||||||
|
};
|
||||||
|
handler();
|
||||||
}
|
}
|
||||||
} else if (root?.dataSources) {
|
} else if (root?.dataSources) {
|
||||||
updateDsData();
|
updateDsData();
|
||||||
@ -706,11 +715,12 @@ export const initServiceEvents = (
|
|||||||
const nodeIds = Object.keys(root.dataSourceDeps?.[id] || {});
|
const nodeIds = Object.keys(root.dataSourceDeps?.[id] || {});
|
||||||
const nodes = getNodes(nodeIds, root.items);
|
const nodes = getNodes(nodeIds, root.items);
|
||||||
|
|
||||||
await Promise.all([
|
const results = await Promise.all([
|
||||||
collectIdle(nodes, false, DepTargetType.DATA_SOURCE),
|
collectIdle(nodes, false, DepTargetType.DATA_SOURCE),
|
||||||
collectIdle(nodes, false, DepTargetType.DATA_SOURCE_COND),
|
collectIdle(nodes, false, DepTargetType.DATA_SOURCE_COND),
|
||||||
collectIdle(nodes, false, DepTargetType.DATA_SOURCE_METHOD),
|
collectIdle(nodes, false, DepTargetType.DATA_SOURCE_METHOD),
|
||||||
]);
|
]);
|
||||||
|
if (!results.every(Boolean)) return;
|
||||||
|
|
||||||
updateDataSourceSchema();
|
updateDataSourceSchema();
|
||||||
|
|
||||||
|
|||||||
@ -14,6 +14,7 @@
|
|||||||
:config="config"
|
:config="config"
|
||||||
:type-match-valid="true"
|
:type-match-valid="true"
|
||||||
:extend-state="extendState"
|
:extend-state="extendState"
|
||||||
|
:validate-on-init="true"
|
||||||
@change="submit"
|
@change="submit"
|
||||||
@error="errorHandler"
|
@error="errorHandler"
|
||||||
></MForm>
|
></MForm>
|
||||||
@ -49,14 +50,13 @@ import { Document as DocumentIcon } from '@element-plus/icons-vue';
|
|||||||
|
|
||||||
import { TMagicButton, tMagicMessage, TMagicScrollbar } from '@tmagic/design';
|
import { TMagicButton, tMagicMessage, TMagicScrollbar } from '@tmagic/design';
|
||||||
import type { ContainerChangeEventData, FormConfig, FormState, FormValue } from '@tmagic/form';
|
import type { ContainerChangeEventData, FormConfig, FormState, FormValue } from '@tmagic/form';
|
||||||
import { MForm } from '@tmagic/form';
|
import { MForm, validateForm } from '@tmagic/form';
|
||||||
import { filterXSS } from '@tmagic/utils';
|
import { filterXSS } from '@tmagic/utils';
|
||||||
|
|
||||||
import MIcon from '@editor/components/Icon.vue';
|
import MIcon from '@editor/components/Icon.vue';
|
||||||
import { ENABLE_PROPS_FORM_VALIDATE } from '@editor/editorProps';
|
import { ENABLE_PROPS_FORM_VALIDATE } from '@editor/editorProps';
|
||||||
import { useEditorContentHeight } from '@editor/hooks/use-editor-content-height';
|
import { useEditorContentHeight } from '@editor/hooks/use-editor-content-height';
|
||||||
import { useServices } from '@editor/hooks/use-services';
|
import { useServices } from '@editor/hooks/use-services';
|
||||||
import { validatePropsForm } from '@editor/utils/props';
|
|
||||||
|
|
||||||
import CodeEditor from '../CodeEditor.vue';
|
import CodeEditor from '../CodeEditor.vue';
|
||||||
|
|
||||||
@ -162,14 +162,19 @@ const saveCode = async (values: any) => {
|
|||||||
// 做一次静默校验(不复用、也不污染页面上正在展示的表单),并将校验结果(错误信息)随提交
|
// 做一次静默校验(不复用、也不污染页面上正在展示的表单),并将校验结果(错误信息)随提交
|
||||||
// 一并抛给上层记录,使源码保存的错误状态与表单编辑保持一致。
|
// 一并抛给上层记录,使源码保存的错误状态与表单编辑保持一致。
|
||||||
try {
|
try {
|
||||||
const error = await validatePropsForm({
|
const error = await validateForm({
|
||||||
config: props.config,
|
config: props.config,
|
||||||
values: newValues,
|
|
||||||
appContext: internalInstance?.appContext ?? null,
|
|
||||||
services,
|
|
||||||
stage: stage.value,
|
|
||||||
typeMatchValid: true,
|
typeMatchValid: true,
|
||||||
extendState: props.extendState,
|
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) || {};
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|||||||
@ -145,6 +145,6 @@ const dragHandler = (e: DragEvent) => {
|
|||||||
|
|
||||||
if (timeout || !stage.value) return;
|
if (timeout || !stage.value) return;
|
||||||
|
|
||||||
timeout = stage.value.delayedMarkContainer(e);
|
timeout = stage.value.delayedMarkContainer(e, [], true);
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
import { computed, markRaw, useTemplateRef } from 'vue';
|
import { computed, markRaw, useTemplateRef } from 'vue';
|
||||||
import { Files, Plus } from '@element-plus/icons-vue';
|
import { Files, Plus } from '@element-plus/icons-vue';
|
||||||
|
|
||||||
import { isPage, isPageFragment } from '@tmagic/utils';
|
import { isPageOrFragment } from '@tmagic/utils';
|
||||||
|
|
||||||
import ContentMenu from '@editor/components/ContentMenu.vue';
|
import ContentMenu from '@editor/components/ContentMenu.vue';
|
||||||
import { useServices } from '@editor/hooks/use-services';
|
import { useServices } from '@editor/hooks/use-services';
|
||||||
@ -101,7 +101,7 @@ const menuData = computed<(MenuButton | MenuComponent)[]>(() =>
|
|||||||
type: 'button',
|
type: 'button',
|
||||||
text: '全部折叠',
|
text: '全部折叠',
|
||||||
icon: FolderMinusIcon,
|
icon: FolderMinusIcon,
|
||||||
display: () => isPage(node.value) || isPageFragment(node.value),
|
display: () => isPageOrFragment(node.value),
|
||||||
handler: () => {
|
handler: () => {
|
||||||
emit('collapse-all');
|
emit('collapse-all');
|
||||||
},
|
},
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { computed, type ComputedRef, nextTick, type Ref, type ShallowRef } from
|
|||||||
import { throttle } from 'lodash-es';
|
import { throttle } from 'lodash-es';
|
||||||
|
|
||||||
import { Id, MNode } from '@tmagic/core';
|
import { Id, MNode } from '@tmagic/core';
|
||||||
import { getElById, isPage, isPageFragment } from '@tmagic/utils';
|
import { getElById, isPageOrFragment } from '@tmagic/utils';
|
||||||
|
|
||||||
import type { LayerNodeStatus, Services, TreeNodeData } from '@editor/type';
|
import type { LayerNodeStatus, Services, TreeNodeData } from '@editor/type';
|
||||||
import { UI_SELECT_MODE_EVENT_NAME } from '@editor/utils/const';
|
import { UI_SELECT_MODE_EVENT_NAME } from '@editor/utils/const';
|
||||||
@ -36,7 +36,7 @@ export const useClick = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const multiSelect = async (data: MNode) => {
|
const multiSelect = async (data: MNode) => {
|
||||||
if (isPage(data) || isPageFragment(data)) {
|
if (isPageOrFragment(data)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -50,7 +50,7 @@ export const useClick = (
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isPage(node) || isPageFragment(node)) {
|
if (isPageOrFragment(node)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||||
|
|
||||||
import type { Id, MApp, MNode, MPage, MPageFragment } from '@tmagic/core';
|
import type { Id, MApp, MNode, MPage, MPageFragment } from '@tmagic/core';
|
||||||
import { getNodePath, isPage, isPageFragment, traverseNode } from '@tmagic/utils';
|
import { getNodePath, isPageOrFragment, traverseNode } from '@tmagic/utils';
|
||||||
|
|
||||||
import type { LayerNodeStatus, Services } from '@editor/type';
|
import type { LayerNodeStatus, Services } from '@editor/type';
|
||||||
import { updateStatus } from '@editor/utils/tree';
|
import { updateStatus } from '@editor/utils/tree';
|
||||||
@ -136,7 +136,7 @@ export const useNodeStatus = ({ editorService }: Services) => {
|
|||||||
|
|
||||||
const addHandler = (newNodes: MNode[]) => {
|
const addHandler = (newNodes: MNode[]) => {
|
||||||
newNodes.forEach((node) => {
|
newNodes.forEach((node) => {
|
||||||
if (isPage(node) || isPageFragment(node)) return;
|
if (isPageOrFragment(node)) return;
|
||||||
|
|
||||||
traverseNode(node, (node: MNode) => {
|
traverseNode(node, (node: MNode) => {
|
||||||
nodeStatusMap.value?.set(node.id, {
|
nodeStatusMap.value?.set(node.id, {
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import { computed, markRaw, ref, useTemplateRef, watch } from 'vue';
|
|||||||
import { Bottom, Top } from '@element-plus/icons-vue';
|
import { Bottom, Top } from '@element-plus/icons-vue';
|
||||||
|
|
||||||
import { NodeType } from '@tmagic/core';
|
import { NodeType } from '@tmagic/core';
|
||||||
import { isPage, isPageFragment } from '@tmagic/utils';
|
import { isPageOrFragment } from '@tmagic/utils';
|
||||||
|
|
||||||
import ContentMenu from '@editor/components/ContentMenu.vue';
|
import ContentMenu from '@editor/components/ContentMenu.vue';
|
||||||
import { useServices } from '@editor/hooks/use-services';
|
import { useServices } from '@editor/hooks/use-services';
|
||||||
@ -59,14 +59,14 @@ const menuData = computed<(MenuButton | MenuComponent)[]>(() =>
|
|||||||
direction: 'horizontal',
|
direction: 'horizontal',
|
||||||
display: () => {
|
display: () => {
|
||||||
if (!node.value) return false;
|
if (!node.value) return false;
|
||||||
return !isPage(node.value) && !isPageFragment(node.value);
|
return !isPageOrFragment(node.value);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'button',
|
type: 'button',
|
||||||
text: '上移一层',
|
text: '上移一层',
|
||||||
icon: markRaw(Top),
|
icon: markRaw(Top),
|
||||||
display: () => !isPage(node.value) && !isPageFragment(node.value) && !props.isMultiSelect,
|
display: () => !isPageOrFragment(node.value) && !props.isMultiSelect,
|
||||||
handler: () => {
|
handler: () => {
|
||||||
editorService.moveLayer(1, { historySource: 'stage-contextmenu' });
|
editorService.moveLayer(1, { historySource: 'stage-contextmenu' });
|
||||||
},
|
},
|
||||||
@ -75,7 +75,7 @@ const menuData = computed<(MenuButton | MenuComponent)[]>(() =>
|
|||||||
type: 'button',
|
type: 'button',
|
||||||
text: '下移一层',
|
text: '下移一层',
|
||||||
icon: markRaw(Bottom),
|
icon: markRaw(Bottom),
|
||||||
display: () => !isPage(node.value) && !isPageFragment(node.value) && !props.isMultiSelect,
|
display: () => !isPageOrFragment(node.value) && !props.isMultiSelect,
|
||||||
handler: () => {
|
handler: () => {
|
||||||
editorService.moveLayer(-1, { historySource: 'stage-contextmenu' });
|
editorService.moveLayer(-1, { historySource: 'stage-contextmenu' });
|
||||||
},
|
},
|
||||||
@ -84,7 +84,7 @@ const menuData = computed<(MenuButton | MenuComponent)[]>(() =>
|
|||||||
type: 'button',
|
type: 'button',
|
||||||
text: '置顶',
|
text: '置顶',
|
||||||
icon: markRaw(Top),
|
icon: markRaw(Top),
|
||||||
display: () => !isPage(node.value) && !isPageFragment(node.value) && !props.isMultiSelect,
|
display: () => !isPageOrFragment(node.value) && !props.isMultiSelect,
|
||||||
handler: () => {
|
handler: () => {
|
||||||
editorService.moveLayer(LayerOffset.TOP, { historySource: 'stage-contextmenu' });
|
editorService.moveLayer(LayerOffset.TOP, { historySource: 'stage-contextmenu' });
|
||||||
},
|
},
|
||||||
@ -93,7 +93,7 @@ const menuData = computed<(MenuButton | MenuComponent)[]>(() =>
|
|||||||
type: 'button',
|
type: 'button',
|
||||||
text: '置底',
|
text: '置底',
|
||||||
icon: markRaw(Bottom),
|
icon: markRaw(Bottom),
|
||||||
display: () => !isPage(node.value) && !isPageFragment(node.value) && !props.isMultiSelect,
|
display: () => !isPageOrFragment(node.value) && !props.isMultiSelect,
|
||||||
handler: () => {
|
handler: () => {
|
||||||
editorService.moveLayer(LayerOffset.BOTTOM, { historySource: 'stage-contextmenu' });
|
editorService.moveLayer(LayerOffset.BOTTOM, { historySource: 'stage-contextmenu' });
|
||||||
},
|
},
|
||||||
@ -102,7 +102,7 @@ const menuData = computed<(MenuButton | MenuComponent)[]>(() =>
|
|||||||
{
|
{
|
||||||
type: 'divider',
|
type: 'divider',
|
||||||
direction: 'horizontal',
|
direction: 'horizontal',
|
||||||
display: () => !isPage(node.value) && !isPageFragment(node.value) && !props.isMultiSelect,
|
display: () => !isPageOrFragment(node.value) && !props.isMultiSelect,
|
||||||
},
|
},
|
||||||
useDeleteMenu('stage-contextmenu'),
|
useDeleteMenu('stage-contextmenu'),
|
||||||
{
|
{
|
||||||
|
|||||||
@ -17,14 +17,14 @@
|
|||||||
*/
|
*/
|
||||||
import { reactive, shallowReactive } from 'vue';
|
import { reactive, shallowReactive } from 'vue';
|
||||||
import { throttle } from 'lodash-es';
|
import { throttle } from 'lodash-es';
|
||||||
import serialize from 'serialize-javascript';
|
|
||||||
|
|
||||||
import type { DepData, DepExtendedData, Id, MApp, MNode, Target, TargetNode } from '@tmagic/core';
|
import type { DepData, DepExtendedData, Id, MApp, MNode, Target } from '@tmagic/core';
|
||||||
import { DepTargetType, traverseTarget, Watcher } from '@tmagic/core';
|
import { DepTargetType, traverseTarget, Watcher } from '@tmagic/core';
|
||||||
import { isPage } from '@tmagic/utils';
|
import { isPage } from '@tmagic/utils';
|
||||||
|
|
||||||
|
import { CollectWorkerClient } from '@editor/utils/dep/collect-worker-client';
|
||||||
import { IdleTask } from '@editor/utils/dep/idle-task';
|
import { IdleTask } from '@editor/utils/dep/idle-task';
|
||||||
import Work from '@editor/utils/dep/worker.ts?worker&inline';
|
import type { DepsData } from '@editor/utils/dep/worker';
|
||||||
|
|
||||||
import BaseService from './BaseService';
|
import BaseService from './BaseService';
|
||||||
|
|
||||||
@ -42,18 +42,42 @@ interface State {
|
|||||||
|
|
||||||
type StateKey = keyof State;
|
type StateKey = keyof State;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一次 collectIdle 调用对应一个批次。批次自行统计入队/完成的任务数,在自身任务全部完成或被中断时
|
||||||
|
* 结算对应的 Promise,避免多次快速触发时共用 idleTask 的 finish 事件造成的串扰、提前 resolve,
|
||||||
|
* 以及 clearTasks 后 Promise 永不 resolve(collecting 卡死、监听器泄漏)。
|
||||||
|
*/
|
||||||
|
interface CollectBatch {
|
||||||
|
nodes: MNode[];
|
||||||
|
deep: boolean;
|
||||||
|
pending: number;
|
||||||
|
dsPending: number;
|
||||||
|
collectedEmitted: boolean;
|
||||||
|
dsSettled: boolean;
|
||||||
|
aborted: boolean;
|
||||||
|
resolve: (completed: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
class Dep extends BaseService {
|
class Dep extends BaseService {
|
||||||
private state = shallowReactive<State>({
|
private state = shallowReactive<State>({
|
||||||
collecting: false,
|
collecting: false,
|
||||||
taskLength: 0,
|
taskLength: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
private idleTask = new IdleTask<{ node: TargetNode; deep: boolean; target: Target }>();
|
private idleTask = new IdleTask<void>();
|
||||||
|
|
||||||
|
private collectWorker = new CollectWorkerClient();
|
||||||
|
|
||||||
private watcher = new Watcher({ initialTargets: reactive({}) });
|
private watcher = new Watcher({ initialTargets: reactive({}) });
|
||||||
|
|
||||||
private waitingWorker?: Promise<void>;
|
private waitingWorker?: Promise<void>;
|
||||||
|
|
||||||
|
private resolveWaitingWorker?: () => void;
|
||||||
|
|
||||||
|
private workerGeneration = 0;
|
||||||
|
|
||||||
|
private activeBatches = new Set<CollectBatch>();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
@ -132,56 +156,77 @@ class Dep extends BaseService {
|
|||||||
this.emit('ds-collected', nodes, deep);
|
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) {
|
public async collectIdle(nodes: MNode[], depExtendedData: DepExtendedData = {}, deep = false, type?: DepTargetType) {
|
||||||
if (this.waitingWorker) {
|
if (this.waitingWorker) {
|
||||||
await this.waitingWorker;
|
await this.waitingWorker;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.set('collecting', true);
|
const batch: CollectBatch = {
|
||||||
let startTask = false;
|
nodes,
|
||||||
this.watcher.collectByCallback(nodes, type, ({ node, target }) => {
|
deep,
|
||||||
startTask = true;
|
pending: 0,
|
||||||
|
dsPending: 0,
|
||||||
|
collectedEmitted: false,
|
||||||
|
dsSettled: false,
|
||||||
|
aborted: false,
|
||||||
|
resolve: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
this.enqueueTask(node, target, depExtendedData, deep);
|
const workerTargets: Target[] = [];
|
||||||
});
|
|
||||||
|
|
||||||
return new Promise<void>((resolve) => {
|
for (const target of this.watcher.getCollectableTargets(type)) {
|
||||||
if (!startTask) {
|
if (target.descriptor && this.collectWorker.isSupported) {
|
||||||
this.emit('collected', nodes, deep);
|
workerTargets.push(target);
|
||||||
this.set('collecting', false);
|
} else {
|
||||||
resolve();
|
this.enqueueTasks(nodes, target, depExtendedData, deep, batch);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
this.idleTask.once('finish', () => {
|
}
|
||||||
|
|
||||||
|
if (workerTargets.length && nodes.length) {
|
||||||
|
this.collectByCollectWorker(nodes, workerTargets, depExtendedData, deep, batch);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 没有命中任何 target,无需收集,直接完成
|
||||||
|
if (batch.pending === 0) {
|
||||||
this.emit('collected', nodes, deep);
|
this.emit('collected', nodes, deep);
|
||||||
this.set('collecting', false);
|
this.updateCollectingState();
|
||||||
});
|
return true;
|
||||||
this.idleTask.once('hight-level-finish', () => {
|
}
|
||||||
this.emit('ds-collected', nodes, deep);
|
|
||||||
resolve();
|
this.activeBatches.add(batch);
|
||||||
});
|
this.set('collecting', true);
|
||||||
|
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
batch.resolve = resolve;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public collectByWorker(dsl: MApp) {
|
public collectByWorker(dsl: MApp) {
|
||||||
this.set('collecting', true);
|
this.set('collecting', true);
|
||||||
|
this.workerGeneration += 1;
|
||||||
|
const generation = this.workerGeneration;
|
||||||
|
|
||||||
const { promise, resolve: waitingResolve } = Promise.withResolvers<void>();
|
const { promise, resolve: waitingResolve } = Promise.withResolvers<void>();
|
||||||
|
|
||||||
this.waitingWorker = promise;
|
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) => {
|
traverseTarget(this.watcher.getTargetsList(), (target) => {
|
||||||
if (depsData[target.type]?.[target.id]) {
|
if (depsData[target.type]?.[target.id]) {
|
||||||
target.deps = reactive(depsData[target.type][target.id]);
|
target.deps = reactive(depsData[target.type][target.id]);
|
||||||
@ -200,6 +245,10 @@ class Dep extends BaseService {
|
|||||||
this.emit('collected', dsl.items, true);
|
this.emit('collected', dsl.items, true);
|
||||||
this.emit('ds-collected', dsl.items, true);
|
this.emit('ds-collected', dsl.items, true);
|
||||||
waitingResolve();
|
waitingResolve();
|
||||||
|
if (this.waitingWorker === promise) {
|
||||||
|
this.waitingWorker = undefined;
|
||||||
|
this.resolveWaitingWorker = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
return depsData;
|
return depsData;
|
||||||
});
|
});
|
||||||
@ -209,8 +258,12 @@ class Dep extends BaseService {
|
|||||||
// 先删除原有依赖,重新收集
|
// 先删除原有依赖,重新收集
|
||||||
if (isPage(node)) {
|
if (isPage(node)) {
|
||||||
this.removePageDep(target, depExtendedData);
|
this.removePageDep(target, depExtendedData);
|
||||||
} else {
|
} else if (deep) {
|
||||||
|
// deep 收集会覆盖整棵子树,删除也要同步清掉子孙旧依赖
|
||||||
this.watcher.removeTargetDep(target, node);
|
this.watcher.removeTargetDep(target, node);
|
||||||
|
} else {
|
||||||
|
// 非 deep 只动当前节点,避免误清子节点上其他尚未重收的依赖
|
||||||
|
target.removeDep(node.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.watcher.collectItem(node, target, depExtendedData, deep);
|
this.watcher.collectItem(node, target, depExtendedData, deep);
|
||||||
@ -233,7 +286,10 @@ class Dep extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public clearIdleTasks() {
|
public clearIdleTasks() {
|
||||||
|
this.abortActiveBatches();
|
||||||
this.idleTask.clearTasks();
|
this.idleTask.clearTasks();
|
||||||
|
// 丢掉 worker 在途任务,避免 abort 后的长任务继续占着常驻 worker
|
||||||
|
this.collectWorker.abort();
|
||||||
}
|
}
|
||||||
|
|
||||||
public on<Name extends keyof DepEvents, Param extends DepEvents[Name]>(
|
public on<Name extends keyof DepEvents, Param extends DepEvents[Name]>(
|
||||||
@ -251,7 +307,13 @@ class Dep extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public reset() {
|
public reset() {
|
||||||
|
this.abortActiveBatches();
|
||||||
|
this.workerGeneration += 1;
|
||||||
|
this.resolveWaitingWorker?.();
|
||||||
|
this.resolveWaitingWorker = undefined;
|
||||||
|
this.waitingWorker = undefined;
|
||||||
this.idleTask.clearTasks();
|
this.idleTask.clearTasks();
|
||||||
|
this.collectWorker.abort();
|
||||||
|
|
||||||
for (const type of Object.keys(this.watcher.getTargetsList())) {
|
for (const type of Object.keys(this.watcher.getTargetsList())) {
|
||||||
this.removeTargets(type);
|
this.removeTargets(type);
|
||||||
@ -267,6 +329,7 @@ class Dep extends BaseService {
|
|||||||
this.reset();
|
this.reset();
|
||||||
this.removeAllPlugins();
|
this.removeAllPlugins();
|
||||||
this.idleTask.removeAllListeners();
|
this.idleTask.removeAllListeners();
|
||||||
|
this.collectWorker.terminate();
|
||||||
}
|
}
|
||||||
|
|
||||||
public emit<Name extends keyof DepEvents, Param extends DepEvents[Name]>(eventName: Name, ...args: Param) {
|
public emit<Name extends keyof DepEvents, Param extends DepEvents[Name]>(eventName: Name, ...args: Param) {
|
||||||
@ -286,25 +349,207 @@ class Dep extends BaseService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private enqueueTask(node: MNode, target: Target, depExtendedData: DepExtendedData, deep: boolean) {
|
/**
|
||||||
|
* 把遍历匹配交给 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);
|
||||||
|
|
||||||
this.idleTask.enqueueTask(
|
this.idleTask.enqueueTask(
|
||||||
({ node, deep, target }) => {
|
() => {
|
||||||
this.collectNode(node, target, depExtendedData, deep);
|
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);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
undefined,
|
||||||
node,
|
isDataSource,
|
||||||
deep: false,
|
);
|
||||||
target,
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
target.type === DepTargetType.DATA_SOURCE,
|
undefined,
|
||||||
|
isDataSource,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (deep && Array.isArray(node.items) && node.items.length) {
|
if (deep && Array.isArray(node.items) && node.items.length) {
|
||||||
node.items.forEach((item) => {
|
node.items.forEach((item) => {
|
||||||
this.enqueueTask(item, target, depExtendedData, deep);
|
this.enqueueTask(item, target, depExtendedData, deep, batch);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 永远不会 resolve(collecting 卡在 true、once 监听器泄漏)。
|
||||||
|
* 收集被中断(通常紧跟一次全量重新收集),因此不再 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;
|
export type DepService = Dep;
|
||||||
|
|||||||
@ -16,7 +16,7 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { reactive, toRaw } from 'vue';
|
import { nextTick, reactive, toRaw } from 'vue';
|
||||||
import { cloneDeep, isEmpty, isEqual, isObject, mergeWith, uniq } from 'lodash-es';
|
import { cloneDeep, isEmpty, isEqual, isObject, mergeWith, uniq } from 'lodash-es';
|
||||||
|
|
||||||
import type { Id, MApp, MContainer, MNode, MPage, MPageFragment, TargetOptions } from '@tmagic/core';
|
import type { Id, MApp, MContainer, MNode, MPage, MPageFragment, TargetOptions } from '@tmagic/core';
|
||||||
@ -30,6 +30,7 @@ import {
|
|||||||
guid,
|
guid,
|
||||||
isPage,
|
isPage,
|
||||||
isPageFragment,
|
isPageFragment,
|
||||||
|
isPageOrFragment,
|
||||||
setValueByKeyPath,
|
setValueByKeyPath,
|
||||||
traverseNode,
|
traverseNode,
|
||||||
} from '@tmagic/utils';
|
} from '@tmagic/utils';
|
||||||
@ -272,7 +273,7 @@ class Editor extends BaseService {
|
|||||||
public isOnDifferentPage(node: MNode): boolean {
|
public isOnDifferentPage(node: MNode): boolean {
|
||||||
const currentPageId = this.get('page')?.id;
|
const currentPageId = this.get('page')?.id;
|
||||||
if (currentPageId === undefined || currentPageId === null) return false;
|
if (currentPageId === undefined || currentPageId === null) return false;
|
||||||
if (isPage(node) || isPageFragment(node)) {
|
if (isPageOrFragment(node)) {
|
||||||
return `${node.id}` !== `${currentPageId}`;
|
return `${node.id}` !== `${currentPageId}`;
|
||||||
}
|
}
|
||||||
const nodePage = this.getNodeInfo(node.id, false).page;
|
const nodePage = this.getNodeInfo(node.id, false).page;
|
||||||
@ -413,11 +414,11 @@ class Editor extends BaseService {
|
|||||||
|
|
||||||
if (!curNode) throw new Error('当前选中节点为空');
|
if (!curNode) throw new Error('当前选中节点为空');
|
||||||
|
|
||||||
if ((parent.type === NodeType.ROOT || curNode?.type === NodeType.ROOT) && !(isPage(node) || isPageFragment(node))) {
|
if ((parent.type === NodeType.ROOT || curNode?.type === NodeType.ROOT) && !isPageOrFragment(node)) {
|
||||||
throw new Error('app下不能添加组件');
|
throw new Error('app下不能添加组件');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parent.id !== curNode.id && !(isPage(node) || isPageFragment(node))) {
|
if (parent.id !== curNode.id && !isPageOrFragment(node)) {
|
||||||
const index = parent.items.indexOf(curNode);
|
const index = parent.items.indexOf(curNode);
|
||||||
parent.items?.splice(index + 1, 0, node);
|
parent.items?.splice(index + 1, 0, node);
|
||||||
} else {
|
} else {
|
||||||
@ -487,7 +488,7 @@ class Editor extends BaseService {
|
|||||||
const newNodes = await Promise.all(
|
const newNodes = await Promise.all(
|
||||||
addNodes.map((node) => {
|
addNodes.map((node) => {
|
||||||
const root = this.get('root');
|
const root = this.get('root');
|
||||||
if ((isPage(node) || isPageFragment(node)) && root) {
|
if (isPageOrFragment(node) && root) {
|
||||||
return this.doAdd(node, root);
|
return this.doAdd(node, root);
|
||||||
}
|
}
|
||||||
const parentNode = parent ?? getAddParent(node);
|
const parentNode = parent ?? getAddParent(node);
|
||||||
@ -523,7 +524,7 @@ class Editor extends BaseService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(isPage(newNodes[0]) || isPageFragment(newNodes[0]))) {
|
if (!isPageOrFragment(newNodes[0])) {
|
||||||
const pageForOp = this.getNodeInfo(newNodes[0].id, false).page;
|
const pageForOp = this.getNodeInfo(newNodes[0].id, false).page;
|
||||||
if (!doNotPushHistory) {
|
if (!doNotPushHistory) {
|
||||||
const parentId = (this.getParentById(newNodes[0].id, false) ?? this.get('root'))!.id;
|
const parentId = (this.getParentById(newNodes[0].id, false) ?? this.get('root'))!.id;
|
||||||
@ -554,8 +555,8 @@ class Editor extends BaseService {
|
|||||||
doNotPushHistory,
|
doNotPushHistory,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 页面 / 页面片新增不入历史栈(见上方 isPage / isPageFragment 分支),这里合并补发一次结构变更通知
|
// 页面 / 页面片新增不入历史栈(见上方 isPageOrFragment 分支),这里合并补发一次结构变更通知
|
||||||
const addedPages = newNodes.filter((node) => isPage(node) || isPageFragment(node)) as (MPage | MPageFragment)[];
|
const addedPages = newNodes.filter((node) => isPageOrFragment(node)) as (MPage | MPageFragment)[];
|
||||||
if (addedPages.length) {
|
if (addedPages.length) {
|
||||||
historyService.notifyPageStructureChange({ add: addedPages, remove: [] });
|
historyService.notifyPageStructureChange({ add: addedPages, remove: [] });
|
||||||
}
|
}
|
||||||
@ -578,12 +579,14 @@ class Editor extends BaseService {
|
|||||||
|
|
||||||
if (typeof index !== 'number' || index === -1) throw new Error('找不要删除的节点');
|
if (typeof index !== 'number' || index === -1) throw new Error('找不要删除的节点');
|
||||||
|
|
||||||
parent.items?.splice(index, 1);
|
|
||||||
const stage = this.get('stage');
|
const stage = this.get('stage');
|
||||||
stage?.remove({ id: node.id, parentId: parent.id, root: cloneDeep(root) });
|
const currentPage = this.get('page');
|
||||||
|
const isDeletingCurrentPage = isPageOrFragment(node) && !!currentPage && `${currentPage.id}` === `${node.id}`;
|
||||||
|
|
||||||
|
parent.items?.splice(index, 1);
|
||||||
|
|
||||||
// 始终清理已删除节点在 state 中的残留引用:
|
// 始终清理已删除节点在 state 中的残留引用:
|
||||||
// - 即使后续会调用 selectDefault / select(parent) 覆盖,跳过这些调用(doNotSelect / doNotSwitchPage)时也不能让 state 持有已删除节点
|
// - 即使后续会调用 select 覆盖,跳过这些调用(doNotSelect / doNotSwitchPage)时也不能让 state 持有已删除节点
|
||||||
const selectedNodes = this.get('nodes');
|
const selectedNodes = this.get('nodes');
|
||||||
const removedSelectedIndex = selectedNodes.findIndex((n: MNode) => `${n.id}` === `${node.id}`);
|
const removedSelectedIndex = selectedNodes.findIndex((n: MNode) => `${n.id}` === `${node.id}`);
|
||||||
if (removedSelectedIndex !== -1) {
|
if (removedSelectedIndex !== -1) {
|
||||||
@ -591,38 +594,44 @@ class Editor extends BaseService {
|
|||||||
nextSelected.splice(removedSelectedIndex, 1);
|
nextSelected.splice(removedSelectedIndex, 1);
|
||||||
this.set('nodes', nextSelected);
|
this.set('nodes', nextSelected);
|
||||||
}
|
}
|
||||||
if (isPage(node) || isPageFragment(node)) {
|
|
||||||
const currentPage = this.get('page');
|
const removeData = { id: node.id, parentId: parent.id, root: cloneDeep(root) };
|
||||||
if (currentPage && `${currentPage.id}` === `${node.id}`) {
|
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 (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);
|
||||||
|
} else {
|
||||||
|
// page 置空会让 Workspace 卸载 Stage 并销毁 renderer,删除通知必须在卸载前发出,
|
||||||
|
// 且不能 await:runtime 未 ready 时 getRuntime 的监听会随 destroy 一起被移除,永远不会 resolve
|
||||||
|
stage?.remove(removeData);
|
||||||
|
|
||||||
|
if (shouldReselect) {
|
||||||
|
// 页面已全部删完
|
||||||
|
this.selectRoot();
|
||||||
|
} else if (isDeletingCurrentPage) {
|
||||||
this.set('page', null);
|
this.set('page', null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectDefault = async (pages: MNode[]) => {
|
|
||||||
if (pages[0]) {
|
|
||||||
await this.select(pages[0]);
|
|
||||||
stage?.select(pages[0].id);
|
|
||||||
} else {
|
} else {
|
||||||
this.selectRoot();
|
stage?.remove(removeData);
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const rootItems = root.items || [];
|
|
||||||
|
|
||||||
if (isPage(node)) {
|
|
||||||
this.state.pageLength -= 1;
|
|
||||||
|
|
||||||
// 删除页面后默认会切到首个剩余页面(selectDefault),doNotSwitchPage 时跳过这次自动切换
|
|
||||||
if (!doNotSelect && !doNotSwitchPage) {
|
|
||||||
await selectDefault(rootItems);
|
|
||||||
}
|
|
||||||
} else if (isPageFragment(node)) {
|
|
||||||
this.state.pageFragmentLength -= 1;
|
|
||||||
|
|
||||||
if (!doNotSelect && !doNotSwitchPage) {
|
|
||||||
await selectDefault(rootItems);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (!doNotSelect) {
|
if (!doNotSelect) {
|
||||||
await this.select(parent);
|
await this.select(parent);
|
||||||
stage?.select(parent.id);
|
stage?.select(parent.id);
|
||||||
@ -664,7 +673,7 @@ class Editor extends BaseService {
|
|||||||
|
|
||||||
const removedItems: StepDiffItem<MNode>[] = [];
|
const removedItems: StepDiffItem<MNode>[] = [];
|
||||||
let pageForOp: { name: string; id: Id } | null = null;
|
let pageForOp: { name: string; id: Id } | null = null;
|
||||||
if (!(isPage(nodes[0]) || isPageFragment(nodes[0]))) {
|
if (!isPageOrFragment(nodes[0])) {
|
||||||
for (const n of nodes) {
|
for (const n of nodes) {
|
||||||
const { parent, node: curNode, page } = this.getNodeInfo(n.id, false);
|
const { parent, node: curNode, page } = this.getNodeInfo(n.id, false);
|
||||||
if (parent && curNode) {
|
if (parent && curNode) {
|
||||||
@ -702,8 +711,8 @@ class Editor extends BaseService {
|
|||||||
this.emit('remove', nodes);
|
this.emit('remove', nodes);
|
||||||
this.emit('change', { type: 'remove', data: changeItems, historySource, doNotPushHistory });
|
this.emit('change', { type: 'remove', data: changeItems, historySource, doNotPushHistory });
|
||||||
|
|
||||||
// 页面 / 页面片删除不入历史栈(见上方 isPage / isPageFragment 分支),这里合并补发一次结构变更通知
|
// 页面 / 页面片删除不入历史栈(见上方 isPageOrFragment 分支),这里合并补发一次结构变更通知
|
||||||
const removedPages = nodes.filter((node) => isPage(node) || isPageFragment(node)) as (MPage | MPageFragment)[];
|
const removedPages = nodes.filter((node) => isPageOrFragment(node)) as (MPage | MPageFragment)[];
|
||||||
if (removedPages.length) {
|
if (removedPages.length) {
|
||||||
historyService.notifyPageStructureChange({ add: [], remove: removedPages });
|
historyService.notifyPageStructureChange({ add: [], remove: removedPages });
|
||||||
}
|
}
|
||||||
@ -764,7 +773,7 @@ class Editor extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 只有被更新节点正好是当前选中页面时才同步 state.page,避免「更新非当前页」误将编辑器切到该页
|
// 只有被更新节点正好是当前选中页面时才同步 state.page,避免「更新非当前页」误将编辑器切到该页
|
||||||
if (isPage(newConfig) || isPageFragment(newConfig)) {
|
if (isPageOrFragment(newConfig)) {
|
||||||
const currentPage = this.get('page');
|
const currentPage = this.get('page');
|
||||||
if (currentPage && `${currentPage.id}` === `${newConfig.id}`) {
|
if (currentPage && `${currentPage.id}` === `${newConfig.id}`) {
|
||||||
this.set('page', newConfig as MPage | MPageFragment);
|
this.set('page', newConfig as MPage | MPageFragment);
|
||||||
@ -1139,7 +1148,7 @@ class Editor extends BaseService {
|
|||||||
}: DslOpOptions = {},
|
}: DslOpOptions = {},
|
||||||
): Promise<MNode | MNode[]> {
|
): Promise<MNode | MNode[]> {
|
||||||
const isBatch = Array.isArray(config);
|
const isBatch = Array.isArray(config);
|
||||||
const configs = (isBatch ? config : [config]).filter((item) => !(isPage(item) || isPageFragment(item)));
|
const configs = (isBatch ? config : [config]).filter((item) => !isPageOrFragment(item));
|
||||||
|
|
||||||
if (configs.length === 0) {
|
if (configs.length === 0) {
|
||||||
throw new Error('没有可移动的节点');
|
throw new Error('没有可移动的节点');
|
||||||
@ -1760,7 +1769,7 @@ class Editor extends BaseService {
|
|||||||
private getPageOfNode(id: Id): MPage | MPageFragment | null {
|
private getPageOfNode(id: Id): MPage | MPageFragment | null {
|
||||||
const { node, page } = this.getNodeInfo(id, false);
|
const { node, page } = this.getNodeInfo(id, false);
|
||||||
if (page) return page;
|
if (page) return page;
|
||||||
if (node && (isPage(node) || isPageFragment(node))) return node as MPage | MPageFragment;
|
if (node && isPageOrFragment(node)) return node as MPage | MPageFragment;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -357,7 +357,7 @@ class History extends BaseService {
|
|||||||
* 派发「页面 / 页面片结构变更」事件(`page-structure-change`)。
|
* 派发「页面 / 页面片结构变更」事件(`page-structure-change`)。
|
||||||
*
|
*
|
||||||
* 常规 `editorService.add` / `remove` 页面节点不会写入 `page` 历史栈(见 editor.add / remove 中
|
* 常规 `editorService.add` / `remove` 页面节点不会写入 `page` 历史栈(见 editor.add / remove 中
|
||||||
* 对 isPage / isPageFragment 的分支),因此不会产生任何 historyService 事件。该方法用于在这些
|
* 对 isPageOrFragment 的分支),因此不会产生任何 historyService 事件。该方法用于在这些
|
||||||
* 场景(以及 setRoot 整体替换 DSL 增删页面)下,向外统一通知页面结构的增删变化,供业务方感知。
|
* 场景(以及 setRoot 整体替换 DSL 增删页面)下,向外统一通知页面结构的增删变化,供业务方感知。
|
||||||
*
|
*
|
||||||
* 一次操作涉及多个页面时,调用方应把本次增删的页面**合并为一个 change 一次性传入**,
|
* 一次操作涉及多个页面时,调用方应把本次增删的页面**合并为一个 change 一次性传入**,
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import KeyController, { KeyControllerEvent } from 'keycon';
|
import KeyController, { KeyControllerEvent } from 'keycon';
|
||||||
|
|
||||||
import { isPage, isPageFragment } from '@tmagic/utils';
|
import { isPageOrFragment } from '@tmagic/utils';
|
||||||
|
|
||||||
import { KeyBindingCacheItem, KeyBindingCommand, KeyBindingItem } from '@editor/type';
|
import { KeyBindingCacheItem, KeyBindingCommand, KeyBindingItem } from '@editor/type';
|
||||||
|
|
||||||
@ -19,7 +19,7 @@ class Keybinding extends BaseService {
|
|||||||
[KeyBindingCommand.DELETE_NODE]: () => {
|
[KeyBindingCommand.DELETE_NODE]: () => {
|
||||||
const nodes = editorService.get('nodes');
|
const nodes = editorService.get('nodes');
|
||||||
|
|
||||||
if (!nodes || isPage(nodes[0]) || isPageFragment(nodes[0])) return;
|
if (!nodes || isPageOrFragment(nodes[0])) return;
|
||||||
editorService.remove(nodes, { historySource: 'shortcut' });
|
editorService.remove(nodes, { historySource: 'shortcut' });
|
||||||
},
|
},
|
||||||
[KeyBindingCommand.COPY_NODE]: () => {
|
[KeyBindingCommand.COPY_NODE]: () => {
|
||||||
@ -29,7 +29,7 @@ class Keybinding extends BaseService {
|
|||||||
[KeyBindingCommand.CUT_NODE]: () => {
|
[KeyBindingCommand.CUT_NODE]: () => {
|
||||||
const nodes = editorService.get('nodes');
|
const nodes = editorService.get('nodes');
|
||||||
|
|
||||||
if (!nodes || isPage(nodes[0]) || isPageFragment(nodes[0])) return;
|
if (!nodes || isPageOrFragment(nodes[0])) return;
|
||||||
editorService.copy(nodes);
|
editorService.copy(nodes);
|
||||||
editorService.remove(nodes, { historySource: 'shortcut' });
|
editorService.remove(nodes, { historySource: 'shortcut' });
|
||||||
},
|
},
|
||||||
|
|||||||
@ -19,7 +19,7 @@
|
|||||||
}
|
}
|
||||||
.el-card__body {
|
.el-card__body {
|
||||||
.tmagic-design-form-item {
|
.tmagic-design-form-item {
|
||||||
margin-bottom: 18px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.code-select-content {
|
.code-select-content {
|
||||||
|
|||||||
@ -3,6 +3,12 @@
|
|||||||
.fullWidth {
|
.fullWidth {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
&.create-button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
&.m-container-ui-event {
|
||||||
|
width: calc(100% - 32px);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.event-select-container {
|
.event-select-container {
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
@ -76,17 +82,18 @@
|
|||||||
.event-item-header {
|
.event-item-header {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
.event-item-title {
|
.event-item-title {
|
||||||
color: #0f1113;
|
color: #0f1113;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 24px;
|
line-height: 24px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
.event-item-delete-button {
|
.event-item-delete-button {
|
||||||
position: absolute;
|
color: #0f1113;
|
||||||
right: 0;
|
|
||||||
top: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-form-item {
|
.el-form-item {
|
||||||
|
|||||||
@ -178,6 +178,11 @@ export interface StageOptions {
|
|||||||
containerHighlightClassName?: string;
|
containerHighlightClassName?: string;
|
||||||
containerHighlightDuration?: number;
|
containerHighlightDuration?: number;
|
||||||
containerHighlightType?: ContainerHighlightType;
|
containerHighlightType?: ContainerHighlightType;
|
||||||
|
/**
|
||||||
|
* 是否仅在新增组件(从组件列表拖入新组件)时才启用识别容器,
|
||||||
|
* 开启后在画布中拖动已有组件不会识别容器,默认 false
|
||||||
|
*/
|
||||||
|
containerHighlightAddOnly?: boolean;
|
||||||
disabledDragStart?: boolean;
|
disabledDragStart?: boolean;
|
||||||
render?: (stage: StageCore) => HTMLDivElement | void | Promise<HTMLDivElement | void>;
|
render?: (stage: StageCore) => HTMLDivElement | void | Promise<HTMLDivElement | void>;
|
||||||
moveableOptions?: CustomizeMoveableOptions;
|
moveableOptions?: CustomizeMoveableOptions;
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { computed, markRaw, type ShallowRef } from 'vue';
|
|||||||
import { CopyDocument, Delete, DocumentCopy } from '@element-plus/icons-vue';
|
import { CopyDocument, Delete, DocumentCopy } from '@element-plus/icons-vue';
|
||||||
|
|
||||||
import { cloneDeep, Id, MContainer, NodeType } from '@tmagic/core';
|
import { cloneDeep, Id, MContainer, NodeType } from '@tmagic/core';
|
||||||
import { calcValueByFontsize, isPage, isPageFragment } from '@tmagic/utils';
|
import { calcValueByFontsize, isPage, isPageOrFragment } from '@tmagic/utils';
|
||||||
|
|
||||||
import ContentMenu from '@editor/components/ContentMenu.vue';
|
import ContentMenu from '@editor/components/ContentMenu.vue';
|
||||||
import type { HistoryOpSource, MenuButton, Services } from '@editor/type';
|
import type { HistoryOpSource, MenuButton, Services } from '@editor/type';
|
||||||
@ -20,7 +20,7 @@ export const useDeleteMenu = (historySource?: HistoryOpSource): MenuButton => ({
|
|||||||
icon: Delete,
|
icon: Delete,
|
||||||
display: ({ editorService }) => {
|
display: ({ editorService }) => {
|
||||||
const node = editorService.get('node');
|
const node = editorService.get('node');
|
||||||
return node?.type !== NodeType.ROOT && !isPage(node) && !isPageFragment(node);
|
return node?.type !== NodeType.ROOT && !isPageOrFragment(node);
|
||||||
},
|
},
|
||||||
handler: ({ editorService }) => {
|
handler: ({ editorService }) => {
|
||||||
const nodes = editorService.get('nodes');
|
const nodes = editorService.get('nodes');
|
||||||
|
|||||||
149
packages/editor/src/utils/dep/collect-worker-client.ts
Normal file
149
packages/editor/src/utils/dep/collect-worker-client.ts
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,7 @@
|
|||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
|
|
||||||
|
import { error } from '../logger';
|
||||||
|
|
||||||
export interface IdleTaskEvents {
|
export interface IdleTaskEvents {
|
||||||
finish: [];
|
finish: [];
|
||||||
'hight-level-finish': [];
|
'hight-level-finish': [];
|
||||||
@ -25,6 +27,12 @@ globalThis.requestIdleCallback =
|
|||||||
}, 1);
|
}, 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
globalThis.cancelIdleCallback =
|
||||||
|
globalThis.cancelIdleCallback ||
|
||||||
|
function (handle) {
|
||||||
|
clearTimeout(handle as unknown as ReturnType<typeof setTimeout>);
|
||||||
|
};
|
||||||
|
|
||||||
export class IdleTask<T = any> extends EventEmitter {
|
export class IdleTask<T = any> extends EventEmitter {
|
||||||
private taskList: TaskList<T> = [];
|
private taskList: TaskList<T> = [];
|
||||||
|
|
||||||
@ -58,7 +66,7 @@ export class IdleTask<T = any> extends EventEmitter {
|
|||||||
this.taskHandle = null;
|
this.taskHandle = null;
|
||||||
|
|
||||||
this.emit('update-task-length', {
|
this.emit('update-task-length', {
|
||||||
length: this.taskList.length + this.hightLevelTaskList.length,
|
length: this.getTaskLength(),
|
||||||
hightLevelLength: this.hightLevelTaskList.length,
|
hightLevelLength: this.hightLevelTaskList.length,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -82,12 +90,14 @@ export class IdleTask<T = any> extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private runTaskQueue(deadline: IdleDeadline) {
|
private runTaskQueue(deadline: IdleDeadline) {
|
||||||
const { hightLevelTaskList, taskList } = this;
|
// 本次回调已触发,句柄随之失效;置空后 clearTasks / enqueueTask 才能正确判断是否需要重新调度
|
||||||
|
this.taskHandle = null;
|
||||||
|
|
||||||
|
try {
|
||||||
// 动画会占用空闲时间,当任务一直无法执行时,看看是否有动画正在播放
|
// 动画会占用空闲时间,当任务一直无法执行时,看看是否有动画正在播放
|
||||||
// 根据空闲时间的多少来决定执行的任务数,保证页面不卡死的情况下尽量多执行任务,不然当任务数巨大时,执行时间会很久
|
// 根据空闲时间的多少来决定执行的任务数,保证页面不卡死的情况下尽量多执行任务,不然当任务数巨大时,执行时间会很久
|
||||||
// 执行不完不会影响配置,但是会影响画布渲染
|
// 执行不完不会影响配置,但是会影响画布渲染
|
||||||
while (deadline.timeRemaining() > 0 && (taskList.length || hightLevelTaskList.length)) {
|
while (deadline.timeRemaining() > 0 && this.getTaskLength()) {
|
||||||
const timeRemaining = deadline.timeRemaining();
|
const timeRemaining = deadline.timeRemaining();
|
||||||
let times = 0;
|
let times = 0;
|
||||||
if (timeRemaining <= 5) {
|
if (timeRemaining <= 5) {
|
||||||
@ -101,32 +111,59 @@ export class IdleTask<T = any> extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < times; i++) {
|
for (let i = 0; i < times; i++) {
|
||||||
const task = hightLevelTaskList.length > 0 ? hightLevelTaskList.shift() : taskList.shift();
|
// 每次都从实例上取队列,任务执行过程中调用 clearTasks 能立即生效,不会继续消费已被清空的旧队列
|
||||||
|
const task = this.hightLevelTaskList.length > 0 ? this.hightLevelTaskList.shift() : this.taskList.shift();
|
||||||
if (task) {
|
if (task) {
|
||||||
task.handler(task.data);
|
this.runTask(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hightLevelTaskList.length === 0 && taskList.length === 0) {
|
if (!this.getTaskLength()) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
if (!hightLevelTaskList.length) {
|
// 必须放在 finally 中:一旦这里被跳过,taskHandle 会一直是真值,
|
||||||
this.emit('hight-level-finish');
|
// enqueueTask 也就不会再重新调度,剩余任务与任务数将永久停在当前状态
|
||||||
|
this.finishRun();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hightLevelTaskList.length || taskList.length) {
|
/**
|
||||||
this.taskHandle = globalThis.requestIdleCallback(this.runTaskQueue.bind(this), { timeout: 300 });
|
* 单个任务失败不能中断整个队列,否则后续任务永远不会被执行,
|
||||||
} else {
|
* 依赖收集会停在半路(收集中状态与剩余任务数都不再变化)
|
||||||
this.taskHandle = 0;
|
*/
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
this.emit('finish');
|
this.emit('finish');
|
||||||
}
|
}
|
||||||
|
|
||||||
this.emit('update-task-length', {
|
this.emit('update-task-length', {
|
||||||
length: taskList.length + hightLevelTaskList.length,
|
length: this.getTaskLength(),
|
||||||
hightLevelLength: hightLevelTaskList.length,
|
hightLevelLength: this.hightLevelTaskList.length,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getTaskLength() {
|
||||||
|
return this.taskList.length + this.hightLevelTaskList.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -3,30 +3,85 @@ import {
|
|||||||
createDataSourceCondTarget,
|
createDataSourceCondTarget,
|
||||||
createDataSourceMethodTarget,
|
createDataSourceMethodTarget,
|
||||||
createDataSourceTarget,
|
createDataSourceTarget,
|
||||||
|
createTargetByDescriptor,
|
||||||
type DepData,
|
type DepData,
|
||||||
|
type DepExtendedData,
|
||||||
DepTargetType,
|
DepTargetType,
|
||||||
type Id,
|
type Id,
|
||||||
type MApp,
|
type MApp,
|
||||||
|
type TargetDescriptor,
|
||||||
|
type TargetNode,
|
||||||
traverseTarget,
|
traverseTarget,
|
||||||
Watcher,
|
Watcher,
|
||||||
} from '@tmagic/core';
|
} from '@tmagic/core';
|
||||||
|
|
||||||
import { error } from '../logger';
|
import { error } from '../logger';
|
||||||
|
|
||||||
onmessage = (e) => {
|
export type DepsData = Record<string, Record<Id, DepData>>;
|
||||||
const watcher = new Watcher({ initialTargets: {} });
|
|
||||||
|
|
||||||
const { dsl } = e.data;
|
/** 全量收集请求:worker 中根据 DSL 重建代码块/数据源 target */
|
||||||
|
export interface DslCollectRequest {
|
||||||
|
/** 请求 id,用于把结果与调用方对应起来 */
|
||||||
|
id: number;
|
||||||
|
/** serialize-javascript 序列化后的 MApp */
|
||||||
|
dsl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
/** 增量收集时本次覆盖到的节点 id(deep=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 {
|
try {
|
||||||
// eslint-disable-next-line no-eval
|
// eslint-disable-next-line no-eval
|
||||||
const mApp: MApp = eval(`(${dsl})`);
|
const mApp: MApp = eval(`(${dsl})`);
|
||||||
|
|
||||||
if (!mApp) {
|
if (!mApp) {
|
||||||
postMessage({});
|
postMessage({ id, deps: {} });
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
watcher.clearTargets();
|
const watcher = new Watcher({ initialTargets: {} });
|
||||||
|
|
||||||
if (mApp.codeBlocks) {
|
if (mApp.codeBlocks) {
|
||||||
for (const [id, code] of Object.entries(mApp.codeBlocks)) {
|
for (const [id, code] of Object.entries(mApp.codeBlocks)) {
|
||||||
@ -48,7 +103,7 @@ onmessage = (e) => {
|
|||||||
watcher.collectItems(page, targets, { pageId: page.id }, true);
|
watcher.collectItems(page, targets, { pageId: page.id }, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data: Record<string, Record<Id, DepData>> = {
|
const deps: DepsData = {
|
||||||
[DepTargetType.DATA_SOURCE]: {},
|
[DepTargetType.DATA_SOURCE]: {},
|
||||||
[DepTargetType.DATA_SOURCE_METHOD]: {},
|
[DepTargetType.DATA_SOURCE_METHOD]: {},
|
||||||
[DepTargetType.DATA_SOURCE_COND]: {},
|
[DepTargetType.DATA_SOURCE_COND]: {},
|
||||||
@ -56,12 +111,61 @@ onmessage = (e) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
traverseTarget(watcher.getTargetsList(), (target) => {
|
traverseTarget(watcher.getTargetsList(), (target) => {
|
||||||
data[target.type][target.id] = target.deps;
|
deps[target.type][target.id] = target.deps;
|
||||||
});
|
});
|
||||||
|
|
||||||
postMessage(data);
|
const response: CollectWorkerResponse = { id, deps };
|
||||||
|
postMessage(response);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
error(e);
|
error(e);
|
||||||
postMessage({});
|
const response: CollectWorkerResponse = { id, deps: {}, failed: true };
|
||||||
|
postMessage(response);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { isEmpty } from 'lodash-es';
|
|||||||
|
|
||||||
import type { Id, MContainer, MNode } from '@tmagic/core';
|
import type { Id, MContainer, MNode } from '@tmagic/core';
|
||||||
import { NodeType } from '@tmagic/core';
|
import { NodeType } from '@tmagic/core';
|
||||||
import { calcValueByFontsize, getElById, isPage, isPageFragment } from '@tmagic/utils';
|
import { calcValueByFontsize, getElById, isPage, isPageOrFragment } from '@tmagic/utils';
|
||||||
|
|
||||||
import editorService from '@editor/services/editor';
|
import editorService from '@editor/services/editor';
|
||||||
import propsService from '@editor/services/props';
|
import propsService from '@editor/services/props';
|
||||||
@ -58,7 +58,7 @@ export const beforePaste = (position: PastePosition, config: MNode[], doc?: Docu
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
const root = editorService.get('root');
|
const root = editorService.get('root');
|
||||||
if ((isPage(pasteConfig) || isPageFragment(pasteConfig)) && root) {
|
if (isPageOrFragment(pasteConfig) && root) {
|
||||||
pasteConfig.name = generatePageNameByApp(root, isPage(pasteConfig) ? NodeType.PAGE : NodeType.PAGE_FRAGMENT);
|
pasteConfig.name = generatePageNameByApp(root, isPage(pasteConfig) ? NodeType.PAGE : NodeType.PAGE_FRAGMENT);
|
||||||
}
|
}
|
||||||
return pasteConfig as MNode;
|
return pasteConfig as MNode;
|
||||||
|
|||||||
@ -16,9 +16,6 @@
|
|||||||
* See the License for the specific language governing permissions and
|
* See the License for the specific language governing permissions and
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { AppContext } from 'vue';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
HookType,
|
HookType,
|
||||||
NODE_CONDS_KEY,
|
NODE_CONDS_KEY,
|
||||||
@ -27,18 +24,7 @@ import {
|
|||||||
NODE_DISABLE_DATA_SOURCE_KEY,
|
NODE_DISABLE_DATA_SOURCE_KEY,
|
||||||
} from '@tmagic/core';
|
} from '@tmagic/core';
|
||||||
import { tMagicMessage } from '@tmagic/design';
|
import { tMagicMessage } from '@tmagic/design';
|
||||||
import type {
|
import type { ChildConfig, DisplayCondsConfig, FormConfig, TabConfig, TabPaneConfig } from '@tmagic/form';
|
||||||
ChildConfig,
|
|
||||||
DisplayCondsConfig,
|
|
||||||
FormConfig,
|
|
||||||
FormState,
|
|
||||||
FormValue,
|
|
||||||
TabConfig,
|
|
||||||
TabPaneConfig,
|
|
||||||
} from '@tmagic/form';
|
|
||||||
import { validateForm } from '@tmagic/form';
|
|
||||||
|
|
||||||
import type { Services } from '@editor/type';
|
|
||||||
|
|
||||||
export const arrayOptions = [
|
export const arrayOptions = [
|
||||||
{ text: '包含', value: 'include' },
|
{ text: '包含', value: 'include' },
|
||||||
@ -403,86 +389,3 @@ 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,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|||||||
@ -58,7 +58,7 @@ const stringifyExampleValue = (value: any): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 参考建议中最多展示的可选值个数,超出以「等」省略。
|
// 参考建议中最多展示的可选值个数,超出以「等」省略。
|
||||||
const MAX_SUGGESTION_OPTIONS = 5;
|
const MAX_SUGGESTION_OPTIONS = 20;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成「请使用以下某一个值:xxx;xxx」形式的参考建议;无可选值时返回空字符串(不追加建议)。
|
* 生成「请使用以下某一个值:xxx;xxx」形式的参考建议;无可选值时返回空字符串(不追加建议)。
|
||||||
|
|||||||
@ -21,6 +21,7 @@ describe('defaultEditorProps', () => {
|
|||||||
test('containerHighlight 默认值', () => {
|
test('containerHighlight 默认值', () => {
|
||||||
expect(defaultEditorProps.containerHighlightDuration).toBe(800);
|
expect(defaultEditorProps.containerHighlightDuration).toBe(800);
|
||||||
expect(typeof defaultEditorProps.containerHighlightClassName).toBe('string');
|
expect(typeof defaultEditorProps.containerHighlightClassName).toBe('string');
|
||||||
|
expect(defaultEditorProps.containerHighlightAddOnly).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('数组/对象工厂函数返回空值', () => {
|
test('数组/对象工厂函数返回空值', () => {
|
||||||
|
|||||||
@ -24,11 +24,6 @@ vi.mock('@editor/hooks/use-services', () => ({
|
|||||||
useServices: () => ({ codeBlockService, uiService }),
|
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) => {
|
vi.mock('@tmagic/form', async (importOriginal) => {
|
||||||
const actual = await importOriginal<any>();
|
const actual = await importOriginal<any>();
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -115,15 +115,6 @@ 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(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
inputRef.input = null;
|
inputRef.input = null;
|
||||||
|
|||||||
@ -33,11 +33,6 @@ vi.mock('@editor/utils', async () => {
|
|||||||
return { ...actual, getFieldType: vi.fn(() => 'string') };
|
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 () => {
|
vi.mock('@tmagic/utils', async () => {
|
||||||
const actual = await vi.importActual<any>('@tmagic/utils');
|
const actual = await vi.importActual<any>('@tmagic/utils');
|
||||||
return { ...actual, DATA_SOURCE_SET_DATA_METHOD_NAME: '__set_data__' };
|
return { ...actual, DATA_SOURCE_SET_DATA_METHOD_NAME: '__set_data__' };
|
||||||
|
|||||||
@ -21,11 +21,6 @@ vi.mock('@editor/hooks/use-services', () => ({
|
|||||||
useServices: () => ({ dataSourceService, uiService }),
|
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) => {
|
vi.mock('@tmagic/form', async (importOriginal) => {
|
||||||
const actual = await importOriginal<any>();
|
const actual = await importOriginal<any>();
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -96,10 +96,6 @@ vi.mock('@tmagic/utils', async () => {
|
|||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
DATA_SOURCE_FIELDS_CHANGE_EVENT_PREFIX: 'ds_change_',
|
DATA_SOURCE_FIELDS_CHANGE_EVENT_PREFIX: 'ds_change_',
|
||||||
traverseNode: (node: any, fn: any) => {
|
|
||||||
fn(node);
|
|
||||||
node.items?.forEach((c: any) => fn(c));
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -44,11 +44,6 @@ 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', () => {
|
describe('PageFragmentSelect', () => {
|
||||||
test('model[name] 不为空时显示编辑图标', () => {
|
test('model[name] 不为空时显示编辑图标', () => {
|
||||||
editorService.get.mockReturnValue({ items: [{ id: 'p1', type: 'page-fragment', name: 'A' }] });
|
editorService.get.mockReturnValue({ items: [{ id: 'p1', type: 'page-fragment', name: 'A' }] });
|
||||||
|
|||||||
@ -27,11 +27,6 @@ vi.mock('@editor/hooks/use-services', () => ({
|
|||||||
useServices: () => ({ editorService, uiService, stageOverlayService }),
|
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', () => ({
|
vi.mock('@tmagic/design', () => ({
|
||||||
TMagicButton: defineComponent({
|
TMagicButton: defineComponent({
|
||||||
name: 'TMagicButton',
|
name: 'TMagicButton',
|
||||||
|
|||||||
@ -91,7 +91,7 @@ const mkServices = () => {
|
|||||||
clear: vi.fn(),
|
clear: vi.fn(),
|
||||||
clearTargets: vi.fn(),
|
clearTargets: vi.fn(),
|
||||||
clearIdleTasks: vi.fn(),
|
clearIdleTasks: vi.fn(),
|
||||||
collectIdle: vi.fn(async () => undefined),
|
collectIdle: vi.fn(async () => true),
|
||||||
collectByWorker: vi.fn(async () => undefined),
|
collectByWorker: vi.fn(async () => undefined),
|
||||||
reset: vi.fn(),
|
reset: vi.fn(),
|
||||||
};
|
};
|
||||||
@ -144,8 +144,6 @@ vi.mock('@tmagic/utils', async () => {
|
|||||||
...actual,
|
...actual,
|
||||||
getDepNodeIds: vi.fn(() => []),
|
getDepNodeIds: vi.fn(() => []),
|
||||||
getNodes: vi.fn(() => []),
|
getNodes: vi.fn(() => []),
|
||||||
isPage: vi.fn((n: any) => n?.type === 'page'),
|
|
||||||
isValueIncludeDataSource: vi.fn((v: any) => /\$\{/.test(String(v))),
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -8,14 +8,6 @@ import { mount } from '@vue/test-utils';
|
|||||||
|
|
||||||
import AddPageBox from '@editor/layouts/AddPageBox.vue';
|
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 = {
|
const editorService = {
|
||||||
get: vi.fn((key: string) => (key === 'root' ? { items: [] } : undefined)),
|
get: vi.fn((key: string) => (key === 'root' ? { items: [] } : undefined)),
|
||||||
add: vi.fn(),
|
add: vi.fn(),
|
||||||
|
|||||||
@ -48,11 +48,6 @@ 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 {
|
class FakeResizeObserver {
|
||||||
cb: any;
|
cb: any;
|
||||||
constructor(cb: any) {
|
constructor(cb: any) {
|
||||||
|
|||||||
@ -135,6 +135,6 @@ describe('ComponentListPanel', () => {
|
|||||||
const item = wrapper.find('.component-item');
|
const item = wrapper.find('.component-item');
|
||||||
await item.trigger('drag', { clientX: 0, clientY: 0 });
|
await item.trigger('drag', { clientX: 0, clientY: 0 });
|
||||||
await item.trigger('drag', { clientX: 0, clientY: 0 });
|
await item.trigger('drag', { clientX: 0, clientY: 0 });
|
||||||
expect(stage.delayedMarkContainer).toHaveBeenCalled();
|
expect(stage.delayedMarkContainer).toHaveBeenCalledWith(expect.anything(), [], true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -82,20 +82,6 @@ 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(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
propsService.getDisabledDataSource.mockReturnValue(false);
|
propsService.getDisabledDataSource.mockReturnValue(false);
|
||||||
|
|||||||
@ -35,11 +35,6 @@ vi.mock('@editor/hooks/use-filter', () => ({
|
|||||||
useFilter: () => ({ filterTextChangeHandler: vi.fn() }),
|
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(() => ({
|
const { messageBoxConfirm, messageError } = vi.hoisted(() => ({
|
||||||
messageBoxConfirm: vi.fn(async () => 'confirm'),
|
messageBoxConfirm: vi.fn(async () => 'confirm'),
|
||||||
messageError: vi.fn(),
|
messageError: vi.fn(),
|
||||||
|
|||||||
@ -32,18 +32,6 @@ vi.mock('@editor/hooks/use-filter', () => ({
|
|||||||
useFilter: () => ({ filterTextChangeHandler: vi.fn() }),
|
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', () => ({
|
vi.mock('@editor/components/Tree.vue', () => ({
|
||||||
default: defineComponent({
|
default: defineComponent({
|
||||||
name: 'TreeStub',
|
name: 'TreeStub',
|
||||||
|
|||||||
@ -28,15 +28,6 @@ vi.mock('@editor/utils/content-menu', () => ({
|
|||||||
useMoveToMenu: () => ({ type: 'button', text: 'moveto' }),
|
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();
|
const showMock = vi.fn();
|
||||||
vi.mock('@editor/components/ContentMenu.vue', () => ({
|
vi.mock('@editor/components/ContentMenu.vue', () => ({
|
||||||
default: defineComponent({
|
default: defineComponent({
|
||||||
|
|||||||
@ -17,8 +17,6 @@ vi.mock('@tmagic/utils', async () => {
|
|||||||
const actual = await vi.importActual<any>('@tmagic/utils');
|
const actual = await vi.importActual<any>('@tmagic/utils');
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
isPage: (n: any) => n.type === 'page',
|
|
||||||
isPageFragment: (n: any) => n.type === 'page-fragment',
|
|
||||||
getElById: () => (_doc: any, id: any) => (id === 'no-el' ? null : { id }),
|
getElById: () => (_doc: any, id: any) => (id === 'no-el' ? null : { id }),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@ -15,17 +15,6 @@ 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 = () => ({
|
const makeEditorService = () => ({
|
||||||
getNodeInfo: vi.fn(),
|
getNodeInfo: vi.fn(),
|
||||||
get: vi.fn(() => []),
|
get: vi.fn(() => []),
|
||||||
|
|||||||
@ -12,13 +12,7 @@ vi.mock('@tmagic/utils', async () => {
|
|||||||
const actual = await vi.importActual<any>('@tmagic/utils');
|
const actual = await vi.importActual<any>('@tmagic/utils');
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
isPage: (n: any) => n.type === 'page',
|
|
||||||
isPageFragment: (n: any) => n.type === 'page-fragment',
|
|
||||||
getNodePath: vi.fn(() => []),
|
getNodePath: vi.fn(() => []),
|
||||||
traverseNode: (node: any, fn: any) => {
|
|
||||||
fn(node);
|
|
||||||
node.items?.forEach((c: any) => fn(c));
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -24,11 +24,6 @@ vi.mock('@editor/hooks/use-services', () => ({
|
|||||||
useServices: () => ({ editorService }),
|
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', () => ({
|
vi.mock('@editor/utils/content-menu', () => ({
|
||||||
useCopyMenu: () => ({ type: 'button', text: '复制', handler: vi.fn() }),
|
useCopyMenu: () => ({ type: 'button', text: '复制', handler: vi.fn() }),
|
||||||
usePasteMenu: () => ({ type: 'button', text: '粘贴', handler: vi.fn() }),
|
usePasteMenu: () => ({ type: 'button', text: '粘贴', handler: vi.fn() }),
|
||||||
|
|||||||
@ -4,26 +4,57 @@
|
|||||||
* Copyright (C) 2025 Tencent.
|
* Copyright (C) 2025 Tencent.
|
||||||
*/
|
*/
|
||||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
import { reactive } from 'vue';
|
||||||
|
|
||||||
import { DepTargetType, Target } from '@tmagic/core';
|
import { createDataSourceTarget, DepTargetType, Target } from '@tmagic/core';
|
||||||
|
|
||||||
import depService from '@editor/services/dep';
|
import depService from '@editor/services/dep';
|
||||||
|
|
||||||
|
// 全量收集({ id, dsl })与增量收集({ id, payload })共用一个常驻 worker
|
||||||
vi.mock('@editor/utils/dep/worker.ts?worker&inline', () => ({
|
vi.mock('@editor/utils/dep/worker.ts?worker&inline', () => ({
|
||||||
default: class FakeWorker {
|
default: class FakeWorker {
|
||||||
|
public static instances: FakeWorker[] = [];
|
||||||
|
/** 全量收集返回的 deps */
|
||||||
public static nextData: Record<string, any> = {};
|
public static nextData: Record<string, any> = {};
|
||||||
public static nextError = false;
|
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 onmessage: ((e: any) => void) | null = null;
|
||||||
public onerror: (() => void) | null = null;
|
public onerror: (() => void) | null = null;
|
||||||
public postMessage() {
|
public onmessageerror: (() => void) | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
FakeWorker.instances.push(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public postMessage(request: any) {
|
||||||
|
FakeWorker.requests.push(request);
|
||||||
|
|
||||||
|
if (!('dsl' in request)) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (FakeWorker.nextError) {
|
this.onmessage?.({
|
||||||
this.onerror?.(new Event('error'));
|
data: { id: request.id, failed: FakeWorker.failed, ...FakeWorker.response },
|
||||||
|
});
|
||||||
|
}, FakeWorker.delay);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.onmessage?.({ data: FakeWorker.nextData });
|
|
||||||
}, 0);
|
setTimeout(() => {
|
||||||
|
if (FakeWorker.nextError) {
|
||||||
|
this.onerror?.();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
this.onmessage?.({ data: { id: request.id, deps: FakeWorker.nextData } });
|
||||||
|
}, FakeWorker.nextDelay);
|
||||||
|
}
|
||||||
|
|
||||||
|
public terminate() {}
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -34,11 +65,31 @@ const makeTarget = (id = 't1', type: string = DepTargetType.DEFAULT) =>
|
|||||||
isTarget: () => false,
|
isTarget: () => false,
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
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 () => {
|
||||||
depService.reset();
|
depService.reset();
|
||||||
|
// collectByWorker / collectIdle 共用常驻 CollectWorkerClient,happy-dom 默认没有 Worker
|
||||||
|
await enableCollectWorker();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
delete (globalThis as any).Worker;
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -121,17 +172,20 @@ describe('Dep service', () => {
|
|||||||
test('collectIdle - 没有命中时立即 resolve 并 emit collected', async () => {
|
test('collectIdle - 没有命中时立即 resolve 并 emit collected', async () => {
|
||||||
const fn = vi.fn();
|
const fn = vi.fn();
|
||||||
depService.on('collected', fn);
|
depService.on('collected', fn);
|
||||||
await depService.collectIdle([{ id: 'n1', type: 'text' }] as any);
|
await expect(depService.collectIdle([{ id: 'n1', type: 'text' }] as any)).resolves.toBe(true);
|
||||||
expect(fn).toHaveBeenCalled();
|
expect(fn).toHaveBeenCalled();
|
||||||
depService.off('collected', fn);
|
depService.off('collected', fn);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('collectByWorker 完成后触发 collected 与 ds-collected', async () => {
|
test('collectByWorker 完成后触发 collected 与 ds-collected', async () => {
|
||||||
|
const fakeWorker = await enableCollectWorker();
|
||||||
const fn = vi.fn();
|
const fn = vi.fn();
|
||||||
const dsFn = vi.fn();
|
const dsFn = vi.fn();
|
||||||
depService.on('collected', fn);
|
depService.on('collected', fn);
|
||||||
depService.on('ds-collected', dsFn);
|
depService.on('ds-collected', dsFn);
|
||||||
await depService.collectByWorker({ items: [], id: 'app', type: 'app' } as any);
|
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(fn).toHaveBeenCalled();
|
||||||
expect(dsFn).toHaveBeenCalled();
|
expect(dsFn).toHaveBeenCalled();
|
||||||
depService.off('collected', fn);
|
depService.off('collected', fn);
|
||||||
@ -182,20 +236,19 @@ describe('Dep service', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('collectByWorker worker 报错时返回空对象并完成 collected', async () => {
|
test('collectByWorker worker 报错时返回空对象并完成 collected', async () => {
|
||||||
const fakeWorker = (await import('@editor/utils/dep/worker.ts?worker&inline')).default as any;
|
const fakeWorker = await enableCollectWorker();
|
||||||
fakeWorker.nextError = true;
|
fakeWorker.nextError = true;
|
||||||
fakeWorker.nextData = {};
|
|
||||||
const collected = vi.fn();
|
const collected = vi.fn();
|
||||||
depService.on('collected', collected);
|
depService.on('collected', collected);
|
||||||
const result = await depService.collectByWorker({ items: [], id: 'app', type: 'app' } as any);
|
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(result).toEqual({});
|
||||||
expect(collected).toHaveBeenCalled();
|
expect(collected).toHaveBeenCalled();
|
||||||
fakeWorker.nextError = false;
|
|
||||||
depService.off('collected', collected);
|
depService.off('collected', collected);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('collectByWorker 会把 worker 返回的 deps 写回 target 与 dsl', async () => {
|
test('collectByWorker 会把 worker 返回的 deps 写回 target 与 dsl', async () => {
|
||||||
const fakeWorker = (await import('@editor/utils/dep/worker.ts?worker&inline')).default as any;
|
const fakeWorker = await enableCollectWorker();
|
||||||
depService.addTarget(makeTarget('ds1', DepTargetType.DATA_SOURCE));
|
depService.addTarget(makeTarget('ds1', DepTargetType.DATA_SOURCE));
|
||||||
depService.addTarget(makeTarget('cond1', DepTargetType.DATA_SOURCE_COND));
|
depService.addTarget(makeTarget('cond1', DepTargetType.DATA_SOURCE_COND));
|
||||||
depService.addTarget(makeTarget('method1', DepTargetType.DATA_SOURCE_METHOD));
|
depService.addTarget(makeTarget('method1', DepTargetType.DATA_SOURCE_METHOD));
|
||||||
@ -213,12 +266,311 @@ describe('Dep service', () => {
|
|||||||
dataSourceMethodDeps: {},
|
dataSourceMethodDeps: {},
|
||||||
};
|
};
|
||||||
await depService.collectByWorker(dsl);
|
await depService.collectByWorker(dsl);
|
||||||
|
expect(fakeWorker.requests.some((request: any) => 'dsl' in request)).toBe(true);
|
||||||
expect(dsl.dataSourceDeps.ds1).toBeDefined();
|
expect(dsl.dataSourceDeps.ds1).toBeDefined();
|
||||||
expect(dsl.dataSourceCondDeps.cond1).toBeDefined();
|
expect(dsl.dataSourceCondDeps.cond1).toBeDefined();
|
||||||
expect(dsl.dataSourceMethodDeps.method1).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 = {};
|
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 并移除监听', () => {
|
test('destroy 会 reset 并移除监听', () => {
|
||||||
depService.addTarget(makeTarget('destroy-me'));
|
depService.addTarget(makeTarget('destroy-me'));
|
||||||
expect(() => depService.destroy()).not.toThrow();
|
expect(() => depService.destroy()).not.toThrow();
|
||||||
|
|||||||
@ -16,7 +16,7 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest';
|
import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||||
import { cloneDeep } from 'lodash-es';
|
import { cloneDeep } from 'lodash-es';
|
||||||
|
|
||||||
import type { MApp, MContainer, MNode } from '@tmagic/core';
|
import type { MApp, MContainer, MNode } from '@tmagic/core';
|
||||||
@ -552,6 +552,153 @@ describe('remove', () => {
|
|||||||
// 自动切到剩余首个页面
|
// 自动切到剩余首个页面
|
||||||
expect(editorService.get('page')?.id).toBe(addedId);
|
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', () => {
|
describe('update', () => {
|
||||||
|
|||||||
@ -0,0 +1,257 @@
|
|||||||
|
/*
|
||||||
|
* 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -6,6 +6,7 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
import { IdleTask } from '@editor/utils/dep/idle-task';
|
import { IdleTask } from '@editor/utils/dep/idle-task';
|
||||||
|
import * as logger from '@editor/utils/logger';
|
||||||
|
|
||||||
const fakeIdleDeadline = (timeRemaining: number, callsBeforeZero = 1): IdleDeadline => {
|
const fakeIdleDeadline = (timeRemaining: number, callsBeforeZero = 1): IdleDeadline => {
|
||||||
let remainingCalls = callsBeforeZero;
|
let remainingCalls = callsBeforeZero;
|
||||||
@ -127,6 +128,77 @@ describe('IdleTask', () => {
|
|||||||
expect(scheduled.length).toBeGreaterThan(1);
|
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 - 取消挂起任务并重置队列', () => {
|
test('clearTasks - 取消挂起任务并重置队列', () => {
|
||||||
const task = new IdleTask<number>();
|
const task = new IdleTask<number>();
|
||||||
task.enqueueTask(() => undefined, 1);
|
task.enqueueTask(() => undefined, 1);
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
* Copyright (C) 2025 Tencent.
|
* Copyright (C) 2025 Tencent.
|
||||||
*/
|
*/
|
||||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
import serialize from 'serialize-javascript';
|
||||||
|
|
||||||
import { DepTargetType } from '@tmagic/core';
|
import { DepTargetType } from '@tmagic/core';
|
||||||
|
|
||||||
@ -26,6 +27,18 @@ afterEach(() => {
|
|||||||
|
|
||||||
const loadWorker = () => import('@editor/utils/dep/worker');
|
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', () => {
|
describe('dep/worker', () => {
|
||||||
test('注册 onmessage 处理器', async () => {
|
test('注册 onmessage 处理器', async () => {
|
||||||
await loadWorker();
|
await loadWorker();
|
||||||
@ -34,36 +47,138 @@ describe('dep/worker', () => {
|
|||||||
|
|
||||||
test('正常 dsl - 收集 codeBlocks/dataSources/items 并 postMessage', async () => {
|
test('正常 dsl - 收集 codeBlocks/dataSources/items 并 postMessage', async () => {
|
||||||
await loadWorker();
|
await loadWorker();
|
||||||
const dsl = JSON.stringify({
|
const response = postDslRequest(
|
||||||
|
JSON.stringify({
|
||||||
id: 'app',
|
id: 'app',
|
||||||
type: 'app',
|
type: 'app',
|
||||||
codeBlocks: { cb_1: { name: 'fn1', content: 'function (){}' } },
|
codeBlocks: { cb_1: { name: 'fn1', content: 'function (){}' } },
|
||||||
dataSources: [{ id: 'ds_1', type: 'base', fields: [] }],
|
dataSources: [{ id: 'ds_1', type: 'base', fields: [] }],
|
||||||
items: [{ id: 'page_1', type: 'page', items: [] }],
|
items: [{ id: 'page_1', type: 'page', items: [] }],
|
||||||
});
|
}),
|
||||||
(globalThis as any).onmessage({ data: { dsl } });
|
);
|
||||||
expect(postedMessages).toHaveLength(1);
|
expect(postedMessages).toHaveLength(1);
|
||||||
const data = postedMessages[0];
|
// 全量与增量收集共用一个 worker,响应必须带上请求 id
|
||||||
expect(data).toHaveProperty(DepTargetType.DATA_SOURCE);
|
expect(response.id).toBe(1);
|
||||||
expect(data).toHaveProperty(DepTargetType.CODE_BLOCK);
|
expect(response.deps).toHaveProperty(DepTargetType.DATA_SOURCE);
|
||||||
|
expect(response.deps).toHaveProperty(DepTargetType.CODE_BLOCK);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('eval dsl 抛错时 postMessage({})', async () => {
|
test('eval dsl 抛错时返回 failed', async () => {
|
||||||
await loadWorker();
|
await loadWorker();
|
||||||
(globalThis as any).onmessage({ data: { dsl: '!@#invalid' } });
|
expect(postDslRequest('!@#invalid')).toEqual({ id: 1, deps: {}, failed: true });
|
||||||
expect(postedMessages[0]).toEqual({});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('mApp 为空时也会调用 postMessage', async () => {
|
test('mApp 为空时只返回一次空结果', async () => {
|
||||||
await loadWorker();
|
await loadWorker();
|
||||||
(globalThis as any).onmessage({ data: { dsl: 'null' } });
|
expect(postDslRequest('null')).toEqual({ id: 1, deps: {} });
|
||||||
expect(postedMessages.length).toBeGreaterThanOrEqual(1);
|
expect(postedMessages).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('mApp 没有 codeBlocks/dataSources 时也能完成', async () => {
|
test('mApp 没有 codeBlocks/dataSources 时也能完成', async () => {
|
||||||
await loadWorker();
|
await loadWorker();
|
||||||
const dsl = JSON.stringify({ id: 'app', type: 'app', items: [] });
|
expect(postDslRequest(JSON.stringify({ id: 'app', type: 'app', items: [] })).deps).toBeDefined();
|
||||||
(globalThis as any).onmessage({ data: { dsl } });
|
|
||||||
expect(postedMessages).toHaveLength(1);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -3,10 +3,9 @@
|
|||||||
*
|
*
|
||||||
* Copyright (C) 2025 Tencent.
|
* Copyright (C) 2025 Tencent.
|
||||||
*/
|
*/
|
||||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
import { describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
import { NODE_CONDS_RESULT_KEY } from '@tmagic/core';
|
import { NODE_CONDS_RESULT_KEY } from '@tmagic/core';
|
||||||
import { validateForm } from '@tmagic/form';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
advancedTabConfig,
|
advancedTabConfig,
|
||||||
@ -20,7 +19,6 @@ import {
|
|||||||
numberOptions,
|
numberOptions,
|
||||||
removeStyleDisplayConfig,
|
removeStyleDisplayConfig,
|
||||||
styleTabConfig,
|
styleTabConfig,
|
||||||
validatePropsForm,
|
|
||||||
} from '@editor/utils/props';
|
} from '@editor/utils/props';
|
||||||
|
|
||||||
vi.mock('@tmagic/design', () => ({
|
vi.mock('@tmagic/design', () => ({
|
||||||
@ -172,59 +170,6 @@ describe('fillConfig', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('validatePropsForm', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.mocked(validateForm).mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('合入 appContext.provides、注入 stage/services 并合并外部 extendState,debug 默认 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 缺省为 null,extendState 缺省时仍注入 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', () => {
|
describe('removeStyleDisplayConfig', () => {
|
||||||
test('将 tab 内「样式」pane 的 display 置为 true', () => {
|
test('将 tab 内「样式」pane 的 display 置为 true', () => {
|
||||||
const config = [
|
const config = [
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.0-beta.15",
|
"version": "1.8.0-beta.20",
|
||||||
"name": "@tmagic/element-plus-adapter",
|
"name": "@tmagic/element-plus-adapter",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.0-beta.15",
|
"version": "1.8.0-beta.20",
|
||||||
"name": "@tmagic/form-schema",
|
"name": "@tmagic/form-schema",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.0-beta.15",
|
"version": "1.8.0-beta.20",
|
||||||
"name": "@tmagic/form",
|
"name": "@tmagic/form",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": [
|
"sideEffects": [
|
||||||
|
|||||||
@ -88,6 +88,13 @@ const props = withDefaults(
|
|||||||
labelWidth?: string;
|
labelWidth?: string;
|
||||||
/** 是否开启类型匹配校验 */
|
/** 是否开启类型匹配校验 */
|
||||||
typeMatchValid?: boolean;
|
typeMatchValid?: boolean;
|
||||||
|
/**
|
||||||
|
* 初始化(`config` / `initValues` 就绪)后是否立即执行一次表单校验。
|
||||||
|
*
|
||||||
|
* - `false`(默认):不自动校验,避免打开表单时就展示错误态;
|
||||||
|
* - `true`:初始化完成后在 `nextTick` 中调用 `validate()`。
|
||||||
|
*/
|
||||||
|
validateOnInit?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
height?: string;
|
height?: string;
|
||||||
stepActive?: string | number;
|
stepActive?: string | number;
|
||||||
@ -156,6 +163,7 @@ const props = withDefaults(
|
|||||||
labelPosition: 'right',
|
labelPosition: 'right',
|
||||||
keyProp: '__key',
|
keyProp: '__key',
|
||||||
useFieldTextInError: true,
|
useFieldTextInError: true,
|
||||||
|
validateOnInit: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -261,6 +269,18 @@ 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` 之前)所访问的任何响应式数据,
|
* `extendState` 的同步段(直到第一个 `await` 之前)所访问的任何响应式数据,
|
||||||
* 都会被 `watchEffect` 自动跟踪。这样可以兼容历史用法 ——
|
* 都会被 `watchEffect` 自动跟踪。这样可以兼容历史用法 ——
|
||||||
@ -299,7 +319,7 @@ watchEffect(async (onCleanup) => {
|
|||||||
}
|
}
|
||||||
if (stale) return;
|
if (stale) return;
|
||||||
|
|
||||||
applyExtendState(formState, state);
|
applyExtendState(formState, state, reservedStateKeys);
|
||||||
});
|
});
|
||||||
|
|
||||||
provide('mForm', formState);
|
provide('mForm', formState);
|
||||||
@ -341,9 +361,11 @@ watch(
|
|||||||
// 非对比模式,初始化完成
|
// 非对比模式,初始化完成
|
||||||
initialized.value = !props.isCompare;
|
initialized.value = !props.isCompare;
|
||||||
|
|
||||||
|
if (props.validateOnInit) {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
tMagicFormRef.value?.validate();
|
tMagicFormRef.value?.validate();
|
||||||
});
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (props.isCompare) {
|
if (props.isCompare) {
|
||||||
|
|||||||
@ -16,6 +16,7 @@
|
|||||||
:use-field-text-in-error="useFieldTextInError"
|
:use-field-text-in-error="useFieldTextInError"
|
||||||
:extend-state="extendState"
|
:extend-state="extendState"
|
||||||
:type-match-valid="typeMatchValid"
|
:type-match-valid="typeMatchValid"
|
||||||
|
:validate-on-init="validateOnInit"
|
||||||
@change="changeHandler"
|
@change="changeHandler"
|
||||||
></Form>
|
></Form>
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
@ -59,6 +60,8 @@ const props = withDefaults(
|
|||||||
labelWidth?: string;
|
labelWidth?: string;
|
||||||
/** 是否开启类型匹配校验 */
|
/** 是否开启类型匹配校验 */
|
||||||
typeMatchValid?: boolean;
|
typeMatchValid?: boolean;
|
||||||
|
/** 透传给内部 `MForm`,初始化完成后是否立即校验(默认 `false`) */
|
||||||
|
validateOnInit?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
size?: 'small' | 'default' | 'large';
|
size?: 'small' | 'default' | 'large';
|
||||||
confirmText?: string;
|
confirmText?: string;
|
||||||
@ -74,6 +77,7 @@ const props = withDefaults(
|
|||||||
values: () => ({}),
|
values: () => ({}),
|
||||||
confirmText: '确定',
|
confirmText: '确定',
|
||||||
useFieldTextInError: true,
|
useFieldTextInError: true,
|
||||||
|
validateOnInit: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -33,6 +33,7 @@
|
|||||||
:prevent-submit-default="preventSubmitDefault"
|
:prevent-submit-default="preventSubmitDefault"
|
||||||
:use-field-text-in-error="useFieldTextInError"
|
:use-field-text-in-error="useFieldTextInError"
|
||||||
:type-match-valid="typeMatchValid"
|
:type-match-valid="typeMatchValid"
|
||||||
|
:validate-on-init="validateOnInit"
|
||||||
:extend-state="extendState"
|
:extend-state="extendState"
|
||||||
:theme="effectiveTheme"
|
:theme="effectiveTheme"
|
||||||
@change="changeHandler"
|
@change="changeHandler"
|
||||||
@ -87,6 +88,8 @@ const props = withDefaults(
|
|||||||
labelWidth?: string;
|
labelWidth?: string;
|
||||||
/** 是否开启类型匹配校验 */
|
/** 是否开启类型匹配校验 */
|
||||||
typeMatchValid?: boolean;
|
typeMatchValid?: boolean;
|
||||||
|
/** 透传给内部 `MForm`,初始化完成后是否立即校验(默认 `false`) */
|
||||||
|
validateOnInit?: boolean;
|
||||||
fullscreen?: boolean;
|
fullscreen?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
title?: string;
|
title?: string;
|
||||||
@ -122,6 +125,7 @@ const props = withDefaults(
|
|||||||
showClose: true,
|
showClose: true,
|
||||||
showCancel: true,
|
showCancel: true,
|
||||||
useFieldTextInError: true,
|
useFieldTextInError: true,
|
||||||
|
validateOnInit: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -30,6 +30,7 @@
|
|||||||
:prevent-submit-default="preventSubmitDefault"
|
:prevent-submit-default="preventSubmitDefault"
|
||||||
:use-field-text-in-error="useFieldTextInError"
|
:use-field-text-in-error="useFieldTextInError"
|
||||||
:type-match-valid="typeMatchValid"
|
:type-match-valid="typeMatchValid"
|
||||||
|
:validate-on-init="validateOnInit"
|
||||||
:extend-state="extendState"
|
:extend-state="extendState"
|
||||||
:theme="effectiveTheme"
|
:theme="effectiveTheme"
|
||||||
@change="changeHandler"
|
@change="changeHandler"
|
||||||
@ -78,6 +79,8 @@ const props = withDefaults(
|
|||||||
labelWidth?: string;
|
labelWidth?: string;
|
||||||
/** 是否开启类型匹配校验 */
|
/** 是否开启类型匹配校验 */
|
||||||
typeMatchValid?: boolean;
|
typeMatchValid?: boolean;
|
||||||
|
/** 透传给内部 `MForm`,初始化完成后是否立即校验(默认 `false`) */
|
||||||
|
validateOnInit?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
closeOnPressEscape?: boolean;
|
closeOnPressEscape?: boolean;
|
||||||
title?: string;
|
title?: string;
|
||||||
@ -106,6 +109,7 @@ const props = withDefaults(
|
|||||||
values: () => ({}),
|
values: () => ({}),
|
||||||
confirmText: '确定',
|
confirmText: '确定',
|
||||||
useFieldTextInError: true,
|
useFieldTextInError: true,
|
||||||
|
validateOnInit: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -87,6 +87,11 @@ export interface SubmitFormOptions {
|
|||||||
*/
|
*/
|
||||||
debug?: boolean;
|
debug?: boolean;
|
||||||
typeMatchValid?: boolean;
|
typeMatchValid?: boolean;
|
||||||
|
/**
|
||||||
|
* 外部中断信号。abort 时会立即以 `signal.reason` reject 并卸载临时表单实例、移除容器。
|
||||||
|
* 主要用于 `debug` 模式(无超时兜底)下取消一个被放弃的表单弹层,避免其无限驻留在页面上。
|
||||||
|
*/
|
||||||
|
signal?: AbortSignal;
|
||||||
}
|
}
|
||||||
// #endregion SubmitFormOptions
|
// #endregion SubmitFormOptions
|
||||||
|
|
||||||
@ -127,7 +132,7 @@ interface MountFormInstanceOptions<T> {
|
|||||||
formProps: Record<string, any>;
|
formProps: Record<string, any>;
|
||||||
/** 父级应用上下文,用于继承全局组件、指令、provide 等 */
|
/** 父级应用上下文,用于继承全局组件、指令、provide 等 */
|
||||||
appContext?: AppContext | null;
|
appContext?: AppContext | null;
|
||||||
/** 等待表单初始化的最长时间(毫秒),<=0 表示不注册超时 */
|
/** 等待表单初始化的最长时间(毫秒),<=0 时回退到默认超时以保证兜底清理生效 */
|
||||||
timeout: number;
|
timeout: number;
|
||||||
/** 超时 reject 的错误文案 */
|
/** 超时 reject 的错误文案 */
|
||||||
timeoutMessage: string;
|
timeoutMessage: string;
|
||||||
@ -135,10 +140,15 @@ interface MountFormInstanceOptions<T> {
|
|||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
/** 是否跳过超时注册。调试模式等待人工操作,应传 `true` */
|
/** 是否跳过超时注册。调试模式等待人工操作,应传 `true` */
|
||||||
skipTimeout?: boolean;
|
skipTimeout?: boolean;
|
||||||
|
/** 外部中断信号:abort 时会 reject 并卸载实例、移除容器,用于取消无超时(如 debug)的挂载 */
|
||||||
|
signal?: AbortSignal;
|
||||||
/** 构造 wrapper 组件 */
|
/** 构造 wrapper 组件 */
|
||||||
createWrapper: FormWrapperFactory<T>;
|
createWrapper: FormWrapperFactory<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 未指定或传入非正数 timeout 时的兜底超时(毫秒),保证非 debug 挂载始终能被清理 */
|
||||||
|
const DEFAULT_MOUNT_TIMEOUT = 10000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* submitForm / validateForm 的公共脚手架:
|
* submitForm / validateForm 的公共脚手架:
|
||||||
*
|
*
|
||||||
@ -149,20 +159,36 @@ interface MountFormInstanceOptions<T> {
|
|||||||
* 容器创建、卸载、超时、上下文注入等模板代码在此统一收口。
|
* 容器创建、卸载、超时、上下文注入等模板代码在此统一收口。
|
||||||
*/
|
*/
|
||||||
const mountFormInstance = <T>(options: MountFormInstanceOptions<T>): Promise<T> => {
|
const mountFormInstance = <T>(options: MountFormInstanceOptions<T>): Promise<T> => {
|
||||||
const { formProps, appContext, timeout, timeoutMessage, hidden = true, skipTimeout = false, createWrapper } = options;
|
const {
|
||||||
|
formProps,
|
||||||
|
appContext,
|
||||||
|
timeout,
|
||||||
|
timeoutMessage,
|
||||||
|
hidden = true,
|
||||||
|
skipTimeout = false,
|
||||||
|
signal,
|
||||||
|
createWrapper,
|
||||||
|
} = options;
|
||||||
|
|
||||||
return new Promise<T>((resolve, reject) => {
|
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');
|
const container = document.createElement('div');
|
||||||
if (hidden) {
|
if (hidden) {
|
||||||
container.style.display = 'none';
|
container.style.display = 'none';
|
||||||
}
|
}
|
||||||
document.body.appendChild(container);
|
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 = () => {
|
const cleanup = () => {
|
||||||
if (cleaned) return;
|
if (cleaned) return;
|
||||||
cleaned = true;
|
cleaned = true;
|
||||||
@ -170,6 +196,10 @@ const mountFormInstance = <T>(options: MountFormInstanceOptions<T>): Promise<T>
|
|||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
timer = null;
|
timer = null;
|
||||||
}
|
}
|
||||||
|
if (signal && onAbort) {
|
||||||
|
signal.removeEventListener('abort', onAbort);
|
||||||
|
onAbort = null;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
instance.app?.unmount();
|
instance.app?.unmount();
|
||||||
} catch {
|
} catch {
|
||||||
@ -178,6 +208,20 @@ const mountFormInstance = <T>(options: MountFormInstanceOptions<T>): Promise<T>
|
|||||||
container.parentNode?.removeChild(container);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从容器创建到 mount 的全流程统一 try/catch:任一步骤(createWrapper/createApp/上下文合并/mount)
|
||||||
|
// 抛错都会走到 cleanup,避免已插入 body 的 container 及未挂载的 app 残留导致泄漏。
|
||||||
|
try {
|
||||||
const formRef = ref<any>(null);
|
const formRef = ref<any>(null);
|
||||||
|
|
||||||
// 将 extendState 从 formProps 中剥离:不由 Form.vue 的 async watchEffect 异步应用,
|
// 将 extendState 从 formProps 中剥离:不由 Form.vue 的 async watchEffect 异步应用,
|
||||||
@ -204,10 +248,14 @@ const mountFormInstance = <T>(options: MountFormInstanceOptions<T>): Promise<T>
|
|||||||
console.error('[MForm] extendState failed:', e);
|
console.error('[MForm] extendState failed:', e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// formState 的内置 key 快照:在 extendState 合并前捕获,
|
||||||
|
// 供 applyExtendState 禁止 extendState 覆盖这些已有字段(只能新增),
|
||||||
|
// 与 Form.vue 中 reservedStateKeys 的语义保持一致。
|
||||||
|
const reservedStateKeys = new Set<string | symbol>(Reflect.ownKeys(form.formState));
|
||||||
// 合并逻辑收口在 applyExtendState:props 派生的只读 getter 字段
|
// 合并逻辑收口在 applyExtendState:props 派生的只读 getter 字段
|
||||||
// (keyProp 等)以普通字段形式返回时会被跳过并告警,避免 proxy set 抛错
|
// (keyProp 等)以普通字段形式返回时会被跳过并告警,避免 proxy set 抛错
|
||||||
const apply = (state: Record<string, any> | null | undefined) =>
|
const apply = (state: Record<string, any> | null | undefined) =>
|
||||||
applyExtendState(form.formState, state);
|
applyExtendState(form.formState, state, reservedStateKeys);
|
||||||
if (result && typeof result.then === 'function') {
|
if (result && typeof result.then === 'function') {
|
||||||
result.then(apply, (e: any) => console.error('[MForm] extendState failed:', e));
|
result.then(apply, (e: any) => console.error('[MForm] extendState failed:', e));
|
||||||
} else {
|
} else {
|
||||||
@ -243,16 +291,18 @@ const mountFormInstance = <T>(options: MountFormInstanceOptions<T>): Promise<T>
|
|||||||
Object.assign(app._context, appContext);
|
Object.assign(app._context, appContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (timeout > 0 && !skipTimeout) {
|
// 非 debug(未跳过超时)场景始终注册超时兜底:timeout 为非正数时回退到默认值,
|
||||||
|
// 避免表单永不初始化时实例/容器/watcher 无限驻留而泄漏。
|
||||||
|
if (!skipTimeout) {
|
||||||
|
const effectiveTimeout = timeout > 0 ? timeout : DEFAULT_MOUNT_TIMEOUT;
|
||||||
timer = setTimeout(() => {
|
timer = setTimeout(() => {
|
||||||
if (!cleaned) {
|
if (!cleaned) {
|
||||||
reject(new Error(timeoutMessage));
|
reject(new Error(timeoutMessage));
|
||||||
cleanup();
|
cleanup();
|
||||||
}
|
}
|
||||||
}, timeout);
|
}, effectiveTimeout);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
app.mount(container);
|
app.mount(container);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
reject(err);
|
reject(err);
|
||||||
@ -450,12 +500,13 @@ const createDebugWrapper = (options: DebugWrapperOptions): Component => {
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export const submitForm = (options: SubmitFormOptions): Promise<any> => {
|
export const submitForm = (options: SubmitFormOptions): Promise<any> => {
|
||||||
const { native, appContext, timeout = 10000, returnChangeRecords, debug = false, ...formProps } = options;
|
const { native, appContext, timeout = 10000, returnChangeRecords, debug = false, signal, ...formProps } = options;
|
||||||
|
|
||||||
return mountFormInstance<any>({
|
return mountFormInstance<any>({
|
||||||
formProps,
|
formProps,
|
||||||
appContext,
|
appContext,
|
||||||
timeout,
|
timeout,
|
||||||
|
signal,
|
||||||
// 调试模式需把表单展示出来;普通模式隐藏挂载
|
// 调试模式需把表单展示出来;普通模式隐藏挂载
|
||||||
hidden: !debug,
|
hidden: !debug,
|
||||||
// 调试模式等待人工操作,不应用超时
|
// 调试模式等待人工操作,不应用超时
|
||||||
@ -559,6 +610,11 @@ export interface ValidateFormOptions {
|
|||||||
*/
|
*/
|
||||||
debug?: boolean;
|
debug?: boolean;
|
||||||
typeMatchValid?: boolean;
|
typeMatchValid?: boolean;
|
||||||
|
/**
|
||||||
|
* 外部中断信号。abort 时会立即以 `signal.reason` reject 并卸载临时表单实例、移除容器。
|
||||||
|
* 主要用于 `debug` 模式(无超时兜底)下取消一个被放弃的表单弹层,避免其无限驻留在页面上。
|
||||||
|
*/
|
||||||
|
signal?: AbortSignal;
|
||||||
}
|
}
|
||||||
// #endregion ValidateFormOptions
|
// #endregion ValidateFormOptions
|
||||||
|
|
||||||
@ -654,7 +710,7 @@ export const stripTabItemsLazy = (config: FormConfig): FormConfig => {
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export const validateForm = (options: ValidateFormOptions): Promise<string> => {
|
export const validateForm = (options: ValidateFormOptions): Promise<string> => {
|
||||||
const { appContext, timeout = 10000, debug = false, config, ...rest } = options;
|
const { appContext, timeout = 10000, debug = false, config, signal, ...rest } = options;
|
||||||
|
|
||||||
// 去掉 tab 容器各标签页的 lazy,确保懒加载标签页内的字段也参与校验
|
// 去掉 tab 容器各标签页的 lazy,确保懒加载标签页内的字段也参与校验
|
||||||
const formProps = { ...rest, config: stripTabItemsLazy(config) };
|
const formProps = { ...rest, config: stripTabItemsLazy(config) };
|
||||||
@ -663,6 +719,7 @@ export const validateForm = (options: ValidateFormOptions): Promise<string> => {
|
|||||||
formProps,
|
formProps,
|
||||||
appContext,
|
appContext,
|
||||||
timeout,
|
timeout,
|
||||||
|
signal,
|
||||||
// 调试模式需把表单展示出来;普通模式隐藏挂载
|
// 调试模式需把表单展示出来;普通模式隐藏挂载
|
||||||
hidden: !debug,
|
hidden: !debug,
|
||||||
// 调试模式等待人工操作,不应用超时
|
// 调试模式等待人工操作,不应用超时
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
.m-fields-group-list-item.tmagic-design-card--flat:last-child {
|
.m-fields-group-list-item.tmagic-design-card--flat:last-child {
|
||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-button--text {
|
.el-button--text {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin-bottom: 7px;
|
margin-bottom: 7px;
|
||||||
@ -32,9 +31,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.m-fields-group-list-footer {
|
.m-fields-group-list-footer {
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
margin-bottom: 16px;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -92,4 +92,15 @@
|
|||||||
text-align: left;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -60,13 +60,15 @@ export const adaptFormValidator = (validator: AsyncValidatorFn): AsyncValidatorF
|
|||||||
const value = arg1;
|
const value = arg1;
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
let settled = false;
|
let settled = false;
|
||||||
const callback = (error?: Error | string) => {
|
const callback = (error?: Error | string | (Error | string)[]) => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
if (error) {
|
// async-validator 约定 callback 也可接收错误数组,TDesign 只能展示一条,取首条
|
||||||
|
const first = Array.isArray(error) ? error[0] : error;
|
||||||
|
if (first) {
|
||||||
resolve({
|
resolve({
|
||||||
result: false,
|
result: false,
|
||||||
message: typeof error === 'string' ? error : error.message,
|
message: typeof first === 'string' ? first : first.message,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
resolve(true);
|
resolve(true);
|
||||||
@ -74,8 +76,9 @@ export const adaptFormValidator = (validator: AsyncValidatorFn): AsyncValidatorF
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 异步 validator 会先返回 undefined,稍后再调 callback,这里不能当成 thenable 取值
|
||||||
const result = validator(undefined, value, callback);
|
const result = validator(undefined, value, callback);
|
||||||
if (result !== null && typeof (result as PromiseLike<unknown>).then === 'function') {
|
if (result && typeof (result as PromiseLike<unknown>).then === 'function') {
|
||||||
Promise.resolve(result).then(
|
Promise.resolve(result).then(
|
||||||
() => {
|
() => {
|
||||||
if (!settled) callback();
|
if (!settled) callback();
|
||||||
@ -449,16 +452,27 @@ export const sortChange = (data: any[], { prop, order }: SortProp) => {
|
|||||||
* - accessor 描述符(`{ get stage() { return ... } }`)按原样 defineProperty,调用方
|
* - accessor 描述符(`{ get stage() { return ... } }`)按原样 defineProperty,调用方
|
||||||
* 可控制读时求值;强制 `configurable: true` 以便下一次合并可再 define。
|
* 可控制读时求值;强制 `configurable: true` 以便下一次合并可再 define。
|
||||||
*
|
*
|
||||||
* 注意:formState 上由 props 派生的字段(keyProp / popperClass / config / initValues /
|
* 注意:extendState 只能向 formState「新增」字段,不允许覆盖其已有 key。
|
||||||
* isCompare / lastValues / parentValues)是只读 getter(无 setter),extendState 若以
|
* 调用方可通过 `reservedKeys` 传入合并前已存在的内置 key 快照(keyProp / popperClass /
|
||||||
* 普通字段形式返回同名 key,直接赋值会让 proxy 的 set trap 失败并抛出
|
* config / initValues / isCompare / lastValues / parentValues / values / $emit / fields /
|
||||||
* `TypeError: 'set' on proxy: trap returned falsish`,这里统一跳过并告警;
|
* post 等),命中这些 key 时统一跳过并告警。
|
||||||
* 如确需覆盖,可在 extendState 中以 get 访问器形式返回。
|
*
|
||||||
|
* 兜底:未传 `reservedKeys` 时,仍会拦截 props 派生的只读 getter 字段(无 setter),
|
||||||
|
* 否则以普通字段形式赋值会让 proxy 的 set trap 抛出
|
||||||
|
* `TypeError: 'set' on proxy: trap returned falsish`。
|
||||||
*/
|
*/
|
||||||
export const applyExtendState = (formState: FormState, state: Record<string, any> | null | undefined): void => {
|
export const applyExtendState = (
|
||||||
|
formState: FormState,
|
||||||
|
state: Record<string, any> | null | undefined,
|
||||||
|
reservedKeys?: Set<string | symbol>,
|
||||||
|
): void => {
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
|
|
||||||
for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(state))) {
|
for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(state))) {
|
||||||
|
if (reservedKeys?.has(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (!('value' in descriptor)) {
|
if (!('value' in descriptor)) {
|
||||||
descriptor.configurable = true;
|
descriptor.configurable = true;
|
||||||
Object.defineProperty(formState, key, descriptor);
|
Object.defineProperty(formState, key, descriptor);
|
||||||
|
|||||||
@ -38,8 +38,15 @@ export interface TypeMatchValidateContext {
|
|||||||
// #endregion TypeMatchValidateContext
|
// #endregion TypeMatchValidateContext
|
||||||
|
|
||||||
// #region TypeMatchValidator
|
// #region TypeMatchValidator
|
||||||
/** 自定义 type 校验器:返回错误文案;通过则返回 undefined */
|
/**
|
||||||
export type TypeMatchValidator = (value: any, context: TypeMatchValidateContext) => string | undefined;
|
* 自定义 type 校验器:返回错误文案;通过则返回 undefined。
|
||||||
|
*
|
||||||
|
* 支持返回 Promise,用于需要异步确认取值是否合法的场景(如请求接口校验 id 是否存在)。
|
||||||
|
*/
|
||||||
|
export type TypeMatchValidator = (
|
||||||
|
value: any,
|
||||||
|
context: TypeMatchValidateContext,
|
||||||
|
) => string | undefined | Promise<string | undefined>;
|
||||||
// #endregion TypeMatchValidator
|
// #endregion TypeMatchValidator
|
||||||
|
|
||||||
const typeMatchRuleRegistry = new Map<string, TypeMatchValidator>();
|
const typeMatchRuleRegistry = new Map<string, TypeMatchValidator>();
|
||||||
@ -142,7 +149,7 @@ const stringifyExampleValue = (value: any): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 参考建议中最多展示的可选值个数,超出以「等」省略。
|
// 参考建议中最多展示的可选值个数,超出以「等」省略。
|
||||||
const MAX_SUGGESTION_OPTIONS = 5;
|
const MAX_SUGGESTION_OPTIONS = 20;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成「请使用以下某一个值:xxx;xxx」形式的参考建议;无可选值时返回空字符串(不追加建议)。
|
* 生成「请使用以下某一个值:xxx;xxx」形式的参考建议;无可选值时返回空字符串(不追加建议)。
|
||||||
@ -692,12 +699,17 @@ const validateBuiltinTypeMatch = (
|
|||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验取值与字段 type 是否匹配;通过返回 undefined,否则返回错误文案。
|
||||||
|
*
|
||||||
|
* 命中的自定义规则是异步校验器时返回 Promise,内置规则始终同步返回。
|
||||||
|
*/
|
||||||
export const validateTypeMatch = (
|
export const validateTypeMatch = (
|
||||||
value: any,
|
value: any,
|
||||||
mForm: FormState | undefined,
|
mForm: FormState | undefined,
|
||||||
props: any,
|
props: any,
|
||||||
message?: string,
|
message?: string,
|
||||||
): string | undefined => {
|
): string | undefined | Promise<string | undefined> => {
|
||||||
if (isEmptyValue(value) || isEmptyArray(value)) {
|
if (isEmptyValue(value) || isEmptyArray(value)) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@ -724,31 +736,74 @@ export const validateTypeMatch = (
|
|||||||
return validateBuiltinTypeMatch(value, fieldType, mForm, props, message);
|
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) => {
|
export const createTypeMatchValidator = (mForm: FormState | undefined, props: any, rule: Rule) => {
|
||||||
const originalValidator = typeof rule.validator === 'function' ? rule.validator : undefined;
|
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) => {
|
return (asyncValidatorRule: any, value: any, callback: Function, source: any, options: any) => {
|
||||||
const actualValue = props.config?.names ? props.model : value;
|
const actualValue = props.config?.names ? props.model : value;
|
||||||
try {
|
|
||||||
const error = validateTypeMatch(actualValue, mForm, props, rule.message);
|
|
||||||
|
|
||||||
if (error) {
|
generation += 1;
|
||||||
callback(new Error(error));
|
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;
|
return;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
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) {
|
if (!originalValidator) {
|
||||||
return originalValidator(
|
conclude();
|
||||||
{
|
return;
|
||||||
rule: asyncValidatorRule,
|
}
|
||||||
value: actualValue,
|
|
||||||
callback,
|
let result: any;
|
||||||
source,
|
try {
|
||||||
options,
|
result = originalValidator(
|
||||||
},
|
{ rule: asyncValidatorRule, value: actualValue, callback: conclude, source, options },
|
||||||
{
|
{
|
||||||
values: mForm?.initValues || {},
|
values: mForm?.initValues || {},
|
||||||
model: props.model,
|
model: props.model,
|
||||||
@ -759,8 +814,57 @@ export const createTypeMatchValidator = (mForm: FormState | undefined, props: an
|
|||||||
},
|
},
|
||||||
mForm,
|
mForm,
|
||||||
);
|
);
|
||||||
|
} catch (err) {
|
||||||
|
conclude(toError(err));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
callback();
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPromise(error)) {
|
||||||
|
error.then((asyncMessage) => {
|
||||||
|
if (asyncMessage) {
|
||||||
|
conclude(new Error(asyncMessage));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settleWithOriginalValidator();
|
||||||
|
}, skipFailedTypeMatch);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
conclude(new Error(error));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
settleWithOriginalValidator();
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@ -189,9 +189,7 @@ describe('Form.vue —— extendState', () => {
|
|||||||
expect(v2).toMatch(/^stage-/);
|
expect(v2).toMatch(/^stage-/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('extendState 以普通字段返回 props 派生的只读字段时跳过并告警,不抛错', async () => {
|
test('extendState 以普通字段返回内置保留字段时静默跳过,其余字段正常合并', async () => {
|
||||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
||||||
|
|
||||||
const wrapper = mountForm({
|
const wrapper = mountForm({
|
||||||
keyProp: 'id',
|
keyProp: 'id',
|
||||||
extendState: () => ({ keyProp: 'custom', config: [], extra: 'ok' }),
|
extendState: () => ({ keyProp: 'custom', config: [], extra: 'ok' }),
|
||||||
@ -201,18 +199,15 @@ describe('Form.vue —— extendState', () => {
|
|||||||
await nextTick();
|
await nextTick();
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
|
||||||
// 只读派生字段保持 props 值,未被覆盖
|
// 内置保留字段(keyProp / config)未被 extendState 覆盖
|
||||||
expect((wrapper.vm.formState as any).keyProp).toBe('id');
|
expect((wrapper.vm.formState as any).keyProp).toBe('id');
|
||||||
expect(Array.isArray((wrapper.vm.formState as any).config)).toBe(true);
|
expect(Array.isArray((wrapper.vm.formState as any).config)).toBe(true);
|
||||||
// 其他字段正常合并
|
// 非保留字段正常合并
|
||||||
expect((wrapper.vm.formState as any).extra).toBe('ok');
|
expect((wrapper.vm.formState as any).extra).toBe('ok');
|
||||||
expect(warnSpy).toHaveBeenCalled();
|
|
||||||
expect(wrapper.find('.m-form').exists()).toBe(true);
|
expect(wrapper.find('.m-form').exists()).toBe(true);
|
||||||
|
|
||||||
warnSpy.mockRestore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('extendState 以 get 访问器返回只读字段同名 key 时允许显式覆盖', async () => {
|
test('extendState 以 get 访问器返回内置保留字段同名 key 时仍被拦截,无法覆盖', async () => {
|
||||||
const wrapper = mountForm({
|
const wrapper = mountForm({
|
||||||
keyProp: 'id',
|
keyProp: 'id',
|
||||||
extendState: () =>
|
extendState: () =>
|
||||||
@ -228,7 +223,8 @@ describe('Form.vue —— extendState', () => {
|
|||||||
await nextTick();
|
await nextTick();
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
|
||||||
expect((wrapper.vm.formState as any).keyProp).toBe('custom-key');
|
// keyProp 属于内置保留字段,即使以访问器形式返回也不允许覆盖
|
||||||
|
expect((wrapper.vm.formState as any).keyProp).toBe('id');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('extendState 同步段读到的响应式数据变化时会重跑', async () => {
|
test('extendState 同步段读到的响应式数据变化时会重跑', async () => {
|
||||||
|
|||||||
@ -90,9 +90,7 @@ describe('submitForm', () => {
|
|||||||
expect(extendState).toHaveBeenCalled();
|
expect(extendState).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('extendState 返回 keyProp 等只读派生字段时不抛错且正常 resolve', async () => {
|
test('extendState 返回 keyProp 等内置保留字段时静默跳过且正常 resolve', async () => {
|
||||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
||||||
|
|
||||||
const values = await submitForm({
|
const values = await submitForm({
|
||||||
config: [{ type: 'text', name: 'text', text: 'text' }],
|
config: [{ type: 'text', name: 'text', text: 'text' }],
|
||||||
initValues: { text: 'foo' },
|
initValues: { text: 'foo' },
|
||||||
@ -100,10 +98,8 @@ describe('submitForm', () => {
|
|||||||
appContext,
|
appContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// keyProp 属于内置保留字段,被静默跳过,不污染最终 values
|
||||||
expect(values).toEqual({ text: 'foo' });
|
expect(values).toEqual({ text: 'foo' });
|
||||||
expect(warnSpy).toHaveBeenCalled();
|
|
||||||
|
|
||||||
warnSpy.mockRestore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('在嵌套 items 配置下也能正确 resolve', async () => {
|
test('在嵌套 items 配置下也能正确 resolve', async () => {
|
||||||
|
|||||||
@ -16,9 +16,10 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, expect, test } from 'vitest';
|
import { describe, expect, test, vi } from 'vitest';
|
||||||
import type { FormState } from '@form/index';
|
import type { FormState } from '@form/index';
|
||||||
import {
|
import {
|
||||||
|
applyExtendState,
|
||||||
createObjectProp,
|
createObjectProp,
|
||||||
createValues,
|
createValues,
|
||||||
datetimeFormatter,
|
datetimeFormatter,
|
||||||
@ -1014,3 +1015,130 @@ describe('createObjectProp', () => {
|
|||||||
expect(createObjectProp('a.b.c', 'newKey', 'c')).toBe('a.b.newKey');
|
expect(createObjectProp('a.b.c', 'newKey', 'c')).toBe('a.b.newKey');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('applyExtendState', () => {
|
||||||
|
test('state 为 null 或 undefined 时不修改 formState', () => {
|
||||||
|
const formState = { a: 1 } as any;
|
||||||
|
|
||||||
|
applyExtendState(formState, null);
|
||||||
|
applyExtendState(formState, undefined);
|
||||||
|
|
||||||
|
expect(formState).toEqual({ a: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('普通字段(data descriptor)以新增字段形式合并进 formState', () => {
|
||||||
|
const formState = {} as any;
|
||||||
|
|
||||||
|
applyExtendState(formState, { foo: 'bar', num: 1 });
|
||||||
|
|
||||||
|
expect(formState.foo).toBe('bar');
|
||||||
|
expect(formState.num).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accessor descriptor 按原样 define 且读时求值', () => {
|
||||||
|
const formState = {} as any;
|
||||||
|
let count = 0;
|
||||||
|
const state = {};
|
||||||
|
Object.defineProperty(state, 'stage', {
|
||||||
|
get() {
|
||||||
|
count += 1;
|
||||||
|
return 'stageValue';
|
||||||
|
},
|
||||||
|
enumerable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
applyExtendState(formState, state);
|
||||||
|
|
||||||
|
// 读时才求值
|
||||||
|
expect(count).toBe(0);
|
||||||
|
expect(formState.stage).toBe('stageValue');
|
||||||
|
expect(count).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accessor descriptor 强制 configurable=true,可再次 define 不报错', () => {
|
||||||
|
const formState = {} as any;
|
||||||
|
const state = {};
|
||||||
|
// 原始描述符 configurable 为 false
|
||||||
|
Object.defineProperty(state, 'stage', {
|
||||||
|
get: () => 'v',
|
||||||
|
enumerable: true,
|
||||||
|
configurable: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
applyExtendState(formState, state);
|
||||||
|
|
||||||
|
const descriptor = Object.getOwnPropertyDescriptor(formState, 'stage');
|
||||||
|
expect(descriptor?.configurable).toBe(true);
|
||||||
|
// 再次合并(重新 define)不应抛出
|
||||||
|
expect(() => applyExtendState(formState, state)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reservedKeys 命中的 key 被跳过,不覆盖已有内置字段', () => {
|
||||||
|
const formState = { values: { origin: true } } as any;
|
||||||
|
const reservedKeys = new Set<string | symbol>(['values']);
|
||||||
|
|
||||||
|
applyExtendState(formState, { values: { changed: true }, extra: 1 }, reservedKeys);
|
||||||
|
|
||||||
|
// reserved key 未被覆盖
|
||||||
|
expect(formState.values).toEqual({ origin: true });
|
||||||
|
// 非 reserved 的新字段正常新增
|
||||||
|
expect(formState.extra).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reservedKeys 对 accessor 形式的同名 key 同样跳过', () => {
|
||||||
|
const formState = { keyProp: 'origin' } as any;
|
||||||
|
const reservedKeys = new Set<string | symbol>(['keyProp']);
|
||||||
|
const state = {};
|
||||||
|
Object.defineProperty(state, 'keyProp', {
|
||||||
|
get: () => 'override',
|
||||||
|
enumerable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
applyExtendState(formState, state, reservedKeys);
|
||||||
|
|
||||||
|
expect(formState.keyProp).toBe('origin');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('未传 reservedKeys 时,只读 getter 字段被跳过并告警', () => {
|
||||||
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||||
|
const formState = {} as any;
|
||||||
|
Object.defineProperty(formState, 'keyProp', {
|
||||||
|
get: () => 'readonly',
|
||||||
|
enumerable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
applyExtendState(formState, { keyProp: 'newValue' });
|
||||||
|
|
||||||
|
expect(formState.keyProp).toBe('readonly');
|
||||||
|
expect(warnSpy).toHaveBeenCalled();
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('未传 reservedKeys 时,存在 setter 的字段可被赋值', () => {
|
||||||
|
let inner = 'old';
|
||||||
|
const formState = {} as any;
|
||||||
|
Object.defineProperty(formState, 'writable', {
|
||||||
|
get: () => inner,
|
||||||
|
set: (v: string) => {
|
||||||
|
inner = v;
|
||||||
|
},
|
||||||
|
enumerable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
applyExtendState(formState, { writable: 'new' });
|
||||||
|
|
||||||
|
expect(formState.writable).toBe('new');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('未传 reservedKeys 时,普通可写字段正常覆盖赋值', () => {
|
||||||
|
const formState = { editable: 'old' } as any;
|
||||||
|
|
||||||
|
applyExtendState(formState, { editable: 'new' });
|
||||||
|
|
||||||
|
expect(formState.editable).toBe('new');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -196,13 +196,14 @@ describe('validateTypeMatch', () => {
|
|||||||
expect(validateTypeMatch(3, mForm, propsOf(config))).toBe('3 不在可选项中\n\n请使用以下某一个值:1;2');
|
expect(validateTypeMatch(3, mForm, propsOf(config))).toBe('3 不在可选项中\n\n请使用以下某一个值:1;2');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('可选项超过 5 个时建议仅展示前 5 个并以「等」省略', () => {
|
test('可选项超过 20 个时建议仅展示前 20 个并以「等」省略', () => {
|
||||||
|
const values = Array.from({ length: 21 }, (_, i) => i + 1);
|
||||||
const config = {
|
const config = {
|
||||||
type: 'select',
|
type: 'select',
|
||||||
options: [1, 2, 3, 4, 5, 6, 7].map((v) => ({ text: `${v}`, value: v })),
|
options: values.map((v) => ({ text: `${v}`, value: v })),
|
||||||
};
|
};
|
||||||
expect(validateTypeMatch(99, mForm, propsOf(config))).toBe(
|
expect(validateTypeMatch(99, mForm, propsOf(config))).toBe(
|
||||||
'99 不在可选项中\n\n请使用以下某一个值:1;2;3;4;5 等',
|
`99 不在可选项中\n\n请使用以下某一个值:${values.slice(0, 20).join(';')} 等`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -624,6 +625,88 @@ describe('getRules tdesign validator', () => {
|
|||||||
});
|
});
|
||||||
await expect(newRules[0].validator(1)).resolves.toBe(true);
|
await expect(newRules[0].validator(1)).resolves.toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('异步 typeMatch 校验器的结果能正确适配', async () => {
|
||||||
|
registerTypeMatchRule('async-type', async (value) => (value === 'ok' ? undefined : '异步校验失败'));
|
||||||
|
|
||||||
|
const rules: any = [{ typeMatch: true }];
|
||||||
|
const newRules: any = getRules(mForm, rules, propsOf({ type: 'async-type' }));
|
||||||
|
|
||||||
|
await expect(newRules[0].validator('ok')).resolves.toBe(true);
|
||||||
|
await expect(newRules[0].validator('bad')).resolves.toEqual({
|
||||||
|
result: false,
|
||||||
|
message: '异步校验失败',
|
||||||
|
});
|
||||||
|
|
||||||
|
deleteTypeMatchRule('async-type');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('自定义 validator 返回 Promise 时按 Promise 结果适配', async () => {
|
||||||
|
const rules: any = [
|
||||||
|
{ validator: () => Promise.resolve() },
|
||||||
|
{ validator: () => Promise.reject(new Error('异步失败')) },
|
||||||
|
{ validator: () => Promise.reject('非 Error') },
|
||||||
|
];
|
||||||
|
const newRules: any = getRules(mForm, rules, { config: {} });
|
||||||
|
|
||||||
|
await expect(newRules[0].validator('ok')).resolves.toBe(true);
|
||||||
|
await expect(newRules[1].validator('ok')).resolves.toEqual({
|
||||||
|
result: false,
|
||||||
|
message: '异步失败',
|
||||||
|
});
|
||||||
|
await expect(newRules[2].validator('ok')).resolves.toEqual({
|
||||||
|
result: false,
|
||||||
|
message: '非 Error',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('自定义 validator 同步抛错时转成 CustomValidateObj', async () => {
|
||||||
|
const rules: any = [
|
||||||
|
{
|
||||||
|
validator: () => {
|
||||||
|
throw new Error('validator 内部抛错');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
validator: () => {
|
||||||
|
throw 'string error';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const newRules: any = getRules(mForm, rules, { config: {} });
|
||||||
|
|
||||||
|
await expect(newRules[0].validator('ok')).resolves.toEqual({
|
||||||
|
result: false,
|
||||||
|
message: 'validator 内部抛错',
|
||||||
|
});
|
||||||
|
await expect(newRules[1].validator('ok')).resolves.toEqual({
|
||||||
|
result: false,
|
||||||
|
message: 'string error',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('callback 收到错误数组时取首条展示', async () => {
|
||||||
|
const rules: any = [
|
||||||
|
{
|
||||||
|
validator: ({ callback }: any) => {
|
||||||
|
callback([new Error('错误一'), new Error('错误二')]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
validator: ({ callback }: any) => {
|
||||||
|
callback([]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const newRules: any = getRules(mForm, rules, { config: {} });
|
||||||
|
|
||||||
|
await expect(newRules[0].validator('ok')).resolves.toEqual({
|
||||||
|
result: false,
|
||||||
|
message: '错误一',
|
||||||
|
});
|
||||||
|
// 空数组视为无错误
|
||||||
|
await expect(newRules[1].validator('ok')).resolves.toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('typeMatch 扩展注册', () => {
|
describe('typeMatch 扩展注册', () => {
|
||||||
@ -747,4 +830,402 @@ describe('createTypeMatchValidator', () => {
|
|||||||
expect(errorSpy).toHaveBeenCalled();
|
expect(errorSpy).toHaveBeenCalled();
|
||||||
errorSpy.mockRestore();
|
errorSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('异步校验器返回错误文案时回调 Error', async () => {
|
||||||
|
registerTypeMatchRule('async-type', async (value) => (value === 'ok' ? undefined : '异步校验失败'));
|
||||||
|
|
||||||
|
const callback = vi.fn();
|
||||||
|
const validator = createTypeMatchValidator(mForm, propsOf({ type: 'async-type' }), { typeMatch: true } as any);
|
||||||
|
|
||||||
|
// 异步校验器不能把 Promise 交还给 async-validator,否则会重复回调
|
||||||
|
expect(validator({}, 'bad', callback, {}, {})).toBeUndefined();
|
||||||
|
expect(callback).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(callback).toHaveBeenCalledTimes(1));
|
||||||
|
expect(callback.mock.calls[0][0]).toBeInstanceOf(Error);
|
||||||
|
expect(callback.mock.calls[0][0].message).toBe('异步校验失败');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('异步校验器通过时无参回调', async () => {
|
||||||
|
registerTypeMatchRule('async-type', async (value) => (value === 'ok' ? undefined : '异步校验失败'));
|
||||||
|
|
||||||
|
const callback = vi.fn();
|
||||||
|
const validator = createTypeMatchValidator(mForm, propsOf({ type: 'async-type' }), { typeMatch: true } as any);
|
||||||
|
|
||||||
|
validator({}, 'ok', callback, {}, {});
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(callback).toHaveBeenCalledTimes(1));
|
||||||
|
expect(callback).toHaveBeenCalledWith();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('异步校验器通过后继续执行原始 validator', async () => {
|
||||||
|
registerTypeMatchRule('async-type', async () => undefined);
|
||||||
|
|
||||||
|
const callback = vi.fn();
|
||||||
|
const originalValidator = vi.fn();
|
||||||
|
const validator = createTypeMatchValidator(mForm, propsOf({ type: 'async-type' }), {
|
||||||
|
typeMatch: true,
|
||||||
|
validator: originalValidator,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
validator({}, 'ok', callback, {}, {});
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(originalValidator).toHaveBeenCalled());
|
||||||
|
// 原始 validator 拿到 callback 后自行回调,这里不重复调用
|
||||||
|
expect(callback).not.toHaveBeenCalled();
|
||||||
|
expect(originalValidator.mock.calls[0][0].value).toBe('ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('异步校验器不通过时不执行原始 validator', async () => {
|
||||||
|
registerTypeMatchRule('async-type', async () => '异步校验失败');
|
||||||
|
|
||||||
|
const callback = vi.fn();
|
||||||
|
const originalValidator = vi.fn();
|
||||||
|
const validator = createTypeMatchValidator(mForm, propsOf({ type: 'async-type' }), {
|
||||||
|
typeMatch: true,
|
||||||
|
validator: originalValidator,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
validator({}, 'bad', callback, {}, {});
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(callback).toHaveBeenCalledTimes(1));
|
||||||
|
expect(originalValidator).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('异步校验器 reject 时忽略异常并继续执行原始 validator', async () => {
|
||||||
|
registerTypeMatchRule('async-type', () => Promise.reject(new Error('boom')));
|
||||||
|
|
||||||
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
const callback = vi.fn();
|
||||||
|
const originalValidator = vi.fn();
|
||||||
|
const validator = createTypeMatchValidator(mForm, propsOf({ type: 'async-type' }), {
|
||||||
|
typeMatch: true,
|
||||||
|
validator: originalValidator,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
validator({}, 'value', callback, {}, {});
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(originalValidator).toHaveBeenCalled());
|
||||||
|
expect(errorSpy).toHaveBeenCalled();
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('异步路径下原始 validator 返回的 Promise 会被转成 callback', async () => {
|
||||||
|
registerTypeMatchRule('async-type', async () => undefined);
|
||||||
|
|
||||||
|
const callback = vi.fn();
|
||||||
|
const validator = createTypeMatchValidator(mForm, propsOf({ type: 'async-type' }), {
|
||||||
|
typeMatch: true,
|
||||||
|
validator: () => Promise.reject(new Error('原始校验失败')),
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
validator({}, 'ok', callback, {}, {});
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(callback).toHaveBeenCalledTimes(1));
|
||||||
|
expect(callback.mock.calls[0][0].message).toBe('原始校验失败');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('原始 validator 既调 callback 又返回 Promise 时只回调一次', async () => {
|
||||||
|
registerTypeMatchRule('async-type', async () => undefined);
|
||||||
|
|
||||||
|
const callback = vi.fn();
|
||||||
|
const validator = createTypeMatchValidator(mForm, propsOf({ type: 'async-type' }), {
|
||||||
|
typeMatch: true,
|
||||||
|
// async 写法很常见:内部调了 callback,函数本身又返回 Promise
|
||||||
|
validator: async ({ callback: cb }: any) => {
|
||||||
|
cb(new Error('原始校验失败'));
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
validator({}, 'ok', callback, {}, {});
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(callback).toHaveBeenCalledTimes(1));
|
||||||
|
expect(callback.mock.calls[0][0].message).toBe('原始校验失败');
|
||||||
|
|
||||||
|
// 等 Promise 链彻底跑完,确认没有第二次回调
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(callback).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('异步校验器不影响同步内置规则', () => {
|
||||||
|
registerTypeMatchRule('async-type', async () => '异步校验失败');
|
||||||
|
|
||||||
|
const callback = vi.fn();
|
||||||
|
const validator = createTypeMatchValidator(mForm, propsOf({ type: 'number' }), { typeMatch: true } as any);
|
||||||
|
|
||||||
|
validator({}, 'not-a-number', callback, {}, {});
|
||||||
|
|
||||||
|
// 内置规则仍同步回调
|
||||||
|
expect(callback).toHaveBeenCalledTimes(1);
|
||||||
|
expect(callback.mock.calls[0][0].message).toContain('类型应为数字');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* async-validator 只解析同步返回给它的 validator 返回值,异步 typeMatch 通过后已脱离其调用栈,
|
||||||
|
* 因此这些约定必须由 createTypeMatchValidator 自行复刻,否则 callback 永不触发、校验一直挂起。
|
||||||
|
*/
|
||||||
|
describe('createTypeMatchValidator 原始 validator 返回值约定', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
clearTypeMatchRules();
|
||||||
|
registerTypeMatchRule('async-type', async () => undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
clearTypeMatchRules();
|
||||||
|
});
|
||||||
|
|
||||||
|
const validatorOf = (originalValidator: any, type = 'async-type') =>
|
||||||
|
createTypeMatchValidator(mForm, propsOf({ type }), {
|
||||||
|
typeMatch: true,
|
||||||
|
validator: originalValidator,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
/** async-type 走异步 typeMatch 路径,text 走同步内置规则路径,两者行为应完全一致 */
|
||||||
|
const paths = ['async-type', 'text'];
|
||||||
|
|
||||||
|
test.each(paths)('%s:返回 false 时回调错误', async (type) => {
|
||||||
|
const callback = vi.fn();
|
||||||
|
validatorOf(() => false, type)({ fullField: 'title' }, 'ok', callback, {}, {});
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(callback).toHaveBeenCalledTimes(1));
|
||||||
|
expect(callback.mock.calls[0][0].message).toBe('title fails');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('返回 false 时优先使用 rule.message', async () => {
|
||||||
|
const callback = vi.fn();
|
||||||
|
createTypeMatchValidator(mForm, propsOf({ type: 'async-type' }), {
|
||||||
|
typeMatch: true,
|
||||||
|
message: '自定义错误',
|
||||||
|
validator: () => false,
|
||||||
|
} as any)({}, 'ok', callback, {}, {});
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(callback).toHaveBeenCalledTimes(1));
|
||||||
|
expect(callback.mock.calls[0][0].message).toBe('自定义错误');
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(paths)('%s:返回 true 时无参回调', async (type) => {
|
||||||
|
const callback = vi.fn();
|
||||||
|
validatorOf(() => true, type)({}, 'ok', callback, {}, {});
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(callback).toHaveBeenCalledTimes(1));
|
||||||
|
expect(callback).toHaveBeenCalledWith();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(paths)('%s:返回 Error 实例时透传', async (type) => {
|
||||||
|
const error = new Error('返回 Error');
|
||||||
|
const callback = vi.fn();
|
||||||
|
validatorOf(() => error, type)({}, 'ok', callback, {}, {});
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(callback).toHaveBeenCalledTimes(1));
|
||||||
|
expect(callback).toHaveBeenCalledWith(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(paths)('%s:返回错误数组时透传', async (type) => {
|
||||||
|
const errors = [new Error('错误一'), new Error('错误二')];
|
||||||
|
const callback = vi.fn();
|
||||||
|
validatorOf(() => errors, type)({}, 'ok', callback, {}, {});
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(callback).toHaveBeenCalledTimes(1));
|
||||||
|
expect(callback).toHaveBeenCalledWith(errors);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(paths)('%s:同步抛错时转成回调而非未捕获异常', async (type) => {
|
||||||
|
const unhandled: any[] = [];
|
||||||
|
const onUnhandled = (reason: any) => unhandled.push(reason);
|
||||||
|
process.on('unhandledRejection', onUnhandled);
|
||||||
|
|
||||||
|
const callback = vi.fn();
|
||||||
|
validatorOf(() => {
|
||||||
|
throw new Error('validator 内部抛错');
|
||||||
|
}, type)({}, 'ok', callback, {}, {});
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(callback).toHaveBeenCalledTimes(1));
|
||||||
|
expect(callback.mock.calls[0][0].message).toBe('validator 内部抛错');
|
||||||
|
|
||||||
|
// 等待微任务队列排空,确认异常没有变成游离的 rejected promise
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
process.off('unhandledRejection', onUnhandled);
|
||||||
|
expect(unhandled).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
[
|
||||||
|
'抛出',
|
||||||
|
() => {
|
||||||
|
throw 'string error';
|
||||||
|
},
|
||||||
|
],
|
||||||
|
['reject', () => Promise.reject('string error')],
|
||||||
|
])('%s非 Error 时包装成 Error', async (_case, originalValidator) => {
|
||||||
|
const callback = vi.fn();
|
||||||
|
validatorOf(originalValidator)({}, 'ok', callback, {}, {});
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(callback).toHaveBeenCalledTimes(1));
|
||||||
|
expect(callback.mock.calls[0][0]).toBeInstanceOf(Error);
|
||||||
|
expect(callback.mock.calls[0][0].message).toBe('string error');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('同步 typeMatch 路径不把 Promise 交还 async-validator,避免重复回调', async () => {
|
||||||
|
const callback = vi.fn();
|
||||||
|
// async 写法很常见:内部调了 callback,函数本身又返回 Promise
|
||||||
|
const returned = validatorOf(async ({ callback: cb }: any) => {
|
||||||
|
cb(new Error('原始校验失败'));
|
||||||
|
}, 'text')({}, 'ok', callback, {}, {});
|
||||||
|
|
||||||
|
// 返回 undefined,async-validator 不会再解析 Promise 二次回调
|
||||||
|
expect(returned).toBeUndefined();
|
||||||
|
expect(callback).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
expect(callback).toHaveBeenCalledTimes(1);
|
||||||
|
expect(callback.mock.calls[0][0].message).toBe('原始校验失败');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createTypeMatchValidator 异步校验竞态', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
clearTypeMatchRules();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
clearTypeMatchRules();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 注册一个由测试手动控制结束时机的异步规则,返回按调用顺序收集的 resolve 队列 */
|
||||||
|
const registerManualRule = () => {
|
||||||
|
const resolvers: ((message: string | undefined) => void)[] = [];
|
||||||
|
registerTypeMatchRule('async-type', () => new Promise<string | undefined>((resolve) => resolvers.push(resolve)));
|
||||||
|
return resolvers;
|
||||||
|
};
|
||||||
|
|
||||||
|
test('旧校验不会用过期结论结算,而是跟随最新一轮的结论', async () => {
|
||||||
|
const resolvers = registerManualRule();
|
||||||
|
const validator = createTypeMatchValidator(mForm, propsOf({ type: 'async-type' }), { typeMatch: true } as any);
|
||||||
|
|
||||||
|
const staleCallback = vi.fn();
|
||||||
|
const freshCallback = vi.fn();
|
||||||
|
validator({}, 'old', staleCallback, {}, {});
|
||||||
|
expect(staleCallback).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// 值变化触发新一轮校验:旧校验的结论作废,但不能提前按通过结算
|
||||||
|
validator({}, 'new', freshCallback, {}, {});
|
||||||
|
expect(staleCallback).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// 旧校验先返回,它的结论被丢弃
|
||||||
|
resolvers[0]('旧值校验失败');
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(staleCallback).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// 最新一轮出结论后,两次调用都以新结论结算
|
||||||
|
resolvers[1]('新值校验失败');
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(staleCallback).toHaveBeenCalledTimes(1));
|
||||||
|
expect(freshCallback).toHaveBeenCalledTimes(1);
|
||||||
|
expect(staleCallback.mock.calls[0][0].message).toBe('新值校验失败');
|
||||||
|
expect(freshCallback.mock.calls[0][0].message).toBe('新值校验失败');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('旧校验被同步路径的校验取代时,跟随同步结论结算', async () => {
|
||||||
|
const resolvers = registerManualRule();
|
||||||
|
const validator = createTypeMatchValidator(mForm, propsOf({ type: 'async-type' }), { typeMatch: true } as any);
|
||||||
|
|
||||||
|
const staleCallback = vi.fn();
|
||||||
|
const freshCallback = vi.fn();
|
||||||
|
validator({}, 'bad', staleCallback, {}, {});
|
||||||
|
|
||||||
|
// 值被清空:空值走同步路径直接通过,在途的旧校验必须跟着通过
|
||||||
|
validator({}, '', freshCallback, {}, {});
|
||||||
|
expect(freshCallback).toHaveBeenCalledWith();
|
||||||
|
expect(staleCallback).toHaveBeenCalledWith();
|
||||||
|
|
||||||
|
// 旧校验的失败结论晚到,不能落到已经通过的新值上
|
||||||
|
resolvers[0]('异步校验失败');
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
expect(staleCallback).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('取值为同一引用(names / 对象值)时也能识别出旧校验', async () => {
|
||||||
|
const resolvers = registerManualRule();
|
||||||
|
const model: Record<string, any> = { start: 'old' };
|
||||||
|
const props = { ...propsOf({ type: 'async-type', names: ['start'] }), model };
|
||||||
|
const validator = createTypeMatchValidator(mForm, props, { typeMatch: true } as any);
|
||||||
|
|
||||||
|
const staleCallback = vi.fn();
|
||||||
|
const freshCallback = vi.fn();
|
||||||
|
validator({}, 'x', staleCallback, {}, {});
|
||||||
|
|
||||||
|
// names 场景下校验的是 model 本身,就地修改后引用不变
|
||||||
|
model.start = 'new';
|
||||||
|
validator({}, 'y', freshCallback, {}, {});
|
||||||
|
|
||||||
|
// 新校验先通过,旧校验的失败结论后到,不能覆盖
|
||||||
|
resolvers[1](undefined);
|
||||||
|
resolvers[0]('旧值校验失败');
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(freshCallback).toHaveBeenCalledTimes(1));
|
||||||
|
expect(freshCallback).toHaveBeenCalledWith();
|
||||||
|
expect(staleCallback).toHaveBeenCalledTimes(1);
|
||||||
|
expect(staleCallback).toHaveBeenCalledWith();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('同值的多次在途校验都会被最新一轮结算', async () => {
|
||||||
|
const resolvers = registerManualRule();
|
||||||
|
const validator = createTypeMatchValidator(mForm, propsOf({ type: 'async-type' }), { typeMatch: true } as any);
|
||||||
|
|
||||||
|
const first = vi.fn();
|
||||||
|
const second = vi.fn();
|
||||||
|
const third = vi.fn();
|
||||||
|
// 同一个值可能被 blur、change 触发两次校验,之后值才变化
|
||||||
|
validator({}, 'old', first, {}, {});
|
||||||
|
validator({}, 'old', second, {}, {});
|
||||||
|
validator({}, 'new', third, {}, {});
|
||||||
|
|
||||||
|
resolvers[0]('旧值校验失败');
|
||||||
|
resolvers[1]('旧值校验失败');
|
||||||
|
resolvers[2]('新值校验失败');
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(third).toHaveBeenCalledTimes(1));
|
||||||
|
expect(first.mock.calls[0][0].message).toBe('新值校验失败');
|
||||||
|
expect(second.mock.calls[0][0].message).toBe('新值校验失败');
|
||||||
|
expect(third.mock.calls[0][0].message).toBe('新值校验失败');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('并发的多次校验都会拿到结论,不会有调用被漏掉', async () => {
|
||||||
|
registerTypeMatchRule('async-type', async () => '异步校验失败');
|
||||||
|
|
||||||
|
const validator = createTypeMatchValidator(mForm, propsOf({ type: 'async-type' }), { typeMatch: true } as any);
|
||||||
|
|
||||||
|
const first = vi.fn();
|
||||||
|
const second = vi.fn();
|
||||||
|
validator({}, 'same', first, {}, {});
|
||||||
|
validator({}, 'same', second, {}, {});
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(first).toHaveBeenCalledTimes(1);
|
||||||
|
expect(second).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
expect(first.mock.calls[0][0].message).toBe('异步校验失败');
|
||||||
|
expect(second.mock.calls[0][0].message).toBe('异步校验失败');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('结论已作废的旧校验返回后不再执行原始 validator', async () => {
|
||||||
|
const resolvers = registerManualRule();
|
||||||
|
const originalValidator = vi.fn(({ callback }: any) => callback());
|
||||||
|
const validator = createTypeMatchValidator(mForm, propsOf({ type: 'async-type' }), {
|
||||||
|
typeMatch: true,
|
||||||
|
validator: originalValidator,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
validator({}, 'old', vi.fn(), {}, {});
|
||||||
|
validator({}, 'new', vi.fn(), {}, {});
|
||||||
|
|
||||||
|
// 旧校验的结论已作废,它的 typeMatch 通过后不应再触发一次原始 validator(可能带副作用)
|
||||||
|
resolvers[0](undefined);
|
||||||
|
resolvers[1](undefined);
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(originalValidator).toHaveBeenCalled());
|
||||||
|
expect(originalValidator).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.0-beta.15",
|
"version": "1.8.0-beta.20",
|
||||||
"name": "@tmagic/schema",
|
"name": "@tmagic/schema",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.0-beta.15",
|
"version": "1.8.0-beta.20",
|
||||||
"name": "@tmagic/stage",
|
"name": "@tmagic/stage",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -84,6 +84,8 @@ export default class ActionManager extends EventEmitter {
|
|||||||
private containerHighlightDuration: number;
|
private containerHighlightDuration: number;
|
||||||
/** 将组件加入容器的操作方式 */
|
/** 将组件加入容器的操作方式 */
|
||||||
private containerHighlightType?: ContainerHighlightType;
|
private containerHighlightType?: ContainerHighlightType;
|
||||||
|
/** 是否仅在新增组件时才启用将组件加入容器 */
|
||||||
|
private containerHighlightAddOnly = false;
|
||||||
private isAltKeydown = false;
|
private isAltKeydown = false;
|
||||||
private getTargetElement: GetTargetElement;
|
private getTargetElement: GetTargetElement;
|
||||||
private getElementsFromPoint: GetElementsFromPoint;
|
private getElementsFromPoint: GetElementsFromPoint;
|
||||||
@ -124,6 +126,7 @@ export default class ActionManager extends EventEmitter {
|
|||||||
this.containerHighlightClassName = config.containerHighlightClassName || CONTAINER_HIGHLIGHT_CLASS_NAME;
|
this.containerHighlightClassName = config.containerHighlightClassName || CONTAINER_HIGHLIGHT_CLASS_NAME;
|
||||||
this.containerHighlightDuration = config.containerHighlightDuration || defaultContainerHighlightDuration;
|
this.containerHighlightDuration = config.containerHighlightDuration || defaultContainerHighlightDuration;
|
||||||
this.containerHighlightType = config.containerHighlightType;
|
this.containerHighlightType = config.containerHighlightType;
|
||||||
|
this.containerHighlightAddOnly = config.containerHighlightAddOnly ?? false;
|
||||||
this.disabledMultiSelect = config.disabledMultiSelect ?? false;
|
this.disabledMultiSelect = config.disabledMultiSelect ?? false;
|
||||||
this.alwaysMultiSelect = config.alwaysMultiSelect ?? false;
|
this.alwaysMultiSelect = config.alwaysMultiSelect ?? false;
|
||||||
this.getTargetElement = config.getTargetElement;
|
this.getTargetElement = config.getTargetElement;
|
||||||
@ -428,10 +431,15 @@ export default class ActionManager extends EventEmitter {
|
|||||||
* 标记的作用:1、高亮容器,给用户一个加入容器的交互感知;2、释放鼠标后,通过标记的标志找到要加入的容器
|
* 标记的作用:1、高亮容器,给用户一个加入容器的交互感知;2、释放鼠标后,通过标记的标志找到要加入的容器
|
||||||
* @param event 鼠标事件
|
* @param event 鼠标事件
|
||||||
* @param excludeElList 计算鼠标所在容器时要排除的元素列表
|
* @param excludeElList 计算鼠标所在容器时要排除的元素列表
|
||||||
|
* @param isAdd 当前操作是否为新增组件(从组件列表拖入),开启 containerHighlightAddOnly 时只有新增才会标记
|
||||||
* @returns timeoutId,调用方在鼠标移走时要取消该timeout,阻止标记
|
* @returns timeoutId,调用方在鼠标移走时要取消该timeout,阻止标记
|
||||||
*/
|
*/
|
||||||
public delayedMarkContainer(event: MouseEvent, excludeElList: Element[] = []): NodeJS.Timeout | undefined {
|
public delayedMarkContainer(
|
||||||
if (this.canAddToContainer()) {
|
event: MouseEvent,
|
||||||
|
excludeElList: Element[] = [],
|
||||||
|
isAdd = false,
|
||||||
|
): NodeJS.Timeout | undefined {
|
||||||
|
if (this.canAddToContainer(isAdd)) {
|
||||||
return globalThis.setTimeout(() => {
|
return globalThis.setTimeout(() => {
|
||||||
this.addContainerHighlightClassName(event, excludeElList);
|
this.addContainerHighlightClassName(event, excludeElList);
|
||||||
}, this.containerHighlightDuration);
|
}, this.containerHighlightDuration);
|
||||||
@ -609,9 +617,13 @@ export default class ActionManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 当前状态下能否将组件加入容器,默认是鼠标悬停一段时间加入,alt模式则是按住alt+鼠标悬停一段时间加入
|
* 当前状态下能否将组件加入容器,默认是鼠标悬停一段时间加入,alt模式则是按住alt+鼠标悬停一段时间加入;
|
||||||
|
* 开启containerHighlightAddOnly时,画布中拖动已有组件不能加入容器
|
||||||
|
* @param isAdd 当前操作是否为新增组件
|
||||||
*/
|
*/
|
||||||
private canAddToContainer(): boolean {
|
private canAddToContainer(isAdd = false): boolean {
|
||||||
|
if (this.containerHighlightAddOnly && !isAdd) return false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
this.containerHighlightType === ContainerHighlightType.DEFAULT ||
|
this.containerHighlightType === ContainerHighlightType.DEFAULT ||
|
||||||
(this.containerHighlightType === ContainerHighlightType.ALT && this.isAltKeydown)
|
(this.containerHighlightType === ContainerHighlightType.ALT && this.isAltKeydown)
|
||||||
@ -624,10 +636,10 @@ export default class ActionManager extends EventEmitter {
|
|||||||
*/
|
*/
|
||||||
private markContainerEnd(): HTMLElement | null {
|
private markContainerEnd(): HTMLElement | null {
|
||||||
const doc = this.getRenderDocument();
|
const doc = this.getRenderDocument();
|
||||||
if (doc && this.canAddToContainer()) {
|
if (!doc) return null;
|
||||||
return removeClassNameByClassName(doc, this.containerHighlightClassName);
|
|
||||||
}
|
const markedContainer = removeClassNameByClassName(doc, this.containerHighlightClassName);
|
||||||
return null;
|
return this.canAddToContainer() ? markedContainer : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private initMouseEvent(): void {
|
private initMouseEvent(): void {
|
||||||
|
|||||||
@ -263,10 +263,15 @@ export default class StageCore extends EventEmitter {
|
|||||||
* 标记的作用:1、高亮容器,给用户一个加入容器的交互感知;2、释放鼠标后,通过标记的标志找到要加入的容器
|
* 标记的作用:1、高亮容器,给用户一个加入容器的交互感知;2、释放鼠标后,通过标记的标志找到要加入的容器
|
||||||
* @param event 鼠标事件
|
* @param event 鼠标事件
|
||||||
* @param excludeElList 计算鼠标所在容器时要排除的元素列表
|
* @param excludeElList 计算鼠标所在容器时要排除的元素列表
|
||||||
|
* @param isAdd 当前操作是否为新增组件(从组件列表拖入),开启 containerHighlightAddOnly 时只有新增才会标记
|
||||||
* @returns timeoutId,调用方在鼠标移走时要取消该timeout,阻止标记
|
* @returns timeoutId,调用方在鼠标移走时要取消该timeout,阻止标记
|
||||||
*/
|
*/
|
||||||
public delayedMarkContainer(event: MouseEvent, excludeElList: Element[] = []): NodeJS.Timeout | undefined {
|
public delayedMarkContainer(
|
||||||
return this.actionManager?.delayedMarkContainer(event, excludeElList);
|
event: MouseEvent,
|
||||||
|
excludeElList: Element[] = [],
|
||||||
|
isAdd = false,
|
||||||
|
): NodeJS.Timeout | undefined {
|
||||||
|
return this.actionManager?.delayedMarkContainer(event, excludeElList, isAdd);
|
||||||
}
|
}
|
||||||
|
|
||||||
public getMoveableOption<K extends keyof MoveableOptions>(key: K): MoveableOptions[K] | undefined {
|
public getMoveableOption<K extends keyof MoveableOptions>(key: K): MoveableOptions[K] | undefined {
|
||||||
@ -368,6 +373,7 @@ export default class StageCore extends EventEmitter {
|
|||||||
containerHighlightClassName: config.containerHighlightClassName,
|
containerHighlightClassName: config.containerHighlightClassName,
|
||||||
containerHighlightDuration: config.containerHighlightDuration,
|
containerHighlightDuration: config.containerHighlightDuration,
|
||||||
containerHighlightType: config.containerHighlightType,
|
containerHighlightType: config.containerHighlightType,
|
||||||
|
containerHighlightAddOnly: config.containerHighlightAddOnly,
|
||||||
moveableOptions: config.moveableOptions,
|
moveableOptions: config.moveableOptions,
|
||||||
container: this.mask!.content,
|
container: this.mask!.content,
|
||||||
disabledDragStart: config.disabledDragStart,
|
disabledDragStart: config.disabledDragStart,
|
||||||
|
|||||||
@ -53,7 +53,11 @@ export type GetTargetElement = (id: Id) => HTMLElement | null;
|
|||||||
/** render提供的接口,通过坐标获得坐标下所有HTML元素数组 */
|
/** render提供的接口,通过坐标获得坐标下所有HTML元素数组 */
|
||||||
export type GetElementsFromPoint = (point: Point) => HTMLElement[];
|
export type GetElementsFromPoint = (point: Point) => HTMLElement[];
|
||||||
export type GetRenderDocument = () => Document | undefined;
|
export type GetRenderDocument = () => Document | undefined;
|
||||||
export type DelayedMarkContainer = (event: MouseEvent, exclude: Element[]) => NodeJS.Timeout | undefined;
|
export type DelayedMarkContainer = (
|
||||||
|
event: MouseEvent,
|
||||||
|
exclude?: Element[],
|
||||||
|
isAdd?: boolean,
|
||||||
|
) => NodeJS.Timeout | undefined;
|
||||||
export type MarkContainerEnd = () => HTMLElement | null;
|
export type MarkContainerEnd = () => HTMLElement | null;
|
||||||
export type GetRootContainer = () => HTMLDivElement | undefined;
|
export type GetRootContainer = () => HTMLDivElement | undefined;
|
||||||
|
|
||||||
@ -74,6 +78,11 @@ export interface StageCoreConfig {
|
|||||||
containerHighlightClassName?: string;
|
containerHighlightClassName?: string;
|
||||||
containerHighlightDuration?: number;
|
containerHighlightDuration?: number;
|
||||||
containerHighlightType?: ContainerHighlightType;
|
containerHighlightType?: ContainerHighlightType;
|
||||||
|
/**
|
||||||
|
* 是否仅在新增组件(从组件列表拖入新组件)时才启用将组件拖入容器,
|
||||||
|
* 开启后在画布中拖动已有组件不会识别容器,默认 false
|
||||||
|
*/
|
||||||
|
containerHighlightAddOnly?: boolean;
|
||||||
moveableOptions?: CustomizeMoveableOptions;
|
moveableOptions?: CustomizeMoveableOptions;
|
||||||
/** runtime 的HTML地址,可以是一个HTTP地址,如果和编辑器不同域,需要设置跨域,也可以是一个相对或绝对路径 */
|
/** runtime 的HTML地址,可以是一个HTTP地址,如果和编辑器不同域,需要设置跨域,也可以是一个相对或绝对路径 */
|
||||||
runtimeUrl?: string;
|
runtimeUrl?: string;
|
||||||
@ -102,6 +111,8 @@ export interface ActionManagerConfig {
|
|||||||
containerHighlightClassName?: string;
|
containerHighlightClassName?: string;
|
||||||
containerHighlightDuration?: number;
|
containerHighlightDuration?: number;
|
||||||
containerHighlightType?: ContainerHighlightType;
|
containerHighlightType?: ContainerHighlightType;
|
||||||
|
/** 见 StageCoreConfig.containerHighlightAddOnly */
|
||||||
|
containerHighlightAddOnly?: boolean;
|
||||||
moveableOptions?: CustomizeMoveableOptions;
|
moveableOptions?: CustomizeMoveableOptions;
|
||||||
disabledDragStart?: boolean;
|
disabledDragStart?: boolean;
|
||||||
disabledMultiSelect?: boolean;
|
disabledMultiSelect?: boolean;
|
||||||
|
|||||||
@ -275,6 +275,84 @@ describe('ActionManager - 交互与事件', () => {
|
|||||||
expect(containerEl.classList.contains('tmagic-stage-container-highlight')).toBe(true);
|
expect(containerEl.classList.contains('tmagic-stage-container-highlight')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('delayedMarkContainer 未配置 containerHighlightType 时不标记容器', () => {
|
||||||
|
am = new ActionManager(createConfig({ isContainer: async () => true }));
|
||||||
|
expect(am.delayedMarkContainer(mouseAtOrigin())).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('delayedMarkContainer alt 模式下需按住 alt 才标记容器', () => {
|
||||||
|
am = new ActionManager(
|
||||||
|
createConfig({
|
||||||
|
containerHighlightType: ContainerHighlightType.ALT,
|
||||||
|
isContainer: async () => true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(am.delayedMarkContainer(mouseAtOrigin())).toBeUndefined();
|
||||||
|
(am as any).isAltKeydown = true;
|
||||||
|
expect(am.delayedMarkContainer(mouseAtOrigin())).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('containerHighlightAddOnly 开启时拖动已有组件不标记容器', () => {
|
||||||
|
const containerEl = globalThis.document.createElement('div');
|
||||||
|
containerEl.dataset.tmagicId = 'container_1';
|
||||||
|
am = new ActionManager(
|
||||||
|
createConfig({
|
||||||
|
containerHighlightType: ContainerHighlightType.DEFAULT,
|
||||||
|
containerHighlightAddOnly: true,
|
||||||
|
getElementsFromPoint: () => [containerEl],
|
||||||
|
isContainer: async () => true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(am.delayedMarkContainer(mouseAtOrigin(), [globalThis.document.createElement('div')])).toBeUndefined();
|
||||||
|
vi.advanceTimersByTime(900);
|
||||||
|
expect(containerEl.classList.contains('tmagic-stage-container-highlight')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('containerHighlightAddOnly 开启时新增组件仍按 containerHighlightType 标记容器', async () => {
|
||||||
|
const containerEl = globalThis.document.createElement('div');
|
||||||
|
containerEl.dataset.tmagicId = 'container_1';
|
||||||
|
am = new ActionManager(
|
||||||
|
createConfig({
|
||||||
|
containerHighlightType: ContainerHighlightType.DEFAULT,
|
||||||
|
containerHighlightAddOnly: true,
|
||||||
|
getElementsFromPoint: () => [containerEl],
|
||||||
|
isContainer: async () => true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(am.delayedMarkContainer(mouseAtOrigin(), [], true)).toBeDefined();
|
||||||
|
vi.advanceTimersByTime(900);
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(containerEl.classList.contains('tmagic-stage-container-highlight')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('containerHighlightAddOnly 开启且 containerHighlightType 为 alt 时新增组件也需按住 alt', () => {
|
||||||
|
am = new ActionManager(
|
||||||
|
createConfig({
|
||||||
|
containerHighlightType: ContainerHighlightType.ALT,
|
||||||
|
containerHighlightAddOnly: true,
|
||||||
|
isContainer: async () => true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(am.delayedMarkContainer(mouseAtOrigin(), [], true)).toBeUndefined();
|
||||||
|
(am as any).isAltKeydown = true;
|
||||||
|
expect(am.delayedMarkContainer(mouseAtOrigin(), [], true)).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('containerHighlightAddOnly 开启时结束标记仍清理高亮但不返回容器', () => {
|
||||||
|
const containerEl = globalThis.document.createElement('div');
|
||||||
|
containerEl.classList.add('tmagic-stage-container-highlight');
|
||||||
|
globalThis.document.body.appendChild(containerEl);
|
||||||
|
am = new ActionManager(
|
||||||
|
createConfig({
|
||||||
|
containerHighlightType: ContainerHighlightType.DEFAULT,
|
||||||
|
containerHighlightAddOnly: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect((am as any).markContainerEnd()).toBeNull();
|
||||||
|
expect(containerEl.classList.contains('tmagic-stage-container-highlight')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
test('updateMoveableOptions / getDragStatus 可调用', () => {
|
test('updateMoveableOptions / getDragStatus 可调用', () => {
|
||||||
am = new ActionManager(createConfig());
|
am = new ActionManager(createConfig());
|
||||||
expect(() => am.updateMoveableOptions()).not.toThrow();
|
expect(() => am.updateMoveableOptions()).not.toThrow();
|
||||||
|
|||||||
@ -181,6 +181,16 @@ describe('StageCore', () => {
|
|||||||
stage.destroy();
|
stage.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('delayedMarkContainer 将新增标识透传给 actionManager', async () => {
|
||||||
|
const { host, stage } = createStage();
|
||||||
|
await stage.mount(host);
|
||||||
|
const spy = vi.spyOn(stage.actionManager!, 'delayedMarkContainer');
|
||||||
|
const event = mouseAtOrigin();
|
||||||
|
stage.delayedMarkContainer(event, [], true);
|
||||||
|
expect(spy).toHaveBeenCalledWith(event, [], true);
|
||||||
|
stage.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
test('autoScrollIntoView 选中时调用 mask.observerIntersection', async () => {
|
test('autoScrollIntoView 选中时调用 mask.observerIntersection', async () => {
|
||||||
const host = globalThis.document.createElement('div');
|
const host = globalThis.document.createElement('div');
|
||||||
globalThis.document.body.appendChild(host);
|
globalThis.document.body.appendChild(host);
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.0-beta.15",
|
"version": "1.8.0-beta.20",
|
||||||
"name": "@tmagic/table",
|
"name": "@tmagic/table",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": [
|
"sideEffects": [
|
||||||
|
|||||||
@ -17,6 +17,7 @@
|
|||||||
:width="action.subActionConfig?.popoverWidth"
|
:width="action.subActionConfig?.popoverWidth"
|
||||||
:popper-class="action.subActionConfig?.popoverClass"
|
:popper-class="action.subActionConfig?.popoverClass"
|
||||||
:destroy-on-close="action.subActionConfig?.popoverDestroyOnClose ?? true"
|
:destroy-on-close="action.subActionConfig?.popoverDestroyOnClose ?? true"
|
||||||
|
@clickoutside="popoverVisible = false"
|
||||||
>
|
>
|
||||||
<template #reference>
|
<template #reference>
|
||||||
<TMagicButton
|
<TMagicButton
|
||||||
@ -84,8 +85,15 @@
|
|||||||
>
|
>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts">
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
// 所有行共享:同一时间仅允许一个 sub-actions 气泡展开,激活新的会自动关闭上一个
|
||||||
|
const activePopoverId = ref<symbol | null>(null);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { watch } from 'vue';
|
||||||
import { ArrowDown, ArrowRight } from '@element-plus/icons-vue';
|
import { ArrowDown, ArrowRight } from '@element-plus/icons-vue';
|
||||||
import { cloneDeep } from 'lodash-es';
|
import { cloneDeep } from 'lodash-es';
|
||||||
|
|
||||||
@ -117,7 +125,23 @@ const props = withDefaults(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const popoverId = Symbol('sub-actions-popover');
|
||||||
const popoverVisible = ref(false);
|
const popoverVisible = ref(false);
|
||||||
|
|
||||||
|
watch(popoverVisible, (visible) => {
|
||||||
|
if (visible) {
|
||||||
|
activePopoverId.value = popoverId;
|
||||||
|
} else if (activePopoverId.value === popoverId) {
|
||||||
|
activePopoverId.value = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(activePopoverId, (id) => {
|
||||||
|
if (id !== popoverId && popoverVisible.value) {
|
||||||
|
popoverVisible.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const togglePopover = () => {
|
const togglePopover = () => {
|
||||||
popoverVisible.value = !popoverVisible.value;
|
popoverVisible.value = !popoverVisible.value;
|
||||||
};
|
};
|
||||||
@ -134,7 +158,8 @@ const actionHandler = async (action: ColumnActionConfig, row: any, index: number
|
|||||||
} else {
|
} else {
|
||||||
await action.handler?.(row, index);
|
await action.handler?.(row, index);
|
||||||
}
|
}
|
||||||
action.after?.(row, index);
|
await action.after?.(row, index);
|
||||||
|
popoverVisible.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const save = async (index: number, config: ColumnConfig) => {
|
const save = async (index: number, config: ColumnConfig) => {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.0-beta.15",
|
"version": "1.8.0-beta.20",
|
||||||
"name": "@tmagic/tdesign-vue-next-adapter",
|
"name": "@tmagic/tdesign-vue-next-adapter",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.0-beta.15",
|
"version": "1.8.0-beta.20",
|
||||||
"name": "@tmagic/utils",
|
"name": "@tmagic/utils",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -142,7 +142,7 @@ export const getNodeInfo = (id: Id, root: { id: Id; items?: MNode[] } | null, sk
|
|||||||
info.parent = path[path.length - 2] as MContainer;
|
info.parent = path[path.length - 2] as MContainer;
|
||||||
|
|
||||||
for (const item of path) {
|
for (const item of path) {
|
||||||
if (isPage(item) || isPageFragment(item)) {
|
if (isPageOrFragment(item)) {
|
||||||
info.page = item as MPage | MPageFragment;
|
info.page = item as MPage | MPageFragment;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -279,6 +279,10 @@ export const isPageFragment = (node?: Pick<MComponent, 'type'> | null): boolean
|
|||||||
return Boolean(node.type?.toLowerCase() === NodeType.PAGE_FRAGMENT);
|
return Boolean(node.type?.toLowerCase() === NodeType.PAGE_FRAGMENT);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 是否为页面或页面片 */
|
||||||
|
export const isPageOrFragment = (node?: Pick<MComponent, 'type'> | null): boolean =>
|
||||||
|
isPage(node) || isPageFragment(node);
|
||||||
|
|
||||||
export const isNumber = (value: any) =>
|
export const isNumber = (value: any) =>
|
||||||
(typeof value === 'number' && !isNaN(value)) || /^(-?\d+)(\.\d+)?$/.test(`${value}`);
|
(typeof value === 'number' && !isNaN(value)) || /^(-?\d+)(\.\d+)?$/.test(`${value}`);
|
||||||
|
|
||||||
|
|||||||
@ -36,6 +36,7 @@ import {
|
|||||||
isNumber,
|
isNumber,
|
||||||
isObject,
|
isObject,
|
||||||
isPageFragment,
|
isPageFragment,
|
||||||
|
isPageOrFragment,
|
||||||
isPercentage,
|
isPercentage,
|
||||||
isPop,
|
isPop,
|
||||||
isValueIncludeDataSource,
|
isValueIncludeDataSource,
|
||||||
@ -112,6 +113,14 @@ describe('node 类型判断', () => {
|
|||||||
expect(isPageFragment(null)).toBe(false);
|
expect(isPageFragment(null)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('isPageOrFragment 识别 page 与 page-fragment', () => {
|
||||||
|
expect(isPageOrFragment({ id: 1, type: NodeType.PAGE })).toBe(true);
|
||||||
|
expect(isPageOrFragment({ id: 1, type: NodeType.PAGE_FRAGMENT })).toBe(true);
|
||||||
|
expect(isPageOrFragment({ id: 1, type: 'text' })).toBe(false);
|
||||||
|
expect(isPageOrFragment(undefined)).toBe(false);
|
||||||
|
expect(isPageOrFragment(null)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
test('isDslNode 默认为 true,手动 false 关闭', () => {
|
test('isDslNode 默认为 true,手动 false 关闭', () => {
|
||||||
expect(isDslNode({ type: 'text' } as any)).toBe(true);
|
expect(isDslNode({ type: 'text' } as any)).toBe(true);
|
||||||
expect(isDslNode({ type: 'text', [IS_DSL_NODE_KEY]: false } as any)).toBe(false);
|
expect(isDslNode({ type: 'text', [IS_DSL_NODE_KEY]: false } as any)).toBe(false);
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "tmagic-playground",
|
"name": "tmagic-playground",
|
||||||
"version": "1.8.0-beta.15",
|
"version": "1.8.0-beta.20",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@ -12,11 +12,11 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@element-plus/icons-vue": "^2.3.2",
|
"@element-plus/icons-vue": "^2.3.2",
|
||||||
"@tmagic/core": "1.8.0-beta.15",
|
"@tmagic/core": "1.8.0-beta.20",
|
||||||
"@tmagic/design": "1.8.0-beta.15",
|
"@tmagic/design": "1.8.0-beta.20",
|
||||||
"@tmagic/editor": "1.8.0-beta.15",
|
"@tmagic/editor": "1.8.0-beta.20",
|
||||||
"@tmagic/element-plus-adapter": "1.8.0-beta.15",
|
"@tmagic/element-plus-adapter": "1.8.0-beta.20",
|
||||||
"@tmagic/tdesign-vue-next-adapter": "1.8.0-beta.15",
|
"@tmagic/tdesign-vue-next-adapter": "1.8.0-beta.20",
|
||||||
"@tmagic/tmagic-form-runtime": "1.1.3",
|
"@tmagic/tmagic-form-runtime": "1.1.3",
|
||||||
"element-plus": "^2.14.3",
|
"element-plus": "^2.14.3",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user