mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-08-07 05:18:38 +00:00
Compare commits
44 Commits
v1.8.0-bet
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f435dde1d3 | ||
|
|
65db14cb98 | ||
|
|
cf007f9082 | ||
|
|
0d1a17dce3 | ||
|
|
efb637e72e | ||
|
|
192c16a7be | ||
|
|
f609a02277 | ||
|
|
1bf0bb9cce | ||
|
|
7777c205b9 | ||
|
|
6f0a41f2db | ||
|
|
af157fcda9 | ||
|
|
a9cda719bc | ||
|
|
d5a2e25e18 | ||
|
|
697830ff29 | ||
|
|
a6e542d668 | ||
|
|
b544f05917 | ||
|
|
d2a02e16d2 | ||
|
|
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 |
2
.github/workflows/pages.yml
vendored
2
.github/workflows/pages.yml
vendored
@ -17,6 +17,8 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 11.20.0
|
||||
|
||||
- name: Set node version to 22
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
82
CHANGELOG.md
82
CHANGELOG.md
@ -1,3 +1,85 @@
|
||||
# [1.8.0-beta.23](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.22...v1.8.0-beta.23) (2026-08-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **editor:** abort 时保留常驻依赖收集 worker,避免反复重建 ([efb637e](https://github.com/Tencent/tmagic-editor/commit/efb637e72e19c4c1ce5eb07a4c6dd9e6828778d1))
|
||||
* **form:** 优化可选项校验建议文案并导出供 editor 复用 ([0d1a17d](https://github.com/Tencent/tmagic-editor/commit/0d1a17dce3b8b912238f822ad7d67c1992af53f4))
|
||||
* **stage:** 修复切换页面后 mask 滚动重置与 scrollIntoView 失效 ([cf007f9](https://github.com/Tencent/tmagic-editor/commit/cf007f9082043589ce94fb3dd1c11dbf96ca0e1d))
|
||||
# [1.8.0-beta.22](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.21...v1.8.0-beta.22) (2026-08-04)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **editor:** 优化组件列表与图层面板搜索过滤 ([7777c20](https://github.com/Tencent/tmagic-editor/commit/7777c205b95429c80f7762510d46c6d98b5f73c5))
|
||||
* **editor:** 修复 StyleSetter Box/Position 输入框无法正确显示 model 值 ([af157fc](https://github.com/Tencent/tmagic-editor/commit/af157fcda96da817d1507cb93a564709b6d010b9))
|
||||
* **stage:** 修复切换页面后 StageMask 尺寸与滚动偏移异常 ([a9cda71](https://github.com/Tencent/tmagic-editor/commit/a9cda719bc85161039405dd3219139347638268e))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **editor:** 完善样式设置器字段 typeMatch 校验 ([d5a2e25](https://github.com/Tencent/tmagic-editor/commit/d5a2e25e18a7eba980839224bf65ed8481901637))
|
||||
* **editor:** 支持 update replace 整节点替换,源码编辑走 replace 模式 ([6f0a41f](https://github.com/Tencent/tmagic-editor/commit/6f0a41f2db8add132943cf7b3e4d796f99106e6f))
|
||||
# [1.8.0-beta.21](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.20...v1.8.0-beta.21) (2026-08-04)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **editor:** 修复 IdleTask 在 requestIdleCallback timeout 时队列空转的问题 ([b544f05](https://github.com/Tencent/tmagic-editor/commit/b544f059173ff7807a421a98d4247d334bce5fbc))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **editor:** getEvent/getMethod 支持节点上下文参数 ([d2a02e1](https://github.com/Tencent/tmagic-editor/commit/d2a02e16d26a4424681ac02592da1de850ebb78d))
|
||||
# [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)
|
||||
|
||||
|
||||
|
||||
@ -18,7 +18,7 @@ https://tencent.github.io/tmagic-editor/playground/index.html
|
||||
|
||||
node.js ^20.19.0 || >=22.12.0
|
||||
|
||||
pnpm >= 10
|
||||
pnpm 11.20.0
|
||||
|
||||
先安装 pnpm
|
||||
|
||||
|
||||
@ -480,6 +480,8 @@ editorService.highlight("text_123");
|
||||
- {`MNode`} config 新的节点
|
||||
- `{Object}` data 可选配置
|
||||
- {`ChangeRecord`[]} changeRecords 变更记录
|
||||
- `{boolean}` replace 是否整节点替换(默认 false)。为 `true` 时跳过 `mergeWith` / `toggleFixedPosition` / `setChildrenLayout`,直接用传入配置覆盖现有节点
|
||||
- `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options)
|
||||
|
||||
- **返回:**
|
||||
- `{Promise<{ newNode: MNode; oldNode: MNode; changeRecords?: ChangeRecord[] }>}` 更新前后的节点信息
|
||||
@ -498,6 +500,8 @@ editorService.highlight("text_123");
|
||||
当被更新节点正好在当前选中列表中时,state 会自动同步到新的节点引用,无需调用方处理
|
||||
|
||||
当被更新节点正好是当前页面时,state.page 也会同步到新的节点引用;更新非当前页面(不同 ID)时不会把编辑器切到该页
|
||||
|
||||
默认会将传入配置与现有节点深合并(保留未传入的字段)。若需要完整 DSL 覆盖(如源码编辑写回),传入 `replace: true`。
|
||||
:::
|
||||
|
||||
## update
|
||||
@ -512,6 +516,7 @@ editorService.highlight("text_123");
|
||||
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false)
|
||||
- `{string}` historyDescription 见[历史记录相关 options](#历史记录相关-options)
|
||||
- `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options)
|
||||
- `{boolean}` replace 是否整节点替换(默认 false)。为 `true` 时跳过 `mergeWith` / `toggleFixedPosition` / `setChildrenLayout`,直接用传入配置覆盖现有节点;适用于源码编辑、完整 DSL 回写等场景
|
||||
- `{Object}` invalidInfo 启用 [enablePropsFormValidate](./props.md#enablepropsformvalidate) 时,属性面板提交携带的校验错误信息,在写入历史记录之前落库,使历史快照与本次变更对齐
|
||||
- `{Id}` id 节点 id
|
||||
- `{'props' | 'style'}` source 错误来源:属性表单 / 样式表单
|
||||
@ -546,6 +551,12 @@ editorService.highlight("text_123");
|
||||
才退化为整节点替换(如内部 `sort` / `moveLayer` / 拖动等纯快照场景)。
|
||||
:::
|
||||
|
||||
:::tip
|
||||
**`replace: true` 整节点替换:** 默认 `update` 会将传入配置与现有节点深合并,未传入的字段会保留。
|
||||
传入 `replace: true` 后跳过合并与布局相关变换,直接用传入配置覆盖,传入对象中不存在的字段会被删除。
|
||||
属性面板局部改动请保持默认;源码编辑 / 完整 DSL 回写等场景使用 `replace: true`。
|
||||
:::
|
||||
|
||||
## sort
|
||||
|
||||
- **[扩展支持](../../guide/editor-expand#行为扩展):** 是
|
||||
|
||||
@ -65,6 +65,7 @@ eventsService.setEvent('button', [
|
||||
- **参数:**
|
||||
|
||||
- `{string}` type 组件类型
|
||||
- `{ node?: MNode | null }` data 可选上下文,便于插件按节点定制事件列表(默认 `{}`)
|
||||
|
||||
- **返回:**
|
||||
|
||||
@ -72,15 +73,25 @@ eventsService.setEvent('button', [
|
||||
|
||||
- **详情:**
|
||||
|
||||
获取指定组件类型的事件列表
|
||||
获取指定组件类型的事件列表。默认实现仅按 `type` 返回注册事件;可通过 `usePlugin` 的 `beforeGetEvent` / `afterGetEvent` 读取 `data.node` 做节点级过滤或扩展。
|
||||
|
||||
- **示例:**
|
||||
|
||||
```js
|
||||
import { eventsService } from '@tmagic/editor';
|
||||
|
||||
const events = eventsService.getEvent('button');
|
||||
const events = eventsService.getEvent('button', { node });
|
||||
console.log(events); // [{ label: '点击', value: 'click' }, ...]
|
||||
|
||||
// 插件按节点定制
|
||||
eventsService.usePlugin({
|
||||
afterGetEvent(events, type, data) {
|
||||
if (data?.node?.id === 'btn_1') {
|
||||
return events.filter((item) => item.value !== 'longpress');
|
||||
}
|
||||
return events;
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## setMethods
|
||||
@ -146,7 +157,9 @@ eventsService.setMethod('video', [
|
||||
- **参数:**
|
||||
|
||||
- `{string}` type 组件类型
|
||||
- `{string | number}` _targetId 目标节点id(保留参数,便于扩展时按节点定制)
|
||||
- `{ node?: MNode | null; targetId?: Id }` data 可选上下文(默认 `{}`)
|
||||
- `targetId`:目标节点 id
|
||||
- `node`:目标节点配置,便于插件按节点定制方法列表
|
||||
|
||||
- **返回:**
|
||||
|
||||
@ -154,15 +167,36 @@ eventsService.setMethod('video', [
|
||||
|
||||
- **详情:**
|
||||
|
||||
获取指定组件类型的方法列表
|
||||
获取指定组件类型的方法列表。默认实现仅按 `type` 返回注册方法;可通过 `usePlugin` 的 `beforeGetMethod` / `afterGetMethod` 读取 `data` 做节点级过滤或扩展。
|
||||
|
||||
::: warning 破坏性变更
|
||||
第二参数已从 `targetId: Id` 调整为对象 `{ node?, targetId? }`。请同步更新调用方与插件钩子入参,例如:
|
||||
|
||||
```js
|
||||
// 旧
|
||||
eventsService.getMethod('video', 'video_123');
|
||||
// 新
|
||||
eventsService.getMethod('video', { targetId: 'video_123', node });
|
||||
```
|
||||
:::
|
||||
|
||||
- **示例:**
|
||||
|
||||
```js
|
||||
import { eventsService } from '@tmagic/editor';
|
||||
|
||||
const methods = eventsService.getMethod('video', 'video_123');
|
||||
const methods = eventsService.getMethod('video', { targetId: 'video_123', node });
|
||||
console.log(methods); // [{ label: '播放', value: 'play' }, ...]
|
||||
|
||||
// 插件按节点定制
|
||||
eventsService.usePlugin({
|
||||
afterGetMethod(methods, type, data) {
|
||||
if (data?.targetId === 'video_123') {
|
||||
return methods.filter((item) => item.value !== 'stop');
|
||||
}
|
||||
return methods;
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## resetState
|
||||
|
||||
@ -1020,6 +1020,18 @@ alt: 按住alt键启动识别
|
||||
|
||||
- **类型:** `'default' | 'alt' | ''`
|
||||
|
||||
## containerHighlightAddOnly
|
||||
|
||||
- **详情:**
|
||||
|
||||
是否只有新增组件(从组件列表拖入新组件)时才启用识别容器
|
||||
|
||||
开启后,在画布中拖动已有组件时不再识别容器,即已有组件不能通过拖动加入其他容器;新增组件仍然按 [containerHighlightType](#containerHighlightType) 配置的方式识别
|
||||
|
||||
- **默认值:** `false`
|
||||
|
||||
- **类型:** `boolean`
|
||||
|
||||
## stageRect
|
||||
|
||||
- **详情:**
|
||||
|
||||
@ -69,10 +69,11 @@
|
||||
|
||||
## delayedMarkContainer
|
||||
|
||||
- **类型**:`(event: MouseEvent, excludeElList?: Element[]) => NodeJS.Timeout | undefined`
|
||||
- **类型**:`(event: MouseEvent, excludeElList?: Element[], isAdd?: boolean) => NodeJS.Timeout | undefined`
|
||||
- **参数**:
|
||||
- `event`:鼠标事件
|
||||
- `excludeElList`:计算鼠标所在容器时要排除的元素列表
|
||||
- `isAdd`:当前操作是否为新增组件(从组件列表拖入新组件),`containerHighlightType` 为 `addOnly` 时只有新增才会标记容器
|
||||
- **详情**:
|
||||
|
||||
鼠标拖拽着元素,在容器上方悬停,延迟一段时间后,对容器进行标记,如果悬停时间够长将标记成功,悬停时间短,调用方通过返回的timeoutId取消标记
|
||||
|
||||
@ -69,6 +69,8 @@
|
||||
}
|
||||
```
|
||||
|
||||
`validator` 除了调用 `callback` 外,也兼容 async-validator 的返回值约定:返回 `false` 或 `Error` / 错误数组表示失败,返回 `true` 表示通过,返回 `Promise` 则以 resolve / reject 为结果,内部抛出的异常会转成校验失败。同步与异步 `typeMatch` 下这些约定行为一致,且只会上报一次结果(既调 `callback` 又返回 `Promise` 时以先到的为准)。
|
||||
|
||||
## 扩展自定义 type 规则
|
||||
|
||||
业务可覆盖内置规则,或为自定义字段 type 注册校验。自定义规则优先于内置规则。
|
||||
@ -107,7 +109,26 @@ deleteTypeMatchRule('foo');
|
||||
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()` 也不会对一个尚未校验完的值返回成功。
|
||||
|
||||
### 安装时注册
|
||||
|
||||
|
||||
@ -69,11 +69,11 @@ $ npm install monaco-editor -S
|
||||
|
||||
```ts
|
||||
import { createApp } from "vue";
|
||||
import EditorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker";
|
||||
import CssWorker from "monaco-editor/esm/vs/language/css/css.worker?worker";
|
||||
import HtmlWorker from "monaco-editor/esm/vs/language/html/html.worker?worker";
|
||||
import JsonWorker from "monaco-editor/esm/vs/language/json/json.worker?worker";
|
||||
import TsWorker from "monaco-editor/esm/vs/language/typescript/ts.worker?worker";
|
||||
import EditorWorker from "monaco-editor/editor/editor.worker.js?worker";
|
||||
import CssWorker from "monaco-editor/language/css/css.worker.js?worker";
|
||||
import HtmlWorker from "monaco-editor/language/html/html.worker.js?worker";
|
||||
import JsonWorker from "monaco-editor/language/json/json.worker.js?worker";
|
||||
import TsWorker from "monaco-editor/language/typescript/ts.worker.js?worker";
|
||||
|
||||
import editorPlugin from "@tmagic/editor";
|
||||
import MagicElementPlusAdapter from "@tmagic/element-plus-adapter";
|
||||
|
||||
@ -10,17 +10,17 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@typescript-eslint/parser": "^8.58.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.58.0",
|
||||
"@stylistic/eslint-plugin": "^5.10.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.66.0",
|
||||
"@typescript-eslint/parser": "^8.66.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-simple-import-sort": "^13.0.0",
|
||||
"eslint-plugin-vue": "^10.8.0",
|
||||
"vue-eslint-parser": "^10.4.0",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"globals": "^17.4.0",
|
||||
"typescript-eslint": "^8.58.0"
|
||||
"eslint-plugin-prettier": "^5.5.6",
|
||||
"eslint-plugin-simple-import-sort": "^14.0.0",
|
||||
"eslint-plugin-vue": "^10.10.0",
|
||||
"globals": "^17.9.0",
|
||||
"typescript-eslint": "^8.66.0",
|
||||
"vue-eslint-parser": "^10.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": ">=10.0.0",
|
||||
|
||||
42
package.json
42
package.json
@ -1,12 +1,12 @@
|
||||
{
|
||||
"version": "1.8.0-beta.15",
|
||||
"version": "1.8.0-beta.23",
|
||||
"name": "tmagic",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.4",
|
||||
"packageManager": "pnpm@11.20.0",
|
||||
"scripts": {
|
||||
"bootstrap": "pnpm i && pnpm build",
|
||||
"clean:top": "rimraf */**/dist */**/types */dist coverage dwt* temp packages/cli/lib",
|
||||
"clean:top": "rimraf packages/*/dist packages/*/types runtime/*/dist runtime/*/types vue-components/*/dist vue-components/*/types react-components/*/dist react-components/*/types playground/dist coverage dwt* temp packages/cli/lib",
|
||||
"clean:modules": "rimraf node_modules **/node_modules **/**/node_modules",
|
||||
"clean:all": "pnpm clean:top && pnpm clean:modules",
|
||||
"lint": "eslint --cache .",
|
||||
@ -40,44 +40,44 @@
|
||||
"url": "https://github.com/Tencent/tmagic-editor.git"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^20.5.3",
|
||||
"@commitlint/config-conventional": "^20.5.3",
|
||||
"@commitlint/cli": "^21.2.1",
|
||||
"@commitlint/config-conventional": "^21.2.0",
|
||||
"@tmagic/eslint-config": "workspace:*",
|
||||
"@types/node": "24.0.10",
|
||||
"@types/node": "26.1.2",
|
||||
"@vitejs/plugin-vue": "^6.0.8",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"@vue/compiler-sfc": "catalog:",
|
||||
"c8": "^10.1.3",
|
||||
"c8": "^12.0.0",
|
||||
"commitizen": "^4.3.2",
|
||||
"conventional-changelog-cli": "^5.0.0",
|
||||
"cosmiconfig": "^9.0.2",
|
||||
"cosmiconfig": "^10.0.0",
|
||||
"cz-conventional-changelog": "^3.3.0",
|
||||
"element-plus": "^2.14.3",
|
||||
"enquirer": "^2.4.1",
|
||||
"eslint": "^10.7.0",
|
||||
"execa": "^9.6.1",
|
||||
"happy-dom": "^20.10.6",
|
||||
"eslint": "^10.8.0",
|
||||
"execa": "^10.0.1",
|
||||
"happy-dom": "^20.11.1",
|
||||
"highlight.js": "^11.11.1",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^27.4.0",
|
||||
"lint-staged": "^16.4.0",
|
||||
"jsdom": "^30.0.1",
|
||||
"lint-staged": "^17.3.0",
|
||||
"minimist": "^1.2.8",
|
||||
"picocolors": "^1.1.1",
|
||||
"prettier": "^3.9.5",
|
||||
"recast": "^0.23.12",
|
||||
"rimraf": "^3.0.2",
|
||||
"rolldown": "^1.2.0",
|
||||
"rolldown-plugin-dts": "^0.26.0",
|
||||
"prettier": "^3.9.6",
|
||||
"recast": "^0.23.19",
|
||||
"rimraf": "^6.1.3",
|
||||
"rolldown": "^1.2.2",
|
||||
"rolldown-plugin-dts": "^0.28.0",
|
||||
"sass-embedded": "^1.100.0",
|
||||
"semver": "^7.8.5",
|
||||
"serialize-javascript": "^7.0.7",
|
||||
"shx": "^0.3.4",
|
||||
"shx": "^0.4.0",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vitepress": "2.0.0-alpha.18",
|
||||
"vitepress": "^2.0.0-alpha.19",
|
||||
"vitest": "^4.1.10",
|
||||
"vue": "catalog:",
|
||||
"vue-tsc": "^3.3.7"
|
||||
"vue-tsc": "^3.3.9"
|
||||
},
|
||||
"config": {
|
||||
"commitizen": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const { cli } = require('../lib');
|
||||
import { cli } from '../lib/index.js';
|
||||
|
||||
cli({
|
||||
source: process.cwd(),
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
{
|
||||
"version": "1.8.0-beta.15",
|
||||
"version": "1.8.0-beta.23",
|
||||
"name": "@tmagic/cli",
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/index.d.ts",
|
||||
"license": "Apache-2.0",
|
||||
"scripts": {
|
||||
"build": "pnpm clean && tsc -b tsconfig.build.json",
|
||||
"build": "pnpm clean && rolldown -c rolldown.config.mjs",
|
||||
"clean": "rimraf lib *.tsbuildinfo",
|
||||
"check:type": "tsc --noEmit --project tsconfig.build.json"
|
||||
},
|
||||
@ -26,25 +27,18 @@
|
||||
"url": "https://github.com/Tencent/tmagic-editor.git"
|
||||
},
|
||||
"dependencies": {
|
||||
"cac": "^6.7.14",
|
||||
"chokidar": "^3.6.0",
|
||||
"esbuild": "^0.21.5",
|
||||
"fs-extra": "^11.2.0",
|
||||
"cac": "^7.0.0",
|
||||
"chokidar": "^5.0.0",
|
||||
"fs-extra": "^11.4.0",
|
||||
"merge-options": "^3.0.4",
|
||||
"picocolors": "^1.1.1",
|
||||
"recast": "^0.23.11",
|
||||
"tslib": "^2.8.0"
|
||||
"recast": "^0.23.19",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/node": "^24.0.10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
"@types/node": "^26.1.2",
|
||||
"rolldown": "^1.2.2",
|
||||
"rolldown-plugin-dts": "^0.28.0"
|
||||
}
|
||||
}
|
||||
|
||||
36
packages/cli/rolldown.config.mjs
Normal file
36
packages/cli/rolldown.config.mjs
Normal file
@ -0,0 +1,36 @@
|
||||
import { builtinModules, createRequire } from 'node:module';
|
||||
|
||||
import { defineConfig } from 'rolldown';
|
||||
import { dts } from 'rolldown-plugin-dts';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const packageJson = require('./package.json');
|
||||
const externalPackages = new Set([
|
||||
...builtinModules,
|
||||
...Object.keys(packageJson.dependencies ?? {}),
|
||||
...Object.keys(packageJson.peerDependencies ?? {}),
|
||||
]);
|
||||
|
||||
export default defineConfig({
|
||||
input: 'src/index.ts',
|
||||
plugins: [dts()],
|
||||
external: (id) => {
|
||||
if (id.startsWith('node:') || externalPackages.has(id)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const packageName of externalPackages) {
|
||||
if (id.startsWith(`${packageName}/`)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
output: {
|
||||
dir: 'lib',
|
||||
format: 'es',
|
||||
sourcemap: false,
|
||||
},
|
||||
codeSplitting: false,
|
||||
});
|
||||
@ -4,10 +4,10 @@ import fs from 'fs-extra';
|
||||
|
||||
import { prepareEntryFile } from './utils/prepareEntryFile';
|
||||
import { resolveAppPackages } from './utils/resolveAppPackages';
|
||||
import { require } from './require';
|
||||
import { ModuleMainFilePath, UserConfig } from './types';
|
||||
|
||||
export default class Core {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
public version = require('../package.json').version;
|
||||
|
||||
public options: UserConfig;
|
||||
|
||||
@ -4,6 +4,7 @@ import { allowTs } from './utils/allowTs';
|
||||
import { error } from './utils/logger';
|
||||
import { scripts } from './commands';
|
||||
import App from './Core';
|
||||
import { require } from './require';
|
||||
import { UserConfig } from './types';
|
||||
|
||||
/**
|
||||
@ -29,7 +30,7 @@ export const cli = (defaultAppConfig: UserConfig): void => {
|
||||
const program = cac('tmagic');
|
||||
|
||||
// display core version and cli version
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
|
||||
const versionCli = require('../package.json').version;
|
||||
program.version(`tmagic/cli@${versionCli}`);
|
||||
|
||||
|
||||
9
packages/cli/src/require.ts
Normal file
9
packages/cli/src/require.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
/**
|
||||
* CJS `require()` in an ESM context, shared by the whole CLI so that
|
||||
* `allowTs`'s `.ts` loader also applies to `loadUserConfig` requires.
|
||||
* Bound to the package `src/` directory, so paths like `../package.json`
|
||||
* resolve to the package root as before.
|
||||
*/
|
||||
export const require = createRequire(import.meta.url);
|
||||
@ -1,23 +1,27 @@
|
||||
import { transformSync } from 'esbuild';
|
||||
import fs from 'fs-extra';
|
||||
import ts from 'typescript';
|
||||
|
||||
import { require } from '../require';
|
||||
|
||||
/**
|
||||
* Transform a ts file to cjs code
|
||||
*/
|
||||
export const transformTsFileToCodeSync = (filename: string): string =>
|
||||
transformSync(fs.readFileSync(filename).toString(), {
|
||||
format: 'cjs',
|
||||
loader: 'ts',
|
||||
sourcefile: filename,
|
||||
sourcemap: 'inline',
|
||||
target: 'node18',
|
||||
}).code;
|
||||
ts.transpileModule(fs.readFileSync(filename).toString(), {
|
||||
compilerOptions: {
|
||||
inlineSourceMap: true,
|
||||
inlineSources: true,
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
},
|
||||
fileName: filename,
|
||||
}).outputText;
|
||||
|
||||
/**
|
||||
* Globally allow ts files to be loaded via `require()`
|
||||
*/
|
||||
export const allowTs = (): void => {
|
||||
require.extensions['.ts'] = (m: any, filename) => {
|
||||
require.extensions['.ts'] = (m: any, filename: string) => {
|
||||
m._compile(transformTsFileToCodeSync(filename), filename);
|
||||
};
|
||||
};
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { require } from '../require';
|
||||
import { UserConfig, UserConfigLoader } from '../types';
|
||||
|
||||
export const isPlainObject = <T extends Record<any, any> = Record<any, any>>(val: unknown): val is T =>
|
||||
@ -10,7 +11,6 @@ export const hasExportDefault = <T = any>(mod: unknown): mod is { default: T } =
|
||||
isPlainObject(mod) && !!mod.__esModule && Object.prototype.hasOwnProperty.call(mod, 'default');
|
||||
|
||||
export const loadUserConfigCjs: UserConfigLoader = async (userConfigPath) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const required = require(userConfigPath);
|
||||
return hasExportDefault(required) ? required.default : required;
|
||||
};
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import * as recast from 'recast';
|
||||
|
||||
import type App from '../Core';
|
||||
import { require } from '../require';
|
||||
import { EntryType } from '../types';
|
||||
|
||||
export const prepareEntryFile = async (app: App) => {
|
||||
@ -138,7 +139,6 @@ export const generateContent = (
|
||||
};
|
||||
|
||||
export const prettyCode = (code: string) =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
recast.prettyPrint(recast.parse(code.replace(/\\/g, '/'), { parser: require('recast/parsers/typescript') }), {
|
||||
tabWidth: 2,
|
||||
trailingComma: true,
|
||||
|
||||
@ -2,10 +2,11 @@ import { execSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { exit } from 'node:process';
|
||||
|
||||
import fs, { existsSync } from 'fs-extra';
|
||||
import fs from 'fs-extra';
|
||||
import * as recast from 'recast';
|
||||
|
||||
import type App from '../Core';
|
||||
import { require } from '../require';
|
||||
import { EntryType, ModuleMainFilePath, NpmConfig, PackageType } from '../types';
|
||||
|
||||
import { backupLock, backupPackageJson, restoreLock, restorePackageJson } from './backupPackageFile';
|
||||
@ -127,7 +128,7 @@ const typeAssertion = function ({
|
||||
|
||||
if (isFile(defaultFile)) {
|
||||
const defaultCode = fs.readFileSync(defaultFile, { encoding: 'utf-8', flag: 'r' });
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
|
||||
const ast = recast.parse(defaultCode, { parser: require('recast/parsers/typescript') });
|
||||
if (
|
||||
isDatasource(
|
||||
@ -294,7 +295,7 @@ const getComponentPackageImports = function ({
|
||||
if (propertyMatch) {
|
||||
let file = getIndexPath(path.resolve(path.dirname(indexPath), propertyMatch.source.value));
|
||||
|
||||
if (!existsSync(file)) {
|
||||
if (!fs.existsSync(file)) {
|
||||
file = propertyMatch.source.value;
|
||||
}
|
||||
|
||||
@ -518,7 +519,7 @@ const setPackages = (packages: ModuleMainFilePath, app: App, packagePath: string
|
||||
.replace('\n', '');
|
||||
|
||||
const indexCode = fs.readFileSync(indexPath, { encoding: 'utf-8', flag: 'r' });
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
|
||||
const ast: Ast = recast.parse(indexCode, { parser: require('recast/parsers/typescript') });
|
||||
const result = typeAssertion({ ast, indexPath, componentFileAffix, datasoucreSuperClass });
|
||||
|
||||
|
||||
@ -2,12 +2,10 @@
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"moduleResolution": "node16",
|
||||
"module": "node16",
|
||||
"moduleResolution": "bundler",
|
||||
"module": "ESNext",
|
||||
"rootDir": "./src",
|
||||
"outDir": "./lib",
|
||||
"declaration": true,
|
||||
|
||||
"types": ["node"],
|
||||
},
|
||||
"include": ["./src"],
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.8.0-beta.15",
|
||||
"version": "1.8.0-beta.23",
|
||||
"name": "@tmagic/core",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
@ -39,11 +39,11 @@
|
||||
"@tmagic/schema": "workspace:*",
|
||||
"@tmagic/utils": "workspace:*",
|
||||
"events": "^3.3.0",
|
||||
"lodash-es": "^4.17.21"
|
||||
"lodash-es": "^4.18.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/events": "^3.0.3",
|
||||
"@types/lodash-es": "^4.17.4"
|
||||
"@types/lodash-es": "^4.17.12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "catalog:"
|
||||
|
||||
@ -341,6 +341,20 @@ describe('App 配置/方法/组件注册', () => {
|
||||
expect(app.page).toBeUndefined();
|
||||
});
|
||||
|
||||
test('deletePage 销毁当前 page', () => {
|
||||
const app = new App({
|
||||
config: {
|
||||
type: NodeType.ROOT,
|
||||
id: 'app',
|
||||
items: [{ type: NodeType.PAGE, id: 'p1', items: [] }],
|
||||
},
|
||||
});
|
||||
expect(app.page?.data.id).toBe('p1');
|
||||
|
||||
app.deletePage();
|
||||
expect(app.page).toBeUndefined();
|
||||
});
|
||||
|
||||
test('runCode 执行代码块', async () => {
|
||||
const fn = vi.fn();
|
||||
const app = new App({
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.8.0-beta.15",
|
||||
"version": "1.8.0-beta.23",
|
||||
"name": "@tmagic/data-source",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
@ -32,9 +32,9 @@
|
||||
"data-source"
|
||||
],
|
||||
"dependencies": {
|
||||
"deep-state-observer": "^5.5.13",
|
||||
"deep-state-observer": "^5.5.17",
|
||||
"events": "^3.3.0",
|
||||
"lodash-es": "^4.17.21"
|
||||
"lodash-es": "^4.18.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tmagic/core": "workspace:*",
|
||||
@ -47,6 +47,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/events": "^3.0.3",
|
||||
"@types/lodash-es": "^4.17.4"
|
||||
"@types/lodash-es": "^4.17.12"
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,8 +7,7 @@ import {
|
||||
DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX,
|
||||
dataSourceTemplateRegExp,
|
||||
getValueByKeyPath,
|
||||
isPage,
|
||||
isPageFragment,
|
||||
isPageOrFragment,
|
||||
NODE_CONDS_KEY,
|
||||
replaceChildNode,
|
||||
} from '@tmagic/core';
|
||||
@ -68,7 +67,7 @@ export const compliedConditions = (node: { [NODE_CONDS_KEY]?: DisplayCond[] }, d
|
||||
};
|
||||
|
||||
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);
|
||||
dsl.items.splice(index, 1, node as MPage | MPageFragment);
|
||||
} else {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.8.0-beta.15",
|
||||
"version": "1.8.0-beta.23",
|
||||
"name": "@tmagic/dep",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
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 {
|
||||
id: string | number;
|
||||
@ -40,12 +40,17 @@ export default class Target {
|
||||
* 是否默认收集,默认为true,当值为false时需要传入type参数给collect方法才会被收集
|
||||
*/
|
||||
public isCollectByDefault?: boolean;
|
||||
/**
|
||||
* 可序列化描述,有该描述的 target 可以在 worker 中重建,从而把依赖收集放到子线程执行
|
||||
*/
|
||||
public descriptor?: TargetDescriptor;
|
||||
|
||||
constructor(options: TargetOptions) {
|
||||
this.isTarget = options.isTarget;
|
||||
this.id = options.id;
|
||||
this.name = options.name;
|
||||
this.isCollectByDefault = options.isCollectByDefault ?? true;
|
||||
this.descriptor = options.descriptor;
|
||||
if (options.type) {
|
||||
this.type = options.type;
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { DepData } from '@tmagic/schema';
|
||||
import type { CodeBlockContent, DataSourceSchema, DepData, Id } from '@tmagic/schema';
|
||||
|
||||
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;
|
||||
|
||||
/** 创建代码块 target 只需要名称,Record 用于兼容直接传入完整代码块 */
|
||||
export type CodeBlockName = Pick<CodeBlockContent, 'name'> & Record<string, any>;
|
||||
|
||||
/**
|
||||
* target 的可序列化描述
|
||||
*
|
||||
* isTarget 是带闭包的函数,无法跨线程传递,只能在另一端用同样的入参重新创建。
|
||||
* 内置的代码块/数据源 target 由工厂函数写入该描述,因此可以放到 worker 中收集;
|
||||
* 业务自定义的 target 没有该描述,只能在主线程收集。
|
||||
*
|
||||
* 各类型只带 isTarget 真正用到的字段:
|
||||
* - CODE_BLOCK: id + name
|
||||
* - DATA_SOURCE / DATA_SOURCE_COND: id + 完整 fields(含 type / 嵌套,用于判断数组模板)
|
||||
* - DATA_SOURCE_METHOD: id + 方法名/字段名(不序列化 content)
|
||||
*/
|
||||
export type TargetDescriptor =
|
||||
| { type: DepTargetType.CODE_BLOCK; id: Id; codeBlock: CodeBlockName }
|
||||
| { type: DepTargetType.DATA_SOURCE; ds: Pick<DataSourceSchema, 'id' | 'fields'> }
|
||||
| { type: DepTargetType.DATA_SOURCE_COND; ds: Pick<DataSourceSchema, 'id' | 'fields'> }
|
||||
| {
|
||||
type: DepTargetType.DATA_SOURCE_METHOD;
|
||||
ds: { id: Id; methods: Array<{ name?: string }>; fields: Array<{ name?: string }> };
|
||||
};
|
||||
|
||||
export interface TargetOptions {
|
||||
isTarget: IsTarget;
|
||||
id: string | number;
|
||||
@ -26,6 +50,8 @@ export interface TargetOptions {
|
||||
initialDeps?: DepData;
|
||||
/** 是否默认收集,默认为true,当值为false时需要传入type参数给collect方法才会被收集 */
|
||||
isCollectByDefault?: boolean;
|
||||
/** 可序列化描述,用于在 worker 中重建该 target */
|
||||
descriptor?: TargetDescriptor;
|
||||
}
|
||||
|
||||
export interface TargetList {
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import {
|
||||
type CodeBlockContent,
|
||||
type DataSchema,
|
||||
type DataSourceSchema,
|
||||
type DepData,
|
||||
@ -16,16 +15,17 @@ import {
|
||||
} from '@tmagic/utils';
|
||||
|
||||
import Target from './Target';
|
||||
import { DepTargetType, type TargetList } from './types';
|
||||
import { type CodeBlockName, DepTargetType, type TargetDescriptor, type TargetList } from './types';
|
||||
|
||||
const INTEGER_REGEXP = /^\d+$/;
|
||||
|
||||
export const createCodeBlockTarget = (id: Id, codeBlock: CodeBlockContent, initialDeps: DepData = {}) =>
|
||||
export const createCodeBlockTarget = (id: Id, codeBlock: CodeBlockName, initialDeps: DepData = {}) =>
|
||||
new Target({
|
||||
type: DepTargetType.CODE_BLOCK,
|
||||
id,
|
||||
initialDeps,
|
||||
name: codeBlock.name,
|
||||
descriptor: { type: DepTargetType.CODE_BLOCK, id, codeBlock: { name: codeBlock.name } },
|
||||
isTarget: (_key: string | number, value: any) => {
|
||||
if (id === value) {
|
||||
return true;
|
||||
@ -238,6 +238,8 @@ export const createDataSourceTarget = (ds: Pick<DataSourceSchema, 'id' | 'fields
|
||||
type: DepTargetType.DATA_SOURCE,
|
||||
id: ds.id,
|
||||
initialDeps,
|
||||
// isTarget 需要完整 fields 结构(含 type / 嵌套 fields),但不必带上 methods 等无关字段
|
||||
descriptor: { type: DepTargetType.DATA_SOURCE, ds: { id: ds.id, fields: ds.fields || [] } },
|
||||
isTarget: (key: string | number, value: any) => isDataSourceTarget(ds, key, value),
|
||||
});
|
||||
|
||||
@ -246,6 +248,8 @@ export const createDataSourceCondTarget = (ds: Pick<DataSourceSchema, 'id' | 'fi
|
||||
type: DepTargetType.DATA_SOURCE_COND,
|
||||
id: ds.id,
|
||||
initialDeps,
|
||||
// 与 DATA_SOURCE 相同,只序列化 isTarget 所需的 id + fields
|
||||
descriptor: { type: DepTargetType.DATA_SOURCE_COND, ds: { id: ds.id, fields: ds.fields || [] } },
|
||||
isTarget: (key: string | number, value: any) => isDataSourceCondTarget(ds, key, value),
|
||||
});
|
||||
|
||||
@ -257,6 +261,15 @@ export const createDataSourceMethodTarget = (
|
||||
type: DepTargetType.DATA_SOURCE_METHOD,
|
||||
id: ds.id,
|
||||
initialDeps,
|
||||
// isTarget 只用方法名/字段名,不序列化 content 等函数,降低 idle 收集通信成本
|
||||
descriptor: {
|
||||
type: DepTargetType.DATA_SOURCE_METHOD,
|
||||
ds: {
|
||||
id: ds.id,
|
||||
methods: (ds.methods || []).map((method) => ({ name: method.name })),
|
||||
fields: (ds.fields || []).map((field) => ({ name: field.name })),
|
||||
},
|
||||
},
|
||||
isTarget: (_key: string | number, value: any) => {
|
||||
// 使用data-source-method-select 可以配置出来
|
||||
if (!Array.isArray(value)) {
|
||||
@ -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 = (
|
||||
targetsList: TargetList,
|
||||
cb: (target: Target) => void,
|
||||
|
||||
@ -266,6 +266,65 @@ describe('utils', () => {
|
||||
expect(t3.isTarget('k', ['ds_1', 'unknown'])).toBe(true);
|
||||
});
|
||||
|
||||
test('内置 target 带可序列化描述,可在 worker 中重建', () => {
|
||||
const ds = { id: 'ds_1', fields: [{ name: 'name' }] as DataSchema[] };
|
||||
|
||||
expect(utils.createCodeBlockTarget('code_1', { name: 'fn', content: () => false, params: [] }).descriptor).toEqual({
|
||||
type: DepTargetType.CODE_BLOCK,
|
||||
id: 'code_1',
|
||||
codeBlock: { name: 'fn' },
|
||||
});
|
||||
const dsWithMethods = {
|
||||
...ds,
|
||||
methods: [{ name: 'load', content: () => undefined, params: [] } as any],
|
||||
};
|
||||
// DATA_SOURCE / COND 只带 id + fields,不透传完整数据源对象
|
||||
expect(utils.createDataSourceTarget(dsWithMethods).descriptor).toEqual({
|
||||
type: DepTargetType.DATA_SOURCE,
|
||||
ds: { id: 'ds_1', fields: ds.fields },
|
||||
});
|
||||
expect(utils.createDataSourceCondTarget(dsWithMethods).descriptor).toEqual({
|
||||
type: DepTargetType.DATA_SOURCE_COND,
|
||||
ds: { id: 'ds_1', fields: ds.fields },
|
||||
});
|
||||
// METHOD 描述只保留名称,不带 content 等函数
|
||||
expect(utils.createDataSourceMethodTarget(dsWithMethods).descriptor).toEqual({
|
||||
type: DepTargetType.DATA_SOURCE_METHOD,
|
||||
ds: { id: 'ds_1', methods: [{ name: 'load' }], fields: [{ name: 'name' }] },
|
||||
});
|
||||
// 自定义 target 无法序列化 isTarget,因此没有描述
|
||||
expect(new Target({ id: 'custom', isTarget: () => true }).descriptor).toBeUndefined();
|
||||
});
|
||||
|
||||
test('createTargetByDescriptor 用描述重建出等价的 target', () => {
|
||||
const ds = { id: 'ds_1', fields: [{ name: 'name' }] as DataSchema[] };
|
||||
|
||||
const codeBlockTarget = utils.createTargetByDescriptor({
|
||||
type: DepTargetType.CODE_BLOCK,
|
||||
id: 'code_1',
|
||||
codeBlock: { name: 'fn' },
|
||||
});
|
||||
expect(codeBlockTarget.type).toBe(DepTargetType.CODE_BLOCK);
|
||||
expect(codeBlockTarget.name).toBe('fn');
|
||||
expect(codeBlockTarget.isTarget('created', 'code_1')).toBe(true);
|
||||
|
||||
const dsTarget = utils.createTargetByDescriptor({ type: DepTargetType.DATA_SOURCE, ds });
|
||||
expect(dsTarget.type).toBe(DepTargetType.DATA_SOURCE);
|
||||
expect(dsTarget.isTarget('text', '${ds_1.name}')).toBe(true);
|
||||
|
||||
const condTarget = utils.createTargetByDescriptor({ type: DepTargetType.DATA_SOURCE_COND, ds });
|
||||
expect(condTarget.isTarget(`${NODE_CONDS_KEY}_x`, ['ds_1', 'name'])).toBe(true);
|
||||
|
||||
const methodTarget = utils.createTargetByDescriptor(
|
||||
{ type: DepTargetType.DATA_SOURCE_METHOD, ds: { ...ds, methods: [] } },
|
||||
{ n1: { name: 'n1', keys: ['k'] } },
|
||||
);
|
||||
expect(methodTarget.isTarget('k', ['ds_1', 'load'])).toBe(true);
|
||||
expect(methodTarget.deps.n1.keys).toEqual(['k']);
|
||||
|
||||
expect(() => utils.createTargetByDescriptor({ type: 'unknown' } as any)).toThrow();
|
||||
});
|
||||
|
||||
test('traverseTarget 遍历所有 / 指定 type', () => {
|
||||
const t1 = new Target({ id: '1', isTarget: () => true, type: 'a' });
|
||||
const t2 = new Target({ id: '2', isTarget: () => true, type: 'b' });
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.8.0-beta.15",
|
||||
"version": "1.8.0-beta.23",
|
||||
"name": "@tmagic/design",
|
||||
"type": "module",
|
||||
"sideEffects": [
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
<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">
|
||||
<slot name="header" class="header"></slot>
|
||||
</template>
|
||||
@ -30,7 +34,7 @@ const uiComponent = ui?.component || 'el-card';
|
||||
// 当祖先 `<MEditor>` / `<MForm>` 的 theme 是 `magic-admin` 时,整套设计语言默认走「无卡片感」,
|
||||
// 因此这里自动等价于 `flat=true`;调用方仍可显式传 `flat` 强制开启。
|
||||
const isFlat = computed(() => !!props.flat || isGlobalFlat.value);
|
||||
|
||||
const isHideBody = computed(() => isFlat.value && props.bodyStyle?.display === 'none');
|
||||
// 把 `flat` 从转发给底层 UI 库(el-card / t-card 等)的 props 中剥离:
|
||||
// 它仅作用于本组件的视觉修饰类,不需要也不应该作为属性落到底层组件 / DOM 上。
|
||||
const uiProps = computed<CardProps>(() => {
|
||||
|
||||
@ -11,7 +11,11 @@
|
||||
border-right: 0;
|
||||
border-radius: 0;
|
||||
border-bottom: 0;
|
||||
|
||||
&.m-flat-hide-body {
|
||||
.el-card__header {
|
||||
border-bottom: 0 !important;
|
||||
}
|
||||
}
|
||||
&.m-fields-group-list-item {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
@ -29,6 +33,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
.m-fields-group-list-footer {
|
||||
margin-bottom: 4px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.el-card__body {
|
||||
padding: 0 16px 0 16px;
|
||||
// 这里每个form-item应该要有一个16px间距,但是除了tbody中的form-item
|
||||
@ -61,7 +69,7 @@
|
||||
}
|
||||
> .el-card__body {
|
||||
background-color: #fff;
|
||||
padding-top: 16px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.8.0-beta.15",
|
||||
"version": "1.8.0-beta.23",
|
||||
"name": "@tmagic/editor",
|
||||
"type": "module",
|
||||
"sideEffects": [
|
||||
@ -59,17 +59,17 @@
|
||||
"events": "^3.3.0",
|
||||
"gesto": "^1.19.4",
|
||||
"keycon": "^1.4.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"lodash-es": "^4.18.1",
|
||||
"moveable": "^0.53.0",
|
||||
"serialize-javascript": "^7.0.0",
|
||||
"sortablejs": "^1.15.6"
|
||||
"serialize-javascript": "^7.0.7",
|
||||
"sortablejs": "^1.15.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/events": "^3.0.3",
|
||||
"@types/lodash-es": "^4.17.4",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/serialize-javascript": "^5.0.4",
|
||||
"@types/sortablejs": "^1.15.9",
|
||||
"@vue/test-utils": "^2.4.6"
|
||||
"@vue/test-utils": "^2.4.11"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tmagic/core": "workspace:*",
|
||||
|
||||
@ -218,6 +218,7 @@ const stageOptions: StageOptions = {
|
||||
containerHighlightClassName: props.containerHighlightClassName,
|
||||
containerHighlightDuration: props.containerHighlightDuration,
|
||||
containerHighlightType: props.containerHighlightType,
|
||||
containerHighlightAddOnly: props.containerHighlightAddOnly,
|
||||
disabledDragStart: props.disabledDragStart,
|
||||
renderType: props.renderType,
|
||||
guidesOptions: props.guidesOptions,
|
||||
|
||||
@ -82,6 +82,11 @@ export interface EditorProps {
|
||||
containerHighlightDuration?: number;
|
||||
/** 拖入画布中容器时,识别容器的操作类型 */
|
||||
containerHighlightType?: ContainerHighlightType;
|
||||
/**
|
||||
* 是否仅在新增组件(从组件列表拖入新组件)时才启用识别容器,
|
||||
* 开启后在画布中拖动已有组件不会识别容器,默认 false
|
||||
*/
|
||||
containerHighlightAddOnly?: boolean;
|
||||
/** 画布大小 */
|
||||
stageRect?: StageRect;
|
||||
/** monaco editor 的配置 */
|
||||
@ -163,6 +168,7 @@ export const defaultEditorProps = {
|
||||
containerHighlightClassName: CONTAINER_HIGHLIGHT_CLASS_NAME,
|
||||
containerHighlightDuration: 800,
|
||||
containerHighlightType: ContainerHighlightType.DEFAULT,
|
||||
containerHighlightAddOnly: false,
|
||||
disabledShowSrc: false,
|
||||
disabledDataSource: false,
|
||||
disabledCodeBlock: false,
|
||||
|
||||
@ -37,13 +37,12 @@
|
||||
:model="entry.cardItem"
|
||||
:last-values="entry.lastCardItem"
|
||||
:is-compare="isCompareMode"
|
||||
:hide-expand="true"
|
||||
:hide-expand="false"
|
||||
:label-width="config.labelWidth || '100px'"
|
||||
@change="onChangeHandler"
|
||||
>
|
||||
<template #header>
|
||||
<div class="event-item-header">
|
||||
<div class="event-item-title">事件{{ Number(entry.index) + 1 }}</div>
|
||||
<MFormContainer
|
||||
class="fullWidth"
|
||||
:config="eventNameConfig"
|
||||
@ -210,6 +209,12 @@ const actionTypeConfig = computed(() => {
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
onChange: (_mForm: FormState, _v: string, { setModel }: any) => {
|
||||
setModel('to', '');
|
||||
setModel('method', '');
|
||||
setModel('codeId', '');
|
||||
setModel('dataSourceMethod', []);
|
||||
},
|
||||
};
|
||||
return { ...defaultActionTypeConfig, ...props.config.actionTypeConfig };
|
||||
});
|
||||
@ -222,7 +227,7 @@ const targetCompConfig = computed(() => {
|
||||
type: 'ui-select',
|
||||
labelPosition: 'left',
|
||||
display: (_mForm, { model }) => model.actionType === ActionType.COMP,
|
||||
onChange: (_MForm, _v, { setModel }) => {
|
||||
onChange: (_mForm, _v, { setModel }) => {
|
||||
setModel('method', '');
|
||||
},
|
||||
rules: [
|
||||
@ -306,10 +311,12 @@ const tableConfig = computed(
|
||||
label: '事件名',
|
||||
type: eventNameConfig.value.type,
|
||||
options: (mForm: FormState, { formValue }: any) =>
|
||||
eventsService.getEvent(formValue.type).map((option: any) => ({
|
||||
text: option.label,
|
||||
value: option.value,
|
||||
})),
|
||||
eventsService
|
||||
.getEvent(formValue.type, { node: editorService.getNodeById(formValue.id) })
|
||||
.map((option: any) => ({
|
||||
text: option.label,
|
||||
value: option.value,
|
||||
})),
|
||||
},
|
||||
{
|
||||
name: 'to',
|
||||
@ -324,7 +331,7 @@ const tableConfig = computed(
|
||||
const node = editorService.getNodeById(model.to);
|
||||
if (!node?.type) return [];
|
||||
|
||||
return eventsService.getMethod(node.type, model.to).map((option: any) => ({
|
||||
return eventsService.getMethod(node.type, { targetId: model.to, node }).map((option: any) => ({
|
||||
text: option.label,
|
||||
value: option.value,
|
||||
}));
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
<span class="help-txt" v-if="item.text">{{ item.text }}</span>
|
||||
<span class="next-input">
|
||||
<input
|
||||
:model-value="model[item.name]"
|
||||
:value="model[item.name]"
|
||||
placeholder="0"
|
||||
:title="model[item.name]"
|
||||
:disabled="disabled"
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
<div v-for="(item, index) in list" :key="index" :class="item.class">
|
||||
<span class="next-input">
|
||||
<input
|
||||
:model-value="model[item.name]"
|
||||
:value="model[item.name]"
|
||||
placeholder="0"
|
||||
:title="model[item.name]"
|
||||
:disabled="disabled"
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { markRaw } from 'vue';
|
||||
|
||||
import { appendValidateSuggestion } from '@tmagic/design';
|
||||
import { type ContainerChangeEventData, defineFormConfig, MContainer } from '@tmagic/form';
|
||||
import type { StyleSchema } from '@tmagic/schema';
|
||||
|
||||
@ -47,6 +48,12 @@ const formConfig = defineFormConfig([
|
||||
fieldConfig: {
|
||||
type: 'colorPicker',
|
||||
},
|
||||
rules: [
|
||||
{
|
||||
typeMatch: true,
|
||||
message: appendValidateSuggestion('背景色应为字符串', '请参考以下示例值:"#000000"'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'backgroundImage',
|
||||
|
||||
@ -17,9 +17,12 @@
|
||||
<script lang="ts" setup>
|
||||
import { markRaw } from 'vue';
|
||||
|
||||
import { appendValidateSuggestion } from '@tmagic/design';
|
||||
import { type ContainerChangeEventData, defineFormConfig, MContainer } from '@tmagic/form';
|
||||
import type { StyleSchema } from '@tmagic/schema';
|
||||
|
||||
import { validateDataSourceFieldSelectValue } from '@editor/utils/type-match-rules';
|
||||
|
||||
import { AlignCenter, AlignLeft, AlignRight } from '../icons/text-align';
|
||||
|
||||
defineProps<{
|
||||
@ -48,6 +51,12 @@ const formConfig = defineFormConfig([
|
||||
fieldConfig: {
|
||||
type: 'text',
|
||||
},
|
||||
rules: [
|
||||
{
|
||||
typeMatch: true,
|
||||
message: appendValidateSuggestion('字号应为字符串或数字', '请参考以下示例值:24 或 "24"'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
labelWidth: '68px',
|
||||
@ -65,8 +74,10 @@ const formConfig = defineFormConfig([
|
||||
text: '字重',
|
||||
labelWidth: '68px',
|
||||
type: 'data-source-field-select',
|
||||
dataSourceFieldType: ['string', 'number'],
|
||||
fieldConfig: {
|
||||
type: 'select',
|
||||
allowCreate: true,
|
||||
options: ['normal', 'bold']
|
||||
.concat(
|
||||
Array(7)
|
||||
@ -78,6 +89,46 @@ const formConfig = defineFormConfig([
|
||||
text: item,
|
||||
})),
|
||||
},
|
||||
rules: [
|
||||
{
|
||||
typeMatch: false,
|
||||
},
|
||||
{
|
||||
validator: ({ value, callback }, { config, model, prop }, mForm) => {
|
||||
if (value === '' || value === null || value === undefined) {
|
||||
return callback();
|
||||
}
|
||||
|
||||
const result = validateDataSourceFieldSelectValue(
|
||||
value,
|
||||
{
|
||||
fieldType: 'data-source-field-select',
|
||||
mForm,
|
||||
props: { config, model, prop },
|
||||
},
|
||||
{
|
||||
// 字重允许 string(含可创建项)与 number(如 700)
|
||||
validatePlainValue: (plainValue) => {
|
||||
if (typeof plainValue === 'string' || (typeof plainValue === 'number' && !Number.isNaN(plainValue))) {
|
||||
return undefined;
|
||||
}
|
||||
return '字重应为字符串或数字';
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (result && typeof (result as Promise<string | undefined>).then === 'function') {
|
||||
(result as Promise<string | undefined>).then(
|
||||
(error) => callback(error),
|
||||
(error) => callback(error),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
return callback(result);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
labelWidth: '68px',
|
||||
@ -87,6 +138,12 @@ const formConfig = defineFormConfig([
|
||||
fieldConfig: {
|
||||
type: 'colorPicker',
|
||||
},
|
||||
rules: [
|
||||
{
|
||||
typeMatch: true,
|
||||
message: appendValidateSuggestion('颜色应为字符串', '请参考以下示例值:"#000000"'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'textAlign',
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { appendValidateSuggestion } from '@tmagic/design';
|
||||
import { type ContainerChangeEventData, defineFormConfig, MContainer } from '@tmagic/form';
|
||||
import type { StyleSchema } from '@tmagic/schema';
|
||||
|
||||
@ -66,6 +67,12 @@ const formConfig = defineFormConfig([
|
||||
fieldConfig: {
|
||||
type: 'text',
|
||||
},
|
||||
rules: [
|
||||
{
|
||||
typeMatch: true,
|
||||
message: appendValidateSuggestion('left 应为字符串', '请参考以下示例值:"10"'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'top',
|
||||
@ -74,6 +81,12 @@ const formConfig = defineFormConfig([
|
||||
fieldConfig: {
|
||||
type: 'text',
|
||||
},
|
||||
rules: [
|
||||
{
|
||||
typeMatch: true,
|
||||
message: appendValidateSuggestion('top 应为字符串', '请参考以下示例值:"10"'),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
@ -89,6 +102,12 @@ const formConfig = defineFormConfig([
|
||||
fieldConfig: {
|
||||
type: 'text',
|
||||
},
|
||||
rules: [
|
||||
{
|
||||
typeMatch: true,
|
||||
message: appendValidateSuggestion('right 应为字符串', '请参考以下示例值:"10"'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'bottom',
|
||||
@ -97,6 +116,12 @@ const formConfig = defineFormConfig([
|
||||
fieldConfig: {
|
||||
type: 'text',
|
||||
},
|
||||
rules: [
|
||||
{
|
||||
typeMatch: true,
|
||||
message: appendValidateSuggestion('bottom 应为字符串', '请参考以下示例值:"10"'),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
@ -108,6 +133,12 @@ const formConfig = defineFormConfig([
|
||||
fieldConfig: {
|
||||
type: 'text',
|
||||
},
|
||||
rules: [
|
||||
{
|
||||
typeMatch: true,
|
||||
message: appendValidateSuggestion('zIndex 应为数字', '请参考以下示例值:10'),
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
@ -28,6 +28,7 @@ export const useStage = (stageOptions: StageOptions) => {
|
||||
containerHighlightClassName: stageOptions.containerHighlightClassName,
|
||||
containerHighlightDuration: stageOptions.containerHighlightDuration,
|
||||
containerHighlightType: stageOptions.containerHighlightType,
|
||||
containerHighlightAddOnly: stageOptions.containerHighlightAddOnly,
|
||||
disabledDragStart: stageOptions.disabledDragStart,
|
||||
renderType: stageOptions.renderType,
|
||||
canSelect: (el, event, stop) => {
|
||||
|
||||
@ -333,11 +333,11 @@ export const initServiceEvents = (
|
||||
Promise.all(
|
||||
nodes.map((node) => {
|
||||
if (node.type === NodeType.ROOT) {
|
||||
return Promise.resolve();
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
return depService.collectIdle([node], { pageId: getPageIdByNode(node) }, deep, type);
|
||||
}),
|
||||
);
|
||||
).then((results) => results.every(Boolean));
|
||||
|
||||
watch(
|
||||
() => editorService.get('stage'),
|
||||
@ -351,7 +351,7 @@ export const initServiceEvents = (
|
||||
|
||||
if (!node) return;
|
||||
|
||||
await collectIdle([node], true, DepTargetType.DATA_SOURCE);
|
||||
if (!(await collectIdle([node], true, DepTargetType.DATA_SOURCE))) return;
|
||||
updateStageNode(node);
|
||||
});
|
||||
},
|
||||
@ -494,8 +494,10 @@ export const initServiceEvents = (
|
||||
|
||||
// 新增节点,收集依赖
|
||||
const nodeAddHandler = (nodes: MComponent[]) => {
|
||||
collectIdle(nodes, true).then(() => {
|
||||
updateStageNodes(nodes);
|
||||
collectIdle(nodes, true).then((completed) => {
|
||||
if (completed) {
|
||||
updateStageNodes(nodes);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@ -549,8 +551,8 @@ export const initServiceEvents = (
|
||||
if (needRecollectNodes.length) {
|
||||
// 有数据源依赖,需要等依赖重新收集完才更新stage
|
||||
const handler = async () => {
|
||||
await collectIdle(needRecollectNodes, true, DepTargetType.DATA_SOURCE);
|
||||
await collectIdle(needRecollectNodes, true, DepTargetType.DATA_SOURCE_COND);
|
||||
if (!(await collectIdle(needRecollectNodes, true, DepTargetType.DATA_SOURCE))) return;
|
||||
if (!(await collectIdle(needRecollectNodes, true, DepTargetType.DATA_SOURCE_COND))) return;
|
||||
updateStageNodes(needRecollectNodes);
|
||||
};
|
||||
handler();
|
||||
@ -572,8 +574,10 @@ export const initServiceEvents = (
|
||||
|
||||
// 历史记录变化时,需要重新收集依赖
|
||||
const historyChangeHandler = (page: MPage | MPageFragment) => {
|
||||
collectIdle([page], true).then(() => {
|
||||
updateStageNode(page);
|
||||
collectIdle([page], true).then((completed) => {
|
||||
if (completed) {
|
||||
updateStageNode(page);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@ -654,7 +658,7 @@ export const initServiceEvents = (
|
||||
if (Array.isArray(root?.items)) {
|
||||
depService.clearIdleTasks();
|
||||
|
||||
let collectIdlePromises: Promise<void[]>[] = [];
|
||||
let collectIdlePromises: Promise<boolean>[] = [];
|
||||
if (isModifyField) {
|
||||
depService.removeTarget(config.id, DepTargetType.DATA_SOURCE);
|
||||
depService.removeTarget(config.id, DepTargetType.DATA_SOURCE_COND);
|
||||
@ -679,10 +683,15 @@ export const initServiceEvents = (
|
||||
|
||||
collectIdlePromises = [collectIdle(root.items, true, DepTargetType.DATA_SOURCE_METHOD)];
|
||||
}
|
||||
Promise.all(collectIdlePromises)
|
||||
.then(() => updateDataSourceSchema())
|
||||
.then(() => updateDsData())
|
||||
.then(() => updateStageNodes(root.items));
|
||||
const handler = async () => {
|
||||
const results = await Promise.all(collectIdlePromises);
|
||||
if (!results.every(Boolean)) return;
|
||||
|
||||
updateDataSourceSchema();
|
||||
await updateDsData();
|
||||
updateStageNodes(root.items);
|
||||
};
|
||||
handler();
|
||||
}
|
||||
} else if (root?.dataSources) {
|
||||
updateDsData();
|
||||
@ -706,11 +715,12 @@ export const initServiceEvents = (
|
||||
const nodeIds = Object.keys(root.dataSourceDeps?.[id] || {});
|
||||
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_COND),
|
||||
collectIdle(nodes, false, DepTargetType.DATA_SOURCE_METHOD),
|
||||
]);
|
||||
if (!results.every(Boolean)) return;
|
||||
|
||||
updateDataSourceSchema();
|
||||
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
:config="config"
|
||||
:type-match-valid="true"
|
||||
:extend-state="extendState"
|
||||
:validate-on-init="true"
|
||||
@change="submit"
|
||||
@error="errorHandler"
|
||||
></MForm>
|
||||
@ -49,14 +50,13 @@ import { Document as DocumentIcon } from '@element-plus/icons-vue';
|
||||
|
||||
import { TMagicButton, tMagicMessage, TMagicScrollbar } from '@tmagic/design';
|
||||
import type { ContainerChangeEventData, FormConfig, FormState, FormValue } from '@tmagic/form';
|
||||
import { MForm } from '@tmagic/form';
|
||||
import { MForm, validateForm } from '@tmagic/form';
|
||||
import { filterXSS } from '@tmagic/utils';
|
||||
|
||||
import MIcon from '@editor/components/Icon.vue';
|
||||
import { ENABLE_PROPS_FORM_VALIDATE } from '@editor/editorProps';
|
||||
import { useEditorContentHeight } from '@editor/hooks/use-editor-content-height';
|
||||
import { useServices } from '@editor/hooks/use-services';
|
||||
import { validatePropsForm } from '@editor/utils/props';
|
||||
|
||||
import CodeEditor from '../CodeEditor.vue';
|
||||
|
||||
@ -162,14 +162,19 @@ const saveCode = async (values: any) => {
|
||||
// 做一次静默校验(不复用、也不污染页面上正在展示的表单),并将校验结果(错误信息)随提交
|
||||
// 一并抛给上层记录,使源码保存的错误状态与表单编辑保持一致。
|
||||
try {
|
||||
const error = await validatePropsForm({
|
||||
const error = await validateForm({
|
||||
config: props.config,
|
||||
values: newValues,
|
||||
appContext: internalInstance?.appContext ?? null,
|
||||
services,
|
||||
stage: stage.value,
|
||||
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) {
|
||||
|
||||
@ -158,14 +158,34 @@ const submit = async (
|
||||
v.id = values.value.id;
|
||||
}
|
||||
|
||||
const newValue: MNode = {
|
||||
...v,
|
||||
style: {},
|
||||
};
|
||||
// 区分操作途径:表单字段编辑(MForm @change)会带上 eventData(含 changeRecords);
|
||||
// 源码编辑器(CodeEditor @save → saveCode)保存时不带 eventData,据此标记为「源码编辑器」。
|
||||
const historySource = eventData ? 'props' : 'code';
|
||||
// 源码编辑统一走整节点 replace,避免 merge 保留已删除字段。
|
||||
const replace = historySource === 'code';
|
||||
|
||||
if (v.style) {
|
||||
// 空字符串样式值表示「清除该样式」,需保留才能在 doUpdate 的 mergeWith 中覆盖旧值。
|
||||
if (eventData) {
|
||||
let newValue: MNode;
|
||||
|
||||
if (replace) {
|
||||
if (source === 'style') {
|
||||
// 样式面板源码仅提交 { style }:先把新 style 替换到原节点上,再整节点覆盖。
|
||||
newValue = {
|
||||
...values.value,
|
||||
id: v.id || values.value.id,
|
||||
style: { ...(v.style || {}) },
|
||||
};
|
||||
} else {
|
||||
// 属性面板源码提交完整节点 DSL
|
||||
newValue = { ...v };
|
||||
}
|
||||
} else {
|
||||
newValue = {
|
||||
...v,
|
||||
style: {},
|
||||
};
|
||||
|
||||
if (v.style) {
|
||||
// 空字符串样式值表示「清除该样式」,需保留才能在 doUpdate 的 mergeWith 中覆盖旧值。
|
||||
// 表单编辑:先过滤掉空字符串(避免表单默认空值污染 DSL),
|
||||
// 再按 changeRecords 恢复被主动清空的字段。
|
||||
Object.entries(v.style).forEach(([key, value]) => {
|
||||
@ -174,24 +194,18 @@ const submit = async (
|
||||
}
|
||||
});
|
||||
|
||||
eventData.changeRecords?.forEach((record) => {
|
||||
eventData?.changeRecords?.forEach((record) => {
|
||||
if (record.propPath?.startsWith('style') && record.value === '') {
|
||||
setValueByKeyPath(record.propPath, record.value, newValue);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 源码编辑器保存(无 eventData):style 原样保留,其中的空字符串视为用户主动清除该样式。
|
||||
newValue.style = { ...v.style };
|
||||
}
|
||||
}
|
||||
|
||||
// 区分操作途径:表单字段编辑(MForm @change)会带上 eventData(含 changeRecords);
|
||||
// 源码编辑器(CodeEditor @save → saveCode)保存时不带 eventData,据此标记为「源码编辑器」。
|
||||
const historySource = eventData ? 'props' : 'code';
|
||||
|
||||
editorService.update(newValue, {
|
||||
changeRecords: eventData?.changeRecords,
|
||||
historySource,
|
||||
replace,
|
||||
// 启用校验联动时,仅校验失败(error 存在)才把错误信息随更新传入 editorService 记录;
|
||||
// 其余情况(含表单校验成功、CodeEditor 源码保存)不携带 invalidInfo,由 editorService 在执行 update 时统一清除该节点错误。
|
||||
...(enablePropsFormValidate && error ? { invalidInfo: { id: newValue.id, source, error: error?.message } } : {}),
|
||||
|
||||
@ -71,7 +71,11 @@ const stage = computed(() => editorService.get('stage'));
|
||||
const list = computed<ComponentGroup[]>(() =>
|
||||
componentListService.getList().map((group: ComponentGroup) => ({
|
||||
...group,
|
||||
items: group.items.filter((item: ComponentItem) => item.text.includes(searchText.value)),
|
||||
items: group.items.filter((item: ComponentItem) =>
|
||||
`${item.text || ''}${item.desc || ''}${item.type}`
|
||||
.toLocaleLowerCase()
|
||||
.includes(searchText.value.toLocaleLowerCase()),
|
||||
),
|
||||
})),
|
||||
);
|
||||
|
||||
@ -145,6 +149,6 @@ const dragHandler = (e: DragEvent) => {
|
||||
|
||||
if (timeout || !stage.value) return;
|
||||
|
||||
timeout = stage.value.delayedMarkContainer(e);
|
||||
timeout = stage.value.delayedMarkContainer(e, [], true);
|
||||
};
|
||||
</script>
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
import { computed, markRaw, useTemplateRef } from '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 { useServices } from '@editor/hooks/use-services';
|
||||
@ -101,7 +101,7 @@ const menuData = computed<(MenuButton | MenuComponent)[]>(() =>
|
||||
type: 'button',
|
||||
text: '全部折叠',
|
||||
icon: FolderMinusIcon,
|
||||
display: () => isPage(node.value) || isPageFragment(node.value),
|
||||
display: () => isPageOrFragment(node.value),
|
||||
handler: () => {
|
||||
emit('collapse-all');
|
||||
},
|
||||
|
||||
@ -120,7 +120,7 @@ const filterNodeMethod = (v: string, data: MNode): boolean => {
|
||||
name = 'container';
|
||||
}
|
||||
|
||||
return `${data.id}${name}${data.type}`.includes(v);
|
||||
return `${data.id}${name}${data.type || ''}`.toLocaleLowerCase().includes(v.toLocaleLowerCase());
|
||||
};
|
||||
|
||||
const { filterTextChangeHandler } = useFilter(nodeData, nodeStatusMap, filterNodeMethod);
|
||||
|
||||
@ -2,7 +2,7 @@ import { computed, type ComputedRef, nextTick, type Ref, type ShallowRef } from
|
||||
import { throttle } from 'lodash-es';
|
||||
|
||||
import { Id, MNode } from '@tmagic/core';
|
||||
import { getElById, isPage, isPageFragment } from '@tmagic/utils';
|
||||
import { getElById, isPageOrFragment } from '@tmagic/utils';
|
||||
|
||||
import type { LayerNodeStatus, Services, TreeNodeData } from '@editor/type';
|
||||
import { UI_SELECT_MODE_EVENT_NAME } from '@editor/utils/const';
|
||||
@ -36,7 +36,7 @@ export const useClick = (
|
||||
};
|
||||
|
||||
const multiSelect = async (data: MNode) => {
|
||||
if (isPage(data) || isPageFragment(data)) {
|
||||
if (isPageOrFragment(data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -50,7 +50,7 @@ export const useClick = (
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPage(node) || isPageFragment(node)) {
|
||||
if (isPageOrFragment(node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||
|
||||
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 { updateStatus } from '@editor/utils/tree';
|
||||
@ -136,7 +136,7 @@ export const useNodeStatus = ({ editorService }: Services) => {
|
||||
|
||||
const addHandler = (newNodes: MNode[]) => {
|
||||
newNodes.forEach((node) => {
|
||||
if (isPage(node) || isPageFragment(node)) return;
|
||||
if (isPageOrFragment(node)) return;
|
||||
|
||||
traverseNode(node, (node: MNode) => {
|
||||
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 { NodeType } from '@tmagic/core';
|
||||
import { isPage, isPageFragment } from '@tmagic/utils';
|
||||
import { isPageOrFragment } from '@tmagic/utils';
|
||||
|
||||
import ContentMenu from '@editor/components/ContentMenu.vue';
|
||||
import { useServices } from '@editor/hooks/use-services';
|
||||
@ -59,14 +59,14 @@ const menuData = computed<(MenuButton | MenuComponent)[]>(() =>
|
||||
direction: 'horizontal',
|
||||
display: () => {
|
||||
if (!node.value) return false;
|
||||
return !isPage(node.value) && !isPageFragment(node.value);
|
||||
return !isPageOrFragment(node.value);
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'button',
|
||||
text: '上移一层',
|
||||
icon: markRaw(Top),
|
||||
display: () => !isPage(node.value) && !isPageFragment(node.value) && !props.isMultiSelect,
|
||||
display: () => !isPageOrFragment(node.value) && !props.isMultiSelect,
|
||||
handler: () => {
|
||||
editorService.moveLayer(1, { historySource: 'stage-contextmenu' });
|
||||
},
|
||||
@ -75,7 +75,7 @@ const menuData = computed<(MenuButton | MenuComponent)[]>(() =>
|
||||
type: 'button',
|
||||
text: '下移一层',
|
||||
icon: markRaw(Bottom),
|
||||
display: () => !isPage(node.value) && !isPageFragment(node.value) && !props.isMultiSelect,
|
||||
display: () => !isPageOrFragment(node.value) && !props.isMultiSelect,
|
||||
handler: () => {
|
||||
editorService.moveLayer(-1, { historySource: 'stage-contextmenu' });
|
||||
},
|
||||
@ -84,7 +84,7 @@ const menuData = computed<(MenuButton | MenuComponent)[]>(() =>
|
||||
type: 'button',
|
||||
text: '置顶',
|
||||
icon: markRaw(Top),
|
||||
display: () => !isPage(node.value) && !isPageFragment(node.value) && !props.isMultiSelect,
|
||||
display: () => !isPageOrFragment(node.value) && !props.isMultiSelect,
|
||||
handler: () => {
|
||||
editorService.moveLayer(LayerOffset.TOP, { historySource: 'stage-contextmenu' });
|
||||
},
|
||||
@ -93,7 +93,7 @@ const menuData = computed<(MenuButton | MenuComponent)[]>(() =>
|
||||
type: 'button',
|
||||
text: '置底',
|
||||
icon: markRaw(Bottom),
|
||||
display: () => !isPage(node.value) && !isPageFragment(node.value) && !props.isMultiSelect,
|
||||
display: () => !isPageOrFragment(node.value) && !props.isMultiSelect,
|
||||
handler: () => {
|
||||
editorService.moveLayer(LayerOffset.BOTTOM, { historySource: 'stage-contextmenu' });
|
||||
},
|
||||
@ -102,7 +102,7 @@ const menuData = computed<(MenuButton | MenuComponent)[]>(() =>
|
||||
{
|
||||
type: 'divider',
|
||||
direction: 'horizontal',
|
||||
display: () => !isPage(node.value) && !isPageFragment(node.value) && !props.isMultiSelect,
|
||||
display: () => !isPageOrFragment(node.value) && !props.isMultiSelect,
|
||||
},
|
||||
useDeleteMenu('stage-contextmenu'),
|
||||
{
|
||||
|
||||
@ -17,14 +17,14 @@
|
||||
*/
|
||||
import { reactive, shallowReactive } from 'vue';
|
||||
import { throttle } from 'lodash-es';
|
||||
import serialize from 'serialize-javascript';
|
||||
|
||||
import type { DepData, DepExtendedData, Id, MApp, MNode, Target, TargetNode } from '@tmagic/core';
|
||||
import type { DepData, DepExtendedData, Id, MApp, MNode, Target } from '@tmagic/core';
|
||||
import { DepTargetType, traverseTarget, Watcher } from '@tmagic/core';
|
||||
import { isPage } from '@tmagic/utils';
|
||||
|
||||
import { CollectWorkerClient } from '@editor/utils/dep/collect-worker-client';
|
||||
import { IdleTask } from '@editor/utils/dep/idle-task';
|
||||
import Work from '@editor/utils/dep/worker.ts?worker&inline';
|
||||
import type { DepsData } from '@editor/utils/dep/worker';
|
||||
|
||||
import BaseService from './BaseService';
|
||||
|
||||
@ -42,18 +42,42 @@ interface 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 {
|
||||
private state = shallowReactive<State>({
|
||||
collecting: false,
|
||||
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 waitingWorker?: Promise<void>;
|
||||
|
||||
private resolveWaitingWorker?: () => void;
|
||||
|
||||
private workerGeneration = 0;
|
||||
|
||||
private activeBatches = new Set<CollectBatch>();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
@ -132,56 +156,77 @@ class Dep extends BaseService {
|
||||
this.emit('ds-collected', nodes, deep);
|
||||
}
|
||||
|
||||
/**
|
||||
* 空闲收集依赖
|
||||
*
|
||||
* 遍历匹配是整个收集过程中最耗时的部分(要深度遍历节点树并对每个属性做 target 匹配),
|
||||
* 节点/数据源多时在主线程执行会长时间卡死页面,因此优先交给 worker 执行,主线程只负责把结果写回 target。
|
||||
* 自定义 target 的 isTarget 是无法跨线程传递的闭包函数(没有 descriptor),仍走主线程空闲队列;
|
||||
* worker 不可用(如 SSR)或执行失败时也会回退到主线程空闲队列。
|
||||
*/
|
||||
public async collectIdle(nodes: MNode[], depExtendedData: DepExtendedData = {}, deep = false, type?: DepTargetType) {
|
||||
if (this.waitingWorker) {
|
||||
await this.waitingWorker;
|
||||
}
|
||||
|
||||
this.set('collecting', true);
|
||||
let startTask = false;
|
||||
this.watcher.collectByCallback(nodes, type, ({ node, target }) => {
|
||||
startTask = true;
|
||||
const batch: CollectBatch = {
|
||||
nodes,
|
||||
deep,
|
||||
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) => {
|
||||
if (!startTask) {
|
||||
this.emit('collected', nodes, deep);
|
||||
this.set('collecting', false);
|
||||
resolve();
|
||||
return;
|
||||
for (const target of this.watcher.getCollectableTargets(type)) {
|
||||
if (target.descriptor && this.collectWorker.isSupported) {
|
||||
workerTargets.push(target);
|
||||
} else {
|
||||
this.enqueueTasks(nodes, target, depExtendedData, deep, batch);
|
||||
}
|
||||
this.idleTask.once('finish', () => {
|
||||
this.emit('collected', nodes, deep);
|
||||
this.set('collecting', false);
|
||||
});
|
||||
this.idleTask.once('hight-level-finish', () => {
|
||||
this.emit('ds-collected', nodes, deep);
|
||||
resolve();
|
||||
});
|
||||
}
|
||||
|
||||
if (workerTargets.length && nodes.length) {
|
||||
this.collectByCollectWorker(nodes, workerTargets, depExtendedData, deep, batch);
|
||||
}
|
||||
|
||||
// 没有命中任何 target,无需收集,直接完成
|
||||
if (batch.pending === 0) {
|
||||
this.emit('collected', nodes, deep);
|
||||
this.updateCollectingState();
|
||||
return true;
|
||||
}
|
||||
|
||||
this.activeBatches.add(batch);
|
||||
this.set('collecting', true);
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
batch.resolve = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
public collectByWorker(dsl: MApp) {
|
||||
this.set('collecting', true);
|
||||
this.workerGeneration += 1;
|
||||
const generation = this.workerGeneration;
|
||||
|
||||
const { promise, resolve: waitingResolve } = Promise.withResolvers<void>();
|
||||
|
||||
this.waitingWorker = promise;
|
||||
this.resolveWaitingWorker = waitingResolve;
|
||||
|
||||
return this.collectWorker.collectDsl(dsl).then((deps) => {
|
||||
const depsData: DepsData = deps || {};
|
||||
|
||||
if (generation !== this.workerGeneration) {
|
||||
waitingResolve();
|
||||
return depsData;
|
||||
}
|
||||
|
||||
return new Promise<Record<string, Record<string, DepData>>>((resolve) => {
|
||||
const worker = new Work();
|
||||
worker.postMessage({
|
||||
dsl: serialize(dsl),
|
||||
});
|
||||
worker.onmessage = (e) => {
|
||||
resolve(e.data);
|
||||
};
|
||||
worker.onerror = () => {
|
||||
resolve({});
|
||||
};
|
||||
}).then((depsData) => {
|
||||
traverseTarget(this.watcher.getTargetsList(), (target) => {
|
||||
if (depsData[target.type]?.[target.id]) {
|
||||
target.deps = reactive(depsData[target.type][target.id]);
|
||||
@ -200,6 +245,10 @@ class Dep extends BaseService {
|
||||
this.emit('collected', dsl.items, true);
|
||||
this.emit('ds-collected', dsl.items, true);
|
||||
waitingResolve();
|
||||
if (this.waitingWorker === promise) {
|
||||
this.waitingWorker = undefined;
|
||||
this.resolveWaitingWorker = undefined;
|
||||
}
|
||||
|
||||
return depsData;
|
||||
});
|
||||
@ -209,8 +258,12 @@ class Dep extends BaseService {
|
||||
// 先删除原有依赖,重新收集
|
||||
if (isPage(node)) {
|
||||
this.removePageDep(target, depExtendedData);
|
||||
} else {
|
||||
} else if (deep) {
|
||||
// deep 收集会覆盖整棵子树,删除也要同步清掉子孙旧依赖
|
||||
this.watcher.removeTargetDep(target, node);
|
||||
} else {
|
||||
// 非 deep 只动当前节点,避免误清子节点上其他尚未重收的依赖
|
||||
target.removeDep(node.id);
|
||||
}
|
||||
|
||||
this.watcher.collectItem(node, target, depExtendedData, deep);
|
||||
@ -233,7 +286,10 @@ class Dep extends BaseService {
|
||||
}
|
||||
|
||||
public clearIdleTasks() {
|
||||
this.abortActiveBatches();
|
||||
this.idleTask.clearTasks();
|
||||
// 丢掉 worker 在途任务,避免 abort 后的长任务继续占着常驻 worker
|
||||
this.collectWorker.abort();
|
||||
}
|
||||
|
||||
public on<Name extends keyof DepEvents, Param extends DepEvents[Name]>(
|
||||
@ -251,7 +307,13 @@ class Dep extends BaseService {
|
||||
}
|
||||
|
||||
public reset() {
|
||||
this.abortActiveBatches();
|
||||
this.workerGeneration += 1;
|
||||
this.resolveWaitingWorker?.();
|
||||
this.resolveWaitingWorker = undefined;
|
||||
this.waitingWorker = undefined;
|
||||
this.idleTask.clearTasks();
|
||||
this.collectWorker.abort();
|
||||
|
||||
for (const type of Object.keys(this.watcher.getTargetsList())) {
|
||||
this.removeTargets(type);
|
||||
@ -267,6 +329,7 @@ class Dep extends BaseService {
|
||||
this.reset();
|
||||
this.removeAllPlugins();
|
||||
this.idleTask.removeAllListeners();
|
||||
this.collectWorker.terminate();
|
||||
}
|
||||
|
||||
public emit<Name extends keyof DepEvents, Param extends DepEvents[Name]>(eventName: Name, ...args: Param) {
|
||||
@ -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(
|
||||
({ 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);
|
||||
}
|
||||
},
|
||||
{
|
||||
node,
|
||||
deep: false,
|
||||
target,
|
||||
undefined,
|
||||
isDataSource,
|
||||
);
|
||||
}
|
||||
|
||||
private enqueueTasks(
|
||||
nodes: MNode[],
|
||||
target: Target,
|
||||
depExtendedData: DepExtendedData,
|
||||
deep: boolean,
|
||||
batch: CollectBatch,
|
||||
) {
|
||||
for (const node of nodes) {
|
||||
this.enqueueTask(node, target, depExtendedData, deep, batch);
|
||||
}
|
||||
}
|
||||
|
||||
private enqueueTask(
|
||||
node: MNode,
|
||||
target: Target,
|
||||
depExtendedData: DepExtendedData,
|
||||
deep: boolean,
|
||||
batch: CollectBatch,
|
||||
) {
|
||||
const isDataSource = target.type === DepTargetType.DATA_SOURCE;
|
||||
|
||||
this.beginBatchTask(batch, isDataSource);
|
||||
|
||||
this.idleTask.enqueueTask(
|
||||
() => {
|
||||
try {
|
||||
this.collectNode(node, target, depExtendedData);
|
||||
} finally {
|
||||
this.onBatchTaskDone(batch, isDataSource);
|
||||
}
|
||||
},
|
||||
target.type === DepTargetType.DATA_SOURCE,
|
||||
undefined,
|
||||
isDataSource,
|
||||
);
|
||||
|
||||
if (deep && Array.isArray(node.items) && node.items.length) {
|
||||
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;
|
||||
|
||||
@ -16,7 +16,7 @@
|
||||
* 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 type { Id, MApp, MContainer, MNode, MPage, MPageFragment, TargetOptions } from '@tmagic/core';
|
||||
@ -30,6 +30,7 @@ import {
|
||||
guid,
|
||||
isPage,
|
||||
isPageFragment,
|
||||
isPageOrFragment,
|
||||
setValueByKeyPath,
|
||||
traverseNode,
|
||||
} from '@tmagic/utils';
|
||||
@ -272,7 +273,7 @@ class Editor extends BaseService {
|
||||
public isOnDifferentPage(node: MNode): boolean {
|
||||
const currentPageId = this.get('page')?.id;
|
||||
if (currentPageId === undefined || currentPageId === null) return false;
|
||||
if (isPage(node) || isPageFragment(node)) {
|
||||
if (isPageOrFragment(node)) {
|
||||
return `${node.id}` !== `${currentPageId}`;
|
||||
}
|
||||
const nodePage = this.getNodeInfo(node.id, false).page;
|
||||
@ -413,11 +414,11 @@ class Editor extends BaseService {
|
||||
|
||||
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下不能添加组件');
|
||||
}
|
||||
|
||||
if (parent.id !== curNode.id && !(isPage(node) || isPageFragment(node))) {
|
||||
if (parent.id !== curNode.id && !isPageOrFragment(node)) {
|
||||
const index = parent.items.indexOf(curNode);
|
||||
parent.items?.splice(index + 1, 0, node);
|
||||
} else {
|
||||
@ -487,7 +488,7 @@ class Editor extends BaseService {
|
||||
const newNodes = await Promise.all(
|
||||
addNodes.map((node) => {
|
||||
const root = this.get('root');
|
||||
if ((isPage(node) || isPageFragment(node)) && root) {
|
||||
if (isPageOrFragment(node) && root) {
|
||||
return this.doAdd(node, root);
|
||||
}
|
||||
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;
|
||||
if (!doNotPushHistory) {
|
||||
const parentId = (this.getParentById(newNodes[0].id, false) ?? this.get('root'))!.id;
|
||||
@ -554,8 +555,8 @@ class Editor extends BaseService {
|
||||
doNotPushHistory,
|
||||
});
|
||||
|
||||
// 页面 / 页面片新增不入历史栈(见上方 isPage / isPageFragment 分支),这里合并补发一次结构变更通知
|
||||
const addedPages = newNodes.filter((node) => isPage(node) || isPageFragment(node)) as (MPage | MPageFragment)[];
|
||||
// 页面 / 页面片新增不入历史栈(见上方 isPageOrFragment 分支),这里合并补发一次结构变更通知
|
||||
const addedPages = newNodes.filter((node) => isPageOrFragment(node)) as (MPage | MPageFragment)[];
|
||||
if (addedPages.length) {
|
||||
historyService.notifyPageStructureChange({ add: addedPages, remove: [] });
|
||||
}
|
||||
@ -578,12 +579,14 @@ class Editor extends BaseService {
|
||||
|
||||
if (typeof index !== 'number' || index === -1) throw new Error('找不要删除的节点');
|
||||
|
||||
parent.items?.splice(index, 1);
|
||||
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 中的残留引用:
|
||||
// - 即使后续会调用 selectDefault / select(parent) 覆盖,跳过这些调用(doNotSelect / doNotSwitchPage)时也不能让 state 持有已删除节点
|
||||
// - 即使后续会调用 select 覆盖,跳过这些调用(doNotSelect / doNotSwitchPage)时也不能让 state 持有已删除节点
|
||||
const selectedNodes = this.get('nodes');
|
||||
const removedSelectedIndex = selectedNodes.findIndex((n: MNode) => `${n.id}` === `${node.id}`);
|
||||
if (removedSelectedIndex !== -1) {
|
||||
@ -591,38 +594,44 @@ class Editor extends BaseService {
|
||||
nextSelected.splice(removedSelectedIndex, 1);
|
||||
this.set('nodes', nextSelected);
|
||||
}
|
||||
if (isPage(node) || isPageFragment(node)) {
|
||||
const currentPage = this.get('page');
|
||||
if (currentPage && `${currentPage.id}` === `${node.id}`) {
|
||||
this.set('page', null);
|
||||
}
|
||||
}
|
||||
|
||||
const selectDefault = async (pages: MNode[]) => {
|
||||
if (pages[0]) {
|
||||
await this.select(pages[0]);
|
||||
stage?.select(pages[0].id);
|
||||
} else {
|
||||
this.selectRoot();
|
||||
}
|
||||
};
|
||||
|
||||
const removeData = { id: node.id, parentId: parent.id, root: cloneDeep(root) };
|
||||
const rootItems = root.items || [];
|
||||
// 删非当前页时画布不应该变动,所以只有删的是当前页才重新选中
|
||||
const shouldReselect = isDeletingCurrentPage && !doNotSelect && !doNotSwitchPage;
|
||||
// 还有剩余页面才能切过去,否则退回选中 root
|
||||
const shouldSwitchPage = shouldReselect && rootItems.length > 0;
|
||||
|
||||
if (isPage(node)) {
|
||||
this.state.pageLength -= 1;
|
||||
|
||||
// 删除页面后默认会切到首个剩余页面(selectDefault),doNotSwitchPage 时跳过这次自动切换
|
||||
if (!doNotSelect && !doNotSwitchPage) {
|
||||
await selectDefault(rootItems);
|
||||
if (isPageOrFragment(node)) {
|
||||
if (isPage(node)) {
|
||||
this.state.pageLength -= 1;
|
||||
} else {
|
||||
this.state.pageFragmentLength -= 1;
|
||||
}
|
||||
} else if (isPageFragment(node)) {
|
||||
this.state.pageFragmentLength -= 1;
|
||||
|
||||
if (!doNotSelect && !doNotSwitchPage) {
|
||||
await selectDefault(rootItems);
|
||||
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);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
stage?.remove(removeData);
|
||||
|
||||
if (!doNotSelect) {
|
||||
await this.select(parent);
|
||||
stage?.select(parent.id);
|
||||
@ -664,7 +673,7 @@ class Editor extends BaseService {
|
||||
|
||||
const removedItems: StepDiffItem<MNode>[] = [];
|
||||
let pageForOp: { name: string; id: Id } | null = null;
|
||||
if (!(isPage(nodes[0]) || isPageFragment(nodes[0]))) {
|
||||
if (!isPageOrFragment(nodes[0])) {
|
||||
for (const n of nodes) {
|
||||
const { parent, node: curNode, page } = this.getNodeInfo(n.id, false);
|
||||
if (parent && curNode) {
|
||||
@ -702,8 +711,8 @@ class Editor extends BaseService {
|
||||
this.emit('remove', nodes);
|
||||
this.emit('change', { type: 'remove', data: changeItems, historySource, doNotPushHistory });
|
||||
|
||||
// 页面 / 页面片删除不入历史栈(见上方 isPage / isPageFragment 分支),这里合并补发一次结构变更通知
|
||||
const removedPages = nodes.filter((node) => isPage(node) || isPageFragment(node)) as (MPage | MPageFragment)[];
|
||||
// 页面 / 页面片删除不入历史栈(见上方 isPageOrFragment 分支),这里合并补发一次结构变更通知
|
||||
const removedPages = nodes.filter((node) => isPageOrFragment(node)) as (MPage | MPageFragment)[];
|
||||
if (removedPages.length) {
|
||||
historyService.notifyPageStructureChange({ add: [], remove: removedPages });
|
||||
}
|
||||
@ -711,7 +720,11 @@ class Editor extends BaseService {
|
||||
|
||||
public async doUpdate(
|
||||
config: MNode,
|
||||
{ changeRecords = [], historySource }: { changeRecords?: ChangeRecord[]; historySource?: HistoryOpSource } = {},
|
||||
{
|
||||
changeRecords = [],
|
||||
historySource,
|
||||
replace = false,
|
||||
}: { changeRecords?: ChangeRecord[]; historySource?: HistoryOpSource; replace?: boolean } = {},
|
||||
): Promise<{ newNode: MNode; oldNode: MNode; changeRecords?: ChangeRecord[] }> {
|
||||
const root = this.get('root');
|
||||
if (!root) throw new Error('root为空');
|
||||
@ -724,9 +737,14 @@ class Editor extends BaseService {
|
||||
|
||||
const node = toRaw(info.node);
|
||||
|
||||
let newConfig = await toggleFixedPosition(toRaw(config), node, info.path, this.getLayout);
|
||||
// replace=true 时跳过 toggleFixedPosition / mergeWith / setChildrenLayout,直接用传入配置整节点替换
|
||||
let newConfig = replace
|
||||
? cloneDeep(toRaw(config))
|
||||
: await toggleFixedPosition(toRaw(config), node, info.path, this.getLayout);
|
||||
|
||||
newConfig = mergeWith(cloneDeep(node), newConfig, editorNodeMergeCustomizer);
|
||||
if (!replace) {
|
||||
newConfig = mergeWith(cloneDeep(node), newConfig, editorNodeMergeCustomizer);
|
||||
}
|
||||
|
||||
if (!newConfig.type) throw new Error('配置缺少type值');
|
||||
|
||||
@ -747,10 +765,12 @@ class Editor extends BaseService {
|
||||
|
||||
if (!parentNodeItems || typeof index === 'undefined' || index === -1) throw new Error('更新的节点未找到');
|
||||
|
||||
const newLayout = await this.getLayout(newConfig);
|
||||
const layout = await this.getLayout(node);
|
||||
if (Array.isArray(newConfig.items) && newLayout !== layout) {
|
||||
newConfig = setChildrenLayout(newConfig as MContainer, newLayout);
|
||||
if (!replace) {
|
||||
const newLayout = await this.getLayout(newConfig);
|
||||
const layout = await this.getLayout(node);
|
||||
if (Array.isArray(newConfig.items) && newLayout !== layout) {
|
||||
newConfig = setChildrenLayout(newConfig as MContainer, newLayout);
|
||||
}
|
||||
}
|
||||
|
||||
parentNodeItems[index] = newConfig;
|
||||
@ -764,7 +784,7 @@ class Editor extends BaseService {
|
||||
}
|
||||
|
||||
// 只有被更新节点正好是当前选中页面时才同步 state.page,避免「更新非当前页」误将编辑器切到该页
|
||||
if (isPage(newConfig) || isPageFragment(newConfig)) {
|
||||
if (isPageOrFragment(newConfig)) {
|
||||
const currentPage = this.get('page');
|
||||
if (currentPage && `${currentPage.id}` === `${newConfig.id}`) {
|
||||
this.set('page', newConfig as MPage | MPageFragment);
|
||||
@ -789,6 +809,7 @@ class Editor extends BaseService {
|
||||
* @param data.changeRecordList 多节点 form 端变更记录列表,按 config 数组同序对应每个节点;优先级高于 changeRecords
|
||||
* @param data.doNotPushHistory 是否不写入历史记录(默认 false)
|
||||
* @param data.historyDescription 入栈时附带的人类可读描述,用于历史面板展示(不影响 undo/redo 行为)
|
||||
* @param data.replace 是否整节点替换:为 true 时跳过 mergeWith / toggleFixedPosition / setChildrenLayout,直接用传入配置覆盖(默认 false)
|
||||
* @returns 更新后的节点配置
|
||||
*/
|
||||
public async update(
|
||||
@ -799,6 +820,11 @@ class Editor extends BaseService {
|
||||
doNotPushHistory?: boolean;
|
||||
historyDescription?: string;
|
||||
historySource?: HistoryOpSource;
|
||||
/**
|
||||
* 为 true 时不做深合并等变换,直接用传入配置整节点替换现有节点。
|
||||
* 适用于源码编辑、历史整节点快照回放等「完整 DSL」场景;默认 false(局部属性更新走 merge)。
|
||||
*/
|
||||
replace?: boolean;
|
||||
/**
|
||||
* 属性面板提交时携带的校验错误信息,在写入历史记录之前落库,
|
||||
* 使历史快照与本次变更对齐,从而 undo/redo 能正确还原错误标记。
|
||||
@ -814,6 +840,7 @@ class Editor extends BaseService {
|
||||
changeRecords,
|
||||
historyDescription,
|
||||
historySource,
|
||||
replace = false,
|
||||
invalidInfo,
|
||||
} = data;
|
||||
|
||||
@ -824,7 +851,7 @@ class Editor extends BaseService {
|
||||
const updateData = await Promise.all(
|
||||
nodes.map((node, index) => {
|
||||
const recordsForNode = changeRecordList ? (changeRecordList[index] ?? []) : (changeRecords ?? []);
|
||||
return this.doUpdate(node, { changeRecords: recordsForNode, historySource });
|
||||
return this.doUpdate(node, { changeRecords: recordsForNode, historySource, replace });
|
||||
}),
|
||||
);
|
||||
|
||||
@ -1139,7 +1166,7 @@ class Editor extends BaseService {
|
||||
}: DslOpOptions = {},
|
||||
): Promise<MNode | MNode[]> {
|
||||
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) {
|
||||
throw new Error('没有可移动的节点');
|
||||
@ -1339,6 +1366,7 @@ class Editor extends BaseService {
|
||||
doNotPushHistory?: boolean;
|
||||
historyDescription?: string;
|
||||
historySource?: HistoryOpSource;
|
||||
replace?: boolean;
|
||||
} = {},
|
||||
): Promise<DslOpWithHistoryIdsResult<MNode | MNode[]>> {
|
||||
this.lastPushedHistoryId = null;
|
||||
@ -1760,7 +1788,7 @@ class Editor extends BaseService {
|
||||
private getPageOfNode(id: Id): MPage | MPageFragment | null {
|
||||
const { node, page } = this.getNodeInfo(id, false);
|
||||
if (page) return page;
|
||||
if (node && (isPage(node) || isPageFragment(node))) return node as MPage | MPageFragment;
|
||||
if (node && isPageOrFragment(node)) return node as MPage | MPageFragment;
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@ -20,7 +20,7 @@ import { reactive } from 'vue';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import type { Writable } from 'type-fest';
|
||||
|
||||
import { type EventOption, type Id } from '@tmagic/core';
|
||||
import type { EventOption, Id, MNode } from '@tmagic/core';
|
||||
import { toLine } from '@tmagic/utils';
|
||||
|
||||
import type { AsyncHookPlugin, SyncHookPlugin } from '@editor/type';
|
||||
@ -56,7 +56,7 @@ class Events extends BaseService {
|
||||
eventMap[toLine(type)] = [...events];
|
||||
}
|
||||
|
||||
public getEvent(type: string): EventOption[] {
|
||||
public getEvent(type: string, _data: { node?: MNode | null } = {}): EventOption[] {
|
||||
return cloneDeep(eventMap[toLine(type)]) || [];
|
||||
}
|
||||
|
||||
@ -70,7 +70,7 @@ class Events extends BaseService {
|
||||
methodMap[toLine(type)] = [...method];
|
||||
}
|
||||
|
||||
public getMethod(type: string, _targetId: Id) {
|
||||
public getMethod(type: string, _data: { node?: MNode | null; targetId?: Id } = {}) {
|
||||
return cloneDeep(methodMap[toLine(type)]) || [];
|
||||
}
|
||||
|
||||
|
||||
@ -357,7 +357,7 @@ class History extends BaseService {
|
||||
* 派发「页面 / 页面片结构变更」事件(`page-structure-change`)。
|
||||
*
|
||||
* 常规 `editorService.add` / `remove` 页面节点不会写入 `page` 历史栈(见 editor.add / remove 中
|
||||
* 对 isPage / isPageFragment 的分支),因此不会产生任何 historyService 事件。该方法用于在这些
|
||||
* 对 isPageOrFragment 的分支),因此不会产生任何 historyService 事件。该方法用于在这些
|
||||
* 场景(以及 setRoot 整体替换 DSL 增删页面)下,向外统一通知页面结构的增删变化,供业务方感知。
|
||||
*
|
||||
* 一次操作涉及多个页面时,调用方应把本次增删的页面**合并为一个 change 一次性传入**,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import KeyController, { KeyControllerEvent } from 'keycon';
|
||||
|
||||
import { isPage, isPageFragment } from '@tmagic/utils';
|
||||
import { isPageOrFragment } from '@tmagic/utils';
|
||||
|
||||
import { KeyBindingCacheItem, KeyBindingCommand, KeyBindingItem } from '@editor/type';
|
||||
|
||||
@ -19,7 +19,7 @@ class Keybinding extends BaseService {
|
||||
[KeyBindingCommand.DELETE_NODE]: () => {
|
||||
const nodes = editorService.get('nodes');
|
||||
|
||||
if (!nodes || isPage(nodes[0]) || isPageFragment(nodes[0])) return;
|
||||
if (!nodes || isPageOrFragment(nodes[0])) return;
|
||||
editorService.remove(nodes, { historySource: 'shortcut' });
|
||||
},
|
||||
[KeyBindingCommand.COPY_NODE]: () => {
|
||||
@ -29,7 +29,7 @@ class Keybinding extends BaseService {
|
||||
[KeyBindingCommand.CUT_NODE]: () => {
|
||||
const nodes = editorService.get('nodes');
|
||||
|
||||
if (!nodes || isPage(nodes[0]) || isPageFragment(nodes[0])) return;
|
||||
if (!nodes || isPageOrFragment(nodes[0])) return;
|
||||
editorService.copy(nodes);
|
||||
editorService.remove(nodes, { historySource: 'shortcut' });
|
||||
},
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
}
|
||||
.el-card__body {
|
||||
.tmagic-design-form-item {
|
||||
margin-bottom: 18px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
.code-select-content {
|
||||
|
||||
@ -3,6 +3,12 @@
|
||||
.fullWidth {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
&.create-button {
|
||||
width: 100%;
|
||||
}
|
||||
&.m-container-ui-event {
|
||||
width: calc(100% - 32px);
|
||||
}
|
||||
}
|
||||
.event-select-container {
|
||||
padding: 0 16px;
|
||||
@ -76,17 +82,18 @@
|
||||
.event-item-header {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
.event-item-title {
|
||||
color: #0f1113;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.event-item-delete-button {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
color: #0f1113;
|
||||
}
|
||||
|
||||
.el-form-item {
|
||||
|
||||
@ -178,6 +178,11 @@ export interface StageOptions {
|
||||
containerHighlightClassName?: string;
|
||||
containerHighlightDuration?: number;
|
||||
containerHighlightType?: ContainerHighlightType;
|
||||
/**
|
||||
* 是否仅在新增组件(从组件列表拖入新组件)时才启用识别容器,
|
||||
* 开启后在画布中拖动已有组件不会识别容器,默认 false
|
||||
*/
|
||||
containerHighlightAddOnly?: boolean;
|
||||
disabledDragStart?: boolean;
|
||||
render?: (stage: StageCore) => HTMLDivElement | void | Promise<HTMLDivElement | void>;
|
||||
moveableOptions?: CustomizeMoveableOptions;
|
||||
|
||||
@ -2,7 +2,7 @@ import { computed, markRaw, type ShallowRef } from 'vue';
|
||||
import { CopyDocument, Delete, DocumentCopy } from '@element-plus/icons-vue';
|
||||
|
||||
import { cloneDeep, Id, MContainer, NodeType } from '@tmagic/core';
|
||||
import { calcValueByFontsize, isPage, isPageFragment } from '@tmagic/utils';
|
||||
import { calcValueByFontsize, isPage, isPageOrFragment } from '@tmagic/utils';
|
||||
|
||||
import ContentMenu from '@editor/components/ContentMenu.vue';
|
||||
import type { HistoryOpSource, MenuButton, Services } from '@editor/type';
|
||||
@ -20,7 +20,7 @@ export const useDeleteMenu = (historySource?: HistoryOpSource): MenuButton => ({
|
||||
icon: Delete,
|
||||
display: ({ editorService }) => {
|
||||
const node = editorService.get('node');
|
||||
return node?.type !== NodeType.ROOT && !isPage(node) && !isPageFragment(node);
|
||||
return node?.type !== NodeType.ROOT && !isPageOrFragment(node);
|
||||
},
|
||||
handler: ({ editorService }) => {
|
||||
const nodes = editorService.get('nodes');
|
||||
|
||||
215
packages/editor/src/utils/dep/collect-worker-client.ts
Normal file
215
packages/editor/src/utils/dep/collect-worker-client.ts
Normal file
@ -0,0 +1,215 @@
|
||||
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[];
|
||||
}
|
||||
|
||||
interface CollectTask {
|
||||
id: number;
|
||||
createRequest: (id: number) => CollectWorkerRequest;
|
||||
resolve: (response: CollectWorkerResponse | null) => void;
|
||||
/** 已被 abort 丢弃:调用方已按 null 结算,结果回来后不再回传 */
|
||||
discarded: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 依赖收集 worker 的主线程客户端
|
||||
*
|
||||
* 依赖收集需要深度遍历整棵节点树并对每个属性做匹配,节点/数据源多时会长时间占用主线程导致页面卡死,
|
||||
* 这里把遍历匹配放到 worker 中执行,主线程只负责把结果写回 target。
|
||||
*
|
||||
* 全量收集与增量收集共用一个常驻 worker:增量收集调用频繁(每次节点更新都会触发),
|
||||
* 按次创建 worker 的启动开销无法接受,也容易漏掉销毁导致线程泄漏。
|
||||
*
|
||||
* 请求在主线程侧排队后逐个投递(worker 本身也是串行处理),这样 abort 时能直接丢掉尚未投递的请求,
|
||||
* 无需销毁线程:worker 只在真正异常或销毁时才重建。
|
||||
*/
|
||||
export class CollectWorkerClient {
|
||||
private worker: Worker | null = null;
|
||||
|
||||
private seed = 0;
|
||||
|
||||
/** 等待投递给 worker 的请求 */
|
||||
private queue: CollectTask[] = [];
|
||||
|
||||
/** 已投递、等待 worker 响应的请求,worker 串行处理,同一时刻最多一个 */
|
||||
private inflight: CollectTask | null = null;
|
||||
|
||||
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.settleAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* 丢弃在途请求,但保留常驻 worker
|
||||
*
|
||||
* clearIdleTasks / reset 中断收集时调用。这里不能销毁 worker:worker 重建要重新加载并启动整份收集逻辑,
|
||||
* 而 clearIdleTasks / reset 触发频繁(root 更新、数据源变更、历史回滚等),一旦下一次 abort 落在
|
||||
* 上一个 worker 还没启动完的窗口内,就会陷入「加载中被销毁 → 重建 → 又被销毁」的循环,
|
||||
* 表现为 worker 反复加载且依赖始终收集不完。
|
||||
*
|
||||
* 因此 abort 只做「不要这些结果」:尚未投递的请求直接丢掉,已投递的那一个标记为丢弃,
|
||||
* 结果回来后忽略(不写回主线程),worker 继续复用。
|
||||
*/
|
||||
public abort() {
|
||||
const { queue } = this;
|
||||
this.queue = [];
|
||||
|
||||
for (const task of queue) {
|
||||
task.resolve(null);
|
||||
}
|
||||
|
||||
// 已投递的请求无法从外部打断(worker 忙时收不到取消消息),只能丢弃其结果
|
||||
if (this.inflight && !this.inflight.discarded) {
|
||||
this.inflight.discarded = true;
|
||||
this.inflight.resolve(null);
|
||||
}
|
||||
}
|
||||
|
||||
private request(createRequest: (id: number) => CollectWorkerRequest): Promise<CollectWorkerResponse | null> {
|
||||
if (!this.isSupported) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
this.seed += 1;
|
||||
const id = this.seed;
|
||||
|
||||
return new Promise<CollectWorkerResponse | null>((resolve) => {
|
||||
this.queue.push({ id, createRequest, resolve, discarded: false });
|
||||
this.flush();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 投递队首请求,worker 串行处理,因此同一时刻只投递一个,用 id 把结果分发回各自的调用方
|
||||
*/
|
||||
private flush() {
|
||||
if (this.inflight || !this.queue.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const worker = this.getWorker();
|
||||
|
||||
if (!worker) {
|
||||
this.settleAll();
|
||||
return;
|
||||
}
|
||||
|
||||
const task = this.queue.shift()!;
|
||||
this.inflight = task;
|
||||
|
||||
try {
|
||||
// 节点配置中可能存在函数等无法结构化克隆的值,需要先序列化再传递
|
||||
worker.postMessage(task.createRequest(task.id));
|
||||
} catch (e) {
|
||||
error('magic editor: 依赖收集 worker 通信失败', e);
|
||||
this.inflight = null;
|
||||
|
||||
if (!task.discarded) {
|
||||
task.resolve(null);
|
||||
}
|
||||
|
||||
this.flush();
|
||||
}
|
||||
}
|
||||
|
||||
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 task = this.inflight;
|
||||
|
||||
if (task?.id !== response?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.inflight = null;
|
||||
|
||||
// 被 abort 丢弃的请求已按 null 结算,结果不能再写回主线程
|
||||
if (!task.discarded) {
|
||||
task.resolve(response.failed ? null : response);
|
||||
}
|
||||
|
||||
this.flush();
|
||||
}
|
||||
|
||||
private handleFatalError() {
|
||||
this.worker?.terminate();
|
||||
this.worker = null;
|
||||
this.settleAll();
|
||||
}
|
||||
|
||||
private settleAll() {
|
||||
const tasks = this.queue;
|
||||
this.queue = [];
|
||||
|
||||
if (this.inflight) {
|
||||
tasks.push(this.inflight);
|
||||
this.inflight = null;
|
||||
}
|
||||
|
||||
for (const task of tasks) {
|
||||
if (!task.discarded) {
|
||||
task.resolve(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,7 @@
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
import { error } from '../logger';
|
||||
|
||||
export interface IdleTaskEvents {
|
||||
finish: [];
|
||||
'hight-level-finish': [];
|
||||
@ -11,6 +13,18 @@ type TaskList<T> = {
|
||||
data: T;
|
||||
}[];
|
||||
|
||||
/**
|
||||
* 回调因 timeout 触发(主线程一直没有空闲)时,单次回调执行任务的时间预算,单位 ms
|
||||
* 参考一帧内可让出的余量:既保证队列有进展,又不长时间阻塞主线程
|
||||
*/
|
||||
const TIMEOUT_RUN_BUDGET = 5;
|
||||
|
||||
/**
|
||||
* 回调因 timeout 触发时,每两次读取时钟之间执行的任务数
|
||||
* 与空闲时间 <=5ms 时的批量保持一致,避免每个任务都读一次时钟
|
||||
*/
|
||||
const TIMEOUT_BATCH_SIZE = 10;
|
||||
|
||||
globalThis.requestIdleCallback =
|
||||
globalThis.requestIdleCallback ||
|
||||
function (cb) {
|
||||
@ -25,6 +39,12 @@ globalThis.requestIdleCallback =
|
||||
}, 1);
|
||||
};
|
||||
|
||||
globalThis.cancelIdleCallback =
|
||||
globalThis.cancelIdleCallback ||
|
||||
function (handle) {
|
||||
clearTimeout(handle as unknown as ReturnType<typeof setTimeout>);
|
||||
};
|
||||
|
||||
export class IdleTask<T = any> extends EventEmitter {
|
||||
private taskList: TaskList<T> = [];
|
||||
|
||||
@ -58,7 +78,7 @@ export class IdleTask<T = any> extends EventEmitter {
|
||||
this.taskHandle = null;
|
||||
|
||||
this.emit('update-task-length', {
|
||||
length: this.taskList.length + this.hightLevelTaskList.length,
|
||||
length: this.getTaskLength(),
|
||||
hightLevelLength: this.hightLevelTaskList.length,
|
||||
});
|
||||
}
|
||||
@ -82,51 +102,100 @@ export class IdleTask<T = any> extends EventEmitter {
|
||||
}
|
||||
|
||||
private runTaskQueue(deadline: IdleDeadline) {
|
||||
const { hightLevelTaskList, taskList } = this;
|
||||
// 本次回调已触发,句柄随之失效;置空后 clearTasks / enqueueTask 才能正确判断是否需要重新调度
|
||||
this.taskHandle = null;
|
||||
|
||||
// 动画会占用空闲时间,当任务一直无法执行时,看看是否有动画正在播放
|
||||
// 根据空闲时间的多少来决定执行的任务数,保证页面不卡死的情况下尽量多执行任务,不然当任务数巨大时,执行时间会很久
|
||||
// 执行不完不会影响配置,但是会影响画布渲染
|
||||
while (deadline.timeRemaining() > 0 && (taskList.length || hightLevelTaskList.length)) {
|
||||
const timeRemaining = deadline.timeRemaining();
|
||||
let times = 0;
|
||||
if (timeRemaining <= 5) {
|
||||
times = 10;
|
||||
} else if (timeRemaining <= 10) {
|
||||
times = 100;
|
||||
} else if (timeRemaining <= 15) {
|
||||
times = 300;
|
||||
try {
|
||||
// 主线程一直没有空闲时,回调只会因 timeout 触发,此时 deadline.timeRemaining() 恒为 0(规范行为)。
|
||||
// 若仍以它作为循环条件,一个任务都执行不了,而 finishRun 又会接着重新调度,
|
||||
// 队列就会无限空转下去:依赖收集永远不结束,collecting 卡在 true,画布也不再更新。
|
||||
// 这种情况下改用自有时间预算推进,既保证有进展,又不会像放开循环那样长时间阻塞主线程。
|
||||
if (deadline.didTimeout) {
|
||||
const start = Date.now();
|
||||
do {
|
||||
this.runTaskBatch(TIMEOUT_BATCH_SIZE);
|
||||
} while (this.getTaskLength() && Date.now() - start < TIMEOUT_RUN_BUDGET);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 动画会占用空闲时间,当任务一直无法执行时,看看是否有动画正在播放
|
||||
// 根据空闲时间的多少来决定执行的任务数,保证页面不卡死的情况下尽量多执行任务,不然当任务数巨大时,执行时间会很久
|
||||
// 执行不完不会影响配置,但是会影响画布渲染
|
||||
while (deadline.timeRemaining() > 0 && this.getTaskLength()) {
|
||||
const timeRemaining = deadline.timeRemaining();
|
||||
let times = 0;
|
||||
if (timeRemaining <= 5) {
|
||||
times = 10;
|
||||
} else if (timeRemaining <= 10) {
|
||||
times = 100;
|
||||
} else if (timeRemaining <= 15) {
|
||||
times = 300;
|
||||
} else {
|
||||
times = 600;
|
||||
}
|
||||
|
||||
this.runTaskBatch(times);
|
||||
}
|
||||
} finally {
|
||||
// 必须放在 finally 中:一旦这里被跳过,taskHandle 会一直是真值,
|
||||
// enqueueTask 也就不会再重新调度,剩余任务与任务数将永久停在当前状态
|
||||
this.finishRun();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行一批任务,队列被清空时提前结束
|
||||
*/
|
||||
private runTaskBatch(times: number) {
|
||||
for (let i = 0; i < times; i++) {
|
||||
// 每次都从实例上取队列,任务执行过程中调用 clearTasks 能立即生效,不会继续消费已被清空的旧队列
|
||||
const task = this.hightLevelTaskList.length > 0 ? this.hightLevelTaskList.shift() : this.taskList.shift();
|
||||
if (task) {
|
||||
this.runTask(task);
|
||||
}
|
||||
|
||||
if (!this.getTaskLength()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个任务失败不能中断整个队列,否则后续任务永远不会被执行,
|
||||
* 依赖收集会停在半路(收集中状态与剩余任务数都不再变化)
|
||||
*/
|
||||
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 {
|
||||
times = 600;
|
||||
this.emit('finish');
|
||||
}
|
||||
|
||||
for (let i = 0; i < times; i++) {
|
||||
const task = hightLevelTaskList.length > 0 ? hightLevelTaskList.shift() : taskList.shift();
|
||||
if (task) {
|
||||
task.handler(task.data);
|
||||
}
|
||||
|
||||
if (hightLevelTaskList.length === 0 && taskList.length === 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.emit('update-task-length', {
|
||||
length: this.getTaskLength(),
|
||||
hightLevelLength: this.hightLevelTaskList.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!hightLevelTaskList.length) {
|
||||
this.emit('hight-level-finish');
|
||||
}
|
||||
|
||||
if (hightLevelTaskList.length || taskList.length) {
|
||||
this.taskHandle = globalThis.requestIdleCallback(this.runTaskQueue.bind(this), { timeout: 300 });
|
||||
} else {
|
||||
this.taskHandle = 0;
|
||||
|
||||
this.emit('finish');
|
||||
}
|
||||
|
||||
this.emit('update-task-length', {
|
||||
length: taskList.length + hightLevelTaskList.length,
|
||||
hightLevelLength: hightLevelTaskList.length,
|
||||
});
|
||||
private getTaskLength() {
|
||||
return this.taskList.length + this.hightLevelTaskList.length;
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,30 +3,85 @@ import {
|
||||
createDataSourceCondTarget,
|
||||
createDataSourceMethodTarget,
|
||||
createDataSourceTarget,
|
||||
createTargetByDescriptor,
|
||||
type DepData,
|
||||
type DepExtendedData,
|
||||
DepTargetType,
|
||||
type Id,
|
||||
type MApp,
|
||||
type TargetDescriptor,
|
||||
type TargetNode,
|
||||
traverseTarget,
|
||||
Watcher,
|
||||
} from '@tmagic/core';
|
||||
|
||||
import { error } from '../logger';
|
||||
|
||||
onmessage = (e) => {
|
||||
const watcher = new Watcher({ initialTargets: {} });
|
||||
export type DepsData = Record<string, Record<Id, DepData>>;
|
||||
|
||||
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 {
|
||||
// eslint-disable-next-line no-eval
|
||||
const mApp: MApp = eval(`(${dsl})`);
|
||||
|
||||
if (!mApp) {
|
||||
postMessage({});
|
||||
postMessage({ id, deps: {} });
|
||||
return;
|
||||
}
|
||||
|
||||
watcher.clearTargets();
|
||||
const watcher = new Watcher({ initialTargets: {} });
|
||||
|
||||
if (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);
|
||||
}
|
||||
|
||||
const data: Record<string, Record<Id, DepData>> = {
|
||||
const deps: DepsData = {
|
||||
[DepTargetType.DATA_SOURCE]: {},
|
||||
[DepTargetType.DATA_SOURCE_METHOD]: {},
|
||||
[DepTargetType.DATA_SOURCE_COND]: {},
|
||||
@ -56,12 +111,61 @@ onmessage = (e) => {
|
||||
};
|
||||
|
||||
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) {
|
||||
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);
|
||||
};
|
||||
|
||||
@ -56,7 +56,8 @@ export const getEventNameOptions = (
|
||||
}
|
||||
|
||||
if (src === 'component') {
|
||||
let events: EventOption[] | CascaderOption[] = eventsService.getEvent(formValue.type) || [];
|
||||
const sourceNode = editorService.getNodeById(formValue.id);
|
||||
let events: EventOption[] | CascaderOption[] = eventsService.getEvent(formValue.type, { node: sourceNode }) || [];
|
||||
|
||||
if (formValue.type === 'page-fragment-container' && formValue.pageFragmentId) {
|
||||
const pageFragment = editorService.get('root')?.items?.find((page) => page.id === formValue.pageFragmentId);
|
||||
@ -72,9 +73,14 @@ export const getEventNameOptions = (
|
||||
},
|
||||
];
|
||||
|
||||
(pageFragment.items || []).forEach((node) => {
|
||||
traverseNode<MComponent | MContainer>(node, (current) => {
|
||||
const nodeEvents = (current.type && eventsService.getEvent(current.type)) || [];
|
||||
(pageFragment.items || []).forEach((item) => {
|
||||
traverseNode<MComponent | MContainer>(item, (current) => {
|
||||
if (!current.type) {
|
||||
return;
|
||||
}
|
||||
|
||||
const node = editorService.getNodeById(current.id) || current;
|
||||
const nodeEvents = eventsService.getEvent(current.type, { node }) || [];
|
||||
(events as CascaderOption[]).push({
|
||||
label: `${current.name}_${current.id}`,
|
||||
value: `${current.id}`,
|
||||
@ -176,7 +182,7 @@ export const getCompActionOptions = (toId?: Id): EventNameOption[] => {
|
||||
return [];
|
||||
}
|
||||
|
||||
let methods: EventOption[] | CascaderOption[] = eventsService.getMethod(node.type, toId) || [];
|
||||
let methods: EventOption[] | CascaderOption[] = eventsService.getMethod(node.type, { targetId: toId, node }) || [];
|
||||
|
||||
if (node.type === 'page-fragment-container' && node.pageFragmentId) {
|
||||
const pageFragment = editorService.get('root')?.items?.find((page) => page.id === node.pageFragmentId);
|
||||
@ -187,15 +193,22 @@ export const getCompActionOptions = (toId?: Id): EventNameOption[] => {
|
||||
methods = [];
|
||||
(pageFragment.items || []).forEach((item: MComponent | MContainer) => {
|
||||
traverseNode<MComponent | MContainer>(item, (current) => {
|
||||
const nodeMethods = (current.type && eventsService.getMethod(current.type, current.id)) || [];
|
||||
const node = editorService.getNodeById(current.id) || current;
|
||||
|
||||
if (nodeMethods.length) {
|
||||
(methods as CascaderOption[]).push({
|
||||
label: `${current.name}_${current.id}`,
|
||||
value: `${current.id}`,
|
||||
children: nodeMethods,
|
||||
});
|
||||
if (!current.type) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeMethods = eventsService.getMethod(current.type, { targetId: current.id, node }) || [];
|
||||
if (!nodeMethods.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
(methods as CascaderOption[]).push({
|
||||
label: `${current.name}_${current.id}`,
|
||||
value: `${current.id}`,
|
||||
children: nodeMethods,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@ import { isEmpty } from 'lodash-es';
|
||||
|
||||
import type { Id, MContainer, MNode } from '@tmagic/core';
|
||||
import { NodeType } from '@tmagic/core';
|
||||
import { calcValueByFontsize, getElById, isPage, isPageFragment } from '@tmagic/utils';
|
||||
import { calcValueByFontsize, getElById, isPage, isPageOrFragment } from '@tmagic/utils';
|
||||
|
||||
import editorService from '@editor/services/editor';
|
||||
import propsService from '@editor/services/props';
|
||||
@ -58,7 +58,7 @@ export const beforePaste = (position: PastePosition, config: MNode[], doc?: Docu
|
||||
};
|
||||
}
|
||||
const root = editorService.get('root');
|
||||
if ((isPage(pasteConfig) || isPageFragment(pasteConfig)) && root) {
|
||||
if (isPageOrFragment(pasteConfig) && root) {
|
||||
pasteConfig.name = generatePageNameByApp(root, isPage(pasteConfig) ? NodeType.PAGE : NodeType.PAGE_FRAGMENT);
|
||||
}
|
||||
return pasteConfig as MNode;
|
||||
|
||||
@ -16,9 +16,6 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { AppContext } from 'vue';
|
||||
|
||||
import {
|
||||
HookType,
|
||||
NODE_CONDS_KEY,
|
||||
@ -27,18 +24,7 @@ import {
|
||||
NODE_DISABLE_DATA_SOURCE_KEY,
|
||||
} from '@tmagic/core';
|
||||
import { tMagicMessage } from '@tmagic/design';
|
||||
import type {
|
||||
ChildConfig,
|
||||
DisplayCondsConfig,
|
||||
FormConfig,
|
||||
FormState,
|
||||
FormValue,
|
||||
TabConfig,
|
||||
TabPaneConfig,
|
||||
} from '@tmagic/form';
|
||||
import { validateForm } from '@tmagic/form';
|
||||
|
||||
import type { Services } from '@editor/type';
|
||||
import type { ChildConfig, DisplayCondsConfig, FormConfig, TabConfig, TabPaneConfig } from '@tmagic/form';
|
||||
|
||||
export const arrayOptions = [
|
||||
{ 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,
|
||||
}),
|
||||
});
|
||||
|
||||
@ -20,7 +20,7 @@ import type { DataSourceFieldType, DataSourceSchema, Id } from '@tmagic/core';
|
||||
import { NodeType } from '@tmagic/core';
|
||||
import { appendValidateSuggestion } from '@tmagic/design';
|
||||
import type { TypeMatchValidateContext, TypeMatchValidator } from '@tmagic/form';
|
||||
import { validateTypeMatch } from '@tmagic/form';
|
||||
import { MAX_SUGGESTION_OPTIONS, optionSuggestion, stringifyExampleValue, validateTypeMatch } from '@tmagic/form';
|
||||
import {
|
||||
DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX,
|
||||
DATA_SOURCE_SET_DATA_METHOD_NAME,
|
||||
@ -41,35 +41,9 @@ import {
|
||||
numberOptions,
|
||||
} from '@editor/utils/props';
|
||||
|
||||
/**
|
||||
* 将值格式化为可读的参考示例字符串。
|
||||
*/
|
||||
const stringifyExampleValue = (value: any): string => {
|
||||
if (typeof value === 'string') return `"${value}"`;
|
||||
if (value === null || value === undefined) return String(value);
|
||||
if (typeof value === 'object') {
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
return String(value);
|
||||
};
|
||||
|
||||
// 参考建议中最多展示的可选值个数,超出以「等」省略。
|
||||
const MAX_SUGGESTION_OPTIONS = 5;
|
||||
|
||||
/**
|
||||
* 生成「请使用以下某一个值:xxx;xxx」形式的参考建议;无可选值时返回空字符串(不追加建议)。
|
||||
*/
|
||||
const listSuggestion = (values: any[]): string => {
|
||||
const list = values.filter((item) => typeof item !== 'undefined' && item !== null && item !== '');
|
||||
if (!list.length) return '';
|
||||
const shown = list.slice(0, MAX_SUGGESTION_OPTIONS).map(stringifyExampleValue);
|
||||
const suffix = list.length > MAX_SUGGESTION_OPTIONS ? ' 等' : '';
|
||||
return `请使用以下某一个值:${shown.join(';')}${suffix}`;
|
||||
};
|
||||
/** 复用 form 的可选项建议文案;额外过滤 null / 空串,避免业务侧无效枚举污染提示。 */
|
||||
const listSuggestion = (values: any[]): string =>
|
||||
optionSuggestion(values.filter((item) => item !== null && item !== ''));
|
||||
|
||||
/**
|
||||
* 列出当前可用数据源 id 的参考建议。
|
||||
@ -476,12 +450,42 @@ const isDataSourceFieldPathValue = (value: any, config: any): value is string[]
|
||||
return `${value[0]}`.startsWith(DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX);
|
||||
};
|
||||
|
||||
const validateDataSourceFieldSelect: TypeMatchValidator = (value, { mForm, message, props }) => {
|
||||
export type ValidateDataSourceFieldSelectOptions = {
|
||||
/**
|
||||
* 覆盖「非数据源字段路径」时的校验。
|
||||
* 不传则按 fieldConfig 走内置 typeMatch(与表单项自身 typeMatch 行为一致)。
|
||||
*/
|
||||
validatePlainValue?: (
|
||||
value: any,
|
||||
context: TypeMatchValidateContext,
|
||||
) => string | undefined | Promise<string | undefined>;
|
||||
};
|
||||
|
||||
/**
|
||||
* data-source-field-select 的 typeMatch 校验逻辑,可供自定义 rules.validator 复用。
|
||||
*/
|
||||
export const validateDataSourceFieldSelectValue = (
|
||||
value: any,
|
||||
context: TypeMatchValidateContext,
|
||||
options?: ValidateDataSourceFieldSelectOptions,
|
||||
): string | undefined | Promise<string | undefined> => {
|
||||
const { mForm, message, props } = context;
|
||||
const config = props.config || {};
|
||||
|
||||
if (config.fieldConfig && !isDataSourceFieldPathValue(value, config)) {
|
||||
// 值不是数据源字段路径时,按 fieldConfig 的类型校验(与表单项自身 typeMatch 行为一致)
|
||||
return validateTypeMatch(value, mForm, { ...props, config: { name: config.name, ...config.fieldConfig } }, message);
|
||||
if (!isDataSourceFieldPathValue(value, config)) {
|
||||
if (options?.validatePlainValue) {
|
||||
return options.validatePlainValue(value, context);
|
||||
}
|
||||
|
||||
if (config.fieldConfig) {
|
||||
// 值不是数据源字段路径时,按 fieldConfig 的类型校验(与表单项自身 typeMatch 行为一致)
|
||||
return validateTypeMatch(
|
||||
value,
|
||||
mForm,
|
||||
{ ...props, config: { name: config.name, ...config.fieldConfig } },
|
||||
message,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {
|
||||
@ -496,6 +500,9 @@ const validateDataSourceFieldSelect: TypeMatchValidator = (value, { mForm, messa
|
||||
});
|
||||
};
|
||||
|
||||
const validateDataSourceFieldSelect: TypeMatchValidator = (value, context) =>
|
||||
validateDataSourceFieldSelectValue(value, context);
|
||||
|
||||
const validateDataSourceSelect: TypeMatchValidator = (value, { message, props }) => {
|
||||
const config = props.config || {};
|
||||
const dataSources = getDataSources();
|
||||
|
||||
@ -21,6 +21,7 @@ describe('defaultEditorProps', () => {
|
||||
test('containerHighlight 默认值', () => {
|
||||
expect(defaultEditorProps.containerHighlightDuration).toBe(800);
|
||||
expect(typeof defaultEditorProps.containerHighlightClassName).toBe('string');
|
||||
expect(defaultEditorProps.containerHighlightAddOnly).toBe(false);
|
||||
});
|
||||
|
||||
test('数组/对象工厂函数返回空值', () => {
|
||||
|
||||
@ -24,11 +24,6 @@ vi.mock('@editor/hooks/use-services', () => ({
|
||||
useServices: () => ({ codeBlockService, uiService }),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/type', async () => {
|
||||
const actual = await vi.importActual<any>('@editor/type');
|
||||
return { ...actual, SideItemKey: { CODE_BLOCK: 'code-block' } };
|
||||
});
|
||||
|
||||
vi.mock('@tmagic/form', async (importOriginal) => {
|
||||
const actual = await importOriginal<any>();
|
||||
return {
|
||||
|
||||
@ -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(() => {
|
||||
vi.clearAllMocks();
|
||||
inputRef.input = null;
|
||||
|
||||
@ -33,11 +33,6 @@ vi.mock('@editor/utils', async () => {
|
||||
return { ...actual, getFieldType: vi.fn(() => 'string') };
|
||||
});
|
||||
|
||||
vi.mock('@editor/type', async () => {
|
||||
const actual = await vi.importActual<any>('@editor/type');
|
||||
return { ...actual, SideItemKey: { DATA_SOURCE: 'data-source' } };
|
||||
});
|
||||
|
||||
vi.mock('@tmagic/utils', async () => {
|
||||
const actual = await vi.importActual<any>('@tmagic/utils');
|
||||
return { ...actual, DATA_SOURCE_SET_DATA_METHOD_NAME: '__set_data__' };
|
||||
|
||||
@ -21,11 +21,6 @@ vi.mock('@editor/hooks/use-services', () => ({
|
||||
useServices: () => ({ dataSourceService, uiService }),
|
||||
}));
|
||||
|
||||
vi.mock('@editor/type', async () => {
|
||||
const actual = await vi.importActual<any>('@editor/type');
|
||||
return { ...actual, SideItemKey: { DATA_SOURCE: 'data-source' } };
|
||||
});
|
||||
|
||||
vi.mock('@tmagic/form', async (importOriginal) => {
|
||||
const actual = await importOriginal<any>();
|
||||
return {
|
||||
|
||||
@ -96,10 +96,6 @@ vi.mock('@tmagic/utils', async () => {
|
||||
return {
|
||||
...actual,
|
||||
DATA_SOURCE_FIELDS_CHANGE_EVENT_PREFIX: 'ds_change_',
|
||||
traverseNode: (node: any, fn: any) => {
|
||||
fn(node);
|
||||
node.items?.forEach((c: any) => fn(c));
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@ -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', () => {
|
||||
test('model[name] 不为空时显示编辑图标', () => {
|
||||
editorService.get.mockReturnValue({ items: [{ id: 'p1', type: 'page-fragment', name: 'A' }] });
|
||||
|
||||
@ -15,6 +15,36 @@ describe('StyleSetter Box', () => {
|
||||
expect(wrapper.findAll('input').length).toBe(8);
|
||||
});
|
||||
|
||||
test('根据 model 显示 margin/padding 值', () => {
|
||||
const wrapper = mount(Box, {
|
||||
props: {
|
||||
model: {
|
||||
marginTop: '10',
|
||||
marginRight: '20',
|
||||
marginBottom: '30',
|
||||
marginLeft: '40',
|
||||
paddingTop: '1',
|
||||
paddingRight: '2',
|
||||
paddingBottom: '3',
|
||||
paddingLeft: '4',
|
||||
},
|
||||
},
|
||||
});
|
||||
const values = wrapper.findAll('input').map((i) => (i.element as HTMLInputElement).value);
|
||||
expect(values).toEqual(['10', '20', '30', '40', '1', '2', '3', '4']);
|
||||
});
|
||||
|
||||
test('model 变更时输入框值同步更新', async () => {
|
||||
const wrapper = mount(Box, {
|
||||
props: { model: { marginTop: '10', marginRight: '20' } },
|
||||
});
|
||||
expect((wrapper.findAll('input')[0].element as HTMLInputElement).value).toBe('10');
|
||||
|
||||
await wrapper.setProps({ model: { marginTop: '50', marginRight: '60' } });
|
||||
expect((wrapper.findAll('input')[0].element as HTMLInputElement).value).toBe('50');
|
||||
expect((wrapper.findAll('input')[1].element as HTMLInputElement).value).toBe('60');
|
||||
});
|
||||
|
||||
test('change 事件携带值与对应字段名', async () => {
|
||||
const wrapper = mount(Box, { props: { model: { marginTop: '10' } } });
|
||||
const input = wrapper.findAll('input')[0];
|
||||
@ -40,6 +70,13 @@ describe('StyleSetter Position', () => {
|
||||
expect(wrapper.findAll('input').length).toBe(4);
|
||||
});
|
||||
|
||||
test('model 变更时输入框值同步更新', async () => {
|
||||
const wrapper = mount(Position, { props: { model: { top: '0', right: '10' } } });
|
||||
await wrapper.setProps({ model: { top: '20', right: '30' } });
|
||||
expect((wrapper.findAll('input')[0].element as HTMLInputElement).value).toBe('20');
|
||||
expect((wrapper.findAll('input')[1].element as HTMLInputElement).value).toBe('30');
|
||||
});
|
||||
|
||||
test('change 事件触发并携带 modifyKey', async () => {
|
||||
const wrapper = mount(Position, { props: { model: { top: '0' } } });
|
||||
const input = wrapper.findAll('input')[1];
|
||||
|
||||
@ -27,11 +27,6 @@ vi.mock('@editor/hooks/use-services', () => ({
|
||||
useServices: () => ({ editorService, uiService, stageOverlayService }),
|
||||
}));
|
||||
|
||||
vi.mock('@tmagic/utils', async () => {
|
||||
const actual = await vi.importActual<any>('@tmagic/utils');
|
||||
return { ...actual, getIdFromEl: () => (el: any) => el?.dataset?.tmagicId };
|
||||
});
|
||||
|
||||
vi.mock('@tmagic/design', () => ({
|
||||
TMagicButton: defineComponent({
|
||||
name: 'TMagicButton',
|
||||
|
||||
@ -91,7 +91,7 @@ const mkServices = () => {
|
||||
clear: vi.fn(),
|
||||
clearTargets: vi.fn(),
|
||||
clearIdleTasks: vi.fn(),
|
||||
collectIdle: vi.fn(async () => undefined),
|
||||
collectIdle: vi.fn(async () => true),
|
||||
collectByWorker: vi.fn(async () => undefined),
|
||||
reset: vi.fn(),
|
||||
};
|
||||
@ -144,8 +144,6 @@ vi.mock('@tmagic/utils', async () => {
|
||||
...actual,
|
||||
getDepNodeIds: vi.fn(() => []),
|
||||
getNodes: vi.fn(() => []),
|
||||
isPage: vi.fn((n: any) => n?.type === 'page'),
|
||||
isValueIncludeDataSource: vi.fn((v: any) => /\$\{/.test(String(v))),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@ -8,14 +8,6 @@ import { mount } from '@vue/test-utils';
|
||||
|
||||
import AddPageBox from '@editor/layouts/AddPageBox.vue';
|
||||
|
||||
vi.mock('@tmagic/core', async () => {
|
||||
const actual = await vi.importActual<any>('@tmagic/core');
|
||||
return {
|
||||
...actual,
|
||||
NodeType: { PAGE: 'page', PAGE_FRAGMENT: 'page-fragment' },
|
||||
};
|
||||
});
|
||||
|
||||
const editorService = {
|
||||
get: vi.fn((key: string) => (key === 'root' ? { items: [] } : undefined)),
|
||||
add: vi.fn(),
|
||||
|
||||
@ -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 {
|
||||
cb: any;
|
||||
constructor(cb: any) {
|
||||
|
||||
@ -92,10 +92,15 @@ vi.mock('@editor/layouts/props-panel/FormPanel.vue', () => ({
|
||||
onClick: () =>
|
||||
emit('submit', { id: 'n1', style: { color: 'red' } }, { changeRecords: [] }, new Error('校验失败详情')),
|
||||
}),
|
||||
// 模拟 CodeEditor 源码保存:仅传 values,无 eventData、无 error(对应 saveCode 路径)
|
||||
// 模拟属性面板 CodeEditor 源码保存:完整节点,无 eventData(对应 saveCode 路径)
|
||||
h('button', {
|
||||
class: 'code-save-btn',
|
||||
onClick: () => emit('submit', { id: 'n1', style: { color: 'red', width: '' } }),
|
||||
onClick: () => emit('submit', { id: 'n1', type: 'text', style: { color: 'red', width: '' } }),
|
||||
}),
|
||||
// 模拟样式面板 CodeEditor 源码保存:仅 { style }(FormPanel codeValueKey=style)
|
||||
h('button', {
|
||||
class: 'style-code-save-btn',
|
||||
onClick: () => emit('submit', { style: { color: 'blue', width: '' } }),
|
||||
}),
|
||||
h('button', { class: 'submit-err-btn', onClick: () => emit('submit-error', new Error('e')) }),
|
||||
h('button', { class: 'form-err-btn', onClick: () => emit('form-error', new Error('e')) }),
|
||||
@ -140,7 +145,7 @@ beforeEach(() => {
|
||||
return null;
|
||||
});
|
||||
editorService.get.mockImplementation((k: string) => {
|
||||
if (k === 'node') return { id: 'n1', type: 'text' };
|
||||
if (k === 'node') return { id: 'n1', type: 'text', text: 'hello', style: { width: 100, color: 'old' } };
|
||||
if (k === 'nodes') return [{ id: 'n1' }];
|
||||
return null;
|
||||
});
|
||||
@ -237,6 +242,8 @@ describe('PropsPanel', () => {
|
||||
expect(options.invalidInfo).toBeUndefined();
|
||||
// historySource 应为 code
|
||||
expect(options.historySource).toBe('code');
|
||||
// 属性面板源码保存应整节点替换,避免 merge 保留已删除字段
|
||||
expect(options.replace).toBe(true);
|
||||
});
|
||||
|
||||
test('CodeEditor 源码保存时 style 中的空字符串值被保留(表示清除该样式)', async () => {
|
||||
@ -249,6 +256,36 @@ describe('PropsPanel', () => {
|
||||
expect(calledNode.style.width).toBe('');
|
||||
});
|
||||
|
||||
test('样式面板源码保存:将新 style 替换到原节点后整节点 replace', async () => {
|
||||
showStylePanel.value = true;
|
||||
const wrapper = mount(PropsPanel, { props: {} as any });
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
// 第二个 FormPanel 为样式面板
|
||||
const styleCodeBtns = wrapper.findAll('.style-code-save-btn');
|
||||
expect(styleCodeBtns.length).toBe(2);
|
||||
await styleCodeBtns[1].trigger('click');
|
||||
|
||||
const [calledNode, options] = editorService.update.mock.calls[0] as any;
|
||||
expect(options.replace).toBe(true);
|
||||
expect(options.historySource).toBe('code');
|
||||
// 原节点其它字段保留
|
||||
expect(calledNode.id).toBe('n1');
|
||||
expect(calledNode.type).toBe('text');
|
||||
expect(calledNode.text).toBe('hello');
|
||||
// style 被源码内容整段替换(旧 width:100 / color:old 不再 merge 保留)
|
||||
expect(calledNode.style).toEqual({ color: 'blue', width: '' });
|
||||
});
|
||||
|
||||
test('表单字段编辑不使用 replace', async () => {
|
||||
const wrapper = mount(PropsPanel, { props: {} as any });
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await wrapper.find('.submit-btn').trigger('click');
|
||||
|
||||
const options = (editorService.update.mock.calls[0] as any)[1];
|
||||
expect(options.replace).toBe(false);
|
||||
});
|
||||
|
||||
test('mounted 事件 emit', async () => {
|
||||
const wrapper = mount(PropsPanel, { props: {} as any });
|
||||
await wrapper.find('.mounted-btn').trigger('click');
|
||||
|
||||
@ -135,6 +135,6 @@ describe('ComponentListPanel', () => {
|
||||
const item = wrapper.find('.component-item');
|
||||
await item.trigger('drag', { clientX: 0, clientY: 0 });
|
||||
await item.trigger('drag', { clientX: 0, clientY: 0 });
|
||||
expect(stage.delayedMarkContainer).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(() => {
|
||||
vi.clearAllMocks();
|
||||
propsService.getDisabledDataSource.mockReturnValue(false);
|
||||
|
||||
@ -35,11 +35,6 @@ vi.mock('@editor/hooks/use-filter', () => ({
|
||||
useFilter: () => ({ filterTextChangeHandler: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('@tmagic/core', async () => {
|
||||
const actual = await vi.importActual<any>('@tmagic/core');
|
||||
return { ...actual, DepTargetType: { CODE_BLOCK: 'code-block' } };
|
||||
});
|
||||
|
||||
const { messageBoxConfirm, messageError } = vi.hoisted(() => ({
|
||||
messageBoxConfirm: vi.fn(async () => 'confirm'),
|
||||
messageError: vi.fn(),
|
||||
|
||||
@ -32,18 +32,6 @@ vi.mock('@editor/hooks/use-filter', () => ({
|
||||
useFilter: () => ({ filterTextChangeHandler: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('@tmagic/core', async () => {
|
||||
const actual = await vi.importActual<any>('@tmagic/core');
|
||||
return {
|
||||
...actual,
|
||||
DepTargetType: {
|
||||
DATA_SOURCE: 'data-source',
|
||||
DATA_SOURCE_METHOD: 'data-source-method',
|
||||
DATA_SOURCE_COND: 'data-source-cond',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@editor/components/Tree.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'TreeStub',
|
||||
|
||||
@ -28,15 +28,6 @@ vi.mock('@editor/utils/content-menu', () => ({
|
||||
useMoveToMenu: () => ({ type: 'button', text: 'moveto' }),
|
||||
}));
|
||||
|
||||
vi.mock('@tmagic/utils', async () => {
|
||||
const actual = await vi.importActual<any>('@tmagic/utils');
|
||||
return {
|
||||
...actual,
|
||||
isPage: (n: any) => n?.type === 'page',
|
||||
isPageFragment: (n: any) => n?.type === 'page-fragment',
|
||||
};
|
||||
});
|
||||
|
||||
const showMock = vi.fn();
|
||||
vi.mock('@editor/components/ContentMenu.vue', () => ({
|
||||
default: defineComponent({
|
||||
|
||||
@ -17,8 +17,6 @@ vi.mock('@tmagic/utils', async () => {
|
||||
const actual = await vi.importActual<any>('@tmagic/utils');
|
||||
return {
|
||||
...actual,
|
||||
isPage: (n: any) => n.type === 'page',
|
||||
isPageFragment: (n: any) => n.type === 'page-fragment',
|
||||
getElById: () => (_doc: any, id: any) => (id === 'no-el' ? null : { id }),
|
||||
};
|
||||
});
|
||||
|
||||
@ -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 = () => ({
|
||||
getNodeInfo: vi.fn(),
|
||||
get: vi.fn(() => []),
|
||||
|
||||
@ -12,13 +12,7 @@ vi.mock('@tmagic/utils', async () => {
|
||||
const actual = await vi.importActual<any>('@tmagic/utils');
|
||||
return {
|
||||
...actual,
|
||||
isPage: (n: any) => n.type === 'page',
|
||||
isPageFragment: (n: any) => n.type === 'page-fragment',
|
||||
getNodePath: vi.fn(() => []),
|
||||
traverseNode: (node: any, fn: any) => {
|
||||
fn(node);
|
||||
node.items?.forEach((c: any) => fn(c));
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@ -24,11 +24,6 @@ vi.mock('@editor/hooks/use-services', () => ({
|
||||
useServices: () => ({ editorService }),
|
||||
}));
|
||||
|
||||
vi.mock('@tmagic/utils', () => ({
|
||||
isPage: (n: any) => n?.type === 'page',
|
||||
isPageFragment: (n: any) => n?.type === 'page-fragment',
|
||||
}));
|
||||
|
||||
vi.mock('@editor/utils/content-menu', () => ({
|
||||
useCopyMenu: () => ({ type: 'button', text: '复制', handler: vi.fn() }),
|
||||
usePasteMenu: () => ({ type: 'button', text: '粘贴', handler: vi.fn() }),
|
||||
|
||||
@ -4,26 +4,57 @@
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
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';
|
||||
|
||||
// 全量收集({ id, dsl })与增量收集({ id, payload })共用一个常驻 worker
|
||||
vi.mock('@editor/utils/dep/worker.ts?worker&inline', () => ({
|
||||
default: class FakeWorker {
|
||||
public static instances: FakeWorker[] = [];
|
||||
/** 全量收集返回的 deps */
|
||||
public static nextData: Record<string, any> = {};
|
||||
public static nextError = false;
|
||||
public static nextDelay = 0;
|
||||
/** 收到的全部请求(全量 + 增量),用于断言测例真正走到了 worker */
|
||||
public static requests: any[] = [];
|
||||
/** 增量收集返回的数据 */
|
||||
public static response: any = { deps: {}, nodeIds: [] };
|
||||
public static failed = false;
|
||||
public static delay = 0;
|
||||
|
||||
public onmessage: ((e: any) => void) | null = null;
|
||||
public onerror: (() => void) | null = null;
|
||||
public postMessage() {
|
||||
public onmessageerror: (() => void) | null = null;
|
||||
|
||||
constructor() {
|
||||
FakeWorker.instances.push(this);
|
||||
}
|
||||
|
||||
public postMessage(request: any) {
|
||||
FakeWorker.requests.push(request);
|
||||
|
||||
if (!('dsl' in request)) {
|
||||
setTimeout(() => {
|
||||
this.onmessage?.({
|
||||
data: { id: request.id, failed: FakeWorker.failed, ...FakeWorker.response },
|
||||
});
|
||||
}, FakeWorker.delay);
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (FakeWorker.nextError) {
|
||||
this.onerror?.(new Event('error'));
|
||||
this.onerror?.();
|
||||
return;
|
||||
}
|
||||
this.onmessage?.({ data: FakeWorker.nextData });
|
||||
}, 0);
|
||||
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,
|
||||
});
|
||||
|
||||
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();
|
||||
// collectByWorker / collectIdle 共用常驻 CollectWorkerClient,happy-dom 默认没有 Worker
|
||||
await enableCollectWorker();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete (globalThis as any).Worker;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
@ -121,17 +172,20 @@ describe('Dep service', () => {
|
||||
test('collectIdle - 没有命中时立即 resolve 并 emit collected', async () => {
|
||||
const fn = vi.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();
|
||||
depService.off('collected', fn);
|
||||
});
|
||||
|
||||
test('collectByWorker 完成后触发 collected 与 ds-collected', async () => {
|
||||
const fakeWorker = await enableCollectWorker();
|
||||
const fn = vi.fn();
|
||||
const dsFn = vi.fn();
|
||||
depService.on('collected', fn);
|
||||
depService.on('ds-collected', dsFn);
|
||||
await depService.collectByWorker({ items: [], id: 'app', type: 'app' } as any);
|
||||
// 必须真正 postMessage 到 worker,避免 isSupported=false 时假通过
|
||||
expect(fakeWorker.requests.some((request: any) => 'dsl' in request)).toBe(true);
|
||||
expect(fn).toHaveBeenCalled();
|
||||
expect(dsFn).toHaveBeenCalled();
|
||||
depService.off('collected', fn);
|
||||
@ -182,20 +236,19 @@ describe('Dep service', () => {
|
||||
});
|
||||
|
||||
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.nextData = {};
|
||||
const collected = vi.fn();
|
||||
depService.on('collected', collected);
|
||||
const result = await depService.collectByWorker({ items: [], id: 'app', type: 'app' } as any);
|
||||
expect(fakeWorker.requests.some((request: any) => 'dsl' in request)).toBe(true);
|
||||
expect(result).toEqual({});
|
||||
expect(collected).toHaveBeenCalled();
|
||||
fakeWorker.nextError = false;
|
||||
depService.off('collected', collected);
|
||||
});
|
||||
|
||||
test('collectByWorker 会把 worker 返回的 deps 写回 target 与 dsl', async () => {
|
||||
const fakeWorker = (await 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('cond1', DepTargetType.DATA_SOURCE_COND));
|
||||
depService.addTarget(makeTarget('method1', DepTargetType.DATA_SOURCE_METHOD));
|
||||
@ -213,12 +266,391 @@ describe('Dep service', () => {
|
||||
dataSourceMethodDeps: {},
|
||||
};
|
||||
await depService.collectByWorker(dsl);
|
||||
expect(fakeWorker.requests.some((request: any) => 'dsl' in request)).toBe(true);
|
||||
expect(dsl.dataSourceDeps.ds1).toBeDefined();
|
||||
expect(dsl.dataSourceCondDeps.cond1).toBeDefined();
|
||||
expect(dsl.dataSourceMethodDeps.method1).toBeDefined();
|
||||
});
|
||||
|
||||
test('collectIdle 命中 target 时最终 resolve 并按批次 emit collected/ds-collected', async () => {
|
||||
depService.addTarget(makeTarget('ds1', DepTargetType.DATA_SOURCE));
|
||||
const collected = vi.fn();
|
||||
const dsCollected = vi.fn();
|
||||
depService.on('collected', collected);
|
||||
depService.on('ds-collected', dsCollected);
|
||||
|
||||
const nodes = [{ id: 'n1', type: 'text' }] as any;
|
||||
await expect(depService.collectIdle(nodes, {}, false, DepTargetType.DATA_SOURCE)).resolves.toBe(true);
|
||||
|
||||
expect(dsCollected).toHaveBeenCalledWith(nodes, false);
|
||||
expect(collected).toHaveBeenCalledWith(nodes, false);
|
||||
expect(depService.get('collecting')).toBe(false);
|
||||
|
||||
depService.off('collected', collected);
|
||||
depService.off('ds-collected', dsCollected);
|
||||
});
|
||||
|
||||
test('clearIdleTasks 会结算在途 collectIdle,避免 Promise 永久挂起且 collecting 复位', async () => {
|
||||
depService.addTarget(makeTarget('ds1', DepTargetType.DATA_SOURCE));
|
||||
|
||||
const promise = depService.collectIdle([{ id: 'n1', type: 'text' }] as any, {}, false, DepTargetType.DATA_SOURCE);
|
||||
expect(depService.get('collecting')).toBe(true);
|
||||
|
||||
// 快速触发:任务尚未执行就清空队列,批次应被主动结算而不是永久挂起
|
||||
depService.clearIdleTasks();
|
||||
|
||||
await expect(promise).resolves.toBe(false);
|
||||
expect(depService.get('collecting')).toBe(false);
|
||||
});
|
||||
|
||||
test('reset 会结算在途 collectIdle', async () => {
|
||||
depService.addTarget(makeTarget('ds1', DepTargetType.DATA_SOURCE));
|
||||
|
||||
const promise = depService.collectIdle([{ id: 'n1', type: 'text' }] as any, {}, false, DepTargetType.DATA_SOURCE);
|
||||
depService.reset();
|
||||
|
||||
await expect(promise).resolves.toBe(false);
|
||||
expect(depService.get('collecting')).toBe(false);
|
||||
});
|
||||
|
||||
test('reset 会忽略在途 worker 的过期结果,避免覆盖新依赖', async () => {
|
||||
const fakeWorker = (await import('@editor/utils/dep/worker.ts?worker&inline')).default as any;
|
||||
fakeWorker.nextDelay = 20;
|
||||
fakeWorker.nextData = {
|
||||
[DepTargetType.DATA_SOURCE]: { ds1: { n1: { data: {} } } },
|
||||
};
|
||||
|
||||
const workerPromise = depService.collectByWorker({ items: [], id: 'app', type: 'app' } as any);
|
||||
depService.reset();
|
||||
|
||||
const target = makeTarget('ds1', DepTargetType.DATA_SOURCE);
|
||||
depService.addTarget(target);
|
||||
const idlePromise = depService.collectIdle(
|
||||
[{ id: 'n1', type: 'text' }] as any,
|
||||
{},
|
||||
false,
|
||||
DepTargetType.DATA_SOURCE,
|
||||
);
|
||||
|
||||
await Promise.all([workerPromise, idlePromise]);
|
||||
expect(target.deps.n1).toBeUndefined();
|
||||
|
||||
fakeWorker.nextDelay = 0;
|
||||
fakeWorker.nextData = {};
|
||||
});
|
||||
|
||||
test('多个批次并发时各自独立 resolve,全部完成后 collecting 复位', async () => {
|
||||
depService.addTarget(makeTarget('ds1', DepTargetType.DATA_SOURCE));
|
||||
|
||||
const p1 = depService.collectIdle([{ id: 'n1', type: 'text' }] as any, {}, false, DepTargetType.DATA_SOURCE);
|
||||
const p2 = depService.collectIdle([{ id: 'n2', type: 'text' }] as any, {}, false, DepTargetType.DATA_SOURCE);
|
||||
|
||||
await Promise.all([p1, p2]);
|
||||
expect(depService.get('collecting')).toBe(false);
|
||||
});
|
||||
|
||||
test('target 收集抛错时批次仍会结算,collecting 与 taskLength 复位', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
depService.addTarget(
|
||||
new Target({
|
||||
id: 'throw-target',
|
||||
type: DepTargetType.DATA_SOURCE,
|
||||
isTarget: () => {
|
||||
throw new Error('boom');
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const nodes = Array.from({ length: 50 }, (_, i) => ({ id: `n${i}`, type: 'text', text: 'x' })) as any;
|
||||
await expect(depService.collectIdle(nodes, {}, false, DepTargetType.DATA_SOURCE)).resolves.toBe(true);
|
||||
|
||||
expect(depService.get('collecting')).toBe(false);
|
||||
// taskLength 的更新做了 1s 节流,等节流窗口结束后才会同步到 0
|
||||
await new Promise((resolve) => setTimeout(resolve, 1100));
|
||||
expect(depService.get('taskLength')).toBe(0);
|
||||
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('collectIdle 优先用 worker 收集,主线程只把结果写回 target', async () => {
|
||||
const fakeCollectWorker = await enableCollectWorker();
|
||||
fakeCollectWorker.response = {
|
||||
deps: { [DepTargetType.DATA_SOURCE]: { ds_1: { n1: { name: 'n1', keys: ['text'] } } } },
|
||||
nodeIds: ['n1'],
|
||||
};
|
||||
|
||||
const target = createDataSourceTarget({ id: 'ds_1', fields: [] }, reactive({}));
|
||||
depService.addTarget(target);
|
||||
// 旧依赖应被本次收集结果覆盖,而不是与之合并
|
||||
target.deps.n1 = { name: 'n1', keys: ['stale'] };
|
||||
|
||||
const collected = vi.fn();
|
||||
const dsCollected = vi.fn();
|
||||
depService.on('collected', collected);
|
||||
depService.on('ds-collected', dsCollected);
|
||||
|
||||
const nodes = [{ id: 'n1', type: 'text', text: '${ds_1.name}' }] as any;
|
||||
await expect(depService.collectIdle(nodes, {}, true, DepTargetType.DATA_SOURCE)).resolves.toBe(true);
|
||||
|
||||
expect(fakeCollectWorker.requests).toHaveLength(1);
|
||||
// isTarget 无法跨线程传递,worker 中用 descriptor 重建 target
|
||||
expect(fakeCollectWorker.requests[0].payload).toContain('ds_1');
|
||||
expect(target.deps.n1.keys).toEqual(['text']);
|
||||
expect(dsCollected).toHaveBeenCalledWith(nodes, true);
|
||||
expect(collected).toHaveBeenCalledWith(nodes, true);
|
||||
expect(depService.get('collecting')).toBe(false);
|
||||
|
||||
depService.off('collected', collected);
|
||||
depService.off('ds-collected', dsCollected);
|
||||
});
|
||||
|
||||
test('collectIdle 会删除 worker 收集范围内节点的旧依赖', async () => {
|
||||
const fakeCollectWorker = await enableCollectWorker();
|
||||
fakeCollectWorker.response = { deps: {}, nodeIds: ['n1', 'n1_1'] };
|
||||
|
||||
const target = createDataSourceTarget({ id: 'ds_1', fields: [] }, reactive({}));
|
||||
depService.addTarget(target);
|
||||
target.deps.n1 = { name: 'n1', keys: ['text'] };
|
||||
target.deps.n1_1 = { name: 'n1_1', keys: ['text'] };
|
||||
target.deps.other = { name: 'other', keys: ['text'] };
|
||||
|
||||
await depService.collectIdle([{ id: 'n1', type: 'text' }] as any, {}, true, DepTargetType.DATA_SOURCE);
|
||||
|
||||
expect(target.deps.n1).toBeUndefined();
|
||||
expect(target.deps.n1_1).toBeUndefined();
|
||||
// 不在本次收集范围内的节点依赖不受影响
|
||||
expect(target.deps.other).toBeDefined();
|
||||
});
|
||||
|
||||
test('collectIdle 对 page 节点按 pageId 清理旧依赖', async () => {
|
||||
const fakeCollectWorker = await enableCollectWorker();
|
||||
fakeCollectWorker.response = { deps: {}, nodeIds: ['p1'] };
|
||||
|
||||
const target = createDataSourceTarget({ id: 'ds_1', fields: [] }, reactive({}));
|
||||
depService.addTarget(target);
|
||||
// 已被删除的节点残留依赖,只能按 pageId 匹配清理
|
||||
target.deps.removed = { name: 'removed', keys: ['text'], data: { pageId: 'p1' } };
|
||||
target.deps.otherPage = { name: 'otherPage', keys: ['text'], data: { pageId: 'p2' } };
|
||||
|
||||
await depService.collectIdle(
|
||||
[{ id: 'p1', type: 'page', items: [] }] as any,
|
||||
{ pageId: 'p1' },
|
||||
true,
|
||||
DepTargetType.DATA_SOURCE,
|
||||
);
|
||||
|
||||
expect(target.deps.removed).toBeUndefined();
|
||||
expect(target.deps.otherPage).toBeDefined();
|
||||
});
|
||||
|
||||
test('worker 收集失败时回退到主线程收集,结果一致', async () => {
|
||||
const fakeCollectWorker = await enableCollectWorker();
|
||||
fakeCollectWorker.failed = true;
|
||||
|
||||
const target = createDataSourceTarget({ id: 'ds_1', fields: [{ name: 'name', type: 'string' }] }, reactive({}));
|
||||
depService.addTarget(target);
|
||||
|
||||
const nodes = [{ id: 'n1', type: 'text', text: '${ds_1.name}' }] as any;
|
||||
await expect(depService.collectIdle(nodes, {}, true, DepTargetType.DATA_SOURCE)).resolves.toBe(true);
|
||||
|
||||
expect(target.deps.n1.keys).toEqual(['text']);
|
||||
});
|
||||
|
||||
test('没有 descriptor 的自定义 target 仍在主线程收集', async () => {
|
||||
const fakeCollectWorker = await enableCollectWorker();
|
||||
|
||||
const target = new Target({
|
||||
id: 'custom',
|
||||
type: DepTargetType.DEFAULT,
|
||||
isTarget: (key) => key === 'text',
|
||||
});
|
||||
depService.addTarget(target);
|
||||
|
||||
await depService.collectIdle([{ id: 'n1', type: 'text', text: 'abc' }] as any);
|
||||
|
||||
expect(fakeCollectWorker.requests).toHaveLength(0);
|
||||
expect(target.deps.n1.keys).toEqual(['text']);
|
||||
});
|
||||
|
||||
test('批次被中断后不再写回 worker 结果', async () => {
|
||||
const fakeCollectWorker = await enableCollectWorker();
|
||||
fakeCollectWorker.delay = 20;
|
||||
fakeCollectWorker.response = {
|
||||
deps: { [DepTargetType.DATA_SOURCE]: { ds_1: { n1: { name: 'n1', keys: ['text'] } } } },
|
||||
nodeIds: ['n1'],
|
||||
};
|
||||
|
||||
const target = createDataSourceTarget({ id: 'ds_1', fields: [] }, reactive({}));
|
||||
depService.addTarget(target);
|
||||
|
||||
const promise = depService.collectIdle([{ id: 'n1', type: 'text' }] as any, {}, true, DepTargetType.DATA_SOURCE);
|
||||
depService.clearIdleTasks();
|
||||
|
||||
await expect(promise).resolves.toBe(false);
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
|
||||
expect(target.deps.n1).toBeUndefined();
|
||||
expect(depService.get('collecting')).toBe(false);
|
||||
});
|
||||
|
||||
test('collectIdle deep=false 不会清掉未参与重收的子孙依赖', async () => {
|
||||
const fakeCollectWorker = await enableCollectWorker();
|
||||
// worker 按 deep=false 只返回容器自身 id
|
||||
fakeCollectWorker.response = { deps: {}, nodeIds: ['container'] };
|
||||
|
||||
const target = createDataSourceTarget({ id: 'ds_1', fields: [] }, reactive({}));
|
||||
depService.addTarget(target);
|
||||
target.deps.container = { name: 'container', keys: ['text'] };
|
||||
target.deps.child = { name: 'child', keys: ['text'] };
|
||||
|
||||
await depService.collectIdle(
|
||||
[{ id: 'container', type: 'container', items: [{ id: 'child', type: 'text', text: '${ds_1.name}' }] }] as any,
|
||||
{},
|
||||
false,
|
||||
DepTargetType.DATA_SOURCE,
|
||||
);
|
||||
|
||||
expect(target.deps.container).toBeUndefined();
|
||||
// 子孙未重收,旧依赖必须保留
|
||||
expect(target.deps.child).toBeDefined();
|
||||
});
|
||||
|
||||
test('target 在 worker 收集期间被重建时丢弃过期结果并主线程补收', async () => {
|
||||
const fakeCollectWorker = await enableCollectWorker();
|
||||
fakeCollectWorker.response = {
|
||||
deps: { [DepTargetType.DATA_SOURCE]: { ds_1: { n1: { name: 'n1', keys: ['stale'] } } } },
|
||||
nodeIds: ['n1'],
|
||||
};
|
||||
|
||||
depService.addTarget(
|
||||
createDataSourceTarget({ id: 'ds_1', fields: [{ name: 'name', type: 'string' }] }, reactive({})),
|
||||
);
|
||||
|
||||
const promise = depService.collectIdle(
|
||||
[{ id: 'n1', type: 'text', text: '${ds_1.name}' }] as any,
|
||||
{},
|
||||
true,
|
||||
DepTargetType.DATA_SOURCE,
|
||||
);
|
||||
|
||||
// 模拟修改数据源字段:target 被移除并重建
|
||||
depService.removeTarget('ds_1', DepTargetType.DATA_SOURCE);
|
||||
const newTarget = createDataSourceTarget({ id: 'ds_1', fields: [{ name: 'name', type: 'string' }] }, reactive({}));
|
||||
depService.addTarget(newTarget);
|
||||
|
||||
await promise;
|
||||
|
||||
// 过期的 stale 结果未写入;对新 target 主线程补收得到正确依赖
|
||||
expect(newTarget.deps.n1.keys).toEqual(['text']);
|
||||
expect(depService.get('collecting')).toBe(false);
|
||||
});
|
||||
|
||||
test('clearIdleTasks 会丢弃在途 worker 结果,但不会销毁重建常驻 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);
|
||||
const instanceLength = fakeCollectWorker.instances.length;
|
||||
|
||||
depService.clearIdleTasks();
|
||||
await expect(promise).resolves.toBe(false);
|
||||
|
||||
// 中断只丢结果:worker 复用,避免「加载中被销毁 → 重建 → 又被销毁」的反复加载
|
||||
fakeCollectWorker.delay = 0;
|
||||
await depService.collectIdle([{ id: 'n2', type: 'text' }] as any, {}, true, DepTargetType.DATA_SOURCE);
|
||||
expect(fakeCollectWorker.instances.length).toBe(instanceLength);
|
||||
});
|
||||
|
||||
test('root 反复更新时复用常驻 worker,不会重复创建', async () => {
|
||||
const fakeCollectWorker = await enableCollectWorker();
|
||||
const dsl = { id: 'app', type: 'app', items: [] } as any;
|
||||
|
||||
depService.clearIdleTasks();
|
||||
await depService.collectByWorker(dsl);
|
||||
const instanceLength = fakeCollectWorker.instances.length;
|
||||
|
||||
// root 更新流程:clearIdleTasks + 全量收集,多次更新应始终复用同一个 worker
|
||||
for (let i = 0; i < 3; i++) {
|
||||
depService.clearIdleTasks();
|
||||
await depService.collectByWorker(dsl);
|
||||
}
|
||||
|
||||
expect(fakeCollectWorker.requests).toHaveLength(4);
|
||||
expect(fakeCollectWorker.instances.length).toBe(instanceLength);
|
||||
});
|
||||
|
||||
test('大页面 + 连续 root/数据源变更:不反复创建 worker,最终收集可完成', async () => {
|
||||
const fakeCollectWorker = await enableCollectWorker();
|
||||
// 模拟大页面:worker 单次收集较慢,后续变更会打在「上一次还没结束」的窗口
|
||||
fakeCollectWorker.nextDelay = 80;
|
||||
fakeCollectWorker.delay = 80;
|
||||
fakeCollectWorker.nextData = {
|
||||
[DepTargetType.DATA_SOURCE]: { ds_1: { n1: { name: 'n1', keys: ['text'] } } },
|
||||
};
|
||||
fakeCollectWorker.response = {
|
||||
deps: { [DepTargetType.DATA_SOURCE]: { ds_1: { n1: { name: 'n1', keys: ['text'] } } } },
|
||||
nodeIds: ['n1'],
|
||||
};
|
||||
|
||||
const dsl = {
|
||||
id: 'app',
|
||||
type: 'app',
|
||||
items: Array.from({ length: 200 }, (_, index) => ({ id: `n${index}`, type: 'text', text: '${ds_1.name}' })),
|
||||
dataSources: [{ id: 'ds_1', fields: [{ name: 'name', type: 'string' }] }],
|
||||
dataSourceDeps: {},
|
||||
} as any;
|
||||
|
||||
depService.addTarget(
|
||||
createDataSourceTarget({ id: 'ds_1', fields: [{ name: 'name', type: 'string' }] }, reactive({})),
|
||||
);
|
||||
|
||||
// 先暖机一次:depService 单例可能复用上个用例的 worker,以当前 instances 为基线
|
||||
await depService.collectByWorker(dsl);
|
||||
const instanceLength = fakeCollectWorker.instances.length;
|
||||
const requestLength = fakeCollectWorker.requests.length;
|
||||
|
||||
const settled: boolean[] = [];
|
||||
// 首轮 root 全量收集尚未完成时,交错触发 root 更新与数据源重收
|
||||
const firstRoot = depService.collectByWorker(dsl).then(() => settled.push(true));
|
||||
const overlapping: Promise<unknown>[] = [];
|
||||
|
||||
for (let i = 0; i < 8; i++) {
|
||||
depService.clearIdleTasks();
|
||||
overlapping.push(depService.collectByWorker(dsl).then(() => settled.push(true)));
|
||||
|
||||
depService.clearIdleTasks();
|
||||
overlapping.push(
|
||||
depService
|
||||
.collectIdle(
|
||||
[{ id: `n${i}`, type: 'text', text: '${ds_1.name}' }] as any,
|
||||
{},
|
||||
true,
|
||||
DepTargetType.DATA_SOURCE,
|
||||
)
|
||||
.then((completed) => settled.push(completed)),
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all([firstRoot, ...overlapping]);
|
||||
|
||||
// 连续变更不应再 new Worker(instances 不增长);请求有实际投递
|
||||
expect(fakeCollectWorker.instances.length).toBe(instanceLength);
|
||||
expect(fakeCollectWorker.requests.length).toBeGreaterThan(requestLength);
|
||||
// 最终至少有一轮完整收集完成;中间被 clearIdleTasks 打断的 idle 可能是 false
|
||||
expect(settled.some(Boolean)).toBe(true);
|
||||
expect(depService.get('collecting')).toBe(false);
|
||||
});
|
||||
|
||||
test('collectIdle 节点为空时不会启动 worker', async () => {
|
||||
const fakeCollectWorker = await enableCollectWorker();
|
||||
depService.addTarget(createDataSourceTarget({ id: 'ds_1', fields: [] }, reactive({})));
|
||||
|
||||
await expect(depService.collectIdle([], {}, true, DepTargetType.DATA_SOURCE)).resolves.toBe(true);
|
||||
|
||||
expect(fakeCollectWorker.requests).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('destroy 会 reset 并移除监听', () => {
|
||||
depService.addTarget(makeTarget('destroy-me'));
|
||||
expect(() => depService.destroy()).not.toThrow();
|
||||
|
||||
@ -16,7 +16,7 @@
|
||||
* 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 type { MApp, MContainer, MNode } from '@tmagic/core';
|
||||
@ -552,6 +552,153 @@ describe('remove', () => {
|
||||
// 自动切到剩余首个页面
|
||||
expect(editorService.get('page')?.id).toBe(addedId);
|
||||
});
|
||||
|
||||
test('默认删除当前页面并自动切页时,不会先把 page 置为 null', async () => {
|
||||
editorService.set('root', cloneDeep(root));
|
||||
const rootNode = editorService.get('root');
|
||||
await editorService.select(NodeId.PAGE_ID);
|
||||
const newPage = await editorService.add({ type: NodeType.PAGE }, rootNode);
|
||||
const addedId = Array.isArray(newPage) ? newPage[0].id : newPage.id;
|
||||
|
||||
await editorService.select(NodeId.PAGE_ID);
|
||||
|
||||
const pageValues: Array<MNode | null> = [];
|
||||
const originalSet = editorService.set.bind(editorService);
|
||||
const setSpy = vi.spyOn(editorService, 'set').mockImplementation((name, value, options) => {
|
||||
if (name === 'page') {
|
||||
pageValues.push(value as MNode | null);
|
||||
}
|
||||
return originalSet(name, value, options);
|
||||
});
|
||||
|
||||
try {
|
||||
await editorService.remove({ id: NodeId.PAGE_ID, type: NodeType.PAGE });
|
||||
} finally {
|
||||
setSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(pageValues.some((page) => page === null)).toBe(false);
|
||||
expect(editorService.get('page')?.id).toBe(addedId);
|
||||
});
|
||||
|
||||
test('删除非当前页面时保持当前页选中,不强制切到首个页面', async () => {
|
||||
editorService.set('root', cloneDeep(root));
|
||||
const rootNode = editorService.get('root');
|
||||
await editorService.select(NodeId.PAGE_ID);
|
||||
const newPage = await editorService.add({ type: NodeType.PAGE }, rootNode);
|
||||
const addedId = Array.isArray(newPage) ? newPage[0].id : newPage.id;
|
||||
|
||||
await editorService.select(NodeId.PAGE_ID);
|
||||
expect(editorService.get('page')?.id).toBe(NodeId.PAGE_ID);
|
||||
|
||||
await editorService.remove({ id: addedId, type: NodeType.PAGE });
|
||||
|
||||
expect(editorService.getNodeById(addedId)).toBeNull();
|
||||
expect(editorService.get('page')?.id).toBe(NodeId.PAGE_ID);
|
||||
});
|
||||
|
||||
test('删除最后一个页面后退回选中 root', async () => {
|
||||
editorService.set('root', cloneDeep(root));
|
||||
const rootNode = editorService.get('root');
|
||||
await editorService.select(NodeId.PAGE_ID);
|
||||
expect(rootNode?.items.length).toBe(1);
|
||||
|
||||
await editorService.remove({ id: NodeId.PAGE_ID, type: NodeType.PAGE });
|
||||
|
||||
expect(rootNode?.items.length).toBe(0);
|
||||
expect(editorService.get('page')).toBeNull();
|
||||
// root 下已无页面,编辑器应回到「选中 root」状态,而不是什么都没选中
|
||||
expect(editorService.get('nodes')).toEqual([rootNode]);
|
||||
expect(editorService.get('parent')).toBeNull();
|
||||
});
|
||||
|
||||
describe('与画布的调用顺序', () => {
|
||||
const createStageStub = () => {
|
||||
const calls: string[] = [];
|
||||
return {
|
||||
calls,
|
||||
stage: {
|
||||
remove: vi.fn(() => {
|
||||
calls.push('remove');
|
||||
return Promise.resolve();
|
||||
}),
|
||||
select: vi.fn(() => {
|
||||
calls.push('select');
|
||||
return Promise.resolve();
|
||||
}),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
editorService.set('stage', null);
|
||||
});
|
||||
|
||||
test('删除当前页面时先把画布切到剩余页面,再通知画布删除', async () => {
|
||||
editorService.set('root', cloneDeep(root));
|
||||
const rootNode = editorService.get('root');
|
||||
await editorService.select(NodeId.PAGE_ID);
|
||||
const newPage = await editorService.add({ type: NodeType.PAGE }, rootNode);
|
||||
const addedId = Array.isArray(newPage) ? newPage[0].id : newPage.id;
|
||||
await editorService.select(NodeId.PAGE_ID);
|
||||
|
||||
const { calls, stage } = createStageStub();
|
||||
editorService.set('stage', stage as any);
|
||||
|
||||
await editorService.remove({ id: NodeId.PAGE_ID, type: NodeType.PAGE });
|
||||
|
||||
// runtime 删除 page 时会销毁当前渲染的实例,必须切页在前,否则画布会被清空
|
||||
expect(calls).toEqual(['select', 'remove']);
|
||||
expect(stage.select).toHaveBeenCalledWith(addedId);
|
||||
expect(editorService.get('page')?.id).toBe(addedId);
|
||||
});
|
||||
|
||||
test('删除当前页面片时同样先切页再通知画布删除', async () => {
|
||||
editorService.set('root', cloneDeep(root));
|
||||
const rootNode = editorService.get('root');
|
||||
await editorService.select(NodeId.PAGE_ID);
|
||||
const fragment = await editorService.add({ type: NodeType.PAGE_FRAGMENT }, rootNode);
|
||||
const fragmentId = Array.isArray(fragment) ? fragment[0].id : fragment.id;
|
||||
// 新增页面片后当前页已切到页面片
|
||||
expect(editorService.get('page')?.id).toBe(fragmentId);
|
||||
|
||||
const { calls, stage } = createStageStub();
|
||||
editorService.set('stage', stage as any);
|
||||
|
||||
await editorService.remove({ id: fragmentId, type: NodeType.PAGE_FRAGMENT });
|
||||
|
||||
expect(calls).toEqual(['select', 'remove']);
|
||||
expect(editorService.get('page')?.id).toBe(NodeId.PAGE_ID);
|
||||
});
|
||||
|
||||
test('删除普通节点时先通知画布删除,再选中父节点', async () => {
|
||||
editorService.set('root', cloneDeep(root));
|
||||
await editorService.select(NodeId.NODE_ID);
|
||||
|
||||
const { calls, stage } = createStageStub();
|
||||
editorService.set('stage', stage as any);
|
||||
|
||||
await editorService.remove({ id: NodeId.NODE_ID, type: 'text' });
|
||||
|
||||
expect(calls).toEqual(['remove', 'select']);
|
||||
expect(stage.select).toHaveBeenCalledWith(NodeId.PAGE_ID);
|
||||
});
|
||||
|
||||
test('删除最后一个页面时在 Stage 卸载前发出删除通知', async () => {
|
||||
editorService.set('root', cloneDeep(root));
|
||||
await editorService.select(NodeId.PAGE_ID);
|
||||
|
||||
const { calls, stage } = createStageStub();
|
||||
editorService.set('stage', stage as any);
|
||||
|
||||
await editorService.remove({ id: NodeId.PAGE_ID, type: NodeType.PAGE });
|
||||
|
||||
// page 置空会让 Workspace 卸载 Stage,删除通知必须在这之前发出
|
||||
expect(calls).toEqual(['remove']);
|
||||
// selectRoot 会顺带清掉 stage 引用
|
||||
expect(editorService.get('stage')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
@ -587,6 +734,21 @@ describe('update', () => {
|
||||
expect(node?.text).toBe('text');
|
||||
});
|
||||
|
||||
test('replace=true 时整节点替换,不 merge 保留旧字段', async () => {
|
||||
editorService.set('root', cloneDeep(root));
|
||||
await editorService.select(NodeId.PAGE_ID);
|
||||
|
||||
// 默认 merge:未传入的 style.width 会保留
|
||||
await editorService.update({ id: NodeId.NODE_ID, type: 'text', text: 'merged' });
|
||||
expect(editorService.getNodeById(NodeId.NODE_ID)?.style?.width).toBe(270);
|
||||
|
||||
// replace:直接用传入配置覆盖,旧字段(style)被去掉
|
||||
await editorService.update({ id: NodeId.NODE_ID, type: 'text', text: 'replaced' }, { replace: true });
|
||||
const node = editorService.getNodeById(NodeId.NODE_ID);
|
||||
expect(node?.text).toBe('replaced');
|
||||
expect(node?.style).toBeUndefined();
|
||||
});
|
||||
|
||||
test('没有id', async () => {
|
||||
try {
|
||||
await editorService.update({ type: 'text', text: 'text', id: '' });
|
||||
|
||||
@ -30,7 +30,7 @@ describe('events', () => {
|
||||
test('setMethod', () => {
|
||||
const method = [{ label: '点击', value: 'magic:common:events:click' }];
|
||||
events.setMethod('button', method);
|
||||
expect(events.getMethod('button', '')).toHaveLength(1);
|
||||
expect(events.getMethod('button', { targetId: 'btn_1' })).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('setEvents 批量设置', () => {
|
||||
@ -46,12 +46,20 @@ describe('events', () => {
|
||||
events.setMethods({
|
||||
Image: [{ label: 'show', value: 'show' }],
|
||||
} as any);
|
||||
expect(events.getMethod('image', '')).toHaveLength(1);
|
||||
expect(events.getMethod('image', {})).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('getEvent / getMethod 支持节点上下文参数', () => {
|
||||
const node = { id: 'n1', type: 'button' } as any;
|
||||
events.setEvent('button', [{ label: '点击', value: 'click' }]);
|
||||
events.setMethod('button', [{ label: '打开', value: 'open' }]);
|
||||
expect(events.getEvent('button', { node })).toEqual([{ label: '点击', value: 'click' }]);
|
||||
expect(events.getMethod('button', { targetId: 'n1', node })).toEqual([{ label: '打开', value: 'open' }]);
|
||||
});
|
||||
|
||||
test('未注册类型返回空数组', () => {
|
||||
expect(events.getEvent('not-exist')).toEqual([]);
|
||||
expect(events.getMethod('not-exist', '')).toEqual([]);
|
||||
expect(events.getMethod('not-exist')).toEqual([]);
|
||||
});
|
||||
|
||||
test('resetState 清空所有事件 / 方法', () => {
|
||||
|
||||
@ -0,0 +1,340 @@
|
||||
/*
|
||||
* 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 static delay = 0;
|
||||
|
||||
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 },
|
||||
});
|
||||
}, FakeCollectWorker.delay);
|
||||
}
|
||||
|
||||
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;
|
||||
fakeCollectWorker.delay = 0;
|
||||
|
||||
(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 丢弃在途请求,但保留常驻 worker(不重建、不重复加载)', async () => {
|
||||
const fakeCollectWorker = await getFakeWorker();
|
||||
const client = new CollectWorkerClient();
|
||||
|
||||
const promise = client.collect(payload);
|
||||
client.abort();
|
||||
|
||||
// 在途请求按 null 结算,调用方回退主线程收集
|
||||
await expect(promise).resolves.toBeNull();
|
||||
expect(fakeCollectWorker.instances[0].terminated).toBe(false);
|
||||
|
||||
// worker 复用,不会因为 abort 重建
|
||||
await expect(client.collect(payload)).resolves.not.toBeNull();
|
||||
expect(fakeCollectWorker.instances).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('被 abort 丢弃的请求结果不会回传,且不阻塞后续请求', async () => {
|
||||
const fakeCollectWorker = await getFakeWorker();
|
||||
const client = new CollectWorkerClient();
|
||||
const settled: unknown[] = [];
|
||||
|
||||
const aborted = client.collect(payload).then((result) => settled.push(result));
|
||||
client.abort();
|
||||
// abort 后紧接着的新请求(如 root 更新触发的全量收集)排在被丢弃请求之后,仍能拿到结果
|
||||
const next = client.collectDsl(dsl);
|
||||
|
||||
await Promise.all([aborted, next]);
|
||||
|
||||
expect(settled).toEqual([null]);
|
||||
await expect(next).resolves.not.toBeNull();
|
||||
expect(fakeCollectWorker.instances).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('无在途请求时 abort 保留常驻 worker,不会反复加载 worker 产物', async () => {
|
||||
const fakeCollectWorker = await getFakeWorker();
|
||||
const client = new CollectWorkerClient();
|
||||
|
||||
await client.collectDsl(dsl);
|
||||
expect(fakeCollectWorker.instances).toHaveLength(1);
|
||||
|
||||
// 模拟 root 更新:clearIdleTasks 先 abort,紧接着发起全量收集
|
||||
client.abort();
|
||||
await client.collectDsl(dsl);
|
||||
|
||||
expect(fakeCollectWorker.instances).toHaveLength(1);
|
||||
expect(fakeCollectWorker.instances[0].terminated).toBe(false);
|
||||
});
|
||||
|
||||
test('大页面长耗时 + 连续 abort/收集:不重建 worker,最终请求仍能完成', async () => {
|
||||
const fakeCollectWorker = await getFakeWorker();
|
||||
// 模拟大页面收集耗时;连续 abort 落在「上一次还没跑完」的窗口内
|
||||
fakeCollectWorker.delay = 80;
|
||||
const client = new CollectWorkerClient();
|
||||
const results: Array<unknown> = [];
|
||||
|
||||
// 首轮投递后,在 worker 忙时连续模拟 root 更新:abort + 新全量收集
|
||||
const first = client.collectDsl(dsl).then((result) => results.push(result));
|
||||
const overlapping: Promise<unknown>[] = [];
|
||||
for (let i = 0; i < 12; i++) {
|
||||
client.abort();
|
||||
overlapping.push(client.collectDsl(dsl).then((result) => results.push(result)));
|
||||
}
|
||||
|
||||
const all = await Promise.all([first, ...overlapping]);
|
||||
|
||||
// 不应陷入「加载中被销毁 → 重建」:始终只有 1 个 worker 实例
|
||||
expect(fakeCollectWorker.instances).toHaveLength(1);
|
||||
expect(fakeCollectWorker.instances[0].terminated).toBe(false);
|
||||
// 被 abort 的立即 null;最后一次保留的请求能拿到结果
|
||||
expect(results.filter((item) => item === null).length).toBeGreaterThan(0);
|
||||
expect(all[all.length - 1]).not.toBeNull();
|
||||
// discarded 长任务仍占 worker:真正投递给 worker 的次数远少于发起次数
|
||||
expect(fakeCollectWorker.requests.length).toBeLessThan(all.length);
|
||||
expect(fakeCollectWorker.requests.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test('abort 后新请求要等 discarded 长任务结束才能投递', async () => {
|
||||
const fakeCollectWorker = await getFakeWorker();
|
||||
fakeCollectWorker.delay = 60;
|
||||
const client = new CollectWorkerClient();
|
||||
|
||||
const first = client.collectDsl(dsl);
|
||||
client.abort();
|
||||
const next = client.collectDsl(dsl);
|
||||
|
||||
// abort 后立刻结算旧请求,但新请求尚未投递(仍被 discarded inflight 堵住)
|
||||
await expect(first).resolves.toBeNull();
|
||||
expect(fakeCollectWorker.requests).toHaveLength(1);
|
||||
|
||||
await expect(next).resolves.not.toBeNull();
|
||||
expect(fakeCollectWorker.requests).toHaveLength(2);
|
||||
expect(fakeCollectWorker.instances).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@ -6,6 +6,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { IdleTask } from '@editor/utils/dep/idle-task';
|
||||
import * as logger from '@editor/utils/logger';
|
||||
|
||||
const fakeIdleDeadline = (timeRemaining: number, callsBeforeZero = 1): IdleDeadline => {
|
||||
let remainingCalls = callsBeforeZero;
|
||||
@ -19,6 +20,15 @@ const fakeIdleDeadline = (timeRemaining: number, callsBeforeZero = 1): IdleDeadl
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 主线程一直没有空闲时浏览器的真实行为:回调因 timeout 触发,
|
||||
* didTimeout 为 true 且 timeRemaining() 恒为 0
|
||||
*/
|
||||
const timedOutIdleDeadline = (): IdleDeadline => ({
|
||||
didTimeout: true,
|
||||
timeRemaining: () => 0,
|
||||
});
|
||||
|
||||
describe('IdleTask', () => {
|
||||
let originalRic: any;
|
||||
let originalCancel: any;
|
||||
@ -39,6 +49,7 @@ describe('IdleTask', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
globalThis.requestIdleCallback = originalRic;
|
||||
globalThis.cancelIdleCallback = originalCancel;
|
||||
});
|
||||
@ -68,38 +79,38 @@ describe('IdleTask', () => {
|
||||
expect(order[1]).toBe('low');
|
||||
});
|
||||
|
||||
test('剩余空闲时间 <=5 时单批最多 10 个任务', () => {
|
||||
// callsBeforeZero 需为 2:第一次读取用于 while 判断,第二次读取才是决定单批任务数的值
|
||||
test('剩余空闲时间 <=5 时单批 10 个任务', () => {
|
||||
const task = new IdleTask<number>();
|
||||
const handler = vi.fn();
|
||||
for (let i = 0; i < 1000; i++) task.enqueueTask(handler, i);
|
||||
|
||||
scheduled[0].cb(fakeIdleDeadline(3, 1));
|
||||
expect(handler.mock.calls.length).toBeGreaterThan(0);
|
||||
expect(handler.mock.calls.length).toBeLessThanOrEqual(10);
|
||||
scheduled[0].cb(fakeIdleDeadline(3, 2));
|
||||
expect(handler.mock.calls.length).toBe(10);
|
||||
});
|
||||
|
||||
test('剩余时间 8(5-10 范围)单批最多 100', () => {
|
||||
test('剩余时间 8(5-10 范围)单批 100', () => {
|
||||
const task = new IdleTask<number>();
|
||||
const handler = vi.fn();
|
||||
for (let i = 0; i < 1000; i++) task.enqueueTask(handler, i);
|
||||
scheduled[0].cb(fakeIdleDeadline(8, 1));
|
||||
expect(handler.mock.calls.length).toBeLessThanOrEqual(100);
|
||||
scheduled[0].cb(fakeIdleDeadline(8, 2));
|
||||
expect(handler.mock.calls.length).toBe(100);
|
||||
});
|
||||
|
||||
test('剩余时间 12 单批最多 300', () => {
|
||||
test('剩余时间 12(10-15 范围)单批 300', () => {
|
||||
const task = new IdleTask<number>();
|
||||
const handler = vi.fn();
|
||||
for (let i = 0; i < 1000; i++) task.enqueueTask(handler, i);
|
||||
scheduled[0].cb(fakeIdleDeadline(12, 1));
|
||||
expect(handler.mock.calls.length).toBeLessThanOrEqual(300);
|
||||
scheduled[0].cb(fakeIdleDeadline(12, 2));
|
||||
expect(handler.mock.calls.length).toBe(300);
|
||||
});
|
||||
|
||||
test('剩余时间 50 单批最多 600', () => {
|
||||
test('剩余时间 50(>15)单批 600', () => {
|
||||
const task = new IdleTask<number>();
|
||||
const handler = vi.fn();
|
||||
for (let i = 0; i < 1000; i++) task.enqueueTask(handler, i);
|
||||
scheduled[0].cb(fakeIdleDeadline(50, 1));
|
||||
expect(handler.mock.calls.length).toBeLessThanOrEqual(600);
|
||||
scheduled[0].cb(fakeIdleDeadline(50, 2));
|
||||
expect(handler.mock.calls.length).toBe(600);
|
||||
});
|
||||
|
||||
test('完成所有任务后触发 finish 与 hight-level-finish 事件', () => {
|
||||
@ -127,6 +138,167 @@ describe('IdleTask', () => {
|
||||
expect(scheduled.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
test('单个任务抛错不会中断整个队列', () => {
|
||||
const errorSpy = vi.spyOn(logger, 'error').mockImplementation(() => undefined);
|
||||
const task = new IdleTask<number>();
|
||||
const done: number[] = [];
|
||||
const finishHandler = vi.fn();
|
||||
task.on('finish', finishHandler);
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
task.enqueueTask((n) => {
|
||||
if (n === 3) throw new Error('boom');
|
||||
done.push(n);
|
||||
}, i);
|
||||
}
|
||||
|
||||
expect(() => scheduled[0].cb(fakeIdleDeadline(50))).not.toThrow();
|
||||
expect(done).toEqual([0, 1, 2, 4, 5, 6, 7, 8, 9]);
|
||||
expect(finishHandler).toHaveBeenCalled();
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('任务抛错且仍有剩余任务时继续调度下一轮并同步任务数', () => {
|
||||
const errorSpy = vi.spyOn(logger, 'error').mockImplementation(() => undefined);
|
||||
const task = new IdleTask<number>();
|
||||
const updateHandler = vi.fn();
|
||||
task.on('update-task-length', updateHandler);
|
||||
|
||||
for (let i = 0; i < 200; i++) {
|
||||
task.enqueueTask((n) => {
|
||||
if (n === 0) throw new Error('boom');
|
||||
}, i);
|
||||
}
|
||||
|
||||
// 单批最多执行 10 个,执行完仍有剩余任务
|
||||
scheduled[0].cb(fakeIdleDeadline(3, 1));
|
||||
|
||||
expect(scheduled.length).toBeGreaterThan(1);
|
||||
expect(updateHandler).toHaveBeenCalledWith({ length: 190, hightLevelLength: 0 });
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('任务执行过程中 clearTasks 会立即停止消费已清空的队列', () => {
|
||||
const task = new IdleTask<number>();
|
||||
const handler = vi.fn((n: number) => {
|
||||
if (n === 0) {
|
||||
task.clearTasks();
|
||||
}
|
||||
});
|
||||
for (let i = 0; i < 200; i++) task.enqueueTask(handler, i);
|
||||
|
||||
scheduled[0].cb(fakeIdleDeadline(50));
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('任务执行过程中清空并重新入队,新任务仍会被执行', () => {
|
||||
const task = new IdleTask<number>();
|
||||
const afterHandler = vi.fn();
|
||||
task.enqueueTask((n) => {
|
||||
if (n === 0) {
|
||||
task.clearTasks();
|
||||
task.enqueueTask(afterHandler, 999);
|
||||
}
|
||||
}, 0);
|
||||
for (let i = 1; i < 200; i++) task.enqueueTask(() => undefined, i);
|
||||
|
||||
scheduled[0].cb(fakeIdleDeadline(50));
|
||||
|
||||
expect(afterHandler).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('因 timeout 触发(无空闲时间)时任务仍会被执行,队列不会永久停滞', () => {
|
||||
const task = new IdleTask<number>();
|
||||
const handler = vi.fn();
|
||||
for (let i = 0; i < 5; i++) task.enqueueTask(handler, i);
|
||||
|
||||
scheduled[0].cb(timedOutIdleDeadline());
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(5);
|
||||
expect(scheduled).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('因 timeout 触发时受时间预算约束,不会一次跑完巨大队列阻塞主线程', () => {
|
||||
const task = new IdleTask<number>();
|
||||
const handler = vi.fn();
|
||||
for (let i = 0; i < 1000; i++) task.enqueueTask(handler, i);
|
||||
|
||||
// 每读一次时钟推进 3ms:预算 5ms 时最多跑两批
|
||||
let now = 0;
|
||||
const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => {
|
||||
now += 3;
|
||||
return now;
|
||||
});
|
||||
|
||||
scheduled[0].cb(timedOutIdleDeadline());
|
||||
nowSpy.mockRestore();
|
||||
|
||||
expect(handler.mock.calls.length).toBe(20);
|
||||
});
|
||||
|
||||
test('因 timeout 触发且仍有剩余任务时继续调度下一轮', () => {
|
||||
const task = new IdleTask<number>();
|
||||
const handler = vi.fn();
|
||||
for (let i = 0; i < 1000; i++) task.enqueueTask(handler, i);
|
||||
|
||||
let now = 0;
|
||||
const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => {
|
||||
now += 10;
|
||||
return now;
|
||||
});
|
||||
|
||||
scheduled[0].cb(timedOutIdleDeadline());
|
||||
nowSpy.mockRestore();
|
||||
|
||||
expect(scheduled.length).toBeGreaterThan(1);
|
||||
expect(handler.mock.calls.length).toBeLessThan(1000);
|
||||
});
|
||||
|
||||
test('因 timeout 触发时高优先级任务仍优先执行', () => {
|
||||
const task = new IdleTask<string>();
|
||||
const order: string[] = [];
|
||||
task.enqueueTask(() => order.push('low'), 'low');
|
||||
task.enqueueTask(() => order.push('high'), 'high', true);
|
||||
|
||||
scheduled[0].cb(timedOutIdleDeadline());
|
||||
|
||||
expect(order).toEqual(['high', 'low']);
|
||||
});
|
||||
|
||||
test('因 timeout 触发时任务中调用 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(timedOutIdleDeadline());
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('因 timeout 触发时单个任务抛错不会中断队列', () => {
|
||||
const errorSpy = vi.spyOn(logger, 'error').mockImplementation(() => undefined);
|
||||
const task = new IdleTask<number>();
|
||||
const done: number[] = [];
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
task.enqueueTask((n) => {
|
||||
if (n === 2) throw new Error('boom');
|
||||
done.push(n);
|
||||
}, i);
|
||||
}
|
||||
|
||||
expect(() => scheduled[0].cb(timedOutIdleDeadline())).not.toThrow();
|
||||
expect(done).toEqual([0, 1, 3, 4]);
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('clearTasks - 取消挂起任务并重置队列', () => {
|
||||
const task = new IdleTask<number>();
|
||||
task.enqueueTask(() => undefined, 1);
|
||||
@ -150,19 +322,42 @@ describe('IdleTask', () => {
|
||||
|
||||
test('全局 requestIdleCallback polyfill 在浏览器无原生时降级到 setTimeout', () => {
|
||||
vi.useFakeTimers();
|
||||
const original = globalThis.requestIdleCallback;
|
||||
delete (globalThis as any).requestIdleCallback;
|
||||
delete (globalThis as any).cancelIdleCallback;
|
||||
// 重新加载模块以触发 polyfill 注册
|
||||
vi.resetModules();
|
||||
return import('@editor/utils/dep/idle-task').then(() => {
|
||||
expect(typeof globalThis.requestIdleCallback).toBe('function');
|
||||
const cb = vi.fn();
|
||||
expect(typeof globalThis.cancelIdleCallback).toBe('function');
|
||||
|
||||
let deadline: IdleDeadline | undefined;
|
||||
const cb = vi.fn((current: IdleDeadline) => {
|
||||
deadline = current;
|
||||
});
|
||||
const id = globalThis.requestIdleCallback(cb);
|
||||
expect(id).toBeDefined();
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(cb).toHaveBeenCalled();
|
||||
vi.useRealTimers();
|
||||
globalThis.requestIdleCallback = original;
|
||||
// polyfill 走不到原生的 timeout 语义,didTimeout 恒为 false,并给出一帧内的剩余时间
|
||||
expect(deadline?.didTimeout).toBe(false);
|
||||
expect(deadline?.timeRemaining()).toBeLessThanOrEqual(50);
|
||||
expect(deadline?.timeRemaining()).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('全局 cancelIdleCallback polyfill 取消后回调不再触发', () => {
|
||||
vi.useFakeTimers();
|
||||
delete (globalThis as any).requestIdleCallback;
|
||||
delete (globalThis as any).cancelIdleCallback;
|
||||
vi.resetModules();
|
||||
return import('@editor/utils/dep/idle-task').then(() => {
|
||||
const cb = vi.fn();
|
||||
const id = globalThis.requestIdleCallback(cb);
|
||||
globalThis.cancelIdleCallback(id);
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import serialize from 'serialize-javascript';
|
||||
|
||||
import { DepTargetType } from '@tmagic/core';
|
||||
|
||||
@ -26,6 +27,18 @@ afterEach(() => {
|
||||
|
||||
const loadWorker = () => import('@editor/utils/dep/worker');
|
||||
|
||||
/** 全量收集:worker 中根据 dsl 重建 target */
|
||||
const postDslRequest = (dsl: string, id = 1) => {
|
||||
(globalThis as any).onmessage({ data: { id, dsl } });
|
||||
return postedMessages[0];
|
||||
};
|
||||
|
||||
/** 增量收集:target 由主线程以可序列化描述传入 */
|
||||
const postCollectRequest = (payload: any, id = 1) => {
|
||||
(globalThis as any).onmessage({ data: { id, payload: typeof payload === 'string' ? payload : serialize(payload) } });
|
||||
return postedMessages[0];
|
||||
};
|
||||
|
||||
describe('dep/worker', () => {
|
||||
test('注册 onmessage 处理器', async () => {
|
||||
await loadWorker();
|
||||
@ -34,36 +47,138 @@ describe('dep/worker', () => {
|
||||
|
||||
test('正常 dsl - 收集 codeBlocks/dataSources/items 并 postMessage', async () => {
|
||||
await loadWorker();
|
||||
const dsl = JSON.stringify({
|
||||
id: 'app',
|
||||
type: 'app',
|
||||
codeBlocks: { cb_1: { name: 'fn1', content: 'function (){}' } },
|
||||
dataSources: [{ id: 'ds_1', type: 'base', fields: [] }],
|
||||
items: [{ id: 'page_1', type: 'page', items: [] }],
|
||||
});
|
||||
(globalThis as any).onmessage({ data: { dsl } });
|
||||
const response = postDslRequest(
|
||||
JSON.stringify({
|
||||
id: 'app',
|
||||
type: 'app',
|
||||
codeBlocks: { cb_1: { name: 'fn1', content: 'function (){}' } },
|
||||
dataSources: [{ id: 'ds_1', type: 'base', fields: [] }],
|
||||
items: [{ id: 'page_1', type: 'page', items: [] }],
|
||||
}),
|
||||
);
|
||||
expect(postedMessages).toHaveLength(1);
|
||||
const data = postedMessages[0];
|
||||
expect(data).toHaveProperty(DepTargetType.DATA_SOURCE);
|
||||
expect(data).toHaveProperty(DepTargetType.CODE_BLOCK);
|
||||
// 全量与增量收集共用一个 worker,响应必须带上请求 id
|
||||
expect(response.id).toBe(1);
|
||||
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();
|
||||
(globalThis as any).onmessage({ data: { dsl: '!@#invalid' } });
|
||||
expect(postedMessages[0]).toEqual({});
|
||||
expect(postDslRequest('!@#invalid')).toEqual({ id: 1, deps: {}, failed: true });
|
||||
});
|
||||
|
||||
test('mApp 为空时也会调用 postMessage', async () => {
|
||||
test('mApp 为空时只返回一次空结果', async () => {
|
||||
await loadWorker();
|
||||
(globalThis as any).onmessage({ data: { dsl: 'null' } });
|
||||
expect(postedMessages.length).toBeGreaterThanOrEqual(1);
|
||||
expect(postDslRequest('null')).toEqual({ id: 1, deps: {} });
|
||||
expect(postedMessages).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('mApp 没有 codeBlocks/dataSources 时也能完成', async () => {
|
||||
await loadWorker();
|
||||
const dsl = JSON.stringify({ id: 'app', type: 'app', items: [] });
|
||||
(globalThis as any).onmessage({ data: { dsl } });
|
||||
expect(postDslRequest(JSON.stringify({ id: 'app', type: 'app', items: [] })).deps).toBeDefined();
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -52,8 +52,10 @@ describe('event utils', () => {
|
||||
});
|
||||
|
||||
test('getCompActionOptions 普通组件返回 select options', () => {
|
||||
editorService.getNodeById.mockReturnValue({ type: 'btn', id: '1' });
|
||||
const node = { type: 'btn', id: '1' };
|
||||
editorService.getNodeById.mockReturnValue(node);
|
||||
expect(getCompActionOptions('1')).toEqual([{ text: 'open', value: 'open' }]);
|
||||
expect(eventsService.getMethod).toHaveBeenCalledWith('btn', { targetId: '1', node });
|
||||
});
|
||||
|
||||
test('getCompActionOptions 无节点返回空', () => {
|
||||
@ -63,8 +65,10 @@ describe('event utils', () => {
|
||||
});
|
||||
|
||||
test('getCompActionOptions 页面片容器返回 cascader options', () => {
|
||||
editorService.getNodeById.mockReturnValue({ type: 'page-fragment-container', id: '1', pageFragmentId: 'pf1' });
|
||||
editorService.get.mockReturnValue({ items: [{ id: 'pf1', items: [{ id: 'c1', type: 'btn', name: 'b' }] }] });
|
||||
const container = { type: 'page-fragment-container', id: '1', pageFragmentId: 'pf1' };
|
||||
const child = { id: 'c1', type: 'btn', name: 'b' };
|
||||
editorService.getNodeById.mockImplementation((id: string) => (id === '1' ? container : null));
|
||||
editorService.get.mockReturnValue({ items: [{ id: 'pf1', items: [child] }] });
|
||||
expect(getCompActionOptions('1')).toEqual([
|
||||
{
|
||||
label: 'b_c1',
|
||||
@ -72,6 +76,7 @@ describe('event utils', () => {
|
||||
children: [{ label: 'open', value: 'open' }],
|
||||
},
|
||||
]);
|
||||
expect(eventsService.getMethod).toHaveBeenCalledWith('btn', { targetId: 'c1', node: child });
|
||||
});
|
||||
|
||||
test('getCompActionAllowedValues 枚举合法动作', () => {
|
||||
@ -99,6 +104,29 @@ describe('event utils', () => {
|
||||
expect(collectEventNameOptionValues([{ value: 'a', children: [{ value: 'b' }] }], false)).toEqual(new Set(['a.b']));
|
||||
});
|
||||
|
||||
test('getEventNameOptions 页面片子节点查找失败时回退 current', () => {
|
||||
const formValue = { type: 'page-fragment-container', id: '1', pageFragmentId: 'pf1' };
|
||||
const child = { id: 'c1', type: 'btn', name: 'b' };
|
||||
editorService.getNodeById.mockReturnValue(null);
|
||||
editorService.get.mockReturnValue({
|
||||
items: [{ id: 'pf1', name: '页面片', items: [child] }],
|
||||
});
|
||||
|
||||
expect(getEventNameOptions('component', formValue)).toEqual([
|
||||
{
|
||||
label: '页面片',
|
||||
value: 'pf1',
|
||||
children: [{ label: 'click', value: 'click' }],
|
||||
},
|
||||
{
|
||||
label: 'b_c1',
|
||||
value: 'c1',
|
||||
children: [{ label: 'click', value: 'click' }],
|
||||
},
|
||||
]);
|
||||
expect(eventsService.getEvent).toHaveBeenCalledWith('btn', { node: child });
|
||||
});
|
||||
|
||||
test('getEventNameOptions datasource 追加数据变化', () => {
|
||||
dataSourceService.getDataSourceById.mockReturnValue({ fields: [{ name: 'f1' }] });
|
||||
const opts = getEventNameOptions('datasource', { type: 'ds', id: 'd1' });
|
||||
|
||||
@ -3,10 +3,9 @@
|
||||
*
|
||||
* 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 { validateForm } from '@tmagic/form';
|
||||
|
||||
import {
|
||||
advancedTabConfig,
|
||||
@ -20,7 +19,6 @@ import {
|
||||
numberOptions,
|
||||
removeStyleDisplayConfig,
|
||||
styleTabConfig,
|
||||
validatePropsForm,
|
||||
} from '@editor/utils/props';
|
||||
|
||||
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', () => {
|
||||
test('将 tab 内「样式」pane 的 display 置为 true', () => {
|
||||
const config = [
|
||||
|
||||
@ -21,7 +21,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { HookCodeType, HookType, NodeType } from '@tmagic/core';
|
||||
import { DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX, DATA_SOURCE_SET_DATA_METHOD_NAME } from '@tmagic/utils';
|
||||
|
||||
import { ALL_COND_OPS, editorTypeMatchRules } from '@editor/utils/type-match-rules';
|
||||
import { ALL_COND_OPS, editorTypeMatchRules, validateDataSourceFieldSelectValue } from '@editor/utils/type-match-rules';
|
||||
|
||||
const codeDslState = vi.hoisted(() => ({ value: null as Record<string, any> | null }));
|
||||
const dataSourcesState = vi.hoisted(() => ({ value: [] as any[] }));
|
||||
@ -270,6 +270,59 @@ describe('editorTypeMatchRules', () => {
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test('validateDataSourceFieldSelectValue 支持自定义 plain 值校验并保留数据源路径校验', () => {
|
||||
const config = {
|
||||
name: 'fontWeight',
|
||||
type: 'data-source-field-select',
|
||||
dataSourceFieldType: ['string', 'number'],
|
||||
fieldConfig: { type: 'select', allowCreate: true, options: [{ text: '700', value: '700' }] },
|
||||
};
|
||||
const context = ctx(config);
|
||||
const allowStringOrNumber = {
|
||||
validatePlainValue: (value: any) => {
|
||||
if (typeof value === 'string' || (typeof value === 'number' && !Number.isNaN(value))) {
|
||||
return undefined;
|
||||
}
|
||||
return 'fontWeight 类型应为字符串或数字';
|
||||
},
|
||||
};
|
||||
|
||||
expect(validateDataSourceFieldSelectValue(700, context, allowStringOrNumber)).toBeUndefined();
|
||||
expect(validateDataSourceFieldSelectValue('bold', context, allowStringOrNumber)).toBeUndefined();
|
||||
expect(firstLine(validateDataSourceFieldSelectValue({ a: 1 }, context, allowStringOrNumber))).toBe(
|
||||
'fontWeight 类型应为字符串或数字',
|
||||
);
|
||||
|
||||
dataSourcesState.value = [
|
||||
{
|
||||
id: 'ds1',
|
||||
type: 'base',
|
||||
fields: [
|
||||
{ name: 'title', type: 'string' },
|
||||
{ name: 'weight', type: 'number' },
|
||||
],
|
||||
methods: [],
|
||||
},
|
||||
];
|
||||
|
||||
expect(
|
||||
validateDataSourceFieldSelectValue(
|
||||
[`${DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX}ds1`, 'weight'],
|
||||
context,
|
||||
allowStringOrNumber,
|
||||
),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
firstLine(
|
||||
validateDataSourceFieldSelectValue(
|
||||
[`${DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX}ds1`, 'missing'],
|
||||
context,
|
||||
allowStringOrNumber,
|
||||
),
|
||||
),
|
||||
).toBe('数据源字段(missing)不存在');
|
||||
});
|
||||
|
||||
test('data-source-select id / 对象形态', () => {
|
||||
expect(run('data-source-select', { isBindDataSource: true, dataSourceId: 'ds1' })).toBeUndefined();
|
||||
expect(run('data-source-select', 'ds1', { value: 'id' })).toBeUndefined();
|
||||
|
||||
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