Compare commits

...

37 Commits

Author SHA1 Message Date
roymondchen
911cecfb1d chore: release v1.8.0-beta.28 2026-09-09 11:25:18 +08:00
roymondchen
2a4a54a495 feat(form): group-list 标题吸顶改为按 header.sticky 显式开启
默认关闭避免长列表误吸顶,嵌套层按外层真实标题/吸底按钮高度让位;属性面板列表通过 stickyAddButton 保持原表现。
2026-09-09 11:19:54 +08:00
roymondchen
7e4546899b test(editor): 补充历史回滚单测并精简确认弹窗文案 2026-09-09 11:17:22 +08:00
roymondchen
66b54a52f8 fix(editor): 丢弃过期 root 快照,避免画布回退与 modelValue 回写死循环 2026-09-07 19:02:59 +08:00
roymondchen
7edb01d330 chore: update lockfile v1.8.0-beta.27 2026-09-04 11:56:13 +08:00
roymondchen
5fa9b37856 chore: release v1.8.0-beta.27 2026-09-04 11:34:03 +08:00
roymondchen
d1cb8c3853 fix(form): 避免 Select init 请求失败或路径缺失时抛出未处理异常 2026-09-04 11:27:06 +08:00
roymondchen
092bc2914d feat(data-source): 新增数据源 mounted 生命周期
页面渲染完成后由 runtime 触发 mounted 事件,统一管理数据源 mounted 及 timing=mounted 的方法执行;支持异步注册数据源在 init 后补充 mounted。
2026-09-03 20:27:18 +08:00
roymondchen
959c0322b6 fix(editor): 修复 eventsService resetState 响应式失效并在编辑器重置时清空
resetState 改为原地清空 reactive 对象以保留代理身份,避免 computed 不更新。
编辑器 reset 时同步调用 eventsService.resetState,并补充文档与单测。
2026-09-03 20:02:03 +08:00
roymondchen
b0d43dc277 fix(editor,form): 合并宿主 FORM_CONTEXT 并修复 formState mForm 自引用
编辑器不再遮蔽外层 provide 的业务上下文。
formState Proxy 支持 Vue2 时代 vm.mForm 跨字段通信,
并修复 context 自引用导致的栈溢出。
Select 对 valueKey 路径使用可选链。
2026-09-02 16:44:01 +08:00
roymondchen
ad2f4e15cc docs: 修正类型定义引用路径并为 editor 类型添加 region 标记 2026-09-01 20:01:04 +08:00
roymondchen
f163112eab chore: update lockfile v1.8.0-beta.25 2026-09-01 19:46:20 +08:00
roymondchen
260582f141 chore: release v1.8.0-beta.26 2026-09-01 19:35:44 +08:00
roymondchen
392f9ae7f0 ci: 落地 85% 行覆盖率硬门禁并校正 AGENTS.md 2026-09-01 15:44:20 +08:00
roymondchen
08b610a4f2 fix(editor): 避免 defaultSelected 早于 root 就绪时 select 抛出未处理错误 2026-09-01 15:30:25 +08:00
roymondchen
58f2a943b8 fix(editor): inject eventBus 时提供 null 默认值
避免未处于 Editor 子树时 Vue 报 injection "eventBus" not found。
2026-09-01 15:11:59 +08:00
roymondchen
90e4bec70b fix(form): 避免 Tooltip 无合法触发器时报 ElOnlyChild
对比模式删除按钮与表格未就绪时,ElTooltip 默认插槽会变成 Comment,触发 Element Plus 警告。
2026-09-01 15:09:59 +08:00
roymondchen
a7999f50d5 feat(form): 用 FormContext 与 provide/inject 替代 extendState 钩子
统一表单业务上下文的注入方式,通过读穿 Proxy 保持 mForm 回调签名不变,并移除 extendFormState/extendState 等分散的扩展钩子。
2026-09-01 14:48:42 +08:00
roymondchen
f2ee7ae7b5 feat(form): 统一字段值 effect 机制并将 nested 重命名为 innerConfig
将字段挂载时的 model 写入迁移至 registerField effect,
渲染与无渲染校验共用 applyMountValueEffects;
nested 重命名为 innerConfig 以区分配置派生与值写入。
2026-09-01 12:05:28 +08:00
roymondchen
02eeb2e87f feat(editor): 统一列表字段添加按钮样式并支持新增后自动滚动
CodeSelect、EventSelect、DisplayConds 使用吸底全宽添加按钮;
group-list 新增后滚到最后一项,避开吸顶标题与吸底按钮。
2026-09-01 12:02:09 +08:00
roymondchen
c34ed6aafc feat(form): 容器透传 labelPosition
容器未单独配置时回落到父级/表单级,子项自身配置优先。
2026-09-01 11:59:41 +08:00
roymondchen
93dc0d2187 feat(form): 新增无渲染校验入口,支持 Node/CI 环境执行表单校验
将 submitForm/validateForm 改为无 DOM 的 headless 实现,并抽取字段登记与编辑器复合字段配置,便于脚本与 CI 批量校验。
2026-08-28 15:33:54 +08:00
roymondchen
4247cfdaf5 chore: update lockfile v1.8.0-beta.25 2026-08-13 15:10:29 +08:00
roymondchen
8ca03b59a0 chore: release v1.8.0-beta.25 2026-08-13 15:09:11 +08:00
roymondchen
05fbdb17b5 fix(editor): 统一数据源字段路径解析并优化 typeMatch 校验
修复 ds-field:: 前缀解析、checkStrictly 数据源取值及字段类型不匹配时的校验提示。
2026-08-13 15:03:49 +08:00
moonszhang
050814f3f8 style(form): 优化 form-box 弹窗布局,修复 flex 内容溢出问题 2026-08-13 04:02:50 +00:00
roymondchen
01915634e4 chore(vue-runtime-help): release v2.0.5 2026-08-10 20:56:23 +08:00
roymondchen
09025f96c8 chore(eslint-config): release 0.1.1 2026-08-10 20:52:37 +08:00
roymondchen
8220b78c15 chore: update lockfile v1.8.0-beta.24 2026-08-10 20:50:20 +08:00
roymondchen
a7ccbe73af chore: release v1.8.0-beta.24 2026-08-10 20:49:19 +08:00
roymondchen
496df1f5e2 refactor(editor): 提取 UpdateOptions 并统一 DSL 操作 options 传递 2026-08-10 20:44:10 +08:00
roymondchen
8d720f0bf1 chore: update deps 2026-08-10 20:37:41 +08:00
roymondchen
d877bd8593 test(editor): 使用 vi.waitFor 稳定 CompareForm 与 ViewForm 单测 2026-08-10 20:18:57 +08:00
roymondchen
8550e6f854 fix(editor): 移除 removeStyleDisplayConfig 并取消属性 tab 懒加载
对比/只读表单直接返回 getPropsConfig 结果,样式/事件/高级 tab 不再使用 lazy 懒加载。
2026-08-10 20:18:57 +08:00
roymondchen
44cd3d480f feat(form,editor): 静默校验模式下跳过叶子字段渲染以提升性能
引入 silentLeafFieldTypes 白名单与 FORM_SILENT_MODE_KEY 注入。
Container 与编辑器重型字段在静默校验时跳过 UI 渲染,仅保留 FormItem 校验。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 20:18:55 +08:00
roymondchen
53c4763a59 fix(editor): 节点未注册时回退使用 formValue 加载事件与动作选项
当 editorService.getNodeById 找不到节点时,使用 formValue 作为 fallback,避免事件名和动作选项为空。
2026-08-07 17:01:52 +08:00
roymondchen
a7198e2731 fix(editor): 修复连续 add 与批量插入时节点顺序错乱
通过 lastAdded 记忆插入锚点、批量 add 改为串行,并向 runtime 传递 index 以保证编辑器与画布 DSL 顺序一致。
2026-08-07 11:59:06 +08:00
244 changed files with 14365 additions and 4926 deletions

View File

@ -18,13 +18,13 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 11.20.0
version: 11.21.0
- name: Set node version to 22
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
cache: "pnpm"
- run: pnpm bootstrap

View File

@ -1 +1 @@
npm run test
npm run coverage

View File

