@xclqmc/base-form 1.0.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/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@xclqmc/base-form",
3
+ "version": "1.0.0",
4
+ "description": "声明式表单组件 BaseForm(弹窗/抽屉/内嵌三态)及配套基础组件(BasePopup/BaseDrawer/BaseRadio/BaseSelect/BaseEditor)—— 纯源码分发的 Vue3 + ElementPlus 表单组件库",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "sideEffects": [
8
+ "**/*.css",
9
+ "**/*.scss"
10
+ ],
11
+ "files": [
12
+ "src"
13
+ ],
14
+ "main": "./src/index.ts",
15
+ "module": "./src/index.ts",
16
+ "types": "./src/index.ts",
17
+ "exports": {
18
+ ".": "./src/index.ts",
19
+ "./src/*": "./src/*"
20
+ },
21
+ "scripts": {
22
+ "dev": "vite",
23
+ "build": "vite build"
24
+ },
25
+ "peerDependencies": {
26
+ "@element-plus/icons-vue": ">=2.0.0",
27
+ "@wangeditor/editor": ">=5.0.0",
28
+ "@wangeditor/editor-for-vue": ">=5.0.0",
29
+ "element-plus": "^2.4.0",
30
+ "vue": "^3.3.0"
31
+ },
32
+ "devDependencies": {
33
+ "@element-plus/icons-vue": "^2.3.1",
34
+ "@vitejs/plugin-vue": "^4.0.0",
35
+ "@wangeditor/editor": "5.1.23",
36
+ "@wangeditor/editor-for-vue": "5.1.12",
37
+ "element-plus": "^2.8.8",
38
+ "sass": "^1.105.0",
39
+ "vite": "^4.3.3",
40
+ "vue": "^3.5.13"
41
+ },
42
+ "keywords": [
43
+ "vue",
44
+ "vue3",
45
+ "element-plus",
46
+ "form",
47
+ "base-form",
48
+ "lowcode"
49
+ ]
50
+ }
@@ -0,0 +1,440 @@
1
+ <template>
2
+ <!-- 弹窗形态:mode='dialog'(默认)时用 BasePopup 包裹表单 -->
3
+ <BasePopup
4
+ v-if="isPopup"
5
+ :title="cptTitle"
6
+ v-model:show="visible"
7
+ @submit="onSubmit"
8
+ :loading="_loading"
9
+ :width="cptDialogWidth"
10
+ :height="cptDialogHeight"
11
+ >
12
+ <FormContent
13
+ ref="formContentRef"
14
+ :options="mergedOptions"
15
+ :form="formData"
16
+ :rules="cptRules"
17
+ :option-lists="optionLists"
18
+ >
19
+ <template v-for="(_, name) in $slots" :key="name" #[name]="slotProps">
20
+ <slot :name="name" v-bind="slotProps || {}"></slot>
21
+ </template>
22
+ </FormContent>
23
+ </BasePopup>
24
+
25
+ <!-- 抽屉形态:mode='drawer' 时用 BaseDrawer(右侧滑出)包裹表单;width 复用为抽屉宽度 -->
26
+ <BaseDrawer
27
+ v-else-if="isDrawer"
28
+ v-model:show="visible"
29
+ :title="cptTitle"
30
+ :width="cptDialogWidth"
31
+ :show-footer="true"
32
+ >
33
+ <div v-loading="_loading">
34
+ <FormContent
35
+ ref="formContentRef"
36
+ :options="mergedOptions"
37
+ :form="formData"
38
+ :rules="cptRules"
39
+ :option-lists="optionLists"
40
+ >
41
+ <template v-for="(_, name) in $slots" :key="name" #[name]="slotProps">
42
+ <slot :name="name" v-bind="slotProps || {}"></slot>
43
+ </template>
44
+ </FormContent>
45
+ </div>
46
+ <template #footer>
47
+ <el-button @click="visible = false">取消</el-button>
48
+ <el-button type="primary" :disabled="_loading" @click="onSubmit"
49
+ >确认</el-button
50
+ >
51
+ </template>
52
+ </BaseDrawer>
53
+
54
+ <!-- 内嵌模式:表单直接渲染在父组件中 -->
55
+ <FormContent
56
+ v-else
57
+ ref="formContentRef"
58
+ :options="mergedOptions"
59
+ :form="formData"
60
+ :rules="cptRules"
61
+ :option-lists="optionLists"
62
+ >
63
+ <template v-for="(_, name) in $slots" :key="name" #[name]="slotProps">
64
+ <slot :name="name" v-bind="slotProps || {}"></slot>
65
+ </template>
66
+ </FormContent>
67
+
68
+ <!-- 子组件宿主:注册到组件内,通过 getComponentRef(ref) 获取实例并调用其方法(如关联弹窗) -->
69
+ <div v-if="!isEmpty(mergedOptions.components)">
70
+ <div v-for="(item, index) in mergedOptions.components" :key="index">
71
+ <component
72
+ :is="item.component"
73
+ :ref="(el: any) => setComponentRef(el, item.ref)"
74
+ @refresh="emit('refresh')"
75
+ v-bind="$attrs"
76
+ ></component>
77
+ </div>
78
+ </div>
79
+ </template>
80
+
81
+ <script setup lang="ts">
82
+ import { ref, computed, watch, reactive, nextTick, provide } from "vue";
83
+ import { useMessage, useProps, useComponent } from "../../hooks";
84
+ import { defaultFormOptions } from "./types";
85
+ import { buildRules } from "./rules";
86
+ import type { BaseFormOptions } from "./types";
87
+ import FormContent from "./FormContent.vue";
88
+ import BasePopup from "../base/BasePopup.vue";
89
+ import BaseDrawer from "../base/BaseDrawer.vue";
90
+
91
+ // 判空:undefined / null / 空串 / 空数组 / 空对象视为空(布尔 false、数字 0 视为有值)
92
+ const isEmpty = (v: any) =>
93
+ v === undefined ||
94
+ v === null ||
95
+ v === "" ||
96
+ (Array.isArray(v) && v.length === 0) ||
97
+ (typeof v === "object" && Object.keys(v).length === 0);
98
+
99
+ const emit = defineEmits(["refresh", "update:form", "update:loading", "close"]);
100
+
101
+ //================================================================================================数据
102
+ // 子组件别名引用管理(与 FormTable 的 useComponent 机制一致)
103
+ const { setComponentRef, getComponentByAlias } = useComponent();
104
+
105
+ const props = withDefaults(
106
+ defineProps<{
107
+ options?: BaseFormOptions;
108
+ loading?: boolean;
109
+ /** 外部表单数据:配合 v-model:form 使用,传入后表单以外部数据为数据源 */
110
+ form?: Record<string, any>;
111
+ }>(),
112
+ {
113
+ options: () => ({}),
114
+ loading: false,
115
+ },
116
+ );
117
+ const { _options } = useProps(props, emit, ["options"]);
118
+
119
+ // loading 为组件内部真实状态:open/submit 内部可直接置位;同时与父组件 v-model:loading 双向同步
120
+ // (原 useProps 生成的 _loading 只是 props.loading 的镜像,内部赋值只会 emit 而自身不存储,导致无效果)
121
+ const internalLoading = ref(props.loading || false);
122
+ const _loading = computed({
123
+ get: () => internalLoading.value,
124
+ set: (val: boolean) => {
125
+ internalLoading.value = val;
126
+ emit("update:loading", val);
127
+ },
128
+ });
129
+ // 父组件通过 prop 强制控制 loading 时,同步进内部状态
130
+ watch(
131
+ () => props.loading,
132
+ (val) => {
133
+ internalLoading.value = val;
134
+ },
135
+ );
136
+
137
+ // 表单数据:传入 v-model:form 时以外部数据为准(受控模式),否则内部自持(非受控模式)
138
+ // 输入框改的是对象字段(引用共享,天然同步);整体替换(回填/重置)走 set 并 emit 通知父组件
139
+ const internalForm = ref<Record<string, any>>({});
140
+ const formData = computed<Record<string, any>>({
141
+ get: () => (props.form !== undefined ? props.form : internalForm.value),
142
+ set: (val) => {
143
+ internalForm.value = val;
144
+ emit("update:form", val);
145
+ },
146
+ });
147
+
148
+ // 字段间可继承的展示类属性:某项缺失时取 fields 第一项的同名属性(占位/栅格/下边距/显隐等渲染外观)。
149
+ // 注意:format(选项来源)、params(组件入参)属字段私有配置,仅当「当前字段类型与首项一致」时才继承,
150
+ // 避免 select 的 options/params 泄漏到其它类型字段(如 input 被误绑 value="id")
151
+ const INHERITABLE_FIELD_KEYS = [
152
+ "type",
153
+ "placeholder",
154
+ "format",
155
+ "span",
156
+ "marginBottom",
157
+ "show",
158
+ "params",
159
+ ];
160
+
161
+ // 合并默认值:用户传入的属性覆盖默认值,未传入的用默认值填充
162
+ const mergedOptions = computed<any>(() => {
163
+ const userOpts = _options.value || {};
164
+ const fields = userOpts.fields ?? defaultFormOptions.fields;
165
+ // 字段属性缺失继承第一项;format/params 仅在类型一致时继承,防止跨类型泄漏
166
+ const base = fields[0] || {};
167
+ const normalizedFields = fields.map((item: any, index: number) => {
168
+ if (index === 0) return item;
169
+ const filled = { ...item };
170
+ INHERITABLE_FIELD_KEYS.forEach((key) => {
171
+ // format/params 跨类型继承无意义且危险:select 的 options 配置会泄漏到 input 等其它类型
172
+ if ((key === "format" || key === "params") && item.type !== base.type)
173
+ return;
174
+ if (isEmpty(filled[key]) && !isEmpty(base[key])) {
175
+ filled[key] = base[key];
176
+ }
177
+ });
178
+ return filled;
179
+ });
180
+ return {
181
+ ...userOpts,
182
+ config: { ...defaultFormOptions.config, ...userOpts.config },
183
+ fields: normalizedFields,
184
+ api: { ...defaultFormOptions.api, ...userOpts.api },
185
+ event: { ...userOpts.event },
186
+ };
187
+ });
188
+
189
+ const formContentRef = ref();
190
+ // 弹窗显隐(dialog 模式)
191
+ const visible = ref(false);
192
+
193
+ // 弹窗关闭(X / 取消 / ESC / 提交成功)统一发出 close 事件,便于父组件在关闭时做刷新等动作
194
+ watch(visible, (val) => {
195
+ if (!val) emit("close");
196
+ });
197
+
198
+ // 存储每个选项类字段(radio / select)的选项列表(支持动态函数获取)
199
+ const optionLists = reactive<Record<string, any>>({});
200
+
201
+ // 向字段控件提供字典拉取函数:控件 format 为字符串(字典 code)时自行调用获取选项,
202
+ // 读取实时 api.dict(宿主可换接口);单独使用控件(无 BaseForm)时未注入,字符串 format 解析为空
203
+ provide("baseFormDict", (code: string) =>
204
+ mergedOptions.value?.api?.dict?.(code),
205
+ );
206
+
207
+ //================================================================================================计算函数
208
+ // 显示形态(统一三态,只认 config.mode):dialog=弹窗 / drawer=抽屉 / form=原始表单(内嵌)
209
+ const cptMode = computed<"dialog" | "drawer" | "form">(() => {
210
+ const cfg = mergedOptions.value?.config?.mode;
211
+ if (cfg === "dialog" || cfg === "drawer" || cfg === "form") return cfg;
212
+ return "form";
213
+ });
214
+
215
+ // 是否包裹形态(弹窗或抽屉,需要用 visible 控制显隐);form 形态为内嵌、无包裹
216
+ const isWrapped = computed(
217
+ () => cptMode.value === "dialog" || cptMode.value === "drawer",
218
+ );
219
+
220
+ // 渲染分支:居中弹窗(BasePopup)/ 侧边抽屉(BaseDrawer)/ 内嵌表单
221
+ const isPopup = computed(() => cptMode.value === "dialog");
222
+ const isDrawer = computed(() => cptMode.value === "drawer");
223
+
224
+ // 弹窗标题:
225
+ // 1) config.title 为函数 → 入参为表单值,返回字符串作为标题
226
+ // 2) 为字符串 → 直接使用
227
+ // 3) 为空 → 按主键(config.id)判断:存在值=编辑(isEdit),无值=新增
228
+ const cptTitle = computed(() => {
229
+ const raw = mergedOptions.value?.config?.title;
230
+ if (typeof raw === "function") return raw(formData.value);
231
+ if (raw) return raw;
232
+ return isEdit.value ? "编辑" : "新增";
233
+ });
234
+
235
+ // 弹窗宽度(mode 为 dialog/drawer 时生效):config.width 优先,默认 600px
236
+ const cptDialogWidth = computed(
237
+ () => mergedOptions.value?.config?.width || "600px",
238
+ );
239
+
240
+ // 弹窗高度(dialog 模式生效):config.height,默认 null(不限制)
241
+ const cptDialogHeight = computed(
242
+ () => mergedOptions.value?.config?.height ?? null,
243
+ );
244
+
245
+ // 当前是编辑还是新增(用于标题)
246
+ const isEdit = ref(false);
247
+
248
+ // 校验规则:把 fields 上的声明式校验(必填/手机号/身份证/邮箱/长度/数值/自定义)翻译成 el-form 规则
249
+ const cptRules = computed(() =>
250
+ buildRules(mergedOptions.value?.fields || [], () => formData.value),
251
+ );
252
+
253
+ // 解析单个选项类字段(radio / select)的选项来源 format,结果写入 optionLists
254
+ // 判断顺序:为空(undefined/null/'')或字符串(字典 code,由控件经注入的 api.dict 自取)跳过 →
255
+ // 数组直接用 → Promise 直接等 → 函数(含异步函数,入参为表单值)先调用再等结果;结果仅接受数组,其余视为无数据置 []
256
+ const refreshFieldOptions = async (item: any) => {
257
+ if (item.type !== "radio" && item.type !== "select") return;
258
+ const fmt = item.format;
259
+ if (
260
+ fmt === undefined ||
261
+ fmt === null ||
262
+ fmt === "" ||
263
+ typeof fmt === "string"
264
+ )
265
+ return;
266
+ try {
267
+ let res: any = fmt;
268
+ if (typeof fmt === "function") {
269
+ res = await fmt(formData.value);
270
+ } else if (fmt instanceof Promise) {
271
+ res = await fmt;
272
+ }
273
+ optionLists[item.value] = Array.isArray(res) ? res : [];
274
+ } catch (err) {
275
+ console.error(`获取 ${item.value} 选项失败:`, err);
276
+ optionLists[item.value] = [];
277
+ }
278
+ };
279
+
280
+ // 初次加载选项列表(options 字段变化时重新加载)
281
+ watch(
282
+ () => mergedOptions.value?.fields,
283
+ (fields) => {
284
+ (fields || []).forEach((item: any) => refreshFieldOptions(item));
285
+ },
286
+ { immediate: true },
287
+ );
288
+
289
+ // 联动:被 watch 的字段变化时,重新获取依赖它的字段选项,并触发 onWatch 回调
290
+ // 注意:deep watch 下字段原地修改时 newVal/oldVal 是同一引用,拿不到旧值,
291
+ // 故自持 prevForm 快照做 changedKey 对比(整体替换 formData 引用的场景同样适用)
292
+ let prevForm: Record<string, any> | null = null;
293
+ watch(
294
+ () => formData.value,
295
+ (val) => {
296
+ const fields = mergedOptions.value?.fields || [];
297
+ const prev = prevForm;
298
+ fields.forEach((item: any) => {
299
+ if (!item.watch || !prev) return;
300
+ const deps = Array.isArray(item.watch) ? item.watch : [item.watch];
301
+ const changedKey = deps.find((d: string) => val[d] !== prev[d]);
302
+ if (changedKey) {
303
+ refreshFieldOptions(item);
304
+ if (typeof item.onWatch === "function")
305
+ item.onWatch(formData.value, changedKey);
306
+ }
307
+ });
308
+ // 快照在回调末尾更新:onWatch 若改动其它字段,已包含在本次快照里,下轮不会重复触发
309
+ prevForm = { ...val };
310
+ },
311
+ { deep: true, immediate: true },
312
+ );
313
+
314
+ //================================================================================================方法
315
+ // 按字段配置生成默认值表单(?? 保留 0 / false 等合法默认值)
316
+ const buildDefaultForm = () => {
317
+ const result: Record<string, any> = {};
318
+ mergedOptions.value.fields.forEach((item: any) => {
319
+ result[item.value] = item.defaultValue ?? null;
320
+ });
321
+ return result;
322
+ };
323
+
324
+ // 打开弹窗/抽屉并载入数据(包裹形态专用,等价于旧版 AppForm 的 openDialog)
325
+ const openDialog = async (id?: string) => {
326
+ isEdit.value = !isEmpty(id);
327
+ if (isWrapped.value) {
328
+ visible.value = true;
329
+ await nextTick();
330
+ }
331
+ // 按 config.id 包裹主键,使非 id 主键(如 roleId)也能走正常 api.get 详情分支
332
+ await open({ [mergedOptions.value?.config?.id ?? "id"]: id });
333
+ };
334
+
335
+ // 载入表单数据:row 中 id 字段有值为编辑(请求详情),否则按默认值新增
336
+ const open = async (row: any) => {
337
+ try {
338
+ _loading.value = true;
339
+ formData.value = buildDefaultForm();
340
+ await nextTick();
341
+ if (
342
+ !isEmpty(mergedOptions.value?.event?.open) &&
343
+ mergedOptions.value?.event?.open instanceof Function
344
+ ) {
345
+ // open 钩子返回值作为表单值回填(覆盖默认空表单),便于扩展/非常规详情加载
346
+ const res = await mergedOptions.value.event.open(row);
347
+ if (!isEmpty(res)) {
348
+ formData.value = { ...buildDefaultForm(), ...res };
349
+ }
350
+ return;
351
+ }
352
+ if (isEmpty(row[mergedOptions.value?.config?.id])) {
353
+ return;
354
+ }
355
+
356
+ // 请求详情并回填:api.get 可返回 axios 响应(含 data)或直接返回数据本体
357
+ const res = await mergedOptions.value.api?.get(
358
+ row[mergedOptions.value?.config?.id],
359
+ );
360
+ const data = res?.data ?? res;
361
+
362
+ // 先在本地构建完整表单再一次性赋值:受控模式下 set 触发 emit 后 props.form 到下个渲染周期才刷新,
363
+ // 若赋值后再逐字段改 formData.value,会写进旧引用导致回填丢失
364
+ const next = buildDefaultForm();
365
+ mergedOptions.value.fields.forEach((item: any) => {
366
+ const val = data?.[item.value];
367
+ next[item.value] = isEmpty(val) ? next[item.value] : val;
368
+ });
369
+ // 接口返回但未在 fields 中配置的隐藏字段,原样保留,随表单一并提交
370
+ Object.keys(data ?? {}).forEach((key) => {
371
+ if (!(key in next)) next[key] = data[key];
372
+ });
373
+ formData.value = next;
374
+ } finally {
375
+ _loading.value = false;
376
+ // 数据就绪后清空校验状态:新增字段为空、或上次提交失败/编辑回填残留的红字,都不应在打开时显示
377
+ // 校验时机交给用户操作(blur/change)与提交时 submit 内显式 validate
378
+ await nextTick();
379
+ formContentRef.value?.formRef?.clearValidate();
380
+ }
381
+ };
382
+
383
+ // 提交表单(弹窗确认按钮和父组件主动调用共用)
384
+ const submit = async () => {
385
+ const formRef = formContentRef.value?.formRef;
386
+ await formRef.validate();
387
+ try {
388
+ _loading.value = true;
389
+ // submit 钩子:提供后覆盖内置提交逻辑(自行调 add/put、自定义提示),BaseForm 仍负责关闭弹窗与刷新
390
+ if (
391
+ !isEmpty(mergedOptions.value?.event?.submit) &&
392
+ mergedOptions.value?.event?.submit instanceof Function
393
+ ) {
394
+ await mergedOptions.value.event.submit(formData.value);
395
+ emit("refresh");
396
+ return;
397
+ }
398
+ const id = formData.value[mergedOptions.value?.config?.id];
399
+ if (id) {
400
+ await mergedOptions.value.api?.put(formData.value);
401
+ useMessage().success("修改成功");
402
+ } else {
403
+ await mergedOptions.value.api?.add(formData.value);
404
+ useMessage().success("添加成功");
405
+ }
406
+ emit("refresh");
407
+ } finally {
408
+ _loading.value = false;
409
+ }
410
+ };
411
+
412
+ // 弹窗/抽屉确认按钮:提交成功后关闭(refresh 由 submit 发出,只发一次)
413
+ const onSubmit = async () => {
414
+ await submit();
415
+ if (isWrapped.value) {
416
+ visible.value = false;
417
+ }
418
+ };
419
+
420
+ // 获取表单数据(深拷贝,避免外部误改内部状态)
421
+ const getForm = () => JSON.parse(JSON.stringify(formData.value));
422
+
423
+ // 合并设置表单数据
424
+ const setForm = (data: Record<string, any>) => {
425
+ formData.value = { ...formData.value, ...data };
426
+ };
427
+
428
+ //================================================================================================暴露变量
429
+ // 对外暴露:open(即打开弹窗,等价于旧 openDialog)、submit、表单读写、子组件获取
430
+ // 注:内部 open(row) 仅用于详情加载,不对外暴露
431
+ // openDialog 作为向后兼容别名保留(旧页面/旧 Form 仍可能调用 .openDialog)
432
+ defineExpose({
433
+ open: openDialog,
434
+ openDialog,
435
+ submit,
436
+ getForm,
437
+ setForm,
438
+ getComponentRef: getComponentByAlias,
439
+ });
440
+ </script>
@@ -0,0 +1,179 @@
1
+ <template>
2
+ <el-form
3
+ ref="formRef"
4
+ :model="form"
5
+ :rules="rules"
6
+ :label-width="options?.config?.labelWidth"
7
+ :validate-on-rule-change="false"
8
+ >
9
+ <el-row>
10
+ <template v-for="item in options?.fields" :key="item.value">
11
+ <slot v-if="getShow(item)" :name="item.value" :item="item">
12
+ <el-col
13
+ :span="item.span || options?.config?.span"
14
+ :style="getColStyle(item)"
15
+ >
16
+ <!-- 表单项壳:label(省略号 + 溢出悬浮提示)只写一处,内部按 type 切换控件 -->
17
+ <el-form-item :prop="item.value" :label-width="item.labelWidth">
18
+ <template #label>
19
+ <el-tooltip
20
+ :content="item.label"
21
+ :disabled="!labelTip[item.value]"
22
+ placement="top"
23
+ >
24
+ <span class="field-label" v-label-tip="item.value">{{
25
+ item.label
26
+ }}</span>
27
+ </el-tooltip>
28
+ </template>
29
+
30
+ <!-- 控件:按 type 解析(内置 input/radio/select/editor,或经 registerField 注入的自定义组件;注入与内置重复时覆盖内置)。
31
+ v-model 双向绑定字段值,placeholder 统一透传,disabled 及类型特有入参(input 的 type / radio·select 的选项 format / editor 的 disable)经 getControlProps 下发 -->
32
+ <component
33
+ v-if="resolveFieldComponent(item.type)"
34
+ :is="resolveFieldComponent(item.type)"
35
+ v-model="form[item.value]"
36
+ :placeholder="item.placeholder"
37
+ v-bind="getControlProps(item)"
38
+ @change="(val: any) => onFieldChange(item, val)"
39
+ @click="(e: any) => onFieldClick(item, e)"
40
+ />
41
+ </el-form-item>
42
+ </el-col>
43
+ </slot>
44
+ </template>
45
+ </el-row>
46
+ </el-form>
47
+ </template>
48
+
49
+ <script setup lang="ts">
50
+ // BaseForm 内部的表单渲染:弹窗模式与内嵌模式共用,避免字段模板重复
51
+ import { ref, computed, reactive, nextTick } from "vue";
52
+ import type { Directive } from "vue";
53
+ import { resolveFieldComponent } from "../base/registry";
54
+
55
+ //=======================数据与对外暴露
56
+ const props = defineProps<{
57
+ options: any;
58
+ form: Record<string, any>;
59
+ rules?: Record<string, any>;
60
+ optionLists?: Record<string, any>;
61
+ }>();
62
+
63
+ // 表单实例:提交时由 BaseForm 调 validate / resetFields
64
+ const formRef = ref();
65
+ defineExpose({ formRef });
66
+
67
+ //=======================标签溢出提示(label 省略号截断,悬浮显示完整内容)
68
+ // 溢出标记:key 为字段 value,超出 label-width 时为 true,才启用 tooltip
69
+ const labelTip = reactive<Record<string, boolean>>({});
70
+ // 溢出检测指令:mounted/updated 时在 nextTick 里测量实际渲染宽度
71
+ const vLabelTip: Directive<HTMLElement, string> = {
72
+ mounted(el, binding) {
73
+ measureLabel(el, binding.value);
74
+ },
75
+ updated(el, binding) {
76
+ measureLabel(el, binding.value);
77
+ },
78
+ };
79
+ function measureLabel(el: HTMLElement, key: string) {
80
+ nextTick(() => {
81
+ labelTip[key] = el.scrollWidth > el.clientWidth + 1;
82
+ });
83
+ }
84
+
85
+ //=======================字段显隐与布局
86
+ // 字段是否显示:支持布尔或 (formData)=>boolean,缺省 true(false 时移除 DOM、回收空间)
87
+ const getShow = (item: any) => {
88
+ if (typeof item.show === "function") return item.show(props.form);
89
+ return item.show !== false;
90
+ };
91
+
92
+ // 字段是否隐藏(保留布局空间):支持布尔或 (formData)=>boolean,缺省 false
93
+ const getHidden = (item: any) => {
94
+ if (typeof item.hidden === "function") return item.hidden(props.form);
95
+ return !!item.hidden;
96
+ };
97
+
98
+ // 可见字段列表(show 缺省视为显示;hidden 不影响可见性,仅控制不可见)
99
+ const visibleFields = computed(() =>
100
+ (props.options?.fields || []).filter((item: any) => getShow(item)),
101
+ );
102
+
103
+ // 按行跨度换算每行列数:span=12 → 2 列,span=8 → 3 列
104
+ const columnsPerRow = computed(() => {
105
+ const span = props.options?.config?.span || 12;
106
+ return Math.max(1, Math.round(24 / span));
107
+ });
108
+
109
+ // 字段下边距:最后一行(最后 N 个可见字段,N = 每行列数)去掉,其余按配置走
110
+ const getMarginBottom = (item: any) => {
111
+ if (item.marginBottom) return item.marginBottom;
112
+ const visible = visibleFields.value;
113
+ const index = visible.lastIndexOf(item);
114
+ if (index !== -1 && index >= visible.length - columnsPerRow.value)
115
+ return null;
116
+ return props.options?.config?.marginBottom || "15px";
117
+ };
118
+
119
+ // el-col 样式:合并下边距 + 隐藏(visibility:hidden 保留空间)
120
+ const getColStyle = (item: any) => {
121
+ const style: Record<string, any> = { marginBottom: getMarginBottom(item) };
122
+ if (getHidden(item)) style.visibility = "hidden";
123
+ return style;
124
+ };
125
+
126
+ //=======================控件入参与事件回调
127
+ // 字段禁用状态:布尔直接取;函数为 (formData) => boolean,缺省 false(不禁用)
128
+ const getDisabled = (item: any) => {
129
+ if (typeof item.disabled === "function")
130
+ return item.disabled(props.form) ?? false;
131
+ return item.disabled ?? false;
132
+ };
133
+
134
+ // 字段级 params:透传给该字段渲染组件的入参,支持「对象」或「(formData)=>对象」
135
+ // 写成函数时在渲染期求值,读取到的表单字段会被追踪,值变化自动重算(故参数可随表单/变量联动)
136
+ const getFieldParams = (item: any) => {
137
+ const conf =
138
+ (typeof item.params === "function"
139
+ ? item.params(props.form)
140
+ : item.params) || {};
141
+ return { ...conf };
142
+ };
143
+
144
+ // 控件入参:disabled 统一下发;类型特有项(input 的输入框类型 format / radio·select 的选项 format / editor 的 disable)
145
+ // 作为缺省值在前,字段级 params 在后(params 可覆盖缺省项)。
146
+ // radio·select 的 format:函数/Promise 已由 BaseForm 解析进 optionLists(下发数组);
147
+ // 字符串(字典 code)/数组原样下发,字符串由控件经 BaseForm 注入的 api.dict 自行拉取
148
+ const getControlProps = (item: any) => {
149
+ const extra: Record<string, any> = { disabled: getDisabled(item) };
150
+ if (item.type === "input") extra.format = item.format || "text";
151
+ if (item.type === "radio" || item.type === "select")
152
+ extra.format = props.optionLists?.[item.value] ?? item.format ?? [];
153
+ if (item.type === "editor") {
154
+ extra.disable = extra.disabled; // BaseEditor 的禁用 prop 名为 disable
155
+ delete extra.disabled;
156
+ }
157
+ return { ...extra, ...getFieldParams(item) };
158
+ };
159
+
160
+ // 字段点击/值变化回调:仅在配置为函数时触发,入参原样透传给 field.click / field.change
161
+ const onFieldClick = (item: any, ...args: any[]) => {
162
+ if (typeof item.click === "function") item.click(...args);
163
+ };
164
+ const onFieldChange = (item: any, ...args: any[]) => {
165
+ if (typeof item.change === "function") item.change(...args);
166
+ };
167
+ </script>
168
+
169
+ <style scoped>
170
+ /* 字段标签:超出 label-width 时省略号截断,完整内容由 tooltip 悬浮展示 */
171
+ .field-label {
172
+ display: inline-block;
173
+ max-width: 100%;
174
+ overflow: hidden;
175
+ text-overflow: ellipsis;
176
+ white-space: nowrap;
177
+ vertical-align: bottom;
178
+ }
179
+ </style>