mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-09-05 02:04:27 +08:00
Compare commits
No commits in common. "master" and "v1.8.0-beta.10" have entirely different histories.
master
...
v1.8.0-bet
4
.github/workflows/pages.yml
vendored
4
.github/workflows/pages.yml
vendored
@ -17,14 +17,12 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 11.21.0
|
||||
|
||||
- name: Set node version to 22
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "pnpm"
|
||||
cache: 'pnpm'
|
||||
|
||||
- run: pnpm bootstrap
|
||||
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -20,7 +20,6 @@ pnpm-debug.log*
|
||||
# Editor directories and files
|
||||
.idea
|
||||
.vscode
|
||||
.codebuddy
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
|
||||
@ -1 +1 @@
|
||||
npm run coverage
|
||||
npm run test
|
||||
|
||||
43
AGENTS.md
43
AGENTS.md
@ -7,41 +7,30 @@
|
||||
|
||||
TMagic Editor 是魔方平台的可视化编辑器核心库,提供拖拽式组件编辑、配置面板、预览发布等能力。支持 Vue 和 React 双框架 Runtime,采用 pnpm monorepo 管理多个核心包。开源项目,同时支持内部业务定制。
|
||||
|
||||
编辑器本体使用 Vue 3;UI 通过 `@tmagic/design` + adapter 接入 Element Plus 或 TDesign Vue Next。
|
||||
|
||||
**技术栈:** Vue 3, TypeScript, Vite, rolldown, vitest, VitePress, Element Plus / TDesign Vue Next
|
||||
**环境:** Node.js `^20.19.0 || >=22.12.0`,pnpm `11.21.0`
|
||||
**当前版本:** `1.8.0-beta.25`
|
||||
**技术栈:** Vue 3, Element Plus, TypeScript, Vite, vitest, VitePress
|
||||
**主仓库:** `https://git.woa.com/vft-magic/tmagic-editor.git`
|
||||
**开源仓库:** `https://github.com/Tencent/tmagic-editor.git`
|
||||
|
||||
## 架构地图
|
||||
|
||||
关键目录:
|
||||
|
||||
- `packages/` — 核心 npm 包(`editor`、`form`、`stage`、`core`、`cli`、`data-source`、`schema`、`form-schema`、`design`、`utils`、`dep`、`table`、`element-plus-adapter`、`tdesign-vue-next-adapter`)
|
||||
- `runtime/` — Vue/React Runtime,以及 `vue-runtime-help`、`react-runtime-help`、`tmagic-form`
|
||||
- `vue-components/` — Vue 业务组件封装
|
||||
- `react-components/` — React 业务组件封装
|
||||
- `packages/` — 核心编辑器包(202 *.vue, 194 *.ts)
|
||||
- `runtime/` — Vue/React Runtime 实现
|
||||
- `vue-components/` — Vue 组件封装
|
||||
- `react-components/` — React 组件封装
|
||||
- `playground/` — 演示 playground
|
||||
- `docs/` — VitePress 文档
|
||||
- `docs/` — VitePress 文档(100 *.md)
|
||||
- `scripts/` — 构建和发布脚本
|
||||
- `eslint-config/` — 共享 ESLint 配置
|
||||
|
||||
## 开发约定
|
||||
|
||||
**分支策略(内部约定):** 日常开发走 `dev`;test/prod 对应 `master`。开源文档站与 playground 从 `dev` 发布。
|
||||
**提交规范:** commitlint(`@commitlint/config-conventional`)+ husky,格式 `type(scope): subject`,详见 `CONTRIBUTING.md`
|
||||
**测试:** 新增或修改的代码必须补充单元测试,覆盖率不低于 85%(lines)。`pnpm coverage` 执行两层硬门禁:
|
||||
1. 全仓 lines ≥ 85%(vitest `coverage.thresholds`)
|
||||
2. 工作区相对 HEAD 的 `packages/*/src` 变更逐文件 lines ≥ 85%(不含 design / UI adapter;未测新文件按 0% 计)
|
||||
pre-commit 跑 lint-staged 与 `pnpm check:type`,pre-push 跑 `pnpm coverage`。
|
||||
**分支策略:** dev=dev, test/prod=master
|
||||
**提交规范:** commitlint + husky,`type: 描述`
|
||||
|
||||
**禁止事项:**
|
||||
|
||||
- 禁止在核心包中引入腾讯内部专有依赖(开源项目)
|
||||
- 禁止直接修改 CHANGELOG.md,应通过 `pnpm changelog` 生成
|
||||
- 禁止提交未达 85% 测试覆盖率的新增/修改代码
|
||||
|
||||
## 常用命令
|
||||
|
||||
@ -50,16 +39,18 @@ pre-commit 跑 lint-staged 与 `pnpm check:type`,pre-push 跑 `pnpm coverage`
|
||||
pnpm pg:react # 启动 React playground
|
||||
pnpm build # 完整构建(DTS + 包)
|
||||
pnpm test # 运行测试
|
||||
pnpm coverage # 运行测试、生成覆盖率,并执行 85% 门禁
|
||||
pnpm check:type # TypeScript 类型检查
|
||||
pnpm lint-fix # ESLint 修复
|
||||
pnpm docs:dev # 启动文档开发
|
||||
pnpm release # 发版
|
||||
|
||||
## 当前状态
|
||||
|
||||
**当前里程碑:** {待人工填写}
|
||||
|
||||
## 深入阅读
|
||||
|
||||
| 文档 | 说明 |
|
||||
| --------------- | ---------------- |
|
||||
| docs/ | VitePress 文档站 |
|
||||
| CONTRIBUTING.md | 贡献指南 |
|
||||
| CHANGELOG.md | 变更日志 |
|
||||
| 文档 | 说明 |
|
||||
|------|------|
|
||||
| docs/ | VitePress 文档站 |
|
||||
| CONTRIBUTING.md | 贡献指南 |
|
||||
| CHANGELOG.md | 变更日志 |
|
||||
|
||||
234
CHANGELOG.md
234
CHANGELOG.md
@ -1,237 +1,3 @@
|
||||
# [1.8.0-beta.27](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.26...v1.8.0-beta.27) (2026-09-04)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **editor,form:** 合并宿主 FORM_CONTEXT 并修复 formState mForm 自引用 ([b0d43dc](https://github.com/Tencent/tmagic-editor/commit/b0d43dc2775a2241499de9ab1b165a5ad3febcf3))
|
||||
* **editor:** 修复 eventsService resetState 响应式失效并在编辑器重置时清空 ([959c032](https://github.com/Tencent/tmagic-editor/commit/959c0322b6885ee00c38bc830f58841ccbb42a40))
|
||||
* **form:** 避免 Select init 请求失败或路径缺失时抛出未处理异常 ([d1cb8c3](https://github.com/Tencent/tmagic-editor/commit/d1cb8c3853ac724b424dbe8de2d2a63bf6302324))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **data-source:** 新增数据源 mounted 生命周期 ([092bc29](https://github.com/Tencent/tmagic-editor/commit/092bc2914d444c9527140a204ab8bc2776d31a1b))
|
||||
# [1.8.0-beta.26](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.25...v1.8.0-beta.26) (2026-09-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **editor:** inject eventBus 时提供 null 默认值 ([58f2a94](https://github.com/Tencent/tmagic-editor/commit/58f2a943b866a836817b12b93194eb0e13f82d16))
|
||||
* **editor:** 避免 defaultSelected 早于 root 就绪时 select 抛出未处理错误 ([08b610a](https://github.com/Tencent/tmagic-editor/commit/08b610a4f2de9c6c433ec792fc46039e6f57e7b0))
|
||||
* **form:** 避免 Tooltip 无合法触发器时报 ElOnlyChild ([90e4bec](https://github.com/Tencent/tmagic-editor/commit/90e4bec70b25194446b6b7c8453ac7c9dc32bff3))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **editor:** 统一列表字段添加按钮样式并支持新增后自动滚动 ([02eeb2e](https://github.com/Tencent/tmagic-editor/commit/02eeb2e87f78f59d18baaf471704d3f574c5556c))
|
||||
* **form:** 容器透传 labelPosition ([c34ed6a](https://github.com/Tencent/tmagic-editor/commit/c34ed6aafc0542ea12ba7d32e0130eb227da1f81))
|
||||
* **form:** 新增无渲染校验入口,支持 Node/CI 环境执行表单校验 ([93dc0d2](https://github.com/Tencent/tmagic-editor/commit/93dc0d2187046d5d5da9409cc67e21afd01a0abe))
|
||||
* **form:** 用 FormContext 与 provide/inject 替代 extendState 钩子 ([a7999f5](https://github.com/Tencent/tmagic-editor/commit/a7999f50d542e3c369db4a1f6f42339ec0056e04))
|
||||
* **form:** 统一字段值 effect 机制并将 nested 重命名为 innerConfig ([f2ee7ae](https://github.com/Tencent/tmagic-editor/commit/f2ee7ae7b522d0fe78f354de531d6cbd6116ab4b))
|
||||
# [1.8.0-beta.25](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.24...v1.8.0-beta.25) (2026-08-13)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **editor:** 统一数据源字段路径解析并优化 typeMatch 校验 ([05fbdb1](https://github.com/Tencent/tmagic-editor/commit/05fbdb17b53e688f562202a32cdb748afae65347))
|
||||
# [1.8.0-beta.24](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.23...v1.8.0-beta.24) (2026-08-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **editor:** 修复连续 add 与批量插入时节点顺序错乱 ([a7198e2](https://github.com/Tencent/tmagic-editor/commit/a7198e273123484822987c4d1838b2cc4689fb86))
|
||||
* **editor:** 移除 removeStyleDisplayConfig 并取消属性 tab 懒加载 ([8550e6f](https://github.com/Tencent/tmagic-editor/commit/8550e6f854e1d0d0267b7064836f845aa2b004f9))
|
||||
* **editor:** 节点未注册时回退使用 formValue 加载事件与动作选项 ([53c4763](https://github.com/Tencent/tmagic-editor/commit/53c4763a596abfb355061db651dc56843e75cacc))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **form,editor:** 静默校验模式下跳过叶子字段渲染以提升性能 ([44cd3d4](https://github.com/Tencent/tmagic-editor/commit/44cd3d480f5f3b76bb6ba6d2bc4b2632f6a5e957))
|
||||
# [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)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **core:** 为背景图片 URL 添加引号 ([97e0cac](https://github.com/Tencent/tmagic-editor/commit/97e0cac44a2d6ceafaf95fbc4ae716bfe5d47f70))
|
||||
* **editor:** 使用属性面板中的源码删除样式无效 ([cde0805](https://github.com/Tencent/tmagic-editor/commit/cde08052924ddef1c51faeb68ce15d3195827adb))
|
||||
* **editor:** 右键菜单动态变化后修复菜单位置防止截断 ([6919a0a](https://github.com/Tencent/tmagic-editor/commit/6919a0a0b19efff92821fc8d52b61b2f7382a817))
|
||||
* 跳过无字段名称配置的类型校验 ([11bf037](https://github.com/Tencent/tmagic-editor/commit/11bf037054774937132743a40006364e49ee8fd5))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **design:** 优化 FormItem 和 colorPicker 样式 ([2e031cc](https://github.com/Tencent/tmagic-editor/commit/2e031cc1346fb6ea739f0d45a89213b6577dc7ee))
|
||||
* **editor:** 数据源字段选择器校验提示细化并支持 fieldConfig 类型校验 ([fe9e754](https://github.com/Tencent/tmagic-editor/commit/fe9e754b50f14e6034170b2284553ec1c4536150))
|
||||
* **form,design:** 一行多个表单默认右靠齐label ([48abab7](https://github.com/Tencent/tmagic-editor/commit/48abab7051b93b7ed336172c21ab3b908733f2e7))
|
||||
* **form,editor:** submitForm/validateForm 静默挂载时注入静默标记,vs-code 字段跳过 monaco 渲染 ([3b9d5da](https://github.com/Tencent/tmagic-editor/commit/3b9d5da0bc9f323e944c0e2adb4fc88642ce1915))
|
||||
* **form:** text 类型校验允许数字值,兼容数据源字段配置 ([1f389ff](https://github.com/Tencent/tmagic-editor/commit/1f389ff9fd77fb93ad749f3fdbbc8aa0fe8725ac))
|
||||
* 新增 page-structure-change 事件,支持页面增删统一通知 ([745f449](https://github.com/Tencent/tmagic-editor/commit/745f44939605528e8e2ccd0639478db98293a441))
|
||||
# [1.8.0-beta.14](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.13...v1.8.0-beta.14) (2026-07-23)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **editor:** 保留历史操作来源并补发插入事件 ([89689bb](https://github.com/Tencent/tmagic-editor/commit/89689bb9fd2d2d3edcc027b87932cbdd3a5755ad))
|
||||
* **form:** 统一 extendState 合并逻辑 ([0feada8](https://github.com/Tencent/tmagic-editor/commit/0feada8d1fa4057444da746040b3824df09eef07))
|
||||
* **stage:** 修复选中组件时 stage 外层滚动 ([09eecd5](https://github.com/Tencent/tmagic-editor/commit/09eecd5bedd49e5a002fa5ef122eaa1579dc895b))
|
||||
* 调整编辑器配置项展示 ([8699dc1](https://github.com/Tencent/tmagic-editor/commit/8699dc12af9d52b1c460f61f3d3f48e4cf3fc5f6))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **design,editor:** 新增magic-admin主题样式,优化组件表单配置布局样式 ([b13cd34](https://github.com/Tencent/tmagic-editor/commit/b13cd3425b16eaeac33f897bf51e80f7984db782))
|
||||
* playground 支持主题切换 ([ee5247c](https://github.com/Tencent/tmagic-editor/commit/ee5247ca9436c385a5b634d161726006912fe77c))
|
||||
# [1.8.0-beta.13](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.11...v1.8.0-beta.13) (2026-07-22)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **adapters:** tag 非法 type 回退为默认主题 ([90492ad](https://github.com/Tencent/tmagic-editor/commit/90492ad3eac82f052e99e7a286772a3feac288f7))
|
||||
* **core:** 在 initNode 前校验 config.items 为数组 ([aa71fe9](https://github.com/Tencent/tmagic-editor/commit/aa71fe9ddd011e98b40af04e898bdf79263e0e89))
|
||||
* **editor:** 为 FloatingBox 拖拽遮罩添加失焦等兜底清理,避免残留导致编辑器无法点击 ([4a893d3](https://github.com/Tencent/tmagic-editor/commit/4a893d35df354cfedce8e57b0deaaa0c535f8fba))
|
||||
* **editor:** 修复 FloatingBox 拖动时鼠标进入 iframe 区域事件丢失 ([284be0d](https://github.com/Tencent/tmagic-editor/commit/284be0d276d90fe58cdcd94372dbacb476fd919c))
|
||||
* **editor:** 修复 Monaco 编辑器卸载时容器失效与 Canceled 未处理异常 ([4a15da2](https://github.com/Tencent/tmagic-editor/commit/4a15da21083a30d2590a03fe68c488c71ec44eb2))
|
||||
* **editor:** 修正 StyleSetter 字段 prop 取值逻辑 ([e5e0da3](https://github.com/Tencent/tmagic-editor/commit/e5e0da37852c78728e6506980dc2de6928dd571c))
|
||||
* **editor:** 延迟 FloatingBox 拖拽遮罩到实际位移时显示,修复关闭按钮点击失效 ([9aa251c](https://github.com/Tencent/tmagic-editor/commit/9aa251ce57e36120a1f6c3b4835ba3bcd604448d))
|
||||
* **form,editor:** 完善表单校验与样式属性匹配 ([ef66e8a](https://github.com/Tencent/tmagic-editor/commit/ef66e8a59863f9ffeb7292adcb314e5620c238ef))
|
||||
* **form:** radio group disabled不生效 ([42d45be](https://github.com/Tencent/tmagic-editor/commit/42d45bed187393ae02a1fef1df5d46b114f198a8))
|
||||
* **form:** typeMatch 使用 toLine 统一类型转换并处理异步 type/defaultValue ([6cdf54a](https://github.com/Tencent/tmagic-editor/commit/6cdf54a4a663dd6531c70a069220b6d9a63ca7c0))
|
||||
* **form:** 修复隐藏字段校验处理 ([78b34a4](https://github.com/Tencent/tmagic-editor/commit/78b34a452b1ac263b652ace81ef351f56fae7ff9))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **editor:** 扩展 customContentMenu 支持 getTarget 获取右键目标 ([bbe73aa](https://github.com/Tencent/tmagic-editor/commit/bbe73aae6437cabc8cfada24367c3d969687faa9))
|
||||
* **editor:** 抽离表单视图与对比逻辑 ([6f3f321](https://github.com/Tencent/tmagic-editor/commit/6f3f321736a40b242e7b556b0bea456484f161ea))
|
||||
* **editor:** 支持自定义历史对比/回滚弹窗内表单组件的尺寸 ([605f07c](https://github.com/Tencent/tmagic-editor/commit/605f07c8fe24fd67de22f5cb9adfdb151d3b8772))
|
||||
* **editor:** 支持通过 uiService 配置列布局最小宽度 ([3c21f45](https://github.com/Tencent/tmagic-editor/commit/3c21f45e88dfe7c61552c704bd9cbd09215017fe))
|
||||
* **editor:** 新增属性配置表单校验联动能力 ([0e46692](https://github.com/Tencent/tmagic-editor/commit/0e4669261fe50acaa8f72203dbc342e6eff324e6))
|
||||
* **editor:** 注册编辑器字段内置 typeMatch 校验规则并补充文档 ([1c67b5e](https://github.com/Tencent/tmagic-editor/commit/1c67b5e77b726ded63b12d761faff37e97563ff2))
|
||||
* **form,editor:** 新增 validateForm 校验能力并接入源码保存校验联动 ([d07e48b](https://github.com/Tencent/tmagic-editor/commit/d07e48bb101a3833b57b97001af3f053e2392c7a))
|
||||
* **form:** 支持 rules.typeMatch 按字段 type 校验值类型与选项匹配 ([734e6a8](https://github.com/Tencent/tmagic-editor/commit/734e6a8ee6f9a948273c25461556eb4f54adc1ee))
|
||||
* **table:** 为操作列新增 Popconfirm 二次确认能力 ([9e8272b](https://github.com/Tencent/tmagic-editor/commit/9e8272b521784265f18007e11f94d2ae5ee3faba))
|
||||
* **table:** 抽取 ActionPopconfirm 并优化 sub-actions 展开交互 ([b183e71](https://github.com/Tencent/tmagic-editor/commit/b183e71f642fba84c82b0e8e63bcb03e0d27e610))
|
||||
* **table:** 操作列支持 sub-actions 更多菜单并抽取 ActionButton 组件 ([78a1259](https://github.com/Tencent/tmagic-editor/commit/78a1259665c9d7057f3cf0066beef2604949345b))
|
||||
# [1.8.0-beta.12](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.11...v1.8.0-beta.12) (2026-07-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **adapters:** tag 非法 type 回退为默认主题 ([90492ad](https://github.com/Tencent/tmagic-editor/commit/90492ad3eac82f052e99e7a286772a3feac288f7))
|
||||
* **core:** 在 initNode 前校验 config.items 为数组 ([aa71fe9](https://github.com/Tencent/tmagic-editor/commit/aa71fe9ddd011e98b40af04e898bdf79263e0e89))
|
||||
* **editor:** 为 FloatingBox 拖拽遮罩添加失焦等兜底清理,避免残留导致编辑器无法点击 ([4a893d3](https://github.com/Tencent/tmagic-editor/commit/4a893d35df354cfedce8e57b0deaaa0c535f8fba))
|
||||
* **editor:** 修复 FloatingBox 拖动时鼠标进入 iframe 区域事件丢失 ([284be0d](https://github.com/Tencent/tmagic-editor/commit/284be0d276d90fe58cdcd94372dbacb476fd919c))
|
||||
* **editor:** 修复 Monaco 编辑器卸载时容器失效与 Canceled 未处理异常 ([4a15da2](https://github.com/Tencent/tmagic-editor/commit/4a15da21083a30d2590a03fe68c488c71ec44eb2))
|
||||
* **editor:** 修正 StyleSetter 字段 prop 取值逻辑 ([e5e0da3](https://github.com/Tencent/tmagic-editor/commit/e5e0da37852c78728e6506980dc2de6928dd571c))
|
||||
* **editor:** 延迟 FloatingBox 拖拽遮罩到实际位移时显示,修复关闭按钮点击失效 ([9aa251c](https://github.com/Tencent/tmagic-editor/commit/9aa251ce57e36120a1f6c3b4835ba3bcd604448d))
|
||||
* **form,editor:** 完善表单校验与样式属性匹配 ([ef66e8a](https://github.com/Tencent/tmagic-editor/commit/ef66e8a59863f9ffeb7292adcb314e5620c238ef))
|
||||
* **form:** radio group disabled不生效 ([42d45be](https://github.com/Tencent/tmagic-editor/commit/42d45bed187393ae02a1fef1df5d46b114f198a8))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **editor:** 扩展 customContentMenu 支持 getTarget 获取右键目标 ([bbe73aa](https://github.com/Tencent/tmagic-editor/commit/bbe73aae6437cabc8cfada24367c3d969687faa9))
|
||||
* **editor:** 支持自定义历史对比/回滚弹窗内表单组件的尺寸 ([605f07c](https://github.com/Tencent/tmagic-editor/commit/605f07c8fe24fd67de22f5cb9adfdb151d3b8772))
|
||||
* **editor:** 支持通过 uiService 配置列布局最小宽度 ([3c21f45](https://github.com/Tencent/tmagic-editor/commit/3c21f45e88dfe7c61552c704bd9cbd09215017fe))
|
||||
* **editor:** 新增属性配置表单校验联动能力 ([0e46692](https://github.com/Tencent/tmagic-editor/commit/0e4669261fe50acaa8f72203dbc342e6eff324e6))
|
||||
* **editor:** 注册编辑器字段内置 typeMatch 校验规则并补充文档 ([1c67b5e](https://github.com/Tencent/tmagic-editor/commit/1c67b5e77b726ded63b12d761faff37e97563ff2))
|
||||
* **form,editor:** 新增 validateForm 校验能力并接入源码保存校验联动 ([d07e48b](https://github.com/Tencent/tmagic-editor/commit/d07e48bb101a3833b57b97001af3f053e2392c7a))
|
||||
* **form:** 支持 rules.typeMatch 按字段 type 校验值类型与选项匹配 ([734e6a8](https://github.com/Tencent/tmagic-editor/commit/734e6a8ee6f9a948273c25461556eb4f54adc1ee))
|
||||
* **table:** 为操作列新增 Popconfirm 二次确认能力 ([9e8272b](https://github.com/Tencent/tmagic-editor/commit/9e8272b521784265f18007e11f94d2ae5ee3faba))
|
||||
* **table:** 抽取 ActionPopconfirm 并优化 sub-actions 展开交互 ([b183e71](https://github.com/Tencent/tmagic-editor/commit/b183e71f642fba84c82b0e8e63bcb03e0d27e610))
|
||||
* **table:** 操作列支持 sub-actions 更多菜单并抽取 ActionButton 组件 ([78a1259](https://github.com/Tencent/tmagic-editor/commit/78a1259665c9d7057f3cf0066beef2604949345b))
|
||||
# [1.8.0-beta.11](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.10...v1.8.0-beta.11) (2026-06-29)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **editor:** support custom width and initial diff mode for history diff dialog ([83f4e52](https://github.com/Tencent/tmagic-editor/commit/83f4e528450be3971eea25b59dab126e04ac4df4))
|
||||
* **form:** add debug mode to submitForm ([4330738](https://github.com/Tencent/tmagic-editor/commit/4330738b9f3d91c728826f5a74129cf1febbcf48))
|
||||
|
||||
|
||||
|
||||
# [1.8.0-beta.10](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.9...v1.8.0-beta.10) (2026-06-26)
|
||||
|
||||
|
||||
|
||||
@ -18,7 +18,7 @@ https://tencent.github.io/tmagic-editor/playground/index.html
|
||||
|
||||
node.js ^20.19.0 || >=22.12.0
|
||||
|
||||
pnpm 11.20.0
|
||||
pnpm >= 10
|
||||
|
||||
先安装 pnpm
|
||||
|
||||
|
||||
@ -111,10 +111,6 @@ export default defineConfig({
|
||||
text: '@tmagic/form',
|
||||
link: '/guide/advanced/tmagic-form.md',
|
||||
},
|
||||
{
|
||||
text: '属性配置表单校验联动',
|
||||
link: '/guide/advanced/prop-form-validate.md',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@ -492,15 +488,6 @@ export default defineConfig({
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '表单校验',
|
||||
items: [
|
||||
{
|
||||
text: '表单校验',
|
||||
link: '/form-config/rules.md',
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
text: '表单对比',
|
||||
items: [
|
||||
@ -588,13 +575,10 @@ export default defineConfig({
|
||||
},
|
||||
resolve: {
|
||||
alias:[
|
||||
{ find: /^@form/, replacement: path.join(__dirname, '../../packages/form/src/') },
|
||||
{ find: /^@tmagic\/form-schema/, replacement: path.join(__dirname, '../../packages/form-schema/src/index.ts') },
|
||||
{ find: /^@tmagic\/form\/headless$/, replacement: path.join(__dirname, '../../packages/form/src/headless.ts') },
|
||||
{ find: /^@tmagic\/form/, replacement: path.join(__dirname, '../../packages/form/src/index.ts') },
|
||||
{ find: /^@tmagic\/utils/, replacement: path.join(__dirname, '../../packages/utils/src/index.ts') },
|
||||
{ find: /^@tmagic\/schema/, replacement: path.join(__dirname, '../../packages/schema/src/index.ts') },
|
||||
{ find: /^@tmagic\/design\/headless$/, replacement: path.join(__dirname, '../../packages/design/src/headless.ts') },
|
||||
{ find: /^@tmagic\/design/, replacement: path.join(__dirname, '../../packages/design/src/index.ts') },
|
||||
{ find: /^@tmagic\/element-plus-adapter/, replacement: path.join(__dirname, '../../packages/element-plus-adapter/src/index.ts') },
|
||||
]
|
||||
|
||||
@ -162,7 +162,7 @@
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-primary,#409eff);;
|
||||
color: #409eff;
|
||||
background-color: #f9fafc;
|
||||
}
|
||||
|
||||
|
||||
@ -103,19 +103,3 @@
|
||||
::: details 查看 EditorChangeEvent 类型定义
|
||||
<<< @/../packages/editor/src/type.ts#EditorChangeEvent{ts}
|
||||
:::
|
||||
|
||||
## invalid-node-change
|
||||
|
||||
- **详情:** 节点校验错误状态发生变化时触发(记录 / 清除错误、删除节点、整体替换 root、撤销重做还原错误标记等场景均会触发),在 [editorService.setInvalidNode()](./editorServiceMethods.md#setinvalidnode)、[editorService.deleteInvalidNode()](./editorServiceMethods.md#deleteinvalidnode)、[editorService.resetInvalidNodeId()](./editorServiceMethods.md#resetinvalidnodeid) 及 DSL 操作 / 撤销重做内部调用后触发。
|
||||
|
||||
携带当前完整的错误 Map,供非响应式消费方(如自定义工具栏)订阅,实现组件树标红、保存拦截等。需响应式读取(如组件树节点内容)请直接读取 `editorService.get('invalidNodeIds')`。
|
||||
|
||||
- **事件回调函数:** `(invalidNodeIds: Map<Id, NodeInvalidInfo>) => void`
|
||||
|
||||
::: details 查看 NodeInvalidInfo 及关联类型定义
|
||||
<<< @/../packages/editor/src/type.ts#NodeInvalidInfo{ts}
|
||||
|
||||
<<< @/../packages/editor/src/type.ts#NodeInvalidSource{ts}
|
||||
|
||||
<<< @/../packages/schema/src/index.ts#Id{ts}
|
||||
:::
|
||||
|
||||
@ -48,7 +48,7 @@ DSL 操作方法(`add` / `remove` / `update` 等)默认返回操作结果(
|
||||
## get
|
||||
|
||||
- **参数:**
|
||||
- `{'root' | 'page' | 'parent' | 'node' | 'highlightNode' | 'nodes' | 'modifiedNodeIds' | 'invalidNodeIds' | 'pageLength' | 'pageFragmentLength' | 'stage' | 'stageLoading' | 'disabledMultiSelect' | 'alwaysMultiSelect'} name`
|
||||
- `{'root' | 'page' | 'parent' | 'node' | 'highlightNode' | 'nodes' | 'modifiedNodeIds' | 'pageLength' | 'pageFragmentLength' | 'stage' | 'stageLoading' | 'disabledMultiSelect' | 'alwaysMultiSelect'} name`
|
||||
|
||||
- **返回:**
|
||||
- `{any} value`
|
||||
@ -71,8 +71,6 @@ DSL 操作方法(`add` / `remove` / `update` 等)默认返回操作结果(
|
||||
|
||||
'modifiedNodeIds': 当前页面所有改动过的节点id
|
||||
|
||||
'invalidNodeIds': 校验失败的节点错误信息(`Map<Id, NodeInvalidInfo>`),供组件树标红提示与保存拦截读取
|
||||
|
||||
'pageLength': 所以页面个数
|
||||
|
||||
'pageFragmentLength': 页面片个数
|
||||
@ -95,7 +93,7 @@ const node = editorService.get("node");
|
||||
|
||||
## set
|
||||
|
||||
- `{'root' | 'page' | 'parent' | 'node' | 'highlightNode' | 'nodes' | 'modifiedNodeIds' | 'invalidNodeIds' | 'pageLength' | 'pageFragmentLength' | 'stage' | 'stageLoading' | 'disabledMultiSelect' | 'alwaysMultiSelect'} name`
|
||||
- `{'root' | 'page' | 'parent' | 'node' | 'highlightNode' | 'nodes' | 'modifiedNodeIds' | 'pageLength' | 'pageFragmentLength' | 'stage' | 'stageLoading' | 'disabledMultiSelect' | 'alwaysMultiSelect'} name`
|
||||
- `{any} value`
|
||||
|
||||
- **详情:**
|
||||
@ -480,8 +478,6 @@ 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[] }>}` 更新前后的节点信息
|
||||
@ -500,8 +496,6 @@ editorService.highlight("text_123");
|
||||
当被更新节点正好在当前选中列表中时,state 会自动同步到新的节点引用,无需调用方处理
|
||||
|
||||
当被更新节点正好是当前页面时,state.page 也会同步到新的节点引用;更新非当前页面(不同 ID)时不会把编辑器切到该页
|
||||
|
||||
默认会将传入配置与现有节点深合并(保留未传入的字段)。若需要完整 DSL 覆盖(如源码编辑写回),传入 `replace: true`。
|
||||
:::
|
||||
|
||||
## update
|
||||
@ -516,11 +510,6 @@ 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 错误来源:属性表单 / 样式表单
|
||||
- `{string}` error 错误文案(可为含 `<br>` 的 HTML);为空时表示清除该来源的错误记录
|
||||
|
||||
::: details 查看 ChangeRecord 类型定义
|
||||
<<< @/../packages/form-schema/src/base.ts#ChangeRecord{ts}
|
||||
@ -551,12 +540,6 @@ editorService.highlight("text_123");
|
||||
才退化为整节点替换(如内部 `sort` / `moveLayer` / 拖动等纯快照场景)。
|
||||
:::
|
||||
|
||||
:::tip
|
||||
**`replace: true` 整节点替换:** 默认 `update` 会将传入配置与现有节点深合并,未传入的字段会保留。
|
||||
传入 `replace: true` 后跳过合并与布局相关变换,直接用传入配置覆盖,传入对象中不存在的字段会被删除。
|
||||
属性面板局部改动请保持默认;源码编辑 / 完整 DSL 回写等场景使用 `replace: true`。
|
||||
:::
|
||||
|
||||
## sort
|
||||
|
||||
- **[扩展支持](../../guide/editor-expand#行为扩展):** 是
|
||||
@ -944,89 +927,6 @@ await editorService.revertPageStepById(historyIds);
|
||||
|
||||
重置当前记录的修改过的节点id记录,通常用于保存之后
|
||||
|
||||
## setInvalidNode
|
||||
|
||||
- **参数:**
|
||||
- `{Id}` id 节点 id
|
||||
- `{'props' | 'style'}` source 错误来源:属性表单(`props`)/ 样式表单(`style`)
|
||||
- `{string}` message 错误文案(可能为包含 `<br>` 的 HTML)
|
||||
|
||||
- **详情:**
|
||||
|
||||
记录(或覆盖)某个节点在指定来源上的校验错误信息,写入 `invalidNodeIds` 状态并发出 [invalid-node-change](#invalid-node-change) 事件。
|
||||
|
||||
属性表单与样式表单是两个独立的 `FormPanel` 且均指向同一节点,故以来源为键分别保存,避免某个面板校验通过时误清另一个面板记录的错误。节点视为存在错误当且仅当任一来源存在非空文本。
|
||||
|
||||
- **示例:**
|
||||
|
||||
```js
|
||||
import { editorService } from "@tmagic/editor";
|
||||
|
||||
// 标记 text_123 的属性表单校验错误
|
||||
editorService.setInvalidNode("text_123", "props", "标题不能为空");
|
||||
```
|
||||
|
||||
## deleteInvalidNode
|
||||
|
||||
- **参数:**
|
||||
- `{Id}` id 节点 id
|
||||
- `{'props' | 'style'}` source 可选;指定来源则仅删除该来源错误,不传则删除该节点全部来源的错误
|
||||
|
||||
- **详情:**
|
||||
|
||||
删除节点的校验错误记录。仅当该来源被清空且另一来源也无错误时,节点整体错误记录才会被移除;随后发出 [invalid-node-change](#invalid-node-change) 事件。
|
||||
|
||||
## getInvalidNodeIds
|
||||
|
||||
- **返回:**
|
||||
- {`Map<Id, NodeInvalidInfo>`} 当前存在校验错误的节点错误 Map(key 为节点 id)
|
||||
|
||||
- **详情:**
|
||||
|
||||
获取当前存在校验错误的节点错误 Map,供组件树标红提示与保存拦截读取。
|
||||
|
||||
::: details 查看 NodeInvalidInfo 及关联类型定义
|
||||
<<< @/../packages/editor/src/type.ts#NodeInvalidInfo{ts}
|
||||
|
||||
<<< @/../packages/editor/src/type.ts#NodeInvalidSource{ts}
|
||||
|
||||
<<< @/../packages/schema/src/index.ts#Id{ts}
|
||||
:::
|
||||
|
||||
- **示例:**
|
||||
|
||||
```js
|
||||
import { editorService } from "@tmagic/editor";
|
||||
|
||||
// 保存前检查是否存在校验错误的组件
|
||||
const invalidNodeIds = editorService.getInvalidNodeIds();
|
||||
if (invalidNodeIds.size > 0) {
|
||||
const names = [...invalidNodeIds.keys()].map((id) => {
|
||||
const node = editorService.getNodeById(id);
|
||||
return node?.name ? `${node.name}(${id})` : `${id}`;
|
||||
});
|
||||
console.warn(`以下组件存在配置校验错误,请修复后再保存:${names.join("、")}`);
|
||||
}
|
||||
```
|
||||
|
||||
## getInvalidNodeInfo
|
||||
|
||||
- **参数:**
|
||||
- `{Id}` id 节点 id
|
||||
|
||||
- **返回:**
|
||||
- {`NodeInvalidInfo` | undefined} 指定节点的校验错误信息(含 `props` / `style` 来源的错误文案)
|
||||
|
||||
- **详情:**
|
||||
|
||||
获取指定节点的校验错误信息。
|
||||
|
||||
## resetInvalidNodeId
|
||||
|
||||
- **详情:**
|
||||
|
||||
清空全部校验错误记录(即 `invalidNodeIds` 状态),随后发出 [invalid-node-change](#invalid-node-change) 事件。
|
||||
|
||||
## resetState
|
||||
|
||||
- **详情:**
|
||||
|
||||
@ -16,11 +16,7 @@
|
||||
|
||||
- **详情:**
|
||||
|
||||
批量设置多个组件类型的事件列表。增量合并语义:只写入入参中出现的类型,已有的其他类型保持不变。
|
||||
|
||||
::: tip 切换 runtime 版本
|
||||
不同版本的 runtime 事件表不同,直接调用 `setEvents` 会让上一个版本独有的类型残留。是否需要清掉上一个版本的数据由业务自行决定,需要清掉时先调用 [`resetState`](#resetstate) 再写入新表。
|
||||
:::
|
||||
批量设置多个组件类型的事件列表
|
||||
|
||||
- **示例:**
|
||||
|
||||
@ -51,7 +47,7 @@ eventsService.setEvents({
|
||||
|
||||
- **详情:**
|
||||
|
||||
设置指定组件类型的事件列表,只影响该类型
|
||||
设置指定组件类型的事件列表
|
||||
|
||||
- **示例:**
|
||||
|
||||
@ -69,7 +65,6 @@ eventsService.setEvent('button', [
|
||||
- **参数:**
|
||||
|
||||
- `{string}` type 组件类型
|
||||
- `{ node?: MNode | null }` data 可选上下文,便于插件按节点定制事件列表(默认 `{}`)
|
||||
|
||||
- **返回:**
|
||||
|
||||
@ -77,25 +72,15 @@ eventsService.setEvent('button', [
|
||||
|
||||
- **详情:**
|
||||
|
||||
获取指定组件类型的事件列表。默认实现仅按 `type` 返回注册事件;可通过 `usePlugin` 的 `beforeGetEvent` / `afterGetEvent` 读取 `data.node` 做节点级过滤或扩展。
|
||||
获取指定组件类型的事件列表
|
||||
|
||||
- **示例:**
|
||||
|
||||
```js
|
||||
import { eventsService } from '@tmagic/editor';
|
||||
|
||||
const events = eventsService.getEvent('button', { node });
|
||||
const events = eventsService.getEvent('button');
|
||||
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
|
||||
@ -110,7 +95,7 @@ eventsService.usePlugin({
|
||||
|
||||
- **详情:**
|
||||
|
||||
批量设置多个组件类型的方法列表。语义与 `setEvents` 一致:增量合并,入参中不存在的类型保持不变。
|
||||
批量设置多个组件类型的方法列表
|
||||
|
||||
- **示例:**
|
||||
|
||||
@ -142,7 +127,7 @@ eventsService.setMethods({
|
||||
|
||||
- **详情:**
|
||||
|
||||
设置指定组件类型的方法列表,只影响该类型
|
||||
设置指定组件类型的方法列表
|
||||
|
||||
- **示例:**
|
||||
|
||||
@ -161,9 +146,7 @@ eventsService.setMethod('video', [
|
||||
- **参数:**
|
||||
|
||||
- `{string}` type 组件类型
|
||||
- `{ node?: MNode | null; targetId?: Id }` data 可选上下文(默认 `{}`)
|
||||
- `targetId`:目标节点 id
|
||||
- `node`:目标节点配置,便于插件按节点定制方法列表
|
||||
- `{string | number}` _targetId 目标节点id(保留参数,便于扩展时按节点定制)
|
||||
|
||||
- **返回:**
|
||||
|
||||
@ -171,36 +154,15 @@ 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', { targetId: 'video_123', node });
|
||||
const methods = eventsService.getMethod('video', 'video_123');
|
||||
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
|
||||
@ -213,17 +175,14 @@ eventsService.usePlugin({
|
||||
|
||||
- **详情:**
|
||||
|
||||
重置事件服务状态,清空所有事件和方法配置。`setEvents` / `setMethods` 是增量合并,切换 runtime 版本这类需要丢弃上一份事件表的场景,由业务在写入新表前自行调用本方法。
|
||||
重置事件服务状态,清空所有事件和方法配置
|
||||
|
||||
- **示例:**
|
||||
|
||||
```js
|
||||
import { eventsService } from '@tmagic/editor';
|
||||
|
||||
// 切到另一个版本的 runtime:先清空,再写入该版本的事件表
|
||||
eventsService.resetState();
|
||||
eventsService.setEvents(nextVersionEvents);
|
||||
eventsService.setMethods(nextVersionMethods);
|
||||
```
|
||||
|
||||
## destroy
|
||||
|
||||
@ -630,10 +630,6 @@ const propsValues = {
|
||||
该属性最终会设置到[eventsService](./eventsServiceMethods.md)中,所以也可直接调用[eventsService.setEvents()](./eventsServiceMethods.md#setEvents)与[eventsService.setMethods()](./eventsServiceMethods.md#setMethods)方法来配置
|
||||
:::
|
||||
|
||||
:::warning
|
||||
该属性每次变更都是增量合并,不会清掉上一次写入的类型。切换 runtime 版本时若需要丢弃旧版本的事件表,请自行调用 [eventsService.resetState()](./eventsServiceMethods.md#resetstate)
|
||||
:::
|
||||
|
||||
- **默认值:** `{}`
|
||||
|
||||
- **类型:** `Record<string, { events: EventOption[]; methods: EventOption[] }>`
|
||||
@ -1023,18 +1019,6 @@ alt: 按住alt键启动识别
|
||||
|
||||
|
||||
- **类型:** `'default' | 'alt' | ''`
|
||||
|
||||
## containerHighlightAddOnly
|
||||
|
||||
- **详情:**
|
||||
|
||||
是否只有新增组件(从组件列表拖入新组件)时才启用识别容器
|
||||
|
||||
开启后,在画布中拖动已有组件时不再识别容器,即已有组件不能通过拖动加入其他容器;新增组件仍然按 [containerHighlightType](#containerHighlightType) 配置的方式识别
|
||||
|
||||
- **默认值:** `false`
|
||||
|
||||
- **类型:** `boolean`
|
||||
|
||||
## stageRect
|
||||
|
||||
@ -1281,7 +1265,7 @@ const guidesOptions = {
|
||||
## disabledShowSrc
|
||||
|
||||
- **详情:**
|
||||
|
||||
|
||||
禁用属性配置面板右下角"显示源码"的按钮
|
||||
|
||||
该按钮可以查看和编辑组件的 JSON 配置
|
||||
@ -1298,45 +1282,6 @@ const guidesOptions = {
|
||||
</template>
|
||||
```
|
||||
|
||||
## enablePropsFormValidate
|
||||
|
||||
- **详情:**
|
||||
|
||||
是否启用「属性配置表单校验」联动能力。
|
||||
|
||||
开启后(默认 `false` 关闭),当属性面板(属性表单 / 样式表单)校验失败时,编辑器会**仍按当前表单值更新节点**,并把错误信息集中记录到 `editorService`(`invalidNodeIds` 状态),用于:
|
||||
|
||||
- 组件树(图层)中对出错节点标红并显示错误图标,鼠标悬停可查看错误文案;
|
||||
- 保存前拦截:业务可通过 `editorService.getInvalidNodeIds()` 读取错误节点,存在校验错误时阻止保存(参考 [playground 菜单保存拦截](../../guide/advanced/prop-form-validate.md#保存拦截))。
|
||||
|
||||
关闭时保持原行为:属性 / 样式表单校验失败则丢弃本次改动,不写入节点。
|
||||
|
||||
:::tip
|
||||
校验错误以「来源」为维度分别记录 —— 属性表单来源记为 `props`,样式表单来源记为 `style`;两者指向同一节点,互不覆盖。节点只要任一来源存在错误即视为出错。
|
||||
|
||||
错误信息会随 DSL 操作写入历史记录快照,因此「撤销 / 重做」能正确还原校验错误状态(撤销一个「校验失败」的改动后错误消失,重做后错误恢复)。
|
||||
:::
|
||||
|
||||
- **默认值:** `false`
|
||||
|
||||
- **类型:** `boolean`
|
||||
|
||||
- **示例:**
|
||||
|
||||
```html
|
||||
<template>
|
||||
<!-- 开启属性配置表单校验联动能力 -->
|
||||
<m-editor :enable-props-form-validate="true"></m-editor>
|
||||
</template>
|
||||
```
|
||||
|
||||
- **相关 API:**
|
||||
|
||||
- `editorService` 错误状态与方法:`get('invalidNodeIds')` / [setInvalidNode](#setinvalidnode) / [deleteInvalidNode](#deleteinvalidnode) / [getInvalidNodeIds](#getinvalidnodeids) / [getInvalidNodeInfo](#getinvalidnodeinfo) / [resetInvalidNodeId](#resetinvalidnodeid)
|
||||
- 错误状态变化事件:[invalid-node-change](#invalid-node-change)
|
||||
- 进阶用法见[属性配置表单校验联动](../../guide/advanced/prop-form-validate.md)
|
||||
|
||||
|
||||
## disabledDataSource
|
||||
|
||||
- **详情:**
|
||||
@ -1420,34 +1365,13 @@ const guidesOptions = {
|
||||
|
||||
- **详情:**
|
||||
|
||||
用于自定义组件树(图层)、画布、数据源、代码块的右键菜单
|
||||
用于自定义组件树与画布的右键菜单
|
||||
|
||||
该函数会在显示右键菜单前被调用,接收默认菜单项与菜单类型作为参数,返回最终显示的菜单项
|
||||
|
||||
第二个参数 `type` 用于区分菜单来源:`'layer'`(图层)、`'viewer'`(画布)、`'data-source'`(数据源)、`'code-block'`(代码块)
|
||||
|
||||
第三个参数 `getTarget` 仅在**数据源**与**代码块**面板下传入,用于读取当前右键目标(节点 `id` 与原始数据);图层 / 画布不会传入该参数
|
||||
该函数会在显示右键菜单前被调用,接收默认菜单项作为参数,返回最终显示的菜单项
|
||||
|
||||
- **默认值:** `(menus) => menus`
|
||||
|
||||
- **类型:**
|
||||
|
||||
```ts
|
||||
interface ContentMenuTarget {
|
||||
/** 目标 ID */
|
||||
id: string;
|
||||
/** 原始节点数据(树节点等) */
|
||||
data?: TreeNodeData;
|
||||
}
|
||||
|
||||
type ContentMenuType = 'layer' | 'data-source' | 'viewer' | 'code-block';
|
||||
|
||||
type CustomContentMenuFunction = (
|
||||
menus: (MenuButton | MenuComponent)[],
|
||||
type: ContentMenuType,
|
||||
getTarget?: () => ContentMenuTarget | null,
|
||||
) => (MenuButton | MenuComponent)[];
|
||||
```
|
||||
- **类型:** `(menus: (MenuButton | MenuComponent)[], data: { node?: MNode; page?: MPage; parent?: MContainer; stage?: StageCore }) => (MenuButton | MenuComponent)[]`
|
||||
|
||||
- **示例:**
|
||||
|
||||
@ -1457,24 +1381,20 @@ type CustomContentMenuFunction = (
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const customContentMenu = (menus, type, getTarget) => {
|
||||
// 为数据源右键菜单追加自定义菜单
|
||||
if (type === 'data-source') {
|
||||
const customContentMenu = (menus, { node }) => {
|
||||
// 为特定类型的组件添加自定义菜单
|
||||
if (node?.type === 'container') {
|
||||
menus.push({
|
||||
type: 'button',
|
||||
text: '查看详情',
|
||||
text: '清空容器',
|
||||
handler: () => {
|
||||
// getTarget 仅在数据源 / 代码块面板下传入
|
||||
const target = getTarget?.();
|
||||
if (target) {
|
||||
console.log(target.id, target.data);
|
||||
}
|
||||
// 清空容器的逻辑
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// 可以过滤掉某些菜单项
|
||||
return menus.filter((menu) => menu.text !== '删除');
|
||||
return menus.filter(menu => menu.text !== '删除');
|
||||
};
|
||||
</script>
|
||||
```
|
||||
@ -1562,71 +1482,52 @@ const onLayerNodeDblclick = (event, data) => {
|
||||
- 返回 `false` 时,会同时阻断默认的"展开/收起"行为以及向上抛出的 [`layer-node-dblclick`](./events.md#layer-node-dblclick) 事件;返回其他值则继续触发默认行为并抛出事件。
|
||||
:::
|
||||
|
||||
## 表单业务上下文
|
||||
## extendFormState
|
||||
|
||||
- **详情:**
|
||||
|
||||
扩展表单状态
|
||||
|
||||
编辑器会把 `services` 与当前 `stage` 通过 `FORM_CONTEXT_KEY` provide 下去,编辑器内所有 `MForm`(属性面板、历史差异对比表单、侧边栏、以及 Link / FormBox 里的嵌套子表单)都自动继承。
|
||||
用于在属性表单中注入自定义的状态数据,这些数据可以在表单配置的各个字段为函数时的第一个参数中获取
|
||||
|
||||
业务方要往里追加自己的字段,在 `<m-editor>` 外层再 provide 一层即可,同名字段覆盖内置的:
|
||||
- **默认值:** `undefined`
|
||||
|
||||
- **类型:** `(state: FormState) => Record<string, any> | Promise<Record<string, any>>`
|
||||
|
||||
- **示例:**
|
||||
|
||||
```html
|
||||
<template>
|
||||
<m-editor></m-editor>
|
||||
<m-editor :extend-form-state="extendFormState"></m-editor>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, provide } from 'vue';
|
||||
import { FORM_CONTEXT_KEY } from '@tmagic/form';
|
||||
|
||||
provide(
|
||||
FORM_CONTEXT_KEY,
|
||||
computed(() => ({
|
||||
currentUser: store.currentUser,
|
||||
env: store.env,
|
||||
})),
|
||||
);
|
||||
const extendFormState = async (state) => {
|
||||
// 返回自定义的状态数据
|
||||
return {
|
||||
// 可以是同步数据
|
||||
currentUser: {
|
||||
name: 'Admin',
|
||||
role: 'admin',
|
||||
},
|
||||
// 也可以是异步获取的数据
|
||||
projectConfig: await fetchProjectConfig(),
|
||||
};
|
||||
};
|
||||
</script>
|
||||
```
|
||||
|
||||
补类型用模块增强:
|
||||
|
||||
```ts
|
||||
declare module '@tmagic/form-schema' {
|
||||
interface FormContext {
|
||||
currentUser?: { name: string; role: string };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
表单配置里统一从第一个参数 `mForm` 读:formState 是读穿 Proxy,`mForm` 上没有的字段自动落到 context,所以回调签名不变,后端下发的存量配置无需改动。
|
||||
:::tip
|
||||
扩展的状态可以在表单配置中通过 `state` 访问,例如:
|
||||
|
||||
```js
|
||||
{
|
||||
name: 'title',
|
||||
text: '标题',
|
||||
disabled: (mForm) => mForm.currentUser?.role !== 'admin',
|
||||
display: (mForm) => mForm.env === 'prod',
|
||||
// 根据扩展的状态动态设置
|
||||
disabled: (state) => state.currentUser.role !== 'admin',
|
||||
}
|
||||
```
|
||||
|
||||
::: warning 从 extendFormState 迁移
|
||||
`extendFormState` prop 已移除,同时移除的还有 `PropsPanel` / `FormPanel` / `HistoryDiffDialog` / `CompareForm` / `ViewForm` 的 `extendState`、`CompareForm` 的 `baseFormState`、`useHistoryRevert` 的 `extendState` 与 `getPropsPanelFormState`。
|
||||
|
||||
改法是把「返回数据的函数」换成「数据本身」:
|
||||
|
||||
```ts
|
||||
// before
|
||||
const extendFormState = (state) => ({ env: store.env });
|
||||
// <m-editor :extend-form-state="extendFormState" />
|
||||
|
||||
// after
|
||||
provide(FORM_CONTEXT_KEY, computed(() => ({ env: store.env })));
|
||||
```
|
||||
|
||||
原先返回 Promise 的写法,改由宿主自己决定挂载时机(`v-if="ready"`),或先 provide 一个空 context、数据到位后更新——后者不保证 `defaultValue` / `onInitValue` 首轮能读到。
|
||||
|
||||
配置里的 `mForm.xxx` 读法不受影响,读穿 Proxy 保留。
|
||||
:::
|
||||
|
||||
## historyListExtraTabs
|
||||
|
||||
@ -219,25 +219,8 @@
|
||||
|
||||
- **类型:** `boolean`
|
||||
|
||||
## context
|
||||
## extendState
|
||||
|
||||
- **详情:** 宿主业务上下文。可用本 prop 直接传,也可由祖先 `provide(FORM_CONTEXT_KEY)` 下发;同名字段本 prop 优先。
|
||||
|
||||
嵌套表单(Link 的子表单、`MFormBox`、`MFormDialog`)会自动继承最近祖先的 context,不需要层层透传。
|
||||
|
||||
配置回调统一通过第一个参数 `mForm` 读取:formState 是一个读穿 Proxy,`mForm` 上找不到的字段会自动落到 context。回调签名因此保持不变,后端 eval 下发的存量配置无需改动。
|
||||
|
||||
- **类型:** `FormContext`
|
||||
|
||||
- **示例:**
|
||||
|
||||
```ts
|
||||
// 模板:<m-form :context="formContext" />
|
||||
const formContext = computed(() => ({ username: store.username }));
|
||||
|
||||
// 配置回调:mForm.username 读穿到 context
|
||||
{
|
||||
display: (mForm) => mForm.username === 'admin',
|
||||
}
|
||||
```
|
||||
- **详情:** 扩展 formState 的钩子函数,返回的对象会被合并到 formState 上
|
||||
|
||||
- **类型:** `(state: FormState) => Record<string, any> | Promise<Record<string, any>>`
|
||||
|
||||
@ -1,107 +1,14 @@
|
||||
# submitForm 函数
|
||||
|
||||
以命令式方式对一份「表单配置 + 表单值」执行一次校验并取回表单值,类似 `ElMessage` 的用法。
|
||||
以命令式方式调用 `MForm` 组件完成一次表单校验/提交,类似 `ElMessage` 的用法。
|
||||
|
||||
走**无渲染**实现:不创建任何 DOM 容器、不实例化任何组件,而是直接遍历 `config` 树收集带规则的字段,交给 [`async-validator`](https://github.com/yiminghe/async-validator)(`element-plus` 内部用的也是它)执行。因此它可以在 Node / CI 等没有 DOM 的环境中使用,也省去了挂载整棵表单的开销。校验通过则 `resolve` 表单值,失败则 `reject` 错误信息。纯 Node 请从 `@tmagic/form/headless` 引入,避免加载 Vue 组件和样式。
|
||||
调用时函数内部会临时挂载一个不可见的 `MForm` 实例,把入参作为 props 透传给它,等待初始化完成后调用其 `submitForm` 方法。校验通过则 `resolve` 表单值,校验失败则 `reject` 错误信息,最后自动卸载实例并清理 DOM。
|
||||
|
||||
适用于一些没有合适的容器、但又需要复用 `MForm` 校验逻辑的场景,例如:
|
||||
|
||||
- 通过快捷菜单/命令面板触发一次性表单
|
||||
- 在脚本/服务层完成一次表单值校验后再发请求
|
||||
- 把 `config` 配置当作"可执行的校验规则"使用
|
||||
- 在 Node 脚本 / CI 中批量校验组件配置
|
||||
|
||||
## 无渲染校验与自定义字段登记
|
||||
|
||||
无渲染实现按 `Container.vue` 及各容器组件的模板规则遍历配置树,产出的字段 `prop` 与规则与「挂载 `MForm` 后调用 `validate()`」等价。需要 UI 时传入 `dialog: true`,会把表单以弹层渲染出来供填写/确认。
|
||||
|
||||
字段只要带了 `rules`(会包 FormItem),就会校验自身,不必先登记为叶子。配置里有 `items` 会下钻子项。内部再渲染 `MContainer` 的复合字段需要 `registerField(type, { innerConfig })`,把内部会挂到父表单上的配置交出来。innerConfig 回调自身抛错时,会以 `FieldInnerConfigError`(`code: 'FIELD_INNER_CONFIG'`)reject。
|
||||
|
||||
innerConfig 回调在校验和表单值初始化两条链路上都会被调用(后者用于走到复合字段内部、找出需要执行 `effect` 的子字段),所以它应当只做配置派生、可重复调用、不要做重活。在表单值初始化链路上,回调抛错只会记录到 console 并跳过该子树,不会让表单渲染不出来。
|
||||
|
||||
自定义字段的渲染组件和无渲染校验都通过 `registerField` / `registerFields` 一次登记。`component` 会写入字段注册表(`getFormField`);传入 `app` 时同时 `app.component('m-fields-*')`。容器组件用 `container`,对应 `m-form-*`。
|
||||
|
||||
字段对表单值的初始化写入统一登记为 `effect`,渲染与无渲染共用同一份登记表,执行点也只有一个:表单值初始化完成后(`MForm` 内部、`validateValues`、以及 tab / table 新增行时)各执行一次 `applyMountValueEffects`,字段组件自身不要在 `setup` 里改写 `model`。因此 effect 有两个约束:一是必须幂等,同一份值可能被执行多次(如 `initValues` 变化后重新初始化);二是不看 `display`(`display: false` 或函数返回假的字段也会被规整,避免字段由隐藏转为显示时漏掉)。`type: 'hidden'` 不同:遍历在该节点停止、不往下分派,内部字段不会执行 effect。需要按路径跨层级写值时用上下文里的 `values`(本次处理的值根对象,`prop` 即以它为根),不要用 `mForm.values`——对比模式处理的是 `lastValues` 那一份,新增行处理的则是还没挂到表单上的一行值。单个 effect 抛错只会记录到 console,不影响其余字段与表单渲染。复合字段可以同时登记 `effect` 与 `innerConfig`:前者改本字段的值,后者只派生内部配置、不要在回调里改 `model`。
|
||||
|
||||
| 字段形态 | 登记方式 |
|
||||
| --------------------------------------------------------------------- | ------------------------------------------- |
|
||||
| 自身带 `rules`,内部没有嵌套的父表单 FormItem | 无需登记,直接校验 |
|
||||
| 内部只渲染叶子 UI,或把子表单渲染在独立的 `MForm` / `MFormBox` 实例里 | `registerField('my-field')`(配置里有 `items` 但不属于父表单时,避免被当下钻) |
|
||||
| 同时需要渲染组件 | `registerField('my-field', { component })` |
|
||||
| 容器组件(`m-form-*`) | `registerField('my-box', { container, walk })` |
|
||||
| 叶子字段,但需要改写表单值(类似 `display` 的 `initValue`) | `registerField('my-field', { effect })` |
|
||||
| 内部再渲染 `MContainer` / `MPanel` / `MGroupList`,向父表单注册字段 | `registerField('my-field', { innerConfig })`(需要改本字段的值时再加 `effect`) |
|
||||
| 自定义 `typeMatch` 类型校验 | `registerField('my-field', { typeMatch })` |
|
||||
|
||||
```ts
|
||||
import { registerField, registerFields } from '@tmagic/form';
|
||||
import MyColorPicker from './MyColorPicker.vue';
|
||||
|
||||
// 叶子字段:内部没有嵌套的表单项;带 component 时即可渲染
|
||||
registerFields({ 'my-color-picker': { component: MyColorPicker } });
|
||||
// 需要挂到当前 app 时传入第二个参数
|
||||
registerFields({ 'my-color-picker': { component: MyColorPicker } }, app);
|
||||
|
||||
// 叶子字段,但需要改写表单值:写成 effect,不要在组件 setup 里改 model
|
||||
registerField('my-status', {
|
||||
effect: ({ config, model }) => {
|
||||
if ((config as any).initValue && model) {
|
||||
model[(config as any).name] = (config as any).initValue;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// 复合字段:把组件内部渲染的 MContainer 配置交出来
|
||||
registerField('my-composite', {
|
||||
innerConfig: ({ config, model, prop }) => ({
|
||||
// 对应组件内部 <MContainer :config="childConfig" :model="model[name]" :prop="prop">
|
||||
config: childConfig,
|
||||
model: model[config.name],
|
||||
prop,
|
||||
}),
|
||||
});
|
||||
|
||||
// typeMatch:覆盖或扩展该 type 的类型匹配校验,可与 innerConfig / effect 同时登记
|
||||
registerField('my-status', {
|
||||
typeMatch: (value, { message }) => (typeof value === 'string' ? undefined : message || '应为字符串'),
|
||||
});
|
||||
```
|
||||
|
||||
返回的 `config` 的 `name` 会被追加到返回的 `prop` 上。因此当内部配置复用了字段自身的 `name`(例如内部渲染 `<MGroupList :config="{ name, items }" :model="model" :prop="prop">`)时,要返回 `parentProp` 而非 `prop`,否则 `name` 会被拼两次:
|
||||
|
||||
```ts
|
||||
registerField('my-list', {
|
||||
innerConfig: ({ config, parentProp }) => ({
|
||||
config: { type: 'group-list', name: config.name, items: innerItems },
|
||||
prop: parentProp,
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
编辑器侧四个复合字段(`code-select` / `display-conds` / `event-select` / `style-setter`)的登记可参考 `packages/editor/src/fields/headless-validation.ts`:innerConfig 与组件共用同一份配置工厂(`packages/editor/src/fields/configs/`),避免两条链路各写一份而逐渐跑偏。
|
||||
|
||||
`type: 'component'` 会把 `config.component` 当任意 Vue 组件渲染。无渲染校验把它视为叶子,**不会**遍历内部结构。因此该组件不得再向父表单注册 FormItem;需要嵌套表单项时,应对该具体组件 `registerField(type, { innerConfig })`。
|
||||
|
||||
### 重复登记与撤销
|
||||
|
||||
同一个 type 多次登记按字段浅合并,后一次只覆盖自己传入的 key:
|
||||
|
||||
```ts
|
||||
registerField('my-composite', { innerConfig });
|
||||
registerField('my-composite', { component: MyComposite }); // innerConfig 仍在
|
||||
```
|
||||
|
||||
登记分「内置」与「业务」两层。`app.use(MagicForm)` / `registerBuiltInFields` 写内置层,`registerField` / `registerFields` 写业务层;读取时业务层优先,`unregisterField` / `clearFields` 只清业务层,内置字段不受影响(单测里 `clearFields` 之后仍能校验 `text`、`tab` 等内置 type)。
|
||||
|
||||
因为是合并语义,把一个已登记 `innerConfig` 的 type 改成普通叶子,不能靠再传一次空对象,要先撤销:
|
||||
|
||||
```ts
|
||||
registerField('my-composite', {}); // ✗ 合并后 innerConfig 还在,仍会下钻
|
||||
unregisterField('my-composite'); // ✓ 先清掉业务层登记
|
||||
registerField('my-composite', { component: MyComposite });
|
||||
```
|
||||
|
||||
一次登记里同时传多个形态时的优先级:`walk` > `innerConfig` > `effect`(叶子),命中低优先级的那份会被忽略并在控制台给出告警。
|
||||
|
||||
## 签名
|
||||
|
||||
@ -111,7 +18,7 @@ function submitForm(options: SubmitFormOptions): Promise<any>;
|
||||
|
||||
## 参数
|
||||
|
||||
`options` 与 `MForm` 组件的 props 基本对齐,额外提供了 `native`、`returnChangeRecords`、`dialog`、`signal` 等参数。`appContext` 仅 `dialog: true` 时生效。
|
||||
`options` 与 `MForm` 组件的 props 基本对齐,额外提供了 `native`、`returnChangeRecords`、`appContext`、`timeout` 等参数。
|
||||
|
||||
| 名称 | 类型 | 默认值 | 说明 |
|
||||
| ---------------------- | ------------------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------- |
|
||||
@ -131,23 +38,22 @@ function submitForm(options: SubmitFormOptions): Promise<any>;
|
||||
| `popperClass` | `string` | — | 弹层 className |
|
||||
| `preventSubmitDefault` | `boolean` | — | 是否阻止表单原生 submit |
|
||||
| `useFieldTextInError` | `boolean` | `true` | 校验失败时错误提示前缀是否使用字段的 `text` 文案;`false` 时直接使用字段 `name` |
|
||||
| `context` | `FormContext` | — | 宿主业务上下文,与 MForm 的 `context` 语义一致;配置回调通过 `mForm.xxx` 读穿取用 |
|
||||
| `extendState` | `(state: FormState) => Record<string, any> \| Promise<Record<string, any>>` | — | 扩展 `formState` |
|
||||
| `native` | `boolean` | `false` | 透传给 `Form.submitForm`。`true` 时返回内部响应式 `values`,否则返回 `cloneDeep(toRaw(values))` |
|
||||
| `returnChangeRecords` | `boolean` | `false` | `true` 时 resolve 结果为 `{ values, changeRecords }`,携带表单变更记录;否则仅 resolve `values` |
|
||||
| `appContext` | `AppContext \| null` | `null` | 父级 Vue 应用上下文。仅 `dialog: true` 时生效,用于继承全局组件、指令、provide 等,常通过 `app._context` 或 `getCurrentInstance()?.appContext` 获取 |
|
||||
| `dialog` | `boolean` | `false` | `true` 时把表单以弹层形式渲染出来,点击「确定」才提交,「取消」则以 reject 中断;校验失败会保留弹层并展示错误,便于修正后重试。等待人工操作,可用 `signal` 中断 |
|
||||
| `title` | `string` | `'submitForm'` / `'validateForm'` | 弹层标题,仅 `dialog: true` 时生效 |
|
||||
| `signal` | `AbortSignal` | — | 外部中断信号。abort 时立即以 `signal.reason` reject,并卸载 `dialog` 模式下已挂载的临时表单实例 |
|
||||
| `appContext` | `AppContext \| null` | `null` | 父级 Vue 应用上下文。需要继承全局组件、指令、provide 等时传入,常通过 `app._context` 或 `getCurrentInstance()?.appContext` 获取 |
|
||||
| `timeout` | `number` | `10000` | 等待表单初始化的最长时间(毫秒)。超时将以错误 reject。设为 `<= 0` 时关闭超时兜底 |
|
||||
|
||||
## 返回值
|
||||
|
||||
- `校验通过` — `Promise<any>` resolve 当前表单值(`native` 决定是否克隆);当 `returnChangeRecords` 为 `true` 时,resolve `{ values, changeRecords }`
|
||||
- `校验失败` — `Promise<any>` reject 一个 `Error`,`message` 中包含逐条字段错误信息(格式 `${text} -> ${message}`,多条用 `<br>` 分隔)
|
||||
- `初始化超时` — `Promise<any>` reject `Error('submitForm timeout after ${timeout}ms: form is not initialized.')`
|
||||
|
||||
`dialog: true` 时无论成功或失败,函数都会在最后自动 `unmount` 内部 app 并移除挂载用的 DOM 容器,无需调用方手动清理。
|
||||
无论成功或失败,函数都会在最后自动 `unmount` 内部 app 并移除挂载用的 DOM 容器,无需调用方手动清理。
|
||||
|
||||
::: tip 关于 changeRecords
|
||||
`changeRecords` 记录的是表单挂载后发生的字段变更(由各字段的 `change` 事件累积而来)。无渲染校验没有用户交互,因此固定返回空数组;只有 `dialog: true` 时才可能有内容(`MForm` 内部的 `submitForm` 在校验通过后会清空变更记录,因此本函数会在调用前先做快照)。
|
||||
`changeRecords` 记录的是表单挂载后发生的字段变更(由各字段的 `change` 事件累积而来)。在 `submitForm` 这种命令式、无用户交互的场景下,通常为空数组;只有在 `extendState` 或字段联动等逻辑中触发了变更时才会有内容。`MForm` 内部的 `submitForm` 在校验通过后会清空变更记录,因此本函数会在调用前先对其做快照再返回。
|
||||
:::
|
||||
|
||||
## 基础用法
|
||||
@ -162,10 +68,7 @@ try {
|
||||
type: 'text',
|
||||
name: 'username',
|
||||
text: '用户名',
|
||||
rules: [
|
||||
{ required: true, message: '请输入用户名' },
|
||||
{ typeMatch: true, message: '用户名类型不合法' },
|
||||
],
|
||||
rules: [{ required: true, message: '请输入用户名' }],
|
||||
},
|
||||
],
|
||||
initValues: { username: '' },
|
||||
@ -193,9 +96,9 @@ console.log(values); // { username: 'foo' }
|
||||
console.log(changeRecords); // ChangeRecord[]
|
||||
```
|
||||
|
||||
## 弹层模式(`dialog: true`)下继承父级应用上下文
|
||||
## 在组件中继承父级应用上下文
|
||||
|
||||
默认路径不挂载组件,不需要 `appContext`。只有 `dialog: true` 会渲染弹层,此时 `MForm` 要用到 `@tmagic/design` 的组件(背后可能是 `element-plus` 或 `tdesign`),需要把宿主应用的上下文带过去:
|
||||
`MForm` 内部使用 `@tmagic/design` 的组件(背后可能是 `element-plus` 或 `tdesign`),需要宿主应用先完成相应的 `app.use(...)` 安装。在 `submitForm` 这种脱离常规组件树的命令式调用中,可通过 `appContext` 把父级应用上下文带过去:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
@ -209,8 +112,6 @@ const onClick = async () => {
|
||||
const values = await submitForm({
|
||||
config: [{ type: 'text', name: 'text', text: '文本' }],
|
||||
initValues: { text: 'hello' },
|
||||
dialog: true,
|
||||
title: '编辑配置',
|
||||
appContext,
|
||||
});
|
||||
console.log(values);
|
||||
@ -254,77 +155,65 @@ try {
|
||||
}
|
||||
```
|
||||
|
||||
## validateForm 函数
|
||||
|
||||
`validateForm` 与 `submitForm` 共用同一套无渲染校验实现,区别在于它是**静默**的:校验失败不抛异常、不返回表单值,而是以返回值形式给出错误文案。适合「只想探测这份配置是否合法」的场景,例如源码编辑器保存后校验、批量校验组件配置。
|
||||
|
||||
```ts
|
||||
function validateForm(options: ValidateFormOptions): Promise<string>;
|
||||
```
|
||||
|
||||
`options` 取 `SubmitFormOptions` 中与校验相关的子集(`config`、`initValues`、`parentValues`、`labelWidth`、`keyProp`、`useFieldTextInError`、`extendState`、`typeMatchValid`、`appContext`、`dialog`、`title`、`signal`)。
|
||||
|
||||
```ts
|
||||
import { validateForm } from '@tmagic/form';
|
||||
|
||||
const error = await validateForm({
|
||||
config: [{ type: 'text', name: 'username', text: '用户名', rules: [{ required: true, message: '请输入用户名' }] }],
|
||||
initValues: { username: '' },
|
||||
});
|
||||
|
||||
if (error) {
|
||||
// '用户名 -> 请输入用户名'
|
||||
}
|
||||
```
|
||||
|
||||
校验通过返回空字符串 `''`,否则返回以 `<br>` 拼接的错误文案。无法完成校验时才会 reject(例如 innerConfig 回调失败抛出 `FieldInnerConfigError`)。
|
||||
|
||||
## 运行环境
|
||||
|
||||
无渲染实现不接触 `document` / `window`,因此在任何 JS 运行时中都可用:
|
||||
`submitForm` 内部依赖 `document` / `window` 来挂载临时 Vue 实例,因此**只能在浏览器或具备 DOM 环境的运行时中使用**。
|
||||
|
||||
| 环境 | 是否可用 | 说明 |
|
||||
| ------------------------------------------ | -------- | ------------------------------------------------------------------------------------- |
|
||||
| 浏览器 / Electron 渲染进程 / 浏览器扩展 | ✅ | 直接可用 |
|
||||
| Vitest / Jest + `happy-dom` / `jsdom` | ✅ | 项目自身的单测就跑在这种环境下 |
|
||||
| 纯 Node.js / Bun / Deno(无 DOM polyfill) | ✅ | 从 `@tmagic/form/headless` 引入,不要用 `@tmagic/form` 主入口 |
|
||||
| 环境 | 是否可用 | 说明 |
|
||||
| ----------------------------------------------- | -------- | --------------------------------------------------------------------------------- |
|
||||
| 浏览器 / Electron 渲染进程 / 浏览器扩展 | ✅ | 直接可用 |
|
||||
| Vitest / Jest + `happy-dom` / `jsdom` | ✅ | 项目自身的单测就跑在这种环境下 |
|
||||
| 纯 Node.js / Bun / Deno(无 DOM polyfill) | ❌ | 模块顶层就会读 `document`,会抛 `document is not defined` |
|
||||
| Node.js + 手动注入 `happy-dom` / `jsdom` | ⚠️ | 可用,需要在 import `@tmagic/form` **之前**完成全局变量注入;校验行为不一定与浏览器完全一致 |
|
||||
|
||||
### 在 Node.js 中使用(需要先准备 DOM)
|
||||
|
||||
下面是一个在 Node 脚本里调用 `submitForm` 的完整例子,使用 [`happy-dom`](https://github.com/capricorn86/happy-dom) 作为 DOM polyfill:
|
||||
|
||||
```ts
|
||||
// scripts/check-form.ts —— 纯 Node 环境,无需任何 DOM polyfill
|
||||
import { builtInFields, registerBuiltInFields, registerFields, validateForm } from '@tmagic/form/headless';
|
||||
import { editorFields } from '@tmagic/editor/headless';
|
||||
// scripts/check-form.ts
|
||||
import { Window } from 'happy-dom';
|
||||
|
||||
// `builtInFields` 只是数据;未 `app.use(MagicForm)` 时要自己登记
|
||||
registerBuiltInFields(builtInFields);
|
||||
registerFields(editorFields);
|
||||
|
||||
const error = await validateForm({
|
||||
config: [{ type: 'text', name: 'username', text: '用户名', rules: [{ required: true }] }],
|
||||
initValues: { username: '' },
|
||||
const window = new Window();
|
||||
Object.assign(globalThis, {
|
||||
window,
|
||||
document: window.document,
|
||||
navigator: window.navigator,
|
||||
HTMLElement: window.HTMLElement,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
// 注意:DOM polyfill 必须先注入到 globalThis,再用动态 import
|
||||
// 加载业务模块,否则 @tmagic/design 等模块顶层执行时就会读 document
|
||||
const { createApp } = await import('vue');
|
||||
const ElementPlus = (await import('element-plus')).default;
|
||||
const MagicForm = (await import('@tmagic/form')).default;
|
||||
const { submitForm } = await import('@tmagic/form');
|
||||
|
||||
const parentApp = createApp({ render: () => null });
|
||||
parentApp.use(ElementPlus);
|
||||
parentApp.use(MagicForm);
|
||||
|
||||
const values = await submitForm({
|
||||
config: [{ type: 'text', name: 'username', text: '用户名' }],
|
||||
initValues: { username: 'foo' },
|
||||
appContext: parentApp._context,
|
||||
});
|
||||
|
||||
console.log(values);
|
||||
```
|
||||
|
||||
::: warning 注意
|
||||
`dialog: true` 依赖 DOM 与已安装的 UI 库(`element-plus` / `tdesign`),在纯 Node 环境中不可用。
|
||||
:::
|
||||
|
||||
::: warning ESM 与 CJS 不要混用
|
||||
`@tmagic/form/headless` 的 ESM 产物与 `@tmagic/form` 共用同一批模块文件,字段注册表是同一份,两个入口可以混着 `import`。
|
||||
|
||||
CJS 产物是两个各自独立的 bundle,注册表不共享。所以同一进程里不要同时 `require('@tmagic/form')` 和 `require('@tmagic/form/headless')`——在一边 `registerField` 另一边读不到,校验会因为「没登记过这个 type」而静默放过。`@tmagic/editor` 与 `@tmagic/design` 的 headless 子路径同理。
|
||||
- DOM polyfill 必须在 **import 业务模块之前** 注入到 `globalThis`,否则模块顶层执行时仍会失败
|
||||
- 在 `happy-dom` / `jsdom` 中,`element-plus` 的部分 `validate()` 行为不一定能 1:1 复现真实浏览器(例如某些场景下必填规则可能不触发),建议关键校验使用自定义 `validator` 函数确保稳定
|
||||
- 如果只是想在 Node 端做一次纯校验,更稳妥的做法是直接复用 [`async-validator`](https://github.com/yiminghe/async-validator)(element-plus 内部用的就是它),绕开整个 Vue 渲染层
|
||||
:::
|
||||
|
||||
## 类型定义
|
||||
|
||||
::: details 查看 `SubmitFormOptions` 类型定义
|
||||
<<< @/../packages/form/src/utils/submitHeadless.ts#SubmitFormOptions{ts}
|
||||
<<< @/../packages/form/src/submitForm.ts#SubmitFormOptions{ts}
|
||||
:::
|
||||
|
||||
::: details 查看 `SubmitFormResult` 类型定义
|
||||
<<< @/../packages/form/src/utils/submitHeadless.ts#SubmitFormResult{ts}
|
||||
<<< @/../packages/form/src/submitForm.ts#SubmitFormResult{ts}
|
||||
:::
|
||||
|
||||
@ -69,11 +69,10 @@
|
||||
|
||||
## delayedMarkContainer
|
||||
|
||||
- **类型**:`(event: MouseEvent, excludeElList?: Element[], isAdd?: boolean) => NodeJS.Timeout | undefined`
|
||||
- **类型**:`(event: MouseEvent, excludeElList?: Element[]) => NodeJS.Timeout | undefined`
|
||||
- **参数**:
|
||||
- `event`:鼠标事件
|
||||
- `excludeElList`:计算鼠标所在容器时要排除的元素列表
|
||||
- `isAdd`:当前操作是否为新增组件(从组件列表拖入新组件),`containerHighlightType` 为 `addOnly` 时只有新增才会标记容器
|
||||
- **详情**:
|
||||
|
||||
鼠标拖拽着元素,在容器上方悬停,延迟一段时间后,对容器进行标记,如果悬停时间够长将标记成功,悬停时间短,调用方通过返回的timeoutId取消标记
|
||||
|
||||
@ -27,10 +27,6 @@
|
||||
}
|
||||
```
|
||||
|
||||
## 值校验
|
||||
|
||||
安装 `@tmagic/editor` 后该字段会自动注册内置 `typeMatch` 校验规则:要求 `string`,有代码块 DSL 时须为已有 codeId。服务数据未就绪时仅做基础形态校验。详见[表单校验 - Editor 字段内置规则](/form-config/rules.md#editor-字段内置规则)。
|
||||
|
||||
## Attributes
|
||||
|
||||
| 参数 | 说明 | 类型 | 可选值 | 默认值 |
|
||||
|
||||
@ -24,10 +24,6 @@ CodeSelect 组件支持:
|
||||
- 选择数据源方法
|
||||
- 配置代码块参数
|
||||
|
||||
## 值校验
|
||||
|
||||
安装 `@tmagic/editor` 后该字段会自动注册内置 `typeMatch` 校验规则:做 `{ hookType: 'code', hookData }` 的浅层结构校验,`codeId` / 数据源方法存在性由内部单元格各自校验。服务数据未就绪时仅做基础形态校验。详见[表单校验 - Editor 字段内置规则](/form-config/rules.md#editor-字段内置规则)。
|
||||
|
||||
## Attributes
|
||||
|
||||
| 参数 | 说明 | 类型 | 可选值 | 默认值 |
|
||||
|
||||
@ -41,10 +41,6 @@
|
||||
}
|
||||
```
|
||||
|
||||
## 值校验
|
||||
|
||||
安装 `@tmagic/editor` 后该字段会自动注册内置 `typeMatch` 校验规则:默认要求 `string`,`parse: true` 时跳过校验。详见[表单校验 - Editor 字段内置规则](/form-config/rules.md#editor-字段内置规则)。
|
||||
|
||||
## Attributes
|
||||
|
||||
| 参数 | 说明 | 类型 | 可选值 | 默认值 |
|
||||
|
||||
@ -27,10 +27,6 @@
|
||||
}
|
||||
```
|
||||
|
||||
## 值校验
|
||||
|
||||
安装 `@tmagic/editor` 后该字段会自动注册内置 `typeMatch` 校验规则:须为已知算子,能解析字段类型时按类型收窄。服务数据未就绪时仅做基础形态校验。详见[表单校验 - Editor 字段内置规则](/form-config/rules.md#editor-字段内置规则)。
|
||||
|
||||
## Attributes
|
||||
|
||||
| 参数 | 说明 | 类型 | 可选值 | 默认值 |
|
||||
|
||||
@ -49,10 +49,6 @@
|
||||
}
|
||||
```
|
||||
|
||||
## 值校验
|
||||
|
||||
安装 `@tmagic/editor` 后该字段会自动注册内置 `typeMatch` 校验规则:须为数据源路径 `string[]`,有 `fieldConfig` 且非路径值时跳过。服务数据未就绪时仅做基础形态校验。详见[表单校验 - Editor 字段内置规则](/form-config/rules.md#editor-字段内置规则)。
|
||||
|
||||
## Attributes
|
||||
|
||||
| 参数 | 说明 | 类型 | 可选值 | 默认值 |
|
||||
|
||||
@ -16,10 +16,6 @@
|
||||
}
|
||||
```
|
||||
|
||||
## 值校验
|
||||
|
||||
安装 `@tmagic/editor` 后该字段会自动注册内置 `typeMatch` 校验规则:须为数组并做 `name`/`type` 等浅层结构校验。详见[表单校验 - Editor 字段内置规则](/form-config/rules.md#editor-字段内置规则)。
|
||||
|
||||
## Attributes
|
||||
|
||||
| 参数 | 说明 | 类型 | 可选值 | 默认值 |
|
||||
|
||||
@ -16,10 +16,6 @@
|
||||
}
|
||||
```
|
||||
|
||||
## 值校验
|
||||
|
||||
安装 `@tmagic/editor` 后该字段会自动注册内置 `typeMatch` 校验规则:要求 `string`,`${...}` 绑定须指向已有数据源 / 字段。服务数据未就绪时仅做基础形态校验。详见[表单校验 - Editor 字段内置规则](/form-config/rules.md#editor-字段内置规则)。
|
||||
|
||||
## Attributes
|
||||
|
||||
| 参数 | 说明 | 类型 | 可选值 | 默认值 |
|
||||
|
||||
@ -27,10 +27,6 @@
|
||||
}
|
||||
```
|
||||
|
||||
## 值校验
|
||||
|
||||
安装 `@tmagic/editor` 后该字段会自动注册内置 `typeMatch` 校验规则:须为 `[dsId, methodName]`,方法须在该数据源可选方法集中。服务数据未就绪时仅做基础形态校验。详见[表单校验 - Editor 字段内置规则](/form-config/rules.md#editor-字段内置规则)。
|
||||
|
||||
## Attributes
|
||||
|
||||
| 参数 | 说明 | 类型 | 可选值 | 默认值 |
|
||||
|
||||
@ -16,10 +16,6 @@
|
||||
}
|
||||
```
|
||||
|
||||
## 值校验
|
||||
|
||||
安装 `@tmagic/editor` 后该字段会自动注册内置 `typeMatch` 校验规则:须为数组并做 `content`/`params` 等浅层结构校验。详见[表单校验 - Editor 字段内置规则](/form-config/rules.md#editor-字段内置规则)。
|
||||
|
||||
## Attributes
|
||||
|
||||
| 参数 | 说明 | 类型 | 可选值 | 默认值 |
|
||||
|
||||
@ -16,10 +16,6 @@
|
||||
}
|
||||
```
|
||||
|
||||
## 值校验
|
||||
|
||||
安装 `@tmagic/editor` 后该字段会自动注册内置 `typeMatch` 校验规则:须为数组并做 `title`/`enable`/`data` 等浅层结构校验。详见[表单校验 - Editor 字段内置规则](/form-config/rules.md#editor-字段内置规则)。
|
||||
|
||||
## Attributes
|
||||
|
||||
| 参数 | 说明 | 类型 | 可选值 | 默认值 |
|
||||
|
||||
@ -38,10 +38,6 @@
|
||||
}
|
||||
```
|
||||
|
||||
## 值校验
|
||||
|
||||
安装 `@tmagic/editor` 后该字段会自动注册内置 `typeMatch` 校验规则:`value: 'id'` 时须为已有 ds id,否则为含 `isBindDataSource` + `dataSourceId` 的对象。服务数据未就绪时仅做基础形态校验。详见[表单校验 - Editor 字段内置规则](/form-config/rules.md#editor-字段内置规则)。
|
||||
|
||||
## Attributes
|
||||
|
||||
| 参数 | 说明 | 类型 | 可选值 | 默认值 |
|
||||
|
||||
@ -27,10 +27,6 @@
|
||||
}
|
||||
```
|
||||
|
||||
## 值校验
|
||||
|
||||
安装 `@tmagic/editor` 后该字段会自动注册内置 `typeMatch` 校验规则:须为数组并做 `cond[].field`/`op` 浅层结构校验,算子合法性 / 字段路径存在性由内部单元格各自校验。服务数据未就绪时仅做基础形态校验。详见[表单校验 - Editor 字段内置规则](/form-config/rules.md#editor-字段内置规则)。
|
||||
|
||||
## Attributes
|
||||
|
||||
| 参数 | 说明 | 类型 | 可选值 | 默认值 |
|
||||
|
||||
@ -28,10 +28,6 @@
|
||||
}
|
||||
```
|
||||
|
||||
## 值校验
|
||||
|
||||
安装 `@tmagic/editor` 后该字段会自动注册内置 `typeMatch` 校验规则:须为数组并做兼容新旧格式的浅层结构校验,事件名 / 联动动作是否属于可选项由内部单元格各自校验。服务数据未就绪时仅做基础形态校验。详见[表单校验 - Editor 字段内置规则](/form-config/rules.md#editor-字段内置规则)。
|
||||
|
||||
## Attributes
|
||||
|
||||
| 参数 | 说明 | 类型 | 可选值 | 默认值 |
|
||||
|
||||
@ -29,10 +29,6 @@
|
||||
}
|
||||
```
|
||||
|
||||
## 值校验
|
||||
|
||||
安装 `@tmagic/editor` 后该字段会自动注册内置 `typeMatch` 校验规则:须为普通对象,`advanced` 且值为 `function` 时放行。详见[表单校验 - Editor 字段内置规则](/form-config/rules.md#editor-字段内置规则)。
|
||||
|
||||
## Attributes
|
||||
|
||||
| 参数 | 说明 | 类型 | 可选值 | 默认值 |
|
||||
|
||||
@ -16,10 +16,6 @@
|
||||
}
|
||||
```
|
||||
|
||||
## 值校验
|
||||
|
||||
安装 `@tmagic/editor` 后该字段会自动注册内置 `typeMatch` 校验规则:须为 `string` 或 `number`,有节点树时须为已有页面片 id。服务数据未就绪时仅做基础形态校验。详见[表单校验 - Editor 字段内置规则](/form-config/rules.md#editor-字段内置规则)。
|
||||
|
||||
## Attributes
|
||||
|
||||
| 参数 | 说明 | 类型 | 可选值 | 默认值 |
|
||||
|
||||
@ -16,10 +16,6 @@
|
||||
}
|
||||
```
|
||||
|
||||
## 值校验
|
||||
|
||||
安装 `@tmagic/editor` 后该字段会自动注册内置 `typeMatch` 校验规则:须为 `string` 或 `number`,有节点树时须为已有组件 id。服务数据未就绪时仅做基础形态校验。详见[表单校验 - Editor 字段内置规则](/form-config/rules.md#editor-字段内置规则)。
|
||||
|
||||
## Attributes
|
||||
|
||||
| 参数 | 说明 | 类型 | 可选值 | 默认值 |
|
||||
|
||||
@ -24,26 +24,6 @@ type为'select'
|
||||
</template>
|
||||
</demo-block>
|
||||
|
||||
## 选项值校验
|
||||
|
||||
可在 `rules` 中开启 `typeMatch`,校验当前值是否落在 `options` 中(`multiple` 时校验数组元素)。详见[表单校验](/form-config/rules.md)。
|
||||
|
||||
```ts
|
||||
{
|
||||
type: 'select',
|
||||
name: 'status',
|
||||
text: '状态',
|
||||
options: [
|
||||
{ text: '启用', value: 1 },
|
||||
{ text: '禁用', value: 0 },
|
||||
],
|
||||
rules: [
|
||||
{ required: true, message: '请选择状态' },
|
||||
{ typeMatch: true, message: '状态值不合法' },
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
## 有禁用选项
|
||||
|
||||
<demo-block type="form" :config="[{
|
||||
|
||||
@ -82,21 +82,6 @@ Input输入框的type为'text', 是type的默认值,所以可以不配置
|
||||
</template>
|
||||
</demo-block>
|
||||
|
||||
## 表单校验
|
||||
|
||||
可通过 `rules` 配置校验规则。其中 `typeMatch: true` 会按字段 type(以及 `filter` / `valueFormat` 等)校验值是否合法,详见[表单校验](/form-config/rules.md)。
|
||||
|
||||
```ts
|
||||
{
|
||||
name: 'text',
|
||||
text: '输入框',
|
||||
rules: [
|
||||
{ required: true, message: '请输入' },
|
||||
{ typeMatch: true, message: '值类型不合法' },
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
## 去掉首尾空格
|
||||
|
||||
<demo-block type="form" :config="[{
|
||||
@ -139,7 +124,6 @@ Input输入框的type为'text', 是type的默认值,所以可以不配置
|
||||
| tooltip | 输入时显示内容 | string / `ToolTipConfigType` | — | — |
|
||||
| trim | 是否去掉首尾空格 | boolean | — | false |
|
||||
| filter | 过滤值 | string / Function | number | - |
|
||||
| rules | 表单验证规则 | `Rule[]` | — | — |
|
||||
| prepend | 前置内容 | string | — | - |
|
||||
| append | 后置内容 | string / Object | — | - |
|
||||
| onChange | 值变化时触发的函数 | `OnChangeHandler` | — | - |
|
||||
@ -156,8 +140,6 @@ Input输入框的type为'text', 是type的默认值,所以可以不配置
|
||||
<<< @/../packages/form-schema/src/base.ts#FormValue{ts}
|
||||
|
||||
<<< @/../packages/form-schema/src/base.ts#ToolTipConfigType{ts}
|
||||
|
||||
<<< @/../packages/form-schema/src/base.ts#Rule{ts}
|
||||
:::
|
||||
|
||||
|
||||
|
||||
@ -1,238 +0,0 @@
|
||||
# 表单校验
|
||||
|
||||
`@tmagic/form` 字段可通过 `rules` 配置校验规则,兼容 [async-validator](https://github.com/yiminghe/async-validator)(Element Plus 内部使用)的常见能力,并额外支持按字段 `type` 做值类型 / 选项匹配校验(`typeMatch`)。
|
||||
|
||||
## 基础用法
|
||||
|
||||
```ts
|
||||
{
|
||||
name: 'title',
|
||||
text: '名称',
|
||||
rules: [
|
||||
{ required: true, message: '请输入名称' },
|
||||
{ typeMatch: true, message: '名称类型不合法' },
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
`rules` 类型见下方 [`Rule`](#类型定义)。未配置 `typeMatch` 时,现有表单行为不变。
|
||||
|
||||
## typeMatch
|
||||
|
||||
开启 `typeMatch: true` 后,表单会按字段 `config.type`(及关联配置)校验当前值是否合法。空值(`undefined` / `null` / `''`,多选类空数组 `[]`)直接通过,必填请继续使用 `required`。
|
||||
|
||||
### 内置映射
|
||||
|
||||
| 字段 type | 期望值 |
|
||||
| --- | --- |
|
||||
| `text` / `textarea` / `color-picker` / `html` / 默认无 type | `string`;若 `filter: 'number'` 则为 `number`;`filter` 为自定义函数时跳过内置类型校验 |
|
||||
| `display` / `hidden` | 不校验 |
|
||||
| `number` | `number`(非 NaN) |
|
||||
| `number-range` | 长度为 2 的数字数组 |
|
||||
| `date` / `datetime` / `time` | 按 `valueFormat` 校验;`x` / `timestamp` 期望 `number`,其余按 [Day.js format](https://day.js.org/docs/en/display/format) 严格解析字符串 |
|
||||
| `daterange` / `timerange` | 无 `names` 时为长度为 2 的数组,元素按 `valueFormat` 校验;有 `names` 时跳过 |
|
||||
| `switch` / `checkbox` | 值必须是解析后的 `activeValue` / `inactiveValue` 之一(显式配置优先;未配置且 `filter === 'number'` 时为 `1`/`0`;否则为 `true`/`false`) |
|
||||
| `select` | 单选:值 ∈ options;`multiple`:数组且每项 ∈ options;`allowCreate` / `remote`:只校验基础形态,不做 options 枚举 |
|
||||
| `radio-group` / `radioGroup` | 值 ∈ options |
|
||||
| `checkbox-group` / `checkboxGroup` | 数组且每项 ∈ options |
|
||||
| `cascader` | 有 `valueSeparator` 时可为 `string` 或 `array`;默认 `emitPath` 为路径数组;`emitPath: false` 为叶子值;`multiple` 为数组;静态 options 校验路径/叶子;`remote` 只做形态校验 |
|
||||
| `table` / `group-list` / `groupList` | `array` |
|
||||
| 容器类(`row` / `tab` / `fieldset` / `panel` / `step` / `flex-layout` / `link` / `component` / `dynamic-field` 等) | 不校验 |
|
||||
|
||||
日期类默认 `valueFormat` 与字段组件一致:
|
||||
|
||||
- `date`:`YYYY/MM/DD`
|
||||
- `datetime` / `daterange`:`YYYY/MM/DD HH:mm:ss`
|
||||
- `time` / `timerange`:`HH:mm:ss`
|
||||
|
||||
### 与自定义 validator 共存
|
||||
|
||||
同一条 rule 同时配置了 `typeMatch` 与 `validator` 时,会先做类型匹配校验,通过后再执行自定义 `validator`。
|
||||
|
||||
```ts
|
||||
{
|
||||
name: 'age',
|
||||
type: 'number',
|
||||
rules: [
|
||||
{
|
||||
typeMatch: true,
|
||||
message: '年龄必须是数字',
|
||||
validator: ({ value, callback }) => {
|
||||
if (value < 0) {
|
||||
callback(new Error('年龄不能小于 0'));
|
||||
return;
|
||||
}
|
||||
callback();
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
`validator` 除了调用 `callback` 外,也兼容 async-validator 的返回值约定:返回 `false` 或 `Error` / 错误数组表示失败,返回 `true` 表示通过,返回 `Promise` 则以 resolve / reject 为结果,内部抛出的异常会转成校验失败。同步与异步 `typeMatch` 下这些约定行为一致,且只会上报一次结果(既调 `callback` 又返回 `Promise` 时以先到的为准)。
|
||||
|
||||
## 扩展自定义 type 规则
|
||||
|
||||
业务可覆盖内置规则,或为自定义字段 type 注册校验。自定义规则优先于内置规则。
|
||||
|
||||
### 运行时注册
|
||||
|
||||
```ts
|
||||
import { registerField, registerFields, unregisterField, clearFields } from '@tmagic/form';
|
||||
|
||||
// 覆盖内置 text
|
||||
registerField('text', {
|
||||
typeMatch: (value, { message }) => {
|
||||
if (typeof value !== 'string') {
|
||||
return message || '值类型应为字符串';
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// 扩展业务字段
|
||||
registerField('vs-code', {
|
||||
typeMatch: (value, { message }) => {
|
||||
if (typeof value !== 'string') {
|
||||
return message || '代码字段应为字符串';
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// 批量注册
|
||||
registerFields({
|
||||
foo: { typeMatch: (value) => (Array.isArray(value) ? undefined : '应为数组') },
|
||||
});
|
||||
|
||||
// 删除 / 清空该 type 的全部登记(含 typeMatch)
|
||||
unregisterField('foo');
|
||||
clearFields();
|
||||
```
|
||||
|
||||
自定义校验器签名:`(value, context) => string | undefined | Promise<string | undefined>`。返回错误文案表示失败,返回 `undefined` 表示通过。`context` 包含 `fieldType`、`mForm`、`props`、`message`。
|
||||
|
||||
### 异步校验
|
||||
|
||||
自定义校验器可以返回 `Promise`,用于需要异步确认取值是否合法的场景(如请求接口校验 id 是否存在)。内置规则均为同步。
|
||||
|
||||
```ts
|
||||
import { registerField } from '@tmagic/form';
|
||||
|
||||
registerField('mod-select', {
|
||||
typeMatch: async (value, { message }) => {
|
||||
const exists = await checkModExists(value);
|
||||
if (!exists) {
|
||||
return message || `模块(${value})不存在`;
|
||||
}
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
异步校验器通过后才会执行同一条 rule 上的自定义 `validator`。需要注意几点:
|
||||
|
||||
- **校验器自身失败不算校验失败。** Promise 被 reject(如接口异常)时只打印错误、该字段按通过处理,避免网络故障阻塞操作。需要把失败暴露给用户时请在校验器内部 catch 并返回错误文案。
|
||||
- **建议自行缓存请求结果。** 每次校验都会执行校验器,无缓存会导致逐次输入都发请求;也可以给 rule 配 `trigger: 'blur'` 降低触发频率。
|
||||
- **只有最新一轮校验的结论会被采用。** 新一轮校验开始后,上一轮针对旧值、尚未返回的校验不再使用自己的结论,而是等最新一轮出结论后一起结算,因此旧结论不会晚到覆盖新结论,`form.validate()` 也不会对一个尚未校验完的值返回成功。
|
||||
|
||||
### 安装时注册
|
||||
|
||||
```ts
|
||||
import MagicForm from '@tmagic/form';
|
||||
import MyField from './MyField.vue';
|
||||
|
||||
app.use(MagicForm, {
|
||||
fields: {
|
||||
'my-field': {
|
||||
component: MyField,
|
||||
typeMatch: (value, { message }) => {
|
||||
if (typeof value !== 'string') {
|
||||
return message || 'my-field 应为字符串';
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Editor 字段内置规则
|
||||
|
||||
安装 `@tmagic/editor` 时会把 `editorFields`(无 Vue 组件)叠上字段组件后作为 `fields` 传给 `@tmagic/form`。Node 里从 `@tmagic/form/headless` 与 `@tmagic/editor/headless` 引入即可。若安装时也传了 `fields`,会与编辑器字段按 type 浅合并:调用方传入的 key 覆盖对应项,未传的 key(如 `innerConfig` / `typeMatch`)保留。服务数据(数据源 / 代码块 / 节点树)未就绪时,只做基础形态校验,不做枚举或存在性失败。
|
||||
|
||||
| 字段 type | 期望值 |
|
||||
| --- | --- |
|
||||
| `key-value` / `style-setter` | 普通对象;`key-value` + `advanced` 且值为 `function` 时放行 |
|
||||
| `cond-op-select` | 已知算子;能解析字段类型时按类型收窄 |
|
||||
| `code-select-col` | `string`;有代码块 DSL 时须为已有 codeId |
|
||||
| `page-fragment-select` / `ui-select` | `string \| number`;有节点树时须为已有页面片 / 组件 id |
|
||||
| `data-source-input` | `string`;`${...}` 绑定须指向已有数据源/字段 |
|
||||
| `data-source-method-select` | `[dsId, methodName]`,方法须在该数据源可选方法集中 |
|
||||
| `data-source-field-select` | 数据源路径 `string[]`;有 `fieldConfig` 且非路径值时跳过 |
|
||||
| `data-source-select` | `value: 'id'` 为已有 ds id;否则为含 `isBindDataSource` + `dataSourceId` 的对象 |
|
||||
| `code-select` | `{ hookType: 'code', hookData }` 的浅层结构校验(`codeId` 存在性 / 数据源方法存在性由内部 `code-select-col`、`data-source-method-select` 单元格各自校验,只标红出错单元格) |
|
||||
| `data-source-fields` / `data-source-mocks` / `data-source-methods` | 数组 + 浅层结构(`name`/`type`、`title`/`enable`/`data`、`content`/`params` 等) |
|
||||
| `event-select` | 数组;兼容新旧格式的浅层结构校验(`name` / 联动组件 `method` 是否 ∈ 可选项由字段内 `eventNameConfig.rules`、`compActionConfig.rules` 单独校验,只标红对应 select) |
|
||||
| `display-conds` | 数组 + `cond[].field`/`op` 浅层结构校验(`op` 是否为已知算子、字段路径是否存在由内部 `cond-op-select`、`field` 单元格各自校验,只标红出错单元格) |
|
||||
|
||||
> 容器类字段(`event-select` / `code-select` / `display-conds`)遵循同一约定:容器级 typeMatch 只做结构校验,「枚举 / 存在性」下沉到内部单元格各自的 typeMatch/rules,避免单个子项非法导致整块表单标红。
|
||||
|
||||
业务仍可用 `registerField(type, { typeMatch })` 覆盖上述任一 type 的类型校验;多次 `registerField` 按字段浅合并,不会丢掉已登记的 `innerConfig` / `walk` / `effect`。
|
||||
|
||||
## 示例
|
||||
|
||||
### select 选项匹配
|
||||
|
||||
```ts
|
||||
{
|
||||
name: 'status',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ text: '启用', value: 1 },
|
||||
{ text: '禁用', value: 0 },
|
||||
],
|
||||
rules: [
|
||||
{ required: true, message: '请选择状态' },
|
||||
{ typeMatch: true, message: '状态值不合法' },
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
### date 按 valueFormat 校验
|
||||
|
||||
```ts
|
||||
{
|
||||
name: 'birthday',
|
||||
type: 'date',
|
||||
valueFormat: 'YYYY-MM-DD',
|
||||
rules: [{ typeMatch: true, message: '日期格式不正确' }],
|
||||
}
|
||||
```
|
||||
|
||||
### text + filter: number
|
||||
|
||||
```ts
|
||||
{
|
||||
name: 'width',
|
||||
type: 'text',
|
||||
filter: 'number',
|
||||
rules: [{ typeMatch: true, message: '宽度应为数字' }],
|
||||
}
|
||||
```
|
||||
|
||||
## 类型定义
|
||||
|
||||
::: details 查看 Rule 类型定义
|
||||
<<< @/../packages/form-schema/src/base.ts#Rule{ts}
|
||||
:::
|
||||
|
||||
::: details 查看 TypeMatchValidator / TypeMatchValidateContext 类型定义
|
||||
<<< @/../packages/form/src/utils/typeMatch.ts#TypeMatchValidator{ts}
|
||||
|
||||
<<< @/../packages/form/src/utils/typeMatch.ts#TypeMatchValidateContext{ts}
|
||||
:::
|
||||
|
||||
::: details 查看 FieldOptions 类型定义
|
||||
<<< @/../packages/form/src/utils/registerField.ts#FieldOptions{ts}
|
||||
:::
|
||||
|
||||
::: details 查看 FormInstallOptions 类型定义
|
||||
<<< @/../packages/form/src/plugin.ts#FormInstallOptions{ts}
|
||||
:::
|
||||
@ -101,7 +101,7 @@ onCodeBlockDiff(id, index);
|
||||
| 字段 | 必填 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `appContext` | 否 | 父级应用上下文,用于让动态挂载的差异确认弹窗继承全局组件 / 指令 / provide / 插件(Element Plus、`@tmagic/form` 字段组件等)。在组件 `setup` 中调用时会自动取当前组件的 `appContext`,无需手动传;仅当在组件 setup 之外调用时才需显式传入(如 `editorApp._context`)。 |
|
||||
| `dialogWidth` | 否 | 内置页面 / 数据源 / 代码块的差异 / 回滚确认弹窗默认宽度(透传给 `TMagicDialog` 的 `width`),如 `'1200px'` / `'80%'`。缺省时使用弹窗内置默认宽度(`900px`)。业务自有历史可在 `viewDiff` / `confirmAndRevert` 调用时通过各自入参的 `width` 单独覆盖。 |
|
||||
| `extendState` | 否 | 透传给差异确认弹窗的 `extendState`(同 Editor 的 [`extendFormState`](#自定义对比判断)),使对比表单中依赖业务上下文的 `display` / `disabled` 等 `filterFunction` 正常工作。 |
|
||||
|
||||
> 若只需要无确认、无校验的静默回滚,直接用上面的 `editorService.revertPageStep` 等即可,无需 `useHistoryRevert`。
|
||||
|
||||
@ -163,7 +163,7 @@ const historyListExtraTabs = [
|
||||
|
||||
## 自定义对比判断
|
||||
|
||||
差异对话框中的「表单对比」最终透传到 `MForm`。Editor 会把 `services` / `stage` provide 为 `FORM_CONTEXT_KEY`,对比表单自动继承;业务字段在 `<m-editor>` 外层再 provide 一层即可,详见 [表单业务上下文](/api/editor/props.html#表单业务上下文)。配置回调通过 `mForm.xxx` 读穿取用。
|
||||
差异对话框中的「表单对比」最终透传到 `MForm`,你可以通过 Editor 顶层注入的 `extendFormState` 让对比表单拿到完整业务上下文,从而让依赖上下文的 `display` / `disabled` 等 `filterFunction` 正常工作。
|
||||
|
||||
若某些字段语义上相等但结构不同(例如 `code-select` 字段中 `''` 与 `{ hookType: 'code', hookData: [] }` 应视为相等),可借助 `@tmagic/form` 的 [`showDiff`](/api/form/form-props.html#showdiff) 自定义判断函数避免被误判为差异。
|
||||
|
||||
|
||||
@ -1,121 +0,0 @@
|
||||
# 属性配置表单校验联动
|
||||
|
||||
编辑器在属性面板(属性表单 / 样式表单)中已支持 [表单校验](../../form-config/rules.md)。默认情况下,表单校验失败时本次改动会被丢弃,不会写入节点。
|
||||
|
||||
「属性配置表单校验联动」能力(`enablePropsFormValidate`)允许在**校验失败时仍按当前表单值更新节点**,并把错误信息集中记录到 `editorService`,从而实现:
|
||||
|
||||
- 组件树(图层)中对出错节点**标红并显示错误图标**,悬停可查看错误文案;
|
||||
- 业务在**保存前拦截**存在校验错误的组件(见 [保存拦截](#保存拦截));
|
||||
- 校验错误随 DSL 操作写入历史快照,**撤销 / 重做能正确还原**错误状态。
|
||||
|
||||
> 该能力默认关闭,需通过 `<m-editor :enable-props-form-validate="true">` 显式开启。
|
||||
|
||||
## 工作原理
|
||||
|
||||
### 错误来源维度
|
||||
|
||||
属性面板分「属性」与「样式」两个独立的 `FormPanel`,它们指向同一节点。为避免某个面板校验通过时误清另一个面板记录的错误,错误信息**以来源(`source`)为键分别保存**:
|
||||
|
||||
- `'props'`:属性表单校验错误
|
||||
- `'style'`:样式表单校验错误
|
||||
|
||||
节点只要**任一来源存在非空错误文案**即视为出错。
|
||||
|
||||
内部类型定义见 `NodeInvalidInfo`:
|
||||
|
||||
```ts
|
||||
interface NodeInvalidInfo {
|
||||
/** 属性表单校验错误文案(可能为包含 <br> 的 HTML) */
|
||||
props?: string;
|
||||
/** 样式表单校验错误文案(可能为包含 <br> 的 HTML) */
|
||||
style?: string;
|
||||
}
|
||||
```
|
||||
|
||||
### 数据流
|
||||
|
||||
1. 表单校验失败时(开启了 `enablePropsFormValidate`),`FormPanel` 仍以当前表单值提交,并把错误随提交事件一并抛出;
|
||||
2. `PropsPanel` 在调用 `editorService.update()` 时,把 `invalidInfo: { id, source, error }` 一并传入;
|
||||
3. `editorService` 在**写入历史记录之前**落库错误标记(`applyInvalidInfo`),使历史快照与本次变更对齐;
|
||||
4. 错误被记录到 `invalidNodeIds` 状态,并触发 `invalid-node-change` 事件;
|
||||
5. 组件树节点通过读取 `invalidNodeIds` 响应式地展示标红与错误图标。
|
||||
|
||||
校验成功时(或源码编辑器保存时)不携带 `invalidInfo`,保持已有错误状态不变(即不会清除错误)——只有对应来源的表单再次校验通过才会清除该来源的错误。
|
||||
|
||||
## 快速开始
|
||||
|
||||
```html
|
||||
<template>
|
||||
<m-editor :enable-props-form-validate="true"></m-editor>
|
||||
</template>
|
||||
```
|
||||
|
||||
开启后无需其它配置,属性 / 样式表单校验失败即会自动记录错误并标红组件树节点。
|
||||
|
||||
## 读取与清除错误
|
||||
|
||||
业务侧可通过 `editorService` 读取或清除错误标记:
|
||||
|
||||
```js
|
||||
import { editorService } from "@tmagic/editor";
|
||||
|
||||
// 读取全部错误节点(Map<Id, NodeInvalidInfo>)
|
||||
const invalidNodeIds = editorService.getInvalidNodeIds();
|
||||
|
||||
// 读取单个节点的错误
|
||||
const info = editorService.getInvalidNodeInfo("text_123");
|
||||
|
||||
// 手动记录 / 清除错误(一般无需手动调用,属性面板已自动维护)
|
||||
editorService.setInvalidNode("text_123", "props", "标题不能为空");
|
||||
editorService.deleteInvalidNode("text_123", "props"); // 仅清除属性表单错误
|
||||
editorService.resetInvalidNodeId(); // 清空全部错误
|
||||
```
|
||||
|
||||
订阅错误状态变化(非响应式消费方,如自定义工具栏):
|
||||
|
||||
```js
|
||||
editorService.on("invalid-node-change", (invalidNodeIds) => {
|
||||
console.log("校验错误节点数:", invalidNodeIds.size);
|
||||
});
|
||||
```
|
||||
|
||||
> 响应式读取(如自定义组件树节点内容)请直接读取 `editorService.get('invalidNodeIds')`,其变化会触发 Vue 响应式更新。
|
||||
|
||||
## 保存拦截
|
||||
|
||||
开启校验联动后,业务可读取 `invalidNodeIds`,在存在校验错误时阻止保存并提示:
|
||||
|
||||
```js
|
||||
import { editorService } from "@tmagic/editor";
|
||||
import { tMagicMessage } from "@tmagic/design";
|
||||
|
||||
const checkInvalidNodes = (services) => {
|
||||
const invalidNodeIds = services?.editorService.getInvalidNodeIds?.();
|
||||
if (!invalidNodeIds || invalidNodeIds.size === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const names = [...invalidNodeIds.keys()].map((id) => {
|
||||
const node = services?.editorService.getNodeById(id);
|
||||
return node?.name ? `${node.name}(${id})` : `${id}`;
|
||||
});
|
||||
|
||||
tMagicMessage.error(`以下组件存在配置校验错误,请修复后再保存:${names.join("、")}`);
|
||||
return false;
|
||||
};
|
||||
|
||||
// 在菜单保存按钮 / 预览保存的 handler 中:
|
||||
if (!checkInvalidNodes(services)) return;
|
||||
save();
|
||||
```
|
||||
|
||||
playground 已内置该拦截逻辑,参见 `playground/src/pages/composables/use-editor-menu.ts`。
|
||||
|
||||
## 与历史记录的关系
|
||||
|
||||
错误标记会随 DSL 操作(`add` / `update` 等)一并写入历史快照。编辑器内部在「操作前」与「操作后」分别留存错误状态快照:
|
||||
|
||||
- **撤销(undo)**:还原到操作前的错误状态(撤销一个「校验失败」的改动后错误消失);
|
||||
- **重做(redo)**:还原到操作后的错误状态(重做后错误恢复)。
|
||||
|
||||
整体替换 `root`(如加载新 DSL)后会自动清理不存在于新 DSL 中的失效节点错误记录,避免残留误报;删除节点时也会同步清理其子树相关的错误记录。
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
tmagic-editor的表单配置,核心就是使用了 @tmagic/form 来作为渲染器。@tmagic/form 是一个 npm 包,可以安装它,在你想使用的地方单独使用。
|
||||
|
||||
@tmagic/form 接受一个表单配置,详细配置可参考[表单 api](../../api/form/form-props.md)。字段校验(含 `typeMatch` 类型匹配)见[表单校验](../../form-config/rules.md)。
|
||||
@tmagic/form 接受一个表单配置,详细配置可参考[表单 api](../../api/form/form-props.md)。
|
||||
|
||||
## 安装
|
||||
|
||||
@ -45,25 +45,6 @@ app.use(MagicForm);
|
||||
app.mount("#app");
|
||||
```
|
||||
|
||||
也可在安装时传入自定义字段登记(叶子 / innerConfig / typeMatch / component),详见[表单校验 - 扩展自定义 type 规则](../../form-config/rules.md#扩展自定义-type-规则):
|
||||
|
||||
```javascript
|
||||
import MyField from './MyField.vue';
|
||||
|
||||
app.use(MagicForm, {
|
||||
fields: {
|
||||
'my-field': {
|
||||
component: MyField,
|
||||
typeMatch: (value, { message }) => {
|
||||
if (typeof value !== 'string') {
|
||||
return message || 'my-field 应为字符串';
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
以上代码便完成了 @tmagic/form 的引入。需要注意的是,Element Plus 的样式文件需要单独引入。
|
||||
|
||||
|
||||
@ -69,11 +69,11 @@ $ npm install monaco-editor -S
|
||||
|
||||
```ts
|
||||
import { createApp } from "vue";
|
||||
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 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 editorPlugin from "@tmagic/editor";
|
||||
import MagicElementPlusAdapter from "@tmagic/element-plus-adapter";
|
||||
|
||||
@ -43,7 +43,6 @@ new DataSource(options: DataSourceOptions)
|
||||
| `methods` | `CodeBlockContent[]` | 自定义方法配置 |
|
||||
| `data` | `any` | 当前数据 |
|
||||
| `isInit` | `boolean` | 是否已初始化 |
|
||||
| `isMounted` | `boolean` | 页面渲染后的 `mounted` 是否已执行 |
|
||||
|
||||
## 实例方法
|
||||
|
||||
@ -173,28 +172,6 @@ ds.onDataChange('user.name', (payload) => {
|
||||
|
||||
初始化数据源。
|
||||
|
||||
### mounted
|
||||
|
||||
- **返回:**
|
||||
- `{Promise<void>}`
|
||||
|
||||
- **详情:**
|
||||
|
||||
页面渲染完成后执行,执行后 `isMounted` 为 `true`。由 `DataSourceManager` 监听到 `mounted` 事件后统一调用,自定义数据源可以重写该方法实现「页面渲染后」的逻辑。
|
||||
|
||||
- **示例:**
|
||||
|
||||
```typescript
|
||||
class CustomDataSource extends DataSource {
|
||||
public async mounted() {
|
||||
// 页面渲染后再拉取数据,避免阻塞首屏
|
||||
this.setData(await fetchSomething());
|
||||
|
||||
await super.mounted();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### destroy
|
||||
|
||||
- **返回:**
|
||||
|
||||
@ -25,7 +25,6 @@ new DataSourceManager(options: DataSourceManagerOptions)
|
||||
| `data` | `DataSourceManagerData` | 所有数据源的数据 |
|
||||
| `initialData` | `DataSourceManagerData` | 初始化数据 |
|
||||
| `useMock` | `boolean` | 是否使用 Mock 数据 |
|
||||
| `isMounted` | `boolean` | 页面是否已渲染完成(收到过 `mounted` 事件) |
|
||||
|
||||
## 静态方法
|
||||
|
||||
@ -110,27 +109,6 @@ if (ds) {
|
||||
}
|
||||
```
|
||||
|
||||
### mounted
|
||||
|
||||
- **参数:**
|
||||
- `{DataSource} ds` 单个数据源实例(必填)
|
||||
|
||||
- **返回:**
|
||||
- `{Promise<void>}`
|
||||
|
||||
- **详情:**
|
||||
|
||||
页面渲染完成后执行单个数据源的 `mounted`:先调用 `ds.mounted()`,再依次执行 `methods` 中 `timing === 'mounted'` 的方法。当 `ds.isMounted` 为 `true`,或当前 `app.jsEngine` 命中 `ds.schema.disabledInitInJsEngine` 时直接跳过。
|
||||
|
||||
一般不需要手动调用,runtime 在顶层组件渲染完成后触发 `mounted` 事件,由 manager 统一对所有数据源调用该方法。
|
||||
|
||||
- **示例:**
|
||||
|
||||
```typescript
|
||||
// runtime 顶层组件渲染完成后
|
||||
app.dataSourceManager?.emit('mounted');
|
||||
```
|
||||
|
||||
### get
|
||||
|
||||
- **参数:**
|
||||
@ -206,7 +184,7 @@ const ds = dataSourceManager.addDataSource({
|
||||
|
||||
- **详情:**
|
||||
|
||||
同步更新数据源 DSL 配置:先按 `id` 移除已有数据源,再以 `cloneDeep` 重新 `addDataSource`,并对新建实例触发 `init`(异步执行,不会被该方法 `await`);若此时 `isMounted` 已为 `true`,`init` 完成后还会补充执行 `mounted`。一般在编辑器中修改配置后调用。
|
||||
同步更新数据源 DSL 配置:先按 `id` 移除已有数据源,再以 `cloneDeep` 重新 `addDataSource`,并对新建实例触发 `init`(异步执行,不会被该方法 `await`)。一般在编辑器中修改配置后调用。
|
||||
|
||||
### compiledNode
|
||||
|
||||
@ -347,7 +325,6 @@ DataSourceManager 继承自 EventEmitter,支持以下事件:
|
||||
|--------|------|----------|
|
||||
| `change` | 单个数据源数据变化 | `(dsId: string, changeEvent: ChangeEvent)` |
|
||||
| `init` | 所有数据源初始化完成;现代分支携带 `(data, errors)`,旧 Promise.all 分支为 `(this.data)` | `(data, errors?)` |
|
||||
| `mounted` | 由 runtime 在顶层组件渲染完成后触发,manager 收到后统一执行各数据源的 `mounted` | 无 |
|
||||
| `registered-all` | 所有数据源注册完成 | 无 |
|
||||
| `update-data` | 由 `createDataSourceManager` 在数据变化后发出,用于通知节点重新渲染 | `(newNodes: MNode[], sourceId: string, changeEvent: ChangeEvent, pageId: Id)` |
|
||||
|
||||
|
||||
@ -22,7 +22,7 @@ export default {
|
||||
['^(@tencent)(/.*|$)'],
|
||||
['^(@tmagic)(/.*|$)'],
|
||||
// Internal packages.
|
||||
['^(@|src|editor-page|@editor|@form|@data-source)(/.*|$)'],
|
||||
['^(@|src|editor-page|@editor|@data-source)(/.*|$)'],
|
||||
// Side effect imports.
|
||||
['^\\u0000'],
|
||||
// Parent imports. Put `..` last.
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@tmagic/eslint-config",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.0",
|
||||
"main": "index.mjs",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
@ -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-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"
|
||||
"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"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": ">=10.0.0",
|
||||
|
||||
@ -16,7 +16,7 @@ export default defineConfig([
|
||||
'*/**/public/**/*',
|
||||
'*/**/types/**/*',
|
||||
'*/**/*.config.ts',
|
||||
'./temp/**/*',
|
||||
'./tepm/**/*',
|
||||
'vite-env.d.ts',
|
||||
]),
|
||||
...eslintConfig(path.join(path.dirname(fileURLToPath(import.meta.url)), 'tsconfig.json')),
|
||||
|
||||
65
package.json
65
package.json
@ -1,12 +1,12 @@
|
||||
{
|
||||
"version": "1.8.0-beta.27",
|
||||
"version": "1.8.0-beta.10",
|
||||
"name": "tmagic",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@11.21.0",
|
||||
"packageManager": "pnpm@10.33.4",
|
||||
"scripts": {
|
||||
"bootstrap": "pnpm i && pnpm build",
|
||||
"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:top": "rimraf */**/dist */**/types */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 .",
|
||||
@ -15,8 +15,7 @@
|
||||
"pg": "pnpm playground",
|
||||
"playground:react": "pnpm --filter \"runtime-react\" build:libs && pnpm --filter \"runtime-react\" --filter \"tmagic-playground\" dev:react",
|
||||
"pg:react": "pnpm playground:react",
|
||||
"build": "pnpm build:dts && node scripts/build.mjs && pnpm check:headless",
|
||||
"check:headless": "node scripts/check-headless-dist.mjs",
|
||||
"build": "pnpm build:dts && node scripts/build.mjs",
|
||||
"build:dts": "pnpm --filter \"@tmagic/cli\" build && tsc -p tsconfig.build-browser.json && vue-tsc --declaration --emitDeclarationOnly --project tsconfig.build-vue.json && rolldown -c rolldown.dts.config.mjs && rimraf temp",
|
||||
"check:type": "node scripts/check-type.mjs",
|
||||
"build:playground": "pnpm --filter \"runtime-vue\" build && pnpm --filter \"tmagic-playground\" build",
|
||||
@ -25,7 +24,7 @@
|
||||
"docs:build": "vitepress build docs",
|
||||
"reinstall": "pnpm clean:all && pnpm bootstrap",
|
||||
"test": "vitest run",
|
||||
"coverage": "vitest run --coverage && node scripts/check-coverage.mjs",
|
||||
"coverage": "vitest run --coverage",
|
||||
"prepare": "husky",
|
||||
"changelog": "conventional-changelog -p angular -i CHANGELOG.md -s",
|
||||
"release": "node scripts/release.mjs"
|
||||
@ -41,44 +40,44 @@
|
||||
"url": "https://github.com/Tencent/tmagic-editor.git"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^21.2.1",
|
||||
"@commitlint/config-conventional": "^21.2.0",
|
||||
"@commitlint/cli": "^20.1.0",
|
||||
"@commitlint/config-conventional": "^20.0.0",
|
||||
"@tmagic/eslint-config": "workspace:*",
|
||||
"@types/node": "26.1.2",
|
||||
"@vitejs/plugin-vue": "^6.0.8",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"@types/node": "24.0.10",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
"@vue/compiler-sfc": "catalog:",
|
||||
"c8": "^12.0.0",
|
||||
"commitizen": "^4.3.2",
|
||||
"c8": "^10.1.3",
|
||||
"commitizen": "^4.3.1",
|
||||
"conventional-changelog-cli": "^5.0.0",
|
||||
"cosmiconfig": "^10.0.0",
|
||||
"cosmiconfig": "^ 9.0.0 ",
|
||||
"cz-conventional-changelog": "^3.3.0",
|
||||
"element-plus": "catalog:",
|
||||
"element-plus": "^2.11.8",
|
||||
"enquirer": "^2.4.1",
|
||||
"eslint": "^10.8.1",
|
||||
"execa": "^10.0.1",
|
||||
"happy-dom": "^20.11.2",
|
||||
"eslint": "^10.3.0",
|
||||
"execa": "^9.6.0",
|
||||
"happy-dom": "^20.9.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^30.0.1",
|
||||
"lint-staged": "^17.3.0",
|
||||
"jsdom": "^27.2.0",
|
||||
"lint-staged": "^16.2.7",
|
||||
"minimist": "^1.2.8",
|
||||
"picocolors": "^1.1.1",
|
||||
"prettier": "^3.9.6",
|
||||
"recast": "^0.23.20",
|
||||
"rimraf": "^6.1.3",
|
||||
"rolldown": "^1.2.3",
|
||||
"rolldown-plugin-dts": "^0.28.1",
|
||||
"sass-embedded": "^1.100.0",
|
||||
"semver": "^7.8.5",
|
||||
"serialize-javascript": "^7.1.0",
|
||||
"shx": "^0.4.0",
|
||||
"prettier": "^3.8.3",
|
||||
"recast": "^0.23.11",
|
||||
"rimraf": "^3.0.2",
|
||||
"rolldown": "^1.1.2",
|
||||
"rolldown-plugin-dts": "^0.26.0",
|
||||
"sass-embedded": "^1.99.0",
|
||||
"semver": "^7.7.3",
|
||||
"serialize-javascript": "^7.0.0",
|
||||
"shx": "^0.3.4",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vitepress": "2.0.0-alpha.19",
|
||||
"vitest": "^4.1.10",
|
||||
"vue": "^3.5.41",
|
||||
"vue-tsc": "^3.3.9"
|
||||
"vitepress": "^2.0.0-alpha.17",
|
||||
"vitest": "^4.1.9",
|
||||
"vue": "catalog:",
|
||||
"vue-tsc": "^3.2.9"
|
||||
},
|
||||
"config": {
|
||||
"commitizen": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { cli } from '../lib/index.js';
|
||||
const { cli } = require('../lib');
|
||||
|
||||
cli({
|
||||
source: process.cwd(),
|
||||
|
||||
@ -1,12 +1,11 @@
|
||||
{
|
||||
"version": "1.8.0-beta.27",
|
||||
"version": "1.8.0-beta.10",
|
||||
"name": "@tmagic/cli",
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/index.d.ts",
|
||||
"license": "Apache-2.0",
|
||||
"scripts": {
|
||||
"build": "pnpm clean && rolldown -c rolldown.config.mjs",
|
||||
"build": "pnpm clean && tsc -b tsconfig.build.json",
|
||||
"clean": "rimraf lib *.tsbuildinfo",
|
||||
"check:type": "tsc --noEmit --project tsconfig.build.json"
|
||||
},
|
||||
@ -27,18 +26,25 @@
|
||||
"url": "https://github.com/Tencent/tmagic-editor.git"
|
||||
},
|
||||
"dependencies": {
|
||||
"cac": "^7.0.0",
|
||||
"chokidar": "^5.0.0",
|
||||
"fs-extra": "^11.4.0",
|
||||
"cac": "^6.7.14",
|
||||
"chokidar": "^3.6.0",
|
||||
"esbuild": "^0.21.5",
|
||||
"fs-extra": "^11.2.0",
|
||||
"merge-options": "^3.0.4",
|
||||
"picocolors": "^1.1.1",
|
||||
"recast": "^0.23.19",
|
||||
"typescript": "catalog:"
|
||||
"recast": "^0.23.11",
|
||||
"tslib": "^2.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/node": "^26.1.2",
|
||||
"rolldown": "^1.2.2",
|
||||
"rolldown-plugin-dts": "^0.28.0"
|
||||
"@types/node": "^24.0.10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,36 +0,0 @@
|
||||
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,7 +4,6 @@ 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';
|
||||
|
||||
/**
|
||||
@ -30,7 +29,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}`);
|
||||
|
||||
|
||||
@ -1,9 +0,0 @@
|
||||
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,27 +1,23 @@
|
||||
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 =>
|
||||
ts.transpileModule(fs.readFileSync(filename).toString(), {
|
||||
compilerOptions: {
|
||||
inlineSourceMap: true,
|
||||
inlineSources: true,
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
},
|
||||
fileName: filename,
|
||||
}).outputText;
|
||||
transformSync(fs.readFileSync(filename).toString(), {
|
||||
format: 'cjs',
|
||||
loader: 'ts',
|
||||
sourcefile: filename,
|
||||
sourcemap: 'inline',
|
||||
target: 'node18',
|
||||
}).code;
|
||||
|
||||
/**
|
||||
* Globally allow ts files to be loaded via `require()`
|
||||
*/
|
||||
export const allowTs = (): void => {
|
||||
require.extensions['.ts'] = (m: any, filename: string) => {
|
||||
require.extensions['.ts'] = (m: any, filename) => {
|
||||
m._compile(transformTsFileToCodeSync(filename), filename);
|
||||
};
|
||||
};
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
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 =>
|
||||
@ -11,6 +10,7 @@ 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,7 +1,6 @@
|
||||
import * as recast from 'recast';
|
||||
|
||||
import type App from '../Core';
|
||||
import { require } from '../require';
|
||||
import { EntryType } from '../types';
|
||||
|
||||
export const prepareEntryFile = async (app: App) => {
|
||||
@ -139,6 +138,7 @@ 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,11 +2,10 @@ import { execSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { exit } from 'node:process';
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import fs, { existsSync } 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';
|
||||
@ -128,7 +127,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(
|
||||
@ -295,7 +294,7 @@ const getComponentPackageImports = function ({
|
||||
if (propertyMatch) {
|
||||
let file = getIndexPath(path.resolve(path.dirname(indexPath), propertyMatch.source.value));
|
||||
|
||||
if (!fs.existsSync(file)) {
|
||||
if (!existsSync(file)) {
|
||||
file = propertyMatch.source.value;
|
||||
}
|
||||
|
||||
@ -519,7 +518,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,10 +2,12 @@
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"moduleResolution": "bundler",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "node16",
|
||||
"module": "node16",
|
||||
"rootDir": "./src",
|
||||
"outDir": "./lib",
|
||||
"declaration": true,
|
||||
|
||||
"types": ["node"],
|
||||
},
|
||||
"include": ["./src"],
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.8.0-beta.27",
|
||||
"version": "1.8.0-beta.10",
|
||||
"name": "@tmagic/core",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
@ -39,11 +39,11 @@
|
||||
"@tmagic/schema": "workspace:*",
|
||||
"@tmagic/utils": "workspace:*",
|
||||
"events": "^3.3.0",
|
||||
"lodash-es": "^4.18.1"
|
||||
"lodash-es": "^4.17.21"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/events": "^3.0.3",
|
||||
"@types/lodash-es": "^4.17.12"
|
||||
"@types/lodash-es": "^4.17.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "catalog:"
|
||||
|
||||
@ -76,11 +76,9 @@ class IteratorContainer extends Node {
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(config.items)) {
|
||||
config.items?.forEach((element: MNode) => {
|
||||
this.initNode(element, node, map);
|
||||
});
|
||||
}
|
||||
config.items?.forEach((element: MNode) => {
|
||||
this.initNode(element, node, map);
|
||||
});
|
||||
}
|
||||
|
||||
public setNodes(nodes: MNode[], index: number) {
|
||||
|
||||
@ -79,11 +79,9 @@ class Page extends Node {
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(config.items)) {
|
||||
config.items?.forEach((element: MComponent | MContainer) => {
|
||||
this.initNode(element, node);
|
||||
});
|
||||
}
|
||||
config.items?.forEach((element: MComponent | MContainer) => {
|
||||
this.initNode(element, node);
|
||||
});
|
||||
}
|
||||
|
||||
public getNode<T extends TMagicNode = TMagicNode>(
|
||||
|
||||
@ -51,7 +51,7 @@ export const style2Obj = (style: string) => {
|
||||
|
||||
export const fillBackgroundImage = (value: string) => {
|
||||
if (value && !/^url/.test(value) && !/^linear-gradient/.test(value)) {
|
||||
return `url("${value}")`;
|
||||
return `url(${value})`;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
@ -341,20 +341,6 @@ describe('App 配置/方法/组件注册', () => {
|
||||
expect(app.page).toBeUndefined();
|
||||
});
|
||||
|
||||
test('deletePage 销毁当前 page', () => {
|
||||
const app = new App({
|
||||
config: {
|
||||
type: NodeType.ROOT,
|
||||
id: 'app',
|
||||
items: [{ type: NodeType.PAGE, id: 'p1', items: [] }],
|
||||
},
|
||||
});
|
||||
expect(app.page?.data.id).toBe('p1');
|
||||
|
||||
app.deletePage();
|
||||
expect(app.page).toBeUndefined();
|
||||
});
|
||||
|
||||
test('runCode 执行代码块', async () => {
|
||||
const fn = vi.fn();
|
||||
const app = new App({
|
||||
|
||||
@ -50,7 +50,7 @@ describe('style2Obj', () => {
|
||||
|
||||
describe('fillBackgroundImage', () => {
|
||||
test('裸路径会包裹 url()', () => {
|
||||
expect(fillBackgroundImage('a.png')).toBe('url("a.png")');
|
||||
expect(fillBackgroundImage('a.png')).toBe('url(a.png)');
|
||||
});
|
||||
|
||||
test('已经是 url() 不重复包裹', () => {
|
||||
@ -127,7 +127,7 @@ describe('transformStyle', () => {
|
||||
|
||||
test('backgroundImage: browser 下补全 url()', () => {
|
||||
expect(transformStyle({ backgroundImage: 'a.png' }, 'browser')).toEqual({
|
||||
backgroundImage: 'url("a.png")',
|
||||
backgroundImage: 'url(a.png)',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.8.0-beta.27",
|
||||
"version": "1.8.0-beta.10",
|
||||
"name": "@tmagic/data-source",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
@ -32,9 +32,9 @@
|
||||
"data-source"
|
||||
],
|
||||
"dependencies": {
|
||||
"deep-state-observer": "^5.5.17",
|
||||
"deep-state-observer": "^5.5.13",
|
||||
"events": "^3.3.0",
|
||||
"lodash-es": "^4.18.1"
|
||||
"lodash-es": "^4.17.21"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tmagic/core": "workspace:*",
|
||||
@ -47,6 +47,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/events": "^3.0.3",
|
||||
"@types/lodash-es": "^4.17.12"
|
||||
"@types/lodash-es": "^4.17.4"
|
||||
}
|
||||
}
|
||||
|
||||
@ -57,7 +57,7 @@ class DataSourceManager extends EventEmitter {
|
||||
for (let config = list.shift(); config; config = list.shift()) {
|
||||
const ds = app.addDataSource(config);
|
||||
if (ds) {
|
||||
app.initAndMount(ds);
|
||||
app.init(ds);
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -84,8 +84,6 @@ class DataSourceManager extends EventEmitter {
|
||||
public data: DataSourceManagerData = {};
|
||||
public initialData: DataSourceManagerData = {};
|
||||
public useMock?: boolean = false;
|
||||
/** 页面是否已经渲染完成 */
|
||||
public isMounted = false;
|
||||
|
||||
constructor({ app, useMock, initialData }: DataSourceManagerOptions) {
|
||||
super();
|
||||
@ -111,12 +109,6 @@ class DataSourceManager extends EventEmitter {
|
||||
this.callDsInit();
|
||||
});
|
||||
}
|
||||
|
||||
// 由runtime在顶层组件渲染完成后触发
|
||||
this.on('mounted', () => {
|
||||
this.isMounted = true;
|
||||
this.callDsMounted();
|
||||
});
|
||||
}
|
||||
|
||||
public async init(ds: DataSource) {
|
||||
@ -145,29 +137,6 @@ class DataSourceManager extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面渲染完成后执行单个数据源的mounted
|
||||
* @param {DataSource} ds 数据源实例
|
||||
*/
|
||||
public async mounted(ds: DataSource) {
|
||||
if (ds.isMounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.app.jsEngine && ds.schema.disabledInitInJsEngine?.includes(this.app.jsEngine)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await ds.mounted?.();
|
||||
|
||||
for (const method of ds.methods) {
|
||||
if (typeof method.content !== 'function') return;
|
||||
if (method.timing === 'mounted') {
|
||||
await method.content({ params: {}, dataSource: ds, app: this.app });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public get(id: string) {
|
||||
return this.dataSourceMap.get(id);
|
||||
}
|
||||
@ -250,7 +219,7 @@ class DataSourceManager extends EventEmitter {
|
||||
this.addDataSource(cloneDeep(schema));
|
||||
const newDs = this.get(schema.id);
|
||||
if (newDs) {
|
||||
this.initAndMount(newDs);
|
||||
this.init(newDs);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -421,28 +390,6 @@ class DataSourceManager extends EventEmitter {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private callDsMounted() {
|
||||
const promises = Array.from(this.dataSourceMap).map(([, ds]) => this.mounted(ds));
|
||||
|
||||
if (typeof Promise.allSettled === 'function') {
|
||||
return Promise.allSettled(promises);
|
||||
}
|
||||
|
||||
return Promise.all(promises.map((promise) => promise.catch(() => undefined)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化数据源,如果页面已经渲染完成(如异步注册的数据源类型),则在初始化后补充执行mounted
|
||||
* @param {DataSource} ds 数据源实例
|
||||
*/
|
||||
private async initAndMount(ds: DataSource) {
|
||||
await this.init(ds);
|
||||
|
||||
if (this.isMounted) {
|
||||
await this.mounted(ds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default DataSourceManager;
|
||||
|
||||
@ -31,7 +31,6 @@ import type { ChangeEvent, DataSourceOptions } from '@data-source/types';
|
||||
*/
|
||||
export default class DataSource<T extends DataSourceSchema = DataSourceSchema> extends EventEmitter {
|
||||
public isInit = false;
|
||||
public isMounted = false;
|
||||
|
||||
/** @tmagic/core 实例 */
|
||||
public app: TMagicApp;
|
||||
@ -153,13 +152,6 @@ export default class DataSource<T extends DataSourceSchema = DataSourceSchema> e
|
||||
this.isInit = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面渲染后执行,由 DataSourceManager 监听到 mounted 事件后统一调用
|
||||
*/
|
||||
public async mounted() {
|
||||
this.isMounted = true;
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
this.#fields = [];
|
||||
this.removeAllListeners();
|
||||
|
||||
@ -38,7 +38,8 @@ export interface HttpDataSourceSchema extends DataSourceSchema {
|
||||
};
|
||||
autoFetch?: boolean;
|
||||
beforeRequest:
|
||||
string | ((options: HttpOptions, content: { app: TMagicApp; dataSource: HttpDataSource }) => HttpOptions);
|
||||
| string
|
||||
| ((options: HttpOptions, content: { app: TMagicApp; dataSource: HttpDataSource }) => HttpOptions);
|
||||
afterResponse:
|
||||
| string
|
||||
| ((response: any, content: { app: TMagicApp; dataSource: HttpDataSource; options: Partial<HttpOptions> }) => any);
|
||||
|
||||
@ -7,7 +7,8 @@ import {
|
||||
DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX,
|
||||
dataSourceTemplateRegExp,
|
||||
getValueByKeyPath,
|
||||
isPageOrFragment,
|
||||
isPage,
|
||||
isPageFragment,
|
||||
NODE_CONDS_KEY,
|
||||
replaceChildNode,
|
||||
} from '@tmagic/core';
|
||||
@ -67,7 +68,7 @@ export const compliedConditions = (node: { [NODE_CONDS_KEY]?: DisplayCond[] }, d
|
||||
};
|
||||
|
||||
export const updateNode = (node: MNode, dsl: MApp) => {
|
||||
if (isPageOrFragment(node)) {
|
||||
if (isPage(node) || isPageFragment(node)) {
|
||||
const index = dsl.items?.findIndex((child: MNode) => child.id === node.id);
|
||||
dsl.items.splice(index, 1, node as MPage | MPageFragment);
|
||||
} else {
|
||||
|
||||
@ -38,25 +38,6 @@ describe('DataSource', () => {
|
||||
|
||||
expect(ds.isInit).toBeTruthy();
|
||||
});
|
||||
|
||||
test('mounted', async () => {
|
||||
const ds = new DataSource({
|
||||
schema: {
|
||||
type: 'base',
|
||||
id: '1',
|
||||
fields: [{ name: 'name' }],
|
||||
methods: [],
|
||||
events: [],
|
||||
},
|
||||
app: new App({}),
|
||||
});
|
||||
|
||||
expect(ds.isMounted).toBe(false);
|
||||
|
||||
await ds.mounted();
|
||||
|
||||
expect(ds.isMounted).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DataSource setData', () => {
|
||||
|
||||
@ -302,227 +302,6 @@ describe('DataSourceManager - init 生命周期', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('DataSourceManager - mounted 生命周期', () => {
|
||||
afterEach(() => {
|
||||
DataSourceManager.clearDataSourceClass();
|
||||
});
|
||||
|
||||
// 等待 mounted 事件触发的异步流程执行完
|
||||
const flush = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const createApp = (id: string, dataSources: any[] = [], jsEngine?: any) =>
|
||||
new TMagicApp({
|
||||
...(jsEngine ? { jsEngine } : {}),
|
||||
config: {
|
||||
type: NodeType.ROOT,
|
||||
id,
|
||||
items: [],
|
||||
dataSources,
|
||||
},
|
||||
} as any);
|
||||
|
||||
test('mounted 事件会统一执行所有数据源的 mounted', async () => {
|
||||
const app = createApp('app_mounted', [
|
||||
{ type: 'base', id: 'ds_m1', fields: [], methods: [], events: [] },
|
||||
{ type: 'base', id: 'ds_m2', fields: [], methods: [], events: [] },
|
||||
]);
|
||||
const dsm = new DataSourceManager({ app });
|
||||
|
||||
expect(dsm.isMounted).toBe(false);
|
||||
expect(dsm.get('ds_m1')?.isMounted).toBe(false);
|
||||
|
||||
dsm.emit('mounted');
|
||||
await flush();
|
||||
|
||||
expect(dsm.isMounted).toBe(true);
|
||||
expect(dsm.get('ds_m1')?.isMounted).toBe(true);
|
||||
expect(dsm.get('ds_m2')?.isMounted).toBe(true);
|
||||
});
|
||||
|
||||
test('methods 中 timing=mounted 的 content 会在 ds.mounted 之后调用', async () => {
|
||||
const app = createApp('app_mounted_method');
|
||||
const dsm = new DataSourceManager({ app });
|
||||
const order: string[] = [];
|
||||
const mountedContent = vi.fn(() => {
|
||||
order.push('method');
|
||||
});
|
||||
const ds = new DataSource({
|
||||
app,
|
||||
schema: {
|
||||
type: 'base',
|
||||
id: 'ds_mounted_method',
|
||||
fields: [],
|
||||
events: [],
|
||||
methods: [{ name: 'onMounted', content: mountedContent, timing: 'mounted', params: [] }],
|
||||
} as any,
|
||||
});
|
||||
const origMounted = ds.mounted.bind(ds);
|
||||
ds.mounted = async () => {
|
||||
order.push('mounted');
|
||||
await origMounted();
|
||||
};
|
||||
|
||||
await dsm.mounted(ds);
|
||||
|
||||
expect(mountedContent).toHaveBeenCalledTimes(1);
|
||||
const arg = mountedContent.mock.calls[0][0] as any;
|
||||
expect(arg.dataSource).toBe(ds);
|
||||
expect(arg.app).toBe(app);
|
||||
expect(order).toEqual(['mounted', 'method']);
|
||||
});
|
||||
|
||||
test('ds.isMounted 为 true 时直接跳过', async () => {
|
||||
const app = createApp('app_mounted_skip');
|
||||
const dsm = new DataSourceManager({ app });
|
||||
const content = vi.fn();
|
||||
const ds = new DataSource({
|
||||
app,
|
||||
schema: {
|
||||
type: 'base',
|
||||
id: 'ds_mounted_skip',
|
||||
fields: [],
|
||||
events: [],
|
||||
methods: [{ name: 'onMounted', content, timing: 'mounted', params: [] }],
|
||||
} as any,
|
||||
});
|
||||
|
||||
await dsm.mounted(ds);
|
||||
await dsm.mounted(ds);
|
||||
|
||||
expect(content).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('jsEngine 命中 disabledInitInJsEngine 时跳过 mounted', async () => {
|
||||
const app = createApp('app_mounted_disabled', [], 'nodejs');
|
||||
const dsm = new DataSourceManager({ app });
|
||||
const ds = new DataSource({
|
||||
app,
|
||||
schema: {
|
||||
type: 'base',
|
||||
id: 'ds_mounted_disabled',
|
||||
fields: [],
|
||||
methods: [],
|
||||
events: [],
|
||||
disabledInitInJsEngine: ['nodejs'],
|
||||
} as any,
|
||||
});
|
||||
|
||||
await dsm.mounted(ds);
|
||||
|
||||
expect(ds.isMounted).toBe(false);
|
||||
});
|
||||
|
||||
test('method.content 非函数时提前返回', async () => {
|
||||
const app = createApp('app_mounted_bad');
|
||||
const dsm = new DataSourceManager({ app });
|
||||
const content = vi.fn();
|
||||
const ds = new DataSource({
|
||||
app,
|
||||
schema: {
|
||||
type: 'base',
|
||||
id: 'ds_mounted_bad',
|
||||
fields: [],
|
||||
events: [],
|
||||
methods: [
|
||||
{ name: 'bad', content: 'not-a-function', timing: 'mounted', params: [] } as any,
|
||||
{ name: 'onMounted', content, timing: 'mounted', params: [] } as any,
|
||||
],
|
||||
} as any,
|
||||
});
|
||||
|
||||
await dsm.mounted(ds);
|
||||
|
||||
expect(ds.isMounted).toBe(true);
|
||||
expect(content).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('mounted 抛错时不会产生未处理的异常', async () => {
|
||||
const mountedSpy = vi.spyOn(DataSource.prototype, 'mounted').mockRejectedValue(new Error('mounted-boom'));
|
||||
|
||||
try {
|
||||
const app = createApp('app_mounted_err', [{ type: 'base', id: 'ds_me', fields: [], methods: [], events: [] }]);
|
||||
const dsm = new DataSourceManager({ app });
|
||||
|
||||
dsm.emit('mounted');
|
||||
await flush();
|
||||
|
||||
expect(dsm.isMounted).toBe(true);
|
||||
} finally {
|
||||
mountedSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('Promise.allSettled 不可用时走 Promise.all 兼容分支', async () => {
|
||||
const original = Promise.allSettled;
|
||||
(Promise as any).allSettled = undefined;
|
||||
const mountedSpy = vi.spyOn(DataSource.prototype, 'mounted').mockRejectedValueOnce(new Error('compat-boom'));
|
||||
|
||||
try {
|
||||
const app = createApp('app_mounted_compat', [
|
||||
{ type: 'base', id: 'ds_mc1', fields: [], methods: [], events: [] },
|
||||
{ type: 'base', id: 'ds_mc2', fields: [], methods: [], events: [] },
|
||||
]);
|
||||
const dsm = new DataSourceManager({ app });
|
||||
|
||||
dsm.emit('mounted');
|
||||
await flush();
|
||||
|
||||
expect(mountedSpy).toHaveBeenCalledTimes(2);
|
||||
expect(dsm.get('ds_mc2')?.isMounted).toBe(true);
|
||||
} finally {
|
||||
(Promise as any).allSettled = original;
|
||||
mountedSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('页面渲染后注册的数据源类型会在 init 之后补充执行 mounted', async () => {
|
||||
const app = createApp('app_mounted_register', [
|
||||
{ type: 'mounted-late', id: 'ds_late', fields: [], methods: [], events: [] },
|
||||
]);
|
||||
const dsm = new DataSourceManager({ app });
|
||||
|
||||
expect(dsm.get('ds_late')).toBeUndefined();
|
||||
|
||||
dsm.emit('mounted');
|
||||
await flush();
|
||||
|
||||
class LateDataSource extends DataSource {}
|
||||
DataSourceManager.register('mounted-late', LateDataSource as any);
|
||||
await flush();
|
||||
|
||||
expect(dsm.get('ds_late')?.isInit).toBe(true);
|
||||
expect(dsm.get('ds_late')?.isMounted).toBe(true);
|
||||
});
|
||||
|
||||
test('页面渲染后 updateSchema 重建的数据源会执行 mounted', async () => {
|
||||
const app = createApp('app_mounted_update', [
|
||||
{ type: 'base', id: 'ds_up', fields: [{ name: 'a' }], methods: [], events: [] },
|
||||
]);
|
||||
const dsm = new DataSourceManager({ app });
|
||||
|
||||
dsm.emit('mounted');
|
||||
await flush();
|
||||
|
||||
dsm.updateSchema([{ type: 'base', id: 'ds_up', fields: [{ name: 'b' }], methods: [], events: [] }]);
|
||||
await flush();
|
||||
|
||||
expect(dsm.get('ds_up')?.isMounted).toBe(true);
|
||||
});
|
||||
|
||||
test('页面未渲染时 updateSchema 不会执行 mounted', async () => {
|
||||
const app = createApp('app_update_not_mounted', [
|
||||
{ type: 'base', id: 'ds_nm', fields: [{ name: 'a' }], methods: [], events: [] },
|
||||
]);
|
||||
const dsm = new DataSourceManager({ app });
|
||||
|
||||
dsm.updateSchema([{ type: 'base', id: 'ds_nm', fields: [{ name: 'b' }], methods: [], events: [] }]);
|
||||
await flush();
|
||||
|
||||
expect(dsm.get('ds_nm')?.isInit).toBe(true);
|
||||
expect(dsm.get('ds_nm')?.isMounted).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DataSourceManager - addDataSource 边界', () => {
|
||||
afterEach(() => {
|
||||
DataSourceManager.clearDataSourceClass();
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.8.0-beta.27",
|
||||
"version": "1.8.0-beta.10",
|
||||
"name": "@tmagic/dep",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { DepData } from '@tmagic/schema';
|
||||
|
||||
import { DepTargetType, type IsTarget, type TargetDescriptor, type TargetOptions } from './types';
|
||||
import { DepTargetType, type IsTarget, type TargetOptions } from './types';
|
||||
|
||||
export interface DepUpdateOptions {
|
||||
id: string | number;
|
||||
@ -40,17 +40,12 @@ export default class Target {
|
||||
* 是否默认收集,默认为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 { CodeBlockContent, DataSourceSchema, DepData, Id } from '@tmagic/schema';
|
||||
import type { DepData } from '@tmagic/schema';
|
||||
|
||||
import type Target from './Target';
|
||||
|
||||
@ -17,30 +17,6 @@ export enum DepTargetType {
|
||||
|
||||
export type IsTarget = (key: string | number, value: any, data?: Record<string, any>) => boolean;
|
||||
|
||||
/** 创建代码块 target 只需要名称,Record 用于兼容直接传入完整代码块 */
|
||||
export type CodeBlockName = Pick<CodeBlockContent, 'name'> & Record<string, any>;
|
||||
|
||||
/**
|
||||
* target 的可序列化描述
|
||||
*
|
||||
* isTarget 是带闭包的函数,无法跨线程传递,只能在另一端用同样的入参重新创建。
|
||||
* 内置的代码块/数据源 target 由工厂函数写入该描述,因此可以放到 worker 中收集;
|
||||
* 业务自定义的 target 没有该描述,只能在主线程收集。
|
||||
*
|
||||
* 各类型只带 isTarget 真正用到的字段:
|
||||
* - CODE_BLOCK: id + name
|
||||
* - DATA_SOURCE / DATA_SOURCE_COND: id + 完整 fields(含 type / 嵌套,用于判断数组模板)
|
||||
* - DATA_SOURCE_METHOD: id + 方法名/字段名(不序列化 content)
|
||||
*/
|
||||
export type TargetDescriptor =
|
||||
| { type: DepTargetType.CODE_BLOCK; id: Id; codeBlock: CodeBlockName }
|
||||
| { type: DepTargetType.DATA_SOURCE; ds: Pick<DataSourceSchema, 'id' | 'fields'> }
|
||||
| { type: DepTargetType.DATA_SOURCE_COND; ds: Pick<DataSourceSchema, 'id' | 'fields'> }
|
||||
| {
|
||||
type: DepTargetType.DATA_SOURCE_METHOD;
|
||||
ds: { id: Id; methods: Array<{ name?: string }>; fields: Array<{ name?: string }> };
|
||||
};
|
||||
|
||||
export interface TargetOptions {
|
||||
isTarget: IsTarget;
|
||||
id: string | number;
|
||||
@ -50,8 +26,6 @@ export interface TargetOptions {
|
||||
initialDeps?: DepData;
|
||||
/** 是否默认收集,默认为true,当值为false时需要传入type参数给collect方法才会被收集 */
|
||||
isCollectByDefault?: boolean;
|
||||
/** 可序列化描述,用于在 worker 中重建该 target */
|
||||
descriptor?: TargetDescriptor;
|
||||
}
|
||||
|
||||
export interface TargetList {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import {
|
||||
type CodeBlockContent,
|
||||
type DataSchema,
|
||||
type DataSourceSchema,
|
||||
type DepData,
|
||||
@ -15,17 +16,16 @@ import {
|
||||
} from '@tmagic/utils';
|
||||
|
||||
import Target from './Target';
|
||||
import { type CodeBlockName, DepTargetType, type TargetDescriptor, type TargetList } from './types';
|
||||
import { DepTargetType, type TargetList } from './types';
|
||||
|
||||
const INTEGER_REGEXP = /^\d+$/;
|
||||
|
||||
export const createCodeBlockTarget = (id: Id, codeBlock: CodeBlockName, initialDeps: DepData = {}) =>
|
||||
export const createCodeBlockTarget = (id: Id, codeBlock: CodeBlockContent, initialDeps: DepData = {}) =>
|
||||
new Target({
|
||||
type: DepTargetType.CODE_BLOCK,
|
||||
id,
|
||||
initialDeps,
|
||||
name: codeBlock.name,
|
||||
descriptor: { type: DepTargetType.CODE_BLOCK, id, codeBlock: { name: codeBlock.name } },
|
||||
isTarget: (_key: string | number, value: any) => {
|
||||
if (id === value) {
|
||||
return true;
|
||||
@ -238,8 +238,6 @@ export const createDataSourceTarget = (ds: Pick<DataSourceSchema, 'id' | 'fields
|
||||
type: DepTargetType.DATA_SOURCE,
|
||||
id: ds.id,
|
||||
initialDeps,
|
||||
// isTarget 需要完整 fields 结构(含 type / 嵌套 fields),但不必带上 methods 等无关字段
|
||||
descriptor: { type: DepTargetType.DATA_SOURCE, ds: { id: ds.id, fields: ds.fields || [] } },
|
||||
isTarget: (key: string | number, value: any) => isDataSourceTarget(ds, key, value),
|
||||
});
|
||||
|
||||
@ -248,8 +246,6 @@ export const createDataSourceCondTarget = (ds: Pick<DataSourceSchema, 'id' | 'fi
|
||||
type: DepTargetType.DATA_SOURCE_COND,
|
||||
id: ds.id,
|
||||
initialDeps,
|
||||
// 与 DATA_SOURCE 相同,只序列化 isTarget 所需的 id + fields
|
||||
descriptor: { type: DepTargetType.DATA_SOURCE_COND, ds: { id: ds.id, fields: ds.fields || [] } },
|
||||
isTarget: (key: string | number, value: any) => isDataSourceCondTarget(ds, key, value),
|
||||
});
|
||||
|
||||
@ -261,15 +257,6 @@ export const createDataSourceMethodTarget = (
|
||||
type: DepTargetType.DATA_SOURCE_METHOD,
|
||||
id: ds.id,
|
||||
initialDeps,
|
||||
// isTarget 只用方法名/字段名,不序列化 content 等函数,降低 idle 收集通信成本
|
||||
descriptor: {
|
||||
type: DepTargetType.DATA_SOURCE_METHOD,
|
||||
ds: {
|
||||
id: ds.id,
|
||||
methods: (ds.methods || []).map((method) => ({ name: method.name })),
|
||||
fields: (ds.fields || []).map((field) => ({ name: field.name })),
|
||||
},
|
||||
},
|
||||
isTarget: (_key: string | number, value: any) => {
|
||||
// 使用data-source-method-select 可以配置出来
|
||||
if (!Array.isArray(value)) {
|
||||
@ -295,31 +282,6 @@ export const createDataSourceMethodTarget = (
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 用可序列化描述重建 target,使依赖收集可以在 worker 等无法传递函数的环境中进行
|
||||
* @param descriptor 由工厂函数写入 target 的描述
|
||||
* @param initialDeps 初始依赖
|
||||
* @returns Target
|
||||
*/
|
||||
export const createTargetByDescriptor = (descriptor: TargetDescriptor, initialDeps: DepData = {}): Target => {
|
||||
switch (descriptor.type) {
|
||||
case DepTargetType.CODE_BLOCK:
|
||||
return createCodeBlockTarget(descriptor.id, descriptor.codeBlock, initialDeps);
|
||||
case DepTargetType.DATA_SOURCE:
|
||||
return createDataSourceTarget(descriptor.ds, initialDeps);
|
||||
case DepTargetType.DATA_SOURCE_COND:
|
||||
return createDataSourceCondTarget(descriptor.ds, initialDeps);
|
||||
case DepTargetType.DATA_SOURCE_METHOD:
|
||||
// descriptor 只保留名称,对 isTarget 已足够;重建时按同样结构传入
|
||||
return createDataSourceMethodTarget(
|
||||
descriptor.ds as Pick<DataSourceSchema, 'id' | 'methods' | 'fields'>,
|
||||
initialDeps,
|
||||
);
|
||||
default:
|
||||
throw new Error(`unknown target descriptor: ${JSON.stringify(descriptor)}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const traverseTarget = (
|
||||
targetsList: TargetList,
|
||||
cb: (target: Target) => void,
|
||||
|
||||
@ -266,65 +266,6 @@ describe('utils', () => {
|
||||
expect(t3.isTarget('k', ['ds_1', 'unknown'])).toBe(true);
|
||||
});
|
||||
|
||||
test('内置 target 带可序列化描述,可在 worker 中重建', () => {
|
||||
const ds = { id: 'ds_1', fields: [{ name: 'name' }] as DataSchema[] };
|
||||
|
||||
expect(utils.createCodeBlockTarget('code_1', { name: 'fn', content: () => false, params: [] }).descriptor).toEqual({
|
||||
type: DepTargetType.CODE_BLOCK,
|
||||
id: 'code_1',
|
||||
codeBlock: { name: 'fn' },
|
||||
});
|
||||
const dsWithMethods = {
|
||||
...ds,
|
||||
methods: [{ name: 'load', content: () => undefined, params: [] } as any],
|
||||
};
|
||||
// DATA_SOURCE / COND 只带 id + fields,不透传完整数据源对象
|
||||
expect(utils.createDataSourceTarget(dsWithMethods).descriptor).toEqual({
|
||||
type: DepTargetType.DATA_SOURCE,
|
||||
ds: { id: 'ds_1', fields: ds.fields },
|
||||
});
|
||||
expect(utils.createDataSourceCondTarget(dsWithMethods).descriptor).toEqual({
|
||||
type: DepTargetType.DATA_SOURCE_COND,
|
||||
ds: { id: 'ds_1', fields: ds.fields },
|
||||
});
|
||||
// METHOD 描述只保留名称,不带 content 等函数
|
||||
expect(utils.createDataSourceMethodTarget(dsWithMethods).descriptor).toEqual({
|
||||
type: DepTargetType.DATA_SOURCE_METHOD,
|
||||
ds: { id: 'ds_1', methods: [{ name: 'load' }], fields: [{ name: 'name' }] },
|
||||
});
|
||||
// 自定义 target 无法序列化 isTarget,因此没有描述
|
||||
expect(new Target({ id: 'custom', isTarget: () => true }).descriptor).toBeUndefined();
|
||||
});
|
||||
|
||||
test('createTargetByDescriptor 用描述重建出等价的 target', () => {
|
||||
const ds = { id: 'ds_1', fields: [{ name: 'name' }] as DataSchema[] };
|
||||
|
||||
const codeBlockTarget = utils.createTargetByDescriptor({
|
||||
type: DepTargetType.CODE_BLOCK,
|
||||
id: 'code_1',
|
||||
codeBlock: { name: 'fn' },
|
||||
});
|
||||
expect(codeBlockTarget.type).toBe(DepTargetType.CODE_BLOCK);
|
||||
expect(codeBlockTarget.name).toBe('fn');
|
||||
expect(codeBlockTarget.isTarget('created', 'code_1')).toBe(true);
|
||||
|
||||
const dsTarget = utils.createTargetByDescriptor({ type: DepTargetType.DATA_SOURCE, ds });
|
||||
expect(dsTarget.type).toBe(DepTargetType.DATA_SOURCE);
|
||||
expect(dsTarget.isTarget('text', '${ds_1.name}')).toBe(true);
|
||||
|
||||
const condTarget = utils.createTargetByDescriptor({ type: DepTargetType.DATA_SOURCE_COND, ds });
|
||||
expect(condTarget.isTarget(`${NODE_CONDS_KEY}_x`, ['ds_1', 'name'])).toBe(true);
|
||||
|
||||
const methodTarget = utils.createTargetByDescriptor(
|
||||
{ type: DepTargetType.DATA_SOURCE_METHOD, ds: { ...ds, methods: [] } },
|
||||
{ n1: { name: 'n1', keys: ['k'] } },
|
||||
);
|
||||
expect(methodTarget.isTarget('k', ['ds_1', 'load'])).toBe(true);
|
||||
expect(methodTarget.deps.n1.keys).toEqual(['k']);
|
||||
|
||||
expect(() => utils.createTargetByDescriptor({ type: 'unknown' } as any)).toThrow();
|
||||
});
|
||||
|
||||
test('traverseTarget 遍历所有 / 指定 type', () => {
|
||||
const t1 = new Target({ id: '1', isTarget: () => true, type: 'a' });
|
||||
const t2 = new Target({ id: '2', isTarget: () => true, type: 'b' });
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.8.0-beta.27",
|
||||
"version": "1.8.0-beta.10",
|
||||
"name": "@tmagic/design",
|
||||
"type": "module",
|
||||
"sideEffects": [
|
||||
@ -16,11 +16,6 @@
|
||||
"import": "./dist/es/index.js",
|
||||
"require": "./dist/tmagic-design.umd.cjs"
|
||||
},
|
||||
"./headless": {
|
||||
"types": "./types/headless.d.ts",
|
||||
"import": "./dist/es/headless.js",
|
||||
"require": "./dist/tmagic-design-headless.umd.cjs"
|
||||
},
|
||||
"./*": "./*"
|
||||
},
|
||||
"files": [
|
||||
|
||||
@ -1,23 +0,0 @@
|
||||
<template>
|
||||
<component class="tmagic-design-alert" :is="uiComponent" v-bind="uiProps">
|
||||
<slot></slot>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { getDesignConfig } from './config';
|
||||
import type { AlertProps } from './types';
|
||||
|
||||
defineOptions({
|
||||
name: 'TMAlert',
|
||||
});
|
||||
|
||||
const props = defineProps<AlertProps>();
|
||||
|
||||
const ui = getDesignConfig('components')?.alert;
|
||||
const uiComponent = ui?.component || 'el-alert';
|
||||
|
||||
const uiProps = computed<AlertProps>(() => ui?.props(props) || props);
|
||||
</script>
|
||||
@ -1,9 +1,5 @@
|
||||
<template>
|
||||
<component
|
||||
:is="uiComponent"
|
||||
:class="['tmagic-design-card', { 'tmagic-design-card--flat': isFlat, 'm-flat-hide-body': isHideBody }]"
|
||||
v-bind="uiProps"
|
||||
>
|
||||
<component class="tmagic-design-card" :is="uiComponent" v-bind="uiProps">
|
||||
<template #header v-if="$slots.header">
|
||||
<slot name="header" class="header"></slot>
|
||||
</template>
|
||||
@ -18,7 +14,6 @@
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { getDesignConfig } from './config';
|
||||
import { isGlobalFlat } from './index';
|
||||
import type { CardProps } from './types';
|
||||
|
||||
defineOptions({
|
||||
@ -31,14 +26,5 @@ const ui = getDesignConfig('components')?.card;
|
||||
|
||||
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>(() => {
|
||||
const { flat: _flat, ...rest } = props;
|
||||
return ui?.props(rest as CardProps) || (rest as CardProps);
|
||||
});
|
||||
const uiProps = computed<CardProps>(() => ui?.props(props) || props);
|
||||
</script>
|
||||
|
||||
@ -7,39 +7,18 @@
|
||||
@update:modelValue="updateModelValue"
|
||||
>
|
||||
</component>
|
||||
<MInput
|
||||
v-if="isFlat && !isLargeStageContainer"
|
||||
@update:modelValue="updateModelValue"
|
||||
@change="changeHandler"
|
||||
v-bind="uiProps"
|
||||
class="tmagic-design-color-picker-input"
|
||||
clearable
|
||||
></MInput>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, type ComputedRef, inject } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { getDesignConfig } from './config';
|
||||
import { isGlobalFlat } from './index';
|
||||
import MInput from './Input.vue';
|
||||
import type { ColorPickerProps } from './types';
|
||||
|
||||
defineOptions({
|
||||
name: 'TMColorPicker',
|
||||
});
|
||||
|
||||
const isFlat = computed(() => !!props.flat || isGlobalFlat.value);
|
||||
|
||||
// 祖先 `<MEditor>` 用 computed 包着 provide,值会随 props.isLargeStageContainer 变化。
|
||||
// 类型标成 `ComputedRef<boolean>`,模板里 `<script setup>` 自动解包,仍可直接写
|
||||
// `!isLargeStageContainer`;默认值也得包成 computed,保持形状一致,避免祖先没提供时
|
||||
// 模板里对一个裸 `false` 和 `ComputedRef` 走两条不同的取值路径。
|
||||
const isLargeStageContainer = inject<ComputedRef<boolean>>(
|
||||
'isLargeStageContainer',
|
||||
computed(() => false),
|
||||
);
|
||||
|
||||
const props = withDefaults(defineProps<ColorPickerProps>(), {
|
||||
showAlpha: false,
|
||||
disabled: false,
|
||||
|
||||
@ -35,16 +35,6 @@ defineExpose({
|
||||
return form.value?.validate();
|
||||
},
|
||||
|
||||
clearValidate(props?: string | string[]) {
|
||||
if (typeof form.value?.clearValidate === 'function') {
|
||||
return form.value?.clearValidate(props);
|
||||
}
|
||||
// tdesign 使用 clearValidate,element-plus 也是 clearValidate;此处兜底其它可能的命名
|
||||
if (typeof form.value?.clearValidateState === 'function') {
|
||||
return form.value?.clearValidateState();
|
||||
}
|
||||
},
|
||||
|
||||
resetFields() {
|
||||
if (typeof form.value?.resetFields === 'function') {
|
||||
return form.value?.resetFields();
|
||||
|
||||
@ -1,65 +1,36 @@
|
||||
<template>
|
||||
<component
|
||||
class="tmagic-design-form-item"
|
||||
:class="{ 'has-extra-tips': adapterType === 'element-plus' && extraTips }"
|
||||
:is="uiComponent"
|
||||
v-bind="uiProps"
|
||||
>
|
||||
<component class="tmagic-design-form-item" :is="uiComponent" v-bind="uiProps">
|
||||
<template #label>
|
||||
<slot name="label"> </slot>
|
||||
<slot name="label"></slot>
|
||||
</template>
|
||||
|
||||
<template #default>
|
||||
<slot></slot>
|
||||
<alert
|
||||
v-if="adapterType === 'element-plus' && extraTips"
|
||||
:title="extraTips"
|
||||
type="warning"
|
||||
show-icon
|
||||
:closable="false"
|
||||
></alert>
|
||||
<div v-else-if="adapterType === 'element-plus' && extra" v-html="extra" class="m-form-tip"></div>
|
||||
</template>
|
||||
|
||||
<template v-if="adapterType === 'element-plus'" #error="{ error }">
|
||||
<div class="el-form-item__error">{{ resolveErrorText(error) }}</div>
|
||||
<div v-if="adapterType === 'element-plus' && extra" v-html="extra" class="m-form-tip"></div>
|
||||
</template>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, inject } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
|
||||
import Alert from './Alert.vue';
|
||||
import { getDesignConfig } from './config';
|
||||
import { stripValidateSuggestion } from './formValidateMessage';
|
||||
import { isGlobalFlat } from './index';
|
||||
import type { FormItemProps } from './types';
|
||||
|
||||
defineOptions({
|
||||
name: 'TMFormItem',
|
||||
});
|
||||
|
||||
const props = defineProps<FormItemProps & { theme?: string }>();
|
||||
const props = defineProps<FormItemProps>();
|
||||
|
||||
const ui = getDesignConfig('components')?.formItem;
|
||||
|
||||
const uiComponent = ui?.component || 'el-form-item';
|
||||
|
||||
const adapterType = getDesignConfig('adapterType');
|
||||
|
||||
const formInline = inject<boolean>('formInline', false);
|
||||
const formInRow = inject<boolean>('isInRow', false);
|
||||
const uiProps = computed<FormItemProps>(() => {
|
||||
const { extra, extraTips, ...rest } = ui?.props(props) || props;
|
||||
if (isGlobalFlat.value && rest.labelPosition === undefined) {
|
||||
return { ...rest, labelPosition: formInline || formInRow ? 'right' : 'left' };
|
||||
}
|
||||
const { extra, ...rest } = ui?.props(props) || props;
|
||||
return rest;
|
||||
});
|
||||
|
||||
/**
|
||||
* 校验错误文案中,「修改建议」仅用于错误汇总展示。
|
||||
* form-item 行内错误只展示主错误描述,不展示修改建议。
|
||||
*/
|
||||
const resolveErrorText = (error?: string) => stripValidateSuggestion(error);
|
||||
</script>
|
||||
|
||||
@ -4,9 +4,10 @@
|
||||
<div
|
||||
v-if="popoverVisible || !destroyOnClose"
|
||||
v-show="popoverVisible"
|
||||
class="tmagic-design-popper"
|
||||
ref="popperElementRef"
|
||||
:tabindex="tabindex"
|
||||
:class="['tmagic-design-popper', popperClass, themeClass]"
|
||||
:class="popperClass"
|
||||
:style="style"
|
||||
@mouseenter.once="popperMouseenterHandler"
|
||||
>
|
||||
@ -21,7 +22,7 @@ import { computed, getCurrentInstance, nextTick, onBeforeUnmount, onMounted, ref
|
||||
import type { Instance } from '@popperjs/core';
|
||||
import { createPopper } from '@popperjs/core';
|
||||
|
||||
import { useThemeClass, useZIndex } from './index';
|
||||
import { useZIndex } from './index';
|
||||
import type { PopoverProps } from './types';
|
||||
|
||||
defineSlots<{
|
||||
@ -45,12 +46,6 @@ const props = withDefaults(defineProps<PopoverProps>(), {
|
||||
closeOnClickOutside: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* 主题修饰类(来自最近的 `<MEditor>` / `<MForm>` 祖先 provide)。
|
||||
* 挂在 `Teleport` 出去的 popper 根节点上,让主题级 CSS 变量(`--el-color-primary` 等)
|
||||
* 在 portal 节点上也能命中。详见 `@tmagic/design/theme.ts`。
|
||||
*/
|
||||
const themeClass = useThemeClass();
|
||||
const emit = defineEmits<{
|
||||
/** 受控模式(传入了 visible)下点击外部收起时触发,便于配合 v-model:visible。 */
|
||||
'update:visible': [_visible: boolean];
|
||||
|
||||
@ -1,11 +1,5 @@
|
||||
<template>
|
||||
<!-- ElTooltip 的 ElOnlyChild 要求默认插槽至少有一个真实节点;v-if 为 false 时插槽是 Comment,会报 no valid child node found。 -->
|
||||
<component
|
||||
v-if="isLegitTooltipTrigger($slots.default?.())"
|
||||
class="tmagic-design-tooltip"
|
||||
:is="uiComponent"
|
||||
v-bind="uiProps"
|
||||
>
|
||||
<component class="tmagic-design-tooltip" :is="uiComponent" v-bind="uiProps">
|
||||
<template #content>
|
||||
<slot name="content"></slot>
|
||||
</template>
|
||||
@ -14,7 +8,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Comment, computed, Fragment, Text, type VNode } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { getDesignConfig } from './config';
|
||||
import type { TooltipProps } from './types';
|
||||
@ -30,30 +24,4 @@ const ui = getDesignConfig('components')?.tooltip;
|
||||
const uiComponent = ui?.component || 'el-tooltip';
|
||||
|
||||
const uiProps = computed<TooltipProps>(() => ui?.props(props) || props);
|
||||
|
||||
/** 与 Element Plus `findFirstLegitChild` 对齐:Comment / 空文本不算合法 trigger。 */
|
||||
const isLegitTooltipTrigger = (nodes: VNode[] | undefined): boolean => {
|
||||
if (!nodes?.length) return false;
|
||||
|
||||
for (const child of nodes) {
|
||||
if (!child) continue;
|
||||
|
||||
switch (child.type) {
|
||||
case Comment:
|
||||
continue;
|
||||
case Text: {
|
||||
const text = typeof child.children === 'string' ? child.children.trim() : '';
|
||||
if (text) return true;
|
||||
continue;
|
||||
}
|
||||
case Fragment:
|
||||
if (isLegitTooltipTrigger(child.children as VNode[])) return true;
|
||||
continue;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
</script>
|
||||
|
||||
@ -1,47 +0,0 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent. All rights reserved.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 校验错误文案中,「主错误描述」与「修改建议」之间的分隔符。
|
||||
*
|
||||
* 约定:typeMatch 等校验器产出的文案形如
|
||||
* `主错误描述${VALIDATE_SUGGESTION_SEPARATOR}修改建议`。
|
||||
* - 行内 form-item 错误、组件树 tooltip 仅展示「主错误描述」;
|
||||
* - 错误汇总(如属性面板报错弹窗)展示完整文案(含修改建议)。
|
||||
*
|
||||
* 该常量为「主错误描述 / 修改建议」这一隐式协议的唯一真源,
|
||||
* 所有生产/消费该文案的位置都应复用这里的常量与 helper,避免魔法字符串散落。
|
||||
*/
|
||||
export const VALIDATE_SUGGESTION_SEPARATOR = '\n\n';
|
||||
|
||||
/**
|
||||
* 在「主错误描述」后追加「修改建议」;无建议时原样返回主错误描述。
|
||||
*
|
||||
* @param message 主错误描述
|
||||
* @param suggestion 可选的修改建议(示例值等)
|
||||
*/
|
||||
export const appendValidateSuggestion = (message: string, suggestion?: string): string =>
|
||||
suggestion ? `${message}${VALIDATE_SUGGESTION_SEPARATOR}${suggestion}` : message;
|
||||
|
||||
/**
|
||||
* 去掉校验文案中的「修改建议」部分,仅保留「主错误描述」。
|
||||
*
|
||||
* @param text 完整校验文案(可能形如 `主错误描述\n\n修改建议`)
|
||||
*/
|
||||
export const stripValidateSuggestion = (text?: string): string =>
|
||||
String(text ?? '').split(VALIDATE_SUGGESTION_SEPARATOR)[0];
|
||||
@ -1,35 +0,0 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent. All rights reserved.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview `@tmagic/design/headless`:不含组件与样式的那部分 design 能力。
|
||||
*
|
||||
* 供 `@tmagic/form/headless` 这类无渲染链路引用,避免为了读一个全局配置
|
||||
* 把整个组件库拉进包里。ES 产物保留模块结构,这里与 `@tmagic/design`
|
||||
* 引用的是同一个 `config` 模块,`setDesignConfig` 的写入两侧都能读到。
|
||||
*
|
||||
* @module @tmagic/design/headless
|
||||
*/
|
||||
|
||||
export { getDesignConfig, setDesignConfig } from './config';
|
||||
export {
|
||||
appendValidateSuggestion,
|
||||
stripValidateSuggestion,
|
||||
VALIDATE_SUGGESTION_SEPARATOR,
|
||||
} from './formValidateMessage';
|
||||
export type { DesignPluginOptions } from './types';
|
||||
@ -8,8 +8,6 @@ import './theme/index.scss';
|
||||
|
||||
export * from './types';
|
||||
export * from './config';
|
||||
export * from './theme';
|
||||
export * from './formValidateMessage';
|
||||
|
||||
export { default as TMagicAutocomplete } from './Autocomplete.vue';
|
||||
export { default as TMagicBadge } from './Badge.vue';
|
||||
@ -95,10 +93,6 @@ export let useZIndex = (zIndexOverrides?: Ref<number>) => {
|
||||
};
|
||||
};
|
||||
|
||||
// 与 form/utils/config、editor/utils/config 里的同名变量保持一致:用 ref 持有 flat 全局开关,
|
||||
// const 引用本身不可变,规避 `no-mutable-exports` / `naming-convention` 对 `let` 模块变量的限制。
|
||||
export const isGlobalFlat = ref(false);
|
||||
|
||||
export default {
|
||||
install(app: App, options: DesignPluginOptions) {
|
||||
tMagicMessage =
|
||||
@ -134,8 +128,6 @@ export default {
|
||||
useZIndex = options.useZIndex;
|
||||
}
|
||||
|
||||
isGlobalFlat.value = options.flat ?? false;
|
||||
|
||||
if (options.adapterType && globalThis.document?.documentElement) {
|
||||
globalThis.document.documentElement.classList.add(`tmagic-adapter-${options.adapterType}`);
|
||||
}
|
||||
|
||||
@ -1,59 +0,0 @@
|
||||
/*
|
||||
* 主题作用域穿透到 portal 节点的基建。
|
||||
*
|
||||
* `<MEditor>` / `<MForm>` 通过 provide 暴露当前的 `theme`,包含 `<Teleport>` 的组件
|
||||
* 通过 `useThemeClass()` inject 得到主题修饰类,挂到传送目标根节点上即可让主题级
|
||||
* CSS 变量(如 `--el-color-primary`)在 body 下的 portal 节点上也命中。
|
||||
*
|
||||
* 命名约定:portal 节点本身既不是 `m-editor` 也不是 `m-form`,因此使用独立的中性类
|
||||
* `m-theme--<theme>`,避免语义混淆。同时把跨组件共享的主题级 CSS 变量统一挂在该类上,
|
||||
* editor 根 / form 根 / portal 根都加上该类即可统一受用,editor / form 各自的内部
|
||||
* 样式仍保留在 `.m-editor.m-editor--<theme>` / `.m-form.m-form--<theme>` 双类选择器
|
||||
* 上,互不污染。
|
||||
*/
|
||||
|
||||
import type { ComputedRef, InjectionKey, Ref } from 'vue';
|
||||
import { computed, inject } from 'vue';
|
||||
|
||||
/** 祖先 `<MEditor>` / `<MForm>` 暴露的当前 theme(一般来自 props.theme,经 MForm 时
|
||||
* 若自身没设 theme,会透传外层 `<MEditor>` 的值,详见 Form.vue 中的 effectiveTheme)。 */
|
||||
export const M_THEME_KEY: InjectionKey<Ref<string>> = Symbol('mTheme');
|
||||
|
||||
/**
|
||||
* 取得来自最近 `<MEditor>` / `<MForm>` 祖先 provide 的主题,拼成可直接套到 class 字符串
|
||||
* 的格式:例如 `"m-theme--magic-admin"`。
|
||||
*
|
||||
* - 不在主题作用域里时返回空串,调用方可直接拼接,无需额外判空。
|
||||
* - 与 `m-editor` / `m-form` 同名修饰类完全解耦:portal 节点只挂这一个中性主题类,
|
||||
* 不会意外命中 editor / form 内部 DOM 的样式作用域。
|
||||
*
|
||||
* 使用场景:组件自身渲染了 `<Teleport>`(如 `TMagicPopover` / `FloatingBox` /
|
||||
* `ContentMenu`),需要在传送目标根节点上挂主题类,让主题 CSS 变量能命中。
|
||||
*/
|
||||
export const useThemeClass = (): ComputedRef<string> => {
|
||||
const theme = inject(M_THEME_KEY, null);
|
||||
return computed(() => {
|
||||
const t = theme?.value;
|
||||
return t ? `m-theme--${t}` : '';
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 在组件内统一解析当前生效的 theme:以 `prop.theme` 为准,缺省时回退到祖先
|
||||
* `<MEditor>` / `<MForm>` provide 的主题。
|
||||
*
|
||||
* 入参 `prop` 是任意结构,约束只看 `theme?: string`。一般直接把 `defineProps()` 的
|
||||
* 返回值传进来即可(reactive proxy,computed 可正确跟踪外部更新);不要传
|
||||
* `toRefs` 解构后的对象。
|
||||
*
|
||||
* 若 `prop.theme` 与祖先都没有,返回空串,调用方可直接拼接。
|
||||
*
|
||||
* @example
|
||||
* const props = defineProps<{ theme?: string }>();
|
||||
* const theme = useTheme(props);
|
||||
* // theme.value -> 'magic-admin' 或 ''
|
||||
*/
|
||||
export const useTheme = <T extends { theme?: string }>(prop: T): ComputedRef<string> => {
|
||||
const injected = inject(M_THEME_KEY, null);
|
||||
return computed(() => prop.theme || injected?.value || '');
|
||||
};
|
||||
@ -1,75 +0,0 @@
|
||||
// `TMagicCard` 的「铺平」模式:去掉卡片自身的视觉表达(背景 / 阴影 / 边框 / 圆角 /
|
||||
// header 分隔线 / header & body 内边距),让卡片在视觉上与外层容器融为一体。
|
||||
//
|
||||
// 不动 body-style、不动折叠相关 prop —— 这些上层用法仍可正常工作。
|
||||
// `is-always-shadow` 是 el-card 在 `shadow="always"` 时附加的类,需要单独再压一次。
|
||||
.tmagic-design-card.tmagic-design-card--flat {
|
||||
background: #fff;
|
||||
box-shadow: none !important;
|
||||
border-top: 0;
|
||||
border-left: 0;
|
||||
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;
|
||||
}
|
||||
&.is-always-shadow {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.el-card__header {
|
||||
border-bottom: none;
|
||||
padding: 16px;
|
||||
&:hover {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.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
|
||||
.tmagic-design-form-item:not(tbody .tmagic-design-form-item) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
> .m-container-row {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
}
|
||||
.tmagic-design-card--flat {
|
||||
// .el-card__body {
|
||||
// padding: 0;
|
||||
// }
|
||||
// .el-card__header {
|
||||
// padding: 0 16px;
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
.m-split-title-card-container {
|
||||
> .tmagic-design-card.tmagic-design-card--flat {
|
||||
background-color: transparent;
|
||||
> .el-card__header {
|
||||
margin-bottom: 10px;
|
||||
padding: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
}
|
||||
> .el-card__body {
|
||||
background-color: #fff;
|
||||
padding-top: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
.tmagic-design-color-picker-input {
|
||||
margin-left: 8px;
|
||||
width: calc(var(--el-input-width, 100%) - 40px);
|
||||
}
|
||||
|
||||
.tmagic-design-color-picker {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
@ -1,3 +1 @@
|
||||
@use "./popover.scss";
|
||||
@use "./card.scss";
|
||||
@use "./colorPicker.scss";
|
||||
|
||||
@ -1,38 +0,0 @@
|
||||
@use "../../index.scss";
|
||||
|
||||
.m-form.m-form--magic-admin {
|
||||
.el-collapse-item__header {
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
padding-left: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.tmagic-design-form-item {
|
||||
&.has-extra-tips {
|
||||
> .el-form-item__content {
|
||||
gap: 10px;
|
||||
> .tmagic-design-alert {
|
||||
border-radius: 2px;
|
||||
width: fit-content;
|
||||
padding: 0 8px;
|
||||
color: var(--el-color-primary, #0056ea);
|
||||
background-color: var(--el-color-primary-light-9, rgb(236, 245, 255));
|
||||
.el-icon.el-alert__icon {
|
||||
fill: var(--el-color-primary, #0056ea);
|
||||
color: var(--el-color-primary, #0056ea);
|
||||
}
|
||||
.el-alert__title {
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -24,17 +24,6 @@ export interface BadgeProps {
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
export interface AlertProps {
|
||||
title?: string;
|
||||
/** ElAlert 实际支持的 type;与 ButtonProps.type 不同,没有 `primary` / `danger` */
|
||||
type?: 'success' | 'warning' | 'info' | 'error' | 'primary';
|
||||
description?: string;
|
||||
closable?: boolean;
|
||||
center?: boolean;
|
||||
closeText?: string;
|
||||
showIcon?: boolean;
|
||||
effect?: 'light' | 'dark';
|
||||
}
|
||||
export interface ButtonProps {
|
||||
type?: string;
|
||||
size?: FieldSize;
|
||||
@ -50,11 +39,6 @@ export interface CardProps {
|
||||
bodyStyle?: Record<string, any>;
|
||||
shadow?: string;
|
||||
header?: string;
|
||||
/**
|
||||
* 铺平模式:去除卡片自身的视觉(背景 / 阴影 / 边框 / 圆角 / header 分隔线 / header 与 body 内边距)。
|
||||
* 用于已有外层容器或者使用方希望「无卡片感」的场景,TMagicCard 的折叠等 prop 仍可正常使用。
|
||||
*/
|
||||
flat?: boolean;
|
||||
}
|
||||
|
||||
export interface CascaderProps {
|
||||
@ -113,7 +97,6 @@ export interface ColorPickerProps {
|
||||
disabled?: boolean;
|
||||
showAlpha?: boolean;
|
||||
size?: FieldSize;
|
||||
flat?: boolean;
|
||||
}
|
||||
|
||||
export interface DatePickerProps {
|
||||
@ -202,9 +185,7 @@ export interface FormItemProps {
|
||||
labelWidth?: string | number;
|
||||
rules?: any;
|
||||
extra?: string;
|
||||
extraTips?: string;
|
||||
labelPosition?: 'top' | 'left' | 'right';
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export interface InputProps {
|
||||
@ -253,9 +234,6 @@ export interface PaginationProps {
|
||||
|
||||
export interface PopconfirmProps {
|
||||
title?: string;
|
||||
width?: string | number;
|
||||
/** 浮层是否插入到 body,默认 true。设为 false 时浮层内联渲染,便于嵌套在 hover 浮层中避免父级因 mouseleave 收起。 */
|
||||
teleported?: boolean;
|
||||
placement?:
|
||||
| 'top'
|
||||
| 'left'
|
||||
@ -483,11 +461,6 @@ export interface Components {
|
||||
props: (props: BadgeProps) => BadgeProps;
|
||||
};
|
||||
|
||||
alert: {
|
||||
component: DefineComponent<AlertProps, {}, any> | string;
|
||||
props: (props: AlertProps) => AlertProps;
|
||||
};
|
||||
|
||||
autocomplete: {
|
||||
component: DefineComponent<AutocompleteProps, {}, any> | string;
|
||||
props: (props: AutocompleteProps) => AutocompleteProps;
|
||||
@ -750,7 +723,6 @@ export interface Components {
|
||||
}
|
||||
|
||||
export interface DesignPluginOptions {
|
||||
flat?: boolean;
|
||||
adapterType?: string;
|
||||
message?: TMagicMessage;
|
||||
messageBox?: TMagicMessageBox;
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.8.0-beta.27",
|
||||
"version": "1.8.0-beta.10",
|
||||
"name": "@tmagic/editor",
|
||||
"type": "module",
|
||||
"sideEffects": [
|
||||
@ -17,11 +17,6 @@
|
||||
"import": "./dist/es/index.js",
|
||||
"require": "./dist/tmagic-editor.umd.cjs"
|
||||
},
|
||||
"./headless": {
|
||||
"types": "./types/headless.d.ts",
|
||||
"import": "./dist/es/headless.js",
|
||||
"require": "./dist/tmagic-editor-headless.umd.cjs"
|
||||
},
|
||||
"./dist/style.css": {
|
||||
"import": "./dist/style.css",
|
||||
"require": "./dist/style.css"
|
||||
@ -64,17 +59,17 @@
|
||||
"events": "^3.3.0",
|
||||
"gesto": "^1.19.4",
|
||||
"keycon": "^1.4.0",
|
||||
"lodash-es": "^4.18.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"moveable": "^0.53.0",
|
||||
"serialize-javascript": "^7.0.7",
|
||||
"sortablejs": "^1.15.7"
|
||||
"serialize-javascript": "^7.0.0",
|
||||
"sortablejs": "^1.15.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/events": "^3.0.3",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/lodash-es": "^4.17.4",
|
||||
"@types/serialize-javascript": "^5.0.4",
|
||||
"@types/sortablejs": "^1.15.9",
|
||||
"@vue/test-utils": "^2.4.11"
|
||||
"@vue/test-utils": "^2.4.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tmagic/core": "workspace:*",
|
||||
|
||||
@ -4,7 +4,6 @@
|
||||
:page-bar-sort-options="pageBarSortOptions"
|
||||
:page-filter-function="pageFilterFunction"
|
||||
:hide-sidebar="hideSidebar"
|
||||
:theme="theme"
|
||||
>
|
||||
<template #header>
|
||||
<slot name="header"></slot>
|
||||
@ -101,6 +100,7 @@
|
||||
<template #props-panel>
|
||||
<slot name="props-panel">
|
||||
<PropsPanel
|
||||
:extend-state="extendFormState"
|
||||
:disabled-show-src="disabledShowSrc"
|
||||
@mounted="propsPanelMountedHandler"
|
||||
@unmounted="propsPanelUnmountedHandler"
|
||||
@ -135,13 +135,10 @@
|
||||
<script lang="ts" setup>
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
import { computed, provide } from 'vue';
|
||||
import { provide, ref } from 'vue';
|
||||
|
||||
import type { MApp } from '@tmagic/core';
|
||||
import { M_THEME_KEY } from '@tmagic/design';
|
||||
import { FORM_CONTEXT_KEY } from '@tmagic/form';
|
||||
|
||||
import { useEditorFormContext } from './hooks/use-form-context';
|
||||
import Framework from './layouts/Framework.vue';
|
||||
import TMagicNavMenu from './layouts/NavMenu.vue';
|
||||
import FormPanel from './layouts/props-panel/FormPanel.vue';
|
||||
@ -161,7 +158,7 @@ import stageOverlayService from './services/stageOverlay';
|
||||
import storageService from './services/storage';
|
||||
import uiService from './services/ui';
|
||||
import keybindingConfig from './utils/keybinding-config';
|
||||
import { defaultEditorProps, EditorProps, ENABLE_PROPS_FORM_VALIDATE } from './editorProps';
|
||||
import { defaultEditorProps, EditorProps } from './editorProps';
|
||||
import { initServiceEvents, initServiceState } from './initService';
|
||||
import type { TreeNodeData } from './type';
|
||||
import type { EditorSlots, EventBus, Services, StageOptions } from './type';
|
||||
@ -219,7 +216,6 @@ const stageOptions: StageOptions = {
|
||||
containerHighlightClassName: props.containerHighlightClassName,
|
||||
containerHighlightDuration: props.containerHighlightDuration,
|
||||
containerHighlightType: props.containerHighlightType,
|
||||
containerHighlightAddOnly: props.containerHighlightAddOnly,
|
||||
disabledDragStart: props.disabledDragStart,
|
||||
renderType: props.renderType,
|
||||
guidesOptions: props.guidesOptions,
|
||||
@ -231,32 +227,24 @@ const stageOptions: StageOptions = {
|
||||
|
||||
stageOverlayService.set('stageOptions', stageOptions);
|
||||
|
||||
const propsPanelRef = ref<InstanceType<typeof FormPanel> | null>(null);
|
||||
|
||||
provide('services', services);
|
||||
|
||||
provide('codeOptions', props.codeOptions);
|
||||
provide('stageOptions', stageOptions);
|
||||
/** 是否启用「属性配置表单校验」联动能力,供 PropsPanel / FormPanel 判断校验失败时是否仍更新节点并记录错误 */
|
||||
provide(ENABLE_PROPS_FORM_VALIDATE, props.enablePropsFormValidate ?? false);
|
||||
/**
|
||||
* 编辑器注入给整棵表单树的业务上下文(`services` / `stage`),属性面板、对比表单、
|
||||
* 侧边栏里的嵌套 MForm 都通过 inject 自动继承。
|
||||
*
|
||||
* 业务方要追加自己的字段,在 `<MEditor>` 外层再 `provide(FORM_CONTEXT_KEY, computed(...))`
|
||||
* 即可,配置回调统一通过 `mForm.xxx` 读取。
|
||||
* 把顶层 `extendFormState` 提供给非 PropsPanel 链路上的组件使用(例如历史差异对话框 HistoryDiffDialog
|
||||
* 内部的 CompareForm)。这样所有依赖业务上下文的表单 filterFunction 都能拿到一致的扩展状态,
|
||||
* 与 PropsPanel 通过 `:extend-state` 显式传入的方式保持等价。
|
||||
*/
|
||||
provide(
|
||||
FORM_CONTEXT_KEY,
|
||||
useEditorFormContext(() => services),
|
||||
);
|
||||
provide('extendFormState', props.extendFormState);
|
||||
/**
|
||||
* 提供 PropsPanel 主属性表单的 formState getter,供历史差异弹窗复用,
|
||||
* 让 CompareForm 与 PropsPanel 的 filterFunction 上下文保持一致。
|
||||
*/
|
||||
provide('getPropsPanelFormState', () => propsPanelRef.value?.configForm?.formState);
|
||||
|
||||
// 用 computed 包一层再 provide,否则传下去的是 provide 那一刻的值快照,
|
||||
// props.isLargeStageContainer 后续变化不会同步到子孙(如 @tmagic/design/ColorPicker)。
|
||||
// 子孙侧 `inject<ComputedRef<boolean>>('isLargeStageContainer', computed(() => false))`
|
||||
// 用 `.value` 或模板自动解包读取即可拿到最新值。
|
||||
provide(
|
||||
'isLargeStageContainer',
|
||||
computed(() => props.isLargeStageContainer),
|
||||
);
|
||||
/**
|
||||
* 把历史记录面板的自定义扩展 tab 提供给深层的 HistoryListPanel(它挂在 NavMenu 中,
|
||||
* 以 markRaw component 形式渲染,无法直接通过 props 透传)。业务方可借此在历史记录
|
||||
@ -266,20 +254,12 @@ provide('historyListExtraTabs', props.historyListExtraTabs);
|
||||
|
||||
provide<EventBus>('eventBus', new EventEmitter());
|
||||
|
||||
/**
|
||||
* 把当前主题以响应式 ref 形式 provide 给后代,供包含 `Teleport` 的组件
|
||||
* (如 `TMagicPopover` / `FloatingBox` / `ContentMenu`)在传送目标上挂 `m-theme--<theme>`
|
||||
* 类,让主题级 CSS 变量在 portal 节点上也能命中。详见 `@tmagic/design/theme.ts`。
|
||||
*/
|
||||
provide(
|
||||
M_THEME_KEY,
|
||||
computed(() => props.theme ?? ''),
|
||||
);
|
||||
|
||||
const propsPanelMountedHandler = (e: InstanceType<typeof FormPanel>) => {
|
||||
propsPanelRef.value = e;
|
||||
emit('props-panel-mounted', e);
|
||||
};
|
||||
const propsPanelUnmountedHandler = () => {
|
||||
propsPanelRef.value = null;
|
||||
emit('props-panel-unmounted');
|
||||
};
|
||||
|
||||
|
||||
@ -4,9 +4,7 @@
|
||||
v-model:visible="boxVisible"
|
||||
v-model:width="width"
|
||||
v-model:height="codeBlockEditorHeight"
|
||||
:body-style="{ padding: '0 16px' }"
|
||||
:title="content.name ? `${disabled ? '查看' : '编辑'}${content.name}` : '新增代码'"
|
||||
:framework-width="frameworkWidth"
|
||||
:position="boxPosition"
|
||||
:before-close="beforeClose"
|
||||
>
|
||||
@ -208,7 +206,6 @@ const closedHandler = () => {
|
||||
|
||||
const parentFloating = inject<Ref<HTMLDivElement | null>>('parentFloating', ref(null));
|
||||
const { boxPosition, calcBoxPosition } = useNextFloatBoxPosition(uiService, parentFloating);
|
||||
const frameworkWidth = computed(() => uiService.get('frameworkRect')?.width || 0);
|
||||
|
||||
watch(boxVisible, (visible) => {
|
||||
nextTick(() => {
|
||||
|
||||
@ -15,13 +15,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, useTemplateRef } from 'vue';
|
||||
|
||||
import {
|
||||
type ContainerChangeEventData,
|
||||
defineFormItem,
|
||||
type FormItemConfig,
|
||||
type FormValue,
|
||||
MForm,
|
||||
} from '@tmagic/form';
|
||||
import { type ContainerChangeEventData, type FormItemConfig, type FormValue, MForm } from '@tmagic/form';
|
||||
|
||||
import type { CodeParamStatement } from '@editor/type';
|
||||
import { error } from '@editor/utils';
|
||||
@ -47,13 +41,13 @@ const emit = defineEmits(['change']);
|
||||
const formRef = useTemplateRef<InstanceType<typeof MForm>>('form');
|
||||
|
||||
const getFormConfig = (items: FormItemConfig[] = []) => [
|
||||
defineFormItem({
|
||||
{
|
||||
type: 'fieldset',
|
||||
items,
|
||||
legend: '参数',
|
||||
labelPosition: 'top',
|
||||
labelWidth: '120px',
|
||||
name: props.name,
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
const codeParamsConfig = computed(() =>
|
||||
|
||||
@ -10,45 +10,109 @@
|
||||
:is-compare="true"
|
||||
:disabled="true"
|
||||
:label-width="labelWidth"
|
||||
:context="formContext"
|
||||
:extend-state="mergedExtendState"
|
||||
:show-diff="showDiff"
|
||||
:self-diff-field-types="selfDiffFieldTypes"
|
||||
:size="size"
|
||||
></MForm>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, type Ref, type ShallowRef } from 'vue';
|
||||
import { computed, inject, provide, type Ref, ref, type ShallowRef, useTemplateRef, watch, watchEffect } from 'vue';
|
||||
import { isEqual } from 'lodash-es';
|
||||
|
||||
import { type CodeBlockContent, HookType } from '@tmagic/core';
|
||||
import { type FormConfig, type FormValue, MForm } from '@tmagic/form';
|
||||
import { type CodeBlockContent, type DataSourceSchema, HookType, type MNode } from '@tmagic/core';
|
||||
import { type FormConfig, type FormState, type FormValue, MForm } from '@tmagic/form';
|
||||
|
||||
import { useCompareForm } from '@editor/hooks/use-compare-form';
|
||||
import type { CompareFormBaseProps } from '@editor/type';
|
||||
import type { CompareCategory, CompareFormLoadConfig, Services } from '@editor/type';
|
||||
import { getCodeBlockFormConfig } from '@editor/utils/code-block';
|
||||
|
||||
defineOptions({
|
||||
name: 'MEditorCompareForm',
|
||||
});
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<
|
||||
CompareFormBaseProps & {
|
||||
/** 用于对比的旧值(修改前的值) */
|
||||
lastValue?: CompareFormBaseProps['value'];
|
||||
/** 需要走 self diff 的字段类型(例如 mod-cond)。 */
|
||||
selfDiffFieldTypes?: string[];
|
||||
}
|
||||
>(),
|
||||
defineProps<{
|
||||
/** 当前值(修改后的值) */
|
||||
value: Partial<MNode> | Partial<DataSourceSchema> | Partial<CodeBlockContent> | Record<string, any>;
|
||||
/** 用于对比的旧值(修改前的值) */
|
||||
lastValue?: Partial<MNode> | Partial<DataSourceSchema> | Partial<CodeBlockContent> | Record<string, any>;
|
||||
/**
|
||||
* 类型说明:
|
||||
* - `category` 为 `node` 时,`type` 为节点组件的类型,例如 'text'、'button'、'page'、'container' 等
|
||||
* - `category` 为 `data-source` 时,`type` 为数据源类型,例如 'base'、'http'
|
||||
* - `category` 为 `code-block` 时,`type` 可不传
|
||||
*/
|
||||
type?: string;
|
||||
/** 表单配置类别,决定从哪里取 FormConfig */
|
||||
category?: CompareCategory;
|
||||
/** 数据源代码块场景下的数据源类型(base/http),用于代码块表单中"执行时机"展示 */
|
||||
dataSourceType?: string;
|
||||
labelWidth?: string;
|
||||
/**
|
||||
* 外层容器高度。设置后表单内容超出时会在 CompareForm 内部出现滚动条,
|
||||
* 避免 dialog / 面板使用方需要自行处理滚动。可传任意 CSS 长度,例如 `60vh` / `400px` / `100%`。
|
||||
*/
|
||||
height?: string;
|
||||
/**
|
||||
* 用户自定义注入到 MForm.formState 的扩展字段,与 Editor 顶层的 `extendFormState`、
|
||||
* PropsPanel 的 `extend-state` 语义一致。表单 item 的 `display` / `disabled` 等
|
||||
* filterFunction 经常依赖这里注入的字段(如 stage、自定义业务上下文等),
|
||||
* 因此在差异对比场景下也需要透传,避免出现 `formState.xxx is undefined` 的运行时错误。
|
||||
*/
|
||||
extendState?: (_state: FormState) => Record<string, any> | Promise<Record<string, any>>;
|
||||
/**
|
||||
* 外部透传的基础 formState(通常来自 PropsPanel 主属性表单)。
|
||||
* CompareForm 会提取其中的扩展字段覆盖到自己的 formState,保证 filterFunction 上下文一致。
|
||||
*/
|
||||
baseFormState?: FormState;
|
||||
/** 需要走 self diff 的字段类型(例如 mod-cond)。 */
|
||||
selfDiffFieldTypes?: string[];
|
||||
/**
|
||||
* 自定义 FormConfig 加载逻辑。传入后将接管内置的按 `category`(node/data-source/code-block)
|
||||
* 取配置逻辑,调用方可根据业务自行返回(或异步返回)表单配置。可通过
|
||||
* `ctx.defaultLoadConfig()` 复用默认结果再做二次加工。返回的 config 直接用于对比展示。
|
||||
*/
|
||||
loadConfig?: CompareFormLoadConfig;
|
||||
/** 编辑器服务集合,由调用方传入(不再通过 inject('services') 获取)。 */
|
||||
services?: Services;
|
||||
}>(),
|
||||
{
|
||||
category: 'node',
|
||||
labelWidth: '120px',
|
||||
extendState: (state: FormState) => state,
|
||||
},
|
||||
);
|
||||
|
||||
const { config, currentValues, wrapperStyle, formContext, loadConfig, formRef, normalizeCodeBlockValue } =
|
||||
useCompareForm(props);
|
||||
provide('services', props.services);
|
||||
|
||||
const config = ref<FormConfig>([]);
|
||||
|
||||
/** vs-code 编辑器的 monaco 配置项,沿用 Editor 顶层 provide('codeOptions', ...) 的注入。 */
|
||||
const codeOptions = inject<Record<string, any>>('codeOptions', {});
|
||||
|
||||
/** 将代码块的 content 字段统一成字符串,便于在表单/对比中展示 */
|
||||
const normalizeCodeBlockValue = (
|
||||
v: Partial<CodeBlockContent> | Record<string, any> | undefined,
|
||||
): Record<string, any> => {
|
||||
if (!v) return {};
|
||||
const next: Record<string, any> = { ...v };
|
||||
if (next.content && typeof next.content !== 'string') {
|
||||
try {
|
||||
next.content = next.content.toString();
|
||||
} catch {
|
||||
next.content = '';
|
||||
}
|
||||
}
|
||||
return next;
|
||||
};
|
||||
|
||||
const currentValues = computed<FormValue>(() => {
|
||||
if (props.category === 'code-block') {
|
||||
return normalizeCodeBlockValue(props.value as Partial<CodeBlockContent>);
|
||||
}
|
||||
return (props.value || {}) as FormValue;
|
||||
});
|
||||
|
||||
const lastValuesProcessed = computed<FormValue>(() => {
|
||||
if (props.category === 'code-block') {
|
||||
@ -57,11 +121,24 @@ const lastValuesProcessed = computed<FormValue>(() => {
|
||||
return (props.lastValue || {}) as FormValue;
|
||||
});
|
||||
|
||||
/**
|
||||
* 外层包裹层的样式:当传入 `height` 时启用固定高度 + 内部滚动,
|
||||
* 这样滚动条会出现在 CompareForm 内部,避免父容器(如 Dialog)自身也产生滚动。
|
||||
*/
|
||||
const wrapperStyle = computed(() => {
|
||||
if (!props.height) return undefined;
|
||||
return {
|
||||
height: props.height,
|
||||
overflow: 'auto',
|
||||
} as Record<string, string>;
|
||||
});
|
||||
|
||||
/**
|
||||
* `code-select` 字段在历史数据中存在两种"语义为空"的形态:
|
||||
* - 字符串 `''`(旧数据 / 用户从未配置过钩子);
|
||||
* - `{ hookType: HookType.CODE, hookData: [] }`(`normalizeCodeSelectValue`
|
||||
* 写入的默认结构)。
|
||||
* - `{ hookType: HookType.CODE, hookData: [] }`(CodeSelect.vue 在挂载时
|
||||
* 写入的默认结构,参见 packages/editor/src/fields/CodeSelect.vue 中
|
||||
* `props.model[props.name] = { hookType: HookType.CODE, hookData: [] }`)。
|
||||
*
|
||||
* 直接 `isEqual` 会把两者判为不等,从而在历史对比里对每个未配置过钩子的组件
|
||||
* 都展示一份"差异",体验很糟糕。这里把它们视为相等,跳过对比。
|
||||
@ -84,6 +161,110 @@ const showDiff = ({ curValue, lastValue, config }: { curValue: any; lastValue: a
|
||||
return !isEqual(curValue, lastValue);
|
||||
};
|
||||
|
||||
const removeStyleDisplayConfig = (formConfig: FormConfig): FormConfig =>
|
||||
formConfig.map((item) => {
|
||||
if (!('type' in item)) return item;
|
||||
if (item?.type !== 'tab' || !Array.isArray(item.items)) return item;
|
||||
|
||||
return {
|
||||
...item,
|
||||
items: item.items.map((tabPane) => {
|
||||
if (tabPane?.title !== '样式' || !Array.isArray(tabPane.items)) return tabPane;
|
||||
|
||||
return {
|
||||
...tabPane,
|
||||
display: true,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const mergedExtendState = (state: FormState) => {
|
||||
return props.extendState(props.baseFormState || state);
|
||||
};
|
||||
|
||||
/**
|
||||
* 内置的默认 FormConfig 加载逻辑:按 `category` 从对应 service / 工具取配置。
|
||||
* 作为 ctx.defaultLoadConfig 透传给自定义 `loadConfig`,方便复用与二次加工。
|
||||
*/
|
||||
const defaultLoadConfig = async (): Promise<FormConfig> => {
|
||||
if (!props.services) {
|
||||
return [];
|
||||
}
|
||||
|
||||
switch (props.category) {
|
||||
case 'node': {
|
||||
if (!props.type) {
|
||||
return [];
|
||||
}
|
||||
return removeStyleDisplayConfig(
|
||||
await props.services.propsService.getPropsConfig(props.type, { node: props.value as unknown as MNode }),
|
||||
);
|
||||
}
|
||||
case 'data-source': {
|
||||
const config = props.services.dataSourceService.getFormConfig(props.type || 'base');
|
||||
// 数据源表单外层 tab 的「数据定义」项 status 为 'fields',tab-pane name 随之为 'fields'。
|
||||
// 未显式设置 active 时,Tabs 默认取 '0',与 'fields' 不匹配会导致打开弹窗时无默认激活项,
|
||||
// 这里与 DataSourceConfigPanel 保持一致,默认激活「数据定义」tab。
|
||||
return config.map((item) => ('type' in item && item.type === 'tab' ? { ...item, active: 'fields' } : item));
|
||||
}
|
||||
case 'code-block': {
|
||||
return getCodeBlockFormConfig({
|
||||
paramColConfig: props.services.codeBlockService.getParamsColConfig(),
|
||||
// 通过传入 dataSourceType 间接表达"是数据源代码块"——在对比场景下 props.dataSourceType
|
||||
// 由调用方按 step 上下文显式传入,未传则视为普通代码块,「执行时机」字段隐藏。
|
||||
isDataSource: () => Boolean(props.dataSourceType),
|
||||
dataSourceType: () => props.dataSourceType,
|
||||
codeOptions,
|
||||
// 对比模式只读,不需要校验/语法检查
|
||||
editable: false,
|
||||
});
|
||||
}
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const loadConfig = async () => {
|
||||
if (props.loadConfig) {
|
||||
config.value = await props.loadConfig({
|
||||
category: props.category,
|
||||
type: props.type,
|
||||
dataSourceType: props.dataSourceType,
|
||||
defaultLoadConfig,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
config.value = await defaultLoadConfig();
|
||||
};
|
||||
|
||||
watch(
|
||||
[() => props.category, () => props.type, () => props.dataSourceType, () => props.loadConfig],
|
||||
() => {
|
||||
loadConfig();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const formRef = useTemplateRef<InstanceType<typeof MForm>>('form');
|
||||
|
||||
/**
|
||||
* 把 services / stage 注入 MForm 的 formState,避免 propsService 注入的表单配置中
|
||||
* 形如 `display: ({ services }) => services.uiService.get(...)` 的 filterFunction
|
||||
* 在执行时拿不到 `formState.services` 而报错。
|
||||
*
|
||||
* 与 props-panel/FormPanel.vue 中的注入方式保持一致:
|
||||
* - services:整个 useServices() 返回的服务集合;
|
||||
* - stage:当前 editorService.get('stage') 的最新值。
|
||||
*/
|
||||
watchEffect(() => {
|
||||
if (formRef.value && props.services) {
|
||||
formRef.value.formState.stage = props.services.editorService.get('stage');
|
||||
formRef.value.formState.services = props.services;
|
||||
}
|
||||
});
|
||||
|
||||
defineExpose<{
|
||||
form: ShallowRef<InstanceType<typeof MForm> | null>;
|
||||
config: Ref<FormConfig>;
|
||||
|
||||
@ -2,8 +2,8 @@
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-show="visible"
|
||||
class="magic-editor-content-menu"
|
||||
ref="menu"
|
||||
:class="['magic-editor-content-menu', themeClass]"
|
||||
:style="menuStyle"
|
||||
@mouseenter="mouseenterHandler()"
|
||||
@contextmenu.prevent
|
||||
@ -39,7 +39,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, type Ref, ref, useTemplateRef } from 'vue';
|
||||
|
||||
import { useThemeClass, useZIndex } from '@tmagic/design';
|
||||
import { useZIndex } from '@tmagic/design';
|
||||
|
||||
import { MenuButton, MenuComponent } from '@editor/type';
|
||||
|
||||
@ -73,14 +73,6 @@ const menuEl = useTemplateRef<HTMLDivElement>('menu');
|
||||
const buttonRefs = useTemplateRef<InstanceType<typeof ToolButton>[]>('buttons');
|
||||
const subMenuRef = useTemplateRef<any>('subMenu');
|
||||
const visible = ref(false);
|
||||
|
||||
/**
|
||||
* 主题修饰类(来自最近的 `<MEditor>` / `<MForm>` 祖先 provide)。
|
||||
* - 顶层 `ContentMenu` 渲染在编辑器子树中,加上去是冗余但无害;
|
||||
* - 子菜单(递归 `<content-menu :is-sub-menu="true">` 被 `<teleport to="body">` 送到 body)
|
||||
* 不在编辑器子树中,必须显式挂上主题类,主题级 CSS 变量才能命中。
|
||||
*/
|
||||
const themeClass = useThemeClass();
|
||||
const subMenuData: Ref<(MenuButton | MenuComponent)[]> = ref<(MenuButton | MenuComponent)[]>([]);
|
||||
const zIndex = useZIndex();
|
||||
const curZIndex = ref<number>(0);
|
||||
@ -128,42 +120,20 @@ const outsideClickHideHandler = (e: MouseEvent) => {
|
||||
hide();
|
||||
};
|
||||
|
||||
// 根据菜单实际尺寸修正位置,避免超出可视范围
|
||||
const fixPosition = () => {
|
||||
const menu = menuEl.value;
|
||||
if (!menu || !visible.value) return;
|
||||
|
||||
const menuHeight = menu.clientHeight;
|
||||
const menuWidth = menu.clientWidth;
|
||||
|
||||
let { top, left } = menuPosition.value;
|
||||
|
||||
if (top + menuHeight > document.body.clientHeight) {
|
||||
top = Math.max(0, document.body.clientHeight - menuHeight);
|
||||
}
|
||||
|
||||
if (left + menuWidth > document.body.clientWidth) {
|
||||
left = Math.max(0, document.body.clientWidth - menuWidth);
|
||||
}
|
||||
|
||||
if (top !== menuPosition.value.top || left !== menuPosition.value.left) {
|
||||
menuPosition.value = { top, left };
|
||||
}
|
||||
};
|
||||
|
||||
const setPosition = (e: { clientY: number; clientX: number }) => {
|
||||
const menuHeight = menuEl.value?.clientHeight || 0;
|
||||
|
||||
let top = e.clientY;
|
||||
if (menuHeight + e.clientY > document.body.clientHeight) {
|
||||
top = document.body.clientHeight - menuHeight;
|
||||
}
|
||||
|
||||
menuPosition.value = {
|
||||
top: e.clientY,
|
||||
top,
|
||||
left: e.clientX,
|
||||
};
|
||||
fixPosition();
|
||||
};
|
||||
|
||||
// 菜单大小动态变化(如菜单项更新)后重新修正位置
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
fixPosition();
|
||||
});
|
||||
|
||||
const show = (e?: { clientY: number; clientX: number }) => {
|
||||
visible.value = true;
|
||||
|
||||
@ -208,18 +178,12 @@ const mouseenterHandler = () => {
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (menuEl.value) {
|
||||
resizeObserver.observe(menuEl.value);
|
||||
}
|
||||
|
||||
if (props.isSubMenu) return;
|
||||
|
||||
globalThis.addEventListener('mousedown', outsideClickHideHandler, true);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resizeObserver.disconnect();
|
||||
|
||||
if (props.isSubMenu) return;
|
||||
|
||||
globalThis.removeEventListener('mousedown', outsideClickHideHandler, true);
|
||||
|
||||
@ -1,12 +1,6 @@
|
||||
<template>
|
||||
<Teleport to="body" v-if="visible">
|
||||
<div
|
||||
ref="target"
|
||||
:class="['m-editor-float-box', themeClass]"
|
||||
v-bind="$attrs"
|
||||
:style="{ ...style, zIndex: curZIndex }"
|
||||
@mousedown="nextZIndex"
|
||||
>
|
||||
<div ref="target" class="m-editor-float-box" :style="{ ...style, zIndex: curZIndex }" @mousedown="nextZIndex">
|
||||
<div ref="title" class="m-editor-float-box-title">
|
||||
<slot name="title">
|
||||
<span>{{ title }}</span>
|
||||
@ -15,7 +9,7 @@
|
||||
<TMagicButton link size="small" @click="closeHandler"><MIcon :icon="Close"></MIcon></TMagicButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="m-editor-float-box-body" :style="{ height: `${bodyHeight}px`, ...bodyStyle }">
|
||||
<div class="m-editor-float-box-body" :style="{ height: `${bodyHeight}px` }">
|
||||
<slot name="body"></slot>
|
||||
</div>
|
||||
</div>
|
||||
@ -23,13 +17,14 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, type CSSProperties, nextTick, onBeforeUnmount, provide, ref, useTemplateRef, watch } from 'vue';
|
||||
import { computed, nextTick, onBeforeUnmount, provide, ref, useTemplateRef, watch } from 'vue';
|
||||
import { Close } from '@element-plus/icons-vue';
|
||||
import VanillaMoveable from 'moveable';
|
||||
|
||||
import { TMagicButton, useThemeClass, useZIndex } from '@tmagic/design';
|
||||
import { TMagicButton, useZIndex } from '@tmagic/design';
|
||||
|
||||
import MIcon from '@editor/components/Icon.vue';
|
||||
import { useServices } from '@editor/hooks/use-services';
|
||||
|
||||
interface Position {
|
||||
left: number;
|
||||
@ -44,30 +39,17 @@ const props = withDefaults(
|
||||
defineProps<{
|
||||
position?: Position;
|
||||
title?: string;
|
||||
bodyStyle?: CSSProperties;
|
||||
/** 浮窗初始样式,会与内部计算样式合并,外部传入优先 */
|
||||
initialStyle?: CSSProperties;
|
||||
/** 用于约束浮窗 left 的容器宽度,传入时按宽度收敛 left,避免超出右边界;默认取视窗宽度 */
|
||||
frameworkWidth?: number;
|
||||
beforeClose?: (_done: (_cancel?: boolean) => void) => void;
|
||||
}>(),
|
||||
{
|
||||
title: '',
|
||||
position: () => ({ left: 0, top: 0 }),
|
||||
initialStyle: () => ({}),
|
||||
frameworkWidth: 0,
|
||||
},
|
||||
);
|
||||
|
||||
const targetEl = useTemplateRef<HTMLDivElement>('target');
|
||||
const titleEl = useTemplateRef<HTMLDivElement>('title');
|
||||
|
||||
/**
|
||||
* 主题修饰类(来自最近的 `<MEditor>` / `<MForm>` 祖先 provide)。
|
||||
* 挂在 `Teleport` 出去的浮动面板根节点上,让主题级 CSS 变量在 portal 节点上也能命中。
|
||||
*/
|
||||
const themeClass = useThemeClass();
|
||||
|
||||
const zIndex = useZIndex();
|
||||
const curZIndex = ref<number>(0);
|
||||
|
||||
@ -84,11 +66,12 @@ const bodyHeight = computed(() => {
|
||||
return 'auto';
|
||||
});
|
||||
|
||||
const { uiService } = useServices();
|
||||
const frameworkWidth = computed(() => uiService.get('frameworkRect').width || 0);
|
||||
const style = computed(() => {
|
||||
let { left } = props.position;
|
||||
const frameworkWidth = props.frameworkWidth || globalThis.window?.innerWidth || 0;
|
||||
if (width.value && frameworkWidth) {
|
||||
left = left + width.value > frameworkWidth ? frameworkWidth - width.value : left;
|
||||
if (width.value) {
|
||||
left = left + width.value > frameworkWidth.value ? frameworkWidth.value - width.value : left;
|
||||
}
|
||||
|
||||
return {
|
||||
@ -96,60 +79,11 @@ const style = computed(() => {
|
||||
top: `${props.position.top}px`,
|
||||
width: width.value ? `${width.value}px` : 'auto',
|
||||
height: height.value ? `${height.value}px` : 'auto',
|
||||
...props.initialStyle,
|
||||
};
|
||||
});
|
||||
|
||||
let moveable: VanillaMoveable | null = null;
|
||||
|
||||
// 拖拽/缩放时用于覆盖 iframe 的遮罩,防止鼠标进入 iframe 区域后事件被 iframe 吞掉导致拖拽丢失
|
||||
let dragMask: HTMLDivElement | null = null;
|
||||
let dragMaskVisible = false;
|
||||
|
||||
// 兜底:正常情况下遮罩由 moveable 的 dragEnd/resizeEnd 移除;但某些极端场景
|
||||
// (拖拽中鼠标移出窗口后松开、alt-tab 切走窗口失焦、标签页被隐藏等)结束事件不会触发,
|
||||
// 会导致全屏透明遮罩残留、整个编辑器无法点击。这里挂全局 pointerup/blur/visibilitychange 强制收尾。
|
||||
const bindDragMaskSafety = () => {
|
||||
globalThis.window?.addEventListener('pointerup', hideDragMask, true);
|
||||
globalThis.window?.addEventListener('blur', hideDragMask);
|
||||
globalThis.document?.addEventListener('visibilitychange', hideDragMask);
|
||||
};
|
||||
|
||||
const unbindDragMaskSafety = () => {
|
||||
globalThis.window?.removeEventListener('pointerup', hideDragMask, true);
|
||||
globalThis.window?.removeEventListener('blur', hideDragMask);
|
||||
globalThis.document?.removeEventListener('visibilitychange', hideDragMask);
|
||||
};
|
||||
|
||||
const showDragMask = () => {
|
||||
if (dragMaskVisible) {
|
||||
return;
|
||||
}
|
||||
if (!dragMask) {
|
||||
dragMask = globalThis.document.createElement('div');
|
||||
dragMask.className = 'm-editor-float-box-drag-mask';
|
||||
}
|
||||
globalThis.document.body.appendChild(dragMask);
|
||||
dragMaskVisible = true;
|
||||
bindDragMaskSafety();
|
||||
|
||||
// 拖拽标题时,root 上的 @mousedown="nextZIndex" 会把浮窗 z-index 抬高,
|
||||
// 若此时才读取会拿到旧值导致遮罩被浮窗(及其内部 iframe)盖住,故用 nextTick 在 z-index 稳定后再设置到浮窗之上
|
||||
const setMaskZIndex = () => {
|
||||
if (dragMask) {
|
||||
dragMask.style.zIndex = `${curZIndex.value + 1}`;
|
||||
}
|
||||
};
|
||||
setMaskZIndex();
|
||||
nextTick(setMaskZIndex);
|
||||
};
|
||||
|
||||
const hideDragMask = () => {
|
||||
unbindDragMaskSafety();
|
||||
dragMask?.parentNode?.removeChild(dragMask);
|
||||
dragMaskVisible = false;
|
||||
};
|
||||
|
||||
const initMoveable = () => {
|
||||
moveable = new VanillaMoveable(globalThis.document.body, {
|
||||
className: 'm-editor-floating-box-moveable',
|
||||
@ -167,14 +101,6 @@ const initMoveable = () => {
|
||||
bounds: { left: 0, top: 0, right: 0, bottom: 0, position: 'css' },
|
||||
});
|
||||
|
||||
// 仅在真正发生拖拽/缩放位移时插入遮罩:moveable 的 dragStart/resizeStart 在 mousedown 时即触发,
|
||||
// 若此时就盖遮罩,会盖住浮窗本身导致 mouseup 落在遮罩上、关闭按钮的 click 无法触发(点击关闭不了)。
|
||||
// 改为在 drag/resize(实际位移)时才显示,纯点击不再触发遮罩。
|
||||
moveable.on('drag', showDragMask);
|
||||
moveable.on('resize', showDragMask);
|
||||
moveable.on('dragEnd', hideDragMask);
|
||||
moveable.on('resizeEnd', hideDragMask);
|
||||
|
||||
moveable.on('drag', (e) => {
|
||||
e.target.style.transform = e.transform;
|
||||
});
|
||||
@ -189,7 +115,6 @@ const initMoveable = () => {
|
||||
};
|
||||
|
||||
const destroyMoveable = () => {
|
||||
hideDragMask();
|
||||
moveable?.destroy();
|
||||
moveable = null;
|
||||
};
|
||||
|
||||
@ -87,3 +87,70 @@ const scrollBy = (delta: number) => {
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.m-editor-scroll-bar {
|
||||
position: absolute;
|
||||
background-color: transparent;
|
||||
opacity: 0.3;
|
||||
transition:
|
||||
background-color 0.2s linear,
|
||||
opacity 0.2s linear;
|
||||
|
||||
.m-editor-scroll-bar-thumb {
|
||||
background-color: #aaa;
|
||||
border-radius: 6px;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
&.horizontal {
|
||||
width: 100%;
|
||||
height: 15px;
|
||||
bottom: 0;
|
||||
|
||||
.m-editor-scroll-bar-thumb {
|
||||
height: 6px;
|
||||
transition:
|
||||
background-color 0.2s linear,
|
||||
height 0.2s ease-in-out;
|
||||
bottom: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
&.vertical {
|
||||
height: 100%;
|
||||
width: 15px;
|
||||
right: 5px;
|
||||
|
||||
.m-editor-scroll-bar-thumb {
|
||||
width: 6px;
|
||||
transition:
|
||||
background-color 0.2s linear,
|
||||
width 0.2s ease-in-out;
|
||||
right: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: #eee;
|
||||
opacity: 0.9;
|
||||
|
||||
.m-editor-scroll-bar-thumb {
|
||||
background-color: #999;
|
||||
}
|
||||
|
||||
&.horizontal {
|
||||
.m-editor-scroll-bar-thumb {
|
||||
height: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
&.vertical {
|
||||
.m-editor-scroll-bar-thumb {
|
||||
width: 11px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,54 +0,0 @@
|
||||
<template>
|
||||
<div class="m-editor-view-form-wrapper" :style="wrapperStyle">
|
||||
<MForm
|
||||
v-if="config.length"
|
||||
ref="form"
|
||||
class="m-editor-view-form"
|
||||
:config="config"
|
||||
:init-values="currentValues"
|
||||
:disabled="disabled"
|
||||
:label-width="labelWidth"
|
||||
:context="formContext"
|
||||
:size="size"
|
||||
></MForm>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { type Ref, type ShallowRef } from 'vue';
|
||||
|
||||
import { type FormConfig, MForm } from '@tmagic/form';
|
||||
|
||||
import { useCompareForm } from '@editor/hooks/use-compare-form';
|
||||
import type { CompareFormBaseProps } from '@editor/type';
|
||||
|
||||
defineOptions({
|
||||
name: 'MEditorViewForm',
|
||||
});
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<
|
||||
CompareFormBaseProps & {
|
||||
/** 是否禁用表单(默认只读展示)。 */
|
||||
disabled?: boolean;
|
||||
}
|
||||
>(),
|
||||
{
|
||||
category: 'node',
|
||||
labelWidth: '120px',
|
||||
disabled: true,
|
||||
},
|
||||
);
|
||||
|
||||
const { config, currentValues, wrapperStyle, formContext, loadConfig, formRef } = useCompareForm(props);
|
||||
|
||||
defineExpose<{
|
||||
form: ShallowRef<InstanceType<typeof MForm> | null>;
|
||||
config: Ref<FormConfig>;
|
||||
reload: () => Promise<void>;
|
||||
}>({
|
||||
form: formRef,
|
||||
config,
|
||||
reload: loadConfig,
|
||||
});
|
||||
</script>
|
||||
@ -1,7 +1,5 @@
|
||||
import type { InjectionKey } from 'vue';
|
||||
|
||||
import type { DataSourceSchema, EventOption, Id, MApp, MNode, MPage, MPageFragment } from '@tmagic/core';
|
||||
import type { FormConfig } from '@tmagic/form';
|
||||
import type { FormConfig, FormState } from '@tmagic/form';
|
||||
import StageCore, {
|
||||
CONTAINER_HIGHLIGHT_CLASS_NAME,
|
||||
ContainerHighlightType,
|
||||
@ -28,17 +26,7 @@ import type {
|
||||
TreeNodeData,
|
||||
} from './type';
|
||||
|
||||
/**
|
||||
* 「属性配置表单校验」联动能力的 provide/inject 注入键。
|
||||
* 使用 Symbol 避免与其它字符串键冲突,供 PropsPanel / FormPanel 注入判断校验失败时是否仍更新节点并记录错误。
|
||||
*/
|
||||
export const ENABLE_PROPS_FORM_VALIDATE: InjectionKey<boolean> = Symbol('enablePropsFormValidate');
|
||||
|
||||
export interface EditorProps {
|
||||
/** 是否是大屏模拟器容器,大屏容器时,左侧属性面板会扩展为两列(正常3列),颜色表单不扩展两列等 */
|
||||
isLargeStageContainer?: boolean;
|
||||
|
||||
theme?: string;
|
||||
/** 页面初始值 */
|
||||
modelValue?: MApp;
|
||||
/** 左侧面板中的组件类型列表 */
|
||||
@ -82,11 +70,6 @@ export interface EditorProps {
|
||||
containerHighlightDuration?: number;
|
||||
/** 拖入画布中容器时,识别容器的操作类型 */
|
||||
containerHighlightType?: ContainerHighlightType;
|
||||
/**
|
||||
* 是否仅在新增组件(从组件列表拖入新组件)时才启用识别容器,
|
||||
* 开启后在画布中拖动已有组件不会识别容器,默认 false
|
||||
*/
|
||||
containerHighlightAddOnly?: boolean;
|
||||
/** 画布大小 */
|
||||
stageRect?: StageRect;
|
||||
/** monaco editor 的配置 */
|
||||
@ -108,11 +91,6 @@ export interface EditorProps {
|
||||
disabledFlashTip?: boolean;
|
||||
/** 禁用双击在浮层中单独编辑选中组件 */
|
||||
disabledStageOverlay?: boolean;
|
||||
/**
|
||||
* 是否启用「属性配置表单校验」联动能力:开启后属性/样式表单校验失败时仍更新节点,
|
||||
* 并把错误信息集中记录到 editorService,用于组件树标红提示与保存拦截;默认 false(关闭)。
|
||||
*/
|
||||
enablePropsFormValidate?: boolean;
|
||||
/** 禁用属性配置面板右下角显示源码的按钮 */
|
||||
disabledShowSrc?: boolean;
|
||||
/** 禁用数据源 */
|
||||
@ -149,6 +127,7 @@ export interface EditorProps {
|
||||
beforeDblclick?: (event: MouseEvent) => Promise<boolean | void> | boolean | void;
|
||||
/** 组件树节点双击前的钩子函数,返回 false 则阻止默认的双击行为 */
|
||||
beforeLayerNodeDblclick?: (event: MouseEvent, data: TreeNodeData) => Promise<boolean | void> | boolean | void;
|
||||
extendFormState?: (state: FormState) => Record<string, any> | Promise<Record<string, any>>;
|
||||
/** 历史记录面板的自定义扩展 tab,追加在内置的页面/数据源/代码块 tab 之后 */
|
||||
historyListExtraTabs?: HistoryListExtraTab[];
|
||||
/** 页面顺序拖拽配置参数 */
|
||||
@ -167,7 +146,6 @@ export const defaultEditorProps = {
|
||||
containerHighlightClassName: CONTAINER_HIGHLIGHT_CLASS_NAME,
|
||||
containerHighlightDuration: 800,
|
||||
containerHighlightType: ContainerHighlightType.DEFAULT,
|
||||
containerHighlightAddOnly: false,
|
||||
disabledShowSrc: false,
|
||||
disabledDataSource: false,
|
||||
disabledCodeBlock: false,
|
||||
|
||||
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