cloud-web-corejs 1.0.54-dev.706 → 1.0.54-dev.708
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.
|
@@ -0,0 +1,1079 @@
|
|
|
1
|
+
# XForm 多页签业务交互实现方案
|
|
2
|
+
|
|
3
|
+
> 基于 `src/components/xform` 现有组件体系,实现「列表+新增/编辑/查看」多 Tab 页签交互模式。
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 一、需求摘要(来自需求图)
|
|
8
|
+
|
|
9
|
+
| # | 需求项 | 详细说明 |
|
|
10
|
+
|---|--------|----------|
|
|
11
|
+
| 1 | **页面样式** | 列表与详情合并,隐藏独立详情页,仅展示列表页签 |
|
|
12
|
+
| 2 | **新增交互** | 点击「新增」→ 新增【**新增**】页签(带关闭符) → 暂存(刷当前页签) / 提交(回列表并刷新) |
|
|
13
|
+
| 3 | **编辑交互** | 点击某行「编辑」→ 新增【**单号**】页签(带关闭符) → 暂存(刷当前页签) / 提交(回列表并刷新) |
|
|
14
|
+
| 4 | **查看交互** | 点击某行「查看」→ 新增【**单号**】页签(带关闭符) → 支持同时打开多个 |
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## 二、技术方案选型
|
|
19
|
+
|
|
20
|
+
### 方案:封装 XFormTabContainer 容器组件
|
|
21
|
+
|
|
22
|
+
在 xform 组件库之上封装一个**业务级 Tab 容器组件**,内部使用 Element UI 的 `el-tabs`(type="card", closable)管理页签生命周期。
|
|
23
|
+
|
|
24
|
+
**核心思路:**
|
|
25
|
+
- 不修改 xform 内部源码,而是通过**组合方式**在其外层包一层容器
|
|
26
|
+
- 利用 xform 已有的 `VFormRender` 渲染表单,`VFormDesigner` 做设计态(如需要)
|
|
27
|
+
- 页签状态由容器组件统一管理
|
|
28
|
+
|
|
29
|
+
### 与现有 xform Tab 组件的关系
|
|
30
|
+
|
|
31
|
+
xform 内部已有 `tab-widget.vue`(设计态)和 `tab-item.vue**(运行态)—— 这些是**表单内部的标签页容器组件**,用于在一个表单内分页展示不同字段组。本方案要实现的**不是这个**,而是**更高层的业务页面级别多页签**,两者互不冲突、可以共存。
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
业务层级: XFormTabContainer (本方案新建)
|
|
35
|
+
└─ el-tabs > el-tab-pane
|
|
36
|
+
├─ [列表] → xform-table 列表组件
|
|
37
|
+
├─ [新增] → VFormRender 表单渲染器
|
|
38
|
+
└─ [BH001] → VFormRender 表单渲染器 (编辑/查看)
|
|
39
|
+
|
|
40
|
+
表单内部层级: tab-item (已有)
|
|
41
|
+
└─ el-tabs > el-tab-pane
|
|
42
|
+
├─ [基础信息] → 字段组1
|
|
43
|
+
└─ [详细信息] → 字段组2
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## 三、组件架构设计
|
|
49
|
+
|
|
50
|
+
### 3.1 文件结构(建议位置)
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
src/components/xform/
|
|
54
|
+
├── XFormTabContainer/
|
|
55
|
+
│ ├── index.vue # 主容器组件 (核心)
|
|
56
|
+
│ ├── useTabManager.js # Composable: 页签状态管理
|
|
57
|
+
│ └── XFormTabPane.vue # 单个页签内容包装器
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### 3.2 核心数据模型
|
|
61
|
+
|
|
62
|
+
```js
|
|
63
|
+
// tabsData 数据结构
|
|
64
|
+
const tabsData = ref([
|
|
65
|
+
{
|
|
66
|
+
name: 'list', // 唯一标识
|
|
67
|
+
title: '列表', // 页签标题
|
|
68
|
+
closable: false, // 是否可关闭
|
|
69
|
+
fixed: true, // 是否固定页签
|
|
70
|
+
component: 'list', // 组件类型: list | form | detail
|
|
71
|
+
formType: null, // 表单类型: add | edit | view | null
|
|
72
|
+
formData: null, // 表单数据/表单JSON
|
|
73
|
+
rowId: null, // 编辑/查看时的行数据ID
|
|
74
|
+
billNo: null, // 单号(用于显示标题)
|
|
75
|
+
key: 0 // 强制刷新key
|
|
76
|
+
},
|
|
77
|
+
// 动态页签举例:
|
|
78
|
+
{
|
|
79
|
+
name: 'add_1687563200',
|
|
80
|
+
title: '新增',
|
|
81
|
+
closable: true,
|
|
82
|
+
fixed: false,
|
|
83
|
+
component: 'form',
|
|
84
|
+
formType: 'add',
|
|
85
|
+
formData: { ... }, // 新增表单的formJson
|
|
86
|
+
rowId: null,
|
|
87
|
+
billNo: null,
|
|
88
|
+
key: 0
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
name: 'edit_BH001',
|
|
92
|
+
title: 'BH001',
|
|
93
|
+
closable: true,
|
|
94
|
+
fixed: false,
|
|
95
|
+
component: 'form',
|
|
96
|
+
formType: 'edit',
|
|
97
|
+
formData: { ... },
|
|
98
|
+
rowId: 'BH001',
|
|
99
|
+
billNo: 'BH001',
|
|
100
|
+
key: 0
|
|
101
|
+
}
|
|
102
|
+
])
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### 3.3 组件接口定义
|
|
106
|
+
|
|
107
|
+
#### XFormTabContainer Props
|
|
108
|
+
|
|
109
|
+
```js
|
|
110
|
+
props: {
|
|
111
|
+
// ========== 列表相关 ==========
|
|
112
|
+
listConfig: {
|
|
113
|
+
type: Object,
|
|
114
|
+
required: true,
|
|
115
|
+
default: () => ({})
|
|
116
|
+
// 列表配置: apiUrl, columns, searchForm, rowKey 等
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
// ========== 表单相关 ==========
|
|
120
|
+
formConfig: {
|
|
121
|
+
type: Object,
|
|
122
|
+
default: () => ({})
|
|
123
|
+
// 表单配置:
|
|
124
|
+
// - addFormJson: 新增表单JSON
|
|
125
|
+
// - editFormJson: 编辑表单JSON
|
|
126
|
+
// - viewFormJson: 查看表单JSON (可选,不传则用editFormJson只读模式)
|
|
127
|
+
// - saveApi: 暂存接口
|
|
128
|
+
// - submitApi: 提交接口
|
|
129
|
+
// - detailApi: 详情数据获取接口
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
// ========== 全局配置 ==========
|
|
133
|
+
options: {
|
|
134
|
+
type: Object,
|
|
135
|
+
default: () => ({
|
|
136
|
+
maxTabs: 10, // 最大打开页签数
|
|
137
|
+
defaultActive: 'list', // 默认激活页签
|
|
138
|
+
showListTab: true, // 是否显示列表页签
|
|
139
|
+
confirmOnClose: true, // 关闭有未保存数据时是否确认
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
#### XFormTabContainer Events
|
|
146
|
+
|
|
147
|
+
```js
|
|
148
|
+
emits: [
|
|
149
|
+
'tab-change', // 页签切换 (name)
|
|
150
|
+
'tab-open', // 页签打开 (tabData)
|
|
151
|
+
'tab-close', // 页签关闭 (tabData)
|
|
152
|
+
'before-close', // 关闭前拦截 (tabData, done/cancel)
|
|
153
|
+
'save-success', // 暂存成功 (data)
|
|
154
|
+
'submit-success', // 提交成功 (data)
|
|
155
|
+
'list-refresh', // 列表刷新触发
|
|
156
|
+
]
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
#### XFormTabContainer Slots
|
|
160
|
+
|
|
161
|
+
```html
|
|
162
|
+
<!-- 列表区域插槽 -->
|
|
163
|
+
<template #list="{ refresh, getSelection }">
|
|
164
|
+
<your-table-component @add="onAdd" @edit="onEdit" @view="onView" />
|
|
165
|
+
</template>
|
|
166
|
+
|
|
167
|
+
<!-- 表单区域插槽 -->
|
|
168
|
+
<template #form="{ tabData, save, submit, loading }">
|
|
169
|
+
<v-form-render :form-json="tabData.formData" ... />
|
|
170
|
+
</template>
|
|
171
|
+
|
|
172
|
+
<!-- 工具栏扩展插槽 -->
|
|
173
|
+
<template #toolbar="{ currentTab }">
|
|
174
|
+
<el-button>导出</el-button>
|
|
175
|
+
</template>
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
## 四、核心逻辑 — useTabManager.js
|
|
181
|
+
|
|
182
|
+
这是整个方案的**核心状态管理 Hook**,负责所有页签的增删改查:
|
|
183
|
+
|
|
184
|
+
```js
|
|
185
|
+
// src/components/xform/XFormTabContainer/useTabManager.js
|
|
186
|
+
|
|
187
|
+
import { ref, computed } from 'vue'
|
|
188
|
+
|
|
189
|
+
export function useTabManager(options = {}) {
|
|
190
|
+
const {
|
|
191
|
+
maxTabs = 10,
|
|
192
|
+
defaultActive = 'list',
|
|
193
|
+
confirmOnClose = true
|
|
194
|
+
} = options
|
|
195
|
+
|
|
196
|
+
// ====== 状态 ======
|
|
197
|
+
const activeTabName = ref(defaultActive)
|
|
198
|
+
const tabsData = ref([
|
|
199
|
+
createListTab()
|
|
200
|
+
])
|
|
201
|
+
|
|
202
|
+
// ====== 计算属性 ======
|
|
203
|
+
const activeTab = computed(() =>
|
|
204
|
+
tabsData.value.find(t => t.name === activeTabName.value)
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
const dynamicTabs = computed(() =>
|
|
208
|
+
tabsData.value.filter(t => !t.fixed)
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
// ====== 工厂函数 ======
|
|
212
|
+
function createListTab() {
|
|
213
|
+
return {
|
|
214
|
+
name: 'list',
|
|
215
|
+
title: '列表',
|
|
216
|
+
closable: false,
|
|
217
|
+
fixed: true,
|
|
218
|
+
component: 'list',
|
|
219
|
+
formType: null,
|
|
220
|
+
formData: null,
|
|
221
|
+
rowId: null,
|
|
222
|
+
billNo: null,
|
|
223
|
+
key: Date.now(),
|
|
224
|
+
dirty: false // 是否有未保存的修改
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function createAddTab(formJson) {
|
|
229
|
+
return {
|
|
230
|
+
name: `add_${Date.now()}`,
|
|
231
|
+
title: '新增',
|
|
232
|
+
closable: true,
|
|
233
|
+
fixed: false,
|
|
234
|
+
component: 'form',
|
|
235
|
+
formType: 'add',
|
|
236
|
+
formData: formJson,
|
|
237
|
+
rowId: null,
|
|
238
|
+
billNo: null,
|
|
239
|
+
key: Date.now(),
|
|
240
|
+
dirty: false
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function createEditTab(billNo, formJson) {
|
|
245
|
+
return {
|
|
246
|
+
name: `edit_${billNo}`,
|
|
247
|
+
title: billNo,
|
|
248
|
+
closable: true,
|
|
249
|
+
fixed: false,
|
|
250
|
+
component: 'form',
|
|
251
|
+
formType: 'edit',
|
|
252
|
+
formData: formJson,
|
|
253
|
+
rowId: billNo,
|
|
254
|
+
billNo: billNo,
|
|
255
|
+
key: Date.now(),
|
|
256
|
+
dirty: false
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function createViewTab(billNo, formJson) {
|
|
261
|
+
return {
|
|
262
|
+
name: `view_${billNo}_${Date.now()}`,
|
|
263
|
+
title: billNo,
|
|
264
|
+
closable: true,
|
|
265
|
+
fixed: false,
|
|
266
|
+
component: 'form',
|
|
267
|
+
formType: 'view',
|
|
268
|
+
formData: formJson,
|
|
269
|
+
rowId: billNo,
|
|
270
|
+
billNo: billNo,
|
|
271
|
+
key: Date.now(),
|
|
272
|
+
dirty: false
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ====== 核心操作 ======
|
|
277
|
+
|
|
278
|
+
/** 打开新增页签 */
|
|
279
|
+
function openAddTab(formJson) {
|
|
280
|
+
// 检查是否已存在未关闭的新增页签 (可选策略)
|
|
281
|
+
const existingAdd = tabsData.value.find(
|
|
282
|
+
t => !t.fixed && t.formType === 'add'
|
|
283
|
+
)
|
|
284
|
+
if (existingAdd) {
|
|
285
|
+
// 策略A: 直接切换到已有的新增页签
|
|
286
|
+
activeTabName.value = existingAdd.name
|
|
287
|
+
refreshTab(existingAdd.name)
|
|
288
|
+
return existingAdd
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// 策略B: 总是创建新的新增页签 (推荐,符合需求图描述)
|
|
292
|
+
const newTab = createAddTab(formJson)
|
|
293
|
+
tabsData.value.push(newTab)
|
|
294
|
+
activeTabName.value = newTab.name
|
|
295
|
+
return newTab
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** 打开编辑页签 */
|
|
299
|
+
function openEditTab(billNo, formJson) {
|
|
300
|
+
// 检查是否已打开该单据的编辑页签
|
|
301
|
+
const existing = tabsData.value.find(
|
|
302
|
+
t => t.rowId === billNo && t.formType === 'edit'
|
|
303
|
+
)
|
|
304
|
+
if (existing) {
|
|
305
|
+
activeTabName.value = existing.name
|
|
306
|
+
refreshTab(existing.name)
|
|
307
|
+
return existing
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const newTab = createEditTab(billNo, formJson)
|
|
311
|
+
tabsData.value.push(newTab)
|
|
312
|
+
activeTabName.value = newTab.name
|
|
313
|
+
return newTab
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** 打开查看页签 (允许同时打开多个) */
|
|
317
|
+
function openViewTab(billNo, formJson) {
|
|
318
|
+
// 查看页签允许重复打开同一单据 (每次打开新页签)
|
|
319
|
+
const newTab = createViewTab(billNo, formJson)
|
|
320
|
+
tabsData.value.push(newTab)
|
|
321
|
+
activeTabName.value = newTab.name
|
|
322
|
+
return newTab
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** 关闭页签 */
|
|
326
|
+
async function closeTab(targetName) {
|
|
327
|
+
const targetIdx = tabsData.value.findIndex(t => t.name === targetName)
|
|
328
|
+
if (targetIdx === -1) return
|
|
329
|
+
|
|
330
|
+
const target = tabsData.value[targetIdx]
|
|
331
|
+
|
|
332
|
+
// 固定页签不可关闭
|
|
333
|
+
if (target.fixed) return
|
|
334
|
+
|
|
335
|
+
// 未保存数据确认
|
|
336
|
+
if (confirmOnClose && target.dirty) {
|
|
337
|
+
// 触发 before-close 事件, 由外部处理确认弹窗
|
|
338
|
+
// 返回 false 可取消关闭
|
|
339
|
+
return { cancelled: true }
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// 执行关闭
|
|
343
|
+
tabsData.value.splice(targetIdx, 1)
|
|
344
|
+
|
|
345
|
+
// 如果关闭的是当前活动页签, 切换到相邻页签
|
|
346
|
+
if (activeTabName.value === targetName) {
|
|
347
|
+
const nextIdx = Math.min(targetIdx, tabsData.value.length - 1)
|
|
348
|
+
activeTabName.value = tabsData.value[nextIdx]?.name || 'list'
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return { closed: true, target }
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** 刷新指定页签内容 (用于暂存后刷新) */
|
|
355
|
+
function refreshTab(tabName) {
|
|
356
|
+
const tab = tabsData.value.find(t => t.name === tabName)
|
|
357
|
+
if (tab) {
|
|
358
|
+
tab.key = Date.now() // 通过改变key强制重新渲染
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** 刷新当前页签 */
|
|
363
|
+
function refreshCurrentTab() {
|
|
364
|
+
refreshTab(activeTabName.value)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** 标记当前页签为脏数据(有修改) */
|
|
368
|
+
function markDirty(tabName) {
|
|
369
|
+
const tab = tabsData.value.find(t => t.name === tabName)
|
|
370
|
+
if (tab) tab.dirty = true
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** 清除脏标记 (暂存/提交后) */
|
|
374
|
+
function clearDirty(tabName) {
|
|
375
|
+
const tab = tabsData.value.find(t => t.name === tabName)
|
|
376
|
+
if (tab) tab.dirty = false
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** 提交后返回列表并刷新 */
|
|
380
|
+
function submitAndBackToList() {
|
|
381
|
+
// 关闭当前非固定页签
|
|
382
|
+
const current = activeTab.value
|
|
383
|
+
if (current && !current.fixed) {
|
|
384
|
+
closeTab(current.name)
|
|
385
|
+
}
|
|
386
|
+
// 切换到列表
|
|
387
|
+
activeTabName.value = 'list'
|
|
388
|
+
// 列表刷新通过事件通知外部
|
|
389
|
+
return { needRefreshList: true }
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** 暂存后停留在当前页签 */
|
|
393
|
+
function saveAndStay() {
|
|
394
|
+
refreshCurrentTab()
|
|
395
|
+
clearDirty(activeTabName.value)
|
|
396
|
+
return { refreshed: true }
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** 切换页签 */
|
|
400
|
+
function switchTab(tabName) {
|
|
401
|
+
activeTabName.value = tabName
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return {
|
|
405
|
+
// 状态
|
|
406
|
+
activeTabName,
|
|
407
|
+
tabsData,
|
|
408
|
+
activeTab,
|
|
409
|
+
dynamicTabs,
|
|
410
|
+
|
|
411
|
+
// 操作
|
|
412
|
+
openAddTab,
|
|
413
|
+
openEditTab,
|
|
414
|
+
openViewTab,
|
|
415
|
+
closeTab,
|
|
416
|
+
refreshTab,
|
|
417
|
+
refreshCurrentTab,
|
|
418
|
+
markDirty,
|
|
419
|
+
clearDirty,
|
|
420
|
+
submitAndBackToList,
|
|
421
|
+
saveAndStay,
|
|
422
|
+
switchTab
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
---
|
|
428
|
+
|
|
429
|
+
## 五、主组件骨架 — index.vue
|
|
430
|
+
|
|
431
|
+
```vue
|
|
432
|
+
<template>
|
|
433
|
+
<div class="xform-tab-container">
|
|
434
|
+
<!-- Tabs 页签栏 -->
|
|
435
|
+
<el-tabs
|
|
436
|
+
v-model="activeTabName"
|
|
437
|
+
type="card"
|
|
438
|
+
closable
|
|
439
|
+
@tab-click="handleTabClick"
|
|
440
|
+
@tab-remove="handleTabRemove"
|
|
441
|
+
>
|
|
442
|
+
<!-- 列表页签 -->
|
|
443
|
+
<el-tab-pane
|
|
444
|
+
v-for="tab in tabsData"
|
|
445
|
+
:key="tab.name + '_' + tab.key"
|
|
446
|
+
:label="renderTabLabel(tab)"
|
|
447
|
+
:name="tab.name"
|
|
448
|
+
:closable="tab.closable"
|
|
449
|
+
>
|
|
450
|
+
<!-- 列表内容 -->
|
|
451
|
+
<template v-if="tab.component === 'list'">
|
|
452
|
+
<slot name="list" :refresh="handleListRefresh" :tab-data="tab">
|
|
453
|
+
<div class="tab-content-list">
|
|
454
|
+
<!-- 默认或自定义列表组件 -->
|
|
455
|
+
<slot />
|
|
456
|
+
</div>
|
|
457
|
+
</slot>
|
|
458
|
+
</template>
|
|
459
|
+
|
|
460
|
+
<!-- 表单/详情内容 -->
|
|
461
|
+
<template v-else-if="tab.component === 'form'">
|
|
462
|
+
<slot name="form" :tab-data="tab" :save="handleSave" :submit="handleSubmit" :loading="loading">
|
|
463
|
+
<XFormTabPane
|
|
464
|
+
:key="tab.key"
|
|
465
|
+
:tab-data="tab"
|
|
466
|
+
:form-config="mergedFormConfig"
|
|
467
|
+
@save="handleSave"
|
|
468
|
+
@submit="handleSubmit"
|
|
469
|
+
@dirty-change="(val) => markDirty(tab.name)"
|
|
470
|
+
/>
|
|
471
|
+
</slot>
|
|
472
|
+
</template>
|
|
473
|
+
</el-tab-pane>
|
|
474
|
+
</el-tabs>
|
|
475
|
+
</div>
|
|
476
|
+
</template>
|
|
477
|
+
|
|
478
|
+
<script>
|
|
479
|
+
import { defineComponent } from 'vue'
|
|
480
|
+
import { useTabManager } from './useTabManager'
|
|
481
|
+
import XFormTabPane from './XFormTabPane.vue'
|
|
482
|
+
|
|
483
|
+
export default defineComponent({
|
|
484
|
+
name: 'XFormTabContainer',
|
|
485
|
+
|
|
486
|
+
components: { XFormTabPane },
|
|
487
|
+
|
|
488
|
+
props: {
|
|
489
|
+
listConfig: { type: Object, required: true },
|
|
490
|
+
formConfig: { type: Object, default: () => ({}) },
|
|
491
|
+
options: { type: Object, default: () => ({}) }
|
|
492
|
+
},
|
|
493
|
+
|
|
494
|
+
emits: ['tab-change', 'tab-open', 'tab-close', 'before-close',
|
|
495
|
+
'save-success', 'submit-success', 'list-refresh'],
|
|
496
|
+
|
|
497
|
+
setup(props, { emit, slots }) {
|
|
498
|
+
const manager = useTabManager(props.options)
|
|
499
|
+
const { activeTabName, tabsData, activeTab, /* ... */ } = manager
|
|
500
|
+
|
|
501
|
+
const loading = ref(false)
|
|
502
|
+
|
|
503
|
+
// 合并表单配置
|
|
504
|
+
const mergedFormConfig = computed(() => ({
|
|
505
|
+
...defaultFormConfig,
|
|
506
|
+
...props.formConfig
|
|
507
|
+
}))
|
|
508
|
+
|
|
509
|
+
// ====== 事件处理 ======
|
|
510
|
+
|
|
511
|
+
/** 渲染页签 label (支持脏数据标识) */
|
|
512
|
+
function renderTabLabel(tab) {
|
|
513
|
+
const prefix = tab.dirty ? '● ' : ''
|
|
514
|
+
return `${prefix}${tab.title}`
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** 页签点击 */
|
|
518
|
+
function handleTabClick(tab) {
|
|
519
|
+
emit('tab-change', tab.paneName || tab.name)
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** 页签关闭按钮点击 */
|
|
523
|
+
async function handleTabRemove(targetName) {
|
|
524
|
+
const result = await manager.closeTab(targetName)
|
|
525
|
+
if (result?.closed) {
|
|
526
|
+
emit('tab-close', result.target)
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** 暂存操作 */
|
|
531
|
+
async function handleSave(formData) {
|
|
532
|
+
loading.value = true
|
|
533
|
+
try {
|
|
534
|
+
await callSaveApi(formData) // 调用暂存接口
|
|
535
|
+
manager.saveAndStay() // 刷新当前页签
|
|
536
|
+
emit('save-success', formData)
|
|
537
|
+
} finally {
|
|
538
|
+
loading.value = false
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** 提交操作 */
|
|
543
|
+
async function handleSubmit(formData) {
|
|
544
|
+
loading.value = true
|
|
545
|
+
try {
|
|
546
|
+
await callSubmitApi(formData) // 调用提交接口
|
|
547
|
+
const result = manager.submitAndBackToList()
|
|
548
|
+
if (result.needRefreshList) {
|
|
549
|
+
emit('list-refresh')
|
|
550
|
+
}
|
|
551
|
+
emit('submit-success', formData)
|
|
552
|
+
} finally {
|
|
553
|
+
loading.value = false
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** 列表刷新 */
|
|
558
|
+
function handleListRefresh() {
|
|
559
|
+
emit('list-refresh')
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// ====== 对外暴露方法 (供父组件调用) ======
|
|
563
|
+
expose({
|
|
564
|
+
openAddTab: manager.openAddTab,
|
|
565
|
+
openEditTab: manager.openEditTab,
|
|
566
|
+
openViewTab: manager.openViewTab,
|
|
567
|
+
closeTab: manager.closeTab,
|
|
568
|
+
refreshList: handleListRefresh,
|
|
569
|
+
// 允许父组件直接操作页签
|
|
570
|
+
...manager
|
|
571
|
+
})
|
|
572
|
+
|
|
573
|
+
return {
|
|
574
|
+
activeTabName,
|
|
575
|
+
tabsData,
|
|
576
|
+
activeTab,
|
|
577
|
+
mergedFormConfig,
|
|
578
|
+
loading,
|
|
579
|
+
renderTabLabel,
|
|
580
|
+
handleTabClick,
|
|
581
|
+
handleTabRemove,
|
|
582
|
+
handleSave,
|
|
583
|
+
handleSubmit,
|
|
584
|
+
handleListRefresh,
|
|
585
|
+
// 将 manager 方法暴露到模板
|
|
586
|
+
...manager
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
})
|
|
590
|
+
</script>
|
|
591
|
+
|
|
592
|
+
<style scoped>
|
|
593
|
+
.xform-tab-container {
|
|
594
|
+
width: 100%;
|
|
595
|
+
height: 100%;
|
|
596
|
+
display: flex;
|
|
597
|
+
flex-direction: column;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
.xform-tab-container :deep(.el-tabs__header) {
|
|
601
|
+
margin-bottom: 0;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
.xform-tab-container :deep(.el-tabs__content) {
|
|
605
|
+
flex: 1;
|
|
606
|
+
overflow: auto;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
.tab-content-list {
|
|
610
|
+
padding: 16px;
|
|
611
|
+
}
|
|
612
|
+
</style>
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
---
|
|
616
|
+
|
|
617
|
+
## 六、页签内容组件 — XFormTabPane.vue
|
|
618
|
+
|
|
619
|
+
```vue
|
|
620
|
+
<template>
|
|
621
|
+
<div class="xform-tab-pane" v-loading="pageLoading">
|
|
622
|
+
<!-- 使用 xform 的 VFormRender 渲染表单 -->
|
|
623
|
+
<v-form-render
|
|
624
|
+
ref="vFormRef"
|
|
625
|
+
:form-json="currentFormJson"
|
|
626
|
+
:form-data="formDataModel"
|
|
627
|
+
:option-data="optionData"
|
|
628
|
+
@form-mounted="onFormMounted"
|
|
629
|
+
/>
|
|
630
|
+
|
|
631
|
+
<!-- 底部操作栏 -->
|
|
632
|
+
<div class="pane-footer" v-if="showActions">
|
|
633
|
+
<el-button @click="handleDraft" :loading="saving">暂存</el-button>
|
|
634
|
+
<el-button type="primary" @click="handleSubmit" :loading="submitting">提交</el-button>
|
|
635
|
+
</div>
|
|
636
|
+
</div>
|
|
637
|
+
</template>
|
|
638
|
+
|
|
639
|
+
<script>
|
|
640
|
+
import { defineComponent, ref, computed, watch, onMounted } from 'vue'
|
|
641
|
+
|
|
642
|
+
export default defineComponent({
|
|
643
|
+
name: 'XFormTabPane',
|
|
644
|
+
|
|
645
|
+
props: {
|
|
646
|
+
tabData: { type: Object, required: true },
|
|
647
|
+
formConfig: { type: Object, default: () => ({}) }
|
|
648
|
+
},
|
|
649
|
+
|
|
650
|
+
emits: ['save', 'submit', 'dirty-change'],
|
|
651
|
+
|
|
652
|
+
setup(props, { emit }) {
|
|
653
|
+
const vFormRef = ref(null)
|
|
654
|
+
const pageLoading = ref(false)
|
|
655
|
+
const saving = ref(false)
|
|
656
|
+
const submitting = ref(false)
|
|
657
|
+
const formDataModel = ref({})
|
|
658
|
+
const optionData = ref({})
|
|
659
|
+
|
|
660
|
+
// 根据 formType 决定用哪个 formJson
|
|
661
|
+
const currentFormJson = computed(() => {
|
|
662
|
+
const cfg = props.formConfig
|
|
663
|
+
const type = props.tabData.formType
|
|
664
|
+
if (type === 'add') return cfg.addFormJson
|
|
665
|
+
if (type === 'view' && cfg.viewFormJson) return cfg.viewFormJson
|
|
666
|
+
return cfg.editFormJson // edit 或默认
|
|
667
|
+
})
|
|
668
|
+
|
|
669
|
+
// 查看模式下隐藏操作按钮
|
|
670
|
+
const showActions = computed(() => props.tabData.formType !== 'view')
|
|
671
|
+
|
|
672
|
+
// 加载编辑/查看时的初始数据
|
|
673
|
+
onMounted(async () => {
|
|
674
|
+
const { formType, rowId } = props.tabData
|
|
675
|
+
if ((formType === 'edit' || formType === 'view') && rowId) {
|
|
676
|
+
await loadDetailData(rowId)
|
|
677
|
+
}
|
|
678
|
+
})
|
|
679
|
+
|
|
680
|
+
/** 加载详情数据 */
|
|
681
|
+
async function loadDetailData(id) {
|
|
682
|
+
pageLoading.value = true
|
|
683
|
+
try {
|
|
684
|
+
const api = props.formConfig.detailApi
|
|
685
|
+
const res = await fetch(`${api}/${id}`)
|
|
686
|
+
formDataModel.value = res.data
|
|
687
|
+
} finally {
|
|
688
|
+
pageLoading.value = false
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/** 获取表单数据 */
|
|
693
|
+
function getFormData() {
|
|
694
|
+
return vFormRef.value?.getFormData()
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/** 校验表单 */
|
|
698
|
+
async function validateForm() {
|
|
699
|
+
return vFormRef.value?.validateForm()
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/** 暂存 */
|
|
703
|
+
async function handleDraft() {
|
|
704
|
+
try {
|
|
705
|
+
await validateForm()
|
|
706
|
+
const data = getFormData()
|
|
707
|
+
saving.value = true
|
|
708
|
+
emit('save', data) // 由父组件调接口
|
|
709
|
+
} catch (e) {
|
|
710
|
+
console.warn('表单校验未通过:', e)
|
|
711
|
+
} finally {
|
|
712
|
+
saving.value = false
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/** 提交 */
|
|
717
|
+
async function handleSubmit() {
|
|
718
|
+
try {
|
|
719
|
+
await validateForm()
|
|
720
|
+
const data = getFormData()
|
|
721
|
+
submitting.value = true
|
|
722
|
+
emit('submit', data) // 由父组件调接口
|
|
723
|
+
} catch (e) {
|
|
724
|
+
console.warn('表单校验未通过:', e)
|
|
725
|
+
} finally {
|
|
726
|
+
submitting.value = false
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/** 表单数据变化 -> 标记脏数据 */
|
|
731
|
+
function onFormMounted() {
|
|
732
|
+
// 监听表单内部数据变化
|
|
733
|
+
if (vFormRef.value) {
|
|
734
|
+
vFormRef.value.$watch?.(() => {
|
|
735
|
+
emit('dirty-change', true)
|
|
736
|
+
})
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
return {
|
|
741
|
+
vFormRef, pageLoading, saving, submitting,
|
|
742
|
+
formDataModel, optionData,
|
|
743
|
+
currentFormJson, showActions,
|
|
744
|
+
handleDraft, handleSubmit, onFormMounted
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
})
|
|
748
|
+
</script>
|
|
749
|
+
|
|
750
|
+
<style scoped>
|
|
751
|
+
.xform-tab-pane {
|
|
752
|
+
height: 100%;
|
|
753
|
+
padding: 16px;
|
|
754
|
+
}
|
|
755
|
+
.pane-footer {
|
|
756
|
+
text-align: right;
|
|
757
|
+
padding-top: 16px;
|
|
758
|
+
border-top: 1px solid #eee;
|
|
759
|
+
margin-top: 16px;
|
|
760
|
+
}
|
|
761
|
+
</style>
|
|
762
|
+
```
|
|
763
|
+
|
|
764
|
+
---
|
|
765
|
+
|
|
766
|
+
## 七、使用示例
|
|
767
|
+
|
|
768
|
+
```vue
|
|
769
|
+
<template>
|
|
770
|
+
<XFormTabContainer
|
|
771
|
+
ref="containerRef"
|
|
772
|
+
:list-config="listConfig"
|
|
773
|
+
:form-config="formConfig"
|
|
774
|
+
:options="{ maxTabs: 10, confirmOnClose: true }"
|
|
775
|
+
@tab-open="onTabOpen"
|
|
776
|
+
@tab-close="onTabClose"
|
|
777
|
+
@submit-success="onSubmitSuccess"
|
|
778
|
+
@list-refresh="fetchListData"
|
|
779
|
+
>
|
|
780
|
+
<!-- 自定义列表内容 -->
|
|
781
|
+
<template #list="{ refresh }">
|
|
782
|
+
<XFormTable
|
|
783
|
+
:columns="tableColumns"
|
|
784
|
+
:data="tableData"
|
|
785
|
+
:loading="tableLoading"
|
|
786
|
+
@row-add="handleAdd"
|
|
787
|
+
@row-edit="handleEdit"
|
|
788
|
+
@row-view="handleView"
|
|
789
|
+
>
|
|
790
|
+
<template #toolbar>
|
|
791
|
+
<el-button type="primary" icon="el-icon-plus" @click="handleAdd">
|
|
792
|
+
新增
|
|
793
|
+
</el-button>
|
|
794
|
+
</template>
|
|
795
|
+
</XFormTable>
|
|
796
|
+
</template>
|
|
797
|
+
|
|
798
|
+
<!-- 自定义表单内容 (可选,默认用内置的 XFormTabPane) -->
|
|
799
|
+
<!-- <template #form="{ tabData, save, submit, loading }"> -->
|
|
800
|
+
<!-- 自定义表单UI... -->
|
|
801
|
+
<!-- </template> -->
|
|
802
|
+
|
|
803
|
+
<!-- 工具栏扩展 -->
|
|
804
|
+
<template #toolbar="{ currentTab }">
|
|
805
|
+
<el-button v-if="currentTab?.name !== 'list'" @click="exportData">
|
|
806
|
+
导出
|
|
807
|
+
</el-button>
|
|
808
|
+
</template>
|
|
809
|
+
</XFormTabContainer>
|
|
810
|
+
</template>
|
|
811
|
+
|
|
812
|
+
<script>
|
|
813
|
+
import { defineComponent, ref, onMounted } from 'vue'
|
|
814
|
+
import XFormTabContainer from '@/components/xform/XFormTabContainer/index.vue'
|
|
815
|
+
|
|
816
|
+
export default defineComponent({
|
|
817
|
+
components: { XFormTabContainer },
|
|
818
|
+
|
|
819
|
+
setup() {
|
|
820
|
+
const containerRef = ref(null)
|
|
821
|
+
const tableData = ref([])
|
|
822
|
+
const tableLoading = ref(false)
|
|
823
|
+
|
|
824
|
+
const listConfig = ref({
|
|
825
|
+
apiUrl: '/api/orders/list',
|
|
826
|
+
rowKey: 'billNo'
|
|
827
|
+
})
|
|
828
|
+
|
|
829
|
+
const formConfig = ref({
|
|
830
|
+
addFormJson: require('./forms/order-add.json'),
|
|
831
|
+
editFormJson: require('./forms/order-edit.json'),
|
|
832
|
+
viewFormJson: require('./forms/order-view.json'),
|
|
833
|
+
saveApi: '/api/orders/draft',
|
|
834
|
+
submitApi: '/api/orders/submit',
|
|
835
|
+
detailApi: '/api/orders/detail'
|
|
836
|
+
})
|
|
837
|
+
|
|
838
|
+
// ====== 操作方法 ======
|
|
839
|
+
|
|
840
|
+
function handleAdd() {
|
|
841
|
+
containerRef.value.openAddTab(formConfig.value.addFormJson)
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function handleEdit(row) {
|
|
845
|
+
containerRef.value.openEditTab(row.billNo, formConfig.value.editFormJson)
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function handleView(row) {
|
|
849
|
+
containerRef.value.openViewTab(row.billNo, formConfig.value.viewFormJson)
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function onSubmitSuccess(data) {
|
|
853
|
+
Message.success('提交成功')
|
|
854
|
+
// submitAndBackToList 已经自动处理了页签关闭和切换
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function fetchListData() {
|
|
858
|
+
tableLoading.value = true
|
|
859
|
+
fetchList().then(res => {
|
|
860
|
+
tableData.value = res.data
|
|
861
|
+
}).finally(() => {
|
|
862
|
+
tableLoading.value = false
|
|
863
|
+
})
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
onMounted(() => {
|
|
867
|
+
fetchListData()
|
|
868
|
+
})
|
|
869
|
+
|
|
870
|
+
return {
|
|
871
|
+
containerRef, listConfig, formConfig,
|
|
872
|
+
tableData, tableLoading,
|
|
873
|
+
handleAdd, handleEdit, handleView,
|
|
874
|
+
onSubmitSuccess, fetchListData
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
})
|
|
878
|
+
</script>
|
|
879
|
+
```
|
|
880
|
+
|
|
881
|
+
---
|
|
882
|
+
|
|
883
|
+
## 八、交互流程详解
|
|
884
|
+
|
|
885
|
+
### 8.1 新增流程
|
|
886
|
+
|
|
887
|
+
```
|
|
888
|
+
用户点击[新增]按钮
|
|
889
|
+
│
|
|
890
|
+
▼
|
|
891
|
+
handleAdd()
|
|
892
|
+
│
|
|
893
|
+
▼
|
|
894
|
+
containerRef.openAddTab(addFormJson)
|
|
895
|
+
│ ← useTabManager.createAddTab() → push 到 tabsData
|
|
896
|
+
│ ← activeTabName 切换到新页签
|
|
897
|
+
▼
|
|
898
|
+
el-tabs 自动渲染新页签
|
|
899
|
+
│
|
|
900
|
+
▼
|
|
901
|
+
XFormTabPane 加载 addFormJson → VFormRender 渲染空表单
|
|
902
|
+
│
|
|
903
|
+
▼
|
|
904
|
+
用户填写表单...
|
|
905
|
+
│
|
|
906
|
+
├──► 点击[暂存]
|
|
907
|
+
│ │
|
|
908
|
+
│ ▼
|
|
909
|
+
│ handleSave(formData) → API调用
|
|
910
|
+
│ │
|
|
911
|
+
│ ▼
|
|
912
|
+
│ saveAndStay() → refreshCurrentTab() → key++ → 强制刷新当前页签
|
|
913
|
+
│ (用户留在当前页签,看到更新后的数据)
|
|
914
|
+
│
|
|
915
|
+
└──► 点击[提交]
|
|
916
|
+
│
|
|
917
|
+
▼
|
|
918
|
+
handleSubmit(formData) → API调用
|
|
919
|
+
│
|
|
920
|
+
▼
|
|
921
|
+
submitAndBackToList() → closeTab(current) + activeTab='list'
|
|
922
|
+
emit('list-refresh') → 外部重新请求列表数据
|
|
923
|
+
(用户回到列表页签,看到最新列表)
|
|
924
|
+
```
|
|
925
|
+
|
|
926
|
+
### 8.2 编辑流程
|
|
927
|
+
|
|
928
|
+
```
|
|
929
|
+
用户点击某行的[编辑]按钮
|
|
930
|
+
│
|
|
931
|
+
▼
|
|
932
|
+
handleEdit(row)
|
|
933
|
+
│
|
|
934
|
+
▼
|
|
935
|
+
containerRef.openEditTab(row.billNo, editFormJson)
|
|
936
|
+
│ ← 检查是否已打开该单据的编辑页签
|
|
937
|
+
│ ├─ 已打开 → 切换过去 + refresh
|
|
938
|
+
│ └─ 未打开 → 创建新页签
|
|
939
|
+
▼
|
|
940
|
+
XFormTabPane 加载 editFormJson → VFormRender 渲染
|
|
941
|
+
│ → 同时加载详情数据 (loadDetailData/billNo) 填充表单
|
|
942
|
+
│
|
|
943
|
+
▼
|
|
944
|
+
用户修改表单... (后续同新增: 暂存/提交)
|
|
945
|
+
```
|
|
946
|
+
|
|
947
|
+
### 8.3 查看流程
|
|
948
|
+
|
|
949
|
+
```
|
|
950
|
+
用户点击某行的[查看]按钮
|
|
951
|
+
│
|
|
952
|
+
▼
|
|
953
|
+
handleView(row)
|
|
954
|
+
│
|
|
955
|
+
▼
|
|
956
|
+
containerRef.openViewTab(row.billNo, viewFormJson)
|
|
957
|
+
│ ← 注意: 查看**每次都创建新页签**, 不做去重
|
|
958
|
+
│ → 允许同时打开多个查看页签
|
|
959
|
+
▼
|
|
960
|
+
XFormTabPane 以只读/禁用模式渲染 (showActions=false)
|
|
961
|
+
│
|
|
962
|
+
▼
|
|
963
|
+
用户点击页签右上角 ✕ → closeTab() → 关闭该页签
|
|
964
|
+
```
|
|
965
|
+
|
|
966
|
+
### 8.4 页签关闭流程
|
|
967
|
+
|
|
968
|
+
```
|
|
969
|
+
用户点击页签 ✕ 按钮
|
|
970
|
+
│
|
|
971
|
+
▼
|
|
972
|
+
handleTabRemove(targetName)
|
|
973
|
+
│
|
|
974
|
+
▼
|
|
975
|
+
closeTab(targetName)
|
|
976
|
+
│
|
|
977
|
+
├──► 该页签有未保存修改 (dirty=true)?
|
|
978
|
+
│ │
|
|
979
|
+
│ ├── 是 → 触发 before-close → 弹窗确认 → 取消/确定
|
|
980
|
+
│ │
|
|
981
|
+
│ └── 否 / 用户确认 → splice 从 tabsData 移除
|
|
982
|
+
│
|
|
983
|
+
└──► 当前活动页签就是被关的那个?
|
|
984
|
+
│
|
|
985
|
+
└── 是 → activeTabName = 相邻页签 or 'list'
|
|
986
|
+
```
|
|
987
|
+
|
|
988
|
+
---
|
|
989
|
+
|
|
990
|
+
## 九、关键实现细节
|
|
991
|
+
|
|
992
|
+
### 9.1 页签唯一性命名规则
|
|
993
|
+
|
|
994
|
+
| 场景 | name 格式 | 示例 | 去重策略 |
|
|
995
|
+
|------|-----------|------|----------|
|
|
996
|
+
| 列表 | `'list'` | `'list'` | 固定,不可删除 |
|
|
997
|
+
| 新增 | `'add_' + timestamp` | `'add_1687563200'` | 可选:复用已有新增页签 |
|
|
998
|
+
| 编辑 | `'edit_' + billNo` | `'edit_BH001'` | **按 billNo 去重**,已打开则切换 |
|
|
999
|
+
| 查看 | `'view_' + billNo + '_' + timestamp` | `'view_BH001_1687563300'` | **不去重**,每次新建 |
|
|
1000
|
+
|
|
1001
|
+
### 9.2 强制刷新机制
|
|
1002
|
+
|
|
1003
|
+
通过 Vue 的 `:key` 属性实现页签内容强制重新渲染:
|
|
1004
|
+
- 每次 `refreshTab()` 时将 `tab.key = Date.now()`
|
|
1005
|
+
- Vue 检测 key 变化会销毁旧组件、创建新组件
|
|
1006
|
+
- 配合 `el-tab-pane` 的 `:key="tab.name + '_' + tab.key"` 绑定
|
|
1007
|
+
|
|
1008
|
+
### 9.3 脏数据检测与防丢失
|
|
1009
|
+
|
|
1010
|
+
```
|
|
1011
|
+
用户在表单中输入 → VFormRender 内部数据变化
|
|
1012
|
+
│
|
|
1013
|
+
▼
|
|
1014
|
+
emit('dirty-change', true) → markDirty(tabName) → tab.dirty = true
|
|
1015
|
+
│
|
|
1016
|
+
▼
|
|
1017
|
+
页签标题前显示 ● 标识 (renderTabLabel 中处理)
|
|
1018
|
+
│
|
|
1019
|
+
▼
|
|
1020
|
+
尝试关闭时 → confirmOnClose && dirty → 弹出确认对话框
|
|
1021
|
+
```
|
|
1022
|
+
|
|
1023
|
+
### 9.4 与现有 xform 组件的关系
|
|
1024
|
+
|
|
1025
|
+
| 现有 xform 组件 | 本方案中的角色 |
|
|
1026
|
+
|-----------------|---------------|
|
|
1027
|
+
| `VFormRender` (`form-render/index.vue`) | 在每个表单页签中渲染表单 JSON |
|
|
1028
|
+
| `tab-item.vue` (运行态 Tab) | **不受影响** — 这是表单内部容器组件,与本方案的业务层 Tab 是不同维度 |
|
|
1029
|
+
| `tab-widget.vue` (设计态 Tab) | **不受影响** — 设计器内部使用 |
|
|
1030
|
+
| `widgetsConfig.js` (组件注册) | **不需要修改** — 本方案在外部组合 |
|
|
1031
|
+
|
|
1032
|
+
### 9.5 样式适配要点
|
|
1033
|
+
|
|
1034
|
+
- 使用 `el-tabs` 的 `type="card"` 样式,自带关闭按钮和圆角卡片风格
|
|
1035
|
+
- 页签栏高度约 40px, 内容区 `flex: 1` 占满剩余空间
|
|
1036
|
+
- 切换动画使用 Element UI 内置的 `fade-in-linear` transition
|
|
1037
|
+
- 多页签时自动出现左右滚动箭头 (el-tabs 内置行为)
|
|
1038
|
+
|
|
1039
|
+
---
|
|
1040
|
+
|
|
1041
|
+
## 十、实施步骤建议
|
|
1042
|
+
|
|
1043
|
+
### Phase 1:基础框架 (预计 2-3 天)
|
|
1044
|
+
1. 创建 `XFormTabContainer/` 目录结构
|
|
1045
|
+
2. 实现 `useTabManager.js` — 页签状态管理
|
|
1046
|
+
3. 实现 `index.vue` — 主容器骨架
|
|
1047
|
+
4. 实现 `XFormTabPane.vue` — 页签内容包装
|
|
1048
|
+
5. **验证**: 手动添加/关闭页签, 切换正常
|
|
1049
|
+
|
|
1050
|
+
### Phase 2:集成 xform (预计 2 天)
|
|
1051
|
+
1. 在 `XFormTabPane` 中集成 `VFormRender` 组件
|
|
1052
|
+
2. 实现表单 JSON 加载和数据回填
|
|
1053
|
+
3. 接入暂存/提交 API 调用
|
|
1054
|
+
4. **验证**: 新增页签能正常渲染表单, 暂存和提交流程跑通
|
|
1055
|
+
|
|
1056
|
+
### Phase 3:列表集成 (预计 1-2 天)
|
|
1057
|
+
1. 在列表页签中接入实际的列表组件
|
|
1058
|
+
2. 实现列表的新增/编辑/查看按钮回调
|
|
1059
|
+
3. 实现提交后的列表自动刷新
|
|
1060
|
+
4. **验证**: 完整 CRUD 流程走通
|
|
1061
|
+
|
|
1062
|
+
### Phase 4:体验优化 (预计 1-2 天)
|
|
1063
|
+
1. 脏数据检测与关闭确认
|
|
1064
|
+
2. 页签最大数量限制
|
|
1065
|
+
3. 页签标题脏数据标识 (●)
|
|
1066
|
+
4. 键盘快捷键支持 (Ctrl+S 暂存, Ctrl+W 关闭等)
|
|
1067
|
+
5. **验收**: 符合需求图中全部 4 条交互要求
|
|
1068
|
+
|
|
1069
|
+
---
|
|
1070
|
+
|
|
1071
|
+
## 十一、风险与注意事项
|
|
1072
|
+
|
|
1073
|
+
| 风险点 | 影响 | 应对措施 |
|
|
1074
|
+
|--------|------|----------|
|
|
1075
|
+
| 大量页签导致内存占用过高 | 页面卡顿 | 设置 `maxTabs` 上限; 关闭时彻底销毁组件实例 |
|
|
1076
|
+
| 表单 JSON 过大导致切换卡顿 | 用户体验差 | 按需加载(懒渲染); 使用 `keepAlive` 缓存最近 N 个 |
|
|
1077
|
+
| 并发编辑冲突 | 数据覆盖 | 提交前校验版本号; 乐观锁机制 |
|
|
1078
|
+
| 浏览器后退按钮 | 意外离开 | 监听 `beforeunload` 事件提示保存 |
|
|
1079
|
+
| 与路由系统配合 | URL 不同步 | 可选: 将当前活跃页签同步到 query 参数 |
|