cloud-web-corejs 1.0.54-dev.754 → 1.0.54-dev.755

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cloud-web-corejs",
3
3
  "private": false,
4
- "version": "1.0.54-dev.754",
4
+ "version": "1.0.54-dev.755",
5
5
  "scripts": {
6
6
  "dev": "vue-cli-service serve",
7
7
  "lint": "eslint --ext .js,.vue src",
@@ -166,6 +166,7 @@
166
166
  "src/components/xform",
167
167
  "src/components/xhsPrint",
168
168
  "src/components/langTag",
169
+ "src/components/formDialog",
169
170
  "src/api",
170
171
  "src/views/bd",
171
172
  "src/views/support",
@@ -186,7 +187,6 @@
186
187
  "src/index.js",
187
188
  "src/public-path.js",
188
189
  "src/microRouter",
189
- "src/formDialog",
190
190
  "src/App.vue"
191
191
  ],
192
192
  "publishConfig": {
@@ -0,0 +1,239 @@
1
+ # $formDialog 表单弹框
2
+
3
+ JS 调用式表单弹框:传入字段配置即可打开一个可编辑表单,确认后以 Promise 返回表单数据,无需为每个小表单单独编写 `.vue` 弹框组件。
4
+
5
+ - 组件位置:`src/components/formDialog/`
6
+ - 全局注册:`src/index.js` 中 `Vue.use(FormDialog)`,挂载为 `Vue.prototype.$formDialog`
7
+ - 示例页面:`src/views/support/form-dialog-demo/index.vue`(路由 `/support/form-dialog-demo`,11 个可运行示例)
8
+
9
+ 两种模式:
10
+
11
+ 1. **字段配置模式**(默认)——传 `fields` 数组,由本组件渲染 element 控件;
12
+ 2. **xform 整表模式**——传 `formCode`(或 `formJson`),弹框内渲染设计器里配置好的完整 xform 表单,见「xform 整表模式」一节。
13
+
14
+ ## 快速上手
15
+
16
+ ```js
17
+ const data = await this.$formDialog({
18
+ title: "新增分类",
19
+ fields: [
20
+ { prop: "name", label: "名称", required: true },
21
+ {
22
+ prop: "type",
23
+ label: "类型",
24
+ type: "select",
25
+ options: [
26
+ { label: "普通", value: 1 },
27
+ { label: "重要", value: 2 },
28
+ ],
29
+ },
30
+ { prop: "enabled", label: "启用", type: "switch" },
31
+ { prop: "remark", label: "备注", type: "textarea" },
32
+ ],
33
+ });
34
+ // 点确定且校验通过 => data 为表单数据对象
35
+ // 取消 / 右上角关闭 => data 为 null(不使用 reject,无需 catch)
36
+ if (data !== null) {
37
+ // ...
38
+ }
39
+ ```
40
+
41
+ ## 顶层 options
42
+
43
+ | 参数 | 类型 | 默认值 | 说明 |
44
+ |---|---|---|---|
45
+ | `title` | String | `""` | 弹框标题 |
46
+ | `width` | String | `"600px"` | 弹框宽度 |
47
+ | `labelWidth` | String | `"100px"` | 表单 label 宽度 |
48
+ | `model` | Object | `{}` | 初始值(编辑回显)。未提供的字段按类型补默认值 |
49
+ | `fields` | Array | `[]` | 字段配置,见下 |
50
+ | `rules` | Object | `{}` | 额外的 el-form 校验规则,与字段级 `required`/`rules` 合并 |
51
+ | `readonly` | Boolean | `false` | 只读查看模式:整表单禁用,底部只有"关闭"按钮 |
52
+ | `beforeConfirm` | Function | - | `(data) => Promise`。确认前钩子(常用于调保存接口),reject 则弹框不关闭;执行期间确定按钮 loading 防重复提交 |
53
+ | `formCode` / `formJson` | String / Object | - | 传入即切换为 **xform 整表模式**(`fields` 不再生效),见「xform 整表模式」一节 |
54
+
55
+ 返回值:`Promise`。确认(校验通过、beforeConfirm 成功)resolve 表单数据浅拷贝;取消/关闭 resolve `null`。
56
+
57
+ ## 字段配置(field)
58
+
59
+ ### 通用属性
60
+
61
+ | 属性 | 类型 | 说明 |
62
+ |---|---|---|
63
+ | `prop` | String | 字段名(必填,作为 model 的 key) |
64
+ | `label` | String | 标签文字 |
65
+ | `type` | String | 控件类型,缺省为 `input`,见下表 |
66
+ | `span` | Number | 栅格宽度(24 为一行一个,默认 24) |
67
+ | `required` | Boolean | 必填(自动生成"XX不能为空"规则) |
68
+ | `rules` | Array/Object | 字段级校验规则 |
69
+ | `placeholder` | String | 占位文案 |
70
+ | `disabled` | Boolean / `(model) => Boolean` | 禁用(支持函数联动) |
71
+ | `visible` | Boolean / `(model) => Boolean` | 显隐(支持函数联动;隐藏时不渲染、不参与校验) |
72
+ | `props` | Object | 透传给底层 element 控件的 props(如 `min`/`max`/`multiple`/`valueFormat` 等,可覆盖默认值) |
73
+ | `render` | `(h, model, field) => VNode` | 自定义渲染逃生口,内置类型覆盖不了时使用(配置了 render 则忽略 type) |
74
+ | `onChange` | `(value, model, field, dialog) => void` | 值变化回调,见「联动」 |
75
+ | `dependsOn` | Array\<String\> | 依赖字段列表,见「联动」 |
76
+
77
+ ### 控件类型(type)
78
+
79
+ | type | 控件 | 值类型 | 说明 |
80
+ |---|---|---|---|
81
+ | `input`(默认) | el-input | String | |
82
+ | `textarea` | el-input type=textarea | String | 默认 3 行 |
83
+ | `number` | baseInputNumber | Number | 字段级 `scale`(小数位,默认 2,0=整数)、`negative`(允许负数);`min`/`max` 走 `props` |
84
+ | `select` | el-select | 任意 | 三种数据源见下节 |
85
+ | `date` | el-date-picker | String | value-format 默认 `yyyy-MM-dd` |
86
+ | `datetime` | el-date-picker | String | value-format 默认 `yyyy-MM-dd HH:mm:ss` |
87
+ | `daterange` | el-date-picker | Array | value-format 默认 `yyyy-MM-dd` |
88
+ | `switch` | el-switch | Boolean | |
89
+ | `radio` | el-radio-group | 任意 | 用 `options` |
90
+ | `checkbox` | el-checkbox-group | Array | 用 `options` |
91
+ | `search` | el-input + 搜索图标 | String | 通用搜索框样式(同 xform vabsearch),见「搜索框」 |
92
+
93
+ ## 下拉数据源(select / radio / checkbox 通用)
94
+
95
+ 三选一:
96
+
97
+ ```js
98
+ // 1. 静态值:数组(或函数,见「联动」)
99
+ { prop: "type", type: "select", options: [{ label: "普通", value: 1 }, "直接字符串也可以"] }
100
+
101
+ // 2. 词汇:/user/common_attribute/listItems,显示 value、取值 sn(与 xform 约定一致)
102
+ { prop: "sex", type: "select", dictCode: "SEX", dictData: { ... } }
103
+
104
+ // 3. 脚本编码:"formCode/scriptCode",先取服务名再请求 /{serviceName}/bd_api/{formCode}/{scriptCode}
105
+ {
106
+ prop: "org", type: "select",
107
+ scriptCode: "demoForm/getOptions",
108
+ scriptData: { ... }, // 请求参数,也支持 (model) => ({...})
109
+ labelField: "name", // 默认 "label"
110
+ valueField: "id", // 默认 "value"
111
+ // 或完全自定义解析:
112
+ // parseOptions: (res) => [{ label, value }],
113
+ }
114
+ ```
115
+
116
+ 远程选项加载中 select 显示 loading。脚本模式选项对象上带 `row`(原始行数据)。
117
+
118
+ ## 搜索框(type: "search")
119
+
120
+ 样式与 xform vabsearch 控件一致:输入框内右侧 `el-icon-search` 图标 + 悬停清除图标。输入框默认禁止手输(值由选择回填),传 `props: { editable: true }` 放开。
121
+
122
+ 点击搜索图标的行为,三选一(优先级从上到下):
123
+
124
+ ```js
125
+ // 方式1:onSearch 完全自定义(自己开弹框、自己回写 model;model 是响应式的)
126
+ {
127
+ prop: "customerName", label: "客户", type: "search",
128
+ onSearch: async (model, field) => { /* ... */ },
129
+ onClear: (model, field) => { model.customerId = null; model.customerName = null; },
130
+ }
131
+
132
+ // 方式2:searchComponent —— 打开自定义 Vue 弹框组件(走 utils/componentDialog)
133
+ {
134
+ prop: "supplierName", label: "供应商", type: "search",
135
+ searchComponent: {
136
+ componentPath: "views/xx/pickerDialog.vue", // openComponentDialog 的全部选项均可透传
137
+ fieldMap: { supplierId: "id", supplierName: "name" },
138
+ },
139
+ }
140
+ // 组件契约:自带弹框(wrapper 默认 false)、确认时 $emit("confirm", rows)、
141
+ // 关闭时 $emit("update:visiable", false)。参考 demo 的 demoPickerDialog.vue
142
+
143
+ // 方式3:searchDialog —— 打开 xform 搜索弹框(searchFormDialog,按 formCode 渲染数据表格)
144
+ {
145
+ prop: "billName", label: "单据", type: "search",
146
+ searchDialog: {
147
+ formCode: "xxx", // 目标表单编码(需已配置搜索弹框)
148
+ multiple: false,
149
+ queryParam: { ... }, // 可选查询参数
150
+ rows: (model) => [...], // 可选回显已选行,支持函数
151
+ fieldMap: { billId: "id", billName: "_tt" },
152
+ // layoutType: "PC", // 默认 PC
153
+ },
154
+ }
155
+ ```
156
+
157
+ 选中回写规则(方式2/3 共用,优先级从上到下):
158
+
159
+ 1. `onConfirm(model, rows, field)` —— 完全自定义
160
+ 2. `fieldMap: { model字段: 行字段名 | (row, rows) => value }` —— 逐字段映射(函数形式可处理多选拼接)
161
+ 3. 默认把首行中与 `prop` 同名(或 `showField` 指定)的字段写回本字段
162
+
163
+ 清除图标触发 `onClear(model, field)`,不传则默认清空本字段;`clearable: false` 隐藏清除。
164
+
165
+ ## 联动
166
+
167
+ 四种机制可组合:
168
+
169
+ ```js
170
+ fields: [
171
+ {
172
+ prop: "province", label: "省份", type: "select", options: [...],
173
+ // 1. onChange:任意字段值变化时触发(含搜索框 fieldMap 回写、render 控件改 model)
174
+ // dialog 上可用:dialog.setFieldOptions(prop, options) 手动设选项
175
+ // dialog.reloadFieldOptions(prop) 按最新 model 重拉词汇/脚本选项
176
+ onChange: (val, model, field, dialog) => { ... },
177
+ },
178
+ {
179
+ prop: "city", label: "城市", type: "select",
180
+ // 2. dependsOn:依赖字段变化时自动清空自身值;
181
+ // 若选项来自 dictCode/scriptCode,会按最新 model 自动重新拉取
182
+ dependsOn: ["province"],
183
+ // 静态级联:options 支持函数,渲染时实时按 model 求值
184
+ options: (model) => cityMap[model.province] || [],
185
+ // 远程级联传参:dictData / scriptData 支持 (model) => ({...})
186
+ },
187
+ // 3. 显隐联动:隐藏时不渲染、不参与必填校验
188
+ { prop: "taxNo", label: "税号", required: true, visible: (model) => model.needInvoice },
189
+ // 4. 禁用联动
190
+ { prop: "reason", label: "原因", disabled: (model) => model.level !== 2 },
191
+ ]
192
+ ```
193
+
194
+ 实现方式为深度监听 model + 快照 diff,链式联动(A 变 → B 清 → B 的 onChange)可用;避免在 onChange 里循环写值。
195
+
196
+ ## xform 整表模式
197
+
198
+ 传 `formCode`(或直接传 `formJson`)即进入整表模式:不再走 `fields` 渲染,而是按 `USER_PREFIX + /formTemplate/getByFormCode` 加载表单模板(与 `views/user/form/vform/render.vue` 同链路),弹框内用 `VFormRender` 渲染完整 xform 表单——业务控件、明细表、脚本事件等设计器能力全部可用。
199
+
200
+ ```js
201
+ const data = await this.$formDialog({
202
+ formCode: "xxx", // 表单模板编码(与 formJson 二选一,formJson 直接传模板 JSON 对象)
203
+ // title: "自定义标题", // 缺省取表单模板名称
204
+ // width: "1000px", // 整表模式默认 1000px
205
+ model: { name: "初始值" }, // 初始数据,结构与 getFormData 一致(也可用 formData 字段名)
206
+ // readonly: true, // 只读:走 xform setReadMode,底部只有"关闭"
207
+ // optionData: {}, // 透传 VFormRender 的 option-data
208
+ // globalDsv: {}, // 透传 VFormRender 的 global-dsv
209
+ onReady: (formRef, dialog) => {
210
+ // 表单挂载后回调,formRef 即 VFormRender 实例:
211
+ // formRef.setFieldValue / getWidgetRef / disableForm(...) 等 API 均可用
212
+ },
213
+ beforeConfirm: (formData) => this.$http({ ... }), // 同字段模式,reject 不关闭
214
+ });
215
+ // data 为 xform getFormData 的完整表单数据(含明细表);取消/关闭仍为 null
216
+ ```
217
+
218
+ 说明:
219
+
220
+ - 确认时走 xform 自身的 `validateForm`(失败自动提示"必填项不能为空"并滚动定位到出错字段),通过后 resolve `getFormData` 的深拷贝。
221
+ - `VFormRender` 为异步组件(独立 chunk),只在首次打开整表模式时加载,不增加主包体积;加载前会自动执行 `loadExtension()` 注册 xform 扩展控件。
222
+ - 弹框规格与 xform 自己的 `dynamicDialogRender` 一致(`dialog-style list-dialog` + `.cont` 容器)。
223
+ - 整表模式下 `fields`/`rules`/联动等字段配置模式的参数不生效——表单结构、校验、联动都在设计器里配置。
224
+
225
+ ## 其他说明
226
+
227
+ - 每次调用创建独立实例,支持叠开多层弹框(如在 `onSearch` 里再开一层 `$formDialog`,见 demo 示例 7/8)。
228
+ - 弹框规格与项目一致:`append-to-body`、禁点遮罩关闭、可拖拽(`v-el-drag-dialog`)、`dialog-style` 样式类。
229
+ - 实例挂在 `window.$vueRoot` 之下,弹框内可正常使用 `$t2`、`$http`、`$getBaseDicts` 等原型能力。
230
+ - 也可不经 Vue 实例调用:`import FormDialog from "@base/components/formDialog/index.js"; FormDialog.open({...})`。
231
+ - 数字框底层为 `baseInputNumber`(`v-limit-number` 指令限制小数位/负号,支持按键、选区替换与粘贴校验)。
232
+
233
+ ## 与既有设施的分工
234
+
235
+ | 场景 | 用什么 |
236
+ |---|---|
237
+ | 轻量配置化表单(增改查小弹框) | `$formDialog`(本组件) |
238
+ | 复杂业务表单弹框(已有/需要专用 .vue) | `openComponentDialog`(`src/utils/componentDialog.js`) |
239
+ | 按 xform 表单模板渲染的完整表单 | xform(`VFormRender` / `searchFormDialog`) |
@@ -0,0 +1,35 @@
1
+ const modules = {};
2
+ import vue from "vue";
3
+ import formDialog from "./index.vue";
4
+
5
+ const FormDialogInstance = vue.extend(formDialog);
6
+
7
+ /**
8
+ * JS 调用打开表单弹框。每次调用创建独立实例,支持叠开多层。
9
+ * 确定(校验通过)resolve 表单数据,取消/关闭 resolve(null),不使用 reject。
10
+ */
11
+ function open(options = {}) {
12
+ const parent
13
+ = typeof window !== "undefined" && window.$vueRoot ? window.$vueRoot : null;
14
+ const instanceOptions = {};
15
+ if (parent) {
16
+ instanceOptions.parent = parent;
17
+ if (parent._i18n) {
18
+ instanceOptions.i18n = parent._i18n;
19
+ }
20
+ }
21
+ const instance = new FormDialogInstance(instanceOptions);
22
+ instance.$mount();
23
+ document.body.appendChild(instance.$el);
24
+ return instance.open(options);
25
+ }
26
+
27
+ // 定义插件对象
28
+ // vue的install方法,用于定义vue插件
29
+ modules.install = function (Vue) {
30
+ // 在Vue的原型上添加实例方法,以全局调用
31
+ Vue.prototype.$formDialog = open;
32
+ };
33
+ modules.open = open;
34
+
35
+ export default modules;
@@ -0,0 +1,724 @@
1
+ <template>
2
+ <el-dialog
3
+ :title="title"
4
+ :visible.sync="showWrap"
5
+ :width="width"
6
+ :close-on-click-modal="false"
7
+ :append-to-body="true"
8
+ :custom-class="xformMode ? 'dialog-style list-dialog' : 'dialog-style'"
9
+ v-el-drag-dialog
10
+ @close="handleClose"
11
+ @closed="handleClosed"
12
+ >
13
+ <!-- xform 整表模式:按 formCode/formJson 渲染已配置的 xform 表单 -->
14
+ <div
15
+ v-if="xformMode"
16
+ class="cont"
17
+ v-loading="xformLoading"
18
+ :style="xformReady ? '' : 'min-height: 120px'"
19
+ >
20
+ <v-form-render
21
+ v-if="xformReady"
22
+ ref="vFormRef"
23
+ :form-json="xformJson"
24
+ :form-data="xformFormData"
25
+ :option-data="xformOptionData"
26
+ :global-dsv="xformGlobalDsv"
27
+ :report-template="xformTemplate"
28
+ :dynamic-creation="true"
29
+ @hook:mounted="handleXformMounted"
30
+ />
31
+ </div>
32
+ <el-form
33
+ v-else
34
+ ref="form"
35
+ :model="model"
36
+ :rules="mergedRules"
37
+ :label-width="labelWidth"
38
+ :disabled="readonly"
39
+ @submit.native.prevent
40
+ >
41
+ <el-row :gutter="16">
42
+ <el-col
43
+ v-for="field in visibleFields"
44
+ :key="field.prop"
45
+ :span="field.span || 24"
46
+ >
47
+ <el-form-item :label="field.label" :prop="field.prop">
48
+ <field-render
49
+ v-if="field.render"
50
+ :field="field"
51
+ :form-model="model"
52
+ />
53
+ <el-input
54
+ v-else-if="!field.type || field.type === 'input'"
55
+ v-model="model[field.prop]"
56
+ v-bind="controlProps(field)"
57
+ />
58
+ <el-input
59
+ v-else-if="field.type === 'textarea'"
60
+ v-model="model[field.prop]"
61
+ v-bind="controlProps(field)"
62
+ />
63
+ <el-input
64
+ v-else-if="field.type === 'search'"
65
+ class="search-input"
66
+ v-model="model[field.prop]"
67
+ v-el-readonly="searchReadonly(field)"
68
+ v-bind="controlProps(field)"
69
+ @clear="handleSearchClear(field)"
70
+ >
71
+ <i
72
+ slot="suffix"
73
+ class="el-input__icon el-icon-search"
74
+ @click="handleSearch(field)"
75
+ ></i>
76
+ </el-input>
77
+ <base-input-number
78
+ v-else-if="field.type === 'number'"
79
+ v-model="model[field.prop]"
80
+ style="width: 100%"
81
+ v-bind="controlProps(field)"
82
+ />
83
+ <el-select
84
+ v-else-if="field.type === 'select'"
85
+ v-model="model[field.prop]"
86
+ style="width: 100%"
87
+ :loading="optionsLoadingMap[field.prop]"
88
+ v-bind="controlProps(field)"
89
+ >
90
+ <el-option
91
+ v-for="opt in getOptions(field)"
92
+ :key="opt.value"
93
+ :label="opt.label"
94
+ :value="opt.value"
95
+ />
96
+ </el-select>
97
+ <el-date-picker
98
+ v-else-if="
99
+ field.type === 'date'
100
+ || field.type === 'datetime'
101
+ || field.type === 'daterange'
102
+ "
103
+ v-model="model[field.prop]"
104
+ style="width: 100%"
105
+ v-bind="controlProps(field)"
106
+ />
107
+ <el-switch
108
+ v-else-if="field.type === 'switch'"
109
+ v-model="model[field.prop]"
110
+ v-bind="controlProps(field)"
111
+ />
112
+ <el-radio-group
113
+ v-else-if="field.type === 'radio'"
114
+ v-model="model[field.prop]"
115
+ v-bind="controlProps(field)"
116
+ >
117
+ <el-radio
118
+ v-for="opt in getOptions(field)"
119
+ :key="opt.value"
120
+ :label="opt.value"
121
+ >{{ opt.label }}</el-radio
122
+ >
123
+ </el-radio-group>
124
+ <el-checkbox-group
125
+ v-else-if="field.type === 'checkbox'"
126
+ v-model="model[field.prop]"
127
+ v-bind="controlProps(field)"
128
+ >
129
+ <el-checkbox
130
+ v-for="opt in getOptions(field)"
131
+ :key="opt.value"
132
+ :label="opt.value"
133
+ >{{ opt.label }}</el-checkbox
134
+ >
135
+ </el-checkbox-group>
136
+ </el-form-item>
137
+ </el-col>
138
+ </el-row>
139
+ </el-form>
140
+ <span slot="footer" class="dialog-footer">
141
+ <template v-if="readonly">
142
+ <el-button type="primary" class="button-sty" @click="showWrap = false"
143
+ ><i class="el-icon-close el-icon"></i
144
+ >{{ $t2("关 闭", "system.button.close2") }}</el-button
145
+ >
146
+ </template>
147
+ <template v-else>
148
+ <el-button
149
+ type="primary"
150
+ plain
151
+ class="button-sty"
152
+ @click="showWrap = false"
153
+ ><i class="el-icon-close el-icon"></i
154
+ >{{ $t2("取 消", "system.button.cancel2") }}</el-button
155
+ >
156
+ <el-button
157
+ type="primary"
158
+ class="button-sty"
159
+ :loading="confirmLoading"
160
+ @click="handleConfirm"
161
+ ><i class="el-icon-check el-icon"></i
162
+ >{{ $t2("确 定", "system.button.confirm2") }}</el-button
163
+ >
164
+ </template>
165
+ </span>
166
+ </el-dialog>
167
+ </template>
168
+
169
+ <script>
170
+ import { openComponentDialog } from "@base/utils/componentDialog";
171
+
172
+ // 自定义渲染逃生口:field.render(h, formModel, field) 返回 VNode
173
+ const FieldRender = {
174
+ functional: true,
175
+ props: {
176
+ field: { type: Object, required: true },
177
+ formModel: { type: Object, required: true },
178
+ },
179
+ render(h, ctx) {
180
+ return ctx.props.field.render(h, ctx.props.formModel, ctx.props.field);
181
+ },
182
+ };
183
+
184
+ export default {
185
+ name: "formDialog",
186
+ components: {
187
+ FieldRender,
188
+ // xform 渲染器按需加载(独立 chunk),加载前先注册 xform 扩展控件
189
+ VFormRender: () =>
190
+ import("@base/components/xform/extension/extension-loader").then(
191
+ (ext) => {
192
+ ext.loadExtension();
193
+ return import("@base/components/xform/form-render/index.vue");
194
+ }
195
+ ),
196
+ },
197
+ data() {
198
+ return {
199
+ title: "",
200
+ width: "600px",
201
+ labelWidth: "100px",
202
+ fields: [],
203
+ model: {},
204
+ rules: {},
205
+ readonly: false,
206
+ showWrap: false,
207
+ confirmLoading: false,
208
+ // 词汇/脚本编码等远程数据源取回的下拉选项,按 field.prop 存放
209
+ remoteOptionsMap: {},
210
+ optionsLoadingMap: {},
211
+ // xform 整表模式(formCode/formJson 二选一触发)
212
+ xformMode: false,
213
+ xformLoading: false,
214
+ xformReady: false,
215
+ xformJson: null,
216
+ xformFormData: {},
217
+ xformOptionData: {},
218
+ xformGlobalDsv: {},
219
+ xformTemplate: null,
220
+ };
221
+ },
222
+ computed: {
223
+ // 显隐联动:visible 为函数时按 model 动态计算,隐藏字段不渲染也不参与校验
224
+ visibleFields() {
225
+ return this.fields.filter((field) =>
226
+ typeof field.visible === "function"
227
+ ? !!field.visible(this.model)
228
+ : field.visible !== false
229
+ );
230
+ },
231
+ mergedRules() {
232
+ const rules = {};
233
+ this.fields.forEach((field) => {
234
+ const list = [];
235
+ if (field.required) {
236
+ list.push({
237
+ required: true,
238
+ message: `${field.label}${this.$t2(
239
+ "不能为空",
240
+ "system.validate.notEmpty"
241
+ )}`,
242
+ trigger: ["blur", "change"],
243
+ });
244
+ }
245
+ if (field.rules) {
246
+ list.push(...[].concat(field.rules));
247
+ }
248
+ if (list.length) {
249
+ rules[field.prop] = list;
250
+ }
251
+ });
252
+ Object.keys(this.rules || {}).forEach((prop) => {
253
+ rules[prop] = (rules[prop] || []).concat(this.rules[prop]);
254
+ });
255
+ return rules;
256
+ },
257
+ },
258
+ watch: {
259
+ // 联动入口:深度监听 model,diff 出变化字段后派发 onChange / dependsOn 级联
260
+ model: {
261
+ deep: true,
262
+ handler() {
263
+ this.handleModelChange();
264
+ },
265
+ },
266
+ },
267
+ methods: {
268
+ open(options = {}) {
269
+ if (options.formCode || options.formJson) {
270
+ return this.openXform(options);
271
+ }
272
+ const { fields = [], model = {} } = options;
273
+ const formModel = { ...model };
274
+ fields.forEach((field) => {
275
+ if (field.prop in formModel) return;
276
+ formModel[field.prop] = this.defaultFieldValue(field);
277
+ });
278
+
279
+ this.title = options.title || "";
280
+ this.width = options.width || "600px";
281
+ this.labelWidth = options.labelWidth || "100px";
282
+ this.fields = fields;
283
+ this.model = formModel;
284
+ this.rules = options.rules || {};
285
+ this.readonly = options.readonly === true;
286
+ this._beforeConfirm = options.beforeConfirm;
287
+ this._settled = false;
288
+ this._prevModel = this.$baseLodash.cloneDeep(formModel);
289
+ this.showWrap = true;
290
+ this.loadRemoteOptions();
291
+
292
+ return new Promise((resolve) => {
293
+ this._resolve = resolve;
294
+ });
295
+ },
296
+ // xform 整表模式:加载模板后由 VFormRender 渲染,确认走 xform 校验并返回表单数据
297
+ openXform(options) {
298
+ this.xformMode = true;
299
+ this.title = options.title || "";
300
+ this.width = options.width || "1000px";
301
+ this.readonly = options.readonly === true;
302
+ this._beforeConfirm = options.beforeConfirm;
303
+ this._onReady = options.onReady;
304
+ this._settled = false;
305
+ this.xformFormData = options.model || options.formData || {};
306
+ this.xformOptionData = options.optionData || {};
307
+ this.xformGlobalDsv = options.globalDsv || {};
308
+ this.showWrap = true;
309
+
310
+ if (options.formJson) {
311
+ this.initXformJson(options.formJson, options.formCode);
312
+ } else {
313
+ this.loadXformTemplate(options.formCode);
314
+ }
315
+ return new Promise((resolve) => {
316
+ this._resolve = resolve;
317
+ });
318
+ },
319
+ // 与 vform/render.vue 同链路:formTemplate/getByFormCode 取模板 JSON
320
+ loadXformTemplate(formCode) {
321
+ this.xformLoading = true;
322
+ this.$http({
323
+ aes: true,
324
+ url: USER_PREFIX + "/formTemplate/getByFormCode",
325
+ method: "post",
326
+ data: { stringOne: formCode },
327
+ isLoading: false,
328
+ success: (res) => {
329
+ this.xformLoading = false;
330
+ const template = res.objx || {};
331
+ const formJson = template.formViewContent
332
+ ? JSON.parse(template.formViewContent)
333
+ : {};
334
+ this.xformTemplate = template;
335
+ if (!this.title) {
336
+ this.title = template.name || template.formName || "";
337
+ }
338
+ this.initXformJson(formJson, formCode);
339
+ },
340
+ });
341
+ },
342
+ initXformJson(formJson, formCode) {
343
+ if (!formJson || !formJson.formConfig) {
344
+ this.$message.error(
345
+ `${this.$t2("表单模板不存在或内容为空", "system.message.formTemplateEmpty")}: ${formCode || ""}`
346
+ );
347
+ return;
348
+ }
349
+ this.xformJson = formJson;
350
+ this.xformReady = true;
351
+ },
352
+ // VFormRender 为异步组件,挂载时机不确定,用 hook:mounted 收口
353
+ handleXformMounted() {
354
+ const formRef = this.$refs.vFormRef;
355
+ if (!formRef) return;
356
+ if (this.readonly) {
357
+ formRef.setReadMode(true);
358
+ }
359
+ if (typeof this._onReady === "function") {
360
+ this._onReady(formRef, this);
361
+ }
362
+ },
363
+ handleXformConfirm() {
364
+ const formRef = this.$refs.vFormRef;
365
+ if (!formRef) return;
366
+ // validateForm 自带失败提示与滚动定位;通过后 getFormData(false) 直取数据
367
+ formRef.validateForm((valid) => {
368
+ if (!valid) return;
369
+ this.confirmWithData(this.$baseLodash.cloneDeep(formRef.getFormData(false)));
370
+ });
371
+ },
372
+ defaultFieldValue(field) {
373
+ if (field.type === "checkbox" || field.type === "daterange") return [];
374
+ if (field.type === "switch") return false;
375
+ return null;
376
+ },
377
+ evalFieldFlag(value) {
378
+ return typeof value === "function" ? !!value(this.model) : value;
379
+ },
380
+ getOptions(field) {
381
+ // 选项来源优先级:远程结果 > options 函数(可依赖 model 做级联)> 静态数组
382
+ const source
383
+ = this.remoteOptionsMap[field.prop]
384
+ || (typeof field.options === "function"
385
+ ? field.options(this.model)
386
+ : field.options)
387
+ || [];
388
+ return source.map((opt) =>
389
+ opt !== null && typeof opt === "object"
390
+ ? opt
391
+ : { label: String(opt), value: opt }
392
+ );
393
+ },
394
+ // 联动派发:对比上次快照,逐字段触发 onChange,并处理 dependsOn 级联
395
+ handleModelChange() {
396
+ const prev = this._prevModel || {};
397
+ const current = this.model;
398
+ const keys = new Set([...Object.keys(current), ...Object.keys(prev)]);
399
+ const changedKeys = [];
400
+ keys.forEach((key) => {
401
+ if (!this.$baseLodash.isEqual(current[key], prev[key])) {
402
+ changedKeys.push(key);
403
+ }
404
+ });
405
+ if (!changedKeys.length) return;
406
+ this._prevModel = this.$baseLodash.cloneDeep(current);
407
+
408
+ changedKeys.forEach((key) => {
409
+ const field = this.fields.find((item) => item.prop === key);
410
+ if (field && typeof field.onChange === "function") {
411
+ field.onChange(current[key], current, field, this);
412
+ }
413
+ // dependsOn:依赖字段变化时清空自身值并刷新远程选项
414
+ this.fields.forEach((item) => {
415
+ if (Array.isArray(item.dependsOn) && item.dependsOn.includes(key)) {
416
+ if (
417
+ !this.$baseLodash.isEqual(
418
+ current[item.prop],
419
+ this.defaultFieldValue(item)
420
+ )
421
+ ) {
422
+ this.$set(this.model, item.prop, this.defaultFieldValue(item));
423
+ }
424
+ this.reloadFieldOptions(item.prop);
425
+ }
426
+ });
427
+ });
428
+ },
429
+ // 供 onChange 回调使用:手动覆盖某字段选项
430
+ setFieldOptions(prop, options) {
431
+ this.$set(this.remoteOptionsMap, prop, options || []);
432
+ },
433
+ // 供 onChange 回调使用:按最新 model 重新拉取词汇/脚本选项
434
+ reloadFieldOptions(prop) {
435
+ const field = this.fields.find((item) => item.prop === prop);
436
+ if (!field) return;
437
+ if (field.dictCode) {
438
+ this.fetchDictOptions(field);
439
+ } else if (field.scriptCode) {
440
+ this.fetchScriptOptions(field);
441
+ }
442
+ },
443
+ loadRemoteOptions() {
444
+ this.remoteOptionsMap = {};
445
+ this.optionsLoadingMap = {};
446
+ this.fields.forEach((field) => {
447
+ if (field.dictCode) {
448
+ this.fetchDictOptions(field);
449
+ } else if (field.scriptCode) {
450
+ this.fetchScriptOptions(field);
451
+ }
452
+ });
453
+ },
454
+ // 词汇:/user/common_attribute/listItems,显示 value、取值 sn(与 xform 约定一致)
455
+ fetchDictOptions(field) {
456
+ this.$set(this.optionsLoadingMap, field.prop, true);
457
+ this.$getBaseDicts({
458
+ code: field.dictCode,
459
+ data:
460
+ typeof field.dictData === "function"
461
+ ? field.dictData(this.model)
462
+ : field.dictData,
463
+ success: ({ dicts }) => {
464
+ this.$set(this.optionsLoadingMap, field.prop, false);
465
+ this.$set(
466
+ this.remoteOptionsMap,
467
+ field.prop,
468
+ dicts.map((item) => ({ label: item.value, value: item.sn }))
469
+ );
470
+ },
471
+ });
472
+ },
473
+ // 脚本编码:"formCode/scriptCode",先取服务名再请求 bd_api 脚本接口
474
+ fetchScriptOptions(field) {
475
+ const parts = String(field.scriptCode)
476
+ .split("/")
477
+ .filter((item) => item);
478
+ if (parts.length < 2) {
479
+ console.warn(
480
+ `[formDialog] scriptCode 需为 "formCode/scriptCode" 格式:${field.scriptCode}`
481
+ );
482
+ return;
483
+ }
484
+ const [formCode, scriptCode] = parts;
485
+ this.$set(this.optionsLoadingMap, field.prop, true);
486
+ this.$http({
487
+ aes: true,
488
+ url: USER_PREFIX + "/formScript/getServiceName",
489
+ method: "post",
490
+ data: { formCode, scriptCode },
491
+ isLoading: false,
492
+ success: (res) => {
493
+ const serviceName = res.objx;
494
+ if (!serviceName) {
495
+ this.$set(this.optionsLoadingMap, field.prop, false);
496
+ this.$baseAlert("服务名不存在");
497
+ return;
498
+ }
499
+ this.$http({
500
+ url: `/${serviceName}/bd_api/${formCode}/${scriptCode}`,
501
+ method: "post",
502
+ data: {
503
+ ...(typeof field.scriptData === "function"
504
+ ? field.scriptData(this.model)
505
+ : field.scriptData),
506
+ },
507
+ isLoading: false,
508
+ success: (res2) => {
509
+ this.$set(this.optionsLoadingMap, field.prop, false);
510
+ let options;
511
+ if (typeof field.parseOptions === "function") {
512
+ options = field.parseOptions(res2) || [];
513
+ } else {
514
+ const objx = res2.objx;
515
+ const rows = Array.isArray(objx)
516
+ ? objx
517
+ : (objx && objx.records) || [];
518
+ const labelField = field.labelField || "label";
519
+ const valueField = field.valueField || "value";
520
+ options = rows.map((row) => ({
521
+ label: row[labelField],
522
+ value: row[valueField],
523
+ row,
524
+ }));
525
+ }
526
+ this.$set(this.remoteOptionsMap, field.prop, options);
527
+ },
528
+ });
529
+ },
530
+ });
531
+ },
532
+ // 搜索框默认禁止手输(值由选择回填),传 props: { editable: true } 可放开
533
+ searchReadonly(field) {
534
+ return !(field.props && field.props.editable === true);
535
+ },
536
+ handleSearch(field) {
537
+ if (this.readonly || this.evalFieldFlag(field.disabled)) return;
538
+ if (typeof field.onSearch === "function") {
539
+ field.onSearch(this.model, field);
540
+ } else if (field.searchComponent) {
541
+ this.openSearchComponent(field);
542
+ } else if (field.searchDialog) {
543
+ this.openXformSearchDialog(field);
544
+ }
545
+ },
546
+ // 方式2:按路径打开自定义 Vue 弹框组件,组件 $emit("confirm", rows) 回写
547
+ openSearchComponent(field) {
548
+ const cfg = field.searchComponent;
549
+ const events = cfg.events || {};
550
+ openComponentDialog(
551
+ {
552
+ wrapper: false,
553
+ ...cfg,
554
+ events: {
555
+ ...events,
556
+ confirm: (...args) => {
557
+ if (typeof events.confirm === "function") {
558
+ events.confirm(...args);
559
+ }
560
+ this.applySearchResult(field, args[0], cfg);
561
+ },
562
+ },
563
+ },
564
+ this
565
+ );
566
+ },
567
+ // 方式3:打开 xform 搜索弹框(searchFormDialog,按 formCode 渲染目标表单的数据表格)
568
+ openXformSearchDialog(field) {
569
+ const cfg = field.searchDialog;
570
+ const { layoutType, fieldMap, onConfirm, showField, ...option } = cfg;
571
+ openComponentDialog(
572
+ {
573
+ componentPath:
574
+ "components/xform/form-designer/form-widget/dialog/searchFormDialog.vue",
575
+ wrapper: false,
576
+ // searchFormDialog 在 xform 环境外运行需要注入 getFormConfig
577
+ provide: {
578
+ getFormConfig: () => ({ layoutType: layoutType || "PC" }),
579
+ },
580
+ props: {
581
+ option: {
582
+ multiple: false,
583
+ ...option,
584
+ rows:
585
+ typeof cfg.rows === "function" ? cfg.rows(this.model) : cfg.rows,
586
+ confirm: (rows, formRef, dialogVm) => {
587
+ if (typeof cfg.confirm === "function") {
588
+ return cfg.confirm(rows, formRef, dialogVm);
589
+ }
590
+ this.applySearchResult(field, rows, cfg);
591
+ },
592
+ },
593
+ },
594
+ },
595
+ this
596
+ );
597
+ },
598
+ // 选中结果回写 model:onConfirm 完全自定义 > fieldMap 逐字段映射 > 默认回写本字段
599
+ applySearchResult(field, rows, cfg) {
600
+ const list = Array.isArray(rows) ? rows : rows ? [rows] : [];
601
+ if (typeof cfg.onConfirm === "function") {
602
+ cfg.onConfirm(this.model, list, field);
603
+ return;
604
+ }
605
+ if (!list.length) return;
606
+ const row = list[0];
607
+ if (cfg.fieldMap) {
608
+ Object.keys(cfg.fieldMap).forEach((modelKey) => {
609
+ const source = cfg.fieldMap[modelKey];
610
+ this.$set(
611
+ this.model,
612
+ modelKey,
613
+ typeof source === "function" ? source(row, list) : row[source]
614
+ );
615
+ });
616
+ } else {
617
+ this.$set(this.model, field.prop, row[cfg.showField || field.prop]);
618
+ }
619
+ },
620
+ handleSearchClear(field) {
621
+ if (this.readonly || this.evalFieldFlag(field.disabled)) return;
622
+ if (typeof field.onClear === "function") {
623
+ field.onClear(this.model, field);
624
+ } else {
625
+ this.model[field.prop] = null;
626
+ }
627
+ },
628
+ controlProps(field) {
629
+ const defaults = {
630
+ placeholder: field.placeholder,
631
+ disabled: this.evalFieldFlag(field.disabled),
632
+ clearable: true,
633
+ };
634
+ switch (field.type) {
635
+ case "textarea":
636
+ Object.assign(defaults, { type: "textarea", rows: 3 });
637
+ break;
638
+ case "search":
639
+ // 搜索框与 xform vabsearch 同款样式:suffix 搜索图标 + clearable;
640
+ // 只读用 v-el-readonly(原生 readonly,不影响清除图标),不走 readonly prop
641
+ defaults.clearable = field.clearable !== false;
642
+ break;
643
+ case "number":
644
+ // baseInputNumber:scale 控制小数位(v-limit-number),negative 放开负号输入
645
+ Object.assign(defaults, {
646
+ scale: field.scale != null ? field.scale : 2,
647
+ negative: field.negative,
648
+ controls: false,
649
+ });
650
+ break;
651
+ case "date":
652
+ Object.assign(defaults, { type: "date", valueFormat: "yyyy-MM-dd" });
653
+ break;
654
+ case "datetime":
655
+ Object.assign(defaults, {
656
+ type: "datetime",
657
+ valueFormat: "yyyy-MM-dd HH:mm:ss",
658
+ });
659
+ break;
660
+ case "daterange":
661
+ Object.assign(defaults, {
662
+ type: "daterange",
663
+ valueFormat: "yyyy-MM-dd",
664
+ });
665
+ break;
666
+ }
667
+ const merged = { ...defaults, ...field.props };
668
+ if (field.type === "search") {
669
+ // editable 只用于 searchReadonly 判断,不透传给 el-input
670
+ delete merged.editable;
671
+ }
672
+ return merged;
673
+ },
674
+ settle(value) {
675
+ if (this._settled) return;
676
+ this._settled = true;
677
+ if (this._resolve) {
678
+ this._resolve(value);
679
+ }
680
+ },
681
+ handleConfirm() {
682
+ if (this.xformMode) {
683
+ this.handleXformConfirm();
684
+ return;
685
+ }
686
+ this.$refs.form.validate((valid) => {
687
+ if (!valid) return;
688
+ this.confirmWithData({ ...this.model });
689
+ });
690
+ },
691
+ confirmWithData(data) {
692
+ if (typeof this._beforeConfirm === "function") {
693
+ this.confirmLoading = true;
694
+ Promise.resolve()
695
+ .then(() => this._beforeConfirm(data))
696
+ .then(() => {
697
+ this.confirmLoading = false;
698
+ this.settle(data);
699
+ this.showWrap = false;
700
+ })
701
+ .catch(() => {
702
+ // beforeConfirm 失败时不关闭弹框,由调用方自行提示错误
703
+ this.confirmLoading = false;
704
+ });
705
+ } else {
706
+ this.settle(data);
707
+ this.showWrap = false;
708
+ }
709
+ },
710
+ handleClose() {
711
+ // 取消/右上角关闭:resolve(null);确认路径已先 settle,此处不生效
712
+ this.settle(null);
713
+ },
714
+ handleClosed() {
715
+ this.$destroy();
716
+ if (this.$el && this.$el.parentNode) {
717
+ this.$el.parentNode.removeChild(this.$el);
718
+ }
719
+ },
720
+ },
721
+ };
722
+ </script>
723
+
724
+ <style></style>