@ -7,7 +7,11 @@
TMagic Editor 是魔方平台的可视化编辑器核心库,提供拖拽式组件编辑、配置面板、预览发布等能力。支持 Vue 和 React 双框架 Runtime采用 pnpm monorepo 管理多个核心包。开源项目,同时支持内部业务定制。
**技术栈:** Vue 3, Element Plus, TypeScript, Vite, vitest, VitePress
编辑器本体使用 Vue 3UI 通过 `@tmagic/design` + adapter 接入 Element Plus 或 TDesign Vue Next。
**技术栈:** Vue 3, TypeScript, Vite, rolldown, vitest, VitePress, Element Plus / TDesign Vue Next
**环境:** Node.js `^20.19.0 || >=22.12.0`pnpm `11.21.0`
**当前版本:** `1.8.0-beta.25`
**主仓库:** `https://git.woa.com/vft-magic/tmagic-editor.git`
**开源仓库:** `https://github.com/Tencent/tmagic-editor.git`
@ -15,10 +19,10 @@ TMagic Editor 是魔方平台的可视化编辑器核心库,提供拖拽式组
关键目录:
- `packages/` — 核心编辑器包
- `runtime/` — Vue/React Runtime 实现
- `vue-components/` — Vue 组件封装
- `react-components/` — React 组件封装
- `packages/` — 核心 npm 包(`editor``form``stage``core``cli``data-source``schema``form-schema``design``utils``dep``table``element-plus-adapter``tdesign-vue-next-adapter`
- `runtime/` — Vue/React Runtime,以及 `vue-runtime-help``react-runtime-help``tmagic-form`
- `vue-components/` — Vue 业务组件封装
- `react-components/` — React 业务组件封装
- `playground/` — 演示 playground
- `docs/` — VitePress 文档
- `scripts/` — 构建和发布脚本
@ -26,9 +30,12 @@ TMagic Editor 是魔方平台的可视化编辑器核心库,提供拖拽式组
## 开发约定
**分支策略:** dev=dev, test/prod=master
**提交规范:** commitlint + husky`type: 描述`
**测试覆盖率:** 新增或修改的代码必须补充单元测试,覆盖率不低于 85%
**分支策略(内部约定):** 日常开发走 `dev`test/prod 对应 `master`。开源文档站与 playground 从 `dev` 发布。
**提交规范:** commitlint`@commitlint/config-conventional`+ husky格式 `type(scope): subject`,详见 `CONTRIBUTING.md`
**测试:** 新增或修改的代码必须补充单元测试,覆盖率不低于 85%lines`pnpm coverage` 执行两层硬门禁:
1. 全仓 lines ≥ 85%vitest `coverage.thresholds`
2. 工作区相对 HEAD 的 `packages/*/src` 变更逐文件 lines ≥ 85%(不含 design / UI adapter未测新文件按 0% 计)
pre-commit 跑 lint-staged 与 `pnpm check:type`pre-push 跑 `pnpm coverage`
**禁止事项:**
@ -43,14 +50,12 @@ TMagic Editor 是魔方平台的可视化编辑器核心库,提供拖拽式组
pnpm pg:react # 启动 React playground
pnpm build # 完整构建DTS + 包)
pnpm test # 运行测试
pnpm coverage # 运行测试、生成覆盖率,并执行 85% 门禁
pnpm check:type # TypeScript 类型检查
pnpm lint-fix # ESLint 修复
pnpm docs:dev # 启动文档开发
pnpm release # 发版
## 当前状态
**当前里程碑:** {待人工填写}
## 深入阅读
| 文档 | 说明 |

View File

@ -1,3 +1,63 @@
# [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)

View File

@ -588,10 +588,13 @@ export default defineConfig({
},
resolve: {
alias:[
{ find: /^@form/, replacement: path.join(__dirname, '../../packages/form/src/') },
{ find: /^@tmagic\/form-schema/, replacement: path.join(__dirname, '../../packages/form-schema/src/index.ts') },
{ find: /^@tmagic\/form\/headless$/, replacement: path.join(__dirname, '../../packages/form/src/headless.ts') },
{ find: /^@tmagic\/form/, replacement: path.join(__dirname, '../../packages/form/src/index.ts') },
{ find: /^@tmagic\/utils/, replacement: path.join(__dirname, '../../packages/utils/src/index.ts') },
{ find: /^@tmagic\/schema/, replacement: path.join(__dirname, '../../packages/schema/src/index.ts') },
{ find: /^@tmagic\/design\/headless$/, replacement: path.join(__dirname, '../../packages/design/src/headless.ts') },
{ find: /^@tmagic\/design/, replacement: path.join(__dirname, '../../packages/design/src/index.ts') },
{ find: /^@tmagic\/element-plus-adapter/, replacement: path.join(__dirname, '../../packages/element-plus-adapter/src/index.ts') },
]

View File

@ -16,7 +16,11 @@
- **详情:**
批量设置多个组件类型的事件列表
批量设置多个组件类型的事件列表。增量合并语义:只写入入参中出现的类型,已有的其他类型保持不变。
::: tip 切换 runtime 版本
不同版本的 runtime 事件表不同,直接调用 `setEvents` 会让上一个版本独有的类型残留。是否需要清掉上一个版本的数据由业务自行决定,需要清掉时先调用 [`resetState`](#resetstate) 再写入新表。
:::
- **示例:**
@ -47,7 +51,7 @@ eventsService.setEvents({
- **详情:**
设置指定组件类型的事件列表
设置指定组件类型的事件列表,只影响该类型
- **示例:**
@ -106,7 +110,7 @@ eventsService.usePlugin({
- **详情:**
批量设置多个组件类型的方法列表
批量设置多个组件类型的方法列表。语义与 `setEvents` 一致:增量合并,入参中不存在的类型保持不变。
- **示例:**
@ -138,7 +142,7 @@ eventsService.setMethods({
- **详情:**
设置指定组件类型的方法列表
设置指定组件类型的方法列表,只影响该类型
- **示例:**
@ -209,14 +213,17 @@ eventsService.usePlugin({
- **详情:**
重置事件服务状态,清空所有事件和方法配置
重置事件服务状态,清空所有事件和方法配置`setEvents` / `setMethods` 是增量合并,切换 runtime 版本这类需要丢弃上一份事件表的场景,由业务在写入新表前自行调用本方法。
- **示例:**
```js
import { eventsService } from '@tmagic/editor';
// 切到另一个版本的 runtime先清空再写入该版本的事件表
eventsService.resetState();
eventsService.setEvents(nextVersionEvents);
eventsService.setMethods(nextVersionMethods);
```
## destroy

View File

@ -630,6 +630,10 @@ const propsValues = {
该属性最终会设置到[eventsService](./eventsServiceMethods.md)中,所以也可直接调用[eventsService.setEvents()](./eventsServiceMethods.md#setEvents)与[eventsService.setMethods()](./eventsServiceMethods.md#setMethods)方法来配置
:::
:::warning
该属性每次变更都是增量合并,不会清掉上一次写入的类型。切换 runtime 版本时若需要丢弃旧版本的事件表,请自行调用 [eventsService.resetState()](./eventsServiceMethods.md#resetstate)
:::
- **默认值:** `{}`
- **类型:** `Record<string, { events: EventOption[]; methods: EventOption[] }>`
@ -1558,52 +1562,71 @@ const onLayerNodeDblclick = (event, data) => {
- 返回 `false` 时,会同时阻断默认的"展开/收起"行为以及向上抛出的 [`layer-node-dblclick`](./events.md#layer-node-dblclick) 事件;返回其他值则继续触发默认行为并抛出事件。
:::
## extendFormState
## 表单业务上下文
- **详情:**
扩展表单状态
用于在属性表单中注入自定义的状态数据,这些数据可以在表单配置的各个字段为函数时的第一个参数中获取
编辑器会把 `services` 与当前 `stage` 通过 `FORM_CONTEXT_KEY` provide 下去,编辑器内所有 `MForm`(属性面板、历史差异对比表单、侧边栏、以及 Link / FormBox 里的嵌套子表单)都自动继承。
- **默认值:** `undefined`
- **类型:** `(state: FormState) => Record<string, any> | Promise<Record<string, any>>`
- **示例:**
业务方要往里追加自己的字段,在 `<m-editor>` 外层再 provide 一层即可,同名字段覆盖内置的:
```html
<template>
<m-editor :extend-form-state="extendFormState"></m-editor>
<m-editor></m-editor>
</template>
<script setup>
const extendFormState = async (state) => {
// 返回自定义的状态数据
return {
// 可以是同步数据
currentUser: {
name: 'Admin',
role: 'admin',
},
// 也可以是异步获取的数据
projectConfig: await fetchProjectConfig(),
};
};
import { computed, provide } from 'vue';
import { FORM_CONTEXT_KEY } from '@tmagic/form';
provide(
FORM_CONTEXT_KEY,
computed(() => ({
currentUser: store.currentUser,
env: store.env,
})),
);
</script>
```
:::tip
扩展的状态可以在表单配置中通过 `state` 访问,例如:
补类型用模块增强:
```ts
declare module '@tmagic/form-schema' {
interface FormContext {
currentUser?: { name: string; role: string };
}
}
```
表单配置里统一从第一个参数 `mForm`formState 是读穿 Proxy`mForm` 上没有的字段自动落到 context所以回调签名不变后端下发的存量配置无需改动。
```js
{
name: 'title',
text: '标题',
// 根据扩展的状态动态设置
disabled: (state) => state.currentUser.role !== 'admin',
disabled: (mForm) => mForm.currentUser?.role !== 'admin',
display: (mForm) => mForm.env === 'prod',
}
```
::: 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

View File

@ -219,8 +219,25 @@
- **类型:** `boolean`
## extendState
## context
- **详情:** 扩展 formState 的钩子函数,返回的对象会被合并到 formState 上
- **详情:** 宿主业务上下文。可用本 prop 直接传,也可由祖先 `provide(FORM_CONTEXT_KEY)` 下发;同名字段本 prop 优先。
嵌套表单Link 的子表单、`MFormBox``MFormDialog`)会自动继承最近祖先的 context不需要层层透传。
配置回调统一通过第一个参数 `mForm` 读取formState 是一个读穿 Proxy`mForm` 上找不到的字段会自动落到 context。回调签名因此保持不变后端 eval 下发的存量配置无需改动。
- **类型:** `FormContext`
- **示例:**
```ts
// 模板:<m-form :context="formContext" />
const formContext = computed(() => ({ username: store.username }));
// 配置回调mForm.username 读穿到 context
{
display: (mForm) => mForm.username === 'admin',
}
```
- **类型:** `(state: FormState) => Record<string, any> | Promise<Record<string, any>>`

View File

@ -1,14 +1,107 @@
# submitForm 函数
以命令式方式调用 `MForm` 组件完成一次表单校验/提交,类似 `ElMessage` 的用法。
以命令式方式对一份「表单配置 + 表单值」执行一次校验并取回表单值,类似 `ElMessage` 的用法。
调用时函数内部会临时挂载一个不可见的 `MForm` 实例,把入参作为 props 透传给它,等待初始化完成后调用其 `submitForm` 方法。校验通过则 `resolve` 表单值,校验失败则 `reject` 错误信息,最后自动卸载实例并清理 DOM
走**无渲染**实现:不创建任何 DOM 容器、不实例化任何组件,而是直接遍历 `config` 树收集带规则的字段,交给 [`async-validator`](https://github.com/yiminghe/async-validator)`element-plus` 内部用的也是它)执行。因此它可以在 Node / CI 等没有 DOM 的环境中使用,也省去了挂载整棵表单的开销。校验通过则 `resolve` 表单值,失败则 `reject` 错误信息。纯 Node 请从 `@tmagic/form/headless` 引入,避免加载 Vue 组件和样式
适用于一些没有合适的容器、但又需要复用 `MForm` 校验逻辑的场景,例如:
- 通过快捷菜单/命令面板触发一次性表单
- 在脚本/服务层完成一次表单值校验后再发请求
- 把 `config` 配置当作"可执行的校验规则"使用
- 在 Node 脚本 / CI 中批量校验组件配置
## 无渲染校验与自定义字段登记
无渲染实现按 `Container.vue` 及各容器组件的模板规则遍历配置树,产出的字段 `prop` 与规则与「挂载 `MForm` 后调用 `validate()`」等价。需要 UI 时传入 `dialog: true`,会把表单以弹层渲染出来供填写/确认。
字段只要带了 `rules`(会包 FormItem就会校验自身不必先登记为叶子。配置里有 `items` 会下钻子项。内部再渲染 `MContainer` 的复合字段需要 `registerField(type, { innerConfig })`把内部会挂到父表单上的配置交出来。innerConfig 回调自身抛错时,会以 `FieldInnerConfigError``code: 'FIELD_INNER_CONFIG'`reject。
innerConfig 回调在校验和表单值初始化两条链路上都会被调用(后者用于走到复合字段内部、找出需要执行 `effect` 的子字段),所以它应当只做配置派生、可重复调用、不要做重活。在表单值初始化链路上,回调抛错只会记录到 console 并跳过该子树,不会让表单渲染不出来。
自定义字段的渲染组件和无渲染校验都通过 `registerField` / `registerFields` 一次登记。`component` 会写入字段注册表(`getFormField`);传入 `app` 时同时 `app.component('m-fields-*')`。容器组件用 `container`,对应 `m-form-*`
字段对表单值的初始化写入统一登记为 `effect`,渲染与无渲染共用同一份登记表,执行点也只有一个:表单值初始化完成后(`MForm` 内部、`validateValues`、以及 tab / table 新增行时)各执行一次 `applyMountValueEffects`,字段组件自身不要在 `setup` 里改写 `model`。因此 effect 有两个约束:一是必须幂等,同一份值可能被执行多次(如 `initValues` 变化后重新初始化);二是不看 `display``display: false` 或函数返回假的字段也会被规整,避免字段由隐藏转为显示时漏掉)。`type: 'hidden'` 不同:遍历在该节点停止、不往下分派,内部字段不会执行 effect。需要按路径跨层级写值时用上下文里的 `values`(本次处理的值根对象,`prop` 即以它为根),不要用 `mForm.values`——对比模式处理的是 `lastValues` 那一份,新增行处理的则是还没挂到表单上的一行值。单个 effect 抛错只会记录到 console不影响其余字段与表单渲染。复合字段可以同时登记 `effect``innerConfig`:前者改本字段的值,后者只派生内部配置、不要在回调里改 `model`
| 字段形态 | 登记方式 |
| --------------------------------------------------------------------- | ------------------------------------------- |
| 自身带 `rules`,内部没有嵌套的父表单 FormItem | 无需登记,直接校验 |
| 内部只渲染叶子 UI或把子表单渲染在独立的 `MForm` / `MFormBox` 实例里 | `registerField('my-field')`(配置里有 `items` 但不属于父表单时,避免被当下钻) |
| 同时需要渲染组件 | `registerField('my-field', { component })` |
| 容器组件(`m-form-*` | `registerField('my-box', { container, walk })` |
| 叶子字段,但需要改写表单值(类似 `display``initValue` | `registerField('my-field', { effect })` |
| 内部再渲染 `MContainer` / `MPanel` / `MGroupList`,向父表单注册字段 | `registerField('my-field', { innerConfig })`(需要改本字段的值时再加 `effect` |
| 自定义 `typeMatch` 类型校验 | `registerField('my-field', { typeMatch })` |
```ts
import { registerField, registerFields } from '@tmagic/form';
import MyColorPicker from './MyColorPicker.vue';
// 叶子字段:内部没有嵌套的表单项;带 component 时即可渲染
registerFields({ 'my-color-picker': { component: MyColorPicker } });
// 需要挂到当前 app 时传入第二个参数
registerFields({ 'my-color-picker': { component: MyColorPicker } }, app);
// 叶子字段,但需要改写表单值:写成 effect不要在组件 setup 里改 model
registerField('my-status', {
effect: ({ config, model }) => {
if ((config as any).initValue && model) {
model[(config as any).name] = (config as any).initValue;
}
},
});
// 复合字段:把组件内部渲染的 MContainer 配置交出来
registerField('my-composite', {
innerConfig: ({ config, model, prop }) => ({
// 对应组件内部 <MContainer :config="childConfig" :model="model[name]" :prop="prop">
config: childConfig,
model: model[config.name],
prop,
}),
});
// typeMatch覆盖或扩展该 type 的类型匹配校验,可与 innerConfig / effect 同时登记
registerField('my-status', {
typeMatch: (value, { message }) => (typeof value === 'string' ? undefined : message || '应为字符串'),
});
```
返回的 `config``name` 会被追加到返回的 `prop` 上。因此当内部配置复用了字段自身的 `name`(例如内部渲染 `<MGroupList :config="{ name, items }" :model="model" :prop="prop">`)时,要返回 `parentProp` 而非 `prop`,否则 `name` 会被拼两次:
```ts
registerField('my-list', {
innerConfig: ({ config, parentProp }) => ({
config: { type: 'group-list', name: config.name, items: innerItems },
prop: parentProp,
}),
});
```
编辑器侧四个复合字段(`code-select` / `display-conds` / `event-select` / `style-setter`)的登记可参考 `packages/editor/src/fields/headless-validation.ts`innerConfig 与组件共用同一份配置工厂(`packages/editor/src/fields/configs/`),避免两条链路各写一份而逐渐跑偏。
`type: 'component'` 会把 `config.component` 当任意 Vue 组件渲染。无渲染校验把它视为叶子,**不会**遍历内部结构。因此该组件不得再向父表单注册 FormItem需要嵌套表单项时应对该具体组件 `registerField(type, { innerConfig })`
### 重复登记与撤销
同一个 type 多次登记按字段浅合并,后一次只覆盖自己传入的 key
```ts
registerField('my-composite', { innerConfig });
registerField('my-composite', { component: MyComposite }); // innerConfig 仍在
```
登记分「内置」与「业务」两层。`app.use(MagicForm)` / `registerBuiltInFields` 写内置层,`registerField` / `registerFields` 写业务层;读取时业务层优先,`unregisterField` / `clearFields` 只清业务层,内置字段不受影响(单测里 `clearFields` 之后仍能校验 `text``tab` 等内置 type
因为是合并语义,把一个已登记 `innerConfig` 的 type 改成普通叶子,不能靠再传一次空对象,要先撤销:
```ts
registerField('my-composite', {}); // ✗ 合并后 innerConfig 还在,仍会下钻
unregisterField('my-composite'); // ✓ 先清掉业务层登记
registerField('my-composite', { component: MyComposite });
```
一次登记里同时传多个形态时的优先级:`walk` > `innerConfig` > `effect`(叶子),命中低优先级的那份会被忽略并在控制台给出告警。
## 签名
@ -18,7 +111,7 @@ function submitForm(options: SubmitFormOptions): Promise<any>;
## 参数
`options``MForm` 组件的 props 基本对齐,额外提供了 `native``returnChangeRecords``appContext`、`timeout` 等参数
`options``MForm` 组件的 props 基本对齐,额外提供了 `native``returnChangeRecords``dialog`、`signal` 等参数。`appContext``dialog: true` 时生效
| 名称 | 类型 | 默认值 | 说明 |
| ---------------------- | ------------------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------- |
@ -38,22 +131,23 @@ function submitForm(options: SubmitFormOptions): Promise<any>;
| `popperClass` | `string` | — | 弹层 className |
| `preventSubmitDefault` | `boolean` | — | 是否阻止表单原生 submit |
| `useFieldTextInError` | `boolean` | `true` | 校验失败时错误提示前缀是否使用字段的 `text` 文案;`false` 时直接使用字段 `name` |
| `extendState` | `(state: FormState) => Record<string, any> \| Promise<Record<string, any>>` | — | 扩展 `formState` |
| `context` | `FormContext` | — | 宿主业务上下文,与 MForm 的 `context` 语义一致;配置回调通过 `mForm.xxx` 读穿取用 |
| `native` | `boolean` | `false` | 透传给 `Form.submitForm``true` 时返回内部响应式 `values`,否则返回 `cloneDeep(toRaw(values))` |
| `returnChangeRecords` | `boolean` | `false` | `true` 时 resolve 结果为 `{ values, changeRecords }`,携带表单变更记录;否则仅 resolve `values` |
| `appContext` | `AppContext \| null` | `null` | 父级 Vue 应用上下文。需要继承全局组件、指令、provide 等时传入,常通过 `app._context``getCurrentInstance()?.appContext` 获取 |
| `timeout` | `number` | `10000` | 等待表单初始化的最长时间(毫秒)。超时将以错误 reject。设为 `<= 0` 时关闭超时兜底 |
| `appContext` | `AppContext \| null` | `null` | 父级 Vue 应用上下文。仅 `dialog: true` 时生效用于继承全局组件、指令、provide 等,常通过 `app._context``getCurrentInstance()?.appContext` 获取 |
| `dialog` | `boolean` | `false` | `true` 时把表单以弹层形式渲染出来,点击「确定」才提交,「取消」则以 reject 中断;校验失败会保留弹层并展示错误,便于修正后重试。等待人工操作,可用 `signal` 中断 |
| `title` | `string` | `'submitForm'` / `'validateForm'` | 弹层标题,仅 `dialog: true` 时生效 |
| `signal` | `AbortSignal` | — | 外部中断信号。abort 时立即以 `signal.reason` reject并卸载 `dialog` 模式下已挂载的临时表单实例 |
## 返回值
- `校验通过``Promise<any>` resolve 当前表单值(`native` 决定是否克隆);当 `returnChangeRecords``true`resolve `{ values, changeRecords }`
- `校验失败``Promise<any>` reject 一个 `Error``message` 中包含逐条字段错误信息(格式 `${text} -> ${message}`,多条用 `<br>` 分隔)
- `初始化超时``Promise<any>` reject `Error('submitForm timeout after ${timeout}ms: form is not initialized.')`
无论成功或失败,函数都会在最后自动 `unmount` 内部 app 并移除挂载用的 DOM 容器,无需调用方手动清理。
`dialog: true`无论成功或失败,函数都会在最后自动 `unmount` 内部 app 并移除挂载用的 DOM 容器,无需调用方手动清理。
::: tip 关于 changeRecords
`changeRecords` 记录的是表单挂载后发生的字段变更(由各字段的 `change` 事件累积而来)。`submitForm` 这种命令式、无用户交互的场景下,通常为空数组;只有在 `extendState` 或字段联动等逻辑中触发了变更时才会有内容。`MForm` 内部的 `submitForm` 在校验通过后会清空变更记录,因此本函数会在调用前先对其做快照再返回
`changeRecords` 记录的是表单挂载后发生的字段变更(由各字段的 `change` 事件累积而来)。无渲染校验没有用户交互,因此固定返回空数组;只有 `dialog: true` 时才可能有内容(`MForm` 内部的 `submitForm` 在校验通过后会清空变更记录,因此本函数会在调用前先做快照)
:::
## 基础用法
@ -99,9 +193,9 @@ console.log(values); // { username: 'foo' }
console.log(changeRecords); // ChangeRecord[]
```
## 在组件中继承父级应用上下文
## 弹层模式(`dialog: true`)下继承父级应用上下文
`MForm` 内部使用 `@tmagic/design` 的组件(背后可能是 `element-plus``tdesign`),需要宿主应用先完成相应 `app.use(...)` 安装。在 `submitForm` 这种脱离常规组件树的命令式调用中,可通过 `appContext` 把父级应用上下文带过去:
默认路径不挂载组件,不需要 `appContext`。只有 `dialog: true` 会渲染弹层,此时 `MForm` 要用到 `@tmagic/design` 的组件(背后可能是 `element-plus``tdesign`),需要宿主应用的上下文带过去:
```vue
<script setup lang="ts">
@ -115,6 +209,8 @@ const onClick = async () => {
const values = await submitForm({
config: [{ type: 'text', name: 'text', text: '文本' }],
initValues: { text: 'hello' },
dialog: true,
title: '编辑配置',
appContext,
});
console.log(values);
@ -158,65 +254,77 @@ try {
}
```
## 运行环境
## validateForm 函数
`submitForm` 内部依赖 `document` / `window` 来挂载临时 Vue 实例,因此**只能在浏览器或具备 DOM 环境的运行时中使用**。
| 环境 | 是否可用 | 说明 |
| ----------------------------------------------- | -------- | --------------------------------------------------------------------------------- |
| 浏览器 / Electron 渲染进程 / 浏览器扩展 | ✅ | 直接可用 |
| Vitest / Jest + `happy-dom` / `jsdom` | ✅ | 项目自身的单测就跑在这种环境下 |
| 纯 Node.js / Bun / Deno无 DOM polyfill | ❌ | 模块顶层就会读 `document`,会抛 `document is not defined` |
| Node.js + 手动注入 `happy-dom` / `jsdom` | ⚠️ | 可用,需要在 import `@tmagic/form` **之前**完成全局变量注入;校验行为不一定与浏览器完全一致 |
### 在 Node.js 中使用(需要先准备 DOM
下面是一个在 Node 脚本里调用 `submitForm` 的完整例子,使用 [`happy-dom`](https://github.com/capricorn86/happy-dom) 作为 DOM polyfill
`validateForm``submitForm` 共用同一套无渲染校验实现,区别在于它是**静默**的:校验失败不抛异常、不返回表单值,而是以返回值形式给出错误文案。适合「只想探测这份配置是否合法」的场景,例如源码编辑器保存后校验、批量校验组件配置。
```ts
// scripts/check-form.ts
import { Window } from 'happy-dom';
function validateForm(options: ValidateFormOptions): Promise<string>;
```
const window = new Window();
Object.assign(globalThis, {
window,
document: window.document,
navigator: window.navigator,
HTMLElement: window.HTMLElement,
`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: '' },
});
// 注意DOM polyfill 必须先注入到 globalThis再用动态 import
// 加载业务模块,否则 @tmagic/design 等模块顶层执行时就会读 document
const { createApp } = await import('vue');
const ElementPlus = (await import('element-plus')).default;
const MagicForm = (await import('@tmagic/form')).default;
const { submitForm } = await import('@tmagic/form');
if (error) {
// '用户名 -> 请输入用户名'
}
```
const parentApp = createApp({ render: () => null });
parentApp.use(ElementPlus);
parentApp.use(MagicForm);
校验通过返回空字符串 `''`,否则返回以 `<br>` 拼接的错误文案。无法完成校验时才会 reject例如 innerConfig 回调失败抛出 `FieldInnerConfigError`)。
const values = await submitForm({
config: [{ type: 'text', name: 'username', text: '用户名' }],
initValues: { username: 'foo' },
appContext: parentApp._context,
## 运行环境
无渲染实现不接触 `document` / `window`,因此在任何 JS 运行时中都可用:
| 环境 | 是否可用 | 说明 |
| ------------------------------------------ | -------- | ------------------------------------------------------------------------------------- |
| 浏览器 / Electron 渲染进程 / 浏览器扩展 | ✅ | 直接可用 |
| Vitest / Jest + `happy-dom` / `jsdom` | ✅ | 项目自身的单测就跑在这种环境下 |
| 纯 Node.js / Bun / Deno无 DOM polyfill | ✅ | 从 `@tmagic/form/headless` 引入,不要用 `@tmagic/form` 主入口 |
```ts
// scripts/check-form.ts —— 纯 Node 环境,无需任何 DOM polyfill
import { builtInFields, registerBuiltInFields, registerFields, validateForm } from '@tmagic/form/headless';
import { editorFields } from '@tmagic/editor/headless';
// `builtInFields` 只是数据;未 `app.use(MagicForm)` 时要自己登记
registerBuiltInFields(builtInFields);
registerFields(editorFields);
const error = await validateForm({
config: [{ type: 'text', name: 'username', text: '用户名', rules: [{ required: true }] }],
initValues: { username: '' },
});
console.log(values);
if (error) {
console.error(error);
process.exit(1);
}
```
::: warning 注意
- 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 渲染层
`dialog: true` 依赖 DOM 与已安装的 UI 库(`element-plus` / `tdesign`),在纯 Node 环境中不可用。
:::
::: warning ESM 与 CJS 不要混用
`@tmagic/form/headless` 的 ESM 产物与 `@tmagic/form` 共用同一批模块文件,字段注册表是同一份,两个入口可以混着 `import`
CJS 产物是两个各自独立的 bundle注册表不共享。所以同一进程里不要同时 `require('@tmagic/form')``require('@tmagic/form/headless')`——在一边 `registerField` 另一边读不到,校验会因为「没登记过这个 type」而静默放过。`@tmagic/editor``@tmagic/design` 的 headless 子路径同理。
:::
## 类型定义
::: details 查看 `SubmitFormOptions` 类型定义
<<< @/../packages/form/src/submitForm.ts#SubmitFormOptions{ts}
<<< @/../packages/form/src/utils/submitHeadless.ts#SubmitFormOptions{ts}
:::
::: details 查看 `SubmitFormResult` 类型定义
<<< @/../packages/form/src/submitForm.ts#SubmitFormResult{ts}
<<< @/../packages/form/src/utils/submitHeadless.ts#SubmitFormResult{ts}
:::

View File

@ -2,7 +2,7 @@
## 配置类型
::: details 查看 ContainerCommonConfig / RowConfig / TabConfig / TabPaneConfig / FieldsetConfig / PanelConfig / StepConfig / FlexLayoutConfig / GroupListConfig / TableConfig / TableColumnConfig / TableGroupListCommonConfig 配置类型定义
::: details 查看 ContainerCommonConfig / RowConfig / TabConfig / TabPaneConfig / FieldsetConfig / PanelConfig / StepConfig / FlexLayoutConfig / GroupListConfig / TableConfig / TableColumnConfig / TableGroupListCommonConfig / GroupListHeaderConfig 配置类型定义
<<< @/../packages/form-schema/src/base.ts#ContainerCommonConfig{ts}
<<< @/../packages/form-schema/src/base.ts#RowConfig{ts}
@ -27,6 +27,8 @@
<<< @/../packages/form-schema/src/base.ts#TableGroupListCommonConfig{ts}
<<< @/../packages/form-schema/src/base.ts#GroupListHeaderConfig{ts}
<<< @/../packages/form-schema/src/base.ts#FormItem{ts}
:::
@ -211,6 +213,27 @@
}]
}]"></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
<demo-block type="form" :config="[{

View File

@ -78,35 +78,34 @@
### 运行时注册
```ts
import {
registerTypeMatchRule,
registerTypeMatchRules,
deleteTypeMatchRule,
clearTypeMatchRules,
} from '@tmagic/form';
import { registerField, registerFields, unregisterField, clearFields } from '@tmagic/form';
// 覆盖内置 text
registerTypeMatchRule('text', (value, { message }) => {
if (typeof value !== 'string') {
return message || '值类型应为字符串';
}
registerField('text', {
typeMatch: (value, { message }) => {
if (typeof value !== 'string') {
return message || '值类型应为字符串';
}
},
});
// 扩展业务字段
registerTypeMatchRule('vs-code', (value, { message }) => {
if (typeof value !== 'string') {
return message || '代码字段应为字符串';
}
registerField('vs-code', {
typeMatch: (value, { message }) => {
if (typeof value !== 'string') {
return message || '代码字段应为字符串';
}
},
});
// 批量注册
registerTypeMatchRules({
foo: (value) => (Array.isArray(value) ? undefined : '应为数组'),
registerFields({
foo: { typeMatch: (value) => (Array.isArray(value) ? undefined : '应为数组') },
});
// 删除 / 清空
deleteTypeMatchRule('foo');
clearTypeMatchRules();
// 删除 / 清空该 type 的全部登记(含 typeMatch
unregisterField('foo');
clearFields();
```
自定义校验器签名:`(value, context) => string | undefined | Promise<string | undefined>`。返回错误文案表示失败,返回 `undefined` 表示通过。`context` 包含 `fieldType``mForm``props``message`
@ -116,11 +115,15 @@ clearTypeMatchRules();
自定义校验器可以返回 `Promise`,用于需要异步确认取值是否合法的场景(如请求接口校验 id 是否存在)。内置规则均为同步。
```ts
registerTypeMatchRule('mod-select', async (value, { message }) => {
const exists = await checkModExists(value);
if (!exists) {
return message || `模块(${value})不存在`;
}
import { registerField } from '@tmagic/form';
registerField('mod-select', {
typeMatch: async (value, { message }) => {
const exists = await checkModExists(value);
if (!exists) {
return message || `模块(${value})不存在`;
}
},
});
```
@ -134,13 +137,17 @@ registerTypeMatchRule('mod-select', async (value, { message }) => {
```ts
import MagicForm from '@tmagic/form';
import MyField from './MyField.vue';
app.use(MagicForm, {
typeMatchRules: {
'my-field': (value, { message }) => {
if (typeof value !== 'string') {
return message || 'my-field 应为字符串';
}
fields: {
'my-field': {
component: MyField,
typeMatch: (value, { message }) => {
if (typeof value !== 'string') {
return message || 'my-field 应为字符串';
}
},
},
},
});
@ -148,7 +155,7 @@ app.use(MagicForm, {
### Editor 字段内置规则
安装 `@tmagic/editor` 时会自动 `registerTypeMatchRules` 注册编辑器自定义字段规则。服务数据(数据源 / 代码块 / 节点树)未就绪时,只做基础形态校验,不做枚举或存在性失败。
安装 `@tmagic/editor` 时会`editorFields`(无 Vue 组件)叠上字段组件后作为 `fields` 传给 `@tmagic/form`。Node 里从 `@tmagic/form/headless``@tmagic/editor/headless` 引入即可。若安装时也传了 `fields`,会与编辑器字段按 type 浅合并:调用方传入的 key 覆盖对应项,未传的 key`innerConfig` / `typeMatch`)保留。服务数据(数据源 / 代码块 / 节点树)未就绪时,只做基础形态校验,不做枚举或存在性失败。
| 字段 type | 期望值 |
| --- | --- |
@ -167,7 +174,7 @@ app.use(MagicForm, {
> 容器类字段(`event-select` / `code-select` / `display-conds`)遵循同一约定:容器级 typeMatch 只做结构校验,「枚举 / 存在性」下沉到内部单元格各自的 typeMatch/rules避免单个子项非法导致整块表单标红。
业务仍可用 `registerTypeMatchRule` 覆盖上述任一 type
业务仍可用 `registerField(type, { typeMatch })` 覆盖上述任一 type 的类型校验;多次 `registerField` 按字段浅合并,不会丢掉已登记的 `innerConfig` / `walk` / `effect`
## 示例
@ -222,6 +229,10 @@ app.use(MagicForm, {
<<< @/../packages/form/src/utils/typeMatch.ts#TypeMatchValidateContext{ts}
:::
::: details 查看 FieldOptions 类型定义
<<< @/../packages/form/src/utils/registerField.ts#FieldOptions{ts}
:::
::: details 查看 FormInstallOptions 类型定义
<<< @/../packages/form/src/plugin.ts#FormInstallOptions{ts}
:::

View File

@ -101,7 +101,6 @@ onCodeBlockDiff(id, index);
| 字段 | 必填 | 说明 |
| --- | --- | --- |
| `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` 单独覆盖。 |
> 若只需要无确认、无校验的静默回滚,直接用上面的 `editorService.revertPageStep` 等即可,无需 `useHistoryRevert`
@ -164,7 +163,7 @@ const historyListExtraTabs = [
## 自定义对比判断
差异对话框中的「表单对比」最终透传到 `MForm`,你可以通过 Editor 顶层注入的 `extendFormState` 让对比表单拿到完整业务上下文,从而让依赖上下文的 `display` / `disabled``filterFunction` 正常工作
差异对话框中的「表单对比」最终透传到 `MForm`。Editor 会把 `services` / `stage` provide 为 `FORM_CONTEXT_KEY`,对比表单自动继承;业务字段在 `<m-editor>` 外层再 provide 一层即可,详见 [表单业务上下文](/api/editor/props.html#表单业务上下文)。配置回调通过 `mForm.xxx` 读穿取用
若某些字段语义上相等但结构不同(例如 `code-select` 字段中 `''``{ hookType: 'code', hookData: [] }` 应视为相等),可借助 `@tmagic/form` 的 [`showDiff`](/api/form/form-props.html#showdiff) 自定义判断函数避免被误判为差异。

View File

@ -45,15 +45,20 @@ app.use(MagicForm);
app.mount("#app");
```
也可在安装时传入自定义 `typeMatch` 规则,详见[表单校验 - 扩展自定义 type 规则](../../form-config/rules.md#扩展自定义-type-规则)
也可在安装时传入自定义字段登记(叶子 / innerConfig / typeMatch / component,详见[表单校验 - 扩展自定义 type 规则](../../form-config/rules.md#扩展自定义-type-规则)
```javascript
import MyField from './MyField.vue';
app.use(MagicForm, {
typeMatchRules: {
'my-field': (value, { message }) => {
if (typeof value !== 'string') {
return message || 'my-field 应为字符串';
}
fields: {
'my-field': {
component: MyField,
typeMatch: (value, { message }) => {
if (typeof value !== 'string') {
return message || 'my-field 应为字符串';
}
},
},
},
});

View File

@ -43,6 +43,7 @@ new DataSource(options: DataSourceOptions)
| `methods` | `CodeBlockContent[]` | 自定义方法配置 |
| `data` | `any` | 当前数据 |
| `isInit` | `boolean` | 是否已初始化 |
| `isMounted` | `boolean` | 页面渲染后的 `mounted` 是否已执行 |
## 实例方法
@ -172,6 +173,28 @@ 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
- **返回:**

View File

@ -25,6 +25,7 @@ new DataSourceManager(options: DataSourceManagerOptions)
| `data` | `DataSourceManagerData` | 所有数据源的数据 |
| `initialData` | `DataSourceManagerData` | 初始化数据 |
| `useMock` | `boolean` | 是否使用 Mock 数据 |
| `isMounted` | `boolean` | 页面是否已渲染完成(收到过 `mounted` 事件) |
## 静态方法
@ -109,6 +110,27 @@ 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
- **参数:**
@ -184,7 +206,7 @@ const ds = dataSourceManager.addDataSource({
- **详情:**
同步更新数据源 DSL 配置:先按 `id` 移除已有数据源,再以 `cloneDeep` 重新 `addDataSource`,并对新建实例触发 `init`(异步执行,不会被该方法 `await`)。一般在编辑器中修改配置后调用。
同步更新数据源 DSL 配置:先按 `id` 移除已有数据源,再以 `cloneDeep` 重新 `addDataSource`,并对新建实例触发 `init`(异步执行,不会被该方法 `await`;若此时 `isMounted` 已为 `true``init` 完成后还会补充执行 `mounted`。一般在编辑器中修改配置后调用。
### compiledNode
@ -325,6 +347,7 @@ DataSourceManager 继承自 EventEmitter支持以下事件
|--------|------|----------|
| `change` | 单个数据源数据变化 | `(dsId: string, changeEvent: ChangeEvent)` |
| `init` | 所有数据源初始化完成;现代分支携带 `(data, errors)`,旧 Promise.all 分支为 `(this.data)` | `(data, errors?)` |
| `mounted` | 由 runtime 在顶层组件渲染完成后触发manager 收到后统一执行各数据源的 `mounted` | 无 |
| `registered-all` | 所有数据源注册完成 | 无 |
| `update-data` | 由 `createDataSourceManager` 在数据变化后发出,用于通知节点重新渲染 | `(newNodes: MNode[], sourceId: string, changeEvent: ChangeEvent, pageId: Id)` |

View File

@ -22,7 +22,7 @@ export default {
['^(@tencent)(/.*|$)'],
['^(@tmagic)(/.*|$)'],
// Internal packages.
['^(@|src|editor-page|@editor|@data-source)(/.*|$)'],
['^(@|src|editor-page|@editor|@form|@data-source)(/.*|$)'],
// Side effect imports.
['^\\u0000'],
// Parent imports. Put `..` last.

View File

@ -1,6 +1,6 @@
{
"name": "@tmagic/eslint-config",
"version": "0.1.0",
"version": "0.1.1",
"main": "index.mjs",
"type": "module",
"repository": {

View File

@ -1,9 +1,9 @@
{
"version": "1.8.0-beta.23",
"version": "1.8.0-beta.28",
"name": "tmagic",
"private": true,
"type": "module",
"packageManager": "pnpm@11.20.0",
"packageManager": "pnpm@11.21.0",
"scripts": {
"bootstrap": "pnpm i && pnpm build",
"clean:top": "rimraf packages/*/dist packages/*/types runtime/*/dist runtime/*/types vue-components/*/dist vue-components/*/types react-components/*/dist react-components/*/types playground/dist coverage dwt* temp packages/cli/lib",
@ -15,7 +15,8 @@
"pg": "pnpm playground",
"playground:react": "pnpm --filter \"runtime-react\" build:libs && pnpm --filter \"runtime-react\" --filter \"tmagic-playground\" dev:react",
"pg:react": "pnpm playground:react",
"build": "pnpm build:dts && node scripts/build.mjs",
"build": "pnpm build:dts && node scripts/build.mjs && pnpm check:headless",
"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",
"check:type": "node scripts/check-type.mjs",
"build:playground": "pnpm --filter \"runtime-vue\" build && pnpm --filter \"tmagic-playground\" build",
@ -24,7 +25,7 @@
"docs:build": "vitepress build docs",
"reinstall": "pnpm clean:all && pnpm bootstrap",
"test": "vitest run",
"coverage": "vitest run --coverage",
"coverage": "vitest run --coverage && node scripts/check-coverage.mjs",
"prepare": "husky",
"changelog": "conventional-changelog -p angular -i CHANGELOG.md -s",
"release": "node scripts/release.mjs"
@ -52,11 +53,11 @@
"conventional-changelog-cli": "^5.0.0",
"cosmiconfig": "^10.0.0",
"cz-conventional-changelog": "^3.3.0",
"element-plus": "^2.14.3",
"element-plus": "catalog:",
"enquirer": "^2.4.1",
"eslint": "^10.8.0",
"eslint": "^10.8.1",
"execa": "^10.0.1",
"happy-dom": "^20.11.1",
"happy-dom": "^20.11.2",
"highlight.js": "^11.11.1",
"husky": "^9.1.7",
"jsdom": "^30.0.1",
@ -64,19 +65,19 @@
"minimist": "^1.2.8",
"picocolors": "^1.1.1",
"prettier": "^3.9.6",
"recast": "^0.23.19",
"recast": "^0.23.20",
"rimraf": "^6.1.3",
"rolldown": "^1.2.2",
"rolldown-plugin-dts": "^0.28.0",
"rolldown": "^1.2.3",
"rolldown-plugin-dts": "^0.28.1",
"sass-embedded": "^1.100.0",
"semver": "^7.8.5",
"serialize-javascript": "^7.0.7",
"serialize-javascript": "^7.1.0",
"shx": "^0.4.0",
"typescript": "catalog:",
"vite": "catalog:",
"vitepress": "^2.0.0-alpha.19",
"vitepress": "2.0.0-alpha.19",
"vitest": "^4.1.10",
"vue": "catalog:",
"vue": "^3.5.41",
"vue-tsc": "^3.3.9"
},
"config": {

View File

@ -1,5 +1,5 @@
{
"version": "1.8.0-beta.23",
"version": "1.8.0-beta.28",
"name": "@tmagic/cli",
"type": "module",
"main": "lib/index.js",

View File

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

View File

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

View File

@ -57,7 +57,7 @@ class DataSourceManager extends EventEmitter {
for (let config = list.shift(); config; config = list.shift()) {
const ds = app.addDataSource(config);
if (ds) {
app.init(ds);
app.initAndMount(ds);
}
}
});
@ -84,6 +84,8 @@ class DataSourceManager extends EventEmitter {
public data: DataSourceManagerData = {};
public initialData: DataSourceManagerData = {};
public useMock?: boolean = false;
/** 页面是否已经渲染完成 */
public isMounted = false;
constructor({ app, useMock, initialData }: DataSourceManagerOptions) {
super();
@ -109,6 +111,12 @@ class DataSourceManager extends EventEmitter {
this.callDsInit();
});
}
// 由runtime在顶层组件渲染完成后触发
this.on('mounted', () => {
this.isMounted = true;
this.callDsMounted();
});
}
public async init(ds: DataSource) {
@ -137,6 +145,29 @@ class DataSourceManager extends EventEmitter {
}
}
/**
* mounted
* @param {DataSource} ds
*/
public async mounted(ds: DataSource) {
if (ds.isMounted) {
return;
}
if (this.app.jsEngine && ds.schema.disabledInitInJsEngine?.includes(this.app.jsEngine)) {
return;
}
await ds.mounted?.();
for (const method of ds.methods) {
if (typeof method.content !== 'function') return;
if (method.timing === 'mounted') {
await method.content({ params: {}, dataSource: ds, app: this.app });
}
}
}
public get(id: string) {
return this.dataSourceMap.get(id);
}
@ -219,7 +250,7 @@ class DataSourceManager extends EventEmitter {
this.addDataSource(cloneDeep(schema));
const newDs = this.get(schema.id);
if (newDs) {
this.init(newDs);
this.initAndMount(newDs);
}
}
}
@ -390,6 +421,28 @@ 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;

View File

@ -31,6 +31,7 @@ import type { ChangeEvent, DataSourceOptions } from '@data-source/types';
*/
export default class DataSource<T extends DataSourceSchema = DataSourceSchema> extends EventEmitter {
public isInit = false;
public isMounted = false;
/** @tmagic/core 实例 */
public app: TMagicApp;
@ -152,6 +153,13 @@ export default class DataSource<T extends DataSourceSchema = DataSourceSchema> e
this.isInit = true;
}
/**
* DataSourceManager mounted
*/
public async mounted() {
this.isMounted = true;
}
public destroy() {
this.#fields = [];
this.removeAllListeners();

View File

@ -38,6 +38,25 @@ describe('DataSource', () => {
expect(ds.isInit).toBeTruthy();
});
test('mounted', async () => {
const ds = new DataSource({
schema: {
type: 'base',
id: '1',
fields: [{ name: 'name' }],
methods: [],
events: [],
},
app: new App({}),
});
expect(ds.isMounted).toBe(false);
await ds.mounted();
expect(ds.isMounted).toBe(true);
});
});
describe('DataSource setData', () => {

View File

@ -302,6 +302,227 @@ describe('DataSourceManager - init 生命周期', () => {
});
});
describe('DataSourceManager - mounted 生命周期', () => {
afterEach(() => {
DataSourceManager.clearDataSourceClass();
});
// 等待 mounted 事件触发的异步流程执行完
const flush = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
const createApp = (id: string, dataSources: any[] = [], jsEngine?: any) =>
new TMagicApp({
...(jsEngine ? { jsEngine } : {}),
config: {
type: NodeType.ROOT,
id,
items: [],
dataSources,
},
} as any);
test('mounted 事件会统一执行所有数据源的 mounted', async () => {
const app = createApp('app_mounted', [
{ type: 'base', id: 'ds_m1', fields: [], methods: [], events: [] },
{ type: 'base', id: 'ds_m2', fields: [], methods: [], events: [] },
]);
const dsm = new DataSourceManager({ app });
expect(dsm.isMounted).toBe(false);
expect(dsm.get('ds_m1')?.isMounted).toBe(false);
dsm.emit('mounted');
await flush();
expect(dsm.isMounted).toBe(true);
expect(dsm.get('ds_m1')?.isMounted).toBe(true);
expect(dsm.get('ds_m2')?.isMounted).toBe(true);
});
test('methods 中 timing=mounted 的 content 会在 ds.mounted 之后调用', async () => {
const app = createApp('app_mounted_method');
const dsm = new DataSourceManager({ app });
const order: string[] = [];
const mountedContent = vi.fn(() => {
order.push('method');
});
const ds = new DataSource({
app,
schema: {
type: 'base',
id: 'ds_mounted_method',
fields: [],
events: [],
methods: [{ name: 'onMounted', content: mountedContent, timing: 'mounted', params: [] }],
} as any,
});
const origMounted = ds.mounted.bind(ds);
ds.mounted = async () => {
order.push('mounted');
await origMounted();
};
await dsm.mounted(ds);
expect(mountedContent).toHaveBeenCalledTimes(1);
const arg = mountedContent.mock.calls[0][0] as any;
expect(arg.dataSource).toBe(ds);
expect(arg.app).toBe(app);
expect(order).toEqual(['mounted', 'method']);
});
test('ds.isMounted 为 true 时直接跳过', async () => {
const app = createApp('app_mounted_skip');
const dsm = new DataSourceManager({ app });
const content = vi.fn();
const ds = new DataSource({
app,
schema: {
type: 'base',
id: 'ds_mounted_skip',
fields: [],
events: [],
methods: [{ name: 'onMounted', content, timing: 'mounted', params: [] }],
} as any,
});
await dsm.mounted(ds);
await dsm.mounted(ds);
expect(content).toHaveBeenCalledTimes(1);
});
test('jsEngine 命中 disabledInitInJsEngine 时跳过 mounted', async () => {
const app = createApp('app_mounted_disabled', [], 'nodejs');
const dsm = new DataSourceManager({ app });
const ds = new DataSource({
app,
schema: {
type: 'base',
id: 'ds_mounted_disabled',
fields: [],
methods: [],
events: [],
disabledInitInJsEngine: ['nodejs'],
} as any,
});
await dsm.mounted(ds);
expect(ds.isMounted).toBe(false);
});
test('method.content 非函数时提前返回', async () => {
const app = createApp('app_mounted_bad');
const dsm = new DataSourceManager({ app });
const content = vi.fn();
const ds = new DataSource({
app,
schema: {
type: 'base',
id: 'ds_mounted_bad',
fields: [],
events: [],
methods: [
{ name: 'bad', content: 'not-a-function', timing: 'mounted', params: [] } as any,
{ name: 'onMounted', content, timing: 'mounted', params: [] } as any,
],
} as any,
});
await dsm.mounted(ds);
expect(ds.isMounted).toBe(true);
expect(content).not.toHaveBeenCalled();
});
test('mounted 抛错时不会产生未处理的异常', async () => {
const mountedSpy = vi.spyOn(DataSource.prototype, 'mounted').mockRejectedValue(new Error('mounted-boom'));
try {
const app = createApp('app_mounted_err', [{ type: 'base', id: 'ds_me', fields: [], methods: [], events: [] }]);
const dsm = new DataSourceManager({ app });
dsm.emit('mounted');
await flush();
expect(dsm.isMounted).toBe(true);
} finally {
mountedSpy.mockRestore();
}
});
test('Promise.allSettled 不可用时走 Promise.all 兼容分支', async () => {
const original = Promise.allSettled;
(Promise as any).allSettled = undefined;
const mountedSpy = vi.spyOn(DataSource.prototype, 'mounted').mockRejectedValueOnce(new Error('compat-boom'));
try {
const app = createApp('app_mounted_compat', [
{ type: 'base', id: 'ds_mc1', fields: [], methods: [], events: [] },
{ type: 'base', id: 'ds_mc2', fields: [], methods: [], events: [] },
]);
const dsm = new DataSourceManager({ app });
dsm.emit('mounted');
await flush();
expect(mountedSpy).toHaveBeenCalledTimes(2);
expect(dsm.get('ds_mc2')?.isMounted).toBe(true);
} finally {
(Promise as any).allSettled = original;
mountedSpy.mockRestore();
}
});
test('页面渲染后注册的数据源类型会在 init 之后补充执行 mounted', async () => {
const app = createApp('app_mounted_register', [
{ type: 'mounted-late', id: 'ds_late', fields: [], methods: [], events: [] },
]);
const dsm = new DataSourceManager({ app });
expect(dsm.get('ds_late')).toBeUndefined();
dsm.emit('mounted');
await flush();
class LateDataSource extends DataSource {}
DataSourceManager.register('mounted-late', LateDataSource as any);
await flush();
expect(dsm.get('ds_late')?.isInit).toBe(true);
expect(dsm.get('ds_late')?.isMounted).toBe(true);
});
test('页面渲染后 updateSchema 重建的数据源会执行 mounted', async () => {
const app = createApp('app_mounted_update', [
{ type: 'base', id: 'ds_up', fields: [{ name: 'a' }], methods: [], events: [] },
]);
const dsm = new DataSourceManager({ app });
dsm.emit('mounted');
await flush();
dsm.updateSchema([{ type: 'base', id: 'ds_up', fields: [{ name: 'b' }], methods: [], events: [] }]);
await flush();
expect(dsm.get('ds_up')?.isMounted).toBe(true);
});
test('页面未渲染时 updateSchema 不会执行 mounted', async () => {
const app = createApp('app_update_not_mounted', [
{ type: 'base', id: 'ds_nm', fields: [{ name: 'a' }], methods: [], events: [] },
]);
const dsm = new DataSourceManager({ app });
dsm.updateSchema([{ type: 'base', id: 'ds_nm', fields: [{ name: 'b' }], methods: [], events: [] }]);
await flush();
expect(dsm.get('ds_nm')?.isInit).toBe(true);
expect(dsm.get('ds_nm')?.isMounted).toBe(false);
});
});
describe('DataSourceManager - addDataSource 边界', () => {
afterEach(() => {
DataSourceManager.clearDataSourceClass();

View File

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

View File

@ -1,5 +1,5 @@
{
"version": "1.8.0-beta.23",
"version": "1.8.0-beta.28",
"name": "@tmagic/design",
"type": "module",
"sideEffects": [
@ -16,6 +16,11 @@
"import": "./dist/es/index.js",
"require": "./dist/tmagic-design.umd.cjs"
},
"./headless": {
"types": "./types/headless.d.ts",
"import": "./dist/es/headless.js",
"require": "./dist/tmagic-design-headless.umd.cjs"
},
"./*": "./*"
},
"files": [

View File

@ -1,5 +1,11 @@
<template>
<component class="tmagic-design-tooltip" :is="uiComponent" v-bind="uiProps">
<!-- ElTooltip ElOnlyChild 要求默认插槽至少有一个真实节点v-if false 时插槽是 Comment会报 no valid child node found -->
<component
v-if="isLegitTooltipTrigger($slots.default?.())"
class="tmagic-design-tooltip"
:is="uiComponent"
v-bind="uiProps"
>
<template #content>
<slot name="content"></slot>
</template>
@ -8,7 +14,7 @@
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { Comment, computed, Fragment, Text, type VNode } from 'vue';
import { getDesignConfig } from './config';
import type { TooltipProps } from './types';
@ -24,4 +30,30 @@ const ui = getDesignConfig('components')?.tooltip;
const uiComponent = ui?.component || 'el-tooltip';
const uiProps = computed<TooltipProps>(() => ui?.props(props) || props);
/** 与 Element Plus `findFirstLegitChild` 对齐Comment / 空文本不算合法 trigger。 */
const isLegitTooltipTrigger = (nodes: VNode[] | undefined): boolean => {
if (!nodes?.length) return false;
for (const child of nodes) {
if (!child) continue;
switch (child.type) {
case Comment:
continue;
case Text: {
const text = typeof child.children === 'string' ? child.children.trim() : '';
if (text) return true;
continue;
}
case Fragment:
if (isLegitTooltipTrigger(child.children as VNode[])) return true;
continue;
default:
return true;
}
}
return false;
};
</script>

View File

@ -0,0 +1,35 @@
/*
* 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';

View File

@ -1,5 +1,5 @@
{
"version": "1.8.0-beta.23",
"version": "1.8.0-beta.28",
"name": "@tmagic/editor",
"type": "module",
"sideEffects": [
@ -17,6 +17,11 @@
"import": "./dist/es/index.js",
"require": "./dist/tmagic-editor.umd.cjs"
},
"./headless": {
"types": "./types/headless.d.ts",
"import": "./dist/es/headless.js",
"require": "./dist/tmagic-editor-headless.umd.cjs"
},
"./dist/style.css": {
"import": "./dist/style.css",
"require": "./dist/style.css"

View File

@ -101,7 +101,6 @@
<template #props-panel>
<slot name="props-panel">
<PropsPanel
:extend-state="extendFormState"
:disabled-show-src="disabledShowSrc"
@mounted="propsPanelMountedHandler"
@unmounted="propsPanelUnmountedHandler"
@ -136,11 +135,13 @@
<script lang="ts" setup>
import { EventEmitter } from 'events';
import { computed, provide, ref } from 'vue';
import { computed, provide } from 'vue';
import type { MApp } from '@tmagic/core';
import { M_THEME_KEY } from '@tmagic/design';
import { FORM_CONTEXT_KEY } from '@tmagic/form';
import { useEditorFormContext } from './hooks/use-form-context';
import Framework from './layouts/Framework.vue';
import TMagicNavMenu from './layouts/NavMenu.vue';
import FormPanel from './layouts/props-panel/FormPanel.vue';
@ -230,8 +231,6 @@ const stageOptions: StageOptions = {
stageOverlayService.set('stageOptions', stageOptions);
const propsPanelRef = ref<InstanceType<typeof FormPanel> | null>(null);
provide('services', services);
provide('codeOptions', props.codeOptions);
@ -239,11 +238,16 @@ provide('stageOptions', stageOptions);
/** 是否启用「属性配置表单校验」联动能力,供 PropsPanel / FormPanel 判断校验失败时是否仍更新节点并记录错误 */
provide(ENABLE_PROPS_FORM_VALIDATE, props.enablePropsFormValidate ?? false);
/**
* 把顶层 `extendFormState` 提供给非 PropsPanel 链路上的组件使用例如历史差异对话框 HistoryDiffDialog
* 内部的 CompareForm这样所有依赖业务上下文的表单 filterFunction 都能拿到一致的扩展状态
* PropsPanel 通过 `:extend-state` 显式传入的方式保持等价
* 编辑器注入给整棵表单树的业务上下文`services` / `stage`属性面板对比表单
* 侧边栏里的嵌套 MForm 都通过 inject 自动继承
*
* 业务方要追加自己的字段 `<MEditor>` 外层再 `provide(FORM_CONTEXT_KEY, computed(...))`
* 即可配置回调统一通过 `mForm.xxx` 读取
*/
provide('extendFormState', props.extendFormState);
provide(
FORM_CONTEXT_KEY,
useEditorFormContext(() => services),
);
// computed provide provide
// props.isLargeStageContainer @tmagic/design/ColorPicker
@ -253,12 +257,6 @@ provide(
'isLargeStageContainer',
computed(() => props.isLargeStageContainer),
);
/**
* 提供 PropsPanel 主属性表单的 formState getter供历史差异弹窗复用
* CompareForm PropsPanel filterFunction 上下文保持一致
*/
provide('getPropsPanelFormState', () => propsPanelRef.value?.configForm?.formState);
/**
* 把历史记录面板的自定义扩展 tab 提供给深层的 HistoryListPanel它挂在 NavMenu
* markRaw component 形式渲染无法直接通过 props 透传业务方可借此在历史记录
@ -279,11 +277,9 @@ provide(
);
const propsPanelMountedHandler = (e: InstanceType<typeof FormPanel>) => {
propsPanelRef.value = e;
emit('props-panel-mounted', e);
};
const propsPanelUnmountedHandler = () => {
propsPanelRef.value = null;
emit('props-panel-unmounted');
};

View File

@ -15,7 +15,13 @@
<script lang="ts" setup>
import { computed, useTemplateRef } from 'vue';
import { type ContainerChangeEventData, type FormItemConfig, type FormValue, MForm } from '@tmagic/form';
import {
type ContainerChangeEventData,
defineFormItem,
type FormItemConfig,
type FormValue,
MForm,
} from '@tmagic/form';
import type { CodeParamStatement } from '@editor/type';
import { error } from '@editor/utils';
@ -41,13 +47,13 @@ const emit = defineEmits(['change']);
const formRef = useTemplateRef<InstanceType<typeof MForm>>('form');
const getFormConfig = (items: FormItemConfig[] = []) => [
{
defineFormItem({
type: 'fieldset',
items,
legend: '参数',
labelWidth: '120px',
labelPosition: 'top',
name: props.name,
},
}),
];
const codeParamsConfig = computed(() =>

View File

@ -10,7 +10,7 @@
:is-compare="true"
:disabled="true"
:label-width="labelWidth"
:extend-state="mergedExtendState"
:context="formContext"
:show-diff="showDiff"
:self-diff-field-types="selfDiffFieldTypes"
:size="size"
@ -47,7 +47,7 @@ const props = withDefaults(
},
);
const { config, currentValues, wrapperStyle, mergedExtendState, loadConfig, formRef, normalizeCodeBlockValue } =
const { config, currentValues, wrapperStyle, formContext, loadConfig, formRef, normalizeCodeBlockValue } =
useCompareForm(props);
const lastValuesProcessed = computed<FormValue>(() => {
@ -60,9 +60,8 @@ const lastValuesProcessed = computed<FormValue>(() => {
/**
* `code-select` 字段在历史数据中存在两种"语义为空"的形态
* - 字符串 `''`旧数据 / 用户从未配置过钩子
* - `{ hookType: HookType.CODE, hookData: [] }`CodeSelect.vue 在挂载时
* 写入的默认结构参见 packages/editor/src/fields/CodeSelect.vue
* `props.model[props.name] = { hookType: HookType.CODE, hookData: [] }`
* - `{ hookType: HookType.CODE, hookData: [] }``normalizeCodeSelectValue`
* 写入的默认结构
*
* 直接 `isEqual` 会把两者判为不等从而在历史对比里对每个未配置过钩子的组件
* 都展示一份"差异"体验很糟糕这里把它们视为相等跳过对比

View File

@ -8,7 +8,7 @@
:init-values="currentValues"
:disabled="disabled"
:label-width="labelWidth"
:extend-state="mergedExtendState"
:context="formContext"
:size="size"
></MForm>
</div>
@ -37,11 +37,10 @@ const props = withDefaults(
category: 'node',
labelWidth: '120px',
disabled: true,
// extendState useCompareForm props.extendState ?? ...
},
);
const { config, currentValues, wrapperStyle, mergedExtendState, loadConfig, formRef } = useCompareForm(props);
const { config, currentValues, wrapperStyle, formContext, loadConfig, formRef } = useCompareForm(props);
defineExpose<{
form: ShallowRef<InstanceType<typeof MForm> | null>;

View File

@ -1,7 +1,7 @@
import type { InjectionKey } from 'vue';
import type { DataSourceSchema, EventOption, Id, MApp, MNode, MPage, MPageFragment } from '@tmagic/core';
import type { FormConfig, FormState } from '@tmagic/form';
import type { FormConfig } from '@tmagic/form';
import StageCore, {
CONTAINER_HIGHLIGHT_CLASS_NAME,
ContainerHighlightType,
@ -149,7 +149,6 @@ export interface EditorProps {
beforeDblclick?: (event: MouseEvent) => Promise<boolean | void> | boolean | void;
/** 组件树节点双击前的钩子函数,返回 false 则阻止默认的双击行为 */
beforeLayerNodeDblclick?: (event: MouseEvent, data: TreeNodeData) => Promise<boolean | void> | boolean | void;
extendFormState?: (state: FormState) => Record<string, any> | Promise<Record<string, any>>;
/** 历史记录面板的自定义扩展 tab追加在内置的页面/数据源/代码块 tab 之后 */
historyListExtraTabs?: HistoryListExtraTab[];
/** 页面顺序拖拽配置参数 */

View File

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

View File

@ -1,36 +1,29 @@
<template>
<div class="m-fields-code-select" :class="config.className">
<TMagicCard :flat="config.flat">
<MContainer
:config="codeConfig"
:size="size"
class="code-select-content"
:prop="prop"
:disabled="disabled"
:is-compare="isCompareMode"
:last-values="lastValues?.[name]"
:model="model[name]"
@change="changeHandler"
>
</MContainer>
<TMagicButton class="create-button fullWidth" :icon="Plus" :size="size" :disabled="disabled" @click="newHandler()"
>添加{{ config.text }}</TMagicButton
>
</TMagicCard>
<MContainer
:config="codeConfig"
:size="size"
class="code-select-content"
:prop="prop"
:disabled="disabled"
:is-compare="isCompareMode"
:last-values="lastValues?.[name]"
:model="model[name]"
:label-position="config.labelPosition"
:label-width="config.labelWidth"
@change="changeHandler"
>
</MContainer>
</div>
</template>
<script lang="ts" setup>
import { computed, watch } from 'vue';
import { Plus } from '@element-plus/icons-vue';
import { isEmpty } from 'lodash-es';
import { HookCodeType, HookType } from '@tmagic/core';
import { TMagicButton, TMagicCard } from '@tmagic/design';
import type { CodeSelectConfig, ContainerChangeEventData, FieldProps, GroupListConfig } from '@tmagic/form';
import type { CodeSelectConfig, ContainerChangeEventData, FieldProps } from '@tmagic/form';
import { MContainer } from '@tmagic/form';
import { useServices } from '@editor/hooks/use-services';
import { createCodeSelectConfig, normalizeCodeSelectValue } from '@editor/fields/configs/codeSelect';
defineOptions({
name: 'MFieldsCodeSelect',
@ -40,8 +33,6 @@ const emit = defineEmits<{
change: [v: any, eventData: ContainerChangeEventData];
}>();
const { dataSourceService, codeBlockService } = useServices();
const props = withDefaults(defineProps<FieldProps<CodeSelectConfig>>(), {});
/**
@ -58,101 +49,14 @@ const props = withDefaults(defineProps<FieldProps<CodeSelectConfig>>(), {});
* 仅当存在历史值时才启用对比避免 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 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'),
},
],
}));
const codeConfig = computed(() => createCodeSelectConfig(props.config));
// applyMountValueEffects code-select effect
watch(
() => props.model[props.name],
(value) => {
//
if (isEmpty(value)) {
//
props.model[props.name] = {
hookType: HookType.CODE,
hookData: [],
};
}
},
{
immediate: true,
() => {
normalizeCodeSelectValue(props.model, props.name);
},
);

View File

@ -84,7 +84,7 @@ defineOptions({
const mForm = inject<FormState | undefined>('mForm');
const { codeBlockService, uiService } = useServices();
const eventBus = inject<EventBus>('eventBus');
const eventBus = inject<EventBus | null>('eventBus', null);
const emit = defineEmits<{
change: [v: any, eventData: ContainerChangeEventData];
}>();

View File

@ -31,6 +31,7 @@ import { computed } from 'vue';
import { getDesignConfig, TMagicSelect } from '@tmagic/design';
import type { CondOpSelectConfig, FieldProps } from '@tmagic/form';
import { removeDataSourceFieldPrefix } from '@tmagic/utils';
import { useServices } from '@editor/hooks/use-services';
import { getCondOpOptionsByFieldType, getFieldType } from '@editor/utils';
@ -50,8 +51,8 @@ const props = defineProps<FieldProps<CondOpSelectConfig>>();
const optionComponent = getDesignConfig('components')?.option;
const options = computed(() => {
const [id, ...fieldNames] = [...(props.config.parentFields || []), ...props.model.field];
const ds = dataSourceService.getDataSourceById(id);
const [id, ...fieldNames] = [...(props.config.parentFields || []), ...(props.model.field || [])];
const ds = id ? dataSourceService.getDataSourceById(removeDataSourceFieldPrefix(id)) : undefined;
const type = getFieldType(ds, fieldNames);
return getCondOpOptionsByFieldType(type);
});

View File

@ -124,7 +124,7 @@ const optionComponent = getDesignConfig('components')?.option;
const { dataSourceService, uiService } = useServices();
const mForm = inject<FormState | undefined>('mForm');
const eventBus = inject<EventBus>('eventBus');
const eventBus = inject<EventBus | null>('eventBus', null);
const allDataSources = computed(() => dataSourceService.get('dataSources') || []);

View File

@ -141,7 +141,10 @@ const checkStrictly = computed(() => {
if (typeof props.config.checkStrictly !== 'function') {
value = props.config.checkStrictly;
} else {
const dsId = removeDataSourceFieldPrefix(props.model[0]);
const fieldValue = props.model[props.name];
// 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);
value = props.config.checkStrictly(mForm, {

View File

@ -89,7 +89,6 @@ const emit = defineEmits<{
const { uiService } = useServices();
const mForm = inject<FormState | undefined>('mForm');
/** 对比模式下隐藏新增/编辑/删除等操作按钮,仅保留只读展示。 */
const isCompare = computed(() => Boolean(mForm?.isCompare));

View File

@ -67,7 +67,7 @@ defineOptions({
const { dataSourceService, uiService } = useServices();
const mForm = inject<FormState | undefined>('mForm');
const eventBus = inject<EventBus>('eventBus');
const eventBus = inject<EventBus | null>('eventBus', null);
const emit = defineEmits(['change']);

View File

@ -26,7 +26,12 @@ import { cloneDeep } from 'lodash-es';
import type { CodeBlockContent } from '@tmagic/core';
import { TMagicButton, tMagicMessageBox } from '@tmagic/design';
import type { ContainerChangeEventData, DataSourceMethodsConfig, FieldProps, FormState } from '@tmagic/form';
import {
type ContainerChangeEventData,
type DataSourceMethodsConfig,
type FieldProps,
type FormState,
} from '@tmagic/form';
import { type ColumnConfig, MagicTable } from '@tmagic/table';
import CodeBlockEditor from '@editor/components/CodeBlockEditor.vue';
@ -43,7 +48,6 @@ const props = withDefaults(defineProps<FieldProps<DataSourceMethodsConfig>>(), {
const emit = defineEmits(['change']);
const mForm = inject<FormState | undefined>('mForm');
/** 对比模式下隐藏新增/编辑/删除等操作按钮,仅保留只读展示。 */
const isCompare = computed(() => Boolean(mForm?.isCompare));

View File

@ -50,7 +50,7 @@ const props = withDefaults(defineProps<FieldProps<DataSourceSelect>>(), {
const mForm = inject<FormState | undefined>('mForm');
const { dataSourceService, uiService } = useServices();
const eventBus = inject<EventBus>('eventBus');
const eventBus = inject<EventBus | null>('eventBus', null);
const dataSources = computed(() => dataSourceService.get('dataSources'));

View File

@ -1,15 +1,17 @@
<template>
<MGroupList
style="width: 100%"
:config="config"
:name="name"
:disabled="disabled"
:model="model"
:last-values="lastValues"
:prop="prop"
:size="size"
@change="changeHandler"
></MGroupList>
<div class="m-fields-display-conds">
<MGroupList
:config="config"
:name="name"
:disabled="disabled"
:model="model"
:last-values="lastValues"
:is-compare="isCompareMode"
:prop="prop"
:size="size"
@change="changeHandler"
></MGroupList>
</div>
</template>
<script lang="ts" setup>
@ -22,170 +24,32 @@ import {
type FieldProps,
filterFunction,
type FormState,
type GroupListConfig,
MGroupList,
} from '@tmagic/form';
import { useServices } from '@editor/hooks/use-services';
import { getCascaderOptionsFromFields, getFieldType } from '@editor/utils';
import { createDisplayCondsConfig } from '@editor/fields/configs/displayConds';
defineOptions({
name: 'm-fields-display-conds',
});
const emit = defineEmits<{
change: [value: DisplayCond[], eventData?: ContainerChangeEventData];
change: [value: DisplayCond | DisplayCond[], eventData?: ContainerChangeEventData];
}>();
const props = withDefaults(defineProps<FieldProps<DisplayCondsConfig>>(), {
disabled: false,
});
const { dataSourceService } = useServices();
const mForm = inject<FormState | undefined>('mForm');
const parentFields = computed(() => filterFunction<string[]>(mForm, props.config.parentFields, props) || []);
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 config = computed(() => createDisplayCondsConfig(props.config, props.name, parentFields.value));
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 isCompareMode = computed(() => Boolean(props.isCompare && props.lastValues));
const changeHandler = (v: DisplayCond[], eventData?: ContainerChangeEventData) => {
if (!Array.isArray(props.model[props.name])) {
props.model[props.name] = [];
}
emit('change', v, eventData);
};
</script>

View File

@ -15,100 +15,50 @@
<div v-else class="fullWidth event-select-container">
<div class="event-select-header">
<div class="event-select-title">事件配置</div>
<TMagicButton
v-if="!isCompareMode && displayList.length > 0"
class="create-button"
text
type="primary"
:icon="Plus"
:size="size"
:disabled="disabled"
@click="addEvent()"
>添加事件</TMagicButton
>
</div>
<MPanel
v-for="entry in displayList"
:key="entry.index"
<MGroupList
:config="eventConfig"
:name="name"
:disabled="disabled"
:size="size"
:prop="`${prop}.${entry.index}`"
:config="actionsConfig"
:model="entry.cardItem"
:last-values="entry.lastCardItem"
:model="model"
:last-values="lastValues"
:is-compare="isCompareMode"
:hide-expand="false"
:label-width="config.labelWidth || '100px'"
:prop="prop"
:size="size"
@change="onChangeHandler"
>
<template #header>
<template #title="{ model: itemModel, lastValues: itemLastValues, prop: itemProp }">
<div class="event-item-header">
<MFormContainer
class="fullWidth"
:config="eventNameConfig"
:model="entry.cardItem"
:last-values="entry.lastCardItem"
:model="itemModel"
:last-values="itemLastValues"
:is-compare="isCompareMode"
:disabled="disabled"
:size="size"
:prop="`${prop}.${entry.index}`"
@change="eventNameChangeHandler"
:prop="itemProp"
@change="onChangeHandler"
></MFormContainer>
<TMagicButton
class="event-item-delete-button"
v-if="!isCompareMode"
link
:icon="Delete"
:disabled="disabled"
:size="size"
@click="removeEvent(Number(entry.index))"
></TMagicButton>
</div>
</template>
</MPanel>
<TMagicButton
v-if="!isCompareMode"
class="create-button fullWidth"
:icon="Plus"
:disabled="disabled"
@click="addEvent()"
>添加事件</TMagicButton
>
</MGroupList>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { Delete } from '@element-plus/icons-vue';
import { Plus } from '@element-plus/icons-vue';
import { has } from 'lodash-es';
import { ActionType } from '@tmagic/core';
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 type { ContainerChangeEventData, EventSelectConfig, FieldProps } from '@tmagic/form';
import { MContainer as MFormContainer, MGroupList, MTable } from '@tmagic/form';
import { useServices } from '@editor/hooks/use-services';
import {
getCompActionAllowedValues,
getCompActionOptions,
getEventNameAllowedValues,
getEventNameOptions,
normalizeCompActionValue,
} from '@editor/utils';
createEventNameConfig,
createEventSelectConfig,
createLegacyTableConfig,
isLegacyEventValue,
} from '@editor/fields/configs/eventSelect';
defineOptions({
name: 'MFieldsEventSelect',
@ -120,322 +70,25 @@ const emit = defineEmits<{
change: [v: any, eventData?: ContainerChangeEventData];
}>();
const { editorService, dataSourceService, eventsService, codeBlockService, propsService } = useServices();
const tableConfig = computed(() => createLegacyTableConfig(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 eventNameConfig = computed(() => createEventNameConfig(props.config));
const actionTypeOptions = computed(() => {
const o: {
text: string;
label: string;
value: string;
disabled?: boolean;
}[] = [
{
text: '组件',
label: '组件',
value: ActionType.COMP,
},
];
const eventConfig = computed(() => createEventSelectConfig(props.config, 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');
});
const isOldVersion = computed(() => isLegacyEventValue(props.model[props.name]));
/**
* 对比模式判定
*
* event-select 内部由事件列表 + 嵌套子表单组成属于复合字段父级 `MFormContainer` 已将其
* 归入自接管对比字段 Container.vue `SELF_DIFF_FIELD_TYPES`即对比时只渲染一次本组件
* 并把当前值 `model` 与历史值 `lastValues` 一并传入由本组件把 `is-compare`/`lastValues` 透传给
* 内部的 MPanel / MFormContainer逐项事件名动作展示前后差异
* event-select 内部是事件列表 group-list父级 `MFormContainer` 已将其归入自接管对比字段
* Container.vue `SELF_DIFF_FIELD_TYPES`对比时只渲染一次本组件并把 `is-compare` /
* `lastValues` 透传给内部 MGroupList title slot 里的事件名表单
*
* 仅当存在历史值时才启用对比避免 lastValues 缺失时退化为全部新增的空对比
*/
const isCompareMode = computed(() => Boolean(props.isCompare && props.lastValues));
/**
* 待渲染的事件卡片列表
*
* - 非对比模式直接映射当前事件列表`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) =>
const onChangeHandler = (_v: any, eventData?: ContainerChangeEventData) =>
emit('change', props.model[props.name], eventData);
</script>

View File

@ -75,7 +75,7 @@ import { computed, inject, ref, watchEffect } from 'vue';
import { Delete, Plus } from '@element-plus/icons-vue';
import { TMagicButton, TMagicInput } from '@tmagic/design';
import type { FieldProps, FormState, KeyValueConfig } from '@tmagic/form';
import { type FieldProps, type FormState, type KeyValueConfig } from '@tmagic/form';
import CodeIcon from '@editor/icons/CodeIcon.vue';
import MagicCodeEditor from '@editor/layouts/CodeEditor.vue';

View File

@ -52,48 +52,14 @@
import { computed, ref } from 'vue';
import type { ContainerChangeEventData, FormValue } from '@tmagic/form';
import { defineFormItem, MContainer } from '@tmagic/form';
import { MContainer } from '@tmagic/form';
import type { StyleSchema } from '@tmagic/schema';
import { createBorderDirectionConfig } from '../configs';
const direction = ref('');
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 config = computed(() => createBorderDirectionConfig(direction.value));
const selectDirection = (d?: string) => (direction.value = d || '');

View File

@ -0,0 +1,690 @@
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 modelleft/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 Boxmargin/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,
})),
},
},
],
});

View File

@ -15,14 +15,12 @@
</template>
<script lang="ts" setup>
import { markRaw } from 'vue';
import { computed } from 'vue';
import { appendValidateSuggestion } from '@tmagic/design';
import { type ContainerChangeEventData, defineFormConfig, MContainer } from '@tmagic/form';
import { type ContainerChangeEventData, MContainer } from '@tmagic/form';
import type { StyleSchema } from '@tmagic/schema';
import BackgroundPosition from '../components/BackgroundPosition.vue';
import { BackgroundNoRepeat, BackgroundRepeat, BackgroundRepeatX, BackgroundRepeatY } from '../icons/background-repeat';
import { createBackgroundConfig } from '../configs';
defineProps<{
values: Partial<StyleSchema>;
@ -39,105 +37,7 @@ const emit = defineEmits<{
addDiffCount: [];
}>();
const formConfig = defineFormConfig([
{
name: 'backgroundColor',
text: '背景色',
labelWidth: '68px',
type: 'data-source-field-select',
fieldConfig: {
type: 'colorPicker',
},
rules: [
{
typeMatch: true,
message: appendValidateSuggestion('背景色应为字符串', '请参考以下示例值:"#000000"'),
},
],
},
{
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 formConfig = computed(() => createBackgroundConfig());
const change = (value: StyleSchema, eventData: ContainerChangeEventData) => {
emit('change', value, eventData);

View File

@ -1,7 +1,7 @@
<template>
<MContainer
:prop="prop"
:config="config"
:config="borderRadiusConfig"
:model="values"
:last-values="lastValues"
:is-compare="isCompare"
@ -22,10 +22,11 @@
</template>
<script lang="ts" setup>
import { type ContainerChangeEventData, defineFormItem, MContainer } from '@tmagic/form';
import { type ContainerChangeEventData, MContainer } from '@tmagic/form';
import type { StyleSchema } from '@tmagic/schema';
import Border from '../components/Border.vue';
import { borderRadiusConfig } from '../configs';
defineProps<{
values: Partial<StyleSchema>;
@ -41,16 +42,6 @@ const emit = defineEmits<{
addDiffCount: [];
}>();
const config = defineFormItem({
labelWidth: '68px',
name: 'borderRadius',
text: '圆角',
type: 'data-source-field-select',
fieldConfig: {
type: 'text',
},
});
const change = (value: StyleSchema, eventData: ContainerChangeEventData) => {
emit('change', value, eventData);
};

View File

@ -15,15 +15,14 @@
</template>
<script lang="ts" setup>
import { markRaw } from 'vue';
import { computed } from 'vue';
import { appendValidateSuggestion } from '@tmagic/design';
import { type ContainerChangeEventData, defineFormConfig, MContainer } from '@tmagic/form';
import { type ContainerChangeEventData, MContainer } from '@tmagic/form';
import type { StyleSchema } from '@tmagic/schema';
import { validateDataSourceFieldSelectValue } from '@editor/utils/type-match-rules';
import { validateDataSourceFieldSelect } from '@editor/utils/type-match-rules';
import { AlignCenter, AlignLeft, AlignRight } from '../icons/text-align';
import { createFontConfig } from '../configs';
defineProps<{
values: Partial<StyleSchema>;
@ -39,125 +38,7 @@ const emit = defineEmits<{
addDiffCount: [];
}>();
const formConfig = defineFormConfig([
{
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',
},
},
],
},
{
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 = validateDataSourceFieldSelectValue(
value,
{
fieldType: 'data-source-field-select',
mForm,
props: { config, model, prop },
},
{
// string number 700
validatePlainValue: (plainValue) => {
if (typeof plainValue === 'string' || (typeof plainValue === 'number' && !Number.isNaN(plainValue))) {
return undefined;
}
return '字重应为字符串或数字';
},
},
);
if (result && typeof (result as Promise<string | undefined>).then === 'function') {
(result as Promise<string | undefined>).then(
(error) => callback(error),
(error) => callback(error),
);
return;
}
return callback(result);
},
},
],
},
{
labelWidth: '68px',
name: 'color',
text: '颜色',
type: 'data-source-field-select',
fieldConfig: {
type: 'colorPicker',
},
rules: [
{
typeMatch: true,
message: appendValidateSuggestion('颜色应为字符串', '请参考以下示例值:"#000000"'),
},
],
},
{
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 formConfig = computed(() => createFontConfig(validateDataSourceFieldSelect));
const change = (value: StyleSchema, eventData: ContainerChangeEventData) => {
emit('change', value, eventData);

View File

@ -24,35 +24,15 @@
</template>
<script lang="ts" setup>
import { markRaw } from 'vue';
import { computed } from 'vue';
import { useTheme } from '@tmagic/design';
import type { ContainerChangeEventData } from '@tmagic/form';
import { defineFormConfig, MContainer } from '@tmagic/form';
import { MContainer } from '@tmagic/form';
import type { StyleSchema } from '@tmagic/schema';
import Box from '../components/Box.vue';
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';
import { createLayoutConfig } from '../configs';
const props = defineProps<{
values: Partial<StyleSchema>;
@ -71,202 +51,7 @@ const emit = defineEmits<{
const displayTheme = useTheme(props);
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 formConfig = computed(() => createLayoutConfig(displayTheme.value));
const change = (value: string | StyleSchema, eventData: ContainerChangeEventData) => {
emit('change', value, eventData);

View File

@ -15,10 +15,13 @@
</template>
<script lang="ts" setup>
import { appendValidateSuggestion } from '@tmagic/design';
import { type ContainerChangeEventData, defineFormConfig, MContainer } from '@tmagic/form';
import { computed } from 'vue';
import { type ContainerChangeEventData, MContainer } from '@tmagic/form';
import type { StyleSchema } from '@tmagic/schema';
import { createPositionConfig } from '../configs';
const props = defineProps<{
values: Partial<StyleSchema>;
lastValues?: Partial<StyleSchema>;
@ -33,114 +36,7 @@ const emit = defineEmits<{
addDiffCount: [];
}>();
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',
},
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"'),
},
],
},
],
},
{
type: 'row',
labelWidth: '68px',
display: () => props.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"'),
},
],
},
],
},
{
labelWidth: '68px',
name: 'zIndex',
text: 'zIndex',
type: 'data-source-field-select',
fieldConfig: {
type: 'text',
},
rules: [
{
typeMatch: true,
message: appendValidateSuggestion('zIndex 应为数字', '请参考以下示例值10'),
},
],
},
]);
const formConfig = computed(() => createPositionConfig(props.values));
const change = (value: string | StyleSchema, eventData: ContainerChangeEventData) => {
emit('change', value, eventData);

View File

@ -1,7 +1,7 @@
<template>
<MContainer
:prop="prop"
:config="config"
:config="transformConfig"
:model="values"
:last-values="lastValues"
:is-compare="isCompare"
@ -13,9 +13,11 @@
</template>
<script lang="ts" setup>
import { type ContainerChangeEventData, defineFormItem, MContainer } from '@tmagic/form';
import { type ContainerChangeEventData, MContainer } from '@tmagic/form';
import type { StyleSchema } from '@tmagic/schema';
import { transformConfig } from '../configs';
defineProps<{
values: Partial<StyleSchema>;
lastValues?: Partial<StyleSchema>;
@ -30,34 +32,6 @@ const emit = defineEmits<{
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) => {
emit('change', value, eventData);
};

View File

@ -0,0 +1,127 @@
/*
* 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: [],
};
}
};

View File

@ -0,0 +1,184 @@
/*
* 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;
};

View File

@ -0,0 +1,309 @@
/*
* 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 slotbody 只放动作组。 */
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');
};

View File

@ -0,0 +1,32 @@
/*
* 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 },
},
});

View File

@ -0,0 +1,177 @@
/*
* 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 复用了字段自身的 nameprop 基准要退回父级,否则 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'] },
};

View File

@ -0,0 +1,33 @@
/*
* 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.vuemonaco DOM Node import
* `@tmagic/form/headless``registerBuiltInFields(builtInFields); registerFields(editorFields);`
*
* Vue `style-setter` UI
* `fields/StyleSetter/configs.ts` `icon` / `component`
* SFCtypeMatch editor services
* import 访 DOM `@tmagic/form/headless`
*
* @module @tmagic/editor/headless
*/
export { editorFields } from './fields/headless-validation';

View File

@ -16,20 +16,20 @@
* limitations under the License.
*/
import { computed, type ComputedRef, inject, provide, type Ref, ref, useTemplateRef, watch, watchEffect } from 'vue';
import { computed, type ComputedRef, inject, provide, type Ref, ref, useTemplateRef, watch } from 'vue';
import type { CodeBlockContent, MNode } from '@tmagic/core';
import { type FormConfig, type FormState, type FormValue, MForm } from '@tmagic/form';
import { type FormConfig, type FormContext, type FormValue, MForm } from '@tmagic/form';
import { useEditorFormContext } from '@editor/hooks/use-form-context';
import type { CompareFormBaseProps } from '@editor/type';
import { getCodeBlockFormConfig } from '@editor/utils/code-block';
import { removeStyleDisplayConfig } from '@editor/utils/props';
export interface UseCompareFormReturn {
config: Ref<FormConfig>;
currentValues: ComputedRef<FormValue>;
wrapperStyle: ComputedRef<Record<string, string> | undefined>;
mergedExtendState: (state: FormState) => Record<string, any> | Promise<Record<string, any>>;
formContext: ComputedRef<FormContext>;
loadConfig: () => Promise<void>;
formRef: Readonly<Ref<InstanceType<typeof MForm> | null>>;
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`
* - `content`
* - + `wrapperStyle`
* - services / stage MForm.formState filterFunction
* - services / stage form context MForm filterFunction
*
*
*/
@ -88,10 +88,7 @@ export const useCompareForm = (props: CompareFormBaseProps): UseCompareFormRetur
return style;
});
const mergedExtendState = (state: FormState) => {
const extendState = props.extendState ?? ((s: FormState) => s);
return extendState(props.baseFormState || state);
};
const formContext = useEditorFormContext(() => props.services);
/**
* FormConfig `category` service /
@ -107,9 +104,7 @@ export const useCompareForm = (props: CompareFormBaseProps): UseCompareFormRetur
if (!props.type) {
return [];
}
return removeStyleDisplayConfig(
await props.services.propsService.getPropsConfig(props.type, { node: props.value as unknown as MNode }),
);
return props.services.propsService.getPropsConfig(props.type, { node: props.value as unknown as MNode });
}
case 'data-source': {
const config = props.services.dataSourceService.getFormConfig(props.type || 'base');
@ -159,27 +154,11 @@ export const useCompareForm = (props: CompareFormBaseProps): UseCompareFormRetur
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 {
config,
currentValues,
wrapperStyle,
mergedExtendState,
formContext,
loadConfig,
formRef,
normalizeCodeBlockValue,

View File

@ -0,0 +1,56 @@
/*
* 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'),
});
});
};

View File

@ -91,4 +91,6 @@ export { default as DisplayConds } from './fields/DisplayConds.vue';
export { default as CondOpSelect } from './fields/CondOpSelect.vue';
export { default as StyleSetter } from './fields/StyleSetter/Index.vue';
export { editorFields } from './fields/headless-validation';
export { default } from './plugin';

View File

@ -29,6 +29,7 @@ import { getDepNodeIds, getNodes, isPage, isValueIncludeDataSource } from '@tmag
import PropsPanel from './layouts/PropsPanel.vue';
import { isIncludeDataSource } from './utils/editor';
import { error as logError } from './utils/logger';
import { EditorProps } from './editorProps';
import { Services } from './type';
@ -36,6 +37,19 @@ export declare type LooseRequired<T> = {
[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 = (
props: EditorProps,
{
@ -193,9 +207,17 @@ export const initServiceState = (
},
);
// defaultSelected 可能早于 modelValue 到位immediate 首次执行时 root 还是空的,或节点尚未出现在 DSL 中),
// 此时 select 会因为「获取不到组件信息」而抛错,交由 root-change 中的选中逻辑在 root 就绪后补上即可。
watch(
() => props.defaultSelected,
(defaultSelected) => defaultSelected && editorService.select(defaultSelected),
(defaultSelected) => {
if (!defaultSelected || !editorService.getNodeById(defaultSelected)) {
return;
}
selectNode(editorService, defaultSelected);
},
{
immediate: true,
},
@ -232,6 +254,7 @@ export const initServiceState = (
uiService.resetState();
componentListService.resetState();
codeBlockService.resetState();
eventsService.resetState();
keybindingService.reset();
depService.reset();
});
@ -404,12 +427,26 @@ 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 stage = await getStage();
const runtime = await stage.renderer?.getRuntime();
const app = await getTMagicApp();
// 等 stage / runtime 就绪期间 root 已被替换:新 root 的刷新可能已经完成,
// 再把旧 dsl 推给 runtime 会让画布回退到旧内容
if (isStaleRoot(value)) return;
if (!app?.dataSourceManager) {
runtime?.updateRootConfig?.(cloneDeep(toRaw(value))!);
}
@ -426,6 +463,8 @@ export const initServiceEvents = (
await (typeof Worker === 'undefined' ? collectIdle(value.items, true) : depService.collectByWorker(value));
if (isStaleRoot(value)) return;
const dsl = cloneDeep(toRaw(value));
if (dsl.dataSources && dsl.dataSourceDeps && app?.dataSourceManager) {
for (const node of getNodes(getDepNodeIds(dsl.dataSourceDeps), dsl.items)) {
@ -443,7 +482,7 @@ export const initServiceEvents = (
depService.addTarget(createDataSourceCondTarget(ds, reactive({})));
};
const rootChangeHandler = (value: MApp | null, preValue?: MApp | null) => {
const rootChangeHandler = (value: MApp | null) => {
if (!value) return;
value.codeBlocks = value.codeBlocks || {};
@ -486,7 +525,13 @@ export const initServiceEvents = (
editorService.set('page', null);
}
if (toRaw(value) !== toRaw(preValue)) {
// 上面的 select 是异步的,期间 root 可能已被替换,过期快照不能再回写给外部:
// 两次整体替换各自持有一个快照时,回写会把对方的 root 顶掉,外部 modelValue 变化又会
// 重新 set root两条链路无休止地交替下去表现为编辑器卡死
if (isStaleRoot(value)) return;
// 外部已经持有这个 root如本次变化就是 modelValue 传进来的)时无需回写
if (toRaw(props.modelValue) !== toRaw(value)) {
emit('update:modelValue', value);
}
})();

View File

@ -43,8 +43,6 @@
:data-source-type="payload.dataSourceType"
:value="rightValue"
:last-value="leftValue"
:base-form-state="compareFormState"
:extend-state="extendState"
:load-config="loadConfig"
:self-diff-field-types="selfDiffFieldTypes"
:services="props.services"
@ -86,7 +84,6 @@ import {
TMagicRadioGroup,
TMagicTag,
} from '@tmagic/design';
import type { FormState } from '@tmagic/form';
import CompareForm from '@editor/components/CompareForm.vue';
import CodeEditor from '@editor/layouts/CodeEditor.vue';
@ -100,12 +97,6 @@ const props = withDefaults(
defineProps<{
/** 编辑器服务集合,由调用方传入(不再通过 inject('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`
* 取配置逻辑可通过 `ctx.defaultLoadConfig()` 复用默认结果再做二次加工
@ -117,7 +108,6 @@ const props = withDefaults(
isConfirm?: boolean;
onConfirm?: () => void;
selfDiffFieldTypes?: string[];
compareFormState?: FormState;
}>(),
{
width: '900px',

View File

@ -133,7 +133,6 @@ import { computed, inject, markRaw, ref, watch } from 'vue';
import { Clock, Close } from '@element-plus/icons-vue';
import { getDesignConfig, TMagicButton, tMagicMessage, TMagicPopover, TMagicTabs, TMagicTooltip } from '@tmagic/design';
import type { FormState } from '@tmagic/form';
import MIcon from '@editor/components/Icon.vue';
import { useServices } from '@editor/hooks/use-services';
@ -193,17 +192,6 @@ watch([disabledDataSource, disabledCodeBlock], ([dsDisabled, cbDisabled]) => {
}
});
/**
* 通过 inject 拿到 Editor 顶层注入的 `extendFormState`转交给 HistoryDiffDialog
* 内部的 CompareForm使差异对比表单的 filterFunction 能拿到完整的业务上下文
* 未提供时为 undefinedCompareForm/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 {
expanded,
toggleGroup,
@ -316,7 +304,7 @@ const onCodeBlockGotoInitial = (id: string | number) => {
* 业务方亦可直接 import useHistoryRevert(options, services) 调用无需自行挂载任何弹窗
*/
const { onPageRevert, onDataSourceRevert, onCodeBlockRevert, onPageDiff, onDataSourceDiff, onCodeBlockDiff } =
useHistoryRevert({ extendState: extendFormState, getPropsPanelFormState }, services);
useHistoryRevert({}, services);
/**
* 把内存中已清空对应类别后的历史状态重新写回 IndexedDB

View File

@ -74,7 +74,7 @@ interface MountedDiffDialog {
* import
*/
const mountHistoryDiffDialog = async (
options: Pick<UseHistoryRevertOptions, 'appContext' | 'extendState'> &
options: Pick<UseHistoryRevertOptions, 'appContext'> &
CustomDiffFormOptions & {
services?: Services;
isConfirm?: boolean;
@ -90,10 +90,8 @@ const mountHistoryDiffDialog = async (
const app = createApp(historyDiffDialog, {
services: options.services,
isConfirm: options.isConfirm,
extendState: options.extendState,
loadConfig: options.loadConfig,
selfDiffFieldTypes: options.selfDiffFieldTypes,
compareFormState: options.compareFormState,
width: options.width,
size: options.size ?? options.services?.uiService?.get('propsPanelSize'),
onClose: options.onClose,
@ -125,7 +123,7 @@ const mountHistoryDiffDialog = async (
*/
const confirmRevertWithDiffDialog = async (
payload: DiffDialogPayload,
options: Pick<UseHistoryRevertOptions, 'appContext' | 'extendState'> &
options: Pick<UseHistoryRevertOptions, 'appContext'> &
CustomDiffFormOptions & {
services?: Services;
},
@ -147,7 +145,7 @@ const confirmRevertWithDiffDialog = async (
*/
const viewHistoryDiffDialog = async (
payload: DiffDialogPayload,
options: Pick<UseHistoryRevertOptions, 'appContext' | 'extendState'> &
options: Pick<UseHistoryRevertOptions, 'appContext'> &
CustomDiffFormOptions & {
services?: Services;
},
@ -194,7 +192,7 @@ const viewHistoryDiffDialog = async (
export const useHistoryRevert = (options: UseHistoryRevertOptions = {}, services?: Services) => {
// 自动捕获调用方所在组件的 appContext在 setup 中调用时),业务方亦可显式覆盖。
const appContext = options.appContext ?? getCurrentInstance()?.appContext ?? null;
const { extendState, getPropsPanelFormState, dialogWidth } = options;
const { dialogWidth } = options;
/** 目标数据已被删除、无法回滚时的统一提示。 */
const showRevertTargetMissing = () => {
@ -206,10 +204,7 @@ export const useHistoryRevert = (options: UseHistoryRevertOptions = {}, services
*
* false
*/
const confirmRevert = (): Promise<boolean> =>
confirmHistoryAction(
'确定回滚该步骤吗?回滚会将该操作作为一条新记录反向应用(新增将被删除、删除将被还原),不影响后续历史记录。',
);
const confirmRevert = (): Promise<boolean> => confirmHistoryAction('确定回滚该步骤吗?');
/**
* HistoryDiffDialog
@ -220,7 +215,6 @@ export const useHistoryRevert = (options: UseHistoryRevertOptions = {}, services
if (payload) {
return confirmRevertWithDiffDialog(payload, {
appContext,
extendState,
services,
...extra,
width: extra?.width ?? dialogWidth,
@ -307,7 +301,7 @@ export const useHistoryRevert = (options: UseHistoryRevertOptions = {}, services
showRevertTargetMissing();
return Promise.resolve(null);
}
return runRevert(buildPageDiffPayload(index), { compareFormState: getPropsPanelFormState?.() }).then((result) =>
return runRevert(buildPageDiffPayload(index), {}).then((result) =>
result ? services?.editorService.revertPageStep(index) : null,
);
};
@ -341,22 +335,20 @@ export const useHistoryRevert = (options: UseHistoryRevertOptions = {}, services
if (payload) {
return viewHistoryDiffDialog(payload, {
appContext,
extendState,
services,
width: dialogWidth,
compareFormState: getPropsPanelFormState?.(),
});
}
};
const onDataSourceDiff = (id: string | number, index: number): Promise<void> | void => {
const payload = buildDataSourceDiffPayload(id, index);
if (payload) return viewHistoryDiffDialog(payload, { appContext, extendState, services, width: dialogWidth });
if (payload) return viewHistoryDiffDialog(payload, { appContext, services, width: dialogWidth });
};
const onCodeBlockDiff = (id: string | number, index: number): Promise<void> | void => {
const payload = buildCodeBlockDiffPayload(id, index);
if (payload) return viewHistoryDiffDialog(payload, { appContext, extendState, services, width: dialogWidth });
if (payload) return viewHistoryDiffDialog(payload, { appContext, services, width: dialogWidth });
};
/**
@ -398,7 +390,6 @@ export const useHistoryRevert = (options: UseHistoryRevertOptions = {}, services
if (payload)
return viewHistoryDiffDialog(payload, {
appContext,
extendState,
services,
...extra,
width: extra?.width ?? dialogWidth,

View File

@ -13,7 +13,7 @@
:init-values="values"
:config="config"
:type-match-valid="true"
:extend-state="extendState"
:context="formContext"
:validate-on-init="true"
@change="submit"
@error="errorHandler"
@ -45,17 +45,18 @@
</template>
<script setup lang="ts">
import { computed, getCurrentInstance, inject, onMounted, onUnmounted, ref, useTemplateRef, watchEffect } from 'vue';
import { computed, getCurrentInstance, inject, onMounted, onUnmounted, ref, useTemplateRef } from 'vue';
import { Document as DocumentIcon } from '@element-plus/icons-vue';
import { TMagicButton, tMagicMessage, TMagicScrollbar } from '@tmagic/design';
import type { ContainerChangeEventData, FormConfig, FormState, FormValue } from '@tmagic/form';
import type { ContainerChangeEventData, FormConfig, FormValue } from '@tmagic/form';
import { MForm, validateForm } from '@tmagic/form';
import { filterXSS } from '@tmagic/utils';
import MIcon from '@editor/components/Icon.vue';
import { ENABLE_PROPS_FORM_VALIDATE } from '@editor/editorProps';
import { useEditorContentHeight } from '@editor/hooks/use-editor-content-height';
import { useEditorFormContext } from '@editor/hooks/use-form-context';
import { useServices } from '@editor/hooks/use-services';
import CodeEditor from '../CodeEditor.vue';
@ -74,8 +75,7 @@ const props = defineProps<{
disabledShowSrc?: boolean;
labelWidth?: string;
codeValueKey?: string;
labelPosition?: string;
extendState?: (_state: FormState) => Record<string, any> | Promise<Record<string, any>>;
labelPosition?: 'top' | 'left' | 'right';
}>();
const emit = defineEmits<{
@ -89,24 +89,18 @@ const emit = defineEmits<{
const enablePropsFormValidate = inject(ENABLE_PROPS_FORM_VALIDATE, false);
const services = useServices();
const { editorService, uiService } = services;
const { uiService } = services;
const codeOptions = inject('codeOptions', {});
const showSrc = ref(false);
const propsPanelSize = computed(() => uiService.get('propsPanelSize') || 'small');
const { height: editorContentHeight } = useEditorContentHeight();
const stage = computed(() => editorService.get('stage'));
const formContext = useEditorFormContext(() => services);
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();
onMounted(() => {
emit('mounted', internalInstance?.proxy);
@ -158,23 +152,17 @@ const saveCode = async (values: any) => {
return;
}
// MForm
//
//
//
// 使
//
// display / rules formState services context
try {
const error = await validateForm({
config: props.config,
typeMatchValid: true,
initValues: newValues,
// provides Editor services / codeOptions provide
// appContext使 MForm DataSourceInput inject
appContext: internalInstance?.appContext ? { ...internalInstance?.appContext, provides: { services } } : null,
extendState: (state) => {
if (configFormRef.value?.formState) {
return { ...(configFormRef.value?.formState || {}) };
}
return props.extendState?.(state) || {};
},
context: formContext.value,
});
if (error) {
@ -187,9 +175,10 @@ const saveCode = async (values: any) => {
emit('submit', newValues, undefined, error ? new Error(error) : undefined);
} catch (e: any) {
console.log('validateForm error', e);
// 退
emit('submit', newValues);
console.error('validateForm error', e);
// / initValue
// error
emit('submit', newValues, undefined, e instanceof Error ? e : new Error(String(e)));
}
};

View File

@ -8,7 +8,6 @@
:config="curFormConfig"
:values="values"
:disabledShowSrc="disabledShowSrc"
:extendState="extendState"
@submit="(v, eventData, error) => submit(v, eventData, error, 'props')"
@submit-error="errorHandler"
@form-error="errorHandler"
@ -26,7 +25,6 @@
:config="styleFormConfig"
:values="values"
:disabledShowSrc="disabledShowSrc"
:extendState="extendState"
@submit="(v, eventData, error) => submit(v, eventData, error, 'style')"
@submit-error="errorHandler"
@form-error="errorHandler"
@ -61,7 +59,7 @@ import type { OnDrag } from 'gesto';
import { type MNode } from '@tmagic/core';
import { TMagicButton } from '@tmagic/design';
import type { ContainerChangeEventData, FormState, FormValue } from '@tmagic/form';
import type { ContainerChangeEventData, FormValue } from '@tmagic/form';
import { setValueByKeyPath } from '@tmagic/utils';
import MIcon from '@editor/components/Icon.vue';
@ -84,7 +82,6 @@ defineOptions({
defineProps<{
disabledShowSrc?: boolean;
extendState?: (_state: FormState) => Record<string, any> | Promise<Record<string, any>>;
}>();
const emit = defineEmits<{

View File

@ -82,7 +82,7 @@ const props = defineProps<{
customContentMenu: CustomContentMenuFunction;
}>();
const eventBus = inject<EventBus>('eventBus');
const eventBus = inject<EventBus | null>('eventBus', null);
const { codeBlockService } = useServices();

View File

@ -6,7 +6,7 @@ import ContentMenu from '@editor/components/ContentMenu.vue';
import type { ContentMenuTarget, EventBus, MenuButton, MenuComponent, TreeNodeData } from '@editor/type';
export const useContentMenu = (deleteCode: (id: string) => void) => {
const eventBus = inject<EventBus>('eventBus');
const eventBus = inject<EventBus | null>('eventBus', null);
const menuRef = useTemplateRef<InstanceType<typeof ContentMenu>>('menu');
let selectId = '';

View File

@ -75,7 +75,7 @@ const props = defineProps<{
customContentMenu: CustomContentMenuFunction;
}>();
const eventBus = inject<EventBus>('eventBus');
const eventBus = inject<EventBus | null>('eventBus', null);
const { dataSourceService } = useServices();
const { editDialog, dataSourceValues, dialogTitle, editable, editHandler, submitDataSourceHandler } =

View File

@ -6,7 +6,7 @@ import ContentMenu from '@editor/components/ContentMenu.vue';
import type { ContentMenuTarget, EventBus, MenuButton, MenuComponent, TreeNodeData } from '@editor/type';
export const useContentMenu = () => {
const eventBus = inject<EventBus>('eventBus');
const eventBus = inject<EventBus | null>('eventBus', null);
const menuRef = useTemplateRef<InstanceType<typeof ContentMenu>>('menu');
let selectId = '';

View File

@ -20,8 +20,8 @@ import { type App } from 'vue';
import type { DesignPluginOptions } from '@tmagic/design';
import designPlugin from '@tmagic/design';
import type { FormInstallOptions } from '@tmagic/form';
import formPlugin, { registerTypeMatchRules } from '@tmagic/form';
import type { FieldOptions, FormInstallOptions } from '@tmagic/form';
import formPlugin, { mergeFieldOptions } from '@tmagic/form';
import tablePlugin from '@tmagic/table';
import Code from './fields/Code.vue';
@ -38,13 +38,13 @@ import DataSourceMocks from './fields/DataSourceMocks.vue';
import DataSourceSelect from './fields/DataSourceSelect.vue';
import DisplayConds from './fields/DisplayConds.vue';
import EventSelect from './fields/EventSelect.vue';
import { editorFields } from './fields/headless-validation';
import KeyValue from './fields/KeyValue.vue';
import PageFragmentSelect from './fields/PageFragmentSelect.vue';
import StyleSetter from './fields/StyleSetter/Index.vue';
import uiSelect from './fields/UISelect.vue';
import CodeEditor from './layouts/CodeEditor.vue';
import { setEditorConfig } from './utils/config';
import { editorTypeMatchRules } from './utils/type-match-rules';
import Editor from './Editor.vue';
import type { EditorInstallOptions } from './type';
@ -60,36 +60,43 @@ const defaultInstallOpt: EditorInstallOptions = {
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 {
install: (app: App, opt?: Partial<EditorInstallOptions | DesignPluginOptions | FormInstallOptions>): void => {
const option = Object.assign(defaultInstallOpt, opt || {});
const incoming = opt || {};
const option = { ...defaultInstallOpt, ...incoming };
const formOpt = incoming as FormInstallOptions;
app.use(designPlugin, opt || {});
app.use(formPlugin, opt || {});
app.use(designPlugin, incoming);
app.use(formPlugin, {
...formOpt,
fields: mergeFieldOptions(editorFields, editorFieldVue, formOpt.fields),
});
app.use(tablePlugin);
registerTypeMatchRules(editorTypeMatchRules);
app.config.globalProperties.$TMAGIC_EDITOR = option;
setEditorConfig(option);
app.component(`${Editor.name || 'MEditor'}`, Editor);
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);
},
};

View File

@ -56,6 +56,7 @@ import type {
StepValue,
StoreState,
StoreStateKey,
UpdateOptions,
} from '@editor/type';
import { canUsePluginMethods, LayerOffset, Layout } from '@editor/type';
import {
@ -83,6 +84,10 @@ import { beforePaste, getAddParent } from '@editor/utils/operator';
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} 使
* `changeRecords` form propPath/value / propPath
@ -126,6 +131,11 @@ class Editor extends BaseService {
* *AndGetHistoryId null
*/
private lastPushedHistoryId: string | null = null;
/**
* doAdd nodeId selectedId
* add / doNotSelect /
*/
private lastAdded: { selectedId: Id; nodeId: Id } | null = null;
constructor() {
super(
@ -404,7 +414,7 @@ class Editor extends BaseService {
this.set('highlightNode', null);
}
public async doAdd(node: MNode, parent: MContainer): Promise<MNode> {
public async doAdd(node: MNode, parent: MContainer, _options: DslOpOptions = {}): Promise<MNode> {
const root = this.get('root');
if (!root) throw new Error('root为空');
@ -418,13 +428,16 @@ class Editor extends BaseService {
throw new Error('app下不能添加组件');
}
if (parent.id !== curNode.id && !isPageOrFragment(node)) {
const index = parent.items.indexOf(curNode);
parent.items?.splice(index + 1, 0, node);
} else {
// 新增节点添加到配置中
parent.items?.push(node);
}
// 连续 add 时接在上一个新增节点之后,否则接在当前选中节点之后;
// 锚点节点可能已不在 items 中(被删 / DSL 整体替换),回退到当前选中节点,都不在则追加到末尾
const anchorId = this.lastAdded?.selectedId === curNode.id ? this.lastAdded.nodeId : curNode.id;
const anchorIndex = isPageOrFragment(node)
? -1
: Math.max(getNodeIndex(anchorId, parent), getNodeIndex(curNode.id, parent));
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);
node.style = getInitPositionStyle(node.style, layout);
@ -434,6 +447,7 @@ class Editor extends BaseService {
parent: cloneDeep(parent),
parentId: parent.id,
root: cloneDeep(root),
index: insertIndex,
});
const newStyle = fixNodePosition(node, parent, stage);
@ -452,24 +466,23 @@ class Editor extends BaseService {
*
* @param addConfig
* @param parent
* @param options
* @param options.doNotSelect false
* @param options.doNotSwitchPage false / true
* @param options.doNotPushHistory false
* @param options {@link DslOpOptions}
* @returns
*/
public async add(
addNode: AddMNode | MNode[],
parent?: MContainer | null,
{
options: DslOpOptions = {},
): Promise<MNode | MNode[]> {
this.captureSelectionBeforeOp();
const {
doNotSelect = false,
doNotSwitchPage = false,
doNotPushHistory = false,
historyDescription,
historySource,
}: DslOpOptions = {},
): Promise<MNode | MNode[]> {
this.captureSelectionBeforeOp();
} = options;
const stage = this.get('stage');
@ -485,17 +498,15 @@ class Editor extends BaseService {
addNodes.push(...addNode);
}
const newNodes = await Promise.all(
addNodes.map((node) => {
const root = this.get('root');
if (isPageOrFragment(node) && root) {
return this.doAdd(node, root);
}
const parentNode = parent ?? getAddParent(node);
if (!parentNode) throw new Error('未找到父元素');
return this.doAdd(node, parentNode);
}),
);
// 必须串行:每个节点的插入位置依赖上一个已插入的节点,并行时插入顺序会受
// doAdd 前置耗时(如异步 beforeDoAdd 插件钩子)影响而错乱
const newNodes: MNode[] = [];
for (const node of addNodes) {
const root = this.get('root');
const parentNode = isPageOrFragment(node) && root ? root : (parent ?? getAddParent(node));
if (!parentNode) throw new Error('未找到父元素');
newNodes.push(await this.doAdd(node, parentNode, options));
}
if (newNodes.length > 1) {
// 多选时只要任一新增节点位于非当前页面,触发的 multiSelect 就会引起页面切换
@ -654,16 +665,8 @@ class Editor extends BaseService {
* @param options.doNotSwitchPage false / true
* @param options.doNotPushHistory false
*/
public async remove(
nodeOrNodeList: MNode | MNode[],
{
doNotSelect = false,
doNotSwitchPage = false,
doNotPushHistory = false,
historyDescription,
historySource,
}: DslOpOptions = {},
): Promise<void> {
public async remove(nodeOrNodeList: MNode | MNode[], options: DslOpOptions = {}): Promise<void> {
const { doNotPushHistory = false, historyDescription, historySource } = options;
this.captureSelectionBeforeOp();
const nodes = Array.isArray(nodeOrNodeList) ? nodeOrNodeList : [nodeOrNodeList];
@ -690,7 +693,7 @@ class Editor extends BaseService {
}
}
await Promise.all(nodes.map((node) => this.doRemove(node, { doNotSelect, doNotSwitchPage })));
await Promise.all(nodes.map((node) => this.doRemove(node, options)));
// 删除节点时同步清理其(含子树)的校验错误记录;置于 pushOpHistory 之前,使历史快照与本次删除对齐。
this.removeInvalidNodesBySubtree(nodes);
@ -720,12 +723,9 @@ class Editor extends BaseService {
public async doUpdate(
config: MNode,
{
changeRecords = [],
historySource,
replace = false,
}: { changeRecords?: ChangeRecord[]; historySource?: HistoryOpSource; replace?: boolean } = {},
data: UpdateOptions = {},
): Promise<{ newNode: MNode; oldNode: MNode; changeRecords?: ChangeRecord[] }> {
const { changeRecords = [], historySource, replace = false } = data;
const root = this.get('root');
if (!root) throw new Error('root为空');
@ -804,34 +804,10 @@ class Editor extends BaseService {
*
* update后会触发依赖收集stage.update方法
* @param config id信息
* @param data
* @param data.changeRecords form 使 changeRecordList
* @param data.changeRecordList form config changeRecords
* @param data.doNotPushHistory false
* @param data.historyDescription undo/redo
* @param data.replace true mergeWith / toggleFixedPosition / setChildrenLayout false
* @param data {@link UpdateOptions}
* @returns
*/
public async update(
config: MNode | MNode[],
data: {
changeRecords?: ChangeRecord[];
changeRecordList?: ChangeRecord[][];
doNotPushHistory?: boolean;
historyDescription?: string;
historySource?: HistoryOpSource;
/**
* true
* DSL false merge
*/
replace?: boolean;
/**
*
* 使 undo/redo
*/
invalidInfo?: { id: Id; source: NodeInvalidSource; error?: string };
} = {},
): Promise<MNode | MNode[]> {
public async update(config: MNode | MNode[], data: UpdateOptions = {}): Promise<MNode | MNode[]> {
this.captureSelectionBeforeOp();
const {
@ -840,7 +816,6 @@ class Editor extends BaseService {
changeRecords,
historyDescription,
historySource,
replace = false,
invalidInfo,
} = data;
@ -851,7 +826,7 @@ class Editor extends BaseService {
const updateData = await Promise.all(
nodes.map((node, index) => {
const recordsForNode = changeRecordList ? (changeRecordList[index] ?? []) : (changeRecords ?? []);
return this.doUpdate(node, { changeRecords: recordsForNode, historySource, replace });
return this.doUpdate(node, { ...data, changeRecords: recordsForNode });
}),
);
@ -1360,14 +1335,7 @@ class Editor extends BaseService {
/** 等价于 {@link update},并额外返回本次写入历史记录的 uuid 列表(未入栈时 historyIds 为 `[]`)。 */
public async updateAndGetHistoryId(
config: MNode | MNode[],
data: {
changeRecords?: ChangeRecord[];
changeRecordList?: ChangeRecord[][];
doNotPushHistory?: boolean;
historyDescription?: string;
historySource?: HistoryOpSource;
replace?: boolean;
} = {},
data: UpdateOptions = {},
): Promise<DslOpWithHistoryIdsResult<MNode | MNode[]>> {
this.lastPushedHistoryId = null;
const result = await this.update(config, data);
@ -1987,17 +1955,15 @@ class Editor extends BaseService {
const parent = this.getNodeById(parentId, false) as MContainer | null;
if (parent?.items) {
const addedNode = cloneDeep(newSchema);
if (typeof index === 'number' && index >= 0 && index < parent.items.length) {
parent.items.splice(index, 0, addedNode);
} else {
parent.items.push(addedNode);
}
const insertIndex = clampIndex(index, parent.items.length);
parent.items.splice(insertIndex, 0, addedNode);
addedNodes.push(addedNode);
await stage?.add({
config: cloneDeep(newSchema),
parent: cloneDeep(parent),
parentId: parent.id,
root: cloneDeep(root),
index: insertIndex,
});
}
}
@ -2016,13 +1982,15 @@ class Editor extends BaseService {
const parent = this.getNodeById(parentId, false) as MContainer | null;
if (parent?.items) {
const addedNode = cloneDeep(oldSchema);
parent.items.splice(index ?? parent.items.length, 0, addedNode);
const insertIndex = clampIndex(index, parent.items.length);
parent.items.splice(insertIndex, 0, addedNode);
addedNodes.push(addedNode);
await stage?.add({
config: cloneDeep(oldSchema),
parent: cloneDeep(parent),
parentId,
root: cloneDeep(root),
index: insertIndex,
});
}
}

View File

@ -35,8 +35,8 @@ const canUsePluginMethods = {
type AsyncMethodName = Writable<(typeof canUsePluginMethods)['async']>;
type SyncMethodName = Writable<(typeof canUsePluginMethods)['sync']>;
let eventMap: Record<string, EventOption[]> = reactive({});
let methodMap: Record<string, EventOption[]> = reactive({});
const eventMap: Record<string, EventOption[]> = reactive({});
const methodMap: Record<string, EventOption[]> = reactive({});
class Events extends BaseService {
constructor() {
@ -75,8 +75,9 @@ class Events extends BaseService {
}
public resetState() {
eventMap = reactive({});
methodMap = reactive({});
// 原地清空而不是重新赋值,保留 reactive 代理身份,已建立的 computed / 渲染副作用才能收到更新
Object.keys(eventMap).forEach((type) => delete eventMap[type]);
Object.keys(methodMap).forEach((type) => delete methodMap[type]);
}
public destroy() {

View File

@ -1,40 +1,3 @@
.m-fields-code-select {
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;
}
}
}

View File

@ -1,3 +1,7 @@
.m-fields-display-conds {
width: 100%;
}
.m-container-text.display-conds-title {
.tmagic-design-form-item {
.el-form-item__label {

View File

@ -1,49 +1,45 @@
.m-fields-event-select {
width: 100%;
.fullWidth {
width: 100%;
box-sizing: border-box;
&.create-button {
width: 100%;
}
&.m-container-ui-event {
width: calc(100% - 32px);
}
}
.event-select-container {
padding: 0 16px;
> .el-card.tmagic-design-card--flat {
> .el-card__header {
> .m-fields-group-list
> .m-fields-group-list-item.tmagic-design-card--flat {
> .el-card__header,
> .t-card__header {
border-bottom: 1px solid #ebeef5;
}
> .el-card__body {
> .el-card__body,
> .t-card__body {
padding-bottom: 16px;
.m-fields-group-list-footer {
div {
justify-content: flex-start !important;
}
}
.el-card.tmagic-design-card--flat {
> .el-card__header {
.el-card.tmagic-design-card--flat,
.t-card.tmagic-design-card--flat {
> .el-card__header,
> .t-card__header {
padding: 16px 0;
}
}
}
}
}
.event-select-code {
margin-left: 20px;
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-data-source-method-select {
width: 100%;
@ -63,10 +59,6 @@
}
}
.event-select-container {
padding: 0 16px;
}
.event-select-header {
display: flex;
align-items: center;
@ -79,28 +71,13 @@
line-height: 24px;
}
}
.event-item-header {
position: relative;
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
.event-item-title {
color: #0f1113;
font-size: 16px;
font-weight: 500;
line-height: 24px;
margin-bottom: 8px;
}
.event-item-delete-button {
color: #0f1113;
}
.el-form-item {
.el-form-item {
&.is-error {
margin-bottom: 18px;
}
}
.event-item-header {
width: 100%;
.tmagic-design-form-item {
margin-bottom: 0;
}
.el-form-item .el-form-item.is-error {
margin-bottom: 18px;
}
}

View File

@ -125,14 +125,28 @@
.m-editor-props-form-panel-form {
padding-right: 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 {
> .tmagic-design-tabs {
> .el-tabs__content {
margin-top: var(--el-tabs-header-height);
> .el-tabs__content,
> .t-tabs__content {
margin-top: var(--el-tabs-header-height, var(--td-comp-size-xxl, 48px));
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;
top: 0;
width: 100%;

View File

@ -137,7 +137,8 @@
.m-editor-props-form-panel-form {
> .m-container-tab {
> .tmagic-design-tabs {
> .el-tabs__content {
> .el-tabs__content,
> .t-tabs__content {
background-color: #fafafa;
border-radius: 8px;
padding-left: 16px;
@ -146,6 +147,16 @@
}
}
}
.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 {

View File

@ -34,7 +34,7 @@ import type {
MPageFragment,
} from '@tmagic/core';
import type { FieldSize } from '@tmagic/design';
import type { ChangeRecord, FormConfig, FormState, TableColumnConfig, TypeFunction } from '@tmagic/form';
import type { ChangeRecord, FormConfig, TableColumnConfig, TypeFunction } from '@tmagic/form';
import type StageCore from '@tmagic/stage';
import type {
CanDropIn,
@ -172,6 +172,15 @@ export interface Services {
}
// #endregion Services
declare module '@tmagic/form-schema' {
interface FormContext {
/** 编辑器服务集合(由 Editor / FormPanel provide */
services?: Services;
/** 当前画布 Stage 实例(由 Editor / FormPanel provide */
stage?: any;
}
}
export interface StageOptions {
runtimeUrl?: string;
autoScrollIntoView?: boolean;
@ -216,6 +225,7 @@ export interface StageOptions {
beforeDblclick?: (event: MouseEvent) => Promise<boolean | void> | boolean | void;
}
// #region NodeInvalidInfo
/**
* /
* FormPanel
@ -228,9 +238,12 @@ export interface NodeInvalidInfo {
/** 样式表单校验错误文案(可能为包含 <br> 的 HTML */
style?: string;
}
// #endregion NodeInvalidInfo
// #region NodeInvalidSource
/** 节点校验错误来源 */
export type NodeInvalidSource = keyof NodeInvalidInfo;
// #endregion NodeInvalidSource
export interface StoreState {
root: MApp | null;
@ -303,6 +316,7 @@ export interface StageRect {
height: number | string;
}
// #region UiState
export interface UiState {
/** 当前点击画布是否触发选中true: 不触发false: 触发默认为false */
uiSelectMode: boolean;
@ -360,6 +374,7 @@ export interface UiState {
height: number;
};
}
// #endregion UiState
// #region EditorNodeInfo
export interface EditorNodeInfo {
@ -598,18 +613,6 @@ export interface CompareFormBaseProps {
* dialog / 使 CSS `60vh` / `400px` / `100%`
*/
height?: string;
/**
* MForm.formState Editor `extendFormState`
* PropsPanel `extend-state` item `display` / `disabled`
* filterFunction stage
* / `formState.xxx is undefined`
*/
extendState?: (_state: FormState) => Record<string, any> | Promise<Record<string, any>>;
/**
* formState PropsPanel
* formState filterFunction
*/
baseFormState?: FormState;
/**
* MForm `size` 'large' | 'default' | 'small'
* 使 MForm
@ -1464,6 +1467,29 @@ export interface DslOpOptions extends HistoryOpOptions {
}
// #endregion DslOpOptions
// #region UpdateOptions
/**
* {@link EditorService.update} / {@link EditorService.doUpdate}
* - changeRecords: 单节点 form 使 changeRecordList
* - changeRecordList: 多节点 form config changeRecords
* - replace: true mergeWith / toggleFixedPosition / setChildrenLayout
* - invalidInfo: 属性面板提交时携带的校验错误信息
*/
export interface UpdateOptions extends HistoryOpOptionsWithChangeRecords {
changeRecordList?: ChangeRecord[][];
/**
* true
* DSL false merge
*/
replace?: boolean;
/**
*
* 使 undo/redo
*/
invalidInfo?: { id: Id; source: NodeInvalidSource; error?: string };
}
// #endregion UpdateOptions
/** 差异对话框的入参 */
export interface DiffDialogPayload {
/** 表单类别 */
@ -1535,17 +1561,6 @@ export interface UseHistoryRevertOptions {
* `getCurrentInstance()?.appContext` setup `editorApp._context`
*/
appContext?: AppContext | null;
/**
* `extendState` Editor `extendFormState`
* 使 `display` / `disabled` filterFunction
*/
extendState?: (_state: FormState) => Record<string, any> | Promise<Record<string, any>>;
/**
* PropsPanel FormPanel -> MForm formState
* / 使 formState CompareForm
* filterFunction
*/
getPropsPanelFormState?: () => FormState | undefined;
/**
* / / / TMagicDialog `width`
* `'1200px'` / `'80%'`使900px
@ -1567,11 +1582,6 @@ export interface CustomDiffFormOptions {
loadConfig?: CompareFormLoadConfig;
/** 需要走 self diff 的字段类型(如模块的 mod-cond。 */
selfDiffFieldTypes?: string[];
/**
* formState PropsPanel
* CompareForm
*/
compareFormState?: FormState;
/**
* / HistoryDiffDialog TMagicDialog `width`
* `'1200px'` / `'80%'`使900px

View File

@ -103,6 +103,7 @@ export const getCodeBlockFormConfig = (options: GetCodeBlockFormConfigOptions =
const list = [
{ text: '初始化前', value: 'beforeInit' },
{ text: '初始化后', value: 'afterInit' },
{ text: '页面渲染后', value: 'mounted' },
];
if (dataSourceType?.() !== 'base') {
list.push({ text: '请求前', value: 'beforeRequest' });

View File

@ -37,6 +37,7 @@ const dataSourceFormConfig: TabConfig = {
name: 'events',
src: 'datasource',
type: 'event-select',
defaultValue: [],
},
],
},

View File

@ -16,7 +16,7 @@
* limitations under the License.
*/
import type { EventOption, Id, MComponent, MContainer } from '@tmagic/core';
import type { EventOption, Id, MComponent, MContainer, MNode } from '@tmagic/core';
import type { CascaderOption } from '@tmagic/form';
import { DATA_SOURCE_FIELDS_CHANGE_EVENT_PREFIX, traverseNode } from '@tmagic/utils';
@ -56,7 +56,7 @@ export const getEventNameOptions = (
}
if (src === 'component') {
const sourceNode = editorService.getNodeById(formValue.id);
const sourceNode = editorService.getNodeById(formValue.id) || (formValue as MNode);
let events: EventOption[] | CascaderOption[] = eventsService.getEvent(formValue.type, { node: sourceNode }) || [];
if (formValue.type === 'page-fragment-container' && formValue.pageFragmentId) {

View File

@ -73,7 +73,6 @@ export const getCondOpOptionsByFieldType = (type: string) => {
export const styleTabConfig: TabPaneConfig = {
title: '样式',
lazy: true,
display: ({ services }: any) => !(services?.uiService?.get('showStylePanel') ?? true),
items: [
{
@ -146,13 +145,13 @@ export const styleTabConfig: TabPaneConfig = {
export const eventTabConfig: TabPaneConfig = {
title: '事件',
lazy: true,
items: [
{
name: 'events',
src: 'component',
labelWidth: '100px',
type: 'event-select',
defaultValue: [],
rules: [{ typeMatch: true }],
},
],
@ -160,13 +159,11 @@ export const eventTabConfig: TabPaneConfig = {
export const advancedTabConfig: TabPaneConfig = {
title: '高级',
lazy: true,
items: [
{
name: NODE_DISABLE_CODE_BLOCK_KEY,
text: '禁用代码块',
type: 'switch',
labelPosition: 'left',
defaultValue: false,
extra: '开启后,配置的代码块将不会被执行',
},
@ -174,7 +171,6 @@ export const advancedTabConfig: TabPaneConfig = {
name: NODE_DISABLE_DATA_SOURCE_KEY,
text: '禁用数据源',
type: 'switch',
labelPosition: 'left',
defaultValue: false,
extra: '开启后,组件内配置的数据源相关配置将不会被编译,显隐条件将失效',
},
@ -260,8 +256,6 @@ export const displayTabConfig: TabPaneConfig<DisplayCondsConfig> = {
type: 'display-conds',
name: NODE_CONDS_KEY,
titlePrefix: '条件组',
fixed: 'right',
operateColWidth: 112,
defaultValue: [],
rules: [{ typeMatch: true }],
},
@ -360,32 +354,3 @@ export const fillConfig = (
return [tabConfig];
};
/**
* tab-pane `display` `true`
*
* `propsService.getPropsConfig` tab
* `display: ({ services }) => !(services?.uiService?.get('showStylePanel') ?? true)`
* / CompareForm / ViewForm uiService
* tab
*
* @param formConfig
* @returns
*/
export const removeStyleDisplayConfig = (formConfig: FormConfig): FormConfig =>
formConfig.map((item) => {
if (!('type' in item)) return item;
if (item?.type !== 'tab' || !Array.isArray(item.items)) return item;
return {
...item,
items: item.items.map((tabPane) => {
if (tabPane?.title !== '样式' || !Array.isArray(tabPane.items)) return tabPane;
return {
...tabPane,
display: true,
};
}),
};
});

View File

@ -18,9 +18,14 @@
import type { DataSourceFieldType, DataSourceSchema, Id } from '@tmagic/core';
import { NodeType } from '@tmagic/core';
import { appendValidateSuggestion } from '@tmagic/design';
import type { TypeMatchValidateContext, TypeMatchValidator } from '@tmagic/form';
import { MAX_SUGGESTION_OPTIONS, optionSuggestion, stringifyExampleValue, validateTypeMatch } from '@tmagic/form';
import { appendValidateSuggestion } from '@tmagic/design/headless';
import type { TypeMatchValidateContext, TypeMatchValidator } from '@tmagic/form/headless';
import {
MAX_SUGGESTION_OPTIONS,
optionSuggestion,
stringifyExampleValue,
validateTypeMatch,
} from '@tmagic/form/headless';
import {
DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX,
DATA_SOURCE_SET_DATA_METHOD_NAME,
@ -209,7 +214,6 @@ const validateDataSourceFieldPath = (
path: string[],
options: {
dataSourceId?: string;
valueMode?: 'key' | 'value';
dataSourceFieldType?: DataSourceFieldType[];
message?: string;
} = {},
@ -219,19 +223,11 @@ const validateDataSourceFieldPath = (
return undefined;
}
let dsId = options.dataSourceId;
let fieldNames = path;
const dsId = options.dataSourceId;
if (!dsId) {
if (!path.length) {
return defaultMessage(options.message, '值不在可选项中', dataSourceIdSuggestion());
}
const rawDsId = path[0];
dsId =
options.valueMode === 'key' || !`${rawDsId}`.startsWith(DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX)
? `${rawDsId}`
: removeDataSourceFieldPrefix(`${rawDsId}`);
fieldNames = path.slice(1);
// 仅当取值为空数组或首项为空串时命中。表单校验管道里 validateTypeMatch 对空数组会短路,
// 因此这里只可能来自直接调用 validateDataSourceFieldSelect 的自定义 validator
return defaultMessage(options.message, '数据源不存在', dataSourceIdSuggestion());
}
const ds = findDataSource(`${dsId}`);
@ -239,11 +235,11 @@ const validateDataSourceFieldPath = (
return defaultMessage(options.message, `数据源(${dsId})不存在`, dataSourceIdSuggestion());
}
if (!fieldNames.length) {
if (!path.length) {
return undefined;
}
const { field, ok, fields, failedName } = resolveFieldByPath(ds.fields, fieldNames);
const { field, ok, fields, failedName } = resolveFieldByPath(ds.fields, path);
if (!ok) {
return defaultMessage(
options.message,
@ -258,11 +254,15 @@ const validateDataSourceFieldPath = (
}
const leafType = field?.type || 'any';
if (leafType === 'any' || allowedTypes.includes(leafType)) {
return undefined;
}
if (leafType !== 'any' && !allowedTypes.includes(leafType)) {
const fieldName = field?.name || path[path.length - 1];
return defaultMessage(options.message, '值不在可选项中', dataSourceIdSuggestion());
// 文案已点明当前字段类型与要求类型,无需再列同级可选字段
return defaultMessage(
options.message,
`请选择类型为${allowedTypes.join('或')}的字段,字段(${fieldName})的类型为${leafType}`,
);
}
};
const validateDataSourceMethodTuple = (value: any, message?: string): string | undefined => {
@ -367,7 +367,7 @@ const validateCondOpSelect: TypeMatchValidator = (value, { message, props }) =>
const parentFields = props.config?.parentFields || [];
const fieldPath = Array.isArray(props.model?.field) ? props.model.field : [];
const [id, ...fieldNames] = [...parentFields, ...fieldPath];
const ds = id ? findDataSource(`${id}`) : undefined;
const ds = id ? findDataSource(removeDataSourceFieldPrefix(`${id}`)) : undefined;
const fieldType = getFieldType(ds, fieldNames);
const allowed = getCondOpsByFieldType(fieldType);
@ -464,7 +464,7 @@ export type ValidateDataSourceFieldSelectOptions = {
/**
* data-source-field-select typeMatch rules.validator
*/
export const validateDataSourceFieldSelectValue = (
export const validateDataSourceFieldSelect = (
value: any,
context: TypeMatchValidateContext,
options?: ValidateDataSourceFieldSelectOptions,
@ -492,17 +492,22 @@ export const validateDataSourceFieldSelectValue = (
return defaultMessage(message, `${value}类型应为字符串数组`, dataSourceFieldPathSuggestion());
}
return validateDataSourceFieldPath(value, {
dataSourceId: config.dataSourceId,
valueMode: config.value,
// 未指定 dataSourceId 时,路径首项为数据源 id其余为字段名。
// value 模式的首项带 ds-field:: 前缀、key 模式不带removeDataSourceFieldPrefix 对后者是幂等的,无需分支。
let dataSourceId = config.dataSourceId ? `${config.dataSourceId}` : undefined;
let fieldNames = value;
if (!dataSourceId) {
dataSourceId = value.length ? removeDataSourceFieldPrefix(value[0]) : undefined;
fieldNames = value.slice(1);
}
return validateDataSourceFieldPath(fieldNames, {
dataSourceId,
dataSourceFieldType: config.dataSourceFieldType,
message,
});
};
const validateDataSourceFieldSelect: TypeMatchValidator = (value, context) =>
validateDataSourceFieldSelectValue(value, context);
const validateDataSourceSelect: TypeMatchValidator = (value, { message, props }) => {
const config = props.config || {};
const dataSources = getDataSources();
@ -678,7 +683,8 @@ export const editorTypeMatchRules: Record<string, TypeMatchValidator> = {
'ui-select': validateUiSelect,
'data-source-input': validateDataSourceInput,
'data-source-method-select': validateDataSourceMethodSelect,
'data-source-field-select': validateDataSourceFieldSelect,
/** 注册进 typeMatch 规则表的入口,收窄为 TypeMatchValidator避免调用方误传第三个参数 */
'data-source-field-select': (value, context) => validateDataSourceFieldSelect(value, context),
'data-source-select': validateDataSourceSelect,
'code-select': validateCodeSelect,
'data-source-fields': validateDataSourceFields,

View File

@ -4,11 +4,15 @@
* Copyright (C) 2025 Tencent.
*/
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { defineComponent, h, nextTick } from 'vue';
import { computed, type ComputedRef, defineComponent, h, inject, nextTick, provide, ref } from 'vue';
import { mount } from '@vue/test-utils';
import { FORM_CONTEXT_KEY, type FormContext } from '@tmagic/form';
import Editor from '@editor/Editor.vue';
let injectedFormContext: ComputedRef<FormContext> | undefined;
const { initServiceEventsMock, initServiceStateMock } = vi.hoisted(() => ({
initServiceEventsMock: vi.fn(),
initServiceStateMock: vi.fn(),
@ -23,7 +27,21 @@ vi.mock('@editor/services/codeBlock', () => ({ default: {} }));
vi.mock('@editor/services/componentList', () => ({ default: {} }));
vi.mock('@editor/services/dataSource', () => ({ default: {} }));
vi.mock('@editor/services/dep', () => ({ default: {} }));
vi.mock('@editor/services/editor', () => ({ default: {} }));
const { stageStub } = vi.hoisted(() => ({ stageStub: { name: 'stage-instance' } }));
// 用响应式 ref 承载 stage模拟真实 editorService 的 reactive state
// 这样 formContext 的 computed 才会在 stage 变化时失效重算
vi.mock('@editor/services/editor', async () => {
const { shallowRef } = await import('vue');
const stage = shallowRef<any>(stageStub);
return {
default: {
get: vi.fn((key: string) => (key === 'stage' ? stage.value : undefined)),
__setStage: (value: any) => {
stage.value = value;
},
},
};
});
vi.mock('@editor/services/events', () => ({ default: {} }));
vi.mock('@editor/services/history', () => ({ default: {} }));
vi.mock('@editor/services/keybinding', () => ({
@ -88,6 +106,7 @@ vi.mock('@editor/layouts/props-panel/PropsPanel.vue', () => ({
name: 'PropsPanel',
emits: ['mounted', 'unmounted', 'submit-error', 'form-error'],
setup(_p, { emit }) {
injectedFormContext = inject(FORM_CONTEXT_KEY, undefined);
return () =>
h('div', { class: 'fake-props-panel' }, [
h('button', { class: 'mounted-btn', onClick: () => emit('mounted', { proxy: true }) }),
@ -105,6 +124,7 @@ vi.mock('@editor/layouts/props-panel/FormPanel.vue', () => ({
beforeEach(() => {
vi.clearAllMocks();
injectedFormContext = undefined;
});
describe('Editor', () => {
@ -153,6 +173,79 @@ describe('Editor', () => {
expect(wrapper.emitted('layer-node-dblclick')).toBeTruthy();
});
test('provide FORM_CONTEXT_KEY子孙可拿到 services 与当前 stage', async () => {
mount(Editor, { props: {} as any });
await nextTick();
expect(injectedFormContext).toBeDefined();
const context = injectedFormContext!.value as any;
expect(context.services.editorService).toBeDefined();
expect(context.services.propsService).toBeDefined();
expect(context.stage).toBe(stageStub);
});
test('合并宿主在外层 provide 的 FORM_CONTEXT_KEY不遮蔽其业务字段', async () => {
const hostComponent = defineComponent({
setup: () => {
provide(
FORM_CONTEXT_KEY,
computed(() => ({ username: 'tester', editor: { editorCore: {} } }) as unknown as FormContext),
);
return () => h(Editor, {} as any);
},
});
mount(hostComponent);
await nextTick();
const context = injectedFormContext!.value as any;
expect(context.username).toBe('tester');
expect(context.editor.editorCore).toBeDefined();
// 编辑器自己的字段优先级更高,仍然兜底
expect(context.services.editorService).toBeDefined();
expect(context.stage).toBe(stageStub);
});
test('宿主上下文的 accessor 保持读时求值', async () => {
const counter = ref(0);
const hostComponent = defineComponent({
setup: () => {
provide(
FORM_CONTEXT_KEY,
computed(
() =>
({
get buildVersion() {
return counter.value;
},
}) as unknown as FormContext,
),
);
return () => h(Editor, {} as any);
},
});
mount(hostComponent);
await nextTick();
expect((injectedFormContext!.value as any).buildVersion).toBe(0);
counter.value = 7;
expect((injectedFormContext!.value as any).buildVersion).toBe(7);
});
test('stage 走 computed 读时求值,切换画布后能读到新实例', async () => {
const editorServiceMod = (await import('@editor/services/editor')) as any;
mount(Editor, { props: {} as any });
await nextTick();
expect((injectedFormContext!.value as any).stage).toBe(stageStub);
const nextStage = { name: 'next-stage' };
editorServiceMod.default.__setStage(nextStage);
expect((injectedFormContext!.value as any).stage).toBe(nextStage);
editorServiceMod.default.__setStage(stageStub);
});
test('expose services', () => {
const wrapper = mount(Editor, { props: {} as any });
expect((wrapper.vm as any).editorService).toBeDefined();

View File

@ -222,7 +222,13 @@ describe('CodeBlockEditor', () => {
});
const timingItem = capturedConfig.find((c: any) => c.name === 'timing');
const opts = timingItem.options();
expect(opts.length).toBe(4);
expect(opts.map((opt: any) => opt.value)).toEqual([
'beforeInit',
'afterInit',
'mounted',
'beforeRequest',
'afterRequest',
]);
});
test('timing options - base 类型', () => {
@ -231,7 +237,7 @@ describe('CodeBlockEditor', () => {
});
const timingItem = capturedConfig.find((c: any) => c.name === 'timing');
const opts = timingItem.options();
expect(opts.length).toBe(2);
expect(opts.map((opt: any) => opt.value)).toEqual(['beforeInit', 'afterInit', 'mounted']);
});
test('changeHandler 触发 changedValue', async () => {

View File

@ -15,6 +15,7 @@ let lastConfig: any;
let lastProps: any;
vi.mock('@tmagic/form', () => ({
defineFormItem: (cfg: any) => cfg,
MForm: defineComponent({
name: 'MFormStub',
props: ['config', 'initValues', 'disabled', 'size', 'watchProps', 'lastValues', 'isCompare'],

View File

@ -4,10 +4,11 @@
* Copyright (C) 2025 Tencent.
*/
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { defineComponent, h, nextTick, ref } from 'vue';
import { mount } from '@vue/test-utils';
import { computed, defineComponent, h, nextTick, ref } from 'vue';
import { mount, type VueWrapper } from '@vue/test-utils';
import { HookType } from '@tmagic/core';
import { FORM_CONTEXT_KEY } from '@tmagic/form';
import CompareForm from '@editor/components/CompareForm.vue';
@ -44,10 +45,11 @@ vi.mock('@editor/utils/code-block', () => ({
getCodeBlockFormConfig: vi.fn(() => [{ type: 'text', name: 'content' }]),
}));
vi.mock('@tmagic/form', () => ({
vi.mock('@tmagic/form', async (importOriginal) => ({
...(await importOriginal<typeof import('@tmagic/form')>()),
MForm: defineComponent({
name: 'MForm',
props: ['config', 'initValues', 'lastValues', 'isCompare', 'disabled', 'labelWidth', 'extendState', 'showDiff'],
props: ['config', 'initValues', 'lastValues', 'isCompare', 'disabled', 'labelWidth', 'context', 'showDiff'],
setup(props, { expose }) {
capturedShowDiff = props.showDiff as (args: any) => boolean;
capturedFormProps = props as Record<string, any>;
@ -63,6 +65,12 @@ beforeEach(() => {
capturedFormProps = {};
});
const waitForFormReady = async (wrapper: VueWrapper) => {
await vi.waitFor(() => {
expect(wrapper.find('.fake-mform').exists()).toBe(true);
});
};
describe('CompareForm.vue', () => {
test('node 类别按 type 加载 props 配置并展示 MForm', async () => {
const wrapper = mount(CompareForm, {
@ -79,13 +87,36 @@ describe('CompareForm.vue', () => {
},
},
});
await nextTick();
await nextTick();
await waitForFormReady(wrapper);
expect(propsService.getPropsConfig).toHaveBeenCalledWith('text', { node: { id: 'n1', name: 'new' } });
expect(wrapper.find('.fake-mform').exists()).toBe(true);
expect(capturedFormProps.initValues).toEqual({ id: 'n1', name: 'new' });
expect(capturedFormProps.lastValues).toEqual({ id: 'n1', name: 'old' });
expect(capturedFormProps.context?.services).toEqual(services);
expect(capturedFormProps.context).toHaveProperty('stage');
});
test('宿主 provide 的上下文不被遮蔽,且 services 由编辑器兜底', async () => {
const wrapper = mount(CompareForm, {
props: {
category: 'node',
type: 'text',
value: { id: 'n1' },
lastValue: { id: 'n1' },
services,
},
global: {
provide: {
codeOptions: {},
[FORM_CONTEXT_KEY as symbol]: computed(() => ({ username: 'alice', services: 'host-services' })),
},
},
});
await waitForFormReady(wrapper);
expect(capturedFormProps.context.username).toBe('alice');
expect(capturedFormProps.context.services).toEqual(services);
});
test('node 类别缺少 type 时不渲染 MForm', async () => {
@ -167,7 +198,7 @@ describe('CompareForm.vue', () => {
});
test('showDiff 对 code-select 的空形态视为相等', async () => {
mount(CompareForm, {
const wrapper = mount(CompareForm, {
props: {
category: 'node',
type: 'text',
@ -175,8 +206,7 @@ describe('CompareForm.vue', () => {
services,
},
});
await nextTick();
await nextTick();
await waitForFormReady(wrapper);
expect(capturedShowDiff).toBeTypeOf('function');
expect(
capturedShowDiff!({

View File

@ -5,7 +5,7 @@
*/
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { defineComponent, h, nextTick } from 'vue';
import { mount } from '@vue/test-utils';
import { mount, type VueWrapper } from '@vue/test-utils';
import ViewForm from '@editor/components/ViewForm.vue';
@ -35,10 +35,11 @@ vi.mock('@editor/utils/code-block', () => ({
getCodeBlockFormConfig: vi.fn(() => [{ type: 'text', name: 'content' }]),
}));
vi.mock('@tmagic/form', () => ({
vi.mock('@tmagic/form', async (importOriginal) => ({
...(await importOriginal<typeof import('@tmagic/form')>()),
MForm: defineComponent({
name: 'MForm',
props: ['config', 'initValues', 'disabled', 'labelWidth', 'extendState', 'size'],
props: ['config', 'initValues', 'disabled', 'labelWidth', 'context', 'size'],
setup(props, { expose }) {
capturedFormProps = props as Record<string, any>;
expose({ formState: {} });
@ -52,6 +53,12 @@ beforeEach(() => {
capturedFormProps = {};
});
const waitForFormReady = async (wrapper: VueWrapper) => {
await vi.waitFor(() => {
expect(wrapper.find('.fake-mform').exists()).toBe(true);
});
};
describe('ViewForm.vue', () => {
test('node 类别按 type 加载配置并渲染 MForm', async () => {
const wrapper = mount(ViewForm, {
@ -62,15 +69,16 @@ describe('ViewForm.vue', () => {
services,
},
});
await nextTick();
await nextTick();
await waitForFormReady(wrapper);
expect(propsService.getPropsConfig).toHaveBeenCalledWith('text', { node: { id: 'n1', name: 'a' } });
expect(wrapper.find('.fake-mform').exists()).toBe(true);
expect(capturedFormProps.initValues).toEqual({ id: 'n1', name: 'a' });
expect(capturedFormProps.context?.services).toEqual(services);
expect(capturedFormProps.context).toHaveProperty('stage');
});
test('默认 disabled 为 true', async () => {
mount(ViewForm, {
const wrapper = mount(ViewForm, {
props: {
category: 'node',
type: 'text',
@ -78,13 +86,12 @@ describe('ViewForm.vue', () => {
services,
},
});
await nextTick();
await nextTick();
await waitForFormReady(wrapper);
expect(capturedFormProps.disabled).toBe(true);
});
test('可通过 disabled=false 覆盖为可编辑', async () => {
mount(ViewForm, {
const wrapper = mount(ViewForm, {
props: {
category: 'node',
type: 'text',
@ -93,13 +100,12 @@ describe('ViewForm.vue', () => {
services,
},
});
await nextTick();
await nextTick();
await waitForFormReady(wrapper);
expect(capturedFormProps.disabled).toBe(false);
});
test('size 透传给 MForm', async () => {
mount(ViewForm, {
const wrapper = mount(ViewForm, {
props: {
category: 'node',
type: 'text',
@ -108,8 +114,7 @@ describe('ViewForm.vue', () => {
services,
},
});
await nextTick();
await nextTick();
await waitForFormReady(wrapper);
expect(capturedFormProps.size).toBe('small');
});

View File

@ -7,8 +7,6 @@ import { beforeEach, describe, expect, test, vi } from 'vitest';
import { defineComponent, h } from 'vue';
import { mount } from '@vue/test-utils';
import { FORM_SILENT_MODE_KEY } from '@tmagic/form';
import Code from '@editor/fields/Code.vue';
// 用一个简单的桩组件代替 MagicCodeEditor把所有 props 原样渲染到 data-* 属性上,
@ -171,28 +169,6 @@ describe('Code', () => {
});
});
describe('静默模式', () => {
test('注入 FORM_SILENT_MODE_KEY=true 时不渲染 CodeEditor', () => {
const wrapper = mountCode(
{},
{
provide: { [FORM_SILENT_MODE_KEY as symbol]: true },
},
);
expect(wrapper.find('.fake-code-editor').exists()).toBe(false);
});
test('注入 FORM_SILENT_MODE_KEY=false 时正常渲染 CodeEditor', () => {
const wrapper = mountCode(
{},
{
provide: { [FORM_SILENT_MODE_KEY as symbol]: false },
},
);
expect(wrapper.find('.fake-code-editor').exists()).toBe(true);
});
});
describe('diff 模式 (对比)', () => {
test('isCompare=true 且有 lastValues 时切换为 diff 模式', () => {
const wrapper = mountCode({

View File

@ -9,19 +9,22 @@ import { mount } from '@vue/test-utils';
import CodeSelect from '@editor/fields/CodeSelect.vue';
const dataSourceService = {
get: vi.fn(() => true),
getDataSourceById: vi.fn(() => ({ title: 'DS1' })),
};
const codeBlockService = {
getCodeContentById: vi.fn(() => ({ name: 'code-name' })),
getEditStatus: vi.fn(() => true),
};
vi.mock('@editor/hooks/use-services', () => ({
useServices: () => ({ dataSourceService, codeBlockService }),
// 表单配置由 fields/configs/codeSelect.ts 产出(组件与无渲染校验的嵌套配置共用),
// 那里直接用服务单例,因此这里 mock 服务模块本身
const { dataSourceService, codeBlockService } = vi.hoisted(() => ({
dataSourceService: {
get: vi.fn(() => true),
getDataSourceById: vi.fn(() => ({ title: 'DS1' })),
},
codeBlockService: {
getCodeContentById: vi.fn(() => ({ name: 'code-name' })),
getEditStatus: vi.fn(() => true),
},
}));
vi.mock('@editor/services/dataSource', () => ({ default: dataSourceService }));
vi.mock('@editor/services/codeBlock', () => ({ default: codeBlockService }));
vi.mock('@tmagic/form', async (importOriginal) => {
const actual = await importOriginal<any>();
return {
@ -31,7 +34,10 @@ vi.mock('@tmagic/form', async (importOriginal) => {
props: ['config', 'size', 'prop', 'disabled', 'lastValues', 'isCompare', 'model'],
emits: ['change'],
setup() {
return () => h('div', { class: 'fake-container' });
return () =>
h('div', { class: 'fake-container' }, [
h('div', { class: 'm-fields-group-list' }, [h('div', { class: 'group-item' })]),
]);
},
}),
};
@ -44,12 +50,6 @@ vi.mock('@tmagic/design', () => ({
return () => h('div', { class: 'fake-card' }, slots.default?.());
},
}),
TMagicButton: defineComponent({
name: 'TMagicButton',
setup(_p, { slots }) {
return () => h('button', { class: 'fake-button' }, slots.default?.());
},
}),
}));
const baseProps = (extra: any = {}) => ({
@ -108,16 +108,23 @@ describe('CodeSelect', () => {
expect(title).toBe('unknown');
});
test('空 model 时初始化为 { hookType, hookData }', () => {
const props = baseProps({ model: { cs: undefined } });
mount(CodeSelect, { props: props as any });
expect((props.model.cs as any).hookData).toEqual([]);
test('运行期被置空时补成 { hookType, hookData }', async () => {
const wrapper = mount(CodeSelect, { props: baseProps() as any });
const model: Record<string, any> = { cs: '' };
await wrapper.setProps({ model });
expect(model.cs).toEqual({ hookType: 'code', hookData: [] });
});
test('codeType items 配置正确', () => {
const wrapper = mount(CodeSelect, { props: baseProps() as any });
const container = wrapper.findComponent({ name: 'MContainer' });
const config = container.props('config') as any;
expect(config.scrollLastItemIntoView).toBe(true);
expect(config.header.sticky).toBe(true);
expect(config.addButtonConfig.sticky).toBe(true);
expect(config.addButtonConfig.text).toBe('添加');
const codeTypeSelect = config.items[0];
expect(codeTypeSelect.name).toBe('codeType');
const setModel = vi.fn();

View File

@ -100,4 +100,14 @@ describe('CondOpSelect', () => {
await wrapper.findComponent({ name: 'TMagicSelect' }).vm.$emit('change', '=');
expect(wrapper.emitted('change')?.[0]?.[0]).toBe('=');
});
test('字段路径带 ds-field:: 前缀时仍按数据源 id 查询', () => {
(getFieldType as any).mockReturnValue('number');
mount(CondOpSelect, {
props: baseProps({
model: { field: ['ds-field::ds1', 'a'], op: '' },
}) as any,
});
expect(dataSourceService.getDataSourceById).toHaveBeenCalledWith('ds1');
});
});

View File

@ -312,4 +312,17 @@ describe('DataSourceFieldSelect Index', () => {
await toggleBtn.trigger('click');
expect(wrapper.findAll('.fake-cascader').length).toBeGreaterThan(0);
});
test('checkStrictly 为函数时从当前字段值解析数据源', () => {
const checkStrictly = vi.fn(() => true);
mount(DSFSIndex, {
props: {
config: { checkStrictly },
model: { v: ['ds-ds1', 'a'] },
name: 'v',
} as any,
});
expect(checkStrictly).toHaveBeenCalled();
expect(checkStrictly.mock.calls[0][1].dataSource?.id).toBe('ds1');
});
});

View File

@ -119,6 +119,15 @@ describe('DataSourceSelect', () => {
});
});
test('未 provide eventBus 时不告警,点击编辑也不抛错', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
dataSourceService.getDataSourceById.mockReturnValue({ id: '1' });
const wrapper = mount(DataSourceSelect, { props: baseProps({ model: { ds: '1' } }) as any });
expect(warn.mock.calls.some((c) => String(c[0]).includes('eventBus'))).toBe(false);
await expect(wrapper.find('button').trigger('click')).resolves.toBeUndefined();
warn.mockRestore();
});
test('editHandler emit edit-data-source', async () => {
const eventBus = { emit: vi.fn() };
dataSourceService.getDataSourceById.mockReturnValue({ id: '1' });

View File

@ -9,14 +9,14 @@ import { mount } from '@vue/test-utils';
import DisplayConds from '@editor/fields/DisplayConds.vue';
const dataSourceService = {
getDataSourceById: vi.fn(),
};
vi.mock('@editor/hooks/use-services', () => ({
useServices: () => ({ dataSourceService }),
// 表单配置由 fields/configs/displayConds.ts 产出(组件与无渲染校验的嵌套配置共用),
// 那里直接用服务单例,因此这里 mock 服务模块本身
const { dataSourceService } = vi.hoisted(() => ({
dataSourceService: { getDataSourceById: vi.fn() },
}));
vi.mock('@editor/services/dataSource', () => ({ default: dataSourceService }));
const { fieldTypeMock } = vi.hoisted(() => ({
fieldTypeMock: vi.fn((_ds: any, names: string[]) => {
const key = names?.[0];
@ -27,8 +27,8 @@ const { fieldTypeMock } = vi.hoisted(() => ({
}),
}));
vi.mock('@editor/utils', async () => {
const actual = await vi.importActual<any>('@editor/utils');
vi.mock('@editor/utils/data-source', async () => {
const actual = await vi.importActual<any>('@editor/utils/data-source');
return {
...actual,
getCascaderOptionsFromFields: vi.fn(() => [{ label: 'f1', value: 'f1' }]),
@ -44,15 +44,19 @@ vi.mock('@tmagic/form', async () => {
filterFunction: vi.fn((_m: any, v: any) => (typeof v === 'function' ? v() : v)),
MGroupList: defineComponent({
name: 'MGroupList',
props: ['config', 'name', 'disabled', 'model', 'lastValues', 'prop', 'size'],
props: ['config', 'name', 'disabled', 'model', 'lastValues', 'isCompare', 'prop', 'size'],
emits: ['change'],
setup(props, { emit }) {
capturedConfig = props.config;
return () =>
h('div', {
class: 'fake-group-list',
onClick: () => emit('change', [{ field: ['fa'], op: 'eq', value: 'a' }]),
});
h(
'div',
{
class: 'fake-group-list m-fields-group-list',
onClick: () => emit('change', [{ field: ['fa'], op: 'eq', value: 'a' }]),
},
[h('div', { class: 'group-item m-fields-group-list-item' })],
);
},
}),
};
@ -65,14 +69,36 @@ beforeEach(() => {
});
describe('DisplayConds', () => {
test('change 事件初始化数组', async () => {
const model: any = {};
test('change 事件向上抛出', async () => {
const wrapper = mount(DisplayConds, {
props: { config: { titlePrefix: 't', parentFields: [] }, model, name: 'conds' } as any,
props: { config: { titlePrefix: 't', parentFields: [] }, model: { conds: [] }, name: 'conds' } as any,
});
await wrapper.find('.fake-group-list').trigger('click');
expect(model.conds).toEqual([]);
expect(wrapper.emitted('change')).toBeTruthy();
expect(wrapper.emitted('change')?.[0]?.[0]).toEqual([{ field: ['fa'], op: 'eq', value: 'a' }]);
});
test('外层与内层 cond 都使用 groupList外层吸底新增由 group-list 接管', () => {
const wrapper = mount(DisplayConds, {
props: { config: { titlePrefix: '条件组', parentFields: [] }, model: { conds: [] }, name: 'conds' } as any,
});
expect(capturedConfig.type).toBe('groupList');
expect(capturedConfig.defaultAdd).toEqual({ cond: [] });
expect(capturedConfig.scrollLastItemIntoView).toBe(true);
expect(capturedConfig.header.sticky).toBe(true);
expect(capturedConfig.addButtonConfig.sticky).toBe(true);
expect(capturedConfig.addButtonConfig.text).toBe('新增条件组');
expect(capturedConfig.items[0].type).toBe('groupList');
expect(capturedConfig.items[0].name).toBe('cond');
expect(capturedConfig.items[0].titlePrefix).toBe('条件');
expect(capturedConfig.items[0].copyable).toBe(true);
expect(capturedConfig.items[0].movable).toBe(false);
expect(capturedConfig.items[0].flat).toBe(true);
expect(capturedConfig.items[0].scrollLastItemIntoView).toBe(true);
expect(capturedConfig.items[0].header.sticky).toBe(true);
expect(capturedConfig.items[0].addButtonConfig.sticky).toBe(true);
expect(capturedConfig.items[0].addButtonConfig.text).toBe('新增条件');
expect(capturedConfig.items[0].items.every((item: any) => item.span === undefined)).toBe(true);
expect(wrapper.findComponent({ name: 'MGroupList' }).exists()).toBe(true);
});
test('parentFields 不为空时使用 cascader', () => {
@ -133,7 +159,7 @@ describe('DisplayConds', () => {
mount(DisplayConds, {
props: { config: { titlePrefix: 't', parentFields: ['ds1'] }, model: {}, name: 'conds' } as any,
});
const valueItem = capturedConfig.items[0].items[2].items[0];
const valueItem = capturedConfig.items[0].items[2];
expect(valueItem.type(undefined, { model: { field: ['numField'] } })).toBe('number');
expect(valueItem.type(undefined, { model: { field: ['boolField'] } })).toBe('select');
expect(valueItem.type(undefined, { model: { field: ['nullField'] } })).toBe('display');
@ -144,7 +170,7 @@ describe('DisplayConds', () => {
mount(DisplayConds, {
props: { config: { titlePrefix: 't', parentFields: [] }, model: {}, name: 'conds' } as any,
});
const valueItem = capturedConfig.items[0].items[2].items[0];
const valueItem = capturedConfig.items[0].items[2];
expect(valueItem.display(undefined, { model: { op: 'eq' } })).toBe(true);
expect(valueItem.display(undefined, { model: { op: 'between' } })).toBe(false);
expect(valueItem.displayText(undefined, { model: { value: null } })).toBe('null');
@ -155,7 +181,7 @@ describe('DisplayConds', () => {
mount(DisplayConds, {
props: { config: { titlePrefix: 't', parentFields: [] }, model: {}, name: 'conds' } as any,
});
const rangeItem = capturedConfig.items[0].items[2].items[1];
const rangeItem = capturedConfig.items[0].items[3];
expect(rangeItem.display(undefined, { model: { op: 'between' } })).toBe(true);
expect(rangeItem.display(undefined, { model: { op: 'eq' } })).toBe(false);
});
@ -194,4 +220,42 @@ describe('DisplayConds', () => {
const item = capturedConfig.items[0].items[0];
expect(item.options()).toEqual([]);
});
test('isCompare 但无 lastValues 时不进入对比', () => {
const wrapper = mount(DisplayConds, {
props: {
config: { titlePrefix: '条件组', parentFields: [] },
model: { conds: [] },
name: 'conds',
isCompare: true,
} as any,
});
expect(wrapper.findComponent({ name: 'MGroupList' }).props('isCompare')).toBe(false);
});
test('外层 defaultAdd 为默认条件组', () => {
mount(DisplayConds, {
props: {
config: { titlePrefix: '条件组', parentFields: [] },
model: { conds: [{ cond: [] }] },
name: 'conds',
} as any,
});
expect(capturedConfig.defaultAdd).toEqual({ cond: [] });
expect(capturedConfig.addButtonConfig.text).toBe('新增条件组');
});
test('对比模式向内部透传 isCompare', () => {
const lastValues = { conds: [{ cond: [] }] };
const wrapper = mount(DisplayConds, {
props: {
config: { titlePrefix: '条件组', parentFields: [] },
model: { conds: [{ cond: [] }] },
name: 'conds',
isCompare: true,
lastValues,
} as any,
});
expect(wrapper.findComponent({ name: 'MGroupList' }).props('isCompare')).toBe(true);
});
});

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