mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-09-13 15:38:39 +00:00
Compare commits
No commits in common. "master" and "v1.8.0-beta.21" 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
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
|
||||||
version: 11.21.0
|
|
||||||
|
|
||||||
- name: Set node version to 22
|
- name: Set node version to 22
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: "pnpm"
|
cache: 'pnpm'
|
||||||
|
|
||||||
- run: pnpm bootstrap
|
- run: pnpm bootstrap
|
||||||
|
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
npm run coverage
|
npm run test
|
||||||
|
|||||||
29
AGENTS.md
29
AGENTS.md
@ -7,11 +7,7 @@
|
|||||||
|
|
||||||
TMagic Editor 是魔方平台的可视化编辑器核心库,提供拖拽式组件编辑、配置面板、预览发布等能力。支持 Vue 和 React 双框架 Runtime,采用 pnpm monorepo 管理多个核心包。开源项目,同时支持内部业务定制。
|
TMagic Editor 是魔方平台的可视化编辑器核心库,提供拖拽式组件编辑、配置面板、预览发布等能力。支持 Vue 和 React 双框架 Runtime,采用 pnpm monorepo 管理多个核心包。开源项目,同时支持内部业务定制。
|
||||||
|
|
||||||
编辑器本体使用 Vue 3;UI 通过 `@tmagic/design` + adapter 接入 Element Plus 或 TDesign Vue Next。
|
**技术栈:** Vue 3, Element Plus, TypeScript, Vite, vitest, VitePress
|
||||||
|
|
||||||
**技术栈:** 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`
|
|
||||||
**主仓库:** `https://git.woa.com/vft-magic/tmagic-editor.git`
|
**主仓库:** `https://git.woa.com/vft-magic/tmagic-editor.git`
|
||||||
**开源仓库:** `https://github.com/Tencent/tmagic-editor.git`
|
**开源仓库:** `https://github.com/Tencent/tmagic-editor.git`
|
||||||
|
|
||||||
@ -19,10 +15,10 @@ TMagic Editor 是魔方平台的可视化编辑器核心库,提供拖拽式组
|
|||||||
|
|
||||||
关键目录:
|
关键目录:
|
||||||
|
|
||||||
- `packages/` — 核心 npm 包(`editor`、`form`、`stage`、`core`、`cli`、`data-source`、`schema`、`form-schema`、`design`、`utils`、`dep`、`table`、`element-plus-adapter`、`tdesign-vue-next-adapter`)
|
- `packages/` — 核心编辑器包
|
||||||
- `runtime/` — Vue/React Runtime,以及 `vue-runtime-help`、`react-runtime-help`、`tmagic-form`
|
- `runtime/` — Vue/React Runtime 实现
|
||||||
- `vue-components/` — Vue 业务组件封装
|
- `vue-components/` — Vue 组件封装
|
||||||
- `react-components/` — React 业务组件封装
|
- `react-components/` — React 组件封装
|
||||||
- `playground/` — 演示 playground
|
- `playground/` — 演示 playground
|
||||||
- `docs/` — VitePress 文档
|
- `docs/` — VitePress 文档
|
||||||
- `scripts/` — 构建和发布脚本
|
- `scripts/` — 构建和发布脚本
|
||||||
@ -30,12 +26,9 @@ TMagic Editor 是魔方平台的可视化编辑器核心库,提供拖拽式组
|
|||||||
|
|
||||||
## 开发约定
|
## 开发约定
|
||||||
|
|
||||||
**分支策略(内部约定):** 日常开发走 `dev`;test/prod 对应 `master`。开源文档站与 playground 从 `dev` 发布。
|
**分支策略:** dev=dev, test/prod=master
|
||||||
**提交规范:** commitlint(`@commitlint/config-conventional`)+ husky,格式 `type(scope): subject`,详见 `CONTRIBUTING.md`
|
**提交规范:** commitlint + husky,`type: 描述`
|
||||||
**测试:** 新增或修改的代码必须补充单元测试,覆盖率不低于 85%(lines)。`pnpm coverage` 执行两层硬门禁:
|
**测试覆盖率:** 新增或修改的代码必须补充单元测试,覆盖率不低于 85%
|
||||||
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`。
|
|
||||||
|
|
||||||
**禁止事项:**
|
**禁止事项:**
|
||||||
|
|
||||||
@ -50,12 +43,14 @@ pre-commit 跑 lint-staged 与 `pnpm check:type`,pre-push 跑 `pnpm coverage`
|
|||||||
pnpm pg:react # 启动 React playground
|
pnpm pg:react # 启动 React playground
|
||||||
pnpm build # 完整构建(DTS + 包)
|
pnpm build # 完整构建(DTS + 包)
|
||||||
pnpm test # 运行测试
|
pnpm test # 运行测试
|
||||||
pnpm coverage # 运行测试、生成覆盖率,并执行 85% 门禁
|
|
||||||
pnpm check:type # TypeScript 类型检查
|
|
||||||
pnpm lint-fix # ESLint 修复
|
pnpm lint-fix # ESLint 修复
|
||||||
pnpm docs:dev # 启动文档开发
|
pnpm docs:dev # 启动文档开发
|
||||||
pnpm release # 发版
|
pnpm release # 发版
|
||||||
|
|
||||||
|
## 当前状态
|
||||||
|
|
||||||
|
**当前里程碑:** {待人工填写}
|
||||||
|
|
||||||
## 深入阅读
|
## 深入阅读
|
||||||
|
|
||||||
| 文档 | 说明 |
|
| 文档 | 说明 |
|
||||||
|
|||||||
82
CHANGELOG.md
82
CHANGELOG.md
@ -1,85 +1,3 @@
|
|||||||
# [1.8.0-beta.28](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.27...v1.8.0-beta.28) (2026-09-09)
|
|
||||||
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
* **editor:** 丢弃过期 root 快照,避免画布回退与 modelValue 回写死循环 ([66b54a5](https://github.com/Tencent/tmagic-editor/commit/66b54a52f8e9ff5041305ab7eb4057c820a4542a))
|
|
||||||
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* **form:** group-list 标题吸顶改为按 header.sticky 显式开启 ([2a4a54a](https://github.com/Tencent/tmagic-editor/commit/2a4a54a49567007787295769e3f8d05e3e8a30e0))
|
|
||||||
# [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)
|
# [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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -18,7 +18,7 @@ https://tencent.github.io/tmagic-editor/playground/index.html
|
|||||||
|
|
||||||
node.js ^20.19.0 || >=22.12.0
|
node.js ^20.19.0 || >=22.12.0
|
||||||
|
|
||||||
pnpm 11.20.0
|
pnpm >= 10
|
||||||
|
|
||||||
先安装 pnpm
|
先安装 pnpm
|
||||||
|
|
||||||
|
|||||||
@ -588,13 +588,10 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias:[
|
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-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\/form/, replacement: path.join(__dirname, '../../packages/form/src/index.ts') },
|
||||||
{ find: /^@tmagic\/utils/, replacement: path.join(__dirname, '../../packages/utils/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\/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\/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') },
|
{ find: /^@tmagic\/element-plus-adapter/, replacement: path.join(__dirname, '../../packages/element-plus-adapter/src/index.ts') },
|
||||||
]
|
]
|
||||||
|
|||||||
@ -480,8 +480,6 @@ editorService.highlight("text_123");
|
|||||||
- {`MNode`} config 新的节点
|
- {`MNode`} config 新的节点
|
||||||
- `{Object}` data 可选配置
|
- `{Object}` data 可选配置
|
||||||
- {`ChangeRecord`[]} changeRecords 变更记录
|
- {`ChangeRecord`[]} changeRecords 变更记录
|
||||||
- `{boolean}` replace 是否整节点替换(默认 false)。为 `true` 时跳过 `mergeWith` / `toggleFixedPosition` / `setChildrenLayout`,直接用传入配置覆盖现有节点
|
|
||||||
- `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options)
|
|
||||||
|
|
||||||
- **返回:**
|
- **返回:**
|
||||||
- `{Promise<{ newNode: MNode; oldNode: MNode; changeRecords?: ChangeRecord[] }>}` 更新前后的节点信息
|
- `{Promise<{ newNode: MNode; oldNode: MNode; changeRecords?: ChangeRecord[] }>}` 更新前后的节点信息
|
||||||
@ -500,8 +498,6 @@ editorService.highlight("text_123");
|
|||||||
当被更新节点正好在当前选中列表中时,state 会自动同步到新的节点引用,无需调用方处理
|
当被更新节点正好在当前选中列表中时,state 会自动同步到新的节点引用,无需调用方处理
|
||||||
|
|
||||||
当被更新节点正好是当前页面时,state.page 也会同步到新的节点引用;更新非当前页面(不同 ID)时不会把编辑器切到该页
|
当被更新节点正好是当前页面时,state.page 也会同步到新的节点引用;更新非当前页面(不同 ID)时不会把编辑器切到该页
|
||||||
|
|
||||||
默认会将传入配置与现有节点深合并(保留未传入的字段)。若需要完整 DSL 覆盖(如源码编辑写回),传入 `replace: true`。
|
|
||||||
:::
|
:::
|
||||||
|
|
||||||
## update
|
## update
|
||||||
@ -516,7 +512,6 @@ editorService.highlight("text_123");
|
|||||||
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false)
|
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false)
|
||||||
- `{string}` historyDescription 见[历史记录相关 options](#历史记录相关-options)
|
- `{string}` historyDescription 见[历史记录相关 options](#历史记录相关-options)
|
||||||
- `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options)
|
- `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options)
|
||||||
- `{boolean}` replace 是否整节点替换(默认 false)。为 `true` 时跳过 `mergeWith` / `toggleFixedPosition` / `setChildrenLayout`,直接用传入配置覆盖现有节点;适用于源码编辑、完整 DSL 回写等场景
|
|
||||||
- `{Object}` invalidInfo 启用 [enablePropsFormValidate](./props.md#enablepropsformvalidate) 时,属性面板提交携带的校验错误信息,在写入历史记录之前落库,使历史快照与本次变更对齐
|
- `{Object}` invalidInfo 启用 [enablePropsFormValidate](./props.md#enablepropsformvalidate) 时,属性面板提交携带的校验错误信息,在写入历史记录之前落库,使历史快照与本次变更对齐
|
||||||
- `{Id}` id 节点 id
|
- `{Id}` id 节点 id
|
||||||
- `{'props' | 'style'}` source 错误来源:属性表单 / 样式表单
|
- `{'props' | 'style'}` source 错误来源:属性表单 / 样式表单
|
||||||
@ -551,12 +546,6 @@ editorService.highlight("text_123");
|
|||||||
才退化为整节点替换(如内部 `sort` / `moveLayer` / 拖动等纯快照场景)。
|
才退化为整节点替换(如内部 `sort` / `moveLayer` / 拖动等纯快照场景)。
|
||||||
:::
|
:::
|
||||||
|
|
||||||
:::tip
|
|
||||||
**`replace: true` 整节点替换:** 默认 `update` 会将传入配置与现有节点深合并,未传入的字段会保留。
|
|
||||||
传入 `replace: true` 后跳过合并与布局相关变换,直接用传入配置覆盖,传入对象中不存在的字段会被删除。
|
|
||||||
属性面板局部改动请保持默认;源码编辑 / 完整 DSL 回写等场景使用 `replace: true`。
|
|
||||||
:::
|
|
||||||
|
|
||||||
## sort
|
## sort
|
||||||
|
|
||||||
- **[扩展支持](../../guide/editor-expand#行为扩展):** 是
|
- **[扩展支持](../../guide/editor-expand#行为扩展):** 是
|
||||||
|
|||||||
@ -16,11 +16,7 @@
|
|||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|
||||||
批量设置多个组件类型的事件列表。增量合并语义:只写入入参中出现的类型,已有的其他类型保持不变。
|
批量设置多个组件类型的事件列表
|
||||||
|
|
||||||
::: tip 切换 runtime 版本
|
|
||||||
不同版本的 runtime 事件表不同,直接调用 `setEvents` 会让上一个版本独有的类型残留。是否需要清掉上一个版本的数据由业务自行决定,需要清掉时先调用 [`resetState`](#resetstate) 再写入新表。
|
|
||||||
:::
|
|
||||||
|
|
||||||
- **示例:**
|
- **示例:**
|
||||||
|
|
||||||
@ -51,7 +47,7 @@ eventsService.setEvents({
|
|||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|
||||||
设置指定组件类型的事件列表,只影响该类型
|
设置指定组件类型的事件列表
|
||||||
|
|
||||||
- **示例:**
|
- **示例:**
|
||||||
|
|
||||||
@ -110,7 +106,7 @@ eventsService.usePlugin({
|
|||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|
||||||
批量设置多个组件类型的方法列表。语义与 `setEvents` 一致:增量合并,入参中不存在的类型保持不变。
|
批量设置多个组件类型的方法列表
|
||||||
|
|
||||||
- **示例:**
|
- **示例:**
|
||||||
|
|
||||||
@ -142,7 +138,7 @@ eventsService.setMethods({
|
|||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|
||||||
设置指定组件类型的方法列表,只影响该类型
|
设置指定组件类型的方法列表
|
||||||
|
|
||||||
- **示例:**
|
- **示例:**
|
||||||
|
|
||||||
@ -213,17 +209,14 @@ eventsService.usePlugin({
|
|||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|
||||||
重置事件服务状态,清空所有事件和方法配置。`setEvents` / `setMethods` 是增量合并,切换 runtime 版本这类需要丢弃上一份事件表的场景,由业务在写入新表前自行调用本方法。
|
重置事件服务状态,清空所有事件和方法配置
|
||||||
|
|
||||||
- **示例:**
|
- **示例:**
|
||||||
|
|
||||||
```js
|
```js
|
||||||
import { eventsService } from '@tmagic/editor';
|
import { eventsService } from '@tmagic/editor';
|
||||||
|
|
||||||
// 切到另一个版本的 runtime:先清空,再写入该版本的事件表
|
|
||||||
eventsService.resetState();
|
eventsService.resetState();
|
||||||
eventsService.setEvents(nextVersionEvents);
|
|
||||||
eventsService.setMethods(nextVersionMethods);
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## destroy
|
## destroy
|
||||||
|
|||||||
@ -630,10 +630,6 @@ const propsValues = {
|
|||||||
该属性最终会设置到[eventsService](./eventsServiceMethods.md)中,所以也可直接调用[eventsService.setEvents()](./eventsServiceMethods.md#setEvents)与[eventsService.setMethods()](./eventsServiceMethods.md#setMethods)方法来配置
|
该属性最终会设置到[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[] }>`
|
- **类型:** `Record<string, { events: EventOption[]; methods: EventOption[] }>`
|
||||||
@ -1562,71 +1558,52 @@ const onLayerNodeDblclick = (event, data) => {
|
|||||||
- 返回 `false` 时,会同时阻断默认的"展开/收起"行为以及向上抛出的 [`layer-node-dblclick`](./events.md#layer-node-dblclick) 事件;返回其他值则继续触发默认行为并抛出事件。
|
- 返回 `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
|
```html
|
||||||
<template>
|
<template>
|
||||||
<m-editor></m-editor>
|
<m-editor :extend-form-state="extendFormState"></m-editor>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, provide } from 'vue';
|
const extendFormState = async (state) => {
|
||||||
import { FORM_CONTEXT_KEY } from '@tmagic/form';
|
// 返回自定义的状态数据
|
||||||
|
return {
|
||||||
provide(
|
// 可以是同步数据
|
||||||
FORM_CONTEXT_KEY,
|
currentUser: {
|
||||||
computed(() => ({
|
name: 'Admin',
|
||||||
currentUser: store.currentUser,
|
role: 'admin',
|
||||||
env: store.env,
|
},
|
||||||
})),
|
// 也可以是异步获取的数据
|
||||||
);
|
projectConfig: await fetchProjectConfig(),
|
||||||
|
};
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
```
|
```
|
||||||
|
|
||||||
补类型用模块增强:
|
:::tip
|
||||||
|
扩展的状态可以在表单配置中通过 `state` 访问,例如:
|
||||||
```ts
|
|
||||||
declare module '@tmagic/form-schema' {
|
|
||||||
interface FormContext {
|
|
||||||
currentUser?: { name: string; role: string };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
表单配置里统一从第一个参数 `mForm` 读:formState 是读穿 Proxy,`mForm` 上没有的字段自动落到 context,所以回调签名不变,后端下发的存量配置无需改动。
|
|
||||||
|
|
||||||
```js
|
```js
|
||||||
{
|
{
|
||||||
name: 'title',
|
name: 'title',
|
||||||
text: '标题',
|
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
|
## historyListExtraTabs
|
||||||
|
|||||||
@ -219,25 +219,8 @@
|
|||||||
|
|
||||||
- **类型:** `boolean`
|
- **类型:** `boolean`
|
||||||
|
|
||||||
## context
|
## extendState
|
||||||
|
|
||||||
- **详情:** 宿主业务上下文。可用本 prop 直接传,也可由祖先 `provide(FORM_CONTEXT_KEY)` 下发;同名字段本 prop 优先。
|
- **详情:** 扩展 formState 的钩子函数,返回的对象会被合并到 formState 上
|
||||||
|
|
||||||
嵌套表单(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',
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
|
- **类型:** `(state: FormState) => Record<string, any> | Promise<Record<string, any>>`
|
||||||
|
|||||||
@ -1,107 +1,14 @@
|
|||||||
# submitForm 函数
|
# 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` 校验逻辑的场景,例如:
|
适用于一些没有合适的容器、但又需要复用 `MForm` 校验逻辑的场景,例如:
|
||||||
|
|
||||||
- 通过快捷菜单/命令面板触发一次性表单
|
- 通过快捷菜单/命令面板触发一次性表单
|
||||||
- 在脚本/服务层完成一次表单值校验后再发请求
|
- 在脚本/服务层完成一次表单值校验后再发请求
|
||||||
- 把 `config` 配置当作"可执行的校验规则"使用
|
- 把 `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 |
|
| `popperClass` | `string` | — | 弹层 className |
|
||||||
| `preventSubmitDefault` | `boolean` | — | 是否阻止表单原生 submit |
|
| `preventSubmitDefault` | `boolean` | — | 是否阻止表单原生 submit |
|
||||||
| `useFieldTextInError` | `boolean` | `true` | 校验失败时错误提示前缀是否使用字段的 `text` 文案;`false` 时直接使用字段 `name` |
|
| `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))` |
|
| `native` | `boolean` | `false` | 透传给 `Form.submitForm`。`true` 时返回内部响应式 `values`,否则返回 `cloneDeep(toRaw(values))` |
|
||||||
| `returnChangeRecords` | `boolean` | `false` | `true` 时 resolve 结果为 `{ values, changeRecords }`,携带表单变更记录;否则仅 resolve `values` |
|
| `returnChangeRecords` | `boolean` | `false` | `true` 时 resolve 结果为 `{ values, changeRecords }`,携带表单变更记录;否则仅 resolve `values` |
|
||||||
| `appContext` | `AppContext \| null` | `null` | 父级 Vue 应用上下文。仅 `dialog: true` 时生效,用于继承全局组件、指令、provide 等,常通过 `app._context` 或 `getCurrentInstance()?.appContext` 获取 |
|
| `appContext` | `AppContext \| null` | `null` | 父级 Vue 应用上下文。需要继承全局组件、指令、provide 等时传入,常通过 `app._context` 或 `getCurrentInstance()?.appContext` 获取 |
|
||||||
| `dialog` | `boolean` | `false` | `true` 时把表单以弹层形式渲染出来,点击「确定」才提交,「取消」则以 reject 中断;校验失败会保留弹层并展示错误,便于修正后重试。等待人工操作,可用 `signal` 中断 |
|
| `timeout` | `number` | `10000` | 等待表单初始化的最长时间(毫秒)。超时将以错误 reject。设为 `<= 0` 时关闭超时兜底 |
|
||||||
| `title` | `string` | `'submitForm'` / `'validateForm'` | 弹层标题,仅 `dialog: true` 时生效 |
|
|
||||||
| `signal` | `AbortSignal` | — | 外部中断信号。abort 时立即以 `signal.reason` reject,并卸载 `dialog` 模式下已挂载的临时表单实例 |
|
|
||||||
|
|
||||||
## 返回值
|
## 返回值
|
||||||
|
|
||||||
- `校验通过` — `Promise<any>` resolve 当前表单值(`native` 决定是否克隆);当 `returnChangeRecords` 为 `true` 时,resolve `{ values, changeRecords }`
|
- `校验通过` — `Promise<any>` resolve 当前表单值(`native` 决定是否克隆);当 `returnChangeRecords` 为 `true` 时,resolve `{ values, changeRecords }`
|
||||||
- `校验失败` — `Promise<any>` reject 一个 `Error`,`message` 中包含逐条字段错误信息(格式 `${text} -> ${message}`,多条用 `<br>` 分隔)
|
- `校验失败` — `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
|
::: tip 关于 changeRecords
|
||||||
`changeRecords` 记录的是表单挂载后发生的字段变更(由各字段的 `change` 事件累积而来)。无渲染校验没有用户交互,因此固定返回空数组;只有 `dialog: true` 时才可能有内容(`MForm` 内部的 `submitForm` 在校验通过后会清空变更记录,因此本函数会在调用前先做快照)。
|
`changeRecords` 记录的是表单挂载后发生的字段变更(由各字段的 `change` 事件累积而来)。在 `submitForm` 这种命令式、无用户交互的场景下,通常为空数组;只有在 `extendState` 或字段联动等逻辑中触发了变更时才会有内容。`MForm` 内部的 `submitForm` 在校验通过后会清空变更记录,因此本函数会在调用前先对其做快照再返回。
|
||||||
:::
|
:::
|
||||||
|
|
||||||
## 基础用法
|
## 基础用法
|
||||||
@ -193,9 +99,9 @@ console.log(values); // { username: 'foo' }
|
|||||||
console.log(changeRecords); // ChangeRecord[]
|
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
|
```vue
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@ -209,8 +115,6 @@ const onClick = async () => {
|
|||||||
const values = await submitForm({
|
const values = await submitForm({
|
||||||
config: [{ type: 'text', name: 'text', text: '文本' }],
|
config: [{ type: 'text', name: 'text', text: '文本' }],
|
||||||
initValues: { text: 'hello' },
|
initValues: { text: 'hello' },
|
||||||
dialog: true,
|
|
||||||
title: '编辑配置',
|
|
||||||
appContext,
|
appContext,
|
||||||
});
|
});
|
||||||
console.log(values);
|
console.log(values);
|
||||||
@ -254,77 +158,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 渲染进程 / 浏览器扩展 | ✅ | 直接可用 |
|
| 浏览器 / Electron 渲染进程 / 浏览器扩展 | ✅ | 直接可用 |
|
||||||
| Vitest / Jest + `happy-dom` / `jsdom` | ✅ | 项目自身的单测就跑在这种环境下 |
|
| Vitest / Jest + `happy-dom` / `jsdom` | ✅ | 项目自身的单测就跑在这种环境下 |
|
||||||
| 纯 Node.js / Bun / Deno(无 DOM polyfill) | ✅ | 从 `@tmagic/form/headless` 引入,不要用 `@tmagic/form` 主入口 |
|
| 纯 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
|
```ts
|
||||||
// scripts/check-form.ts —— 纯 Node 环境,无需任何 DOM polyfill
|
// scripts/check-form.ts
|
||||||
import { builtInFields, registerBuiltInFields, registerFields, validateForm } from '@tmagic/form/headless';
|
import { Window } from 'happy-dom';
|
||||||
import { editorFields } from '@tmagic/editor/headless';
|
|
||||||
|
|
||||||
// `builtInFields` 只是数据;未 `app.use(MagicForm)` 时要自己登记
|
const window = new Window();
|
||||||
registerBuiltInFields(builtInFields);
|
Object.assign(globalThis, {
|
||||||
registerFields(editorFields);
|
window,
|
||||||
|
document: window.document,
|
||||||
const error = await validateForm({
|
navigator: window.navigator,
|
||||||
config: [{ type: 'text', name: 'username', text: '用户名', rules: [{ required: true }] }],
|
HTMLElement: window.HTMLElement,
|
||||||
initValues: { username: '' },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) {
|
// 注意:DOM polyfill 必须先注入到 globalThis,再用动态 import
|
||||||
console.error(error);
|
// 加载业务模块,否则 @tmagic/design 等模块顶层执行时就会读 document
|
||||||
process.exit(1);
|
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 注意
|
::: warning 注意
|
||||||
`dialog: true` 依赖 DOM 与已安装的 UI 库(`element-plus` / `tdesign`),在纯 Node 环境中不可用。
|
- 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 渲染层
|
||||||
::: 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 子路径同理。
|
|
||||||
:::
|
:::
|
||||||
|
|
||||||
## 类型定义
|
## 类型定义
|
||||||
|
|
||||||
::: details 查看 `SubmitFormOptions` 类型定义
|
::: details 查看 `SubmitFormOptions` 类型定义
|
||||||
<<< @/../packages/form/src/utils/submitHeadless.ts#SubmitFormOptions{ts}
|
<<< @/../packages/form/src/submitForm.ts#SubmitFormOptions{ts}
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::: details 查看 `SubmitFormResult` 类型定义
|
::: details 查看 `SubmitFormResult` 类型定义
|
||||||
<<< @/../packages/form/src/utils/submitHeadless.ts#SubmitFormResult{ts}
|
<<< @/../packages/form/src/submitForm.ts#SubmitFormResult{ts}
|
||||||
:::
|
:::
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## 配置类型
|
## 配置类型
|
||||||
|
|
||||||
::: details 查看 ContainerCommonConfig / RowConfig / TabConfig / TabPaneConfig / FieldsetConfig / PanelConfig / StepConfig / FlexLayoutConfig / GroupListConfig / TableConfig / TableColumnConfig / TableGroupListCommonConfig / GroupListHeaderConfig 配置类型定义
|
::: details 查看 ContainerCommonConfig / RowConfig / TabConfig / TabPaneConfig / FieldsetConfig / PanelConfig / StepConfig / FlexLayoutConfig / GroupListConfig / TableConfig / TableColumnConfig / TableGroupListCommonConfig 配置类型定义
|
||||||
<<< @/../packages/form-schema/src/base.ts#ContainerCommonConfig{ts}
|
<<< @/../packages/form-schema/src/base.ts#ContainerCommonConfig{ts}
|
||||||
|
|
||||||
<<< @/../packages/form-schema/src/base.ts#RowConfig{ts}
|
<<< @/../packages/form-schema/src/base.ts#RowConfig{ts}
|
||||||
@ -27,8 +27,6 @@
|
|||||||
|
|
||||||
<<< @/../packages/form-schema/src/base.ts#TableGroupListCommonConfig{ts}
|
<<< @/../packages/form-schema/src/base.ts#TableGroupListCommonConfig{ts}
|
||||||
|
|
||||||
<<< @/../packages/form-schema/src/base.ts#GroupListHeaderConfig{ts}
|
|
||||||
|
|
||||||
<<< @/../packages/form-schema/src/base.ts#FormItem{ts}
|
<<< @/../packages/form-schema/src/base.ts#FormItem{ts}
|
||||||
|
|
||||||
:::
|
:::
|
||||||
@ -213,27 +211,6 @@
|
|||||||
}]
|
}]
|
||||||
}]"></demo-block>
|
}]"></demo-block>
|
||||||
|
|
||||||
#### 标题吸顶
|
|
||||||
|
|
||||||
列表项较多、需要边滚动边看清当前编辑的是哪一项时,可以用 `header.sticky` 让卡片标题吸顶:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
{
|
|
||||||
type: 'groupList',
|
|
||||||
name: 'group',
|
|
||||||
header: { sticky: true },
|
|
||||||
items: [/* ... */],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
吸顶默认关闭,需要逐层显式开启:外层开了不会带动内层,嵌套列表想一起吸顶就各自配一次,内层标题会自动下移让开外层标题。
|
|
||||||
|
|
||||||
标题让位的高度默认按 65px 计算。如果自定义了卡片标题的样式导致实际高度不同、内层标题出现重叠或空隙,用 `header.height` 告知真实高度即可(它只影响内层的让位距离,不会改变标题本身的高度)。
|
|
||||||
|
|
||||||
::: warning 行为变更
|
|
||||||
1.8.0-beta.28 之前,group-list 的卡片标题在任何情况下都会吸顶。现在改为默认关闭、由 `header.sticky` 显式开启。升级后如需保持原有表现,请在对应配置上补上 `header: { sticky: true }`。
|
|
||||||
:::
|
|
||||||
|
|
||||||
### table
|
### table
|
||||||
|
|
||||||
<demo-block type="form" :config="[{
|
<demo-block type="form" :config="[{
|
||||||
|
|||||||
@ -78,34 +78,35 @@
|
|||||||
### 运行时注册
|
### 运行时注册
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { registerField, registerFields, unregisterField, clearFields } from '@tmagic/form';
|
import {
|
||||||
|
registerTypeMatchRule,
|
||||||
|
registerTypeMatchRules,
|
||||||
|
deleteTypeMatchRule,
|
||||||
|
clearTypeMatchRules,
|
||||||
|
} from '@tmagic/form';
|
||||||
|
|
||||||
// 覆盖内置 text
|
// 覆盖内置 text
|
||||||
registerField('text', {
|
registerTypeMatchRule('text', (value, { message }) => {
|
||||||
typeMatch: (value, { message }) => {
|
|
||||||
if (typeof value !== 'string') {
|
if (typeof value !== 'string') {
|
||||||
return message || '值类型应为字符串';
|
return message || '值类型应为字符串';
|
||||||
}
|
}
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 扩展业务字段
|
// 扩展业务字段
|
||||||
registerField('vs-code', {
|
registerTypeMatchRule('vs-code', (value, { message }) => {
|
||||||
typeMatch: (value, { message }) => {
|
|
||||||
if (typeof value !== 'string') {
|
if (typeof value !== 'string') {
|
||||||
return message || '代码字段应为字符串';
|
return message || '代码字段应为字符串';
|
||||||
}
|
}
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 批量注册
|
// 批量注册
|
||||||
registerFields({
|
registerTypeMatchRules({
|
||||||
foo: { typeMatch: (value) => (Array.isArray(value) ? undefined : '应为数组') },
|
foo: (value) => (Array.isArray(value) ? undefined : '应为数组'),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 删除 / 清空该 type 的全部登记(含 typeMatch)
|
// 删除 / 清空
|
||||||
unregisterField('foo');
|
deleteTypeMatchRule('foo');
|
||||||
clearFields();
|
clearTypeMatchRules();
|
||||||
```
|
```
|
||||||
|
|
||||||
自定义校验器签名:`(value, context) => string | undefined | Promise<string | undefined>`。返回错误文案表示失败,返回 `undefined` 表示通过。`context` 包含 `fieldType`、`mForm`、`props`、`message`。
|
自定义校验器签名:`(value, context) => string | undefined | Promise<string | undefined>`。返回错误文案表示失败,返回 `undefined` 表示通过。`context` 包含 `fieldType`、`mForm`、`props`、`message`。
|
||||||
@ -115,15 +116,11 @@ clearFields();
|
|||||||
自定义校验器可以返回 `Promise`,用于需要异步确认取值是否合法的场景(如请求接口校验 id 是否存在)。内置规则均为同步。
|
自定义校验器可以返回 `Promise`,用于需要异步确认取值是否合法的场景(如请求接口校验 id 是否存在)。内置规则均为同步。
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { registerField } from '@tmagic/form';
|
registerTypeMatchRule('mod-select', async (value, { message }) => {
|
||||||
|
|
||||||
registerField('mod-select', {
|
|
||||||
typeMatch: async (value, { message }) => {
|
|
||||||
const exists = await checkModExists(value);
|
const exists = await checkModExists(value);
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
return message || `模块(${value})不存在`;
|
return message || `模块(${value})不存在`;
|
||||||
}
|
}
|
||||||
},
|
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -137,25 +134,21 @@ registerField('mod-select', {
|
|||||||
|
|
||||||
```ts
|
```ts
|
||||||
import MagicForm from '@tmagic/form';
|
import MagicForm from '@tmagic/form';
|
||||||
import MyField from './MyField.vue';
|
|
||||||
|
|
||||||
app.use(MagicForm, {
|
app.use(MagicForm, {
|
||||||
fields: {
|
typeMatchRules: {
|
||||||
'my-field': {
|
'my-field': (value, { message }) => {
|
||||||
component: MyField,
|
|
||||||
typeMatch: (value, { message }) => {
|
|
||||||
if (typeof value !== 'string') {
|
if (typeof value !== 'string') {
|
||||||
return message || 'my-field 应为字符串';
|
return message || 'my-field 应为字符串';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### Editor 字段内置规则
|
### Editor 字段内置规则
|
||||||
|
|
||||||
安装 `@tmagic/editor` 时会把 `editorFields`(无 Vue 组件)叠上字段组件后作为 `fields` 传给 `@tmagic/form`。Node 里从 `@tmagic/form/headless` 与 `@tmagic/editor/headless` 引入即可。若安装时也传了 `fields`,会与编辑器字段按 type 浅合并:调用方传入的 key 覆盖对应项,未传的 key(如 `innerConfig` / `typeMatch`)保留。服务数据(数据源 / 代码块 / 节点树)未就绪时,只做基础形态校验,不做枚举或存在性失败。
|
安装 `@tmagic/editor` 时会自动 `registerTypeMatchRules` 注册编辑器自定义字段规则。服务数据(数据源 / 代码块 / 节点树)未就绪时,只做基础形态校验,不做枚举或存在性失败。
|
||||||
|
|
||||||
| 字段 type | 期望值 |
|
| 字段 type | 期望值 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@ -174,7 +167,7 @@ app.use(MagicForm, {
|
|||||||
|
|
||||||
> 容器类字段(`event-select` / `code-select` / `display-conds`)遵循同一约定:容器级 typeMatch 只做结构校验,「枚举 / 存在性」下沉到内部单元格各自的 typeMatch/rules,避免单个子项非法导致整块表单标红。
|
> 容器类字段(`event-select` / `code-select` / `display-conds`)遵循同一约定:容器级 typeMatch 只做结构校验,「枚举 / 存在性」下沉到内部单元格各自的 typeMatch/rules,避免单个子项非法导致整块表单标红。
|
||||||
|
|
||||||
业务仍可用 `registerField(type, { typeMatch })` 覆盖上述任一 type 的类型校验;多次 `registerField` 按字段浅合并,不会丢掉已登记的 `innerConfig` / `walk` / `effect`。
|
业务仍可用 `registerTypeMatchRule` 覆盖上述任一 type。
|
||||||
|
|
||||||
## 示例
|
## 示例
|
||||||
|
|
||||||
@ -229,10 +222,6 @@ app.use(MagicForm, {
|
|||||||
<<< @/../packages/form/src/utils/typeMatch.ts#TypeMatchValidateContext{ts}
|
<<< @/../packages/form/src/utils/typeMatch.ts#TypeMatchValidateContext{ts}
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::: details 查看 FieldOptions 类型定义
|
|
||||||
<<< @/../packages/form/src/utils/registerField.ts#FieldOptions{ts}
|
|
||||||
:::
|
|
||||||
|
|
||||||
::: details 查看 FormInstallOptions 类型定义
|
::: details 查看 FormInstallOptions 类型定义
|
||||||
<<< @/../packages/form/src/plugin.ts#FormInstallOptions{ts}
|
<<< @/../packages/form/src/plugin.ts#FormInstallOptions{ts}
|
||||||
:::
|
:::
|
||||||
|
|||||||
@ -101,6 +101,7 @@ onCodeBlockDiff(id, index);
|
|||||||
| 字段 | 必填 | 说明 |
|
| 字段 | 必填 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `appContext` | 否 | 父级应用上下文,用于让动态挂载的差异确认弹窗继承全局组件 / 指令 / provide / 插件(Element Plus、`@tmagic/form` 字段组件等)。在组件 `setup` 中调用时会自动取当前组件的 `appContext`,无需手动传;仅当在组件 setup 之外调用时才需显式传入(如 `editorApp._context`)。 |
|
| `appContext` | 否 | 父级应用上下文,用于让动态挂载的差异确认弹窗继承全局组件 / 指令 / provide / 插件(Element Plus、`@tmagic/form` 字段组件等)。在组件 `setup` 中调用时会自动取当前组件的 `appContext`,无需手动传;仅当在组件 setup 之外调用时才需显式传入(如 `editorApp._context`)。 |
|
||||||
|
| `extendState` | 否 | 透传给差异确认弹窗的 `extendState`(同 Editor 的 [`extendFormState`](#自定义对比判断)),使对比表单中依赖业务上下文的 `display` / `disabled` 等 `filterFunction` 正常工作。 |
|
||||||
| `dialogWidth` | 否 | 内置页面 / 数据源 / 代码块的差异 / 回滚确认弹窗默认宽度(透传给 `TMagicDialog` 的 `width`),如 `'1200px'` / `'80%'`。缺省时使用弹窗内置默认宽度(`900px`)。业务自有历史可在 `viewDiff` / `confirmAndRevert` 调用时通过各自入参的 `width` 单独覆盖。 |
|
| `dialogWidth` | 否 | 内置页面 / 数据源 / 代码块的差异 / 回滚确认弹窗默认宽度(透传给 `TMagicDialog` 的 `width`),如 `'1200px'` / `'80%'`。缺省时使用弹窗内置默认宽度(`900px`)。业务自有历史可在 `viewDiff` / `confirmAndRevert` 调用时通过各自入参的 `width` 单独覆盖。 |
|
||||||
|
|
||||||
> 若只需要无确认、无校验的静默回滚,直接用上面的 `editorService.revertPageStep` 等即可,无需 `useHistoryRevert`。
|
> 若只需要无确认、无校验的静默回滚,直接用上面的 `editorService.revertPageStep` 等即可,无需 `useHistoryRevert`。
|
||||||
@ -163,7 +164,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) 自定义判断函数避免被误判为差异。
|
若某些字段语义上相等但结构不同(例如 `code-select` 字段中 `''` 与 `{ hookType: 'code', hookData: [] }` 应视为相等),可借助 `@tmagic/form` 的 [`showDiff`](/api/form/form-props.html#showdiff) 自定义判断函数避免被误判为差异。
|
||||||
|
|
||||||
|
|||||||
@ -45,22 +45,17 @@ app.use(MagicForm);
|
|||||||
app.mount("#app");
|
app.mount("#app");
|
||||||
```
|
```
|
||||||
|
|
||||||
也可在安装时传入自定义字段登记(叶子 / innerConfig / typeMatch / component),详见[表单校验 - 扩展自定义 type 规则](../../form-config/rules.md#扩展自定义-type-规则):
|
也可在安装时传入自定义 `typeMatch` 规则,详见[表单校验 - 扩展自定义 type 规则](../../form-config/rules.md#扩展自定义-type-规则):
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
import MyField from './MyField.vue';
|
|
||||||
|
|
||||||
app.use(MagicForm, {
|
app.use(MagicForm, {
|
||||||
fields: {
|
typeMatchRules: {
|
||||||
'my-field': {
|
'my-field': (value, { message }) => {
|
||||||
component: MyField,
|
|
||||||
typeMatch: (value, { message }) => {
|
|
||||||
if (typeof value !== 'string') {
|
if (typeof value !== 'string') {
|
||||||
return message || 'my-field 应为字符串';
|
return message || 'my-field 应为字符串';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@ -69,11 +69,11 @@ $ npm install monaco-editor -S
|
|||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { createApp } from "vue";
|
import { createApp } from "vue";
|
||||||
import EditorWorker from "monaco-editor/editor/editor.worker.js?worker";
|
import EditorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker";
|
||||||
import CssWorker from "monaco-editor/language/css/css.worker.js?worker";
|
import CssWorker from "monaco-editor/esm/vs/language/css/css.worker?worker";
|
||||||
import HtmlWorker from "monaco-editor/language/html/html.worker.js?worker";
|
import HtmlWorker from "monaco-editor/esm/vs/language/html/html.worker?worker";
|
||||||
import JsonWorker from "monaco-editor/language/json/json.worker.js?worker";
|
import JsonWorker from "monaco-editor/esm/vs/language/json/json.worker?worker";
|
||||||
import TsWorker from "monaco-editor/language/typescript/ts.worker.js?worker";
|
import TsWorker from "monaco-editor/esm/vs/language/typescript/ts.worker?worker";
|
||||||
|
|
||||||
import editorPlugin from "@tmagic/editor";
|
import editorPlugin from "@tmagic/editor";
|
||||||
import MagicElementPlusAdapter from "@tmagic/element-plus-adapter";
|
import MagicElementPlusAdapter from "@tmagic/element-plus-adapter";
|
||||||
|
|||||||
@ -43,7 +43,6 @@ new DataSource(options: DataSourceOptions)
|
|||||||
| `methods` | `CodeBlockContent[]` | 自定义方法配置 |
|
| `methods` | `CodeBlockContent[]` | 自定义方法配置 |
|
||||||
| `data` | `any` | 当前数据 |
|
| `data` | `any` | 当前数据 |
|
||||||
| `isInit` | `boolean` | 是否已初始化 |
|
| `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
|
### destroy
|
||||||
|
|
||||||
- **返回:**
|
- **返回:**
|
||||||
|
|||||||
@ -25,7 +25,6 @@ new DataSourceManager(options: DataSourceManagerOptions)
|
|||||||
| `data` | `DataSourceManagerData` | 所有数据源的数据 |
|
| `data` | `DataSourceManagerData` | 所有数据源的数据 |
|
||||||
| `initialData` | `DataSourceManagerData` | 初始化数据 |
|
| `initialData` | `DataSourceManagerData` | 初始化数据 |
|
||||||
| `useMock` | `boolean` | 是否使用 Mock 数据 |
|
| `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
|
### 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
|
### compiledNode
|
||||||
|
|
||||||
@ -347,7 +325,6 @@ DataSourceManager 继承自 EventEmitter,支持以下事件:
|
|||||||
|--------|------|----------|
|
|--------|------|----------|
|
||||||
| `change` | 单个数据源数据变化 | `(dsId: string, changeEvent: ChangeEvent)` |
|
| `change` | 单个数据源数据变化 | `(dsId: string, changeEvent: ChangeEvent)` |
|
||||||
| `init` | 所有数据源初始化完成;现代分支携带 `(data, errors)`,旧 Promise.all 分支为 `(this.data)` | `(data, errors?)` |
|
| `init` | 所有数据源初始化完成;现代分支携带 `(data, errors)`,旧 Promise.all 分支为 `(this.data)` | `(data, errors?)` |
|
||||||
| `mounted` | 由 runtime 在顶层组件渲染完成后触发,manager 收到后统一执行各数据源的 `mounted` | 无 |
|
|
||||||
| `registered-all` | 所有数据源注册完成 | 无 |
|
| `registered-all` | 所有数据源注册完成 | 无 |
|
||||||
| `update-data` | 由 `createDataSourceManager` 在数据变化后发出,用于通知节点重新渲染 | `(newNodes: MNode[], sourceId: string, changeEvent: ChangeEvent, pageId: Id)` |
|
| `update-data` | 由 `createDataSourceManager` 在数据变化后发出,用于通知节点重新渲染 | `(newNodes: MNode[], sourceId: string, changeEvent: ChangeEvent, pageId: Id)` |
|
||||||
|
|
||||||
|
|||||||
@ -22,7 +22,7 @@ export default {
|
|||||||
['^(@tencent)(/.*|$)'],
|
['^(@tencent)(/.*|$)'],
|
||||||
['^(@tmagic)(/.*|$)'],
|
['^(@tmagic)(/.*|$)'],
|
||||||
// Internal packages.
|
// Internal packages.
|
||||||
['^(@|src|editor-page|@editor|@form|@data-source)(/.*|$)'],
|
['^(@|src|editor-page|@editor|@data-source)(/.*|$)'],
|
||||||
// Side effect imports.
|
// Side effect imports.
|
||||||
['^\\u0000'],
|
['^\\u0000'],
|
||||||
// Parent imports. Put `..` last.
|
// Parent imports. Put `..` last.
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@tmagic/eslint-config",
|
"name": "@tmagic/eslint-config",
|
||||||
"version": "0.1.1",
|
"version": "0.1.0",
|
||||||
"main": "index.mjs",
|
"main": "index.mjs",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"repository": {
|
"repository": {
|
||||||
@ -10,17 +10,17 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@typescript-eslint/parser": "^8.58.0",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.58.0",
|
||||||
"@stylistic/eslint-plugin": "^5.10.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-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-import": "^2.32.0",
|
"eslint-plugin-import": "^2.32.0",
|
||||||
"eslint-plugin-prettier": "^5.5.6",
|
"eslint-plugin-simple-import-sort": "^13.0.0",
|
||||||
"eslint-plugin-simple-import-sort": "^14.0.0",
|
"eslint-plugin-vue": "^10.8.0",
|
||||||
"eslint-plugin-vue": "^10.10.0",
|
"vue-eslint-parser": "^10.4.0",
|
||||||
"globals": "^17.9.0",
|
"eslint-plugin-prettier": "^5.5.5",
|
||||||
"typescript-eslint": "^8.66.0",
|
"globals": "^17.4.0",
|
||||||
"vue-eslint-parser": "^10.4.1"
|
"typescript-eslint": "^8.58.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"eslint": ">=10.0.0",
|
"eslint": ">=10.0.0",
|
||||||
|
|||||||
53
package.json
53
package.json
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.0-beta.28",
|
"version": "1.8.0-beta.21",
|
||||||
"name": "tmagic",
|
"name": "tmagic",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "pnpm@11.21.0",
|
"packageManager": "pnpm@10.33.4",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"bootstrap": "pnpm i && pnpm build",
|
"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:modules": "rimraf node_modules **/node_modules **/**/node_modules",
|
||||||
"clean:all": "pnpm clean:top && pnpm clean:modules",
|
"clean:all": "pnpm clean:top && pnpm clean:modules",
|
||||||
"lint": "eslint --cache .",
|
"lint": "eslint --cache .",
|
||||||
@ -15,8 +15,7 @@
|
|||||||
"pg": "pnpm playground",
|
"pg": "pnpm playground",
|
||||||
"playground:react": "pnpm --filter \"runtime-react\" build:libs && pnpm --filter \"runtime-react\" --filter \"tmagic-playground\" dev:react",
|
"playground:react": "pnpm --filter \"runtime-react\" build:libs && pnpm --filter \"runtime-react\" --filter \"tmagic-playground\" dev:react",
|
||||||
"pg:react": "pnpm playground:react",
|
"pg:react": "pnpm playground:react",
|
||||||
"build": "pnpm build:dts && node scripts/build.mjs && pnpm check:headless",
|
"build": "pnpm build:dts && node scripts/build.mjs",
|
||||||
"check:headless": "node scripts/check-headless-dist.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",
|
"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",
|
"check:type": "node scripts/check-type.mjs",
|
||||||
"build:playground": "pnpm --filter \"runtime-vue\" build && pnpm --filter \"tmagic-playground\" build",
|
"build:playground": "pnpm --filter \"runtime-vue\" build && pnpm --filter \"tmagic-playground\" build",
|
||||||
@ -25,7 +24,7 @@
|
|||||||
"docs:build": "vitepress build docs",
|
"docs:build": "vitepress build docs",
|
||||||
"reinstall": "pnpm clean:all && pnpm bootstrap",
|
"reinstall": "pnpm clean:all && pnpm bootstrap",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"coverage": "vitest run --coverage && node scripts/check-coverage.mjs",
|
"coverage": "vitest run --coverage",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"changelog": "conventional-changelog -p angular -i CHANGELOG.md -s",
|
"changelog": "conventional-changelog -p angular -i CHANGELOG.md -s",
|
||||||
"release": "node scripts/release.mjs"
|
"release": "node scripts/release.mjs"
|
||||||
@ -41,44 +40,44 @@
|
|||||||
"url": "https://github.com/Tencent/tmagic-editor.git"
|
"url": "https://github.com/Tencent/tmagic-editor.git"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@commitlint/cli": "^21.2.1",
|
"@commitlint/cli": "^20.5.3",
|
||||||
"@commitlint/config-conventional": "^21.2.0",
|
"@commitlint/config-conventional": "^20.5.3",
|
||||||
"@tmagic/eslint-config": "workspace:*",
|
"@tmagic/eslint-config": "workspace:*",
|
||||||
"@types/node": "26.1.2",
|
"@types/node": "24.0.10",
|
||||||
"@vitejs/plugin-vue": "^6.0.8",
|
"@vitejs/plugin-vue": "^6.0.8",
|
||||||
"@vitest/coverage-v8": "^4.1.10",
|
"@vitest/coverage-v8": "^4.1.10",
|
||||||
"@vue/compiler-sfc": "catalog:",
|
"@vue/compiler-sfc": "catalog:",
|
||||||
"c8": "^12.0.0",
|
"c8": "^10.1.3",
|
||||||
"commitizen": "^4.3.2",
|
"commitizen": "^4.3.2",
|
||||||
"conventional-changelog-cli": "^5.0.0",
|
"conventional-changelog-cli": "^5.0.0",
|
||||||
"cosmiconfig": "^10.0.0",
|
"cosmiconfig": "^9.0.2",
|
||||||
"cz-conventional-changelog": "^3.3.0",
|
"cz-conventional-changelog": "^3.3.0",
|
||||||
"element-plus": "catalog:",
|
"element-plus": "^2.14.3",
|
||||||
"enquirer": "^2.4.1",
|
"enquirer": "^2.4.1",
|
||||||
"eslint": "^10.8.1",
|
"eslint": "^10.7.0",
|
||||||
"execa": "^10.0.1",
|
"execa": "^9.6.1",
|
||||||
"happy-dom": "^20.11.2",
|
"happy-dom": "^20.10.6",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jsdom": "^30.0.1",
|
"jsdom": "^27.4.0",
|
||||||
"lint-staged": "^17.3.0",
|
"lint-staged": "^16.4.0",
|
||||||
"minimist": "^1.2.8",
|
"minimist": "^1.2.8",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"prettier": "^3.9.6",
|
"prettier": "^3.9.5",
|
||||||
"recast": "^0.23.20",
|
"recast": "^0.23.12",
|
||||||
"rimraf": "^6.1.3",
|
"rimraf": "^3.0.2",
|
||||||
"rolldown": "^1.2.3",
|
"rolldown": "^1.2.0",
|
||||||
"rolldown-plugin-dts": "^0.28.1",
|
"rolldown-plugin-dts": "^0.26.0",
|
||||||
"sass-embedded": "^1.100.0",
|
"sass-embedded": "^1.100.0",
|
||||||
"semver": "^7.8.5",
|
"semver": "^7.8.5",
|
||||||
"serialize-javascript": "^7.1.0",
|
"serialize-javascript": "^7.0.7",
|
||||||
"shx": "^0.4.0",
|
"shx": "^0.3.4",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"vite": "catalog:",
|
"vite": "catalog:",
|
||||||
"vitepress": "2.0.0-alpha.19",
|
"vitepress": "2.0.0-alpha.18",
|
||||||
"vitest": "^4.1.10",
|
"vitest": "^4.1.10",
|
||||||
"vue": "^3.5.41",
|
"vue": "catalog:",
|
||||||
"vue-tsc": "^3.3.9"
|
"vue-tsc": "^3.3.7"
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"commitizen": {
|
"commitizen": {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
import { cli } from '../lib/index.js';
|
const { cli } = require('../lib');
|
||||||
|
|
||||||
cli({
|
cli({
|
||||||
source: process.cwd(),
|
source: process.cwd(),
|
||||||
|
|||||||
@ -1,12 +1,11 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.0-beta.28",
|
"version": "1.8.0-beta.21",
|
||||||
"name": "@tmagic/cli",
|
"name": "@tmagic/cli",
|
||||||
"type": "module",
|
|
||||||
"main": "lib/index.js",
|
"main": "lib/index.js",
|
||||||
"types": "lib/index.d.ts",
|
"types": "lib/index.d.ts",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "pnpm clean && rolldown -c rolldown.config.mjs",
|
"build": "pnpm clean && tsc -b tsconfig.build.json",
|
||||||
"clean": "rimraf lib *.tsbuildinfo",
|
"clean": "rimraf lib *.tsbuildinfo",
|
||||||
"check:type": "tsc --noEmit --project tsconfig.build.json"
|
"check:type": "tsc --noEmit --project tsconfig.build.json"
|
||||||
},
|
},
|
||||||
@ -27,18 +26,25 @@
|
|||||||
"url": "https://github.com/Tencent/tmagic-editor.git"
|
"url": "https://github.com/Tencent/tmagic-editor.git"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cac": "^7.0.0",
|
"cac": "^6.7.14",
|
||||||
"chokidar": "^5.0.0",
|
"chokidar": "^3.6.0",
|
||||||
"fs-extra": "^11.4.0",
|
"esbuild": "^0.21.5",
|
||||||
|
"fs-extra": "^11.2.0",
|
||||||
"merge-options": "^3.0.4",
|
"merge-options": "^3.0.4",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"recast": "^0.23.19",
|
"recast": "^0.23.11",
|
||||||
"typescript": "catalog:"
|
"tslib": "^2.8.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/fs-extra": "^11.0.4",
|
"@types/fs-extra": "^11.0.4",
|
||||||
"@types/node": "^26.1.2",
|
"@types/node": "^24.0.10"
|
||||||
"rolldown": "^1.2.2",
|
},
|
||||||
"rolldown-plugin-dts": "^0.28.0"
|
"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 { prepareEntryFile } from './utils/prepareEntryFile';
|
||||||
import { resolveAppPackages } from './utils/resolveAppPackages';
|
import { resolveAppPackages } from './utils/resolveAppPackages';
|
||||||
import { require } from './require';
|
|
||||||
import { ModuleMainFilePath, UserConfig } from './types';
|
import { ModuleMainFilePath, UserConfig } from './types';
|
||||||
|
|
||||||
export default class Core {
|
export default class Core {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
public version = require('../package.json').version;
|
public version = require('../package.json').version;
|
||||||
|
|
||||||
public options: UserConfig;
|
public options: UserConfig;
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import { allowTs } from './utils/allowTs';
|
|||||||
import { error } from './utils/logger';
|
import { error } from './utils/logger';
|
||||||
import { scripts } from './commands';
|
import { scripts } from './commands';
|
||||||
import App from './Core';
|
import App from './Core';
|
||||||
import { require } from './require';
|
|
||||||
import { UserConfig } from './types';
|
import { UserConfig } from './types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -30,7 +29,7 @@ export const cli = (defaultAppConfig: UserConfig): void => {
|
|||||||
const program = cac('tmagic');
|
const program = cac('tmagic');
|
||||||
|
|
||||||
// display core version and cli version
|
// display core version and cli version
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
const versionCli = require('../package.json').version;
|
const versionCli = require('../package.json').version;
|
||||||
program.version(`tmagic/cli@${versionCli}`);
|
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 fs from 'fs-extra';
|
||||||
import ts from 'typescript';
|
|
||||||
|
|
||||||
import { require } from '../require';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transform a ts file to cjs code
|
* Transform a ts file to cjs code
|
||||||
*/
|
*/
|
||||||
export const transformTsFileToCodeSync = (filename: string): string =>
|
export const transformTsFileToCodeSync = (filename: string): string =>
|
||||||
ts.transpileModule(fs.readFileSync(filename).toString(), {
|
transformSync(fs.readFileSync(filename).toString(), {
|
||||||
compilerOptions: {
|
format: 'cjs',
|
||||||
inlineSourceMap: true,
|
loader: 'ts',
|
||||||
inlineSources: true,
|
sourcefile: filename,
|
||||||
module: ts.ModuleKind.CommonJS,
|
sourcemap: 'inline',
|
||||||
target: ts.ScriptTarget.ES2022,
|
target: 'node18',
|
||||||
},
|
}).code;
|
||||||
fileName: filename,
|
|
||||||
}).outputText;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Globally allow ts files to be loaded via `require()`
|
* Globally allow ts files to be loaded via `require()`
|
||||||
*/
|
*/
|
||||||
export const allowTs = (): void => {
|
export const allowTs = (): void => {
|
||||||
require.extensions['.ts'] = (m: any, filename: string) => {
|
require.extensions['.ts'] = (m: any, filename) => {
|
||||||
m._compile(transformTsFileToCodeSync(filename), filename);
|
m._compile(transformTsFileToCodeSync(filename), filename);
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
import { require } from '../require';
|
|
||||||
import { UserConfig, UserConfigLoader } from '../types';
|
import { UserConfig, UserConfigLoader } from '../types';
|
||||||
|
|
||||||
export const isPlainObject = <T extends Record<any, any> = Record<any, any>>(val: unknown): val is T =>
|
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');
|
isPlainObject(mod) && !!mod.__esModule && Object.prototype.hasOwnProperty.call(mod, 'default');
|
||||||
|
|
||||||
export const loadUserConfigCjs: UserConfigLoader = async (userConfigPath) => {
|
export const loadUserConfigCjs: UserConfigLoader = async (userConfigPath) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
const required = require(userConfigPath);
|
const required = require(userConfigPath);
|
||||||
return hasExportDefault(required) ? required.default : required;
|
return hasExportDefault(required) ? required.default : required;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import * as recast from 'recast';
|
import * as recast from 'recast';
|
||||||
|
|
||||||
import type App from '../Core';
|
import type App from '../Core';
|
||||||
import { require } from '../require';
|
|
||||||
import { EntryType } from '../types';
|
import { EntryType } from '../types';
|
||||||
|
|
||||||
export const prepareEntryFile = async (app: App) => {
|
export const prepareEntryFile = async (app: App) => {
|
||||||
@ -139,6 +138,7 @@ export const generateContent = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const prettyCode = (code: string) =>
|
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') }), {
|
recast.prettyPrint(recast.parse(code.replace(/\\/g, '/'), { parser: require('recast/parsers/typescript') }), {
|
||||||
tabWidth: 2,
|
tabWidth: 2,
|
||||||
trailingComma: true,
|
trailingComma: true,
|
||||||
|
|||||||
@ -2,11 +2,10 @@ import { execSync } from 'node:child_process';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { exit } from 'node:process';
|
import { exit } from 'node:process';
|
||||||
|
|
||||||
import fs from 'fs-extra';
|
import fs, { existsSync } from 'fs-extra';
|
||||||
import * as recast from 'recast';
|
import * as recast from 'recast';
|
||||||
|
|
||||||
import type App from '../Core';
|
import type App from '../Core';
|
||||||
import { require } from '../require';
|
|
||||||
import { EntryType, ModuleMainFilePath, NpmConfig, PackageType } from '../types';
|
import { EntryType, ModuleMainFilePath, NpmConfig, PackageType } from '../types';
|
||||||
|
|
||||||
import { backupLock, backupPackageJson, restoreLock, restorePackageJson } from './backupPackageFile';
|
import { backupLock, backupPackageJson, restoreLock, restorePackageJson } from './backupPackageFile';
|
||||||
@ -128,7 +127,7 @@ const typeAssertion = function ({
|
|||||||
|
|
||||||
if (isFile(defaultFile)) {
|
if (isFile(defaultFile)) {
|
||||||
const defaultCode = fs.readFileSync(defaultFile, { encoding: 'utf-8', flag: 'r' });
|
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') });
|
const ast = recast.parse(defaultCode, { parser: require('recast/parsers/typescript') });
|
||||||
if (
|
if (
|
||||||
isDatasource(
|
isDatasource(
|
||||||
@ -295,7 +294,7 @@ const getComponentPackageImports = function ({
|
|||||||
if (propertyMatch) {
|
if (propertyMatch) {
|
||||||
let file = getIndexPath(path.resolve(path.dirname(indexPath), propertyMatch.source.value));
|
let file = getIndexPath(path.resolve(path.dirname(indexPath), propertyMatch.source.value));
|
||||||
|
|
||||||
if (!fs.existsSync(file)) {
|
if (!existsSync(file)) {
|
||||||
file = propertyMatch.source.value;
|
file = propertyMatch.source.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -519,7 +518,7 @@ const setPackages = (packages: ModuleMainFilePath, app: App, packagePath: string
|
|||||||
.replace('\n', '');
|
.replace('\n', '');
|
||||||
|
|
||||||
const indexCode = fs.readFileSync(indexPath, { encoding: 'utf-8', flag: 'r' });
|
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 ast: Ast = recast.parse(indexCode, { parser: require('recast/parsers/typescript') });
|
||||||
const result = typeAssertion({ ast, indexPath, componentFileAffix, datasoucreSuperClass });
|
const result = typeAssertion({ ast, indexPath, componentFileAffix, datasoucreSuperClass });
|
||||||
|
|
||||||
|
|||||||
@ -2,10 +2,12 @@
|
|||||||
"extends": "../../tsconfig.json",
|
"extends": "../../tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "node16",
|
||||||
"module": "ESNext",
|
"module": "node16",
|
||||||
"rootDir": "./src",
|
"rootDir": "./src",
|
||||||
"outDir": "./lib",
|
"outDir": "./lib",
|
||||||
|
"declaration": true,
|
||||||
|
|
||||||
"types": ["node"],
|
"types": ["node"],
|
||||||
},
|
},
|
||||||
"include": ["./src"],
|
"include": ["./src"],
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.0-beta.28",
|
"version": "1.8.0-beta.21",
|
||||||
"name": "@tmagic/core",
|
"name": "@tmagic/core",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
@ -39,11 +39,11 @@
|
|||||||
"@tmagic/schema": "workspace:*",
|
"@tmagic/schema": "workspace:*",
|
||||||
"@tmagic/utils": "workspace:*",
|
"@tmagic/utils": "workspace:*",
|
||||||
"events": "^3.3.0",
|
"events": "^3.3.0",
|
||||||
"lodash-es": "^4.18.1"
|
"lodash-es": "^4.17.21"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/events": "^3.0.3",
|
"@types/events": "^3.0.3",
|
||||||
"@types/lodash-es": "^4.17.12"
|
"@types/lodash-es": "^4.17.4"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "catalog:"
|
"typescript": "catalog:"
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.0-beta.28",
|
"version": "1.8.0-beta.21",
|
||||||
"name": "@tmagic/data-source",
|
"name": "@tmagic/data-source",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
@ -32,9 +32,9 @@
|
|||||||
"data-source"
|
"data-source"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"deep-state-observer": "^5.5.17",
|
"deep-state-observer": "^5.5.13",
|
||||||
"events": "^3.3.0",
|
"events": "^3.3.0",
|
||||||
"lodash-es": "^4.18.1"
|
"lodash-es": "^4.17.21"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@tmagic/core": "workspace:*",
|
"@tmagic/core": "workspace:*",
|
||||||
@ -47,6 +47,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/events": "^3.0.3",
|
"@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()) {
|
for (let config = list.shift(); config; config = list.shift()) {
|
||||||
const ds = app.addDataSource(config);
|
const ds = app.addDataSource(config);
|
||||||
if (ds) {
|
if (ds) {
|
||||||
app.initAndMount(ds);
|
app.init(ds);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -84,8 +84,6 @@ class DataSourceManager extends EventEmitter {
|
|||||||
public data: DataSourceManagerData = {};
|
public data: DataSourceManagerData = {};
|
||||||
public initialData: DataSourceManagerData = {};
|
public initialData: DataSourceManagerData = {};
|
||||||
public useMock?: boolean = false;
|
public useMock?: boolean = false;
|
||||||
/** 页面是否已经渲染完成 */
|
|
||||||
public isMounted = false;
|
|
||||||
|
|
||||||
constructor({ app, useMock, initialData }: DataSourceManagerOptions) {
|
constructor({ app, useMock, initialData }: DataSourceManagerOptions) {
|
||||||
super();
|
super();
|
||||||
@ -111,12 +109,6 @@ class DataSourceManager extends EventEmitter {
|
|||||||
this.callDsInit();
|
this.callDsInit();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 由runtime在顶层组件渲染完成后触发
|
|
||||||
this.on('mounted', () => {
|
|
||||||
this.isMounted = true;
|
|
||||||
this.callDsMounted();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async init(ds: DataSource) {
|
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) {
|
public get(id: string) {
|
||||||
return this.dataSourceMap.get(id);
|
return this.dataSourceMap.get(id);
|
||||||
}
|
}
|
||||||
@ -250,7 +219,7 @@ class DataSourceManager extends EventEmitter {
|
|||||||
this.addDataSource(cloneDeep(schema));
|
this.addDataSource(cloneDeep(schema));
|
||||||
const newDs = this.get(schema.id);
|
const newDs = this.get(schema.id);
|
||||||
if (newDs) {
|
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;
|
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 {
|
export default class DataSource<T extends DataSourceSchema = DataSourceSchema> extends EventEmitter {
|
||||||
public isInit = false;
|
public isInit = false;
|
||||||
public isMounted = false;
|
|
||||||
|
|
||||||
/** @tmagic/core 实例 */
|
/** @tmagic/core 实例 */
|
||||||
public app: TMagicApp;
|
public app: TMagicApp;
|
||||||
@ -153,13 +152,6 @@ export default class DataSource<T extends DataSourceSchema = DataSourceSchema> e
|
|||||||
this.isInit = true;
|
this.isInit = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 页面渲染后执行,由 DataSourceManager 监听到 mounted 事件后统一调用
|
|
||||||
*/
|
|
||||||
public async mounted() {
|
|
||||||
this.isMounted = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public destroy() {
|
public destroy() {
|
||||||
this.#fields = [];
|
this.#fields = [];
|
||||||
this.removeAllListeners();
|
this.removeAllListeners();
|
||||||
|
|||||||
@ -38,25 +38,6 @@ describe('DataSource', () => {
|
|||||||
|
|
||||||
expect(ds.isInit).toBeTruthy();
|
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', () => {
|
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 边界', () => {
|
describe('DataSourceManager - addDataSource 边界', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
DataSourceManager.clearDataSourceClass();
|
DataSourceManager.clearDataSourceClass();
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.0-beta.28",
|
"version": "1.8.0-beta.21",
|
||||||
"name": "@tmagic/dep",
|
"name": "@tmagic/dep",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.0-beta.28",
|
"version": "1.8.0-beta.21",
|
||||||
"name": "@tmagic/design",
|
"name": "@tmagic/design",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": [
|
"sideEffects": [
|
||||||
@ -16,11 +16,6 @@
|
|||||||
"import": "./dist/es/index.js",
|
"import": "./dist/es/index.js",
|
||||||
"require": "./dist/tmagic-design.umd.cjs"
|
"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": [
|
"files": [
|
||||||
|
|||||||
@ -1,11 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<!-- ElTooltip 的 ElOnlyChild 要求默认插槽至少有一个真实节点;v-if 为 false 时插槽是 Comment,会报 no valid child node found。 -->
|
<component class="tmagic-design-tooltip" :is="uiComponent" v-bind="uiProps">
|
||||||
<component
|
|
||||||
v-if="isLegitTooltipTrigger($slots.default?.())"
|
|
||||||
class="tmagic-design-tooltip"
|
|
||||||
:is="uiComponent"
|
|
||||||
v-bind="uiProps"
|
|
||||||
>
|
|
||||||
<template #content>
|
<template #content>
|
||||||
<slot name="content"></slot>
|
<slot name="content"></slot>
|
||||||
</template>
|
</template>
|
||||||
@ -14,7 +8,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Comment, computed, Fragment, Text, type VNode } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
|
||||||
import { getDesignConfig } from './config';
|
import { getDesignConfig } from './config';
|
||||||
import type { TooltipProps } from './types';
|
import type { TooltipProps } from './types';
|
||||||
@ -30,30 +24,4 @@ const ui = getDesignConfig('components')?.tooltip;
|
|||||||
const uiComponent = ui?.component || 'el-tooltip';
|
const uiComponent = ui?.component || 'el-tooltip';
|
||||||
|
|
||||||
const uiProps = computed<TooltipProps>(() => ui?.props(props) || props);
|
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>
|
</script>
|
||||||
|
|||||||
@ -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';
|
|
||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.0-beta.28",
|
"version": "1.8.0-beta.21",
|
||||||
"name": "@tmagic/editor",
|
"name": "@tmagic/editor",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": [
|
"sideEffects": [
|
||||||
@ -17,11 +17,6 @@
|
|||||||
"import": "./dist/es/index.js",
|
"import": "./dist/es/index.js",
|
||||||
"require": "./dist/tmagic-editor.umd.cjs"
|
"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": {
|
"./dist/style.css": {
|
||||||
"import": "./dist/style.css",
|
"import": "./dist/style.css",
|
||||||
"require": "./dist/style.css"
|
"require": "./dist/style.css"
|
||||||
@ -64,17 +59,17 @@
|
|||||||
"events": "^3.3.0",
|
"events": "^3.3.0",
|
||||||
"gesto": "^1.19.4",
|
"gesto": "^1.19.4",
|
||||||
"keycon": "^1.4.0",
|
"keycon": "^1.4.0",
|
||||||
"lodash-es": "^4.18.1",
|
"lodash-es": "^4.17.21",
|
||||||
"moveable": "^0.53.0",
|
"moveable": "^0.53.0",
|
||||||
"serialize-javascript": "^7.0.7",
|
"serialize-javascript": "^7.0.0",
|
||||||
"sortablejs": "^1.15.7"
|
"sortablejs": "^1.15.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/events": "^3.0.3",
|
"@types/events": "^3.0.3",
|
||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.4",
|
||||||
"@types/serialize-javascript": "^5.0.4",
|
"@types/serialize-javascript": "^5.0.4",
|
||||||
"@types/sortablejs": "^1.15.9",
|
"@types/sortablejs": "^1.15.9",
|
||||||
"@vue/test-utils": "^2.4.11"
|
"@vue/test-utils": "^2.4.6"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@tmagic/core": "workspace:*",
|
"@tmagic/core": "workspace:*",
|
||||||
|
|||||||
@ -101,6 +101,7 @@
|
|||||||
<template #props-panel>
|
<template #props-panel>
|
||||||
<slot name="props-panel">
|
<slot name="props-panel">
|
||||||
<PropsPanel
|
<PropsPanel
|
||||||
|
:extend-state="extendFormState"
|
||||||
:disabled-show-src="disabledShowSrc"
|
:disabled-show-src="disabledShowSrc"
|
||||||
@mounted="propsPanelMountedHandler"
|
@mounted="propsPanelMountedHandler"
|
||||||
@unmounted="propsPanelUnmountedHandler"
|
@unmounted="propsPanelUnmountedHandler"
|
||||||
@ -135,13 +136,11 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
|
|
||||||
import { computed, provide } from 'vue';
|
import { computed, provide, ref } from 'vue';
|
||||||
|
|
||||||
import type { MApp } from '@tmagic/core';
|
import type { MApp } from '@tmagic/core';
|
||||||
import { M_THEME_KEY } from '@tmagic/design';
|
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 Framework from './layouts/Framework.vue';
|
||||||
import TMagicNavMenu from './layouts/NavMenu.vue';
|
import TMagicNavMenu from './layouts/NavMenu.vue';
|
||||||
import FormPanel from './layouts/props-panel/FormPanel.vue';
|
import FormPanel from './layouts/props-panel/FormPanel.vue';
|
||||||
@ -231,6 +230,8 @@ const stageOptions: StageOptions = {
|
|||||||
|
|
||||||
stageOverlayService.set('stageOptions', stageOptions);
|
stageOverlayService.set('stageOptions', stageOptions);
|
||||||
|
|
||||||
|
const propsPanelRef = ref<InstanceType<typeof FormPanel> | null>(null);
|
||||||
|
|
||||||
provide('services', services);
|
provide('services', services);
|
||||||
|
|
||||||
provide('codeOptions', props.codeOptions);
|
provide('codeOptions', props.codeOptions);
|
||||||
@ -238,16 +239,11 @@ provide('stageOptions', stageOptions);
|
|||||||
/** 是否启用「属性配置表单校验」联动能力,供 PropsPanel / FormPanel 判断校验失败时是否仍更新节点并记录错误 */
|
/** 是否启用「属性配置表单校验」联动能力,供 PropsPanel / FormPanel 判断校验失败时是否仍更新节点并记录错误 */
|
||||||
provide(ENABLE_PROPS_FORM_VALIDATE, props.enablePropsFormValidate ?? false);
|
provide(ENABLE_PROPS_FORM_VALIDATE, props.enablePropsFormValidate ?? false);
|
||||||
/**
|
/**
|
||||||
* 编辑器注入给整棵表单树的业务上下文(`services` / `stage`),属性面板、对比表单、
|
* 把顶层 `extendFormState` 提供给非 PropsPanel 链路上的组件使用(例如历史差异对话框 HistoryDiffDialog
|
||||||
* 侧边栏里的嵌套 MForm 都通过 inject 自动继承。
|
* 内部的 CompareForm)。这样所有依赖业务上下文的表单 filterFunction 都能拿到一致的扩展状态,
|
||||||
*
|
* 与 PropsPanel 通过 `:extend-state` 显式传入的方式保持等价。
|
||||||
* 业务方要追加自己的字段,在 `<MEditor>` 外层再 `provide(FORM_CONTEXT_KEY, computed(...))`
|
|
||||||
* 即可,配置回调统一通过 `mForm.xxx` 读取。
|
|
||||||
*/
|
*/
|
||||||
provide(
|
provide('extendFormState', props.extendFormState);
|
||||||
FORM_CONTEXT_KEY,
|
|
||||||
useEditorFormContext(() => services),
|
|
||||||
);
|
|
||||||
|
|
||||||
// 用 computed 包一层再 provide,否则传下去的是 provide 那一刻的值快照,
|
// 用 computed 包一层再 provide,否则传下去的是 provide 那一刻的值快照,
|
||||||
// props.isLargeStageContainer 后续变化不会同步到子孙(如 @tmagic/design/ColorPicker)。
|
// props.isLargeStageContainer 后续变化不会同步到子孙(如 @tmagic/design/ColorPicker)。
|
||||||
@ -257,6 +253,12 @@ provide(
|
|||||||
'isLargeStageContainer',
|
'isLargeStageContainer',
|
||||||
computed(() => props.isLargeStageContainer),
|
computed(() => props.isLargeStageContainer),
|
||||||
);
|
);
|
||||||
|
/**
|
||||||
|
* 提供 PropsPanel 主属性表单的 formState getter,供历史差异弹窗复用,
|
||||||
|
* 让 CompareForm 与 PropsPanel 的 filterFunction 上下文保持一致。
|
||||||
|
*/
|
||||||
|
provide('getPropsPanelFormState', () => propsPanelRef.value?.configForm?.formState);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 把历史记录面板的自定义扩展 tab 提供给深层的 HistoryListPanel(它挂在 NavMenu 中,
|
* 把历史记录面板的自定义扩展 tab 提供给深层的 HistoryListPanel(它挂在 NavMenu 中,
|
||||||
* 以 markRaw component 形式渲染,无法直接通过 props 透传)。业务方可借此在历史记录
|
* 以 markRaw component 形式渲染,无法直接通过 props 透传)。业务方可借此在历史记录
|
||||||
@ -277,9 +279,11 @@ provide(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const propsPanelMountedHandler = (e: InstanceType<typeof FormPanel>) => {
|
const propsPanelMountedHandler = (e: InstanceType<typeof FormPanel>) => {
|
||||||
|
propsPanelRef.value = e;
|
||||||
emit('props-panel-mounted', e);
|
emit('props-panel-mounted', e);
|
||||||
};
|
};
|
||||||
const propsPanelUnmountedHandler = () => {
|
const propsPanelUnmountedHandler = () => {
|
||||||
|
propsPanelRef.value = null;
|
||||||
emit('props-panel-unmounted');
|
emit('props-panel-unmounted');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -15,13 +15,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, useTemplateRef } from 'vue';
|
import { computed, useTemplateRef } from 'vue';
|
||||||
|
|
||||||
import {
|
import { type ContainerChangeEventData, type FormItemConfig, type FormValue, MForm } from '@tmagic/form';
|
||||||
type ContainerChangeEventData,
|
|
||||||
defineFormItem,
|
|
||||||
type FormItemConfig,
|
|
||||||
type FormValue,
|
|
||||||
MForm,
|
|
||||||
} from '@tmagic/form';
|
|
||||||
|
|
||||||
import type { CodeParamStatement } from '@editor/type';
|
import type { CodeParamStatement } from '@editor/type';
|
||||||
import { error } from '@editor/utils';
|
import { error } from '@editor/utils';
|
||||||
@ -47,13 +41,13 @@ const emit = defineEmits(['change']);
|
|||||||
const formRef = useTemplateRef<InstanceType<typeof MForm>>('form');
|
const formRef = useTemplateRef<InstanceType<typeof MForm>>('form');
|
||||||
|
|
||||||
const getFormConfig = (items: FormItemConfig[] = []) => [
|
const getFormConfig = (items: FormItemConfig[] = []) => [
|
||||||
defineFormItem({
|
{
|
||||||
type: 'fieldset',
|
type: 'fieldset',
|
||||||
items,
|
items,
|
||||||
legend: '参数',
|
legend: '参数',
|
||||||
labelPosition: 'top',
|
labelWidth: '120px',
|
||||||
name: props.name,
|
name: props.name,
|
||||||
}),
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const codeParamsConfig = computed(() =>
|
const codeParamsConfig = computed(() =>
|
||||||
|
|||||||
@ -10,7 +10,7 @@
|
|||||||
:is-compare="true"
|
:is-compare="true"
|
||||||
:disabled="true"
|
:disabled="true"
|
||||||
:label-width="labelWidth"
|
:label-width="labelWidth"
|
||||||
:context="formContext"
|
:extend-state="mergedExtendState"
|
||||||
:show-diff="showDiff"
|
:show-diff="showDiff"
|
||||||
:self-diff-field-types="selfDiffFieldTypes"
|
:self-diff-field-types="selfDiffFieldTypes"
|
||||||
:size="size"
|
:size="size"
|
||||||
@ -47,7 +47,7 @@ const props = withDefaults(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const { config, currentValues, wrapperStyle, formContext, loadConfig, formRef, normalizeCodeBlockValue } =
|
const { config, currentValues, wrapperStyle, mergedExtendState, loadConfig, formRef, normalizeCodeBlockValue } =
|
||||||
useCompareForm(props);
|
useCompareForm(props);
|
||||||
|
|
||||||
const lastValuesProcessed = computed<FormValue>(() => {
|
const lastValuesProcessed = computed<FormValue>(() => {
|
||||||
@ -60,8 +60,9 @@ const lastValuesProcessed = computed<FormValue>(() => {
|
|||||||
/**
|
/**
|
||||||
* `code-select` 字段在历史数据中存在两种"语义为空"的形态:
|
* `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` 会把两者判为不等,从而在历史对比里对每个未配置过钩子的组件
|
* 直接 `isEqual` 会把两者判为不等,从而在历史对比里对每个未配置过钩子的组件
|
||||||
* 都展示一份"差异",体验很糟糕。这里把它们视为相等,跳过对比。
|
* 都展示一份"差异",体验很糟糕。这里把它们视为相等,跳过对比。
|
||||||
|
|||||||
@ -8,7 +8,7 @@
|
|||||||
:init-values="currentValues"
|
:init-values="currentValues"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:label-width="labelWidth"
|
:label-width="labelWidth"
|
||||||
:context="formContext"
|
:extend-state="mergedExtendState"
|
||||||
:size="size"
|
:size="size"
|
||||||
></MForm>
|
></MForm>
|
||||||
</div>
|
</div>
|
||||||
@ -37,10 +37,11 @@ const props = withDefaults(
|
|||||||
category: 'node',
|
category: 'node',
|
||||||
labelWidth: '120px',
|
labelWidth: '120px',
|
||||||
disabled: true,
|
disabled: true,
|
||||||
|
// extendState 的默认值由 useCompareForm 内部兜底(props.extendState ?? ...),此处无需重复提供
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const { config, currentValues, wrapperStyle, formContext, loadConfig, formRef } = useCompareForm(props);
|
const { config, currentValues, wrapperStyle, mergedExtendState, loadConfig, formRef } = useCompareForm(props);
|
||||||
|
|
||||||
defineExpose<{
|
defineExpose<{
|
||||||
form: ShallowRef<InstanceType<typeof MForm> | null>;
|
form: ShallowRef<InstanceType<typeof MForm> | null>;
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import type { InjectionKey } from 'vue';
|
import type { InjectionKey } from 'vue';
|
||||||
|
|
||||||
import type { DataSourceSchema, EventOption, Id, MApp, MNode, MPage, MPageFragment } from '@tmagic/core';
|
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, {
|
import StageCore, {
|
||||||
CONTAINER_HIGHLIGHT_CLASS_NAME,
|
CONTAINER_HIGHLIGHT_CLASS_NAME,
|
||||||
ContainerHighlightType,
|
ContainerHighlightType,
|
||||||
@ -149,6 +149,7 @@ export interface EditorProps {
|
|||||||
beforeDblclick?: (event: MouseEvent) => Promise<boolean | void> | boolean | void;
|
beforeDblclick?: (event: MouseEvent) => Promise<boolean | void> | boolean | void;
|
||||||
/** 组件树节点双击前的钩子函数,返回 false 则阻止默认的双击行为 */
|
/** 组件树节点双击前的钩子函数,返回 false 则阻止默认的双击行为 */
|
||||||
beforeLayerNodeDblclick?: (event: MouseEvent, data: TreeNodeData) => Promise<boolean | void> | boolean | void;
|
beforeLayerNodeDblclick?: (event: MouseEvent, data: TreeNodeData) => Promise<boolean | void> | boolean | void;
|
||||||
|
extendFormState?: (state: FormState) => Record<string, any> | Promise<Record<string, any>>;
|
||||||
/** 历史记录面板的自定义扩展 tab,追加在内置的页面/数据源/代码块 tab 之后 */
|
/** 历史记录面板的自定义扩展 tab,追加在内置的页面/数据源/代码块 tab 之后 */
|
||||||
historyListExtraTabs?: HistoryListExtraTab[];
|
historyListExtraTabs?: HistoryListExtraTab[];
|
||||||
/** 页面顺序拖拽配置参数 */
|
/** 页面顺序拖拽配置参数 */
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<MagicCodeEditor
|
<MagicCodeEditor
|
||||||
|
v-if="!silentMode"
|
||||||
:height="config.height"
|
:height="config.height"
|
||||||
:type="diffMode ? 'diff' : undefined"
|
:type="diffMode ? 'diff' : undefined"
|
||||||
:init-values="diffMode ? (lastValues || {})[name] : model[name]"
|
:init-values="diffMode ? (lastValues || {})[name] : model[name]"
|
||||||
@ -17,9 +18,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed } from 'vue';
|
import { computed, inject } from 'vue';
|
||||||
|
|
||||||
import { type CodeConfig, type FieldProps } from '@tmagic/form';
|
import { type CodeConfig, type FieldProps, FORM_SILENT_MODE_KEY } from '@tmagic/form';
|
||||||
|
|
||||||
import MagicCodeEditor from '@editor/layouts/CodeEditor.vue';
|
import MagicCodeEditor from '@editor/layouts/CodeEditor.vue';
|
||||||
|
|
||||||
@ -27,6 +28,13 @@ defineOptions({
|
|||||||
name: 'MFieldsVsCode',
|
name: 'MFieldsVsCode',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 静默模式(submitForm/validateForm 隐藏挂载)下跳过 monaco 渲染:
|
||||||
|
* 校验由 FormItem 针对 model 中的值完成,与编辑器实例无关;本组件挂载无值副作用
|
||||||
|
* (save 仅由用户操作/编辑器内容变更触发),跳过可省去 monaco worker/model 的无谓实例化。
|
||||||
|
*/
|
||||||
|
const silentMode = inject(FORM_SILENT_MODE_KEY, false);
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
change: [value: string | any];
|
change: [value: string | any];
|
||||||
}>();
|
}>();
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="m-fields-code-select" :class="config.className">
|
<div class="m-fields-code-select" :class="config.className">
|
||||||
|
<TMagicCard :flat="config.flat">
|
||||||
<MContainer
|
<MContainer
|
||||||
:config="codeConfig"
|
:config="codeConfig"
|
||||||
:size="size"
|
:size="size"
|
||||||
@ -9,21 +10,27 @@
|
|||||||
:is-compare="isCompareMode"
|
:is-compare="isCompareMode"
|
||||||
:last-values="lastValues?.[name]"
|
:last-values="lastValues?.[name]"
|
||||||
:model="model[name]"
|
:model="model[name]"
|
||||||
:label-position="config.labelPosition"
|
|
||||||
:label-width="config.labelWidth"
|
|
||||||
@change="changeHandler"
|
@change="changeHandler"
|
||||||
>
|
>
|
||||||
</MContainer>
|
</MContainer>
|
||||||
|
<TMagicButton class="create-button fullWidth" :icon="Plus" :size="size" :disabled="disabled" @click="newHandler()"
|
||||||
|
>添加{{ config.text }}</TMagicButton
|
||||||
|
>
|
||||||
|
</TMagicCard>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, watch } from 'vue';
|
import { computed, watch } from 'vue';
|
||||||
|
import { Plus } from '@element-plus/icons-vue';
|
||||||
|
import { isEmpty } from 'lodash-es';
|
||||||
|
|
||||||
import type { CodeSelectConfig, ContainerChangeEventData, FieldProps } from '@tmagic/form';
|
import { HookCodeType, HookType } from '@tmagic/core';
|
||||||
|
import { TMagicButton, TMagicCard } from '@tmagic/design';
|
||||||
|
import type { CodeSelectConfig, ContainerChangeEventData, FieldProps, GroupListConfig } from '@tmagic/form';
|
||||||
import { MContainer } from '@tmagic/form';
|
import { MContainer } from '@tmagic/form';
|
||||||
|
|
||||||
import { createCodeSelectConfig, normalizeCodeSelectValue } from '@editor/fields/configs/codeSelect';
|
import { useServices } from '@editor/hooks/use-services';
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'MFieldsCodeSelect',
|
name: 'MFieldsCodeSelect',
|
||||||
@ -33,6 +40,8 @@ const emit = defineEmits<{
|
|||||||
change: [v: any, eventData: ContainerChangeEventData];
|
change: [v: any, eventData: ContainerChangeEventData];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const { dataSourceService, codeBlockService } = useServices();
|
||||||
|
|
||||||
const props = withDefaults(defineProps<FieldProps<CodeSelectConfig>>(), {});
|
const props = withDefaults(defineProps<FieldProps<CodeSelectConfig>>(), {});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -49,14 +58,101 @@ const props = withDefaults(defineProps<FieldProps<CodeSelectConfig>>(), {});
|
|||||||
* 仅当存在历史值时才启用对比,避免 lastValues 缺失时退化为「全部新增」的空对比。
|
* 仅当存在历史值时才启用对比,避免 lastValues 缺失时退化为「全部新增」的空对比。
|
||||||
*/
|
*/
|
||||||
const isCompareMode = computed(() => Boolean(props.isCompare && props.lastValues));
|
const isCompareMode = computed(() => Boolean(props.isCompare && props.lastValues));
|
||||||
|
const newHandler = () => {
|
||||||
|
const defaultCode = {
|
||||||
|
codeType: HookCodeType.CODE,
|
||||||
|
codeId: '',
|
||||||
|
};
|
||||||
|
const name = props.config.name || '';
|
||||||
|
const hookData = props.model[name]?.hookData || [];
|
||||||
|
emit('change', defaultCode, {
|
||||||
|
modifyKey: `hookData.${hookData.length}`,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const codeConfig = computed<GroupListConfig>(() => ({
|
||||||
|
type: 'group-list',
|
||||||
|
name: 'hookData',
|
||||||
|
enableToggleMode: false,
|
||||||
|
expandAll: true,
|
||||||
|
addable: () => false,
|
||||||
|
title: (mForm, { model, index }: any) => {
|
||||||
|
if (model.codeType === HookCodeType.DATA_SOURCE_METHOD) {
|
||||||
|
if (Array.isArray(model.codeId)) {
|
||||||
|
if (model.codeId.length < 2) {
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
const codeConfig = computed(() => createCodeSelectConfig(props.config));
|
const ds = dataSourceService.getDataSourceById(model.codeId[0]);
|
||||||
|
return `${ds?.title} / ${model.codeId[1]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.isArray(model.codeId) ? model.codeId.join('/') : index;
|
||||||
|
}
|
||||||
|
|
||||||
|
const codeContent = codeBlockService.getCodeContentById(model.codeId);
|
||||||
|
|
||||||
|
if (codeContent) {
|
||||||
|
return codeContent.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return model.codeId || index;
|
||||||
|
},
|
||||||
|
titlePrefix: props.config.name === undefined ? undefined : String(props.config.name),
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
text: '代码类型',
|
||||||
|
type: 'select',
|
||||||
|
name: 'codeType',
|
||||||
|
labelPosition: 'right',
|
||||||
|
rules: [{ typeMatch: true, trigger: 'change' }],
|
||||||
|
options: [
|
||||||
|
{ value: HookCodeType.CODE, text: '代码块' },
|
||||||
|
{ value: HookCodeType.DATA_SOURCE_METHOD, text: '数据源方法' },
|
||||||
|
],
|
||||||
|
defaultValue: HookCodeType.CODE,
|
||||||
|
onChange: (_mForm, v: HookCodeType, { setModel }) => {
|
||||||
|
if (v === HookCodeType.DATA_SOURCE_METHOD) {
|
||||||
|
setModel('codeId', []);
|
||||||
|
} else {
|
||||||
|
setModel('codeId', '');
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'code-select-col',
|
||||||
|
name: 'codeId',
|
||||||
|
text: '代码块',
|
||||||
|
rules: [{ typeMatch: true, trigger: 'change' }],
|
||||||
|
|
||||||
|
display: (_mForm, { model }) => model.codeType !== HookCodeType.DATA_SOURCE_METHOD,
|
||||||
|
notEditable: () => !codeBlockService.getEditStatus(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'data-source-method-select',
|
||||||
|
name: 'codeId',
|
||||||
|
text: '数据源字段',
|
||||||
|
rules: [{ typeMatch: true, trigger: 'change' }],
|
||||||
|
display: (_mForm, { model }) => model.codeType === HookCodeType.DATA_SOURCE_METHOD,
|
||||||
|
notEditable: () => !dataSourceService.get('editable'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
// 挂载时的归一化由 applyMountValueEffects → code-select 的 effect 完成,这里只兜运行期被置空
|
|
||||||
watch(
|
watch(
|
||||||
() => props.model[props.name],
|
() => props.model[props.name],
|
||||||
() => {
|
(value) => {
|
||||||
normalizeCodeSelectValue(props.model, props.name);
|
// 兼容旧的数据结构
|
||||||
|
if (isEmpty(value)) {
|
||||||
|
// 空值或者空数组
|
||||||
|
props.model[props.name] = {
|
||||||
|
hookType: HookType.CODE,
|
||||||
|
hookData: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
immediate: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -84,7 +84,7 @@ defineOptions({
|
|||||||
|
|
||||||
const mForm = inject<FormState | undefined>('mForm');
|
const mForm = inject<FormState | undefined>('mForm');
|
||||||
const { codeBlockService, uiService } = useServices();
|
const { codeBlockService, uiService } = useServices();
|
||||||
const eventBus = inject<EventBus | null>('eventBus', null);
|
const eventBus = inject<EventBus>('eventBus');
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
change: [v: any, eventData: ContainerChangeEventData];
|
change: [v: any, eventData: ContainerChangeEventData];
|
||||||
}>();
|
}>();
|
||||||
|
|||||||
@ -31,7 +31,6 @@ import { computed } from 'vue';
|
|||||||
|
|
||||||
import { getDesignConfig, TMagicSelect } from '@tmagic/design';
|
import { getDesignConfig, TMagicSelect } from '@tmagic/design';
|
||||||
import type { CondOpSelectConfig, FieldProps } from '@tmagic/form';
|
import type { CondOpSelectConfig, FieldProps } from '@tmagic/form';
|
||||||
import { removeDataSourceFieldPrefix } from '@tmagic/utils';
|
|
||||||
|
|
||||||
import { useServices } from '@editor/hooks/use-services';
|
import { useServices } from '@editor/hooks/use-services';
|
||||||
import { getCondOpOptionsByFieldType, getFieldType } from '@editor/utils';
|
import { getCondOpOptionsByFieldType, getFieldType } from '@editor/utils';
|
||||||
@ -51,8 +50,8 @@ const props = defineProps<FieldProps<CondOpSelectConfig>>();
|
|||||||
const optionComponent = getDesignConfig('components')?.option;
|
const optionComponent = getDesignConfig('components')?.option;
|
||||||
|
|
||||||
const options = computed(() => {
|
const options = computed(() => {
|
||||||
const [id, ...fieldNames] = [...(props.config.parentFields || []), ...(props.model.field || [])];
|
const [id, ...fieldNames] = [...(props.config.parentFields || []), ...props.model.field];
|
||||||
const ds = id ? dataSourceService.getDataSourceById(removeDataSourceFieldPrefix(id)) : undefined;
|
const ds = dataSourceService.getDataSourceById(id);
|
||||||
const type = getFieldType(ds, fieldNames);
|
const type = getFieldType(ds, fieldNames);
|
||||||
return getCondOpOptionsByFieldType(type);
|
return getCondOpOptionsByFieldType(type);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -124,7 +124,7 @@ const optionComponent = getDesignConfig('components')?.option;
|
|||||||
|
|
||||||
const { dataSourceService, uiService } = useServices();
|
const { dataSourceService, uiService } = useServices();
|
||||||
const mForm = inject<FormState | undefined>('mForm');
|
const mForm = inject<FormState | undefined>('mForm');
|
||||||
const eventBus = inject<EventBus | null>('eventBus', null);
|
const eventBus = inject<EventBus>('eventBus');
|
||||||
|
|
||||||
const allDataSources = computed(() => dataSourceService.get('dataSources') || []);
|
const allDataSources = computed(() => dataSourceService.get('dataSources') || []);
|
||||||
|
|
||||||
|
|||||||
@ -141,10 +141,7 @@ const checkStrictly = computed(() => {
|
|||||||
if (typeof props.config.checkStrictly !== 'function') {
|
if (typeof props.config.checkStrictly !== 'function') {
|
||||||
value = props.config.checkStrictly;
|
value = props.config.checkStrictly;
|
||||||
} else {
|
} else {
|
||||||
const fieldValue = props.model[props.name];
|
const dsId = removeDataSourceFieldPrefix(props.model[0]);
|
||||||
// 指定了 dataSourceId 时取值里只有字段名,数据源 id 只能取自 config
|
|
||||||
const rawDsId = props.config.dataSourceId ?? (Array.isArray(fieldValue) ? fieldValue[0] : fieldValue);
|
|
||||||
const dsId = removeDataSourceFieldPrefix(`${rawDsId}`);
|
|
||||||
const dataSource = dataSources.value.find((ds) => ds.id === dsId);
|
const dataSource = dataSources.value.find((ds) => ds.id === dsId);
|
||||||
|
|
||||||
value = props.config.checkStrictly(mForm, {
|
value = props.config.checkStrictly(mForm, {
|
||||||
|
|||||||
@ -89,6 +89,7 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const { uiService } = useServices();
|
const { uiService } = useServices();
|
||||||
const mForm = inject<FormState | undefined>('mForm');
|
const mForm = inject<FormState | undefined>('mForm');
|
||||||
|
|
||||||
/** 对比模式下隐藏新增/编辑/删除等操作按钮,仅保留只读展示。 */
|
/** 对比模式下隐藏新增/编辑/删除等操作按钮,仅保留只读展示。 */
|
||||||
const isCompare = computed(() => Boolean(mForm?.isCompare));
|
const isCompare = computed(() => Boolean(mForm?.isCompare));
|
||||||
|
|
||||||
|
|||||||
@ -67,7 +67,7 @@ defineOptions({
|
|||||||
|
|
||||||
const { dataSourceService, uiService } = useServices();
|
const { dataSourceService, uiService } = useServices();
|
||||||
const mForm = inject<FormState | undefined>('mForm');
|
const mForm = inject<FormState | undefined>('mForm');
|
||||||
const eventBus = inject<EventBus | null>('eventBus', null);
|
const eventBus = inject<EventBus>('eventBus');
|
||||||
|
|
||||||
const emit = defineEmits(['change']);
|
const emit = defineEmits(['change']);
|
||||||
|
|
||||||
|
|||||||
@ -26,12 +26,7 @@ import { cloneDeep } from 'lodash-es';
|
|||||||
|
|
||||||
import type { CodeBlockContent } from '@tmagic/core';
|
import type { CodeBlockContent } from '@tmagic/core';
|
||||||
import { TMagicButton, tMagicMessageBox } from '@tmagic/design';
|
import { TMagicButton, tMagicMessageBox } from '@tmagic/design';
|
||||||
import {
|
import type { ContainerChangeEventData, DataSourceMethodsConfig, FieldProps, FormState } from '@tmagic/form';
|
||||||
type ContainerChangeEventData,
|
|
||||||
type DataSourceMethodsConfig,
|
|
||||||
type FieldProps,
|
|
||||||
type FormState,
|
|
||||||
} from '@tmagic/form';
|
|
||||||
import { type ColumnConfig, MagicTable } from '@tmagic/table';
|
import { type ColumnConfig, MagicTable } from '@tmagic/table';
|
||||||
|
|
||||||
import CodeBlockEditor from '@editor/components/CodeBlockEditor.vue';
|
import CodeBlockEditor from '@editor/components/CodeBlockEditor.vue';
|
||||||
@ -48,6 +43,7 @@ const props = withDefaults(defineProps<FieldProps<DataSourceMethodsConfig>>(), {
|
|||||||
const emit = defineEmits(['change']);
|
const emit = defineEmits(['change']);
|
||||||
|
|
||||||
const mForm = inject<FormState | undefined>('mForm');
|
const mForm = inject<FormState | undefined>('mForm');
|
||||||
|
|
||||||
/** 对比模式下隐藏新增/编辑/删除等操作按钮,仅保留只读展示。 */
|
/** 对比模式下隐藏新增/编辑/删除等操作按钮,仅保留只读展示。 */
|
||||||
const isCompare = computed(() => Boolean(mForm?.isCompare));
|
const isCompare = computed(() => Boolean(mForm?.isCompare));
|
||||||
|
|
||||||
|
|||||||
@ -50,7 +50,7 @@ const props = withDefaults(defineProps<FieldProps<DataSourceSelect>>(), {
|
|||||||
|
|
||||||
const mForm = inject<FormState | undefined>('mForm');
|
const mForm = inject<FormState | undefined>('mForm');
|
||||||
const { dataSourceService, uiService } = useServices();
|
const { dataSourceService, uiService } = useServices();
|
||||||
const eventBus = inject<EventBus | null>('eventBus', null);
|
const eventBus = inject<EventBus>('eventBus');
|
||||||
|
|
||||||
const dataSources = computed(() => dataSourceService.get('dataSources'));
|
const dataSources = computed(() => dataSourceService.get('dataSources'));
|
||||||
|
|
||||||
|
|||||||
@ -1,17 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="m-fields-display-conds">
|
|
||||||
<MGroupList
|
<MGroupList
|
||||||
|
style="width: 100%"
|
||||||
:config="config"
|
:config="config"
|
||||||
:name="name"
|
:name="name"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:model="model"
|
:model="model"
|
||||||
:last-values="lastValues"
|
:last-values="lastValues"
|
||||||
:is-compare="isCompareMode"
|
|
||||||
:prop="prop"
|
:prop="prop"
|
||||||
:size="size"
|
:size="size"
|
||||||
@change="changeHandler"
|
@change="changeHandler"
|
||||||
></MGroupList>
|
></MGroupList>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
@ -24,32 +22,170 @@ import {
|
|||||||
type FieldProps,
|
type FieldProps,
|
||||||
filterFunction,
|
filterFunction,
|
||||||
type FormState,
|
type FormState,
|
||||||
|
type GroupListConfig,
|
||||||
MGroupList,
|
MGroupList,
|
||||||
} from '@tmagic/form';
|
} from '@tmagic/form';
|
||||||
|
|
||||||
import { createDisplayCondsConfig } from '@editor/fields/configs/displayConds';
|
import { useServices } from '@editor/hooks/use-services';
|
||||||
|
import { getCascaderOptionsFromFields, getFieldType } from '@editor/utils';
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'm-fields-display-conds',
|
name: 'm-fields-display-conds',
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
change: [value: DisplayCond | DisplayCond[], eventData?: ContainerChangeEventData];
|
change: [value: DisplayCond[], eventData?: ContainerChangeEventData];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const props = withDefaults(defineProps<FieldProps<DisplayCondsConfig>>(), {
|
const props = withDefaults(defineProps<FieldProps<DisplayCondsConfig>>(), {
|
||||||
disabled: false,
|
disabled: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { dataSourceService } = useServices();
|
||||||
const mForm = inject<FormState | undefined>('mForm');
|
const mForm = inject<FormState | undefined>('mForm');
|
||||||
|
|
||||||
const parentFields = computed(() => filterFunction<string[]>(mForm, props.config.parentFields, props) || []);
|
const parentFields = computed(() => filterFunction<string[]>(mForm, props.config.parentFields, props) || []);
|
||||||
|
|
||||||
const config = computed(() => createDisplayCondsConfig(props.config, props.name, parentFields.value));
|
const fieldOnChange = (_formState: FormState | undefined, v: string[], { model }: { model: Record<string, any> }) => {
|
||||||
|
const [id, ...fieldNames] = [...parentFields.value, ...v];
|
||||||
|
const ds = dataSourceService.getDataSourceById(id);
|
||||||
|
const type = getFieldType(ds, fieldNames);
|
||||||
|
if (type === 'number') {
|
||||||
|
model.value = Number(model.value);
|
||||||
|
} else if (type === 'boolean') {
|
||||||
|
model.value = Boolean(model.value);
|
||||||
|
} else if (type === 'null') {
|
||||||
|
model.value = null;
|
||||||
|
} else {
|
||||||
|
model.value = `${model.value}`;
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
};
|
||||||
|
|
||||||
const isCompareMode = computed(() => Boolean(props.isCompare && props.lastValues));
|
const config = computed<GroupListConfig>(() => ({
|
||||||
|
type: 'groupList',
|
||||||
|
name: props.name,
|
||||||
|
titlePrefix: props.config.titlePrefix,
|
||||||
|
expandAll: true,
|
||||||
|
enableToggleMode: false,
|
||||||
|
flat: props.config.flat,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
type: 'table',
|
||||||
|
name: 'cond',
|
||||||
|
operateColWidth: props.config.operateColWidth,
|
||||||
|
enableToggleMode: false,
|
||||||
|
fixed: props.config.fixed,
|
||||||
|
flat: props.config.flat,
|
||||||
|
items: [
|
||||||
|
parentFields.value.length
|
||||||
|
? {
|
||||||
|
type: 'cascader',
|
||||||
|
options: () => {
|
||||||
|
const [dsId, ...keys] = parentFields.value;
|
||||||
|
const ds = dataSourceService.getDataSourceById(dsId);
|
||||||
|
if (!ds) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
let fields = ds.fields || [];
|
||||||
|
keys.forEach((key) => {
|
||||||
|
const field = fields.find((f) => f.name === key);
|
||||||
|
fields = field?.fields || [];
|
||||||
|
});
|
||||||
|
|
||||||
|
return getCascaderOptionsFromFields(fields, ['string', 'number', 'boolean', 'any']);
|
||||||
|
},
|
||||||
|
name: 'field',
|
||||||
|
value: 'key',
|
||||||
|
label: '字段',
|
||||||
|
checkStrictly: false,
|
||||||
|
onChange: fieldOnChange,
|
||||||
|
defaultValue: () => [],
|
||||||
|
rules: [
|
||||||
|
{ required: true, trigger: 'blur', message: '请选择字段' },
|
||||||
|
{ typeMatch: true, trigger: 'change' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
type: 'data-source-field-select',
|
||||||
|
name: 'field',
|
||||||
|
value: 'key',
|
||||||
|
label: '字段',
|
||||||
|
checkStrictly: false,
|
||||||
|
dataSourceFieldType: ['string', 'number', 'boolean', 'any'],
|
||||||
|
onChange: fieldOnChange,
|
||||||
|
defaultValue: () => [],
|
||||||
|
rules: [
|
||||||
|
{ required: true, trigger: 'blur', message: '请选择字段' },
|
||||||
|
{ typeMatch: true, trigger: 'change' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'cond-op-select',
|
||||||
|
parentFields: parentFields.value,
|
||||||
|
label: '条件',
|
||||||
|
width: 140,
|
||||||
|
name: 'op',
|
||||||
|
rules: [
|
||||||
|
{ required: true, trigger: 'blur', message: '请选择条件' },
|
||||||
|
{ typeMatch: true, trigger: 'change' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '值',
|
||||||
|
width: 160,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
name: 'value',
|
||||||
|
type: (_mForm, { model }) => {
|
||||||
|
const [id, ...fieldNames] = [...parentFields.value, ...model.field];
|
||||||
|
const ds = dataSourceService.getDataSourceById(id);
|
||||||
|
const type = getFieldType(ds, fieldNames);
|
||||||
|
|
||||||
|
if (type === 'number') {
|
||||||
|
return 'number';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'boolean') {
|
||||||
|
return 'select';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'null') {
|
||||||
|
return 'display';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'text';
|
||||||
|
},
|
||||||
|
options: [
|
||||||
|
{ text: 'true', value: true },
|
||||||
|
{ text: 'false', value: false },
|
||||||
|
],
|
||||||
|
display: (_mForm, { model }) => !['between', 'not_between'].includes(model.op),
|
||||||
|
displayText: (_mForm: FormState | undefined, { model }: any) => {
|
||||||
|
if (model.value === null) {
|
||||||
|
return 'null';
|
||||||
|
}
|
||||||
|
return model.value;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'range',
|
||||||
|
type: 'number-range',
|
||||||
|
display: (vm, { model }) => ['between', 'not_between'].includes(model.op),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
const changeHandler = (v: DisplayCond[], eventData?: ContainerChangeEventData) => {
|
const changeHandler = (v: DisplayCond[], eventData?: ContainerChangeEventData) => {
|
||||||
|
if (!Array.isArray(props.model[props.name])) {
|
||||||
|
props.model[props.name] = [];
|
||||||
|
}
|
||||||
|
|
||||||
emit('change', v, eventData);
|
emit('change', v, eventData);
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -15,50 +15,100 @@
|
|||||||
<div v-else class="fullWidth event-select-container">
|
<div v-else class="fullWidth event-select-container">
|
||||||
<div class="event-select-header">
|
<div class="event-select-header">
|
||||||
<div class="event-select-title">事件配置</div>
|
<div class="event-select-title">事件配置</div>
|
||||||
</div>
|
<TMagicButton
|
||||||
<MGroupList
|
v-if="!isCompareMode && displayList.length > 0"
|
||||||
:config="eventConfig"
|
class="create-button"
|
||||||
:name="name"
|
text
|
||||||
:disabled="disabled"
|
type="primary"
|
||||||
:model="model"
|
:icon="Plus"
|
||||||
:last-values="lastValues"
|
|
||||||
:is-compare="isCompareMode"
|
|
||||||
:prop="prop"
|
|
||||||
:size="size"
|
:size="size"
|
||||||
|
:disabled="disabled"
|
||||||
|
@click="addEvent()"
|
||||||
|
>添加事件</TMagicButton
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<MPanel
|
||||||
|
v-for="entry in displayList"
|
||||||
|
:key="entry.index"
|
||||||
|
:disabled="disabled"
|
||||||
|
:size="size"
|
||||||
|
:prop="`${prop}.${entry.index}`"
|
||||||
|
:config="actionsConfig"
|
||||||
|
:model="entry.cardItem"
|
||||||
|
:last-values="entry.lastCardItem"
|
||||||
|
:is-compare="isCompareMode"
|
||||||
|
:hide-expand="false"
|
||||||
|
:label-width="config.labelWidth || '100px'"
|
||||||
@change="onChangeHandler"
|
@change="onChangeHandler"
|
||||||
>
|
>
|
||||||
<template #title="{ model: itemModel, lastValues: itemLastValues, prop: itemProp }">
|
<template #header>
|
||||||
<div class="event-item-header">
|
<div class="event-item-header">
|
||||||
<MFormContainer
|
<MFormContainer
|
||||||
class="fullWidth"
|
class="fullWidth"
|
||||||
:config="eventNameConfig"
|
:config="eventNameConfig"
|
||||||
:model="itemModel"
|
:model="entry.cardItem"
|
||||||
:last-values="itemLastValues"
|
:last-values="entry.lastCardItem"
|
||||||
:is-compare="isCompareMode"
|
:is-compare="isCompareMode"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:size="size"
|
:size="size"
|
||||||
:prop="itemProp"
|
:prop="`${prop}.${entry.index}`"
|
||||||
@change="onChangeHandler"
|
@change="eventNameChangeHandler"
|
||||||
></MFormContainer>
|
></MFormContainer>
|
||||||
|
<TMagicButton
|
||||||
|
class="event-item-delete-button"
|
||||||
|
v-if="!isCompareMode"
|
||||||
|
link
|
||||||
|
:icon="Delete"
|
||||||
|
:disabled="disabled"
|
||||||
|
:size="size"
|
||||||
|
@click="removeEvent(Number(entry.index))"
|
||||||
|
></TMagicButton>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</MGroupList>
|
</MPanel>
|
||||||
|
|
||||||
|
<TMagicButton
|
||||||
|
v-if="!isCompareMode"
|
||||||
|
class="create-button fullWidth"
|
||||||
|
:icon="Plus"
|
||||||
|
:disabled="disabled"
|
||||||
|
@click="addEvent()"
|
||||||
|
>添加事件</TMagicButton
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
import { Delete } from '@element-plus/icons-vue';
|
||||||
|
import { Plus } from '@element-plus/icons-vue';
|
||||||
|
import { has } from 'lodash-es';
|
||||||
|
|
||||||
import type { ContainerChangeEventData, EventSelectConfig, FieldProps } from '@tmagic/form';
|
import { ActionType } from '@tmagic/core';
|
||||||
import { MContainer as MFormContainer, MGroupList, MTable } from '@tmagic/form';
|
import { TMagicButton } from '@tmagic/design';
|
||||||
|
import type {
|
||||||
|
CodeSelectColConfig,
|
||||||
|
ContainerChangeEventData,
|
||||||
|
DataSourceMethodSelectConfig,
|
||||||
|
DynamicTypeConfig,
|
||||||
|
EventSelectConfig,
|
||||||
|
FieldProps,
|
||||||
|
FormState,
|
||||||
|
PanelConfig,
|
||||||
|
TableConfig,
|
||||||
|
UISelectConfig,
|
||||||
|
} from '@tmagic/form';
|
||||||
|
import { defineFormItem, MContainer as MFormContainer, MPanel, MTable } from '@tmagic/form';
|
||||||
|
|
||||||
|
import { useServices } from '@editor/hooks/use-services';
|
||||||
import {
|
import {
|
||||||
createEventNameConfig,
|
getCompActionAllowedValues,
|
||||||
createEventSelectConfig,
|
getCompActionOptions,
|
||||||
createLegacyTableConfig,
|
getEventNameAllowedValues,
|
||||||
isLegacyEventValue,
|
getEventNameOptions,
|
||||||
} from '@editor/fields/configs/eventSelect';
|
normalizeCompActionValue,
|
||||||
|
} from '@editor/utils';
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'MFieldsEventSelect',
|
name: 'MFieldsEventSelect',
|
||||||
@ -70,25 +120,322 @@ const emit = defineEmits<{
|
|||||||
change: [v: any, eventData?: ContainerChangeEventData];
|
change: [v: any, eventData?: ContainerChangeEventData];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const tableConfig = computed(() => createLegacyTableConfig(props.config));
|
const { editorService, dataSourceService, eventsService, codeBlockService, propsService } = useServices();
|
||||||
|
|
||||||
const eventNameConfig = computed(() => createEventNameConfig(props.config));
|
// 事件名称下拉框表单配置
|
||||||
|
const eventNameConfig = computed(() => {
|
||||||
|
const defaultEventNameConfig = {
|
||||||
|
name: 'name',
|
||||||
|
text: '事件类型',
|
||||||
|
type: (mForm: FormState | undefined, { formValue }: any) => {
|
||||||
|
if (
|
||||||
|
props.config.src !== 'component' ||
|
||||||
|
(formValue.type === 'page-fragment-container' && formValue.pageFragmentId)
|
||||||
|
) {
|
||||||
|
return 'cascader';
|
||||||
|
}
|
||||||
|
return 'select';
|
||||||
|
},
|
||||||
|
labelWidth: '70px',
|
||||||
|
checkStrictly: () => props.config.src !== 'component',
|
||||||
|
valueSeparator: '.',
|
||||||
|
options: (_mForm: FormState, { formValue }: any) => getEventNameOptions(props.config.src, formValue),
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
validator: ({ value, callback }: any, { formValue }: any) => {
|
||||||
|
const allowedNames = getEventNameAllowedValues(props.config as any, formValue);
|
||||||
|
if (allowedNames && allowedNames.size > 0 && value && !allowedNames.has(value)) {
|
||||||
|
return callback(`事件名(${value})不存在`);
|
||||||
|
}
|
||||||
|
callback();
|
||||||
|
},
|
||||||
|
trigger: 'blur',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
return { ...defaultEventNameConfig, ...props.config.eventNameConfig };
|
||||||
|
});
|
||||||
|
|
||||||
const eventConfig = computed(() => createEventSelectConfig(props.config, props.name));
|
const actionTypeOptions = computed(() => {
|
||||||
|
const o: {
|
||||||
|
text: string;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
text: '组件',
|
||||||
|
label: '组件',
|
||||||
|
value: ActionType.COMP,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const isOldVersion = computed(() => isLegacyEventValue(props.model[props.name]));
|
if (!propsService.getDisabledCodeBlock()) {
|
||||||
|
o.push({
|
||||||
|
text: '代码',
|
||||||
|
label: '代码',
|
||||||
|
disabled: !Object.keys(codeBlockService.getCodeDsl() || {}).length,
|
||||||
|
value: ActionType.CODE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!propsService.getDisabledDataSource()) {
|
||||||
|
o.push({
|
||||||
|
text: '数据源',
|
||||||
|
label: '数据源',
|
||||||
|
value: ActionType.DATA_SOURCE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return o;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 联动类型
|
||||||
|
const actionTypeConfig = computed(() => {
|
||||||
|
const defaultActionTypeConfig = {
|
||||||
|
name: 'actionType',
|
||||||
|
text: '联动类型',
|
||||||
|
type: 'select',
|
||||||
|
labelPosition: 'left',
|
||||||
|
defaultValue: ActionType.COMP,
|
||||||
|
options: actionTypeOptions.value,
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: '联动类型不能为空',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
typeMatch: true,
|
||||||
|
trigger: 'blur',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
onChange: (_mForm: FormState, _v: string, { setModel }: any) => {
|
||||||
|
setModel('to', '');
|
||||||
|
setModel('method', '');
|
||||||
|
setModel('codeId', '');
|
||||||
|
setModel('dataSourceMethod', []);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { ...defaultActionTypeConfig, ...props.config.actionTypeConfig };
|
||||||
|
});
|
||||||
|
|
||||||
|
// 联动组件配置
|
||||||
|
const targetCompConfig = computed(() => {
|
||||||
|
const defaultTargetCompConfig: UISelectConfig = {
|
||||||
|
name: 'to',
|
||||||
|
text: '联动组件',
|
||||||
|
type: 'ui-select',
|
||||||
|
labelPosition: 'left',
|
||||||
|
display: (_mForm, { model }) => model.actionType === ActionType.COMP,
|
||||||
|
onChange: (_mForm, _v, { setModel }) => {
|
||||||
|
setModel('method', '');
|
||||||
|
},
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
typeMatch: true,
|
||||||
|
trigger: 'blur',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
return { ...defaultTargetCompConfig, ...props.config.targetCompConfig };
|
||||||
|
});
|
||||||
|
|
||||||
|
// 联动组件动作配置
|
||||||
|
const compActionConfig = computed(() => {
|
||||||
|
const defaultCompActionConfig: DynamicTypeConfig = {
|
||||||
|
name: 'method',
|
||||||
|
text: '动作',
|
||||||
|
labelPosition: 'left',
|
||||||
|
type: (mForm: FormState | undefined, { model }: any) => {
|
||||||
|
const to = editorService.getNodeById(model.to);
|
||||||
|
|
||||||
|
if (to && to.type === 'page-fragment-container' && to.pageFragmentId) {
|
||||||
|
return 'cascader';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'select';
|
||||||
|
},
|
||||||
|
checkStrictly: () => props.config.src !== 'component',
|
||||||
|
display: (mForm: FormState | undefined, { model }: any) => model.actionType === ActionType.COMP,
|
||||||
|
options: (_mForm: FormState, { model }: any) => getCompActionOptions(model.to),
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
trigger: 'blur',
|
||||||
|
validator: ({ value, callback }: any, { model }: any) => {
|
||||||
|
const allowedMethods = getCompActionAllowedValues(props.config as any, model);
|
||||||
|
const normalized = normalizeCompActionValue(value);
|
||||||
|
if (allowedMethods && allowedMethods.size > 0 && normalized && !allowedMethods.has(normalized)) {
|
||||||
|
return callback(`动作名(${normalized})不存在`);
|
||||||
|
}
|
||||||
|
callback();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
return { ...defaultCompActionConfig, ...props.config.compActionConfig };
|
||||||
|
});
|
||||||
|
|
||||||
|
// 代码联动配置
|
||||||
|
const codeActionConfig = computed(() => {
|
||||||
|
const defaultCodeActionConfig: CodeSelectColConfig = {
|
||||||
|
type: 'code-select-col',
|
||||||
|
text: '代码块',
|
||||||
|
name: 'codeId',
|
||||||
|
notEditable: () => !codeBlockService.getEditStatus(),
|
||||||
|
display: (mForm, { model }) => model.actionType === ActionType.CODE,
|
||||||
|
};
|
||||||
|
return { ...defaultCodeActionConfig, ...props.config.codeActionConfig };
|
||||||
|
});
|
||||||
|
|
||||||
|
// 数据源联动配置
|
||||||
|
const dataSourceActionConfig = computed(() => {
|
||||||
|
const defaultDataSourceActionConfig: DataSourceMethodSelectConfig = {
|
||||||
|
type: 'data-source-method-select',
|
||||||
|
text: '数据源方法',
|
||||||
|
name: 'dataSourceMethod',
|
||||||
|
notEditable: () => !dataSourceService.get('editable'),
|
||||||
|
display: (mForm, { model }) => model.actionType === ActionType.DATA_SOURCE,
|
||||||
|
};
|
||||||
|
return { ...defaultDataSourceActionConfig, ...props.config.dataSourceActionConfig };
|
||||||
|
});
|
||||||
|
|
||||||
|
// 兼容旧的数据格式
|
||||||
|
const tableConfig = computed(
|
||||||
|
() =>
|
||||||
|
defineFormItem({
|
||||||
|
type: 'table',
|
||||||
|
name: 'events',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
label: '事件名',
|
||||||
|
type: eventNameConfig.value.type,
|
||||||
|
options: (mForm: FormState, { formValue }: any) =>
|
||||||
|
eventsService
|
||||||
|
.getEvent(formValue.type, { node: editorService.getNodeById(formValue.id) })
|
||||||
|
.map((option: any) => ({
|
||||||
|
text: option.label,
|
||||||
|
value: option.value,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'to',
|
||||||
|
label: '联动组件',
|
||||||
|
type: 'ui-select',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'method',
|
||||||
|
label: '动作',
|
||||||
|
type: compActionConfig.value.type,
|
||||||
|
options: (mForm: FormState, { model }: any) => {
|
||||||
|
const node = editorService.getNodeById(model.to);
|
||||||
|
if (!node?.type) return [];
|
||||||
|
|
||||||
|
return eventsService.getMethod(node.type, { targetId: model.to, node }).map((option: any) => ({
|
||||||
|
text: option.label,
|
||||||
|
value: option.value,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}) as TableConfig,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 组件动作组表单配置
|
||||||
|
const actionsConfig = computed(
|
||||||
|
() =>
|
||||||
|
defineFormItem({
|
||||||
|
type: 'panel',
|
||||||
|
labelPosition: 'left',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
type: 'group-list',
|
||||||
|
name: 'actions',
|
||||||
|
expandAll: true,
|
||||||
|
enableToggleMode: false,
|
||||||
|
titlePrefix: '动作',
|
||||||
|
labelPosition: 'left',
|
||||||
|
items: [
|
||||||
|
actionTypeConfig.value,
|
||||||
|
targetCompConfig.value,
|
||||||
|
compActionConfig.value,
|
||||||
|
codeActionConfig.value,
|
||||||
|
dataSourceActionConfig.value,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}) as PanelConfig,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 是否为旧的数据格式
|
||||||
|
const isOldVersion = computed(() => {
|
||||||
|
if (props.model[props.name].length === 0) return false;
|
||||||
|
return !has(props.model[props.name][0], 'actions');
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 对比模式判定:
|
* 对比模式判定:
|
||||||
*
|
*
|
||||||
* event-select 内部是事件列表 group-list。父级 `MFormContainer` 已将其归入「自接管对比字段」
|
* event-select 内部由「事件列表 + 嵌套子表单」组成,属于复合字段。父级 `MFormContainer` 已将其
|
||||||
* (见 Container.vue 的 `SELF_DIFF_FIELD_TYPES`),对比时只渲染一次本组件,并把 `is-compare` /
|
* 归入「自接管对比字段」(见 Container.vue 的 `SELF_DIFF_FIELD_TYPES`),即对比时只渲染一次本组件,
|
||||||
* `lastValues` 透传给内部 MGroupList 与 title slot 里的事件名表单。
|
* 并把当前值 `model` 与历史值 `lastValues` 一并传入,由本组件把 `is-compare`/`lastValues` 透传给
|
||||||
|
* 内部的 MPanel / MFormContainer,逐项(事件名、动作)展示前后差异。
|
||||||
*
|
*
|
||||||
* 仅当存在历史值时才启用对比,避免 lastValues 缺失时退化为「全部新增」的空对比。
|
* 仅当存在历史值时才启用对比,避免 lastValues 缺失时退化为「全部新增」的空对比。
|
||||||
*/
|
*/
|
||||||
const isCompareMode = computed(() => Boolean(props.isCompare && props.lastValues));
|
const isCompareMode = computed(() => Boolean(props.isCompare && props.lastValues));
|
||||||
|
|
||||||
const onChangeHandler = (_v: any, eventData?: ContainerChangeEventData) =>
|
/**
|
||||||
|
* 待渲染的事件卡片列表。
|
||||||
|
*
|
||||||
|
* - 非对比模式:直接映射当前事件列表,`lastCardItem` 为空;
|
||||||
|
* - 对比模式:按索引对齐当前值与历史值,取两者长度的最大值,使得「新增」(仅当前有)与
|
||||||
|
* 「删除」(仅历史有)的事件都能被渲染出来;缺失的一侧用空对象兜底,从而让子级正确高亮差异。
|
||||||
|
*/
|
||||||
|
const displayList = computed<{ cardItem: any; lastCardItem: any; index: number }[]>(() => {
|
||||||
|
const current = props.model[props.name] || [];
|
||||||
|
|
||||||
|
if (!isCompareMode.value) {
|
||||||
|
return current.map((cardItem: any, index: number) => ({ cardItem, lastCardItem: undefined, index }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const last = props.lastValues?.[props.name] || [];
|
||||||
|
const length = Math.max(current.length, last.length);
|
||||||
|
|
||||||
|
return Array.from({ length }, (_, index) => ({
|
||||||
|
cardItem: current[index] ?? {},
|
||||||
|
lastCardItem: last[index] ?? {},
|
||||||
|
index,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 添加事件
|
||||||
|
const addEvent = () => {
|
||||||
|
const defaultEvent = {
|
||||||
|
name: '',
|
||||||
|
actions: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!props.model[props.name]) {
|
||||||
|
props.model[props.name] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('change', defaultEvent, {
|
||||||
|
modifyKey: props.model[props.name].length,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 删除事件
|
||||||
|
const removeEvent = (index: number) => {
|
||||||
|
if (!props.name) return;
|
||||||
|
props.model[props.name].splice(index, 1);
|
||||||
|
emit('change', props.model[props.name]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const eventNameChangeHandler = (v: any, eventData: ContainerChangeEventData) => {
|
||||||
|
emit('change', props.model[props.name], eventData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onChangeHandler = (v: any, eventData: ContainerChangeEventData) =>
|
||||||
emit('change', props.model[props.name], eventData);
|
emit('change', props.model[props.name], eventData);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -75,7 +75,7 @@ import { computed, inject, ref, watchEffect } from 'vue';
|
|||||||
import { Delete, Plus } from '@element-plus/icons-vue';
|
import { Delete, Plus } from '@element-plus/icons-vue';
|
||||||
|
|
||||||
import { TMagicButton, TMagicInput } from '@tmagic/design';
|
import { TMagicButton, TMagicInput } from '@tmagic/design';
|
||||||
import { type FieldProps, type FormState, type KeyValueConfig } from '@tmagic/form';
|
import type { FieldProps, FormState, KeyValueConfig } from '@tmagic/form';
|
||||||
|
|
||||||
import CodeIcon from '@editor/icons/CodeIcon.vue';
|
import CodeIcon from '@editor/icons/CodeIcon.vue';
|
||||||
import MagicCodeEditor from '@editor/layouts/CodeEditor.vue';
|
import MagicCodeEditor from '@editor/layouts/CodeEditor.vue';
|
||||||
|
|||||||
@ -52,14 +52,48 @@
|
|||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
import type { ContainerChangeEventData, FormValue } from '@tmagic/form';
|
import type { ContainerChangeEventData, FormValue } from '@tmagic/form';
|
||||||
import { MContainer } from '@tmagic/form';
|
import { defineFormItem, MContainer } from '@tmagic/form';
|
||||||
import type { StyleSchema } from '@tmagic/schema';
|
import type { StyleSchema } from '@tmagic/schema';
|
||||||
|
|
||||||
import { createBorderDirectionConfig } from '../configs';
|
|
||||||
|
|
||||||
const direction = ref('');
|
const direction = ref('');
|
||||||
|
|
||||||
const config = computed(() => createBorderDirectionConfig(direction.value));
|
const config = computed(() =>
|
||||||
|
defineFormItem({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
name: `border${direction.value}Width`,
|
||||||
|
text: '边框宽度',
|
||||||
|
labelWidth: '68px',
|
||||||
|
type: 'data-source-field-select',
|
||||||
|
fieldConfig: {
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: `border${direction.value}Color`,
|
||||||
|
text: '边框颜色',
|
||||||
|
labelWidth: '68px',
|
||||||
|
type: 'data-source-field-select',
|
||||||
|
fieldConfig: {
|
||||||
|
type: 'colorPicker',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: `border${direction.value}Style`,
|
||||||
|
text: '边框样式',
|
||||||
|
labelWidth: '68px',
|
||||||
|
type: 'data-source-field-select',
|
||||||
|
fieldConfig: {
|
||||||
|
type: 'select',
|
||||||
|
options: ['solid', 'dashed', 'dotted'].map((item) => ({
|
||||||
|
value: item,
|
||||||
|
text: item,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const selectDirection = (d?: string) => (direction.value = d || '');
|
const selectDirection = (d?: string) => (direction.value = d || '');
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
<span class="help-txt" v-if="item.text">{{ item.text }}</span>
|
<span class="help-txt" v-if="item.text">{{ item.text }}</span>
|
||||||
<span class="next-input">
|
<span class="next-input">
|
||||||
<input
|
<input
|
||||||
:value="model[item.name]"
|
:model-value="model[item.name]"
|
||||||
placeholder="0"
|
placeholder="0"
|
||||||
:title="model[item.name]"
|
:title="model[item.name]"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
<div v-for="(item, index) in list" :key="index" :class="item.class">
|
<div v-for="(item, index) in list" :key="index" :class="item.class">
|
||||||
<span class="next-input">
|
<span class="next-input">
|
||||||
<input
|
<input
|
||||||
:value="model[item.name]"
|
:model-value="model[item.name]"
|
||||||
placeholder="0"
|
placeholder="0"
|
||||||
:title="model[item.name]"
|
:title="model[item.name]"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
|
|||||||
@ -1,690 +0,0 @@
|
|||||||
import { markRaw } from 'vue';
|
|
||||||
|
|
||||||
import { appendValidateSuggestion } from '@tmagic/design/headless';
|
|
||||||
import { defineFormItem, type FormConfig } from '@tmagic/form/headless';
|
|
||||||
import type { StyleSchema } from '@tmagic/schema';
|
|
||||||
|
|
||||||
import type { validateDataSourceFieldSelect } from '@editor/utils/type-match-rules';
|
|
||||||
|
|
||||||
import BackgroundPosition from './components/BackgroundPosition.vue';
|
|
||||||
import {
|
|
||||||
AlignItemsCenter,
|
|
||||||
AlignItemsFlexEnd,
|
|
||||||
AlignItemsFlexStart,
|
|
||||||
AlignItemsSpaceAround,
|
|
||||||
AlignItemsSpaceBetween,
|
|
||||||
} from './icons/align-items';
|
|
||||||
import { BackgroundNoRepeat, BackgroundRepeat, BackgroundRepeatX, BackgroundRepeatY } from './icons/background-repeat';
|
|
||||||
import { DisplayBlock, DisplayFlex, DisplayInline, DisplayInlineBlock, DisplayNone } from './icons/display';
|
|
||||||
import {
|
|
||||||
FlexDirectionColumn,
|
|
||||||
FlexDirectionColumnReverse,
|
|
||||||
FlexDirectionRow,
|
|
||||||
FlexDirectionRowReverse,
|
|
||||||
} from './icons/flex-direction';
|
|
||||||
import {
|
|
||||||
JustifyContentCenter,
|
|
||||||
JustifyContentFlexEnd,
|
|
||||||
JustifyContentFlexStart,
|
|
||||||
JustifyContentSpaceAround,
|
|
||||||
JustifyContentSpaceBetween,
|
|
||||||
} from './icons/justify-content';
|
|
||||||
import { AlignCenter, AlignLeft, AlignRight } from './icons/text-align';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 「布局」面板配置。
|
|
||||||
*
|
|
||||||
* `theme` 需要由调用方从 `useTheme()` 解析后传入:magic-admin 主题下换行只能用普通
|
|
||||||
* radio,其余主题用 button。
|
|
||||||
*/
|
|
||||||
export const createLayoutConfig = (theme: string): FormConfig => [
|
|
||||||
defineFormItem({
|
|
||||||
name: 'display',
|
|
||||||
text: '模式',
|
|
||||||
type: 'radioGroup',
|
|
||||||
childType: 'button',
|
|
||||||
labelWidth: '90px',
|
|
||||||
iconSize: '24px',
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
value: 'inline',
|
|
||||||
icon: markRaw(DisplayInline),
|
|
||||||
tooltip: '内联布局 inline',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'flex',
|
|
||||||
icon: markRaw(DisplayFlex),
|
|
||||||
tooltip: '弹性布局 flex',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'block',
|
|
||||||
icon: markRaw(DisplayBlock),
|
|
||||||
tooltip: '块级布局 block',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'inline-block',
|
|
||||||
icon: markRaw(DisplayInlineBlock),
|
|
||||||
tooltip: '内联块布局 inline-block',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'none',
|
|
||||||
icon: markRaw(DisplayNone),
|
|
||||||
tooltip: '隐藏 none',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
defineFormItem({
|
|
||||||
name: 'flexDirection',
|
|
||||||
text: '主轴方向',
|
|
||||||
type: 'radioGroup',
|
|
||||||
childType: 'button',
|
|
||||||
labelWidth: '90px',
|
|
||||||
iconSize: '24px',
|
|
||||||
options: [
|
|
||||||
{ value: 'row', icon: markRaw(FlexDirectionRow), tooltip: '水平方向 起点在左侧 row' },
|
|
||||||
{
|
|
||||||
value: 'row-reverse',
|
|
||||||
icon: markRaw(FlexDirectionRowReverse),
|
|
||||||
tooltip: '水平方向 起点在右侧 row-reverse',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'column',
|
|
||||||
icon: markRaw(FlexDirectionColumn),
|
|
||||||
tooltip: '垂直方向 起点在上沿 column',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'column-reverse',
|
|
||||||
icon: markRaw(FlexDirectionColumnReverse),
|
|
||||||
tooltip: '垂直方向 起点在下沿 column-reverse',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
display: (_mForm, { model }: { model: Record<any, any> }) => model.display === 'flex',
|
|
||||||
}),
|
|
||||||
defineFormItem({
|
|
||||||
name: 'justifyContent',
|
|
||||||
text: '主轴对齐',
|
|
||||||
type: 'radioGroup',
|
|
||||||
childType: 'button',
|
|
||||||
labelWidth: '90px',
|
|
||||||
iconSize: '24px',
|
|
||||||
options: [
|
|
||||||
{ value: 'flex-start', icon: markRaw(JustifyContentFlexStart), tooltip: '左对齐 flex-start' },
|
|
||||||
{ value: 'flex-end', icon: markRaw(JustifyContentFlexEnd), tooltip: '右对齐 flex-end' },
|
|
||||||
{ value: 'center', icon: markRaw(JustifyContentCenter), tooltip: '居中 center' },
|
|
||||||
{
|
|
||||||
value: 'space-between',
|
|
||||||
icon: markRaw(JustifyContentSpaceBetween),
|
|
||||||
tooltip: '两端对齐 space-between',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'space-around',
|
|
||||||
icon: markRaw(JustifyContentSpaceAround),
|
|
||||||
tooltip: '横向平分 space-around',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
display: (_mForm, { model }: { model: Record<any, any> }) => model.display === 'flex',
|
|
||||||
}),
|
|
||||||
defineFormItem({
|
|
||||||
name: 'alignItems',
|
|
||||||
text: '辅轴对齐',
|
|
||||||
type: 'radioGroup',
|
|
||||||
childType: 'button',
|
|
||||||
labelWidth: '90px',
|
|
||||||
iconSize: '24px',
|
|
||||||
options: [
|
|
||||||
{ value: 'flex-start', icon: markRaw(AlignItemsFlexStart), tooltip: '左对齐 flex-start' },
|
|
||||||
{ value: 'flex-end', icon: markRaw(AlignItemsFlexEnd), tooltip: '右对齐 flex-end' },
|
|
||||||
{ value: 'center', icon: markRaw(AlignItemsCenter), tooltip: '居中 center' },
|
|
||||||
{
|
|
||||||
value: 'space-between',
|
|
||||||
icon: markRaw(AlignItemsSpaceBetween),
|
|
||||||
tooltip: '两端对齐 space-between',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'space-around',
|
|
||||||
icon: markRaw(AlignItemsSpaceAround),
|
|
||||||
tooltip: '横向平分 space-around',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
display: (_mForm, { model }: { model: Record<any, any> }) => model.display === 'flex',
|
|
||||||
}),
|
|
||||||
defineFormItem({
|
|
||||||
name: 'flexWrap',
|
|
||||||
text: '换行',
|
|
||||||
type: 'radioGroup',
|
|
||||||
childType: theme !== 'magic-admin' ? 'button' : 'default',
|
|
||||||
labelWidth: '90px',
|
|
||||||
iconSize: '24px',
|
|
||||||
options: [
|
|
||||||
{ value: 'nowrap', text: '不换行', tooltip: '不换行 nowrap' },
|
|
||||||
{ value: 'wrap', text: '正换行', tooltip: '第一行在上方 wrap' },
|
|
||||||
{ value: 'wrap-reverse', text: '逆换行', tooltip: '第一行在下方 wrap-reverse' },
|
|
||||||
],
|
|
||||||
display: (_mForm, { model }: { model: Record<any, any> }) => model.display === 'flex',
|
|
||||||
}),
|
|
||||||
defineFormItem({
|
|
||||||
type: 'row',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
name: 'width',
|
|
||||||
text: '宽度(px)',
|
|
||||||
labelWidth: '90px',
|
|
||||||
type: 'data-source-field-select',
|
|
||||||
fieldConfig: {
|
|
||||||
type: 'text',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
defineFormItem({
|
|
||||||
type: 'row',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
name: 'height',
|
|
||||||
text: '高度(px)',
|
|
||||||
labelWidth: '90px',
|
|
||||||
type: 'data-source-field-select',
|
|
||||||
fieldConfig: {
|
|
||||||
type: 'text',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
defineFormItem({
|
|
||||||
type: 'row',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
type: 'data-source-field-select',
|
|
||||||
text: 'overflow',
|
|
||||||
name: 'overflow',
|
|
||||||
labelWidth: '90px',
|
|
||||||
checkStrictly: false,
|
|
||||||
dataSourceFieldType: ['string'],
|
|
||||||
fieldConfig: {
|
|
||||||
type: 'select',
|
|
||||||
clearable: true,
|
|
||||||
allowCreate: true,
|
|
||||||
options: [
|
|
||||||
{ text: 'visible', value: 'visible' },
|
|
||||||
{ text: 'hidden', value: 'hidden' },
|
|
||||||
{ text: 'clip', value: 'clip' },
|
|
||||||
{ text: 'scroll', value: 'scroll' },
|
|
||||||
{ text: 'auto', value: 'auto' },
|
|
||||||
{ text: 'overlay', value: 'overlay' },
|
|
||||||
{ text: 'initial', value: 'initial' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
defineFormItem({
|
|
||||||
type: 'row',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
type: 'data-source-field-select',
|
|
||||||
text: '透明度(%)',
|
|
||||||
name: 'opacity',
|
|
||||||
labelWidth: '90px',
|
|
||||||
dataSourceFieldType: ['string', 'number'],
|
|
||||||
fieldConfig: {
|
|
||||||
type: 'text',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
const positionText: Record<string, string> = {
|
|
||||||
static: '不定位',
|
|
||||||
relative: '相对定位',
|
|
||||||
absolute: '绝对定位',
|
|
||||||
fixed: '固定定位',
|
|
||||||
sticky: '粘性定位',
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 「位置」面板配置。
|
|
||||||
*
|
|
||||||
* `values` 是当前 style 的 model:left/top/right/bottom 的 display 在求值时读取
|
|
||||||
* `values.position`,所以必须传入响应式对象本身,不能先解构。
|
|
||||||
*/
|
|
||||||
export const createPositionConfig = (values: Partial<StyleSchema>): FormConfig => [
|
|
||||||
defineFormItem({
|
|
||||||
name: 'position',
|
|
||||||
text: '定位',
|
|
||||||
labelWidth: '68px',
|
|
||||||
type: 'data-source-field-select',
|
|
||||||
fieldConfig: {
|
|
||||||
type: 'select',
|
|
||||||
options: Object.keys(positionText).map((item) => ({
|
|
||||||
value: item,
|
|
||||||
text: `${item}(${positionText[item]})`,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
defineFormItem({
|
|
||||||
type: 'row',
|
|
||||||
labelWidth: '68px',
|
|
||||||
display: () => values.position !== 'static',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
name: 'left',
|
|
||||||
type: 'data-source-field-select',
|
|
||||||
text: 'left',
|
|
||||||
fieldConfig: {
|
|
||||||
type: 'text',
|
|
||||||
},
|
|
||||||
rules: [
|
|
||||||
{
|
|
||||||
typeMatch: true,
|
|
||||||
message: appendValidateSuggestion('left 应为字符串', '请参考以下示例值:"10"'),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'top',
|
|
||||||
type: 'data-source-field-select',
|
|
||||||
text: 'top',
|
|
||||||
fieldConfig: {
|
|
||||||
type: 'text',
|
|
||||||
},
|
|
||||||
rules: [
|
|
||||||
{
|
|
||||||
typeMatch: true,
|
|
||||||
message: appendValidateSuggestion('top 应为字符串', '请参考以下示例值:"10"'),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
defineFormItem({
|
|
||||||
type: 'row',
|
|
||||||
labelWidth: '68px',
|
|
||||||
display: () => values.position !== 'static',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
name: 'right',
|
|
||||||
type: 'data-source-field-select',
|
|
||||||
text: 'right',
|
|
||||||
fieldConfig: {
|
|
||||||
type: 'text',
|
|
||||||
},
|
|
||||||
rules: [
|
|
||||||
{
|
|
||||||
typeMatch: true,
|
|
||||||
message: appendValidateSuggestion('right 应为字符串', '请参考以下示例值:"10"'),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'bottom',
|
|
||||||
type: 'data-source-field-select',
|
|
||||||
text: 'bottom',
|
|
||||||
fieldConfig: {
|
|
||||||
type: 'text',
|
|
||||||
},
|
|
||||||
rules: [
|
|
||||||
{
|
|
||||||
typeMatch: true,
|
|
||||||
message: appendValidateSuggestion('bottom 应为字符串', '请参考以下示例值:"10"'),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
defineFormItem({
|
|
||||||
labelWidth: '68px',
|
|
||||||
name: 'zIndex',
|
|
||||||
text: 'zIndex',
|
|
||||||
type: 'data-source-field-select',
|
|
||||||
fieldConfig: {
|
|
||||||
type: 'text',
|
|
||||||
},
|
|
||||||
rules: [
|
|
||||||
{
|
|
||||||
typeMatch: true,
|
|
||||||
message: appendValidateSuggestion('zIndex 应为数字', '请参考以下示例值:10'),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 「背景」面板配置。
|
|
||||||
*
|
|
||||||
* 校验文案由 `@tmagic/design` 的 `appendValidateSuggestion` 生成,必须延迟到调用时
|
|
||||||
* 才执行,不能在模块顶层求值:本模块会被大量组件间接引入,而 design 层在部分场景下
|
|
||||||
* 才可用。
|
|
||||||
*/
|
|
||||||
export const createBackgroundConfig = (): FormConfig => [
|
|
||||||
defineFormItem({
|
|
||||||
name: 'backgroundColor',
|
|
||||||
text: '背景色',
|
|
||||||
labelWidth: '68px',
|
|
||||||
type: 'data-source-field-select',
|
|
||||||
fieldConfig: {
|
|
||||||
type: 'colorPicker',
|
|
||||||
},
|
|
||||||
rules: [
|
|
||||||
{
|
|
||||||
typeMatch: true,
|
|
||||||
message: appendValidateSuggestion('背景色应为字符串', '请参考以下示例值:"#000000"'),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
defineFormItem({
|
|
||||||
name: 'backgroundImage',
|
|
||||||
text: '背景图',
|
|
||||||
labelWidth: '68px',
|
|
||||||
type: 'data-source-field-select',
|
|
||||||
fieldConfig: {
|
|
||||||
type: 'img-upload',
|
|
||||||
} as any,
|
|
||||||
}),
|
|
||||||
defineFormItem({
|
|
||||||
name: 'backgroundSize',
|
|
||||||
text: '背景尺寸',
|
|
||||||
type: 'radioGroup',
|
|
||||||
childType: 'button',
|
|
||||||
labelWidth: '68px',
|
|
||||||
options: [
|
|
||||||
{ value: 'auto', text: '默认', tooltip: '默认 auto' },
|
|
||||||
{ value: 'contain', text: '等比填充', tooltip: '等比填充 contain' },
|
|
||||||
{ value: 'cover', text: '等比覆盖', tooltip: '等比覆盖 cover' },
|
|
||||||
],
|
|
||||||
rules: [
|
|
||||||
{
|
|
||||||
typeMatch: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
validator: ({ value, callback }) => {
|
|
||||||
if (value === '' || value === null || value === undefined) {
|
|
||||||
return callback();
|
|
||||||
}
|
|
||||||
|
|
||||||
const keywords = ['auto', 'cover', 'contain', 'inherit', 'initial', 'revert', 'unset'];
|
|
||||||
// 单值:关键字 或 长度/百分比
|
|
||||||
const lengthPercent = /^-?\d+(\.\d+)?(px|em|rem|ex|ch|vw|vh|vmin|vmax|cm|mm|in|pt|pc|%)$/;
|
|
||||||
const singleValue = (v: string) => keywords.includes(v) || lengthPercent.test(v);
|
|
||||||
|
|
||||||
const str = String(value).trim();
|
|
||||||
const parts = str.split(/\s+/);
|
|
||||||
|
|
||||||
// cover / contain 不能与其他值组合
|
|
||||||
if (parts.length > 1 && (parts.includes('cover') || parts.includes('contain'))) {
|
|
||||||
return callback('cover/contain 不能与其他值组合');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 多值最多两个
|
|
||||||
if (parts.length > 2) {
|
|
||||||
return callback('backgroundSize 最多支持两个值');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 关键字 auto 在多值场景中允许与其他长度/百分比组合
|
|
||||||
if (parts.every((part) => singleValue(part))) {
|
|
||||||
return callback();
|
|
||||||
}
|
|
||||||
|
|
||||||
return callback('backgroundSize 值不合法');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
defineFormItem({
|
|
||||||
name: 'backgroundRepeat',
|
|
||||||
text: '重复显示',
|
|
||||||
type: 'radioGroup',
|
|
||||||
childType: 'button',
|
|
||||||
labelWidth: '68px',
|
|
||||||
options: [
|
|
||||||
{ value: 'no-repeat', icon: markRaw(BackgroundNoRepeat), tooltip: '不重复 no-repeat' },
|
|
||||||
{ value: 'repeat-x', icon: markRaw(BackgroundRepeatX), tooltip: '水平方向重复 repeat-x' },
|
|
||||||
{ value: 'repeat-y', icon: markRaw(BackgroundRepeatY), tooltip: '垂直方向重复 repeat-y' },
|
|
||||||
{
|
|
||||||
value: 'repeat',
|
|
||||||
icon: markRaw(BackgroundRepeat),
|
|
||||||
tooltip: '垂直和水平方向重复 repeat',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
defineFormItem({
|
|
||||||
name: 'backgroundPosition',
|
|
||||||
text: '背景定位',
|
|
||||||
type: 'component',
|
|
||||||
component: BackgroundPosition,
|
|
||||||
labelWidth: '68px',
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 「文字」面板配置。
|
|
||||||
*
|
|
||||||
* 字重校验依赖编辑器服务(数据源/代码块),由调用方把 `type-match-rules` 的
|
|
||||||
* `validateDataSourceFieldSelect` 传进来:本模块只描述配置,不直接引用服务层,
|
|
||||||
* 否则会把整条服务依赖链拖进所有引用方。
|
|
||||||
*/
|
|
||||||
export const createFontConfig = (validateDataSourceField: typeof validateDataSourceFieldSelect): FormConfig => [
|
|
||||||
defineFormItem({
|
|
||||||
type: 'row',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
labelWidth: '68px',
|
|
||||||
name: 'fontSize',
|
|
||||||
text: '字号',
|
|
||||||
type: 'data-source-field-select',
|
|
||||||
fieldConfig: {
|
|
||||||
type: 'text',
|
|
||||||
},
|
|
||||||
rules: [
|
|
||||||
{
|
|
||||||
typeMatch: true,
|
|
||||||
message: appendValidateSuggestion('字号应为字符串或数字', '请参考以下示例值:24 或 "24"'),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
labelWidth: '68px',
|
|
||||||
name: 'lineHeight',
|
|
||||||
text: '行高',
|
|
||||||
type: 'data-source-field-select',
|
|
||||||
fieldConfig: {
|
|
||||||
type: 'text',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
defineFormItem({
|
|
||||||
name: 'fontWeight',
|
|
||||||
text: '字重',
|
|
||||||
labelWidth: '68px',
|
|
||||||
type: 'data-source-field-select',
|
|
||||||
dataSourceFieldType: ['string', 'number'],
|
|
||||||
fieldConfig: {
|
|
||||||
type: 'select',
|
|
||||||
allowCreate: true,
|
|
||||||
options: ['normal', 'bold']
|
|
||||||
.concat(
|
|
||||||
Array(7)
|
|
||||||
.fill(1)
|
|
||||||
.map((x, i) => `${i + 1}00`),
|
|
||||||
)
|
|
||||||
.map((item) => ({
|
|
||||||
value: item,
|
|
||||||
text: item,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
rules: [
|
|
||||||
{
|
|
||||||
typeMatch: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
validator: ({ value, callback }, { config, model, prop }, mForm) => {
|
|
||||||
if (value === '' || value === null || value === undefined) {
|
|
||||||
return callback();
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = validateDataSourceField(
|
|
||||||
value,
|
|
||||||
{
|
|
||||||
fieldType: 'data-source-field-select',
|
|
||||||
mForm,
|
|
||||||
props: { config, model, prop },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// 字重允许 string(含可创建项)与 number(如 700)
|
|
||||||
validatePlainValue: (plainValue) => {
|
|
||||||
if (typeof plainValue === 'string' || (typeof plainValue === 'number' && !Number.isNaN(plainValue))) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return '字重应为字符串或数字';
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result && typeof (result as Promise<string | undefined>).then === 'function') {
|
|
||||||
(result as Promise<string | undefined>).then(
|
|
||||||
(error) => callback(error),
|
|
||||||
(error) => callback(error),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
return callback(result);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
defineFormItem({
|
|
||||||
labelWidth: '68px',
|
|
||||||
name: 'color',
|
|
||||||
text: '颜色',
|
|
||||||
type: 'data-source-field-select',
|
|
||||||
fieldConfig: {
|
|
||||||
type: 'colorPicker',
|
|
||||||
},
|
|
||||||
rules: [
|
|
||||||
{
|
|
||||||
typeMatch: true,
|
|
||||||
message: appendValidateSuggestion('颜色应为字符串', '请参考以下示例值:"#000000"'),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
defineFormItem({
|
|
||||||
name: 'textAlign',
|
|
||||||
text: '对齐',
|
|
||||||
type: 'radioGroup',
|
|
||||||
childType: 'button',
|
|
||||||
labelWidth: '68px',
|
|
||||||
options: [
|
|
||||||
{ value: 'left', icon: markRaw(AlignLeft), tooltip: '左对齐 row', text: '左对齐' },
|
|
||||||
{ value: 'center', icon: markRaw(AlignCenter), tooltip: '居中对齐 center', text: '居中对齐' },
|
|
||||||
{ value: 'right', icon: markRaw(AlignRight), tooltip: '右对齐 right', text: '右对齐' },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
/** 「边框与圆角」面板中圆角部分的配置,边框四向配置见 `createBorderDirectionConfig`。 */
|
|
||||||
export const borderRadiusConfig = defineFormItem({
|
|
||||||
labelWidth: '68px',
|
|
||||||
name: 'borderRadius',
|
|
||||||
text: '圆角',
|
|
||||||
type: 'data-source-field-select',
|
|
||||||
fieldConfig: {
|
|
||||||
type: 'text',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
/** 「变形」面板配置。 */
|
|
||||||
export const transformConfig = defineFormItem({
|
|
||||||
name: 'transform',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
name: 'rotate',
|
|
||||||
text: '旋转角度',
|
|
||||||
labelWidth: '68px',
|
|
||||||
type: 'data-source-field-select',
|
|
||||||
checkStrictly: false,
|
|
||||||
dataSourceFieldType: ['string', 'number'],
|
|
||||||
fieldConfig: {
|
|
||||||
type: 'text',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'scale',
|
|
||||||
text: '缩放',
|
|
||||||
labelWidth: '68px',
|
|
||||||
type: 'data-source-field-select',
|
|
||||||
checkStrictly: false,
|
|
||||||
dataSourceFieldType: ['string', 'number'],
|
|
||||||
fieldConfig: {
|
|
||||||
type: 'text',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 把 6 个样式面板里会向父表单注册 FormItem 的配置按 `Index.vue` 的面板顺序拼成一份。
|
|
||||||
*
|
|
||||||
* 给无渲染校验的嵌套配置用:组件侧仍按面板各自渲染,这份合成配置只描述「父表单上
|
|
||||||
* 实际会出现哪些字段」。以下两处与组件一致、不纳入:
|
|
||||||
* - Layout 里的 Box(margin/padding)是裸 input,没有 MContainer;
|
|
||||||
* - Border 子组件的四向边框 MContainer 没传 `:prop`(路径从父表单脱落,本轮不修)。
|
|
||||||
*
|
|
||||||
* `theme` 只影响 flexWrap 的 UI `childType`,不影响校验字段。
|
|
||||||
*/
|
|
||||||
export const createStyleSetterConfig = (
|
|
||||||
values: Partial<StyleSchema>,
|
|
||||||
theme: string,
|
|
||||||
validateDataSourceField: typeof validateDataSourceFieldSelect,
|
|
||||||
): FormConfig => [
|
|
||||||
...createLayoutConfig(theme),
|
|
||||||
...createPositionConfig(values),
|
|
||||||
...createBackgroundConfig(),
|
|
||||||
...createFontConfig(validateDataSourceField),
|
|
||||||
borderRadiusConfig,
|
|
||||||
transformConfig,
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 边框配置,按当前选中的方向拼字段名。
|
|
||||||
*
|
|
||||||
* `direction` 取 ''(四向) / 'Top' / 'Right' / 'Bottom' / 'Left',空串对应
|
|
||||||
* `borderWidth` 这类不带方向的字段。
|
|
||||||
*/
|
|
||||||
export const createBorderDirectionConfig = (direction: string) =>
|
|
||||||
defineFormItem({
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
name: `border${direction}Width`,
|
|
||||||
text: '边框宽度',
|
|
||||||
labelWidth: '68px',
|
|
||||||
type: 'data-source-field-select',
|
|
||||||
fieldConfig: {
|
|
||||||
type: 'text',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: `border${direction}Color`,
|
|
||||||
text: '边框颜色',
|
|
||||||
labelWidth: '68px',
|
|
||||||
type: 'data-source-field-select',
|
|
||||||
fieldConfig: {
|
|
||||||
type: 'colorPicker',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: `border${direction}Style`,
|
|
||||||
text: '边框样式',
|
|
||||||
labelWidth: '68px',
|
|
||||||
type: 'data-source-field-select',
|
|
||||||
fieldConfig: {
|
|
||||||
type: 'select',
|
|
||||||
options: ['solid', 'dashed', 'dotted'].map((item) => ({
|
|
||||||
value: item,
|
|
||||||
text: item,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
@ -15,12 +15,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed } from 'vue';
|
import { markRaw } from 'vue';
|
||||||
|
|
||||||
import { type ContainerChangeEventData, MContainer } from '@tmagic/form';
|
import { type ContainerChangeEventData, defineFormConfig, MContainer } from '@tmagic/form';
|
||||||
import type { StyleSchema } from '@tmagic/schema';
|
import type { StyleSchema } from '@tmagic/schema';
|
||||||
|
|
||||||
import { createBackgroundConfig } from '../configs';
|
import BackgroundPosition from '../components/BackgroundPosition.vue';
|
||||||
|
import { BackgroundNoRepeat, BackgroundRepeat, BackgroundRepeatX, BackgroundRepeatY } from '../icons/background-repeat';
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
values: Partial<StyleSchema>;
|
values: Partial<StyleSchema>;
|
||||||
@ -37,7 +38,99 @@ const emit = defineEmits<{
|
|||||||
addDiffCount: [];
|
addDiffCount: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const formConfig = computed(() => createBackgroundConfig());
|
const formConfig = defineFormConfig([
|
||||||
|
{
|
||||||
|
name: 'backgroundColor',
|
||||||
|
text: '背景色',
|
||||||
|
labelWidth: '68px',
|
||||||
|
type: 'data-source-field-select',
|
||||||
|
fieldConfig: {
|
||||||
|
type: 'colorPicker',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'backgroundImage',
|
||||||
|
text: '背景图',
|
||||||
|
labelWidth: '68px',
|
||||||
|
type: 'data-source-field-select',
|
||||||
|
fieldConfig: {
|
||||||
|
type: 'img-upload',
|
||||||
|
} as any,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'backgroundSize',
|
||||||
|
text: '背景尺寸',
|
||||||
|
type: 'radioGroup',
|
||||||
|
childType: 'button',
|
||||||
|
labelWidth: '68px',
|
||||||
|
options: [
|
||||||
|
{ value: 'auto', text: '默认', tooltip: '默认 auto' },
|
||||||
|
{ value: 'contain', text: '等比填充', tooltip: '等比填充 contain' },
|
||||||
|
{ value: 'cover', text: '等比覆盖', tooltip: '等比覆盖 cover' },
|
||||||
|
],
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
typeMatch: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
validator: ({ value, callback }) => {
|
||||||
|
if (value === '' || value === null || value === undefined) {
|
||||||
|
return callback();
|
||||||
|
}
|
||||||
|
|
||||||
|
const keywords = ['auto', 'cover', 'contain', 'inherit', 'initial', 'revert', 'unset'];
|
||||||
|
// 单值:关键字 或 长度/百分比
|
||||||
|
const lengthPercent = /^-?\d+(\.\d+)?(px|em|rem|ex|ch|vw|vh|vmin|vmax|cm|mm|in|pt|pc|%)$/;
|
||||||
|
const singleValue = (v: string) => keywords.includes(v) || lengthPercent.test(v);
|
||||||
|
|
||||||
|
const str = String(value).trim();
|
||||||
|
const parts = str.split(/\s+/);
|
||||||
|
|
||||||
|
// cover / contain 不能与其他值组合
|
||||||
|
if (parts.length > 1 && (parts.includes('cover') || parts.includes('contain'))) {
|
||||||
|
return callback('cover/contain 不能与其他值组合');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 多值最多两个
|
||||||
|
if (parts.length > 2) {
|
||||||
|
return callback('backgroundSize 最多支持两个值');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关键字 auto 在多值场景中允许与其他长度/百分比组合
|
||||||
|
if (parts.every((part) => singleValue(part))) {
|
||||||
|
return callback();
|
||||||
|
}
|
||||||
|
|
||||||
|
return callback('backgroundSize 值不合法');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'backgroundRepeat',
|
||||||
|
text: '重复显示',
|
||||||
|
type: 'radioGroup',
|
||||||
|
childType: 'button',
|
||||||
|
labelWidth: '68px',
|
||||||
|
options: [
|
||||||
|
{ value: 'no-repeat', icon: markRaw(BackgroundNoRepeat), tooltip: '不重复 no-repeat' },
|
||||||
|
{ value: 'repeat-x', icon: markRaw(BackgroundRepeatX), tooltip: '水平方向重复 repeat-x' },
|
||||||
|
{ value: 'repeat-y', icon: markRaw(BackgroundRepeatY), tooltip: '垂直方向重复 repeat-y' },
|
||||||
|
{
|
||||||
|
value: 'repeat',
|
||||||
|
icon: markRaw(BackgroundRepeat),
|
||||||
|
tooltip: '垂直和水平方向重复 repeat',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'backgroundPosition',
|
||||||
|
text: '背景定位',
|
||||||
|
type: 'component',
|
||||||
|
component: BackgroundPosition,
|
||||||
|
labelWidth: '68px',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
const change = (value: StyleSchema, eventData: ContainerChangeEventData) => {
|
const change = (value: StyleSchema, eventData: ContainerChangeEventData) => {
|
||||||
emit('change', value, eventData);
|
emit('change', value, eventData);
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<MContainer
|
<MContainer
|
||||||
:prop="prop"
|
:prop="prop"
|
||||||
:config="borderRadiusConfig"
|
:config="config"
|
||||||
:model="values"
|
:model="values"
|
||||||
:last-values="lastValues"
|
:last-values="lastValues"
|
||||||
:is-compare="isCompare"
|
:is-compare="isCompare"
|
||||||
@ -22,11 +22,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { type ContainerChangeEventData, MContainer } from '@tmagic/form';
|
import { type ContainerChangeEventData, defineFormItem, MContainer } from '@tmagic/form';
|
||||||
import type { StyleSchema } from '@tmagic/schema';
|
import type { StyleSchema } from '@tmagic/schema';
|
||||||
|
|
||||||
import Border from '../components/Border.vue';
|
import Border from '../components/Border.vue';
|
||||||
import { borderRadiusConfig } from '../configs';
|
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
values: Partial<StyleSchema>;
|
values: Partial<StyleSchema>;
|
||||||
@ -42,6 +41,16 @@ const emit = defineEmits<{
|
|||||||
addDiffCount: [];
|
addDiffCount: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const config = defineFormItem({
|
||||||
|
labelWidth: '68px',
|
||||||
|
name: 'borderRadius',
|
||||||
|
text: '圆角',
|
||||||
|
type: 'data-source-field-select',
|
||||||
|
fieldConfig: {
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const change = (value: StyleSchema, eventData: ContainerChangeEventData) => {
|
const change = (value: StyleSchema, eventData: ContainerChangeEventData) => {
|
||||||
emit('change', value, eventData);
|
emit('change', value, eventData);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -15,14 +15,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed } from 'vue';
|
import { markRaw } from 'vue';
|
||||||
|
|
||||||
import { type ContainerChangeEventData, MContainer } from '@tmagic/form';
|
import { type ContainerChangeEventData, defineFormConfig, MContainer } from '@tmagic/form';
|
||||||
import type { StyleSchema } from '@tmagic/schema';
|
import type { StyleSchema } from '@tmagic/schema';
|
||||||
|
|
||||||
import { validateDataSourceFieldSelect } from '@editor/utils/type-match-rules';
|
import { AlignCenter, AlignLeft, AlignRight } from '../icons/text-align';
|
||||||
|
|
||||||
import { createFontConfig } from '../configs';
|
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
values: Partial<StyleSchema>;
|
values: Partial<StyleSchema>;
|
||||||
@ -38,7 +36,71 @@ const emit = defineEmits<{
|
|||||||
addDiffCount: [];
|
addDiffCount: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const formConfig = computed(() => createFontConfig(validateDataSourceFieldSelect));
|
const formConfig = defineFormConfig([
|
||||||
|
{
|
||||||
|
type: 'row',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
labelWidth: '68px',
|
||||||
|
name: 'fontSize',
|
||||||
|
text: '字号',
|
||||||
|
type: 'data-source-field-select',
|
||||||
|
fieldConfig: {
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
labelWidth: '68px',
|
||||||
|
name: 'lineHeight',
|
||||||
|
text: '行高',
|
||||||
|
type: 'data-source-field-select',
|
||||||
|
fieldConfig: {
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'fontWeight',
|
||||||
|
text: '字重',
|
||||||
|
labelWidth: '68px',
|
||||||
|
type: 'data-source-field-select',
|
||||||
|
fieldConfig: {
|
||||||
|
type: 'select',
|
||||||
|
options: ['normal', 'bold']
|
||||||
|
.concat(
|
||||||
|
Array(7)
|
||||||
|
.fill(1)
|
||||||
|
.map((x, i) => `${i + 1}00`),
|
||||||
|
)
|
||||||
|
.map((item) => ({
|
||||||
|
value: item,
|
||||||
|
text: item,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
labelWidth: '68px',
|
||||||
|
name: 'color',
|
||||||
|
text: '颜色',
|
||||||
|
type: 'data-source-field-select',
|
||||||
|
fieldConfig: {
|
||||||
|
type: 'colorPicker',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'textAlign',
|
||||||
|
text: '对齐',
|
||||||
|
type: 'radioGroup',
|
||||||
|
childType: 'button',
|
||||||
|
labelWidth: '68px',
|
||||||
|
options: [
|
||||||
|
{ value: 'left', icon: markRaw(AlignLeft), tooltip: '左对齐 row', text: '左对齐' },
|
||||||
|
{ value: 'center', icon: markRaw(AlignCenter), tooltip: '居中对齐 center', text: '居中对齐' },
|
||||||
|
{ value: 'right', icon: markRaw(AlignRight), tooltip: '右对齐 right', text: '右对齐' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
const change = (value: StyleSchema, eventData: ContainerChangeEventData) => {
|
const change = (value: StyleSchema, eventData: ContainerChangeEventData) => {
|
||||||
emit('change', value, eventData);
|
emit('change', value, eventData);
|
||||||
|
|||||||
@ -24,15 +24,35 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed } from 'vue';
|
import { markRaw } from 'vue';
|
||||||
|
|
||||||
import { useTheme } from '@tmagic/design';
|
import { useTheme } from '@tmagic/design';
|
||||||
import type { ContainerChangeEventData } from '@tmagic/form';
|
import type { ContainerChangeEventData } from '@tmagic/form';
|
||||||
import { MContainer } from '@tmagic/form';
|
import { defineFormConfig, MContainer } from '@tmagic/form';
|
||||||
import type { StyleSchema } from '@tmagic/schema';
|
import type { StyleSchema } from '@tmagic/schema';
|
||||||
|
|
||||||
import Box from '../components/Box.vue';
|
import Box from '../components/Box.vue';
|
||||||
import { createLayoutConfig } from '../configs';
|
import {
|
||||||
|
AlignItemsCenter,
|
||||||
|
AlignItemsFlexEnd,
|
||||||
|
AlignItemsFlexStart,
|
||||||
|
AlignItemsSpaceAround,
|
||||||
|
AlignItemsSpaceBetween,
|
||||||
|
} from '../icons/align-items';
|
||||||
|
import { DisplayBlock, DisplayFlex, DisplayInline, DisplayInlineBlock, DisplayNone } from '../icons/display';
|
||||||
|
import {
|
||||||
|
FlexDirectionColumn,
|
||||||
|
FlexDirectionColumnReverse,
|
||||||
|
FlexDirectionRow,
|
||||||
|
FlexDirectionRowReverse,
|
||||||
|
} from '../icons/flex-direction';
|
||||||
|
import {
|
||||||
|
JustifyContentCenter,
|
||||||
|
JustifyContentFlexEnd,
|
||||||
|
JustifyContentFlexStart,
|
||||||
|
JustifyContentSpaceAround,
|
||||||
|
JustifyContentSpaceBetween,
|
||||||
|
} from '../icons/justify-content';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
values: Partial<StyleSchema>;
|
values: Partial<StyleSchema>;
|
||||||
@ -51,7 +71,202 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const displayTheme = useTheme(props);
|
const displayTheme = useTheme(props);
|
||||||
|
|
||||||
const formConfig = computed(() => createLayoutConfig(displayTheme.value));
|
const formConfig = defineFormConfig([
|
||||||
|
{
|
||||||
|
name: 'display',
|
||||||
|
text: '模式',
|
||||||
|
type: 'radioGroup',
|
||||||
|
childType: 'button',
|
||||||
|
labelWidth: '90px',
|
||||||
|
iconSize: '24px',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
value: 'inline',
|
||||||
|
icon: markRaw(DisplayInline),
|
||||||
|
tooltip: '内联布局 inline',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'flex',
|
||||||
|
icon: markRaw(DisplayFlex),
|
||||||
|
tooltip: '弹性布局 flex',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'block',
|
||||||
|
icon: markRaw(DisplayBlock),
|
||||||
|
tooltip: '块级布局 block',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'inline-block',
|
||||||
|
icon: markRaw(DisplayInlineBlock),
|
||||||
|
tooltip: '内联块布局 inline-block',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'none',
|
||||||
|
icon: markRaw(DisplayNone),
|
||||||
|
tooltip: '隐藏 none',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'flexDirection',
|
||||||
|
text: '主轴方向',
|
||||||
|
type: 'radioGroup',
|
||||||
|
childType: 'button',
|
||||||
|
labelWidth: '90px',
|
||||||
|
iconSize: '24px',
|
||||||
|
options: [
|
||||||
|
{ value: 'row', icon: markRaw(FlexDirectionRow), tooltip: '水平方向 起点在左侧 row' },
|
||||||
|
{
|
||||||
|
value: 'row-reverse',
|
||||||
|
icon: markRaw(FlexDirectionRowReverse),
|
||||||
|
tooltip: '水平方向 起点在右侧 row-reverse',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'column',
|
||||||
|
icon: markRaw(FlexDirectionColumn),
|
||||||
|
tooltip: '垂直方向 起点在上沿 column',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'column-reverse',
|
||||||
|
icon: markRaw(FlexDirectionColumnReverse),
|
||||||
|
tooltip: '垂直方向 起点在下沿 column-reverse',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
display: (_mForm, { model }: { model: Record<any, any> }) => model.display === 'flex',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'justifyContent',
|
||||||
|
text: '主轴对齐',
|
||||||
|
type: 'radioGroup',
|
||||||
|
childType: 'button',
|
||||||
|
labelWidth: '90px',
|
||||||
|
iconSize: '24px',
|
||||||
|
options: [
|
||||||
|
{ value: 'flex-start', icon: markRaw(JustifyContentFlexStart), tooltip: '左对齐 flex-start' },
|
||||||
|
{ value: 'flex-end', icon: markRaw(JustifyContentFlexEnd), tooltip: '右对齐 flex-end' },
|
||||||
|
{ value: 'center', icon: markRaw(JustifyContentCenter), tooltip: '居中 center' },
|
||||||
|
{
|
||||||
|
value: 'space-between',
|
||||||
|
icon: markRaw(JustifyContentSpaceBetween),
|
||||||
|
tooltip: '两端对齐 space-between',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'space-around',
|
||||||
|
icon: markRaw(JustifyContentSpaceAround),
|
||||||
|
tooltip: '横向平分 space-around',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
display: (_mForm, { model }: { model: Record<any, any> }) => model.display === 'flex',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'alignItems',
|
||||||
|
text: '辅轴对齐',
|
||||||
|
type: 'radioGroup',
|
||||||
|
childType: 'button',
|
||||||
|
labelWidth: '90px',
|
||||||
|
iconSize: '24px',
|
||||||
|
options: [
|
||||||
|
{ value: 'flex-start', icon: markRaw(AlignItemsFlexStart), tooltip: '左对齐 flex-start' },
|
||||||
|
{ value: 'flex-end', icon: markRaw(AlignItemsFlexEnd), tooltip: '右对齐 flex-end' },
|
||||||
|
{ value: 'center', icon: markRaw(AlignItemsCenter), tooltip: '居中 center' },
|
||||||
|
{
|
||||||
|
value: 'space-between',
|
||||||
|
icon: markRaw(AlignItemsSpaceBetween),
|
||||||
|
tooltip: '两端对齐 space-between',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'space-around',
|
||||||
|
icon: markRaw(AlignItemsSpaceAround),
|
||||||
|
tooltip: '横向平分 space-around',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
display: (_mForm, { model }: { model: Record<any, any> }) => model.display === 'flex',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'flexWrap',
|
||||||
|
text: '换行',
|
||||||
|
type: 'radioGroup',
|
||||||
|
childType: displayTheme.value !== 'magic-admin' ? 'button' : 'default',
|
||||||
|
labelWidth: '90px',
|
||||||
|
iconSize: '24px',
|
||||||
|
options: [
|
||||||
|
{ value: 'nowrap', text: '不换行', tooltip: '不换行 nowrap' },
|
||||||
|
{ value: 'wrap', text: '正换行', tooltip: '第一行在上方 wrap' },
|
||||||
|
{ value: 'wrap-reverse', text: '逆换行', tooltip: '第一行在下方 wrap-reverse' },
|
||||||
|
],
|
||||||
|
display: (_mForm, { model }: { model: Record<any, any> }) => model.display === 'flex',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'row',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
name: 'width',
|
||||||
|
text: '宽度(px)',
|
||||||
|
labelWidth: '90px',
|
||||||
|
type: 'data-source-field-select',
|
||||||
|
fieldConfig: {
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'row',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
name: 'height',
|
||||||
|
text: '高度(px)',
|
||||||
|
labelWidth: '90px',
|
||||||
|
type: 'data-source-field-select',
|
||||||
|
fieldConfig: {
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'row',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
type: 'data-source-field-select',
|
||||||
|
text: 'overflow',
|
||||||
|
name: 'overflow',
|
||||||
|
labelWidth: '90px',
|
||||||
|
checkStrictly: false,
|
||||||
|
dataSourceFieldType: ['string'],
|
||||||
|
fieldConfig: {
|
||||||
|
type: 'select',
|
||||||
|
clearable: true,
|
||||||
|
allowCreate: true,
|
||||||
|
options: [
|
||||||
|
{ text: 'visible', value: 'visible' },
|
||||||
|
{ text: 'hidden', value: 'hidden' },
|
||||||
|
{ text: 'clip', value: 'clip' },
|
||||||
|
{ text: 'scroll', value: 'scroll' },
|
||||||
|
{ text: 'auto', value: 'auto' },
|
||||||
|
{ text: 'overlay', value: 'overlay' },
|
||||||
|
{ text: 'initial', value: 'initial' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'row',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
type: 'data-source-field-select',
|
||||||
|
text: '透明度(%)',
|
||||||
|
name: 'opacity',
|
||||||
|
labelWidth: '90px',
|
||||||
|
dataSourceFieldType: ['string', 'number'],
|
||||||
|
fieldConfig: {
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
const change = (value: string | StyleSchema, eventData: ContainerChangeEventData) => {
|
const change = (value: string | StyleSchema, eventData: ContainerChangeEventData) => {
|
||||||
emit('change', value, eventData);
|
emit('change', value, eventData);
|
||||||
|
|||||||
@ -15,13 +15,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed } from 'vue';
|
import { type ContainerChangeEventData, defineFormConfig, MContainer } from '@tmagic/form';
|
||||||
|
|
||||||
import { type ContainerChangeEventData, MContainer } from '@tmagic/form';
|
|
||||||
import type { StyleSchema } from '@tmagic/schema';
|
import type { StyleSchema } from '@tmagic/schema';
|
||||||
|
|
||||||
import { createPositionConfig } from '../configs';
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
values: Partial<StyleSchema>;
|
values: Partial<StyleSchema>;
|
||||||
lastValues?: Partial<StyleSchema>;
|
lastValues?: Partial<StyleSchema>;
|
||||||
@ -36,7 +32,84 @@ const emit = defineEmits<{
|
|||||||
addDiffCount: [];
|
addDiffCount: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const formConfig = computed(() => createPositionConfig(props.values));
|
const positionText: Record<string, string> = {
|
||||||
|
static: '不定位',
|
||||||
|
relative: '相对定位',
|
||||||
|
absolute: '绝对定位',
|
||||||
|
fixed: '固定定位',
|
||||||
|
sticky: '粘性定位',
|
||||||
|
};
|
||||||
|
|
||||||
|
const formConfig = defineFormConfig([
|
||||||
|
{
|
||||||
|
name: 'position',
|
||||||
|
text: '定位',
|
||||||
|
labelWidth: '68px',
|
||||||
|
type: 'data-source-field-select',
|
||||||
|
fieldConfig: {
|
||||||
|
type: 'select',
|
||||||
|
options: Object.keys(positionText).map((item) => ({
|
||||||
|
value: item,
|
||||||
|
text: `${item}(${positionText[item]})`,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'row',
|
||||||
|
labelWidth: '68px',
|
||||||
|
display: () => props.values.position !== 'static',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
name: 'left',
|
||||||
|
type: 'data-source-field-select',
|
||||||
|
text: 'left',
|
||||||
|
fieldConfig: {
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'top',
|
||||||
|
type: 'data-source-field-select',
|
||||||
|
text: 'top',
|
||||||
|
fieldConfig: {
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'row',
|
||||||
|
labelWidth: '68px',
|
||||||
|
display: () => props.values.position !== 'static',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
name: 'right',
|
||||||
|
type: 'data-source-field-select',
|
||||||
|
text: 'right',
|
||||||
|
fieldConfig: {
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'bottom',
|
||||||
|
type: 'data-source-field-select',
|
||||||
|
text: 'bottom',
|
||||||
|
fieldConfig: {
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
labelWidth: '68px',
|
||||||
|
name: 'zIndex',
|
||||||
|
text: 'zIndex',
|
||||||
|
type: 'data-source-field-select',
|
||||||
|
fieldConfig: {
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
const change = (value: string | StyleSchema, eventData: ContainerChangeEventData) => {
|
const change = (value: string | StyleSchema, eventData: ContainerChangeEventData) => {
|
||||||
emit('change', value, eventData);
|
emit('change', value, eventData);
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<MContainer
|
<MContainer
|
||||||
:prop="prop"
|
:prop="prop"
|
||||||
:config="transformConfig"
|
:config="config"
|
||||||
:model="values"
|
:model="values"
|
||||||
:last-values="lastValues"
|
:last-values="lastValues"
|
||||||
:is-compare="isCompare"
|
:is-compare="isCompare"
|
||||||
@ -13,11 +13,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { type ContainerChangeEventData, MContainer } from '@tmagic/form';
|
import { type ContainerChangeEventData, defineFormItem, MContainer } from '@tmagic/form';
|
||||||
import type { StyleSchema } from '@tmagic/schema';
|
import type { StyleSchema } from '@tmagic/schema';
|
||||||
|
|
||||||
import { transformConfig } from '../configs';
|
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
values: Partial<StyleSchema>;
|
values: Partial<StyleSchema>;
|
||||||
lastValues?: Partial<StyleSchema>;
|
lastValues?: Partial<StyleSchema>;
|
||||||
@ -32,6 +30,34 @@ const emit = defineEmits<{
|
|||||||
addDiffCount: [];
|
addDiffCount: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const config = defineFormItem({
|
||||||
|
name: 'transform',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
name: 'rotate',
|
||||||
|
text: '旋转角度',
|
||||||
|
labelWidth: '68px',
|
||||||
|
type: 'data-source-field-select',
|
||||||
|
checkStrictly: false,
|
||||||
|
dataSourceFieldType: ['string', 'number'],
|
||||||
|
fieldConfig: {
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'scale',
|
||||||
|
text: '缩放',
|
||||||
|
labelWidth: '68px',
|
||||||
|
type: 'data-source-field-select',
|
||||||
|
checkStrictly: false,
|
||||||
|
dataSourceFieldType: ['string', 'number'],
|
||||||
|
fieldConfig: {
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
const change = (value: StyleSchema, eventData: ContainerChangeEventData) => {
|
const change = (value: StyleSchema, eventData: ContainerChangeEventData) => {
|
||||||
emit('change', value, eventData);
|
emit('change', value, eventData);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,127 +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.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { isEmpty } from 'lodash-es';
|
|
||||||
|
|
||||||
import { HookCodeType, HookType } from '@tmagic/core';
|
|
||||||
import type { CodeSelectConfig, FormValue, GroupListConfig } from '@tmagic/form/headless';
|
|
||||||
|
|
||||||
import codeBlockService from '@editor/services/codeBlock';
|
|
||||||
import dataSourceService from '@editor/services/dataSource';
|
|
||||||
|
|
||||||
import { stickyAddButton } from './stickyAddButton';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `fields/CodeSelect.vue` 内部渲染的钩子列表配置。
|
|
||||||
*
|
|
||||||
* 由组件与无渲染校验的嵌套配置共用:组件用它渲染,嵌套配置用它让父表单校验到这些字段。
|
|
||||||
*/
|
|
||||||
export const createCodeSelectConfig = (config: CodeSelectConfig): GroupListConfig => {
|
|
||||||
const groupListConfig: GroupListConfig = {
|
|
||||||
type: 'group-list',
|
|
||||||
name: 'hookData',
|
|
||||||
enableToggleMode: false,
|
|
||||||
expandAll: true,
|
|
||||||
defaultAdd: () => ({
|
|
||||||
codeType: HookCodeType.CODE,
|
|
||||||
codeId: '',
|
|
||||||
}),
|
|
||||||
...stickyAddButton(`添加${config.text || ''}`),
|
|
||||||
title: (_mForm: any, { model, index }: any) => {
|
|
||||||
if (model.codeType === HookCodeType.DATA_SOURCE_METHOD) {
|
|
||||||
if (Array.isArray(model.codeId)) {
|
|
||||||
if (model.codeId.length < 2) {
|
|
||||||
return index;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ds = dataSourceService.getDataSourceById(model.codeId[0]);
|
|
||||||
return `${ds?.title} / ${model.codeId[1]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.isArray(model.codeId) ? model.codeId.join('/') : index;
|
|
||||||
}
|
|
||||||
|
|
||||||
const codeContent = codeBlockService.getCodeContentById(model.codeId);
|
|
||||||
|
|
||||||
if (codeContent) {
|
|
||||||
return codeContent.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
return model.codeId || index;
|
|
||||||
},
|
|
||||||
titlePrefix: config.name === undefined ? undefined : String(config.name),
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
text: '代码类型',
|
|
||||||
type: 'select',
|
|
||||||
name: 'codeType',
|
|
||||||
rules: [{ typeMatch: true, trigger: 'change' }],
|
|
||||||
options: [
|
|
||||||
{ value: HookCodeType.CODE, text: '代码块' },
|
|
||||||
{ value: HookCodeType.DATA_SOURCE_METHOD, text: '数据源方法' },
|
|
||||||
],
|
|
||||||
defaultValue: HookCodeType.CODE,
|
|
||||||
onChange: (_mForm: any, v: HookCodeType, { setModel }: any) => {
|
|
||||||
if (v === HookCodeType.DATA_SOURCE_METHOD) {
|
|
||||||
setModel('codeId', []);
|
|
||||||
} else {
|
|
||||||
setModel('codeId', '');
|
|
||||||
}
|
|
||||||
return v;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'code-select-col',
|
|
||||||
name: 'codeId',
|
|
||||||
text: '代码块',
|
|
||||||
rules: [{ typeMatch: true, trigger: 'change' }],
|
|
||||||
|
|
||||||
display: (_mForm: any, { model }: any) => model.codeType !== HookCodeType.DATA_SOURCE_METHOD,
|
|
||||||
notEditable: () => !codeBlockService.getEditStatus(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'data-source-method-select',
|
|
||||||
name: 'codeId',
|
|
||||||
text: '数据源字段',
|
|
||||||
rules: [{ typeMatch: true, trigger: 'change' }],
|
|
||||||
display: (_mForm: any, { model }: any) => model.codeType === HookCodeType.DATA_SOURCE_METHOD,
|
|
||||||
notEditable: () => !dataSourceService.get('editable'),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
} as any as GroupListConfig;
|
|
||||||
|
|
||||||
return groupListConfig;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `code-select` 的旧数据兼容:钩子值可能是空值或空数组,改写成 `{ hookType, hookData }`。
|
|
||||||
*
|
|
||||||
* 表单值初始化时由 `code-select` 的 `effect` 执行(`applyMountValueEffects`);
|
|
||||||
* 组件里的 `watch`(无 immediate)只兜运行期被置空。必须幂等。
|
|
||||||
*/
|
|
||||||
export const normalizeCodeSelectValue = (model: FormValue | undefined, name: string): void => {
|
|
||||||
if (!model) return;
|
|
||||||
|
|
||||||
// 空值或者空数组
|
|
||||||
if (isEmpty(model[name])) {
|
|
||||||
model[name] = {
|
|
||||||
hookType: HookType.CODE,
|
|
||||||
hookData: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,184 +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.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { DisplayCondsConfig, FormState, GroupListConfig } from '@tmagic/form/headless';
|
|
||||||
import { removeDataSourceFieldPrefix } from '@tmagic/utils';
|
|
||||||
|
|
||||||
import dataSourceService from '@editor/services/dataSource';
|
|
||||||
import { getCascaderOptionsFromFields, getFieldType } from '@editor/utils/data-source';
|
|
||||||
|
|
||||||
import { stickyAddButton } from './stickyAddButton';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `fields/DisplayConds.vue` 内部渲染的条件列表配置。
|
|
||||||
*
|
|
||||||
* 外层是条件组,内层 `cond` 是组内条件(groupList,每个字段单独一行)。
|
|
||||||
* 由组件与无渲染校验的嵌套配置共用。
|
|
||||||
*
|
|
||||||
* `parentFields` 由调用方求值:有父级字段路径时用 cascader 在该路径下选字段,
|
|
||||||
* 没有时用 data-source-field-select 从头选。
|
|
||||||
*/
|
|
||||||
export const createDisplayCondsConfig = (
|
|
||||||
config: Pick<DisplayCondsConfig, 'titlePrefix' | 'flat' | 'defaultValue' | 'rules'>,
|
|
||||||
name: string,
|
|
||||||
parentFields: string[],
|
|
||||||
): GroupListConfig => {
|
|
||||||
const resolveFieldPath = (path: string[]) => {
|
|
||||||
const [id, ...fieldNames] = path;
|
|
||||||
const ds = id ? dataSourceService.getDataSourceById(removeDataSourceFieldPrefix(`${id}`)) : undefined;
|
|
||||||
return { ds, fieldNames };
|
|
||||||
};
|
|
||||||
|
|
||||||
// 字段变更后按新字段的类型把已填的值转成对应类型,避免类型校验与运行期取值不一致
|
|
||||||
const fieldOnChange = (_formState: FormState | undefined, v: string[], { model }: { model: Record<string, any> }) => {
|
|
||||||
const { ds, fieldNames } = resolveFieldPath([...parentFields, ...v]);
|
|
||||||
const type = getFieldType(ds, fieldNames);
|
|
||||||
if (type === 'number') {
|
|
||||||
model.value = Number(model.value);
|
|
||||||
} else if (type === 'boolean') {
|
|
||||||
model.value = Boolean(model.value);
|
|
||||||
} else if (type === 'null') {
|
|
||||||
model.value = null;
|
|
||||||
} else {
|
|
||||||
model.value = `${model.value}`;
|
|
||||||
}
|
|
||||||
return v;
|
|
||||||
};
|
|
||||||
|
|
||||||
const fieldItem = parentFields.length
|
|
||||||
? {
|
|
||||||
type: 'cascader',
|
|
||||||
options: () => {
|
|
||||||
const { ds, fieldNames } = resolveFieldPath(parentFields);
|
|
||||||
if (!ds) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
let fields = ds.fields || [];
|
|
||||||
fieldNames.forEach((key) => {
|
|
||||||
const field = fields.find((f) => f.name === key);
|
|
||||||
fields = field?.fields || [];
|
|
||||||
});
|
|
||||||
|
|
||||||
return getCascaderOptionsFromFields(fields, ['string', 'number', 'boolean', 'any']);
|
|
||||||
},
|
|
||||||
name: 'field',
|
|
||||||
value: 'key',
|
|
||||||
text: '字段',
|
|
||||||
checkStrictly: false,
|
|
||||||
onChange: fieldOnChange,
|
|
||||||
defaultValue: () => [],
|
|
||||||
rules: [
|
|
||||||
{ required: true, trigger: 'blur', message: '请选择字段' },
|
|
||||||
{ typeMatch: true, trigger: 'change' },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
type: 'data-source-field-select',
|
|
||||||
name: 'field',
|
|
||||||
value: 'key',
|
|
||||||
text: '字段',
|
|
||||||
checkStrictly: false,
|
|
||||||
dataSourceFieldType: ['string', 'number', 'boolean', 'any'],
|
|
||||||
onChange: fieldOnChange,
|
|
||||||
defaultValue: () => [],
|
|
||||||
rules: [
|
|
||||||
{ required: true, trigger: 'blur', message: '请选择字段' },
|
|
||||||
{ typeMatch: true, trigger: 'change' },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: 'groupList',
|
|
||||||
name,
|
|
||||||
titlePrefix: config.titlePrefix,
|
|
||||||
expandAll: true,
|
|
||||||
enableToggleMode: false,
|
|
||||||
defaultAdd: { cond: [] },
|
|
||||||
...stickyAddButton(`新增${config.titlePrefix || '条件组'}`),
|
|
||||||
flat: config.flat,
|
|
||||||
defaultValue: config.defaultValue ?? [],
|
|
||||||
rules: config.rules ?? [{ typeMatch: true }],
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
type: 'groupList',
|
|
||||||
name: 'cond',
|
|
||||||
titlePrefix: '条件',
|
|
||||||
expandAll: true,
|
|
||||||
enableToggleMode: false,
|
|
||||||
copyable: true,
|
|
||||||
movable: false,
|
|
||||||
flat: true,
|
|
||||||
labelWidth: '80px',
|
|
||||||
...stickyAddButton('新增条件'),
|
|
||||||
items: [
|
|
||||||
fieldItem,
|
|
||||||
{
|
|
||||||
type: 'cond-op-select',
|
|
||||||
parentFields,
|
|
||||||
text: '条件',
|
|
||||||
name: 'op',
|
|
||||||
rules: [
|
|
||||||
{ required: true, trigger: 'blur', message: '请选择条件' },
|
|
||||||
{ typeMatch: true, trigger: 'change' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'value',
|
|
||||||
text: '值',
|
|
||||||
type: (_mForm: FormState | undefined, { model }: any) => {
|
|
||||||
const { ds, fieldNames } = resolveFieldPath([...parentFields, ...(model.field || [])]);
|
|
||||||
const type = getFieldType(ds, fieldNames);
|
|
||||||
|
|
||||||
if (type === 'number') {
|
|
||||||
return 'number';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === 'boolean') {
|
|
||||||
return 'select';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === 'null') {
|
|
||||||
return 'display';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'text';
|
|
||||||
},
|
|
||||||
options: [
|
|
||||||
{ text: 'true', value: true },
|
|
||||||
{ text: 'false', value: false },
|
|
||||||
],
|
|
||||||
display: (_mForm: FormState | undefined, { model }: any) => !['between', 'not_between'].includes(model.op),
|
|
||||||
displayText: (_mForm: FormState | undefined, { model }: any) => {
|
|
||||||
if (model.value === null) {
|
|
||||||
return 'null';
|
|
||||||
}
|
|
||||||
return model.value;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'range',
|
|
||||||
text: '值',
|
|
||||||
type: 'number-range',
|
|
||||||
display: (_mForm: FormState | undefined, { model }: any) => ['between', 'not_between'].includes(model.op),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
} as any as GroupListConfig;
|
|
||||||
};
|
|
||||||
@ -1,309 +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.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { has } from 'lodash-es';
|
|
||||||
|
|
||||||
import { ActionType, type MNode } from '@tmagic/core';
|
|
||||||
import type {
|
|
||||||
CodeSelectColConfig,
|
|
||||||
DataSourceMethodSelectConfig,
|
|
||||||
DynamicTypeConfig,
|
|
||||||
EventSelectConfig,
|
|
||||||
FormState,
|
|
||||||
GroupListConfig,
|
|
||||||
TableConfig,
|
|
||||||
UISelectConfig,
|
|
||||||
} from '@tmagic/form/headless';
|
|
||||||
import { defineFormItem } from '@tmagic/form/headless';
|
|
||||||
|
|
||||||
import codeBlockService from '@editor/services/codeBlock';
|
|
||||||
import dataSourceService from '@editor/services/dataSource';
|
|
||||||
import editorService from '@editor/services/editor';
|
|
||||||
import eventsService from '@editor/services/events';
|
|
||||||
import propsService from '@editor/services/props';
|
|
||||||
import {
|
|
||||||
getCompActionAllowedValues,
|
|
||||||
getCompActionOptions,
|
|
||||||
getEventNameAllowedValues,
|
|
||||||
getEventNameOptions,
|
|
||||||
normalizeCompActionValue,
|
|
||||||
} from '@editor/utils';
|
|
||||||
|
|
||||||
import { stickyAddButton } from './stickyAddButton';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `fields/EventSelect.vue` 内部渲染的各段配置。
|
|
||||||
*
|
|
||||||
* 由组件与无渲染校验的嵌套配置共用:组件用它们渲染,嵌套配置用它们让父表单校验到这些字段。
|
|
||||||
* 每段都支持通过 `config.xxxConfig` 覆盖,覆盖逻辑也必须两边一致,因此一并收口在此。
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** 事件名称下拉框配置,渲染在每张事件卡片的头部 */
|
|
||||||
export const createEventNameConfig = (config: EventSelectConfig) => {
|
|
||||||
const defaultEventNameConfig = {
|
|
||||||
name: 'name',
|
|
||||||
text: '事件类型',
|
|
||||||
type: (_mForm: FormState | undefined, { formValue }: any) => {
|
|
||||||
if (config.src !== 'component' || (formValue.type === 'page-fragment-container' && formValue.pageFragmentId)) {
|
|
||||||
return 'cascader';
|
|
||||||
}
|
|
||||||
return 'select';
|
|
||||||
},
|
|
||||||
labelWidth: '70px',
|
|
||||||
checkStrictly: () => config.src !== 'component',
|
|
||||||
valueSeparator: '.',
|
|
||||||
options: (_mForm: FormState, { formValue }: any) => getEventNameOptions(config.src, formValue),
|
|
||||||
rules: [
|
|
||||||
{
|
|
||||||
validator: ({ value, callback }: any, { formValue }: any) => {
|
|
||||||
const allowedNames = getEventNameAllowedValues(config as any, formValue);
|
|
||||||
if (allowedNames && allowedNames.size > 0 && value && !allowedNames.has(value)) {
|
|
||||||
return callback(`事件名(${value})不存在`);
|
|
||||||
}
|
|
||||||
callback();
|
|
||||||
},
|
|
||||||
trigger: 'blur',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
return { ...defaultEventNameConfig, ...config.eventNameConfig };
|
|
||||||
};
|
|
||||||
|
|
||||||
const createActionTypeOptions = () => {
|
|
||||||
const o: {
|
|
||||||
text: string;
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
}[] = [
|
|
||||||
{
|
|
||||||
text: '组件',
|
|
||||||
label: '组件',
|
|
||||||
value: ActionType.COMP,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (!propsService.getDisabledCodeBlock()) {
|
|
||||||
o.push({
|
|
||||||
text: '代码',
|
|
||||||
label: '代码',
|
|
||||||
disabled: !Object.keys(codeBlockService.getCodeDsl() || {}).length,
|
|
||||||
value: ActionType.CODE,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!propsService.getDisabledDataSource()) {
|
|
||||||
o.push({
|
|
||||||
text: '数据源',
|
|
||||||
label: '数据源',
|
|
||||||
value: ActionType.DATA_SOURCE,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return o;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 联动类型 */
|
|
||||||
const createActionTypeConfig = (config: EventSelectConfig) => {
|
|
||||||
const defaultActionTypeConfig = {
|
|
||||||
name: 'actionType',
|
|
||||||
text: '联动类型',
|
|
||||||
type: 'select',
|
|
||||||
defaultValue: ActionType.COMP,
|
|
||||||
options: createActionTypeOptions(),
|
|
||||||
rules: [
|
|
||||||
{
|
|
||||||
required: true,
|
|
||||||
message: '联动类型不能为空',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
typeMatch: true,
|
|
||||||
trigger: 'blur',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
onChange: (_mForm: FormState, _v: string, { setModel }: any) => {
|
|
||||||
setModel('to', '');
|
|
||||||
setModel('method', '');
|
|
||||||
setModel('codeId', '');
|
|
||||||
setModel('dataSourceMethod', []);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return { ...defaultActionTypeConfig, ...config.actionTypeConfig };
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 联动组件 */
|
|
||||||
const createTargetCompConfig = (config: EventSelectConfig) => {
|
|
||||||
const defaultTargetCompConfig: UISelectConfig = {
|
|
||||||
name: 'to',
|
|
||||||
text: '联动组件',
|
|
||||||
type: 'ui-select',
|
|
||||||
display: (_mForm, { model }) => model.actionType === ActionType.COMP,
|
|
||||||
onChange: (_mForm, _v, { setModel }) => {
|
|
||||||
setModel('method', '');
|
|
||||||
},
|
|
||||||
rules: [
|
|
||||||
{
|
|
||||||
typeMatch: true,
|
|
||||||
trigger: 'blur',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
return { ...defaultTargetCompConfig, ...config.targetCompConfig };
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 联动组件动作 */
|
|
||||||
const createCompActionConfig = (config: EventSelectConfig) => {
|
|
||||||
const defaultCompActionConfig: DynamicTypeConfig = {
|
|
||||||
name: 'method',
|
|
||||||
text: '动作',
|
|
||||||
type: (_mForm: FormState | undefined, { model }: any) => {
|
|
||||||
const to = editorService.getNodeById(model.to);
|
|
||||||
|
|
||||||
if (to?.type === 'page-fragment-container' && to.pageFragmentId) {
|
|
||||||
return 'cascader';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'select';
|
|
||||||
},
|
|
||||||
checkStrictly: () => config.src !== 'component',
|
|
||||||
display: (_mForm: FormState | undefined, { model }: any) => model.actionType === ActionType.COMP,
|
|
||||||
options: (_mForm: FormState, { model }: any) => getCompActionOptions(model.to),
|
|
||||||
rules: [
|
|
||||||
{
|
|
||||||
trigger: 'blur',
|
|
||||||
validator: ({ value, callback }: any, { model }: any) => {
|
|
||||||
const allowedMethods = getCompActionAllowedValues(config as any, model);
|
|
||||||
const normalized = normalizeCompActionValue(value);
|
|
||||||
if (allowedMethods && allowedMethods.size > 0 && normalized && !allowedMethods.has(normalized)) {
|
|
||||||
return callback(`动作名(${normalized})不存在`);
|
|
||||||
}
|
|
||||||
callback();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
return { ...defaultCompActionConfig, ...config.compActionConfig };
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 代码联动 */
|
|
||||||
const createCodeActionConfig = (config: EventSelectConfig) => {
|
|
||||||
const defaultCodeActionConfig: CodeSelectColConfig = {
|
|
||||||
type: 'code-select-col',
|
|
||||||
text: '代码块',
|
|
||||||
name: 'codeId',
|
|
||||||
notEditable: () => !codeBlockService.getEditStatus(),
|
|
||||||
display: (_mForm, { model }) => model.actionType === ActionType.CODE,
|
|
||||||
};
|
|
||||||
return { ...defaultCodeActionConfig, ...config.codeActionConfig };
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 数据源联动 */
|
|
||||||
const createDataSourceActionConfig = (config: EventSelectConfig) => {
|
|
||||||
const defaultDataSourceActionConfig: DataSourceMethodSelectConfig = {
|
|
||||||
type: 'data-source-method-select',
|
|
||||||
text: '数据源方法',
|
|
||||||
name: 'dataSourceMethod',
|
|
||||||
notEditable: () => !dataSourceService.get('editable'),
|
|
||||||
display: (_mForm, { model }) => model.actionType === ActionType.DATA_SOURCE,
|
|
||||||
};
|
|
||||||
return { ...defaultDataSourceActionConfig, ...config.dataSourceActionConfig };
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 单张事件里的动作组 */
|
|
||||||
export const createActionsConfig = (config: EventSelectConfig): GroupListConfig =>
|
|
||||||
defineFormItem({
|
|
||||||
type: 'group-list',
|
|
||||||
name: 'actions',
|
|
||||||
expandAll: true,
|
|
||||||
enableToggleMode: false,
|
|
||||||
titlePrefix: '动作',
|
|
||||||
labelPosition: 'top',
|
|
||||||
flat: true,
|
|
||||||
...stickyAddButton('新增动作'),
|
|
||||||
items: [
|
|
||||||
createActionTypeConfig(config),
|
|
||||||
createTargetCompConfig(config),
|
|
||||||
createCompActionConfig(config),
|
|
||||||
createCodeActionConfig(config),
|
|
||||||
createDataSourceActionConfig(config),
|
|
||||||
],
|
|
||||||
}) as GroupListConfig;
|
|
||||||
|
|
||||||
/** 事件列表(外层 group-list)。事件名走 title slot,body 只放动作组。 */
|
|
||||||
export const createEventSelectConfig = (
|
|
||||||
config: EventSelectConfig,
|
|
||||||
name: string,
|
|
||||||
options?: { includeEventName?: boolean },
|
|
||||||
): GroupListConfig =>
|
|
||||||
defineFormItem({
|
|
||||||
type: 'group-list',
|
|
||||||
name,
|
|
||||||
titlePrefix: '事件',
|
|
||||||
expandAll: true,
|
|
||||||
enableToggleMode: false,
|
|
||||||
movable: false,
|
|
||||||
defaultAdd: { name: '', actions: [] },
|
|
||||||
...stickyAddButton('添加事件'),
|
|
||||||
items: [...(options?.includeEventName ? [createEventNameConfig(config)] : []), createActionsConfig(config)],
|
|
||||||
}) as GroupListConfig;
|
|
||||||
|
|
||||||
/** 兼容旧数据格式(事件列表里没有 actions)时渲染的表格配置,本身不带校验规则 */
|
|
||||||
export const createLegacyTableConfig = (config: EventSelectConfig): TableConfig =>
|
|
||||||
defineFormItem({
|
|
||||||
type: 'table',
|
|
||||||
name: 'events',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
name: 'name',
|
|
||||||
label: '事件名',
|
|
||||||
type: createEventNameConfig(config).type,
|
|
||||||
options: (_mForm: FormState, { formValue }: any) =>
|
|
||||||
eventsService
|
|
||||||
.getEvent(formValue.type, { node: editorService.getNodeById(formValue.id) })
|
|
||||||
.map((option: any) => ({
|
|
||||||
text: option.label,
|
|
||||||
value: option.value,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'to',
|
|
||||||
label: '联动组件',
|
|
||||||
type: 'ui-select',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'method',
|
|
||||||
label: '动作',
|
|
||||||
type: createCompActionConfig(config).type,
|
|
||||||
options: (_mForm: FormState, { model, formValue }: any) => {
|
|
||||||
const node = editorService.getNodeById(model.to) || (formValue as MNode);
|
|
||||||
if (!node?.type) return [];
|
|
||||||
|
|
||||||
return eventsService.getMethod(node.type, { targetId: model.to, node }).map((option: any) => ({
|
|
||||||
text: option.label,
|
|
||||||
value: option.value,
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}) as TableConfig;
|
|
||||||
|
|
||||||
/** 事件列表是否为旧数据格式(列表项里没有 actions) */
|
|
||||||
export const isLegacyEventValue = (events: any): boolean => {
|
|
||||||
if (!Array.isArray(events) || events.length === 0) return false;
|
|
||||||
return !has(events[0], 'actions');
|
|
||||||
};
|
|
||||||
@ -1,32 +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.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 属性面板列表字段共用的吸底全宽「添加」按钮与吸顶标题,展开到 group-list 配置上。
|
|
||||||
*
|
|
||||||
* 吸底按钮会盖住列表底部,吸顶标题会盖住列表顶部,新增的项必须同时滚进视口才看得见。
|
|
||||||
*/
|
|
||||||
export const stickyAddButton = (text: string) => ({
|
|
||||||
scrollLastItemIntoView: true as const,
|
|
||||||
header: { sticky: true as const },
|
|
||||||
addButtonConfig: {
|
|
||||||
sticky: true as const,
|
|
||||||
text,
|
|
||||||
props: { type: 'primary', plain: true, text: false },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@ -1,177 +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.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
type DisplayCondsConfig,
|
|
||||||
type EventSelectConfig,
|
|
||||||
type FieldInnerConfig,
|
|
||||||
type FieldMountValueEffect,
|
|
||||||
filterFunction,
|
|
||||||
type FormItemConfig,
|
|
||||||
type HeadlessFieldOptions,
|
|
||||||
} from '@tmagic/form/headless';
|
|
||||||
import type { StyleSchema } from '@tmagic/schema';
|
|
||||||
|
|
||||||
import { editorTypeMatchRules, validateDataSourceFieldSelect } from '@editor/utils/type-match-rules';
|
|
||||||
|
|
||||||
import { createCodeSelectConfig, normalizeCodeSelectValue } from './configs/codeSelect';
|
|
||||||
import { createDisplayCondsConfig } from './configs/displayConds';
|
|
||||||
import { createEventSelectConfig, isLegacyEventValue } from './configs/eventSelect';
|
|
||||||
import { createStyleSetterConfig } from './StyleSetter/configs';
|
|
||||||
|
|
||||||
const getName = (config: FormItemConfig): string => `${(config as any).name ?? ''}`;
|
|
||||||
|
|
||||||
/** `code-select` 旧数据兼容:空值改写成 `{ hookType, hookData }`。 */
|
|
||||||
const codeSelectEffect: FieldMountValueEffect = ({ config, model }) => {
|
|
||||||
normalizeCodeSelectValue(model, getName(config));
|
|
||||||
};
|
|
||||||
|
|
||||||
/** `event-select`:表单 init 对未声明 defaultValue 的自定义 type 会写成空串,统一收成数组。 */
|
|
||||||
const eventSelectEffect: FieldMountValueEffect = ({ config, model }) => {
|
|
||||||
const name = getName(config);
|
|
||||||
if (model && !Array.isArray(model[name])) {
|
|
||||||
model[name] = [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `code-select` 的内部配置。
|
|
||||||
*
|
|
||||||
* 对应 `fields/CodeSelect.vue` 内部
|
|
||||||
* `<MContainer :config="codeConfig" :model="model[name]" :prop="prop">`。
|
|
||||||
*
|
|
||||||
* @param ctx - innerConfig 回调入参
|
|
||||||
* @returns 内部容器的 config / model / prop
|
|
||||||
*/
|
|
||||||
const codeSelectInnerConfig: FieldInnerConfig = ({ config, model, prop }) => {
|
|
||||||
const name = getName(config);
|
|
||||||
|
|
||||||
return {
|
|
||||||
config: createCodeSelectConfig(config as any),
|
|
||||||
model: model?.[name],
|
|
||||||
prop,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `display-conds` 的内部配置。
|
|
||||||
*
|
|
||||||
* 对应 `fields/DisplayConds.vue` 内部把同一份 groupList 配置交给 `MGroupList`。
|
|
||||||
*
|
|
||||||
* @param ctx - innerConfig 回调入参
|
|
||||||
* @returns 内部 group-list 配置;prop 基准为 parentProp,避免 name 被拼两次
|
|
||||||
*/
|
|
||||||
const displayCondsInnerConfig: FieldInnerConfig = ({ config, model, prop, parentProp, mForm }) => {
|
|
||||||
const name = getName(config);
|
|
||||||
const parentFields =
|
|
||||||
filterFunction<string[]>(mForm, (config as DisplayCondsConfig).parentFields, {
|
|
||||||
model,
|
|
||||||
config,
|
|
||||||
prop,
|
|
||||||
}) || [];
|
|
||||||
|
|
||||||
return {
|
|
||||||
config: createDisplayCondsConfig(config as DisplayCondsConfig, name, parentFields),
|
|
||||||
// 内部 group-list 复用了字段自身的 name,prop 基准要退回父级,否则 name 会被拼两次
|
|
||||||
prop: parentProp,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `event-select` 的内部配置。
|
|
||||||
*
|
|
||||||
* 对应 `fields/EventSelect.vue`:列表走 group-list,事件名渲染在 title slot,
|
|
||||||
* 无渲染校验仍把事件名当作列表项字段,路径为 `<prop>.<index>.name`。
|
|
||||||
*
|
|
||||||
* @param ctx - innerConfig 回调入参
|
|
||||||
* @returns 合成的 group-list 配置;旧数据格式返回 null,不参与校验
|
|
||||||
*/
|
|
||||||
const eventSelectInnerConfig: FieldInnerConfig = ({ config, model, parentProp }) => {
|
|
||||||
const name = getName(config);
|
|
||||||
const events = model?.[name];
|
|
||||||
|
|
||||||
// 旧数据格式走的是另一套表格配置,其中不含任何 rules,不参与校验
|
|
||||||
if (!Array.isArray(events) || isLegacyEventValue(events)) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
config: createEventSelectConfig(config as EventSelectConfig, name, { includeEventName: true }),
|
|
||||||
prop: parentProp,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `style-setter` 的内部配置。
|
|
||||||
*
|
|
||||||
* 对应 `fields/StyleSetter/Index.vue`:6 个面板共用 `:values="model[name]"`、`:prop="prop || name"`。
|
|
||||||
* `theme` 按 `useTheme` 缺省空串传入,只改 flexWrap 的 UI childType,不影响校验字段。
|
|
||||||
*
|
|
||||||
* @param ctx - innerConfig 回调入参
|
|
||||||
* @returns style 面板配置及 styleModel
|
|
||||||
*/
|
|
||||||
const styleSetterInnerConfig: FieldInnerConfig = ({ config, model, prop }) => {
|
|
||||||
const name = getName(config);
|
|
||||||
const styleModel = (model?.[name] ?? {}) as Partial<StyleSchema>;
|
|
||||||
|
|
||||||
return {
|
|
||||||
config: createStyleSetterConfig(styleModel, '', validateDataSourceFieldSelect),
|
|
||||||
model: styleModel,
|
|
||||||
prop,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 编辑器字段的无渲染登记表(不含 Vue 组件)。
|
|
||||||
*
|
|
||||||
* Node 里 `registerFields(editorFields)` 即可配合 `validateForm` / `submitForm`。
|
|
||||||
* 安装编辑器时由 plugin 把 `component` 叠上去再传给 `@tmagic/form`。
|
|
||||||
*
|
|
||||||
* - 叶子:内部只渲染叶子 UI,或把子表单渲染在独立的 MForm / MFormBox 实例里
|
|
||||||
* - innerConfig:内部再渲染 MContainer / MPanel / MGroupList,把运行期配置交出来
|
|
||||||
* - typeMatch:该 type 自身的类型匹配校验
|
|
||||||
*
|
|
||||||
* innerConfig 返回的 config / model / prop 与组件模板里传给内部容器的那一组保持一一对应,
|
|
||||||
* 且配置本身与组件共用同一份工厂(`fields/configs/`)。
|
|
||||||
*/
|
|
||||||
export const editorFields: Record<string, HeadlessFieldOptions> = {
|
|
||||||
'vs-code': {},
|
|
||||||
'code-link': {},
|
|
||||||
'ui-select': { typeMatch: editorTypeMatchRules['ui-select'] },
|
|
||||||
'cond-op-select': { typeMatch: editorTypeMatchRules['cond-op-select'] },
|
|
||||||
'page-fragment-select': { typeMatch: editorTypeMatchRules['page-fragment-select'] },
|
|
||||||
'data-source-select': { typeMatch: editorTypeMatchRules['data-source-select'] },
|
|
||||||
'data-source-input': { typeMatch: editorTypeMatchRules['data-source-input'] },
|
|
||||||
'key-value': { typeMatch: editorTypeMatchRules['key-value'] },
|
|
||||||
'code-select-col': { typeMatch: editorTypeMatchRules['code-select-col'] },
|
|
||||||
'data-source-fields': { typeMatch: editorTypeMatchRules['data-source-fields'] },
|
|
||||||
'data-source-mocks': { typeMatch: editorTypeMatchRules['data-source-mocks'] },
|
|
||||||
'data-source-methods': { typeMatch: editorTypeMatchRules['data-source-methods'] },
|
|
||||||
'data-source-method-select': { typeMatch: editorTypeMatchRules['data-source-method-select'] },
|
|
||||||
'data-source-field-select': { typeMatch: editorTypeMatchRules['data-source-field-select'] },
|
|
||||||
'code-select': {
|
|
||||||
effect: codeSelectEffect,
|
|
||||||
innerConfig: codeSelectInnerConfig,
|
|
||||||
typeMatch: editorTypeMatchRules['code-select'],
|
|
||||||
},
|
|
||||||
'display-conds': { innerConfig: displayCondsInnerConfig, typeMatch: editorTypeMatchRules['display-conds'] },
|
|
||||||
'event-select': {
|
|
||||||
effect: eventSelectEffect,
|
|
||||||
innerConfig: eventSelectInnerConfig,
|
|
||||||
typeMatch: editorTypeMatchRules['event-select'],
|
|
||||||
},
|
|
||||||
'style-setter': { innerConfig: styleSetterInnerConfig, typeMatch: editorTypeMatchRules['style-setter'] },
|
|
||||||
};
|
|
||||||
@ -1,33 +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/editor/headless`:编辑器字段的无渲染登记表。
|
|
||||||
*
|
|
||||||
* 不加载编辑器界面(Editor.vue、各面板、monaco 等),可在无 DOM 的 Node 里 import。
|
|
||||||
* 配合 `@tmagic/form/headless`:`registerBuiltInFields(builtInFields); registerFields(editorFields);`
|
|
||||||
*
|
|
||||||
* 注意这里不是「零 Vue 依赖」:`style-setter` 的字段配置与编辑器 UI 共用同一份工厂
|
|
||||||
* (`fields/StyleSetter/configs.ts`),配置里的 `icon` / `component` 会带进来若干图标
|
|
||||||
* SFC;typeMatch 的提示文案要读画布与数据源,也会带进 editor services。这些都只在
|
|
||||||
* import 时定义、不访问 DOM,但产物体积明显大于 `@tmagic/form/headless`。
|
|
||||||
*
|
|
||||||
* @module @tmagic/editor/headless
|
|
||||||
*/
|
|
||||||
|
|
||||||
export { editorFields } from './fields/headless-validation';
|
|
||||||
@ -16,20 +16,20 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { computed, type ComputedRef, inject, provide, type Ref, ref, useTemplateRef, watch } from 'vue';
|
import { computed, type ComputedRef, inject, provide, type Ref, ref, useTemplateRef, watch, watchEffect } from 'vue';
|
||||||
|
|
||||||
import type { CodeBlockContent, MNode } from '@tmagic/core';
|
import type { CodeBlockContent, MNode } from '@tmagic/core';
|
||||||
import { type FormConfig, type FormContext, type FormValue, MForm } from '@tmagic/form';
|
import { type FormConfig, type FormState, type FormValue, MForm } from '@tmagic/form';
|
||||||
|
|
||||||
import { useEditorFormContext } from '@editor/hooks/use-form-context';
|
|
||||||
import type { CompareFormBaseProps } from '@editor/type';
|
import type { CompareFormBaseProps } from '@editor/type';
|
||||||
import { getCodeBlockFormConfig } from '@editor/utils/code-block';
|
import { getCodeBlockFormConfig } from '@editor/utils/code-block';
|
||||||
|
import { removeStyleDisplayConfig } from '@editor/utils/props';
|
||||||
|
|
||||||
export interface UseCompareFormReturn {
|
export interface UseCompareFormReturn {
|
||||||
config: Ref<FormConfig>;
|
config: Ref<FormConfig>;
|
||||||
currentValues: ComputedRef<FormValue>;
|
currentValues: ComputedRef<FormValue>;
|
||||||
wrapperStyle: ComputedRef<Record<string, string> | undefined>;
|
wrapperStyle: ComputedRef<Record<string, string> | undefined>;
|
||||||
formContext: ComputedRef<FormContext>;
|
mergedExtendState: (state: FormState) => Record<string, any> | Promise<Record<string, any>>;
|
||||||
loadConfig: () => Promise<void>;
|
loadConfig: () => Promise<void>;
|
||||||
formRef: Readonly<Ref<InstanceType<typeof MForm> | null>>;
|
formRef: Readonly<Ref<InstanceType<typeof MForm> | null>>;
|
||||||
normalizeCodeBlockValue: (v: Partial<CodeBlockContent> | Record<string, any> | undefined) => Record<string, any>;
|
normalizeCodeBlockValue: (v: Partial<CodeBlockContent> | Record<string, any> | undefined) => Record<string, any>;
|
||||||
@ -40,7 +40,7 @@ export interface UseCompareFormReturn {
|
|||||||
* - 按 `category`(node / data-source / code-block) 加载 FormConfig(支持自定义 `loadConfig`);
|
* - 按 `category`(node / data-source / code-block) 加载 FormConfig(支持自定义 `loadConfig`);
|
||||||
* - 代码块 `content` 归一化为字符串;
|
* - 代码块 `content` 归一化为字符串;
|
||||||
* - 外层容器固定高度 + 内部滚动的 `wrapperStyle`;
|
* - 外层容器固定高度 + 内部滚动的 `wrapperStyle`;
|
||||||
* - 将 services / stage 作为 form context 传给 MForm,保证 filterFunction 上下文一致。
|
* - 将 services / stage 注入 MForm.formState,保证 filterFunction 上下文一致。
|
||||||
*
|
*
|
||||||
* 两个组件的差异仅在于是否做新旧值对比,这部分逻辑保留在各自组件中。
|
* 两个组件的差异仅在于是否做新旧值对比,这部分逻辑保留在各自组件中。
|
||||||
*/
|
*/
|
||||||
@ -88,7 +88,10 @@ export const useCompareForm = (props: CompareFormBaseProps): UseCompareFormRetur
|
|||||||
return style;
|
return style;
|
||||||
});
|
});
|
||||||
|
|
||||||
const formContext = useEditorFormContext(() => props.services);
|
const mergedExtendState = (state: FormState) => {
|
||||||
|
const extendState = props.extendState ?? ((s: FormState) => s);
|
||||||
|
return extendState(props.baseFormState || state);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 内置的默认 FormConfig 加载逻辑:按 `category` 从对应 service / 工具取配置。
|
* 内置的默认 FormConfig 加载逻辑:按 `category` 从对应 service / 工具取配置。
|
||||||
@ -104,7 +107,9 @@ export const useCompareForm = (props: CompareFormBaseProps): UseCompareFormRetur
|
|||||||
if (!props.type) {
|
if (!props.type) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return props.services.propsService.getPropsConfig(props.type, { node: props.value as unknown as MNode });
|
return removeStyleDisplayConfig(
|
||||||
|
await props.services.propsService.getPropsConfig(props.type, { node: props.value as unknown as MNode }),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
case 'data-source': {
|
case 'data-source': {
|
||||||
const config = props.services.dataSourceService.getFormConfig(props.type || 'base');
|
const config = props.services.dataSourceService.getFormConfig(props.type || 'base');
|
||||||
@ -154,11 +159,27 @@ export const useCompareForm = (props: CompareFormBaseProps): UseCompareFormRetur
|
|||||||
|
|
||||||
const formRef = useTemplateRef<InstanceType<typeof MForm>>('form');
|
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
config,
|
config,
|
||||||
currentValues,
|
currentValues,
|
||||||
wrapperStyle,
|
wrapperStyle,
|
||||||
formContext,
|
mergedExtendState,
|
||||||
loadConfig,
|
loadConfig,
|
||||||
formRef,
|
formRef,
|
||||||
normalizeCodeBlockValue,
|
normalizeCodeBlockValue,
|
||||||
|
|||||||
@ -1,56 +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.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { computed, type ComputedRef, inject, unref } from 'vue';
|
|
||||||
|
|
||||||
import { FORM_CONTEXT_KEY, type FormContext, mergeFormContexts } from '@tmagic/form';
|
|
||||||
|
|
||||||
import type { Services } from '@editor/type';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 编辑器注入给表单的业务上下文:`services` 与当前画布 `stage`。
|
|
||||||
*
|
|
||||||
* 由 `Editor.vue`(provide `FORM_CONTEXT_KEY`)、`FormPanel.vue` 与 `useCompareForm`
|
|
||||||
* 共用。`stage` 走 computed 而非快照,保证切换画布后配置回调读到的是最新实例。
|
|
||||||
*
|
|
||||||
* 宿主可能在 `<MEditor>` 外层 provide 了自己的业务字段,这里必须把那一层合并进来:
|
|
||||||
* 否则编辑器再 provide 一次会把宿主整层遮蔽掉,属性面板、对比表单里的配置回调就
|
|
||||||
* 读不到宿主字段了。`services` / `stage` 由编辑器兜底,优先级高于宿主同名字段。
|
|
||||||
*
|
|
||||||
* 字段类型见 `@editor/type` 里对 `@tmagic/form-schema` 的 `FormContext` 模块增强。
|
|
||||||
*/
|
|
||||||
export const useEditorFormContext = (getServices: () => Services | undefined): ComputedRef<FormContext> => {
|
|
||||||
// inject 只能在 setup 期取值,因此本 hook 必须在 setup 中调用(`use` 前缀即此约定)。
|
|
||||||
// 返回的 computed 可以随便传递,但 hook 本身不能延迟到事件回调或 onMounted 里再调。
|
|
||||||
const hostContext = inject(FORM_CONTEXT_KEY, undefined);
|
|
||||||
|
|
||||||
return computed(() => {
|
|
||||||
const services = getServices();
|
|
||||||
const host = unref(hostContext);
|
|
||||||
|
|
||||||
// 上层(通常是 Editor.vue)已经用同一份 services 合并过了。FormPanel / useCompareForm
|
|
||||||
// 都是它的后代,再合并一次只会多套一层 Proxy,让每次属性 miss 多一轮线性查找。
|
|
||||||
// services 相同即可判定 stage 也相同——两边都是从同一个 editorService 读的。
|
|
||||||
if (host && host.services === services) return host;
|
|
||||||
|
|
||||||
return mergeFormContexts(host, {
|
|
||||||
services,
|
|
||||||
stage: services?.editorService.get('stage'),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -91,6 +91,4 @@ export { default as DisplayConds } from './fields/DisplayConds.vue';
|
|||||||
export { default as CondOpSelect } from './fields/CondOpSelect.vue';
|
export { default as CondOpSelect } from './fields/CondOpSelect.vue';
|
||||||
export { default as StyleSetter } from './fields/StyleSetter/Index.vue';
|
export { default as StyleSetter } from './fields/StyleSetter/Index.vue';
|
||||||
|
|
||||||
export { editorFields } from './fields/headless-validation';
|
|
||||||
|
|
||||||
export { default } from './plugin';
|
export { default } from './plugin';
|
||||||
|
|||||||
@ -29,7 +29,6 @@ import { getDepNodeIds, getNodes, isPage, isValueIncludeDataSource } from '@tmag
|
|||||||
|
|
||||||
import PropsPanel from './layouts/PropsPanel.vue';
|
import PropsPanel from './layouts/PropsPanel.vue';
|
||||||
import { isIncludeDataSource } from './utils/editor';
|
import { isIncludeDataSource } from './utils/editor';
|
||||||
import { error as logError } from './utils/logger';
|
|
||||||
import { EditorProps } from './editorProps';
|
import { EditorProps } from './editorProps';
|
||||||
import { Services } from './type';
|
import { Services } from './type';
|
||||||
|
|
||||||
@ -37,19 +36,6 @@ export declare type LooseRequired<T> = {
|
|||||||
[P in string & keyof T]: T[P];
|
[P in string & keyof T]: T[P];
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* select 为异步串行执行,节点不可选中(如已从 DSL 中移除)时会抛错。
|
|
||||||
* 在 watch 回调中调用不能把拒绝抛回调用方,否则会变成 Vue 的「Unhandled error during execution of watcher callback」,
|
|
||||||
* 这里统一兜底成日志。
|
|
||||||
*/
|
|
||||||
const selectNode = async (editorService: Services['editorService'], id: Id) => {
|
|
||||||
try {
|
|
||||||
await editorService.select(id);
|
|
||||||
} catch (e) {
|
|
||||||
logError(e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const initServiceState = (
|
export const initServiceState = (
|
||||||
props: EditorProps,
|
props: EditorProps,
|
||||||
{
|
{
|
||||||
@ -207,17 +193,9 @@ export const initServiceState = (
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// defaultSelected 可能早于 modelValue 到位(immediate 首次执行时 root 还是空的,或节点尚未出现在 DSL 中),
|
|
||||||
// 此时 select 会因为「获取不到组件信息」而抛错,交由 root-change 中的选中逻辑在 root 就绪后补上即可。
|
|
||||||
watch(
|
watch(
|
||||||
() => props.defaultSelected,
|
() => props.defaultSelected,
|
||||||
(defaultSelected) => {
|
(defaultSelected) => defaultSelected && editorService.select(defaultSelected),
|
||||||
if (!defaultSelected || !editorService.getNodeById(defaultSelected)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
selectNode(editorService, defaultSelected);
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
immediate: true,
|
immediate: true,
|
||||||
},
|
},
|
||||||
@ -254,7 +232,6 @@ export const initServiceState = (
|
|||||||
uiService.resetState();
|
uiService.resetState();
|
||||||
componentListService.resetState();
|
componentListService.resetState();
|
||||||
codeBlockService.resetState();
|
codeBlockService.resetState();
|
||||||
eventsService.resetState();
|
|
||||||
keybindingService.reset();
|
keybindingService.reset();
|
||||||
depService.reset();
|
depService.reset();
|
||||||
});
|
});
|
||||||
@ -427,26 +404,12 @@ export const initServiceEvents = (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* root 是否已经不是编辑器当前的 root。
|
|
||||||
*
|
|
||||||
* `editorService.set('root', v)` 是同步赋值后再派发 `root-change`,因此派发那一刻 `v` 一定
|
|
||||||
* 就是当前 root;但处理 `root-change` 要等 stage / runtime / 依赖收集等异步过程,期间 root
|
|
||||||
* 可能被新的一次整体替换(外部重设 DSL、更新 root 节点、源码保存等)顶掉,此时手上的 `v`
|
|
||||||
* 已是过期快照,继续用它刷画布或回写给外部都会与新 root 互相覆盖。
|
|
||||||
*/
|
|
||||||
const isStaleRoot = (value: MApp | null) => toRaw(editorService.get('root')) !== toRaw(value);
|
|
||||||
|
|
||||||
const updateStageDsl = async (value: MApp | null) => {
|
const updateStageDsl = async (value: MApp | null) => {
|
||||||
const stage = await getStage();
|
const stage = await getStage();
|
||||||
|
|
||||||
const runtime = await stage.renderer?.getRuntime();
|
const runtime = await stage.renderer?.getRuntime();
|
||||||
const app = await getTMagicApp();
|
const app = await getTMagicApp();
|
||||||
|
|
||||||
// 等 stage / runtime 就绪期间 root 已被替换:新 root 的刷新可能已经完成,
|
|
||||||
// 再把旧 dsl 推给 runtime 会让画布回退到旧内容
|
|
||||||
if (isStaleRoot(value)) return;
|
|
||||||
|
|
||||||
if (!app?.dataSourceManager) {
|
if (!app?.dataSourceManager) {
|
||||||
runtime?.updateRootConfig?.(cloneDeep(toRaw(value))!);
|
runtime?.updateRootConfig?.(cloneDeep(toRaw(value))!);
|
||||||
}
|
}
|
||||||
@ -463,8 +426,6 @@ export const initServiceEvents = (
|
|||||||
|
|
||||||
await (typeof Worker === 'undefined' ? collectIdle(value.items, true) : depService.collectByWorker(value));
|
await (typeof Worker === 'undefined' ? collectIdle(value.items, true) : depService.collectByWorker(value));
|
||||||
|
|
||||||
if (isStaleRoot(value)) return;
|
|
||||||
|
|
||||||
const dsl = cloneDeep(toRaw(value));
|
const dsl = cloneDeep(toRaw(value));
|
||||||
if (dsl.dataSources && dsl.dataSourceDeps && app?.dataSourceManager) {
|
if (dsl.dataSources && dsl.dataSourceDeps && app?.dataSourceManager) {
|
||||||
for (const node of getNodes(getDepNodeIds(dsl.dataSourceDeps), dsl.items)) {
|
for (const node of getNodes(getDepNodeIds(dsl.dataSourceDeps), dsl.items)) {
|
||||||
@ -482,7 +443,7 @@ export const initServiceEvents = (
|
|||||||
depService.addTarget(createDataSourceCondTarget(ds, reactive({})));
|
depService.addTarget(createDataSourceCondTarget(ds, reactive({})));
|
||||||
};
|
};
|
||||||
|
|
||||||
const rootChangeHandler = (value: MApp | null) => {
|
const rootChangeHandler = (value: MApp | null, preValue?: MApp | null) => {
|
||||||
if (!value) return;
|
if (!value) return;
|
||||||
|
|
||||||
value.codeBlocks = value.codeBlocks || {};
|
value.codeBlocks = value.codeBlocks || {};
|
||||||
@ -525,13 +486,7 @@ export const initServiceEvents = (
|
|||||||
editorService.set('page', null);
|
editorService.set('page', null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 上面的 select 是异步的,期间 root 可能已被替换,过期快照不能再回写给外部:
|
if (toRaw(value) !== toRaw(preValue)) {
|
||||||
// 两次整体替换各自持有一个快照时,回写会把对方的 root 顶掉,外部 modelValue 变化又会
|
|
||||||
// 重新 set root,两条链路无休止地交替下去(表现为编辑器卡死)
|
|
||||||
if (isStaleRoot(value)) return;
|
|
||||||
|
|
||||||
// 外部已经持有这个 root(如本次变化就是 modelValue 传进来的)时无需回写
|
|
||||||
if (toRaw(props.modelValue) !== toRaw(value)) {
|
|
||||||
emit('update:modelValue', value);
|
emit('update:modelValue', value);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
@ -43,6 +43,8 @@
|
|||||||
:data-source-type="payload.dataSourceType"
|
:data-source-type="payload.dataSourceType"
|
||||||
:value="rightValue"
|
:value="rightValue"
|
||||||
:last-value="leftValue"
|
:last-value="leftValue"
|
||||||
|
:base-form-state="compareFormState"
|
||||||
|
:extend-state="extendState"
|
||||||
:load-config="loadConfig"
|
:load-config="loadConfig"
|
||||||
:self-diff-field-types="selfDiffFieldTypes"
|
:self-diff-field-types="selfDiffFieldTypes"
|
||||||
:services="props.services"
|
:services="props.services"
|
||||||
@ -84,6 +86,7 @@ import {
|
|||||||
TMagicRadioGroup,
|
TMagicRadioGroup,
|
||||||
TMagicTag,
|
TMagicTag,
|
||||||
} from '@tmagic/design';
|
} from '@tmagic/design';
|
||||||
|
import type { FormState } from '@tmagic/form';
|
||||||
|
|
||||||
import CompareForm from '@editor/components/CompareForm.vue';
|
import CompareForm from '@editor/components/CompareForm.vue';
|
||||||
import CodeEditor from '@editor/layouts/CodeEditor.vue';
|
import CodeEditor from '@editor/layouts/CodeEditor.vue';
|
||||||
@ -97,6 +100,12 @@ const props = withDefaults(
|
|||||||
defineProps<{
|
defineProps<{
|
||||||
/** 编辑器服务集合,由调用方传入(不再通过 inject('services') 获取)。 */
|
/** 编辑器服务集合,由调用方传入(不再通过 inject('services') 获取)。 */
|
||||||
services?: Services;
|
services?: Services;
|
||||||
|
/**
|
||||||
|
* 来自 Editor 顶层的 `extendFormState`,用于扩展 MForm.formState。
|
||||||
|
* 透传给 CompareForm,从而让差异对比时表单 item 中依赖业务上下文的
|
||||||
|
* `display` / `disabled` 等 filterFunction 正常工作。
|
||||||
|
*/
|
||||||
|
extendState?: (_state: FormState) => Record<string, any> | Promise<Record<string, any>>;
|
||||||
/**
|
/**
|
||||||
* 自定义 FormConfig 加载逻辑,透传给 CompareForm。传入后将接管内置的按 `category`
|
* 自定义 FormConfig 加载逻辑,透传给 CompareForm。传入后将接管内置的按 `category`
|
||||||
* 取配置逻辑,可通过 `ctx.defaultLoadConfig()` 复用默认结果再做二次加工。
|
* 取配置逻辑,可通过 `ctx.defaultLoadConfig()` 复用默认结果再做二次加工。
|
||||||
@ -108,6 +117,7 @@ const props = withDefaults(
|
|||||||
isConfirm?: boolean;
|
isConfirm?: boolean;
|
||||||
onConfirm?: () => void;
|
onConfirm?: () => void;
|
||||||
selfDiffFieldTypes?: string[];
|
selfDiffFieldTypes?: string[];
|
||||||
|
compareFormState?: FormState;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
width: '900px',
|
width: '900px',
|
||||||
|
|||||||
@ -133,6 +133,7 @@ import { computed, inject, markRaw, ref, watch } from 'vue';
|
|||||||
import { Clock, Close } from '@element-plus/icons-vue';
|
import { Clock, Close } from '@element-plus/icons-vue';
|
||||||
|
|
||||||
import { getDesignConfig, TMagicButton, tMagicMessage, TMagicPopover, TMagicTabs, TMagicTooltip } from '@tmagic/design';
|
import { getDesignConfig, TMagicButton, tMagicMessage, TMagicPopover, TMagicTabs, TMagicTooltip } from '@tmagic/design';
|
||||||
|
import type { FormState } from '@tmagic/form';
|
||||||
|
|
||||||
import MIcon from '@editor/components/Icon.vue';
|
import MIcon from '@editor/components/Icon.vue';
|
||||||
import { useServices } from '@editor/hooks/use-services';
|
import { useServices } from '@editor/hooks/use-services';
|
||||||
@ -192,6 +193,17 @@ watch([disabledDataSource, disabledCodeBlock], ([dsDisabled, cbDisabled]) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过 inject 拿到 Editor 顶层注入的 `extendFormState`,转交给 HistoryDiffDialog
|
||||||
|
* 内部的 CompareForm,使差异对比表单的 filterFunction 能拿到完整的业务上下文。
|
||||||
|
* 未提供时为 undefined,CompareForm/MForm 会跳过 extendState 处理。
|
||||||
|
*/
|
||||||
|
const extendFormState = inject<((_state: FormState) => Record<string, any> | Promise<Record<string, any>>) | undefined>(
|
||||||
|
'extendFormState',
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
const getPropsPanelFormState = inject<(() => FormState | undefined) | undefined>('getPropsPanelFormState', undefined);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
expanded,
|
expanded,
|
||||||
toggleGroup,
|
toggleGroup,
|
||||||
@ -304,7 +316,7 @@ const onCodeBlockGotoInitial = (id: string | number) => {
|
|||||||
* 业务方亦可直接 import useHistoryRevert(options, services) 调用,无需自行挂载任何弹窗。
|
* 业务方亦可直接 import useHistoryRevert(options, services) 调用,无需自行挂载任何弹窗。
|
||||||
*/
|
*/
|
||||||
const { onPageRevert, onDataSourceRevert, onCodeBlockRevert, onPageDiff, onDataSourceDiff, onCodeBlockDiff } =
|
const { onPageRevert, onDataSourceRevert, onCodeBlockRevert, onPageDiff, onDataSourceDiff, onCodeBlockDiff } =
|
||||||
useHistoryRevert({}, services);
|
useHistoryRevert({ extendState: extendFormState, getPropsPanelFormState }, services);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 把内存中(已清空对应类别后的)历史状态重新写回 IndexedDB,
|
* 把内存中(已清空对应类别后的)历史状态重新写回 IndexedDB,
|
||||||
|
|||||||
@ -74,7 +74,7 @@ interface MountedDiffDialog {
|
|||||||
* 弹窗组件动态 import,避免拖累其它消费者。供「确认回滚」与「查看差异」两种交互共用。
|
* 弹窗组件动态 import,避免拖累其它消费者。供「确认回滚」与「查看差异」两种交互共用。
|
||||||
*/
|
*/
|
||||||
const mountHistoryDiffDialog = async (
|
const mountHistoryDiffDialog = async (
|
||||||
options: Pick<UseHistoryRevertOptions, 'appContext'> &
|
options: Pick<UseHistoryRevertOptions, 'appContext' | 'extendState'> &
|
||||||
CustomDiffFormOptions & {
|
CustomDiffFormOptions & {
|
||||||
services?: Services;
|
services?: Services;
|
||||||
isConfirm?: boolean;
|
isConfirm?: boolean;
|
||||||
@ -90,8 +90,10 @@ const mountHistoryDiffDialog = async (
|
|||||||
const app = createApp(historyDiffDialog, {
|
const app = createApp(historyDiffDialog, {
|
||||||
services: options.services,
|
services: options.services,
|
||||||
isConfirm: options.isConfirm,
|
isConfirm: options.isConfirm,
|
||||||
|
extendState: options.extendState,
|
||||||
loadConfig: options.loadConfig,
|
loadConfig: options.loadConfig,
|
||||||
selfDiffFieldTypes: options.selfDiffFieldTypes,
|
selfDiffFieldTypes: options.selfDiffFieldTypes,
|
||||||
|
compareFormState: options.compareFormState,
|
||||||
width: options.width,
|
width: options.width,
|
||||||
size: options.size ?? options.services?.uiService?.get('propsPanelSize'),
|
size: options.size ?? options.services?.uiService?.get('propsPanelSize'),
|
||||||
onClose: options.onClose,
|
onClose: options.onClose,
|
||||||
@ -123,7 +125,7 @@ const mountHistoryDiffDialog = async (
|
|||||||
*/
|
*/
|
||||||
const confirmRevertWithDiffDialog = async (
|
const confirmRevertWithDiffDialog = async (
|
||||||
payload: DiffDialogPayload,
|
payload: DiffDialogPayload,
|
||||||
options: Pick<UseHistoryRevertOptions, 'appContext'> &
|
options: Pick<UseHistoryRevertOptions, 'appContext' | 'extendState'> &
|
||||||
CustomDiffFormOptions & {
|
CustomDiffFormOptions & {
|
||||||
services?: Services;
|
services?: Services;
|
||||||
},
|
},
|
||||||
@ -145,7 +147,7 @@ const confirmRevertWithDiffDialog = async (
|
|||||||
*/
|
*/
|
||||||
const viewHistoryDiffDialog = async (
|
const viewHistoryDiffDialog = async (
|
||||||
payload: DiffDialogPayload,
|
payload: DiffDialogPayload,
|
||||||
options: Pick<UseHistoryRevertOptions, 'appContext'> &
|
options: Pick<UseHistoryRevertOptions, 'appContext' | 'extendState'> &
|
||||||
CustomDiffFormOptions & {
|
CustomDiffFormOptions & {
|
||||||
services?: Services;
|
services?: Services;
|
||||||
},
|
},
|
||||||
@ -192,7 +194,7 @@ const viewHistoryDiffDialog = async (
|
|||||||
export const useHistoryRevert = (options: UseHistoryRevertOptions = {}, services?: Services) => {
|
export const useHistoryRevert = (options: UseHistoryRevertOptions = {}, services?: Services) => {
|
||||||
// 自动捕获调用方所在组件的 appContext(在 setup 中调用时),业务方亦可显式覆盖。
|
// 自动捕获调用方所在组件的 appContext(在 setup 中调用时),业务方亦可显式覆盖。
|
||||||
const appContext = options.appContext ?? getCurrentInstance()?.appContext ?? null;
|
const appContext = options.appContext ?? getCurrentInstance()?.appContext ?? null;
|
||||||
const { dialogWidth } = options;
|
const { extendState, getPropsPanelFormState, dialogWidth } = options;
|
||||||
|
|
||||||
/** 目标数据已被删除、无法回滚时的统一提示。 */
|
/** 目标数据已被删除、无法回滚时的统一提示。 */
|
||||||
const showRevertTargetMissing = () => {
|
const showRevertTargetMissing = () => {
|
||||||
@ -204,7 +206,10 @@ export const useHistoryRevert = (options: UseHistoryRevertOptions = {}, services
|
|||||||
* 不弹差异弹窗,改用一个普通确认框替代「确定回滚」按钮,避免点击后无任何提示直接执行。
|
* 不弹差异弹窗,改用一个普通确认框替代「确定回滚」按钮,避免点击后无任何提示直接执行。
|
||||||
* 用户取消时返回 false,调用方据此中止回滚。
|
* 用户取消时返回 false,调用方据此中止回滚。
|
||||||
*/
|
*/
|
||||||
const confirmRevert = (): Promise<boolean> => confirmHistoryAction('确定回滚该步骤吗?');
|
const confirmRevert = (): Promise<boolean> =>
|
||||||
|
confirmHistoryAction(
|
||||||
|
'确定回滚该步骤吗?回滚会将该操作作为一条新记录反向应用(新增将被删除、删除将被还原),不影响后续历史记录。',
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 「回滚」统一确认入口:可差异对比的步骤动态挂载 HistoryDiffDialog 走差异确认弹窗,
|
* 「回滚」统一确认入口:可差异对比的步骤动态挂载 HistoryDiffDialog 走差异确认弹窗,
|
||||||
@ -215,6 +220,7 @@ export const useHistoryRevert = (options: UseHistoryRevertOptions = {}, services
|
|||||||
if (payload) {
|
if (payload) {
|
||||||
return confirmRevertWithDiffDialog(payload, {
|
return confirmRevertWithDiffDialog(payload, {
|
||||||
appContext,
|
appContext,
|
||||||
|
extendState,
|
||||||
services,
|
services,
|
||||||
...extra,
|
...extra,
|
||||||
width: extra?.width ?? dialogWidth,
|
width: extra?.width ?? dialogWidth,
|
||||||
@ -301,7 +307,7 @@ export const useHistoryRevert = (options: UseHistoryRevertOptions = {}, services
|
|||||||
showRevertTargetMissing();
|
showRevertTargetMissing();
|
||||||
return Promise.resolve(null);
|
return Promise.resolve(null);
|
||||||
}
|
}
|
||||||
return runRevert(buildPageDiffPayload(index), {}).then((result) =>
|
return runRevert(buildPageDiffPayload(index), { compareFormState: getPropsPanelFormState?.() }).then((result) =>
|
||||||
result ? services?.editorService.revertPageStep(index) : null,
|
result ? services?.editorService.revertPageStep(index) : null,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -335,20 +341,22 @@ export const useHistoryRevert = (options: UseHistoryRevertOptions = {}, services
|
|||||||
if (payload) {
|
if (payload) {
|
||||||
return viewHistoryDiffDialog(payload, {
|
return viewHistoryDiffDialog(payload, {
|
||||||
appContext,
|
appContext,
|
||||||
|
extendState,
|
||||||
services,
|
services,
|
||||||
width: dialogWidth,
|
width: dialogWidth,
|
||||||
|
compareFormState: getPropsPanelFormState?.(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDataSourceDiff = (id: string | number, index: number): Promise<void> | void => {
|
const onDataSourceDiff = (id: string | number, index: number): Promise<void> | void => {
|
||||||
const payload = buildDataSourceDiffPayload(id, index);
|
const payload = buildDataSourceDiffPayload(id, index);
|
||||||
if (payload) return viewHistoryDiffDialog(payload, { appContext, services, width: dialogWidth });
|
if (payload) return viewHistoryDiffDialog(payload, { appContext, extendState, services, width: dialogWidth });
|
||||||
};
|
};
|
||||||
|
|
||||||
const onCodeBlockDiff = (id: string | number, index: number): Promise<void> | void => {
|
const onCodeBlockDiff = (id: string | number, index: number): Promise<void> | void => {
|
||||||
const payload = buildCodeBlockDiffPayload(id, index);
|
const payload = buildCodeBlockDiffPayload(id, index);
|
||||||
if (payload) return viewHistoryDiffDialog(payload, { appContext, services, width: dialogWidth });
|
if (payload) return viewHistoryDiffDialog(payload, { appContext, extendState, services, width: dialogWidth });
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -390,6 +398,7 @@ export const useHistoryRevert = (options: UseHistoryRevertOptions = {}, services
|
|||||||
if (payload)
|
if (payload)
|
||||||
return viewHistoryDiffDialog(payload, {
|
return viewHistoryDiffDialog(payload, {
|
||||||
appContext,
|
appContext,
|
||||||
|
extendState,
|
||||||
services,
|
services,
|
||||||
...extra,
|
...extra,
|
||||||
width: extra?.width ?? dialogWidth,
|
width: extra?.width ?? dialogWidth,
|
||||||
|
|||||||
@ -13,7 +13,7 @@
|
|||||||
:init-values="values"
|
:init-values="values"
|
||||||
:config="config"
|
:config="config"
|
||||||
:type-match-valid="true"
|
:type-match-valid="true"
|
||||||
:context="formContext"
|
:extend-state="extendState"
|
||||||
:validate-on-init="true"
|
:validate-on-init="true"
|
||||||
@change="submit"
|
@change="submit"
|
||||||
@error="errorHandler"
|
@error="errorHandler"
|
||||||
@ -45,18 +45,17 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, getCurrentInstance, inject, onMounted, onUnmounted, ref, useTemplateRef } from 'vue';
|
import { computed, getCurrentInstance, inject, onMounted, onUnmounted, ref, useTemplateRef, watchEffect } from 'vue';
|
||||||
import { Document as DocumentIcon } from '@element-plus/icons-vue';
|
import { Document as DocumentIcon } from '@element-plus/icons-vue';
|
||||||
|
|
||||||
import { TMagicButton, tMagicMessage, TMagicScrollbar } from '@tmagic/design';
|
import { TMagicButton, tMagicMessage, TMagicScrollbar } from '@tmagic/design';
|
||||||
import type { ContainerChangeEventData, FormConfig, FormValue } from '@tmagic/form';
|
import type { ContainerChangeEventData, FormConfig, FormState, FormValue } from '@tmagic/form';
|
||||||
import { MForm, validateForm } from '@tmagic/form';
|
import { MForm, validateForm } from '@tmagic/form';
|
||||||
import { filterXSS } from '@tmagic/utils';
|
import { filterXSS } from '@tmagic/utils';
|
||||||
|
|
||||||
import MIcon from '@editor/components/Icon.vue';
|
import MIcon from '@editor/components/Icon.vue';
|
||||||
import { ENABLE_PROPS_FORM_VALIDATE } from '@editor/editorProps';
|
import { ENABLE_PROPS_FORM_VALIDATE } from '@editor/editorProps';
|
||||||
import { useEditorContentHeight } from '@editor/hooks/use-editor-content-height';
|
import { useEditorContentHeight } from '@editor/hooks/use-editor-content-height';
|
||||||
import { useEditorFormContext } from '@editor/hooks/use-form-context';
|
|
||||||
import { useServices } from '@editor/hooks/use-services';
|
import { useServices } from '@editor/hooks/use-services';
|
||||||
|
|
||||||
import CodeEditor from '../CodeEditor.vue';
|
import CodeEditor from '../CodeEditor.vue';
|
||||||
@ -75,7 +74,8 @@ const props = defineProps<{
|
|||||||
disabledShowSrc?: boolean;
|
disabledShowSrc?: boolean;
|
||||||
labelWidth?: string;
|
labelWidth?: string;
|
||||||
codeValueKey?: string;
|
codeValueKey?: string;
|
||||||
labelPosition?: 'top' | 'left' | 'right';
|
labelPosition?: string;
|
||||||
|
extendState?: (_state: FormState) => Record<string, any> | Promise<Record<string, any>>;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@ -89,18 +89,24 @@ const emit = defineEmits<{
|
|||||||
const enablePropsFormValidate = inject(ENABLE_PROPS_FORM_VALIDATE, false);
|
const enablePropsFormValidate = inject(ENABLE_PROPS_FORM_VALIDATE, false);
|
||||||
|
|
||||||
const services = useServices();
|
const services = useServices();
|
||||||
const { uiService } = services;
|
const { editorService, uiService } = services;
|
||||||
|
|
||||||
const codeOptions = inject('codeOptions', {});
|
const codeOptions = inject('codeOptions', {});
|
||||||
|
|
||||||
const showSrc = ref(false);
|
const showSrc = ref(false);
|
||||||
const propsPanelSize = computed(() => uiService.get('propsPanelSize') || 'small');
|
const propsPanelSize = computed(() => uiService.get('propsPanelSize') || 'small');
|
||||||
const { height: editorContentHeight } = useEditorContentHeight();
|
const { height: editorContentHeight } = useEditorContentHeight();
|
||||||
|
const stage = computed(() => editorService.get('stage'));
|
||||||
const formContext = useEditorFormContext(() => services);
|
|
||||||
|
|
||||||
const configFormRef = useTemplateRef<InstanceType<typeof MForm>>('configForm');
|
const configFormRef = useTemplateRef<InstanceType<typeof MForm>>('configForm');
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
if (configFormRef.value) {
|
||||||
|
configFormRef.value.formState.stage = stage.value;
|
||||||
|
configFormRef.value.formState.services = services;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const internalInstance = getCurrentInstance();
|
const internalInstance = getCurrentInstance();
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
emit('mounted', internalInstance?.proxy);
|
emit('mounted', internalInstance?.proxy);
|
||||||
@ -152,17 +158,23 @@ const saveCode = async (values: any) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 启用校验联动:源码编辑器保存的值未经过表单交互,这里对最新配置做一次无渲染的静默校验
|
// 启用校验联动:源码编辑器保存的值未经过表单交互,这里另建一个独立的 MForm 实例对最新配置
|
||||||
// (不挂载任何组件,也不影响页面上正在展示的表单),并将校验结果(错误信息)随提交
|
// 做一次静默校验(不复用、也不污染页面上正在展示的表单),并将校验结果(错误信息)随提交
|
||||||
// 一并抛给上层记录,使源码保存的错误状态与表单编辑保持一致。
|
// 一并抛给上层记录,使源码保存的错误状态与表单编辑保持一致。
|
||||||
//
|
|
||||||
// 配置里的 display / rules 回调会从 formState 上读 services,因此必须带上 context。
|
|
||||||
try {
|
try {
|
||||||
const error = await validateForm({
|
const error = await validateForm({
|
||||||
config: props.config,
|
config: props.config,
|
||||||
typeMatchValid: true,
|
typeMatchValid: true,
|
||||||
initValues: newValues,
|
initValues: newValues,
|
||||||
context: formContext.value,
|
// 将当前组件实例的 provides(含 Editor 顶层的 services / codeOptions 等组件级 provide)
|
||||||
|
// 合入 appContext,使临时 MForm 中的编辑器字段组件(DataSourceInput 等)能正常 inject
|
||||||
|
appContext: internalInstance?.appContext ? { ...internalInstance?.appContext, provides: { services } } : null,
|
||||||
|
extendState: (state) => {
|
||||||
|
if (configFormRef.value?.formState) {
|
||||||
|
return { ...(configFormRef.value?.formState || {}) };
|
||||||
|
}
|
||||||
|
return props.extendState?.(state) || {};
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@ -175,10 +187,9 @@ const saveCode = async (values: any) => {
|
|||||||
|
|
||||||
emit('submit', newValues, undefined, error ? new Error(error) : undefined);
|
emit('submit', newValues, undefined, error ? new Error(error) : undefined);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error('validateForm error', e);
|
console.log('validateForm error', e);
|
||||||
// 嵌套配置 / initValue 等机制故障不得当成「校验通过」,
|
// 静默校验本身出现异常(如初始化超时)时,退回到不携带错误信息的提交,避免阻塞源码保存
|
||||||
// 把 error 交给上层记录,同时仍提交源码,避免阻塞保存。
|
emit('submit', newValues);
|
||||||
emit('submit', newValues, undefined, e instanceof Error ? e : new Error(String(e)));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
:config="curFormConfig"
|
:config="curFormConfig"
|
||||||
:values="values"
|
:values="values"
|
||||||
:disabledShowSrc="disabledShowSrc"
|
:disabledShowSrc="disabledShowSrc"
|
||||||
|
:extendState="extendState"
|
||||||
@submit="(v, eventData, error) => submit(v, eventData, error, 'props')"
|
@submit="(v, eventData, error) => submit(v, eventData, error, 'props')"
|
||||||
@submit-error="errorHandler"
|
@submit-error="errorHandler"
|
||||||
@form-error="errorHandler"
|
@form-error="errorHandler"
|
||||||
@ -25,6 +26,7 @@
|
|||||||
:config="styleFormConfig"
|
:config="styleFormConfig"
|
||||||
:values="values"
|
:values="values"
|
||||||
:disabledShowSrc="disabledShowSrc"
|
:disabledShowSrc="disabledShowSrc"
|
||||||
|
:extendState="extendState"
|
||||||
@submit="(v, eventData, error) => submit(v, eventData, error, 'style')"
|
@submit="(v, eventData, error) => submit(v, eventData, error, 'style')"
|
||||||
@submit-error="errorHandler"
|
@submit-error="errorHandler"
|
||||||
@form-error="errorHandler"
|
@form-error="errorHandler"
|
||||||
@ -59,7 +61,7 @@ import type { OnDrag } from 'gesto';
|
|||||||
|
|
||||||
import { type MNode } from '@tmagic/core';
|
import { type MNode } from '@tmagic/core';
|
||||||
import { TMagicButton } from '@tmagic/design';
|
import { TMagicButton } from '@tmagic/design';
|
||||||
import type { ContainerChangeEventData, FormValue } from '@tmagic/form';
|
import type { ContainerChangeEventData, FormState, FormValue } from '@tmagic/form';
|
||||||
import { setValueByKeyPath } from '@tmagic/utils';
|
import { setValueByKeyPath } from '@tmagic/utils';
|
||||||
|
|
||||||
import MIcon from '@editor/components/Icon.vue';
|
import MIcon from '@editor/components/Icon.vue';
|
||||||
@ -82,6 +84,7 @@ defineOptions({
|
|||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
disabledShowSrc?: boolean;
|
disabledShowSrc?: boolean;
|
||||||
|
extendState?: (_state: FormState) => Record<string, any> | Promise<Record<string, any>>;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@ -155,34 +158,14 @@ const submit = async (
|
|||||||
v.id = values.value.id;
|
v.id = values.value.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 区分操作途径:表单字段编辑(MForm @change)会带上 eventData(含 changeRecords);
|
const newValue: MNode = {
|
||||||
// 源码编辑器(CodeEditor @save → saveCode)保存时不带 eventData,据此标记为「源码编辑器」。
|
|
||||||
const historySource = eventData ? 'props' : 'code';
|
|
||||||
// 源码编辑统一走整节点 replace,避免 merge 保留已删除字段。
|
|
||||||
const replace = historySource === 'code';
|
|
||||||
|
|
||||||
let newValue: MNode;
|
|
||||||
|
|
||||||
if (replace) {
|
|
||||||
if (source === 'style') {
|
|
||||||
// 样式面板源码仅提交 { style }:先把新 style 替换到原节点上,再整节点覆盖。
|
|
||||||
newValue = {
|
|
||||||
...values.value,
|
|
||||||
id: v.id || values.value.id,
|
|
||||||
style: { ...(v.style || {}) },
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
// 属性面板源码提交完整节点 DSL
|
|
||||||
newValue = { ...v };
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
newValue = {
|
|
||||||
...v,
|
...v,
|
||||||
style: {},
|
style: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (v.style) {
|
if (v.style) {
|
||||||
// 空字符串样式值表示「清除该样式」,需保留才能在 doUpdate 的 mergeWith 中覆盖旧值。
|
// 空字符串样式值表示「清除该样式」,需保留才能在 doUpdate 的 mergeWith 中覆盖旧值。
|
||||||
|
if (eventData) {
|
||||||
// 表单编辑:先过滤掉空字符串(避免表单默认空值污染 DSL),
|
// 表单编辑:先过滤掉空字符串(避免表单默认空值污染 DSL),
|
||||||
// 再按 changeRecords 恢复被主动清空的字段。
|
// 再按 changeRecords 恢复被主动清空的字段。
|
||||||
Object.entries(v.style).forEach(([key, value]) => {
|
Object.entries(v.style).forEach(([key, value]) => {
|
||||||
@ -191,18 +174,24 @@ const submit = async (
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
eventData?.changeRecords?.forEach((record) => {
|
eventData.changeRecords?.forEach((record) => {
|
||||||
if (record.propPath?.startsWith('style') && record.value === '') {
|
if (record.propPath?.startsWith('style') && record.value === '') {
|
||||||
setValueByKeyPath(record.propPath, record.value, newValue);
|
setValueByKeyPath(record.propPath, record.value, newValue);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
// 源码编辑器保存(无 eventData):style 原样保留,其中的空字符串视为用户主动清除该样式。
|
||||||
|
newValue.style = { ...v.style };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 区分操作途径:表单字段编辑(MForm @change)会带上 eventData(含 changeRecords);
|
||||||
|
// 源码编辑器(CodeEditor @save → saveCode)保存时不带 eventData,据此标记为「源码编辑器」。
|
||||||
|
const historySource = eventData ? 'props' : 'code';
|
||||||
|
|
||||||
editorService.update(newValue, {
|
editorService.update(newValue, {
|
||||||
changeRecords: eventData?.changeRecords,
|
changeRecords: eventData?.changeRecords,
|
||||||
historySource,
|
historySource,
|
||||||
replace,
|
|
||||||
// 启用校验联动时,仅校验失败(error 存在)才把错误信息随更新传入 editorService 记录;
|
// 启用校验联动时,仅校验失败(error 存在)才把错误信息随更新传入 editorService 记录;
|
||||||
// 其余情况(含表单校验成功、CodeEditor 源码保存)不携带 invalidInfo,由 editorService 在执行 update 时统一清除该节点错误。
|
// 其余情况(含表单校验成功、CodeEditor 源码保存)不携带 invalidInfo,由 editorService 在执行 update 时统一清除该节点错误。
|
||||||
...(enablePropsFormValidate && error ? { invalidInfo: { id: newValue.id, source, error: error?.message } } : {}),
|
...(enablePropsFormValidate && error ? { invalidInfo: { id: newValue.id, source, error: error?.message } } : {}),
|
||||||
|
|||||||
@ -71,11 +71,7 @@ const stage = computed(() => editorService.get('stage'));
|
|||||||
const list = computed<ComponentGroup[]>(() =>
|
const list = computed<ComponentGroup[]>(() =>
|
||||||
componentListService.getList().map((group: ComponentGroup) => ({
|
componentListService.getList().map((group: ComponentGroup) => ({
|
||||||
...group,
|
...group,
|
||||||
items: group.items.filter((item: ComponentItem) =>
|
items: group.items.filter((item: ComponentItem) => item.text.includes(searchText.value)),
|
||||||
`${item.text || ''}${item.desc || ''}${item.type}`
|
|
||||||
.toLocaleLowerCase()
|
|
||||||
.includes(searchText.value.toLocaleLowerCase()),
|
|
||||||
),
|
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -82,7 +82,7 @@ const props = defineProps<{
|
|||||||
customContentMenu: CustomContentMenuFunction;
|
customContentMenu: CustomContentMenuFunction;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const eventBus = inject<EventBus | null>('eventBus', null);
|
const eventBus = inject<EventBus>('eventBus');
|
||||||
|
|
||||||
const { codeBlockService } = useServices();
|
const { codeBlockService } = useServices();
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import ContentMenu from '@editor/components/ContentMenu.vue';
|
|||||||
import type { ContentMenuTarget, EventBus, MenuButton, MenuComponent, TreeNodeData } from '@editor/type';
|
import type { ContentMenuTarget, EventBus, MenuButton, MenuComponent, TreeNodeData } from '@editor/type';
|
||||||
|
|
||||||
export const useContentMenu = (deleteCode: (id: string) => void) => {
|
export const useContentMenu = (deleteCode: (id: string) => void) => {
|
||||||
const eventBus = inject<EventBus | null>('eventBus', null);
|
const eventBus = inject<EventBus>('eventBus');
|
||||||
const menuRef = useTemplateRef<InstanceType<typeof ContentMenu>>('menu');
|
const menuRef = useTemplateRef<InstanceType<typeof ContentMenu>>('menu');
|
||||||
|
|
||||||
let selectId = '';
|
let selectId = '';
|
||||||
|
|||||||
@ -75,7 +75,7 @@ const props = defineProps<{
|
|||||||
customContentMenu: CustomContentMenuFunction;
|
customContentMenu: CustomContentMenuFunction;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const eventBus = inject<EventBus | null>('eventBus', null);
|
const eventBus = inject<EventBus>('eventBus');
|
||||||
const { dataSourceService } = useServices();
|
const { dataSourceService } = useServices();
|
||||||
|
|
||||||
const { editDialog, dataSourceValues, dialogTitle, editable, editHandler, submitDataSourceHandler } =
|
const { editDialog, dataSourceValues, dialogTitle, editable, editHandler, submitDataSourceHandler } =
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import ContentMenu from '@editor/components/ContentMenu.vue';
|
|||||||
import type { ContentMenuTarget, EventBus, MenuButton, MenuComponent, TreeNodeData } from '@editor/type';
|
import type { ContentMenuTarget, EventBus, MenuButton, MenuComponent, TreeNodeData } from '@editor/type';
|
||||||
|
|
||||||
export const useContentMenu = () => {
|
export const useContentMenu = () => {
|
||||||
const eventBus = inject<EventBus | null>('eventBus', null);
|
const eventBus = inject<EventBus>('eventBus');
|
||||||
const menuRef = useTemplateRef<InstanceType<typeof ContentMenu>>('menu');
|
const menuRef = useTemplateRef<InstanceType<typeof ContentMenu>>('menu');
|
||||||
|
|
||||||
let selectId = '';
|
let selectId = '';
|
||||||
|
|||||||
@ -120,7 +120,7 @@ const filterNodeMethod = (v: string, data: MNode): boolean => {
|
|||||||
name = 'container';
|
name = 'container';
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${data.id}${name}${data.type || ''}`.toLocaleLowerCase().includes(v.toLocaleLowerCase());
|
return `${data.id}${name}${data.type}`.includes(v);
|
||||||
};
|
};
|
||||||
|
|
||||||
const { filterTextChangeHandler } = useFilter(nodeData, nodeStatusMap, filterNodeMethod);
|
const { filterTextChangeHandler } = useFilter(nodeData, nodeStatusMap, filterNodeMethod);
|
||||||
|
|||||||
@ -20,8 +20,8 @@ import { type App } from 'vue';
|
|||||||
|
|
||||||
import type { DesignPluginOptions } from '@tmagic/design';
|
import type { DesignPluginOptions } from '@tmagic/design';
|
||||||
import designPlugin from '@tmagic/design';
|
import designPlugin from '@tmagic/design';
|
||||||
import type { FieldOptions, FormInstallOptions } from '@tmagic/form';
|
import type { FormInstallOptions } from '@tmagic/form';
|
||||||
import formPlugin, { mergeFieldOptions } from '@tmagic/form';
|
import formPlugin, { registerTypeMatchRules } from '@tmagic/form';
|
||||||
import tablePlugin from '@tmagic/table';
|
import tablePlugin from '@tmagic/table';
|
||||||
|
|
||||||
import Code from './fields/Code.vue';
|
import Code from './fields/Code.vue';
|
||||||
@ -38,13 +38,13 @@ import DataSourceMocks from './fields/DataSourceMocks.vue';
|
|||||||
import DataSourceSelect from './fields/DataSourceSelect.vue';
|
import DataSourceSelect from './fields/DataSourceSelect.vue';
|
||||||
import DisplayConds from './fields/DisplayConds.vue';
|
import DisplayConds from './fields/DisplayConds.vue';
|
||||||
import EventSelect from './fields/EventSelect.vue';
|
import EventSelect from './fields/EventSelect.vue';
|
||||||
import { editorFields } from './fields/headless-validation';
|
|
||||||
import KeyValue from './fields/KeyValue.vue';
|
import KeyValue from './fields/KeyValue.vue';
|
||||||
import PageFragmentSelect from './fields/PageFragmentSelect.vue';
|
import PageFragmentSelect from './fields/PageFragmentSelect.vue';
|
||||||
import StyleSetter from './fields/StyleSetter/Index.vue';
|
import StyleSetter from './fields/StyleSetter/Index.vue';
|
||||||
import uiSelect from './fields/UISelect.vue';
|
import uiSelect from './fields/UISelect.vue';
|
||||||
import CodeEditor from './layouts/CodeEditor.vue';
|
import CodeEditor from './layouts/CodeEditor.vue';
|
||||||
import { setEditorConfig } from './utils/config';
|
import { setEditorConfig } from './utils/config';
|
||||||
|
import { editorTypeMatchRules } from './utils/type-match-rules';
|
||||||
import Editor from './Editor.vue';
|
import Editor from './Editor.vue';
|
||||||
import type { EditorInstallOptions } from './type';
|
import type { EditorInstallOptions } from './type';
|
||||||
|
|
||||||
@ -60,43 +60,36 @@ const defaultInstallOpt: EditorInstallOptions = {
|
|||||||
flat: false,
|
flat: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const editorFieldVue: Record<string, Pick<FieldOptions, 'component' | 'container'>> = {
|
|
||||||
'vs-code': { component: Code },
|
|
||||||
'ui-select': { component: uiSelect },
|
|
||||||
'cond-op-select': { component: CondOpSelect },
|
|
||||||
'page-fragment-select': { component: PageFragmentSelect },
|
|
||||||
'data-source-select': { component: DataSourceSelect },
|
|
||||||
'data-source-input': { component: DataSourceInput },
|
|
||||||
'code-link': { component: CodeLink },
|
|
||||||
'key-value': { component: KeyValue },
|
|
||||||
'code-select-col': { component: CodeSelectCol },
|
|
||||||
'data-source-fields': { component: DataSourceFields },
|
|
||||||
'data-source-mocks': { component: DataSourceMocks },
|
|
||||||
'data-source-methods': { component: DataSourceMethods },
|
|
||||||
'data-source-method-select': { component: DataSourceMethodSelect },
|
|
||||||
'data-source-field-select': { component: DataSourceFieldSelect },
|
|
||||||
'code-select': { component: CodeSelect },
|
|
||||||
'display-conds': { component: DisplayConds },
|
|
||||||
'event-select': { component: EventSelect },
|
|
||||||
'style-setter': { container: StyleSetter },
|
|
||||||
};
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
install: (app: App, opt?: Partial<EditorInstallOptions | DesignPluginOptions | FormInstallOptions>): void => {
|
install: (app: App, opt?: Partial<EditorInstallOptions | DesignPluginOptions | FormInstallOptions>): void => {
|
||||||
const incoming = opt || {};
|
const option = Object.assign(defaultInstallOpt, opt || {});
|
||||||
const option = { ...defaultInstallOpt, ...incoming };
|
|
||||||
const formOpt = incoming as FormInstallOptions;
|
|
||||||
|
|
||||||
app.use(designPlugin, incoming);
|
app.use(designPlugin, opt || {});
|
||||||
app.use(formPlugin, {
|
app.use(formPlugin, opt || {});
|
||||||
...formOpt,
|
|
||||||
fields: mergeFieldOptions(editorFields, editorFieldVue, formOpt.fields),
|
|
||||||
});
|
|
||||||
app.use(tablePlugin);
|
app.use(tablePlugin);
|
||||||
|
registerTypeMatchRules(editorTypeMatchRules);
|
||||||
|
|
||||||
app.config.globalProperties.$TMAGIC_EDITOR = option;
|
app.config.globalProperties.$TMAGIC_EDITOR = option;
|
||||||
setEditorConfig(option);
|
setEditorConfig(option);
|
||||||
app.component(`${Editor.name || 'MEditor'}`, Editor);
|
app.component(`${Editor.name || 'MEditor'}`, Editor);
|
||||||
app.component('magic-code-editor', CodeEditor);
|
app.component('magic-code-editor', CodeEditor);
|
||||||
|
app.component('m-fields-ui-select', uiSelect);
|
||||||
|
app.component('m-fields-code-link', CodeLink);
|
||||||
|
app.component('m-fields-vs-code', Code);
|
||||||
|
app.component('m-fields-code-select', CodeSelect);
|
||||||
|
app.component('m-fields-code-select-col', CodeSelectCol);
|
||||||
|
app.component('m-fields-event-select', EventSelect);
|
||||||
|
app.component('m-fields-data-source-fields', DataSourceFields);
|
||||||
|
app.component('m-fields-data-source-mocks', DataSourceMocks);
|
||||||
|
app.component('m-fields-key-value', KeyValue);
|
||||||
|
app.component('m-fields-data-source-input', DataSourceInput);
|
||||||
|
app.component('m-fields-data-source-select', DataSourceSelect);
|
||||||
|
app.component('m-fields-data-source-methods', DataSourceMethods);
|
||||||
|
app.component('m-fields-data-source-method-select', DataSourceMethodSelect);
|
||||||
|
app.component('m-fields-data-source-field-select', DataSourceFieldSelect);
|
||||||
|
app.component('m-fields-page-fragment-select', PageFragmentSelect);
|
||||||
|
app.component('m-fields-display-conds', DisplayConds);
|
||||||
|
app.component('m-fields-cond-op-select', CondOpSelect);
|
||||||
|
app.component('m-form-style-setter', StyleSetter);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -56,7 +56,6 @@ import type {
|
|||||||
StepValue,
|
StepValue,
|
||||||
StoreState,
|
StoreState,
|
||||||
StoreStateKey,
|
StoreStateKey,
|
||||||
UpdateOptions,
|
|
||||||
} from '@editor/type';
|
} from '@editor/type';
|
||||||
import { canUsePluginMethods, LayerOffset, Layout } from '@editor/type';
|
import { canUsePluginMethods, LayerOffset, Layout } from '@editor/type';
|
||||||
import {
|
import {
|
||||||
@ -84,10 +83,6 @@ import { beforePaste, getAddParent } from '@editor/utils/operator';
|
|||||||
|
|
||||||
type MoveItem = { node: MNode; parent: MContainer; pageForOp: { name: string; id: Id } | null };
|
type MoveItem = { node: MNode; parent: MContainer; pageForOp: { name: string; id: Id } | null };
|
||||||
|
|
||||||
/** 历史插回时把记录的下标收敛到 [0, length],越界(含未记录)一律追加到末尾 */
|
|
||||||
const clampIndex = (index: number | undefined, length: number): number =>
|
|
||||||
typeof index === 'number' && index >= 0 && index <= length ? index : length;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 把「变更前后节点快照」列表归一成 update 类型的 {@link StepDiffItem} 列表,供 {@link StepValue.diff} 使用。
|
* 把「变更前后节点快照」列表归一成 update 类型的 {@link StepDiffItem} 列表,供 {@link StepValue.diff} 使用。
|
||||||
* `changeRecords` 来自 form 端的 propPath/value 列表,撤销/重做时只对这些 propPath 做局部更新;
|
* `changeRecords` 来自 form 端的 propPath/value 列表,撤销/重做时只对这些 propPath 做局部更新;
|
||||||
@ -131,11 +126,6 @@ class Editor extends BaseService {
|
|||||||
* 普通操作不会读取它,调用前由 *AndGetHistoryId 重置为 null。
|
* 普通操作不会读取它,调用前由 *AndGetHistoryId 重置为 null。
|
||||||
*/
|
*/
|
||||||
private lastPushedHistoryId: string | null = null;
|
private lastPushedHistoryId: string | null = null;
|
||||||
/**
|
|
||||||
* 上一次 doAdd 的插入记忆:nodeId 为插入的节点,selectedId 为当时的选中节点。
|
|
||||||
* 仅在选中节点未变化时复用(连续 add / doNotSelect / 批量粘贴),选中变化后自动失效,无需手动清理。
|
|
||||||
*/
|
|
||||||
private lastAdded: { selectedId: Id; nodeId: Id } | null = null;
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super(
|
super(
|
||||||
@ -414,7 +404,7 @@ class Editor extends BaseService {
|
|||||||
this.set('highlightNode', null);
|
this.set('highlightNode', null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async doAdd(node: MNode, parent: MContainer, _options: DslOpOptions = {}): Promise<MNode> {
|
public async doAdd(node: MNode, parent: MContainer): Promise<MNode> {
|
||||||
const root = this.get('root');
|
const root = this.get('root');
|
||||||
|
|
||||||
if (!root) throw new Error('root为空');
|
if (!root) throw new Error('root为空');
|
||||||
@ -428,16 +418,13 @@ class Editor extends BaseService {
|
|||||||
throw new Error('app下不能添加组件');
|
throw new Error('app下不能添加组件');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 连续 add 时接在上一个新增节点之后,否则接在当前选中节点之后;
|
if (parent.id !== curNode.id && !isPageOrFragment(node)) {
|
||||||
// 锚点节点可能已不在 items 中(被删 / DSL 整体替换),回退到当前选中节点,都不在则追加到末尾
|
const index = parent.items.indexOf(curNode);
|
||||||
const anchorId = this.lastAdded?.selectedId === curNode.id ? this.lastAdded.nodeId : curNode.id;
|
parent.items?.splice(index + 1, 0, node);
|
||||||
const anchorIndex = isPageOrFragment(node)
|
} else {
|
||||||
? -1
|
// 新增节点添加到配置中
|
||||||
: Math.max(getNodeIndex(anchorId, parent), getNodeIndex(curNode.id, parent));
|
parent.items?.push(node);
|
||||||
const insertIndex = anchorIndex < 0 ? parent.items.length : anchorIndex + 1;
|
}
|
||||||
|
|
||||||
parent.items.splice(insertIndex, 0, node);
|
|
||||||
this.lastAdded = { selectedId: curNode.id, nodeId: node.id };
|
|
||||||
|
|
||||||
const layout = await this.getLayout(toRaw(parent), node as MNode);
|
const layout = await this.getLayout(toRaw(parent), node as MNode);
|
||||||
node.style = getInitPositionStyle(node.style, layout);
|
node.style = getInitPositionStyle(node.style, layout);
|
||||||
@ -447,7 +434,6 @@ class Editor extends BaseService {
|
|||||||
parent: cloneDeep(parent),
|
parent: cloneDeep(parent),
|
||||||
parentId: parent.id,
|
parentId: parent.id,
|
||||||
root: cloneDeep(root),
|
root: cloneDeep(root),
|
||||||
index: insertIndex,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const newStyle = fixNodePosition(node, parent, stage);
|
const newStyle = fixNodePosition(node, parent, stage);
|
||||||
@ -466,23 +452,24 @@ class Editor extends BaseService {
|
|||||||
* 向指点容器添加组件节点
|
* 向指点容器添加组件节点
|
||||||
* @param addConfig 将要添加的组件节点配置
|
* @param addConfig 将要添加的组件节点配置
|
||||||
* @param parent 要添加到的容器组件节点配置,如果不设置,默认为当前选中的组件的父节点
|
* @param parent 要添加到的容器组件节点配置,如果不设置,默认为当前选中的组件的父节点
|
||||||
* @param options 可选配置,见 {@link DslOpOptions}
|
* @param options 可选配置
|
||||||
|
* @param options.doNotSelect 添加后是否不更新当前选中节点(默认 false,添加后会选中新增的节点)
|
||||||
|
* @param options.doNotSwitchPage 添加后是否不切换当前页面(默认 false;新增页面 / 跨页新增时为 true 会跳过会引发页面切换的选中操作)
|
||||||
|
* @param options.doNotPushHistory 是否不写入历史记录(默认 false)
|
||||||
* @returns 添加后的节点
|
* @returns 添加后的节点
|
||||||
*/
|
*/
|
||||||
public async add(
|
public async add(
|
||||||
addNode: AddMNode | MNode[],
|
addNode: AddMNode | MNode[],
|
||||||
parent?: MContainer | null,
|
parent?: MContainer | null,
|
||||||
options: DslOpOptions = {},
|
{
|
||||||
): Promise<MNode | MNode[]> {
|
|
||||||
this.captureSelectionBeforeOp();
|
|
||||||
|
|
||||||
const {
|
|
||||||
doNotSelect = false,
|
doNotSelect = false,
|
||||||
doNotSwitchPage = false,
|
doNotSwitchPage = false,
|
||||||
doNotPushHistory = false,
|
doNotPushHistory = false,
|
||||||
historyDescription,
|
historyDescription,
|
||||||
historySource,
|
historySource,
|
||||||
} = options;
|
}: DslOpOptions = {},
|
||||||
|
): Promise<MNode | MNode[]> {
|
||||||
|
this.captureSelectionBeforeOp();
|
||||||
|
|
||||||
const stage = this.get('stage');
|
const stage = this.get('stage');
|
||||||
|
|
||||||
@ -498,15 +485,17 @@ class Editor extends BaseService {
|
|||||||
addNodes.push(...addNode);
|
addNodes.push(...addNode);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 必须串行:每个节点的插入位置依赖上一个已插入的节点,并行时插入顺序会受
|
const newNodes = await Promise.all(
|
||||||
// doAdd 前置耗时(如异步 beforeDoAdd 插件钩子)影响而错乱
|
addNodes.map((node) => {
|
||||||
const newNodes: MNode[] = [];
|
|
||||||
for (const node of addNodes) {
|
|
||||||
const root = this.get('root');
|
const root = this.get('root');
|
||||||
const parentNode = isPageOrFragment(node) && root ? root : (parent ?? getAddParent(node));
|
if (isPageOrFragment(node) && root) {
|
||||||
if (!parentNode) throw new Error('未找到父元素');
|
return this.doAdd(node, root);
|
||||||
newNodes.push(await this.doAdd(node, parentNode, options));
|
|
||||||
}
|
}
|
||||||
|
const parentNode = parent ?? getAddParent(node);
|
||||||
|
if (!parentNode) throw new Error('未找到父元素');
|
||||||
|
return this.doAdd(node, parentNode);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
if (newNodes.length > 1) {
|
if (newNodes.length > 1) {
|
||||||
// 多选时只要任一新增节点位于非当前页面,触发的 multiSelect 就会引起页面切换
|
// 多选时只要任一新增节点位于非当前页面,触发的 multiSelect 就会引起页面切换
|
||||||
@ -665,8 +654,16 @@ class Editor extends BaseService {
|
|||||||
* @param options.doNotSwitchPage 删除后是否不切换当前页面(默认 false;删除页面 / 页面片段时为 true 会跳过自动切换到首个剩余页面)
|
* @param options.doNotSwitchPage 删除后是否不切换当前页面(默认 false;删除页面 / 页面片段时为 true 会跳过自动切换到首个剩余页面)
|
||||||
* @param options.doNotPushHistory 是否不写入历史记录(默认 false)
|
* @param options.doNotPushHistory 是否不写入历史记录(默认 false)
|
||||||
*/
|
*/
|
||||||
public async remove(nodeOrNodeList: MNode | MNode[], options: DslOpOptions = {}): Promise<void> {
|
public async remove(
|
||||||
const { doNotPushHistory = false, historyDescription, historySource } = options;
|
nodeOrNodeList: MNode | MNode[],
|
||||||
|
{
|
||||||
|
doNotSelect = false,
|
||||||
|
doNotSwitchPage = false,
|
||||||
|
doNotPushHistory = false,
|
||||||
|
historyDescription,
|
||||||
|
historySource,
|
||||||
|
}: DslOpOptions = {},
|
||||||
|
): Promise<void> {
|
||||||
this.captureSelectionBeforeOp();
|
this.captureSelectionBeforeOp();
|
||||||
|
|
||||||
const nodes = Array.isArray(nodeOrNodeList) ? nodeOrNodeList : [nodeOrNodeList];
|
const nodes = Array.isArray(nodeOrNodeList) ? nodeOrNodeList : [nodeOrNodeList];
|
||||||
@ -693,7 +690,7 @@ class Editor extends BaseService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all(nodes.map((node) => this.doRemove(node, options)));
|
await Promise.all(nodes.map((node) => this.doRemove(node, { doNotSelect, doNotSwitchPage })));
|
||||||
|
|
||||||
// 删除节点时同步清理其(含子树)的校验错误记录;置于 pushOpHistory 之前,使历史快照与本次删除对齐。
|
// 删除节点时同步清理其(含子树)的校验错误记录;置于 pushOpHistory 之前,使历史快照与本次删除对齐。
|
||||||
this.removeInvalidNodesBySubtree(nodes);
|
this.removeInvalidNodesBySubtree(nodes);
|
||||||
@ -723,9 +720,8 @@ class Editor extends BaseService {
|
|||||||
|
|
||||||
public async doUpdate(
|
public async doUpdate(
|
||||||
config: MNode,
|
config: MNode,
|
||||||
data: UpdateOptions = {},
|
{ changeRecords = [], historySource }: { changeRecords?: ChangeRecord[]; historySource?: HistoryOpSource } = {},
|
||||||
): Promise<{ newNode: MNode; oldNode: MNode; changeRecords?: ChangeRecord[] }> {
|
): Promise<{ newNode: MNode; oldNode: MNode; changeRecords?: ChangeRecord[] }> {
|
||||||
const { changeRecords = [], historySource, replace = false } = data;
|
|
||||||
const root = this.get('root');
|
const root = this.get('root');
|
||||||
if (!root) throw new Error('root为空');
|
if (!root) throw new Error('root为空');
|
||||||
|
|
||||||
@ -737,14 +733,9 @@ class Editor extends BaseService {
|
|||||||
|
|
||||||
const node = toRaw(info.node);
|
const node = toRaw(info.node);
|
||||||
|
|
||||||
// replace=true 时跳过 toggleFixedPosition / mergeWith / setChildrenLayout,直接用传入配置整节点替换
|
let newConfig = await toggleFixedPosition(toRaw(config), node, info.path, this.getLayout);
|
||||||
let newConfig = replace
|
|
||||||
? cloneDeep(toRaw(config))
|
|
||||||
: await toggleFixedPosition(toRaw(config), node, info.path, this.getLayout);
|
|
||||||
|
|
||||||
if (!replace) {
|
|
||||||
newConfig = mergeWith(cloneDeep(node), newConfig, editorNodeMergeCustomizer);
|
newConfig = mergeWith(cloneDeep(node), newConfig, editorNodeMergeCustomizer);
|
||||||
}
|
|
||||||
|
|
||||||
if (!newConfig.type) throw new Error('配置缺少type值');
|
if (!newConfig.type) throw new Error('配置缺少type值');
|
||||||
|
|
||||||
@ -765,13 +756,11 @@ class Editor extends BaseService {
|
|||||||
|
|
||||||
if (!parentNodeItems || typeof index === 'undefined' || index === -1) throw new Error('更新的节点未找到');
|
if (!parentNodeItems || typeof index === 'undefined' || index === -1) throw new Error('更新的节点未找到');
|
||||||
|
|
||||||
if (!replace) {
|
|
||||||
const newLayout = await this.getLayout(newConfig);
|
const newLayout = await this.getLayout(newConfig);
|
||||||
const layout = await this.getLayout(node);
|
const layout = await this.getLayout(node);
|
||||||
if (Array.isArray(newConfig.items) && newLayout !== layout) {
|
if (Array.isArray(newConfig.items) && newLayout !== layout) {
|
||||||
newConfig = setChildrenLayout(newConfig as MContainer, newLayout);
|
newConfig = setChildrenLayout(newConfig as MContainer, newLayout);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
parentNodeItems[index] = newConfig;
|
parentNodeItems[index] = newConfig;
|
||||||
|
|
||||||
@ -804,10 +793,28 @@ class Editor extends BaseService {
|
|||||||
* 更新节点
|
* 更新节点
|
||||||
* update后会触发依赖收集,收集完后会掉stage.update方法
|
* update后会触发依赖收集,收集完后会掉stage.update方法
|
||||||
* @param config 新的节点配置,配置中需要有id信息
|
* @param config 新的节点配置,配置中需要有id信息
|
||||||
* @param data 额外数据,见 {@link UpdateOptions}
|
* @param data 额外数据
|
||||||
|
* @param data.changeRecords 单节点 form 端变更记录(多节点场景下被忽略,使用 changeRecordList)
|
||||||
|
* @param data.changeRecordList 多节点 form 端变更记录列表,按 config 数组同序对应每个节点;优先级高于 changeRecords
|
||||||
|
* @param data.doNotPushHistory 是否不写入历史记录(默认 false)
|
||||||
|
* @param data.historyDescription 入栈时附带的人类可读描述,用于历史面板展示(不影响 undo/redo 行为)
|
||||||
* @returns 更新后的节点配置
|
* @returns 更新后的节点配置
|
||||||
*/
|
*/
|
||||||
public async update(config: MNode | MNode[], data: UpdateOptions = {}): Promise<MNode | MNode[]> {
|
public async update(
|
||||||
|
config: MNode | MNode[],
|
||||||
|
data: {
|
||||||
|
changeRecords?: ChangeRecord[];
|
||||||
|
changeRecordList?: ChangeRecord[][];
|
||||||
|
doNotPushHistory?: boolean;
|
||||||
|
historyDescription?: string;
|
||||||
|
historySource?: HistoryOpSource;
|
||||||
|
/**
|
||||||
|
* 属性面板提交时携带的校验错误信息,在写入历史记录之前落库,
|
||||||
|
* 使历史快照与本次变更对齐,从而 undo/redo 能正确还原错误标记。
|
||||||
|
*/
|
||||||
|
invalidInfo?: { id: Id; source: NodeInvalidSource; error?: string };
|
||||||
|
} = {},
|
||||||
|
): Promise<MNode | MNode[]> {
|
||||||
this.captureSelectionBeforeOp();
|
this.captureSelectionBeforeOp();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@ -826,7 +833,7 @@ class Editor extends BaseService {
|
|||||||
const updateData = await Promise.all(
|
const updateData = await Promise.all(
|
||||||
nodes.map((node, index) => {
|
nodes.map((node, index) => {
|
||||||
const recordsForNode = changeRecordList ? (changeRecordList[index] ?? []) : (changeRecords ?? []);
|
const recordsForNode = changeRecordList ? (changeRecordList[index] ?? []) : (changeRecords ?? []);
|
||||||
return this.doUpdate(node, { ...data, changeRecords: recordsForNode });
|
return this.doUpdate(node, { changeRecords: recordsForNode, historySource });
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -1335,7 +1342,13 @@ class Editor extends BaseService {
|
|||||||
/** 等价于 {@link update},并额外返回本次写入历史记录的 uuid 列表(未入栈时 historyIds 为 `[]`)。 */
|
/** 等价于 {@link update},并额外返回本次写入历史记录的 uuid 列表(未入栈时 historyIds 为 `[]`)。 */
|
||||||
public async updateAndGetHistoryId(
|
public async updateAndGetHistoryId(
|
||||||
config: MNode | MNode[],
|
config: MNode | MNode[],
|
||||||
data: UpdateOptions = {},
|
data: {
|
||||||
|
changeRecords?: ChangeRecord[];
|
||||||
|
changeRecordList?: ChangeRecord[][];
|
||||||
|
doNotPushHistory?: boolean;
|
||||||
|
historyDescription?: string;
|
||||||
|
historySource?: HistoryOpSource;
|
||||||
|
} = {},
|
||||||
): Promise<DslOpWithHistoryIdsResult<MNode | MNode[]>> {
|
): Promise<DslOpWithHistoryIdsResult<MNode | MNode[]>> {
|
||||||
this.lastPushedHistoryId = null;
|
this.lastPushedHistoryId = null;
|
||||||
const result = await this.update(config, data);
|
const result = await this.update(config, data);
|
||||||
@ -1955,15 +1968,17 @@ class Editor extends BaseService {
|
|||||||
const parent = this.getNodeById(parentId, false) as MContainer | null;
|
const parent = this.getNodeById(parentId, false) as MContainer | null;
|
||||||
if (parent?.items) {
|
if (parent?.items) {
|
||||||
const addedNode = cloneDeep(newSchema);
|
const addedNode = cloneDeep(newSchema);
|
||||||
const insertIndex = clampIndex(index, parent.items.length);
|
if (typeof index === 'number' && index >= 0 && index < parent.items.length) {
|
||||||
parent.items.splice(insertIndex, 0, addedNode);
|
parent.items.splice(index, 0, addedNode);
|
||||||
|
} else {
|
||||||
|
parent.items.push(addedNode);
|
||||||
|
}
|
||||||
addedNodes.push(addedNode);
|
addedNodes.push(addedNode);
|
||||||
await stage?.add({
|
await stage?.add({
|
||||||
config: cloneDeep(newSchema),
|
config: cloneDeep(newSchema),
|
||||||
parent: cloneDeep(parent),
|
parent: cloneDeep(parent),
|
||||||
parentId: parent.id,
|
parentId: parent.id,
|
||||||
root: cloneDeep(root),
|
root: cloneDeep(root),
|
||||||
index: insertIndex,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1982,15 +1997,13 @@ class Editor extends BaseService {
|
|||||||
const parent = this.getNodeById(parentId, false) as MContainer | null;
|
const parent = this.getNodeById(parentId, false) as MContainer | null;
|
||||||
if (parent?.items) {
|
if (parent?.items) {
|
||||||
const addedNode = cloneDeep(oldSchema);
|
const addedNode = cloneDeep(oldSchema);
|
||||||
const insertIndex = clampIndex(index, parent.items.length);
|
parent.items.splice(index ?? parent.items.length, 0, addedNode);
|
||||||
parent.items.splice(insertIndex, 0, addedNode);
|
|
||||||
addedNodes.push(addedNode);
|
addedNodes.push(addedNode);
|
||||||
await stage?.add({
|
await stage?.add({
|
||||||
config: cloneDeep(oldSchema),
|
config: cloneDeep(oldSchema),
|
||||||
parent: cloneDeep(parent),
|
parent: cloneDeep(parent),
|
||||||
parentId,
|
parentId,
|
||||||
root: cloneDeep(root),
|
root: cloneDeep(root),
|
||||||
index: insertIndex,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,8 +35,8 @@ const canUsePluginMethods = {
|
|||||||
type AsyncMethodName = Writable<(typeof canUsePluginMethods)['async']>;
|
type AsyncMethodName = Writable<(typeof canUsePluginMethods)['async']>;
|
||||||
type SyncMethodName = Writable<(typeof canUsePluginMethods)['sync']>;
|
type SyncMethodName = Writable<(typeof canUsePluginMethods)['sync']>;
|
||||||
|
|
||||||
const eventMap: Record<string, EventOption[]> = reactive({});
|
let eventMap: Record<string, EventOption[]> = reactive({});
|
||||||
const methodMap: Record<string, EventOption[]> = reactive({});
|
let methodMap: Record<string, EventOption[]> = reactive({});
|
||||||
|
|
||||||
class Events extends BaseService {
|
class Events extends BaseService {
|
||||||
constructor() {
|
constructor() {
|
||||||
@ -75,9 +75,8 @@ class Events extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public resetState() {
|
public resetState() {
|
||||||
// 原地清空而不是重新赋值,保留 reactive 代理身份,已建立的 computed / 渲染副作用才能收到更新
|
eventMap = reactive({});
|
||||||
Object.keys(eventMap).forEach((type) => delete eventMap[type]);
|
methodMap = reactive({});
|
||||||
Object.keys(methodMap).forEach((type) => delete methodMap[type]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public destroy() {
|
public destroy() {
|
||||||
|
|||||||
@ -1,3 +1,40 @@
|
|||||||
.m-fields-code-select {
|
.m-fields-code-select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
> .el-card.tmagic-design-card--flat {
|
||||||
|
background-color: transparent;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
> .el-card__body {
|
||||||
|
padding-left: 0;
|
||||||
|
.code-select-content {
|
||||||
|
> .m-fields-group-list {
|
||||||
|
> .tmagic-design-card--flat.el-card {
|
||||||
|
padding-left: 16px;
|
||||||
|
padding-right: 16px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.el-card__body {
|
||||||
|
.tmagic-design-form-item {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.code-select-content {
|
||||||
|
> .m-fields-group-list {
|
||||||
|
> .tmagic-design-card--flat {
|
||||||
|
> .el-card__header {
|
||||||
|
padding: 16px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.create-button {
|
||||||
|
&.fullWidth {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,3 @@
|
|||||||
.m-fields-display-conds {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-container-text.display-conds-title {
|
.m-container-text.display-conds-title {
|
||||||
.tmagic-design-form-item {
|
.tmagic-design-form-item {
|
||||||
.el-form-item__label {
|
.el-form-item__label {
|
||||||
|
|||||||
@ -1,45 +1,49 @@
|
|||||||
.m-fields-event-select {
|
.m-fields-event-select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
.fullWidth {
|
.fullWidth {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
&.create-button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
&.m-container-ui-event {
|
&.m-container-ui-event {
|
||||||
width: calc(100% - 32px);
|
width: calc(100% - 32px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-select-container {
|
.event-select-container {
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
|
> .el-card.tmagic-design-card--flat {
|
||||||
> .m-fields-group-list
|
> .el-card__header {
|
||||||
> .m-fields-group-list-item.tmagic-design-card--flat {
|
|
||||||
> .el-card__header,
|
|
||||||
> .t-card__header {
|
|
||||||
border-bottom: 1px solid #ebeef5;
|
border-bottom: 1px solid #ebeef5;
|
||||||
}
|
}
|
||||||
|
> .el-card__body {
|
||||||
> .el-card__body,
|
|
||||||
> .t-card__body {
|
|
||||||
padding-bottom: 16px;
|
padding-bottom: 16px;
|
||||||
|
.m-fields-group-list-footer {
|
||||||
.el-card.tmagic-design-card--flat,
|
div {
|
||||||
.t-card.tmagic-design-card--flat {
|
justify-content: flex-start !important;
|
||||||
> .el-card__header,
|
}
|
||||||
> .t-card__header {
|
}
|
||||||
|
.el-card.tmagic-design-card--flat {
|
||||||
|
> .el-card__header {
|
||||||
padding: 16px 0;
|
padding: 16px 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-select-code {
|
.event-select-code {
|
||||||
margin-left: 20px;
|
margin-left: 20px;
|
||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.m-form-panel {
|
||||||
|
margin: 10px 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.el-card.is-always-shadow {
|
||||||
|
box-shadow: 0px 0px 5px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
}
|
||||||
.m-fields-code-select-col,
|
.m-fields-code-select-col,
|
||||||
.m-fields-data-source-method-select {
|
.m-fields-data-source-method-select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@ -59,6 +63,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.event-select-container {
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.event-select-header {
|
.event-select-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -71,13 +79,28 @@
|
|||||||
line-height: 24px;
|
line-height: 24px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-item-header {
|
.event-item-header {
|
||||||
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
.tmagic-design-form-item {
|
display: flex;
|
||||||
margin-bottom: 0;
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
.event-item-title {
|
||||||
|
color: #0f1113;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 24px;
|
||||||
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
.el-form-item .el-form-item.is-error {
|
.event-item-delete-button {
|
||||||
|
color: #0f1113;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-form-item {
|
||||||
|
.el-form-item {
|
||||||
|
&.is-error {
|
||||||
margin-bottom: 18px;
|
margin-bottom: 18px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -125,28 +125,14 @@
|
|||||||
.m-editor-props-form-panel-form {
|
.m-editor-props-form-panel-form {
|
||||||
padding-right: 10px;
|
padding-right: 10px;
|
||||||
padding-left: 10px;
|
padding-left: 10px;
|
||||||
// 吸顶标题避开绝对定位的 tabs header
|
|
||||||
--m-group-list-header-sticky-top: var(
|
|
||||||
--el-tabs-header-height,
|
|
||||||
var(--td-comp-size-xxl, 48px)
|
|
||||||
);
|
|
||||||
|
|
||||||
> .m-container-tab {
|
> .m-container-tab {
|
||||||
> .tmagic-design-tabs {
|
> .tmagic-design-tabs {
|
||||||
> .el-tabs__content,
|
> .el-tabs__content {
|
||||||
> .t-tabs__content {
|
margin-top: var(--el-tabs-header-height);
|
||||||
margin-top: var(--el-tabs-header-height, var(--td-comp-size-xxl, 48px));
|
|
||||||
padding-top: 15px;
|
padding-top: 15px;
|
||||||
// 允许吸底按钮相对属性面板滚动容器定位(tabs 默认 overflow:hidden 会截断 sticky)
|
|
||||||
overflow: visible;
|
|
||||||
|
|
||||||
> .el-tab-pane,
|
|
||||||
> .t-tab-panel {
|
|
||||||
overflow: visible;
|
|
||||||
}
|
}
|
||||||
}
|
> .el-tabs__header.is-top {
|
||||||
> .el-tabs__header.is-top,
|
|
||||||
> .t-tabs__header {
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@ -137,8 +137,7 @@
|
|||||||
.m-editor-props-form-panel-form {
|
.m-editor-props-form-panel-form {
|
||||||
> .m-container-tab {
|
> .m-container-tab {
|
||||||
> .tmagic-design-tabs {
|
> .tmagic-design-tabs {
|
||||||
> .el-tabs__content,
|
> .el-tabs__content {
|
||||||
> .t-tabs__content {
|
|
||||||
background-color: #fafafa;
|
background-color: #fafafa;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding-left: 16px;
|
padding-left: 16px;
|
||||||
@ -147,16 +146,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.m-fields-group-list-footer.is-sticky-full {
|
|
||||||
--m-group-list-footer-bg: #fafafa;
|
|
||||||
background-color: #fafafa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-fields-group-list-item .m-fields-group-list-footer.is-sticky-full {
|
|
||||||
--m-group-list-footer-bg: #fff;
|
|
||||||
background-color: #fff;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.m-editor.m-theme--magic-admin {
|
.m-editor.m-theme--magic-admin {
|
||||||
|
|||||||
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