flame-plus 1.3.31 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.json +11 -0
- package/.claude/settings.local.json +42 -0
- package/cursor_modelvalue_prop_type_check_issue.md +102 -0
- package/docs/current-page-full.png +0 -0
- package/docs/dev-notes/container-drag-refactor.md +453 -0
- package/docs/flame-plus-attrfields-onchange-support.md +201 -0
- package/docs/formslot-test.png +0 -0
- package/docs/framework-dynamic-form-value-retrieval.md +153 -0
- package/docs/framework-external-components.md +182 -0
- package/docs/page-structure.md +165 -0
- package/flameDist/flame-plus.css +1 -1
- package/flameDist/flame-plus.es.js +21558 -21770
- package/flameDist/flame-plus.es.js.map +1 -1
- package/flameDist/flame-plus.umd.js +132 -132
- package/flameDist/flame-plus.umd.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"permissions": {
|
|
3
|
+
"allow": [
|
|
4
|
+
"Read(//Users/albert/Workspaces/gmp/gmp-frontend/gmp-platform/node_modules/.pnpm/flame-plus@1.3.26_@element-plus+icons-vue@2.3.2_vue@3.3.4__@interactjs+core@1.10.27_@in_3c4e5d5dc5fd652459cb45b714b2aa3c/node_modules/flame-plus/flameDist/**)",
|
|
5
|
+
"Bash(pnpm run:*)"
|
|
6
|
+
]
|
|
7
|
+
},
|
|
8
|
+
"enabledPlugins": {
|
|
9
|
+
"chrome-devtools-mcp@claude-plugins-official": true
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"permissions": {
|
|
3
|
+
"allow": [
|
|
4
|
+
"Bash(python3:*)",
|
|
5
|
+
"Bash(npm run:*)",
|
|
6
|
+
"mcp__ide__getDiagnostics",
|
|
7
|
+
"Bash(cat /Users/albert/Workspaces/poba/flame-plus/node_modules/flame-types/lib/*.d.ts 2>/dev/null | head -30)",
|
|
8
|
+
"Bash(wc -l /Users/albert/Workspaces/poba/flame-plus/src/packages/components/framework/*.ts /Users/albert/Workspaces/poba/flame-plus/src/packages/components/framework/*.vue 2>/dev/null | head -20)",
|
|
9
|
+
"Bash(ls -la /Users/albert/Workspaces/spark/flame-types/src/ 2>/dev/null || find /Users/albert/Workspaces/spark/flame-types -name \"*.ts\" -o -name \"*.d.ts\" | head -30)",
|
|
10
|
+
"Bash(cd /Users/albert/Workspaces/spark/flame-types && npm install 2>&1)",
|
|
11
|
+
"Bash(npm pack:*)",
|
|
12
|
+
"Bash(cd /Users/albert/Workspaces/poba/flame-plus && npm install /Users/albert/Workspaces/spark/flame-types/flame-types-1.1.7.tgz 2>&1)",
|
|
13
|
+
"Bash(npm install:*)",
|
|
14
|
+
"Bash(npx vue-tsc:*)",
|
|
15
|
+
"Bash(grep -l \"framework.vue\" /Users/albert/.claude/plans/*.md 2>/dev/null | head -5)",
|
|
16
|
+
"Bash(npx run:*)",
|
|
17
|
+
"Bash(wc -l /Users/albert/Workspaces/poba/flame-plus/src/packages/components/framework/framework.vue /Users/albert/Workspaces/poba/flame-plus/src/packages/components/framework/utils/*.ts /Users/albert/Workspaces/poba/flame-plus/src/packages/components/framework/components/DetailSettingsSlot.vue)",
|
|
18
|
+
"Bash(wc -l /Users/albert/Workspaces/poba/flame-plus/src/packages/components/framework/types.ts /Users/albert/Workspaces/poba/flame-plus/src/packages/components/framework/utils/*.ts /Users/albert/Workspaces/poba/flame-plus/src/packages/components/framework/composables/*.ts /Users/albert/Workspaces/poba/flame-plus/src/packages/components/framework/components/*.vue /Users/albert/Workspaces/poba/flame-plus/src/packages/components/framework/*.ts /Users/albert/Workspaces/poba/flame-plus/src/packages/components/framework/*.vue)",
|
|
19
|
+
"Bash(npx vite:*)",
|
|
20
|
+
"Bash(kill %1)",
|
|
21
|
+
"Bash(curl -s http://localhost:5173/)",
|
|
22
|
+
"Bash(curl -s 'http://localhost:5173/src/packages/components/framework/framework.vue?vue&type=style&index=0&lang.scss')",
|
|
23
|
+
"Bash(jobs -l)",
|
|
24
|
+
"Bash(npx skills:*)",
|
|
25
|
+
"mcp__plugin_chrome-devtools-mcp_chrome-devtools__list_pages",
|
|
26
|
+
"mcp__plugin_chrome-devtools-mcp_chrome-devtools__navigate_page",
|
|
27
|
+
"mcp__plugin_chrome-devtools-mcp_chrome-devtools__take_snapshot",
|
|
28
|
+
"mcp__plugin_chrome-devtools-mcp_chrome-devtools__take_screenshot",
|
|
29
|
+
"mcp__plugin_chrome-devtools-mcp_chrome-devtools__list_console_messages",
|
|
30
|
+
"mcp__plugin_chrome-devtools-mcp_chrome-devtools__get_console_message",
|
|
31
|
+
"mcp__plugin_chrome-devtools-mcp_chrome-devtools__evaluate_script",
|
|
32
|
+
"mcp__plugin_chrome-devtools-mcp_chrome-devtools__click",
|
|
33
|
+
"mcp__plugin_chrome-devtools-mcp_chrome-devtools__press_key",
|
|
34
|
+
"mcp__plugin_chrome-devtools-mcp_chrome-devtools__drag",
|
|
35
|
+
"mcp__plugin_chrome-devtools-mcp_chrome-devtools__wait_for",
|
|
36
|
+
"Bash(curl -s http://localhost:5173)"
|
|
37
|
+
]
|
|
38
|
+
},
|
|
39
|
+
"enabledPlugins": {
|
|
40
|
+
"chrome-devtools-mcp@claude-plugins-official": true
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# ModelValue prop type check issue
|
|
2
|
+
_Exported on 2026/4/1 at GMT+8 15:24:02 from Cursor (2.6.22)_
|
|
3
|
+
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
**User**
|
|
7
|
+
|
|
8
|
+
排查问题
|
|
9
|
+
|
|
10
|
+
MasterFormSwitch.vue?t=1775015461216:215 Invalid prop: type check failed for prop "modelValue". Expected Number | Null, got String with value "12".
|
|
11
|
+
Proxy(Object) {…}
|
|
12
|
+
at <ElInputNumber>
|
|
13
|
+
at <FlmInputNumber>
|
|
14
|
+
at <ElFormItem>
|
|
15
|
+
at <ElForm>
|
|
16
|
+
at <Anonymous>
|
|
17
|
+
at <BaseTransition>
|
|
18
|
+
at <Transition>
|
|
19
|
+
at <ElCollapseTransition>
|
|
20
|
+
at <ElCollapseItem>
|
|
21
|
+
at <ElCollapse>
|
|
22
|
+
at <FlmCollapse>
|
|
23
|
+
at <FlmDynamicForm>
|
|
24
|
+
at <ElFocusTrap>
|
|
25
|
+
at <ElOverlay>
|
|
26
|
+
at <BaseTransition>
|
|
27
|
+
at <Transition>
|
|
28
|
+
at <Teleport>
|
|
29
|
+
at <ElDrawer>
|
|
30
|
+
at <MasterFormSwitch>
|
|
31
|
+
at <KeepAlive>
|
|
32
|
+
at <RouterView>
|
|
33
|
+
at <AppView>
|
|
34
|
+
at <ElScrollbar>
|
|
35
|
+
at <Layout>
|
|
36
|
+
at <RouterView>
|
|
37
|
+
at <ElConfigProvider>
|
|
38
|
+
at <ConfigGlobal>
|
|
39
|
+
at <App>
|
|
40
|
+
(28) [{…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
**Cursor**
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
正在排查:`ElInputNumber` 的 `modelValue` 需要 `number | null`,但收到了字符串 `"12"`。正在查看 `MasterFormSwitch.vue` 与 `FlmInputNumber` 的数据流。
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
## 结论
|
|
55
|
+
|
|
56
|
+
这是 **Element Plus `el-input-number` 的类型校验**:`modelValue` 只能是 `number | null | undefined`,但当前收到的是字符串 **`"12"`**。
|
|
57
|
+
组件链是:`MasterFormSwitch` → `flm-dynamic-form` → `FlmInputNumber` → `ElInputNumber`。问题出在 **喂给动态表单的 model 里数字字段是字符串**,不是 `MasterFormSwitch` 模板里某一行写错类型。
|
|
58
|
+
|
|
59
|
+
控制台里 `MasterFormSwitch.vue:215` 多半是 **Vite 带 query 的编译映射**,和源码行号不一定一致;真正相关的逻辑在下面这段。
|
|
60
|
+
|
|
61
|
+
## 数据从哪来
|
|
62
|
+
|
|
63
|
+
编辑时 `dynamicFormModel` 来自详情接口解析结果,或退化为表格行:
|
|
64
|
+
|
|
65
|
+
```353:356:/Users/albert/Workspaces/gmp/gmp-frontend/gmp-platform/src/views/SPARKFORM/MasterFormSwitch/MasterFormSwitch.vue
|
|
66
|
+
dynamicFormModel.value =
|
|
67
|
+
Object.keys(fromApi).length > 0
|
|
68
|
+
? { ...fromApi }
|
|
69
|
+
: { ...(data && typeof data === 'object' ? data : {}) }
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`fromApi` 由 `parseDynamicFormGetDtlItems` 从 `dynamicFormGetDtl` 响应里拆出来,**不会做数字类型转换**,后端/JSON 里经常是 `"12"` 这种字符串:
|
|
73
|
+
|
|
74
|
+
```289:317:/Users/albert/Workspaces/gmp/gmp-frontend/gmp-platform/src/views/SPARKFORM/MasterFormSwitch/MasterFormSwitch.vue
|
|
75
|
+
const parseDynamicFormGetDtlItems = (res: any): Record<string, any> => {
|
|
76
|
+
// ...
|
|
77
|
+
if (typeof items === 'object') {
|
|
78
|
+
const row = (items as any).record ?? (items as any).row ?? items
|
|
79
|
+
if (row && typeof row === 'object' && !Array.isArray(row)) {
|
|
80
|
+
return { ...row }
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return {}
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
新增时 `dynamicFormModel` 先是 `{}`,一般 **不会** 立刻报这个错;**编辑**(或详情为空、走了表格行 `data` 回显)最容易复现。
|
|
88
|
+
|
|
89
|
+
## 与 flame-plus 的关系
|
|
90
|
+
|
|
91
|
+
你们仓库里的说明已经对齐根因:`flm-dynamic-form` / grid 把 model 里的值原样塞给控件,`FlmInputNumber` 又原样传给 `ElInputNumber`,**没有**把字符串数字转成 `number`。见 `docs/flame-plus-fix-input-number-string-modelValue.md`。
|
|
92
|
+
|
|
93
|
+
## 处理思路(择一即可)
|
|
94
|
+
|
|
95
|
+
1. **业务侧(本页)**:在赋给 `dynamicFormModel` 之前,按表单配置或字段元数据,对 `flmInputNumber` 对应字段做归一(空串/`null` 与 `Number()` 的规则见该文档表格)。
|
|
96
|
+
2. **组件库侧**:在 flame-plus 的 `FlmInputNumber`(或 grid 赋值处)统一归一 `modelValue`,升级依赖或用 `pnpm patch`。
|
|
97
|
+
3. **后端**:保证动态详情/列表里数值字段以 JSON number 返回(改动面取决于接口约定)。
|
|
98
|
+
|
|
99
|
+
若只想先确认,可在 `editData` 里 `console.log(fromApi)` 或打断点,看对应数字字段是否为字符串 `"12"`。
|
|
100
|
+
|
|
101
|
+
(当前为 Ask 模式,我无法直接改代码;若要我在项目里加归一或 patch,需要切换到 Agent 模式。)
|
|
102
|
+
|
|
Binary file
|
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
# 容器拖拽重构 & 嵌套展望
|
|
2
|
+
|
|
3
|
+
## 一、本次解决的问题
|
|
4
|
+
|
|
5
|
+
### 1.1 背景
|
|
6
|
+
|
|
7
|
+
原「新增分组」流程:用户点击设计区底部的「新增分组」按钮 → 弹出 `el-dialog` 选择分组类型(表单/子表单/插槽) → 点击确认 → 创建分组。
|
|
8
|
+
|
|
9
|
+
**痛点**:操作路径长,打断设计节奏;弹窗交互与拖拽式设计体验不一致。
|
|
10
|
+
|
|
11
|
+
### 1.2 解决方案
|
|
12
|
+
|
|
13
|
+
将弹窗选择改为从左侧控件面板「容器」分组直接拖入设计区:
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
左侧面板 设计区
|
|
17
|
+
┌───────────┐ ┌──────────────────────┐
|
|
18
|
+
│ 控件 │ │ 表单设计区 │
|
|
19
|
+
│ ▼ 容器 │ ─── 拖拽 ──→ │ │
|
|
20
|
+
│ 表单 │ │ ┌──────────────────┐ │
|
|
21
|
+
│ 子表单 │ │ │ 拖入容器创建分组 │ │
|
|
22
|
+
│ 插槽 │ │ └──────────────────┘ │
|
|
23
|
+
│ ▶ 基础控件 │ │ │
|
|
24
|
+
└───────────┘ └──────────────────────┘
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
核心设计决策:使用独立 SortableJS group name `'containers'` 与基础控件的 `'formWidgets'` 隔离,防止基础控件被误拖到根层。
|
|
28
|
+
|
|
29
|
+
### 1.3 涉及文件
|
|
30
|
+
|
|
31
|
+
| 文件 | 改动 |
|
|
32
|
+
|------|------|
|
|
33
|
+
| `utils/defaultConfigs.ts` | 新增 `CONTAINER_WIDGET_LIST` 常量 |
|
|
34
|
+
| `components/WidgetPanel.vue` | 新增「容器」分组,group name = `containers` |
|
|
35
|
+
| `components/FormDesignArea.vue` | 根层新增容器 drop zone,移除弹窗按钮 |
|
|
36
|
+
| `composables/useGroupManagement.ts` | 新增 `addGroupByContainerType()`,移除弹窗相关方法 |
|
|
37
|
+
| `composables/useDropHandlers.ts` | 新增 `onContainerDrop()`,处理容器拖入 |
|
|
38
|
+
| `framework.vue` | 串联组件,移除弹窗引用 |
|
|
39
|
+
|
|
40
|
+
### 1.4 关键实现细节
|
|
41
|
+
|
|
42
|
+
#### 1.4.1 拖拽组隔离
|
|
43
|
+
|
|
44
|
+
SortableJS 通过 `group.name` 决定跨列表拖拽的合法性。我们定义了两个互不干扰的拖拽通道:
|
|
45
|
+
|
|
46
|
+
| 通道 | group.name | 源(左侧面板) | 目标(设计区) |
|
|
47
|
+
|------|------------|---------------|---------------|
|
|
48
|
+
| 容器 | `containers` | 容器面板项 | 根层 container-drop-zone |
|
|
49
|
+
| 基础控件 | `formWidgets` | 基础/自定义面板项 | 分组内 form-inner-drag-zone |
|
|
50
|
+
|
|
51
|
+
容器面板配置:
|
|
52
|
+
```html
|
|
53
|
+
<!-- WidgetPanel.vue — 容器面板(源) -->
|
|
54
|
+
<draggable
|
|
55
|
+
:group="{ name: 'containers', pull: 'clone', put: false }"
|
|
56
|
+
:clone="clonePaletteItem"
|
|
57
|
+
/>
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
```html
|
|
61
|
+
<!-- FormDesignArea.vue — 容器放置区(目标) -->
|
|
62
|
+
<draggable
|
|
63
|
+
:list="containerDropList"
|
|
64
|
+
:group="{ name: 'containers', put: true }"
|
|
65
|
+
:sort="false"
|
|
66
|
+
@add="onContainerAdd()"
|
|
67
|
+
/>
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
#### 1.4.2 数据流
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
用户拖拽容器项
|
|
74
|
+
→ SortableJS clone → clonePaletteItem() 生成 DroppedItem { type, id, label }
|
|
75
|
+
→ SortableJS add → containerDropList 接收 DroppedItem
|
|
76
|
+
→ Vue @add → onContainerAdd() → emit('drop-container')
|
|
77
|
+
→ framework.vue → onContainerDrop()
|
|
78
|
+
→ 读取 containerDropList,清空列表
|
|
79
|
+
→ addGroupByContainerType(type) 创建分组
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
#### 1.4.3 vuedraggable v4 的 slot 陷阱
|
|
83
|
+
|
|
84
|
+
vuedraggable v4(vue.draggable.next)**只渲染 `#item` 和 `#footer` 具名 slot**,默认 slot 内容会被忽略:
|
|
85
|
+
|
|
86
|
+
```html
|
|
87
|
+
<!-- 错误:提示文字不会渲染 -->
|
|
88
|
+
<draggable :list="items" item-key="id">
|
|
89
|
+
<template #item="{ element }"><span /></template>
|
|
90
|
+
<span class="drop-hint">提示文字</span> <!-- 被忽略! -->
|
|
91
|
+
</draggable>
|
|
92
|
+
|
|
93
|
+
<!-- 正确:使用 #footer slot -->
|
|
94
|
+
<draggable :list="items" item-key="id">
|
|
95
|
+
<template #item="{ element }"><span :key="element.id" /></template>
|
|
96
|
+
<template #footer>
|
|
97
|
+
<span class="drop-hint">提示文字</span>
|
|
98
|
+
</template>
|
|
99
|
+
</draggable>
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## 二、当前数据模型
|
|
105
|
+
|
|
106
|
+
### 2.1 三层固定树
|
|
107
|
+
|
|
108
|
+
```
|
|
109
|
+
LayoutItem[] (顶层分组)
|
|
110
|
+
└── FormChildItem[] (表单 form / 子表单 subForm / 插槽 slot)
|
|
111
|
+
└── FormChildItem[] (基础控件 control / 表单插槽 formSlot)
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
- `LayoutItem` — 顶层分组节点,`nodeKind: 'group'`
|
|
115
|
+
- `FormChildItem` — 内部节点,`nodeKind` 区分 `form` / `subForm` / `slot` / `control`
|
|
116
|
+
|
|
117
|
+
### 2.2 类型定义(types.ts 关键片段)
|
|
118
|
+
|
|
119
|
+
```typescript
|
|
120
|
+
interface LayoutItem {
|
|
121
|
+
i: string; x: number; y: number; w: number; h: number
|
|
122
|
+
type?: 'group' | 'flmForm' | BasicControlType
|
|
123
|
+
label?: string
|
|
124
|
+
config?: Record<string, unknown>
|
|
125
|
+
children?: FormChildItem[]
|
|
126
|
+
nodeKind?: 'group' | 'form' | 'subForm' | ...
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
interface FormChildItem {
|
|
130
|
+
i: string; x: number; y: number; w: number; h: number
|
|
131
|
+
type: BasicControlType | SlotControlType
|
|
132
|
+
children?: FormChildItem[] // 子表单/表单内部的控件列表
|
|
133
|
+
nodeKind?: 'form' | 'subForm' | 'slot' | 'control'
|
|
134
|
+
formItem?: FormItemSettings
|
|
135
|
+
config?: Record<string, unknown>
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### 2.3 关键约束
|
|
140
|
+
|
|
141
|
+
1. **每个分组只能有一个子项**:form 或 subForm 或 slot(互斥)
|
|
142
|
+
2. **树深度固定为三层**:group → form/subForm/slot → controls
|
|
143
|
+
3. **容器不可嵌套**:group 下不能有 group,form 下不能有 form
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## 三、容器嵌套实现方案(展望)
|
|
148
|
+
|
|
149
|
+
### 3.1 什么是容器嵌套
|
|
150
|
+
|
|
151
|
+
允许分组内包含子分组,形成任意深度的树:
|
|
152
|
+
|
|
153
|
+
```
|
|
154
|
+
分组 A
|
|
155
|
+
├── 表单
|
|
156
|
+
│ ├── 输入框
|
|
157
|
+
│ └── 选择器
|
|
158
|
+
└── 分组 B(子分组)
|
|
159
|
+
└── 子表单
|
|
160
|
+
├── 日期选择器
|
|
161
|
+
└── 开关
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
典型业务场景:
|
|
165
|
+
- **卡片式分组**:一个分组内包含多个卡片(子分组),每个卡片有独立标题
|
|
166
|
+
- **Tab 页签**:分组内包含多个 Tab,每个 Tab 是一个子分组
|
|
167
|
+
- **折叠面板**:分组内包含多个可折叠区域
|
|
168
|
+
|
|
169
|
+
### 3.2 需要修改的文件与方向
|
|
170
|
+
|
|
171
|
+
#### 3.2.1 类型定义(types.ts)
|
|
172
|
+
|
|
173
|
+
**目标**:让分组支持递归嵌套。
|
|
174
|
+
|
|
175
|
+
```typescript
|
|
176
|
+
// 方案 A:LayoutItem 自引用
|
|
177
|
+
interface LayoutItem {
|
|
178
|
+
i: string; x: number; y: number; w: number; h: number
|
|
179
|
+
type: 'group'
|
|
180
|
+
label?: string
|
|
181
|
+
config?: Record<string, unknown>
|
|
182
|
+
children?: LayoutItem[] | FormChildItem[] // ← 关键:允许嵌套分组或表单子项
|
|
183
|
+
nodeKind: 'group'
|
|
184
|
+
}
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
```typescript
|
|
188
|
+
// 方案 B(推荐):统一树节点,通过 nodeKind 区分
|
|
189
|
+
interface TreeNode {
|
|
190
|
+
i: string; x: number; y: number; w: number; h: number
|
|
191
|
+
type: string
|
|
192
|
+
label?: string
|
|
193
|
+
nodeKind: 'group' | 'form' | 'subForm' | 'slot' | 'control'
|
|
194
|
+
children?: TreeNode[]
|
|
195
|
+
config?: Record<string, unknown>
|
|
196
|
+
formItem?: FormItemSettings
|
|
197
|
+
parentGroup?: string // ← 新增:指向父分组 id
|
|
198
|
+
}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
**推荐方案 B**,理由:
|
|
202
|
+
- 统一类型便于递归处理(渲染、遍历、序列化)
|
|
203
|
+
- `parentGroup` 便于向上查找,对选择、删除、复制等操作有帮助
|
|
204
|
+
- 渐进式迁移:可先让 `LayoutItem = TreeNode`,`FormChildItem = TreeNode` 作为别名
|
|
205
|
+
|
|
206
|
+
#### 3.2.2 渲染层(FormDesignArea.vue)
|
|
207
|
+
|
|
208
|
+
**目标**:支持递归渲染分组。
|
|
209
|
+
|
|
210
|
+
当前 FormDesignArea 是手写的扁平结构,改为递归组件:
|
|
211
|
+
|
|
212
|
+
```vue
|
|
213
|
+
<!-- 新组件:GroupNode.vue -->
|
|
214
|
+
<template>
|
|
215
|
+
<div class="group-node" :class="{ selected }">
|
|
216
|
+
<div class="group-header" @click="select">
|
|
217
|
+
<span>{{ item.label || '分组' }}</span>
|
|
218
|
+
<div class="group-actions">
|
|
219
|
+
<el-button @click.stop="addChildGroup">添加子分组</el-button>
|
|
220
|
+
<el-button @click.stop="remove">删除</el-button>
|
|
221
|
+
</div>
|
|
222
|
+
</div>
|
|
223
|
+
|
|
224
|
+
<!-- 分组内容:表单/子表单/插槽(同现有逻辑) -->
|
|
225
|
+
<div v-if="formChild" class="group-body">
|
|
226
|
+
<template v-if="formChild.nodeKind === 'form' || formChild.nodeKind === 'subForm'">
|
|
227
|
+
<el-form>
|
|
228
|
+
<GridLayout :layout="formChild.children" ...>
|
|
229
|
+
<GridItem v-for="ctrl in formChild.children" :key="ctrl.i" ...>
|
|
230
|
+
<!-- 控件渲染 -->
|
|
231
|
+
</GridItem>
|
|
232
|
+
</GridLayout>
|
|
233
|
+
</el-form>
|
|
234
|
+
</template>
|
|
235
|
+
</div>
|
|
236
|
+
|
|
237
|
+
<!-- 递归:子分组 -->
|
|
238
|
+
<draggable
|
|
239
|
+
v-model="childGroups"
|
|
240
|
+
item-key="i"
|
|
241
|
+
handle=".group-header"
|
|
242
|
+
:group="{ name: 'containers', put: true }"
|
|
243
|
+
:sort="true"
|
|
244
|
+
>
|
|
245
|
+
<template #item="{ element }">
|
|
246
|
+
<GroupNode :item="element" ... /> <!-- 递归 -->
|
|
247
|
+
</template>
|
|
248
|
+
</draggable>
|
|
249
|
+
</div>
|
|
250
|
+
</template>
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
**关键变化**:
|
|
254
|
+
- 分组渲染逻辑提取为独立组件 `GroupNode.vue`
|
|
255
|
+
- 子分组区域接收 `'containers'` 拖拽,实现嵌套拖入
|
|
256
|
+
- `handle=".group-header"` 保证只有拖标题才能排序,不会误触控件
|
|
257
|
+
|
|
258
|
+
#### 3.2.3 拖拽处理(useDropHandlers.ts)
|
|
259
|
+
|
|
260
|
+
**目标**:支持向任意深度的分组拖入子分组。
|
|
261
|
+
|
|
262
|
+
当前 `onContainerDrop` 只处理根层拖入,嵌套需要:
|
|
263
|
+
|
|
264
|
+
```typescript
|
|
265
|
+
function onContainerDrop(targetGroupId?: string) {
|
|
266
|
+
const items = [...containerDropList.value]
|
|
267
|
+
containerDropList.value = []
|
|
268
|
+
|
|
269
|
+
for (const dropped of items) {
|
|
270
|
+
const type = dropped.type === 'groupSlot' ? 'slot' : dropped.type
|
|
271
|
+
if (targetGroupId) {
|
|
272
|
+
// 嵌套场景:找到目标分组,push 子分组
|
|
273
|
+
const parent = findGroupById(layout.value, targetGroupId)
|
|
274
|
+
if (parent) {
|
|
275
|
+
parent.children.push(createGroupItem(type))
|
|
276
|
+
}
|
|
277
|
+
} else {
|
|
278
|
+
// 根层场景(现有逻辑)
|
|
279
|
+
addGroupByContainerType(type)
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
新增递归查找辅助:
|
|
286
|
+
|
|
287
|
+
```typescript
|
|
288
|
+
function findGroupById(nodes: TreeNode[], id: string): TreeNode | null {
|
|
289
|
+
for (const n of nodes) {
|
|
290
|
+
if (n.i === id && n.nodeKind === 'group') return n
|
|
291
|
+
if (n.children) {
|
|
292
|
+
const found = findGroupById(n.children, id)
|
|
293
|
+
if (found) return found
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return null
|
|
297
|
+
}
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
#### 3.2.4 配置序列化(useConfigSerialization.ts)
|
|
301
|
+
|
|
302
|
+
**目标**:`buildFrameworkConfig` 和 `parseConfigToLayout` 支持递归。
|
|
303
|
+
|
|
304
|
+
**序列化(layout → config)**:
|
|
305
|
+
|
|
306
|
+
```typescript
|
|
307
|
+
function serializeGroup(group: TreeNode): FrameworkGroupConfig {
|
|
308
|
+
const formChild = group.children?.find(c =>
|
|
309
|
+
c.nodeKind === 'form' || c.nodeKind === 'subForm'
|
|
310
|
+
)
|
|
311
|
+
const childGroups = group.children?.filter(c => c.nodeKind === 'group') || []
|
|
312
|
+
|
|
313
|
+
return {
|
|
314
|
+
id: group.i,
|
|
315
|
+
title: group.label,
|
|
316
|
+
type: formChild?.nodeKind === 'subForm' ? 'subForm' : 'form',
|
|
317
|
+
items: serializeControls(formChild?.children),
|
|
318
|
+
children: childGroups.map(g => serializeGroup(g)), // ← 递归
|
|
319
|
+
...formChild?.config
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
**反序列化(config → layout)**:
|
|
325
|
+
|
|
326
|
+
```typescript
|
|
327
|
+
function parseGroup(cfg: any, idx: number): TreeNode {
|
|
328
|
+
const group: TreeNode = {
|
|
329
|
+
i: cfg.id || `group_${idx}`,
|
|
330
|
+
nodeKind: 'group',
|
|
331
|
+
children: []
|
|
332
|
+
}
|
|
333
|
+
// 解析表单子项
|
|
334
|
+
if (cfg.type !== 'slot') {
|
|
335
|
+
group.children.push(parseFormChild(cfg))
|
|
336
|
+
}
|
|
337
|
+
// 递归解析子分组
|
|
338
|
+
if (Array.isArray(cfg.children)) {
|
|
339
|
+
cfg.children.forEach((child, ci) => {
|
|
340
|
+
group.children.push(parseGroup(child, ci)) // ← 递归
|
|
341
|
+
})
|
|
342
|
+
}
|
|
343
|
+
return group
|
|
344
|
+
}
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
**FrameworkGroupConfig 需扩展**:
|
|
348
|
+
|
|
349
|
+
```typescript
|
|
350
|
+
interface FrameworkGroupConfig {
|
|
351
|
+
id: string
|
|
352
|
+
title: string
|
|
353
|
+
type: FrameworkGroupType
|
|
354
|
+
items?: FormConfig['items']
|
|
355
|
+
children?: FrameworkGroupConfig[] // ← 新增:子分组
|
|
356
|
+
// ...
|
|
357
|
+
}
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
#### 3.2.5 选中状态(useSelection.ts)
|
|
361
|
+
|
|
362
|
+
**目标**:支持深层节点的选中与定位。
|
|
363
|
+
|
|
364
|
+
```typescript
|
|
365
|
+
// 当前:selectedFormChild = { formId, childId }
|
|
366
|
+
// 嵌套:需要完整路径
|
|
367
|
+
interface SelectionPath {
|
|
368
|
+
groupPath: string[] // 从根到目标分组的 id 路径,如 ['group_1', 'group_1_2']
|
|
369
|
+
formId?: string // 所属表单/子表单 id
|
|
370
|
+
childId?: string // 具体控件 id
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const selection = ref<SelectionPath | null>(null)
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
`selectedItem` computed 需沿路径递归查找:
|
|
377
|
+
|
|
378
|
+
```typescript
|
|
379
|
+
const selectedItem = computed(() => {
|
|
380
|
+
const sel = selection.value
|
|
381
|
+
if (!sel) return null
|
|
382
|
+
return findByPath(layout.value, sel.groupPath, sel.childId)
|
|
383
|
+
})
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
#### 3.2.6 属性面板(framework.vue + SettingsPanel.vue)
|
|
387
|
+
|
|
388
|
+
无需结构改动——属性面板已通过 `selectedItem` 驱动,只要 `selectedItem` 返回正确的节点引用即可。
|
|
389
|
+
|
|
390
|
+
但分组属性区可能需要增加「子分组管理」入口,如在基础属性中显示子分组列表,支持排序、删除。
|
|
391
|
+
|
|
392
|
+
### 3.3 嵌套的最大深度限制
|
|
393
|
+
|
|
394
|
+
建议设定最大嵌套深度(如 3 层),原因:
|
|
395
|
+
- **用户体验**:过深的嵌套导致设计区视觉混乱
|
|
396
|
+
- **性能**:每层分组都有 vue-grid-layout 实例,嵌套过多影响渲染
|
|
397
|
+
- **配置可读性**:JSON 配置过深不易维护
|
|
398
|
+
|
|
399
|
+
```typescript
|
|
400
|
+
const MAX_NESTING_DEPTH = 3
|
|
401
|
+
|
|
402
|
+
function canNest(parentGroup: TreeNode): boolean {
|
|
403
|
+
let depth = 0
|
|
404
|
+
let current = parentGroup
|
|
405
|
+
while (current) {
|
|
406
|
+
depth++
|
|
407
|
+
current = findParentGroup(current.i)
|
|
408
|
+
}
|
|
409
|
+
return depth < MAX_NESTING_DEPTH
|
|
410
|
+
}
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
### 3.4 实施路线
|
|
414
|
+
|
|
415
|
+
建议分三个阶段渐进式推进:
|
|
416
|
+
|
|
417
|
+
#### 阶段 1:类型统一(低风险)
|
|
418
|
+
|
|
419
|
+
- 将 `LayoutItem` 和 `FormChildItem` 统一为 `TreeNode`
|
|
420
|
+
- 现有功能不变,只是类型重命名
|
|
421
|
+
- 验证:全部现有测试通过
|
|
422
|
+
|
|
423
|
+
#### 阶段 2:单层嵌套(中等风险)
|
|
424
|
+
|
|
425
|
+
- `TreeNode.children` 允许同时包含分组和表单子项
|
|
426
|
+
- 渲染层支持一层嵌套
|
|
427
|
+
- 拖拽支持向分组内拖入子分组
|
|
428
|
+
- 序列化支持 `children` 字段
|
|
429
|
+
- 验证:能创建一层子分组
|
|
430
|
+
|
|
431
|
+
#### 阶段 3:递归嵌套(较高风险)
|
|
432
|
+
|
|
433
|
+
- 抽取 `GroupNode.vue` 递归组件
|
|
434
|
+
- 选中状态改为路径模式
|
|
435
|
+
- 所有 composable 支持递归操作
|
|
436
|
+
- 加入深度限制
|
|
437
|
+
- 验证:多层嵌套完整可用
|
|
438
|
+
|
|
439
|
+
### 3.5 注意事项
|
|
440
|
+
|
|
441
|
+
1. **vue-grid-layout 实例数量**:每个表单/子表单区域都是一个 GridLayout 实例,嵌套会线性增长。深层嵌套时注意性能。
|
|
442
|
+
2. **拖拽冲突**:子分组的拖拽排序与父级拖拽排序可能冲突(handle 选择器需严格限定)。
|
|
443
|
+
3. **预览渲染**:PreviewDialog 渲染嵌套配置时需递归处理。
|
|
444
|
+
4. **向后兼容**:`FrameworkConfig.children` 应为可选字段,旧配置(无 children)仍可正常回显。
|
|
445
|
+
5. **ID 唯一性**:嵌套后 id 生成需包含父级路径信息(如 `group_1_child_2`),避免全局冲突。
|
|
446
|
+
|
|
447
|
+
---
|
|
448
|
+
|
|
449
|
+
## 四、总结
|
|
450
|
+
|
|
451
|
+
本次改动将「新增分组」从弹窗交互改为拖拽交互,通过 SortableJS group 隔离机制实现了容器与基础控件的独立拖拽通道。整体改动集中在拖拽链路和分组管理两个 composable,未改变数据模型和渲染结构。
|
|
452
|
+
|
|
453
|
+
容器嵌套是对当前三层固定树的扩展,核心挑战在于将现有的扁平渲染和操作逻辑改为递归模式。建议按照「类型统一 → 单层嵌套 → 递归嵌套」的渐进路线实施,每个阶段可独立交付验证。
|