@neatui/vue 4.8.2 → 5.1.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/MIGRATION-5.0.md +73 -0
- package/README.md +3 -0
- package/package.json +12 -8
- package/src/components/basic/IFollowView.vue +1 -1
- package/src/components/basic/LayerView/Layer.vue +0 -2
- package/src/components/display/Avatar.vue +124 -1
- package/src/components/display/Tree.vue +10 -4
- package/src/components/display/TreeView.vue +12 -5
- package/src/components/display/index.ts +2 -1
- package/src/components/form/Cascader.vue +1 -1
- package/src/components/form/DateRangeView.vue +6 -6
- package/src/components/form/Gallery.vue +50 -0
- package/src/components/form/Input.vue +5 -2
- package/src/components/form/PageSelect.vue +3 -2
- package/src/components/form/Radio.vue +27 -0
- package/src/components/form/SearchMoreSelect.vue +3 -2
- package/src/components/form/Select.vue +22 -28
- package/src/components/form/SelectPicker.vue +3 -2
- package/src/components/form/Textarea.vue +169 -57
- package/src/components/form/TimePicker.vue +43 -7
- package/src/components/form/Upload.vue +1 -1
- package/src/components/form/index.ts +5 -0
- package/src/components/loader/FormLoader/FormLoader.vue +1098 -351
- package/src/components/loader/FormLoader/FormRender.vue +503 -0
- package/src/components/loader/TableLoader/TableLoader.vue +338 -32
- package/src/components/loader/TableLoader/virtual.ts +27 -0
- package/src/components/loader/index.ts +2 -0
- package/src/index.ts +2 -0
- package/.prettierrc +0 -12
- package/rollup.config.js +0 -75
- package/tsconfig.json +0 -23
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div ref="root" class="flex-block pr" @focusout="col._touched = true">
|
|
3
|
+
<slot v-if="custom" />
|
|
4
|
+
<button v-else-if="col.model[0] === 'Button'" type="button" ui-btn="@a s" v-bind="contentAttrs" v-on="events"><FieldExtra :value="displayOf(col, ctx)" :trusted="trusted" /></button>
|
|
5
|
+
<div v-else-if="col.model[0] === 'Html' && trusted" v-bind="contentAttrs" v-on="events" v-html="displayOf(col, ctx)"></div>
|
|
6
|
+
<div v-else-if="col.model[0] === 'Html' || col.model[0] === 'Text'" v-bind="contentAttrs" v-on="events"><FieldExtra :value="displayOf(col, ctx)" :trusted="trusted" /></div>
|
|
7
|
+
<div v-else-if="(mode === 'view' && !entry?.view) || entry?.component === FieldView" class="flex-block o-ls" :class="clh(col.model?.[1] || {})" v-on="events">{{ text }}</div>
|
|
8
|
+
<component
|
|
9
|
+
v-else-if="entry"
|
|
10
|
+
:is="entry.component"
|
|
11
|
+
:modelValue="displayOf(col, ctx)"
|
|
12
|
+
:class="entry.class"
|
|
13
|
+
v-bind="controlProps"
|
|
14
|
+
v-on="controlEvents"
|
|
15
|
+
@update:modelValue="update"
|
|
16
|
+
/>
|
|
17
|
+
<FieldMessage :col="col" :mode="mode" />
|
|
18
|
+
</div>
|
|
19
|
+
</template>
|
|
20
|
+
<script lang="ts">
|
|
21
|
+
import { defineComponent, h, isVNode, markRaw } from 'vue';
|
|
22
|
+
import type { Component, VNode } from 'vue';
|
|
23
|
+
import { isArray, isObject, isString, isFunction } from '@fekit/utils';
|
|
24
|
+
|
|
25
|
+
/** 最近一层数组的操作上下文;行引用在排序后仍指向原行。 */
|
|
26
|
+
export interface FormArrayContext {
|
|
27
|
+
readonly list: any[];
|
|
28
|
+
readonly row: any;
|
|
29
|
+
readonly index: number;
|
|
30
|
+
readonly canAdd: boolean;
|
|
31
|
+
readonly canRemove: boolean;
|
|
32
|
+
readonly canMoveUp: boolean;
|
|
33
|
+
readonly canMoveDown: boolean;
|
|
34
|
+
add: () => void;
|
|
35
|
+
insertAfter: (index?: number) => void;
|
|
36
|
+
remove: () => void;
|
|
37
|
+
moveUp: () => void;
|
|
38
|
+
moveDown: () => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 联动上下文 */
|
|
42
|
+
export interface FormContext {
|
|
43
|
+
// 根表单数据
|
|
44
|
+
root: any;
|
|
45
|
+
// 当前层数据:分组内是嵌套对象,数组内是当前行
|
|
46
|
+
data: any;
|
|
47
|
+
// 数组行下标,不在数组内时为 undefined
|
|
48
|
+
index?: number;
|
|
49
|
+
array?: FormArrayContext;
|
|
50
|
+
// 当前层在根数据中的路径,比如 'business.address'、'members.0'
|
|
51
|
+
path: string;
|
|
52
|
+
mode: string;
|
|
53
|
+
route: any;
|
|
54
|
+
router: any;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 动态属性函数:(根数据只读副本, 当前字段, 上下文) */
|
|
58
|
+
export type FormSetup = (data: any, item: any, ctx: Omit<FormContext, 'path'> & { path: string; formPath: string; [key: string]: any }, router: any) => any;
|
|
59
|
+
|
|
60
|
+
/** 带监听的动态属性配置;watch 用点路径,'.' 开头表示当前层 */
|
|
61
|
+
export interface FormLinkage<T = any> {
|
|
62
|
+
value?: T;
|
|
63
|
+
setup?: FormSetup;
|
|
64
|
+
watch?: string[] | true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 可以写成固定值、函数或联动配置的属性 */
|
|
68
|
+
export type Dynamic<T> = T | FormSetup | FormLinkage<T>;
|
|
69
|
+
|
|
70
|
+
/** 校验规则 */
|
|
71
|
+
export interface FormRule {
|
|
72
|
+
required?: boolean;
|
|
73
|
+
validator?: (value: any) => boolean | Promise<boolean>;
|
|
74
|
+
msg?: string;
|
|
75
|
+
// 兼容旧版提示字段;同时设置时以 msg 为准(包括空字符串)。
|
|
76
|
+
message?: string;
|
|
77
|
+
type?: 'risk' | 'warn' | string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 字段值:default 初始化,get 显示/校验,set 写回。普通 value 可直接表示内容。 */
|
|
81
|
+
export interface FormValue {
|
|
82
|
+
default?: any;
|
|
83
|
+
get?: (value: any, ctx: FormContext) => any;
|
|
84
|
+
set?: (value: any, ctx: FormContext) => any;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 内容节点可以渲染的值 */
|
|
88
|
+
export type FormContent = string | number | boolean | null | VNode | { text?: string; html?: boolean; attrs?: Record<string, any> } | FormContent[];
|
|
89
|
+
|
|
90
|
+
/** 数组配置 */
|
|
91
|
+
export interface FormArray {
|
|
92
|
+
// 每一行的字段,兼容旧版 lists
|
|
93
|
+
child?: FormItem[];
|
|
94
|
+
lists?: FormItem[];
|
|
95
|
+
min?: number;
|
|
96
|
+
max?: number;
|
|
97
|
+
// 是否显示上移/下移
|
|
98
|
+
sortable?: boolean;
|
|
99
|
+
// 添加按钮文案,false 不显示
|
|
100
|
+
add?: string | false;
|
|
101
|
+
// 删除按钮文案,false 不显示
|
|
102
|
+
del?: string | false;
|
|
103
|
+
// 行内栅格
|
|
104
|
+
rows?: string;
|
|
105
|
+
// 绑定到每一行容器上
|
|
106
|
+
attrs?: Record<string, any>;
|
|
107
|
+
// 新行默认值
|
|
108
|
+
item?: Record<string, any> | (() => Record<string, any>);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 基础节点:标题和子节点复用同一套配置,不为装饰或交互增加专用字段。 */
|
|
112
|
+
export interface FormItem {
|
|
113
|
+
label?: Dynamic<string | [string, Record<string, any>] | FormItem[]>;
|
|
114
|
+
field?: string;
|
|
115
|
+
// 固定内容、{ text, html, attrs } 内容对象、VNode、数组;绑定字段时用 { default, get, set };也可以是函数或联动配置
|
|
116
|
+
value?: FormValue | FormContent | FormSetup | FormLinkage<FormContent>;
|
|
117
|
+
child?: Dynamic<FormItem[]>;
|
|
118
|
+
attrs?: Dynamic<Record<string, any>>;
|
|
119
|
+
clear?: Dynamic<boolean | { value: boolean; restore?: boolean }>;
|
|
120
|
+
enums?: Dynamic<any[]>;
|
|
121
|
+
array?: Dynamic<FormArray | string>;
|
|
122
|
+
event?: Record<string, (ctx: FormContext & { item: any; node: any }, ...args: any[]) => any>;
|
|
123
|
+
model?: Dynamic<string | [string, Record<string, any>?]>;
|
|
124
|
+
rules?: Dynamic<FormRule[]>;
|
|
125
|
+
// 兼容原有布局和数据路径;普通容器布局也可直接使用 attrs。
|
|
126
|
+
media?: Dynamic<string>;
|
|
127
|
+
vertical?: Dynamic<boolean>;
|
|
128
|
+
paths?: string;
|
|
129
|
+
// 4.x 兼容:自定义插槽名;字段级冒号(false 不显示,string 自定义字符)
|
|
130
|
+
slot?: string;
|
|
131
|
+
colon?: boolean | string;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** 字段与控件插槽(${field}、model 名、${field}-value、${field}-row)的参数 */
|
|
135
|
+
export interface FormSlotScope {
|
|
136
|
+
col: any;
|
|
137
|
+
node: any;
|
|
138
|
+
// 当前层数据
|
|
139
|
+
data: any;
|
|
140
|
+
mode: string;
|
|
141
|
+
// 原始值(未经 value.get)
|
|
142
|
+
value: any;
|
|
143
|
+
ctx: FormContext;
|
|
144
|
+
// 写回:经过 value.set,查看模式下不写入
|
|
145
|
+
update: (value: any) => void;
|
|
146
|
+
// Vue 控件直接 v-bind:modelValue 已经过 value.get
|
|
147
|
+
bind: { modelValue: any; 'onUpdate:modelValue': (value: any) => void; disabled: boolean };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** 校验结果 */
|
|
151
|
+
export interface FormError {
|
|
152
|
+
field: string;
|
|
153
|
+
path: string;
|
|
154
|
+
label: string;
|
|
155
|
+
msg: string;
|
|
156
|
+
type: string;
|
|
157
|
+
}
|
|
158
|
+
export interface FormValidateResult {
|
|
159
|
+
valid: boolean;
|
|
160
|
+
errors: FormError[];
|
|
161
|
+
warnings: FormError[];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** FormLoader 对外方法 */
|
|
165
|
+
export interface FormLoaderExpose {
|
|
166
|
+
/** 校验已显示字段;paths 只校验这些路径(含子路径) */
|
|
167
|
+
validate: (options?: { scroll?: boolean; paths?: string[] }) => Promise<FormValidateResult>;
|
|
168
|
+
reset: () => void;
|
|
169
|
+
clearValidate: () => void;
|
|
170
|
+
scrollToField: (path: string) => void;
|
|
171
|
+
/** 回填外部错误,返回没有匹配到字段的路径 */
|
|
172
|
+
setErrors: (errors: Record<string, string | { msg: string; type?: string }> | Array<{ path: string; msg: string; type?: string }>, options?: { scroll?: boolean }) => string[];
|
|
173
|
+
/** 与初始值相比发生变化的字段路径 */
|
|
174
|
+
getChanges: () => string[];
|
|
175
|
+
isDirty: (path?: string) => boolean;
|
|
176
|
+
/** 把当前数据设为新的初始值 */
|
|
177
|
+
setBaseline: () => void;
|
|
178
|
+
/** 数据副本;visibleOnly 去掉被 clear 隐藏的字段 */
|
|
179
|
+
getData: (options?: { visibleOnly?: boolean }) => any;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** 动态属性任务 */
|
|
183
|
+
export interface LinkageTask {
|
|
184
|
+
idx: number;
|
|
185
|
+
key: string;
|
|
186
|
+
value: any;
|
|
187
|
+
setup: FormSetup;
|
|
188
|
+
// true 表示监听全部数据
|
|
189
|
+
watch: string[] | true;
|
|
190
|
+
hasInitialResult: boolean;
|
|
191
|
+
initialResult: any;
|
|
192
|
+
// 函数属性的计算结果;变化时重新应用,替代整表深拷贝监听
|
|
193
|
+
source?: () => any;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** 控件注册信息 */
|
|
197
|
+
export interface FieldEntry {
|
|
198
|
+
component: Component;
|
|
199
|
+
// 查看模式使用控件自身的 readonly 展示(如图片库)。
|
|
200
|
+
view?: boolean;
|
|
201
|
+
validate?: (value: any, props: any) => string | null | Promise<string | null>;
|
|
202
|
+
// 是否把 enums 作为 options 传入
|
|
203
|
+
options?: boolean;
|
|
204
|
+
// 控件默认 class
|
|
205
|
+
class?: string;
|
|
206
|
+
// 字段缺失时的初始值,函数参数为 model[1]
|
|
207
|
+
emptyValue?: any | ((props: any) => any);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// 计算Label行高
|
|
211
|
+
export const clh = ({ sz = '' }: any = {}) => {
|
|
212
|
+
const lhs: any = { xs: 'lh-sm', s: 'lh-mm', m: 'lh-ls', l: 'lh-ll', xl: 'lh-xl' };
|
|
213
|
+
return lhs[sz] || 'lh-ls';
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
// 数组里全是空值也算空,比如日期范围的 ['', '']
|
|
217
|
+
const isBlank = (value: any) => value === '' || value === null || value === undefined;
|
|
218
|
+
export const isEmpty = (value: any) => isBlank(value) || (isArray(value) && value.every(isBlank));
|
|
219
|
+
|
|
220
|
+
// 标题既可以是文字,也可以是一棵普通节点树。
|
|
221
|
+
export const labelNodes = (label: any): FormItem[] | null =>
|
|
222
|
+
isArray(label) && label.every((item: any) => isObject(item) && !isArray(item)) ? label : null;
|
|
223
|
+
export const labelText = (label: any): string => {
|
|
224
|
+
const nodes = labelNodes(label);
|
|
225
|
+
if (nodes) return nodes.map((node) => typeof node.value === 'string' ? node.value : labelText(node.label)).join('');
|
|
226
|
+
return String((isArray(label) ? label[0] : label) ?? '');
|
|
227
|
+
};
|
|
228
|
+
export const labelAttrs = (label: any) => (!labelNodes(label) && isArray(label) ? label[1] || {} : {});
|
|
229
|
+
export const rulesOf = (col: any = {}): FormRule[] => {
|
|
230
|
+
// model 参数与旧版 v-bind 一致,可以覆盖节点 rules(包括显式空数组)。
|
|
231
|
+
const rules = col.model?.[1]?.rules ?? col.rules;
|
|
232
|
+
return isArray(rules) ? rules.map((rule: FormRule) => rule && rule.msg == null && rule.message !== undefined ? { ...rule, msg: rule.message } : rule) : [];
|
|
233
|
+
};
|
|
234
|
+
export const valueBinding = (value: any): value is FormValue =>
|
|
235
|
+
isObject(value) && !isArray(value) && ['default', 'get', 'set'].some((key) => Object.prototype.hasOwnProperty.call(value, key));
|
|
236
|
+
export const displayOf = (col: any = {}, ctx?: any) => {
|
|
237
|
+
const raw = col._data?.[col.field];
|
|
238
|
+
if (valueBinding(col.value)) return isFunction(col.value.get) ? col.value.get(raw, ctx) : raw;
|
|
239
|
+
return col.value !== undefined ? col.value : raw;
|
|
240
|
+
};
|
|
241
|
+
// 内置控件和插槽使用相同写回规则,查看模式不接受写入。
|
|
242
|
+
export const writeValue = (col: any, value: any, ctx: FormContext) => {
|
|
243
|
+
if (!col.field || ctx.mode === 'view') return;
|
|
244
|
+
const binding = col.value;
|
|
245
|
+
col._data[col.field] = valueBinding(binding) && typeof binding.set === 'function' ? binding.set(value, ctx) : value;
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
export const eventsOf = (col: any, ctx: FormContext) => {
|
|
249
|
+
const handlers: Record<string, (...args: any[]) => any> = {};
|
|
250
|
+
Object.entries(col.event || {}).forEach(([name, fn]) => {
|
|
251
|
+
if (isFunction(fn)) handlers[name] = (...args: any[]) => (fn as Function)({ ...ctx, item: col, node: col }, ...args);
|
|
252
|
+
});
|
|
253
|
+
return handlers;
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
// 内容渲染:字符串按文本渲染;VNode 原样渲染;{ text, html, attrs } 只有 html 为 true 且内容来自配置时才按 HTML 渲染
|
|
257
|
+
const render = (value: any, trusted: boolean): any => {
|
|
258
|
+
if (value === null || value === undefined || value === '') return null;
|
|
259
|
+
if (isVNode(value)) return value;
|
|
260
|
+
if (isArray(value)) return value.map((item: any) => render(item, trusted));
|
|
261
|
+
if (isObject(value)) {
|
|
262
|
+
const { text = '', html = false, attrs = {} } = value;
|
|
263
|
+
return html && trusted ? h('span', { ...attrs, innerHTML: text }) : h('span', trusted ? attrs : {}, String(text));
|
|
264
|
+
}
|
|
265
|
+
return h('span', String(value));
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
export const FieldMessage = defineComponent({
|
|
269
|
+
props: ['col', 'mode'],
|
|
270
|
+
setup: (props) => () => props.mode !== 'view' ? [
|
|
271
|
+
props.col.model?.[1]?.verify && props.col._checked
|
|
272
|
+
? h('i', { 'ui-form-verify': props.col._error ? 'no' : isEmpty(props.col._data?.[props.col.field]) ? '' : 'ok', 'aria-hidden': 'true' }) : null,
|
|
273
|
+
props.col._error ? h('p', { id: `${props.col._fid}-error`, 'data-field-error': props.col.field,
|
|
274
|
+
role: props.col._error.type === 'warn' ? 'status' : 'alert',
|
|
275
|
+
class: `mt-xs ${props.col._error.type === 'warn' ? 'co-warn' : 'co-risk'}` }, props.col._error.msg) : null,
|
|
276
|
+
] : null,
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
export const FieldExtra = defineComponent({
|
|
280
|
+
name: 'FieldExtra',
|
|
281
|
+
props: {
|
|
282
|
+
value: { type: null, default: null },
|
|
283
|
+
trusted: { type: Boolean, default: false },
|
|
284
|
+
},
|
|
285
|
+
setup(props) {
|
|
286
|
+
return () => render(props.value, props.trusted);
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
import {
|
|
291
|
+
Radio,
|
|
292
|
+
Gallery,
|
|
293
|
+
Input,
|
|
294
|
+
InputRange,
|
|
295
|
+
Switch,
|
|
296
|
+
Checkbox,
|
|
297
|
+
CheckboxGroup,
|
|
298
|
+
Textarea,
|
|
299
|
+
Select,
|
|
300
|
+
SelectList,
|
|
301
|
+
SelectTags,
|
|
302
|
+
SelectPicker,
|
|
303
|
+
SearchSelect,
|
|
304
|
+
PageSelect,
|
|
305
|
+
SelectTree,
|
|
306
|
+
MoreSelect,
|
|
307
|
+
MoreSelectList,
|
|
308
|
+
MoreSelectTags,
|
|
309
|
+
MoreSelectPicker,
|
|
310
|
+
MoreSelectTree,
|
|
311
|
+
DatePicker,
|
|
312
|
+
TimePicker,
|
|
313
|
+
DateRangePicker,
|
|
314
|
+
SearchMoreSelect,
|
|
315
|
+
ImgUpload,
|
|
316
|
+
Upload,
|
|
317
|
+
Cascader,
|
|
318
|
+
} from '../../form';
|
|
319
|
+
import { timeError } from '../../form/TimePicker.vue';
|
|
320
|
+
import { Tree } from '../../display';
|
|
321
|
+
|
|
322
|
+
const option = (component: any, extra: Partial<FieldEntry> = {}): FieldEntry => ({ component: markRaw(component), options: true, class: 'w-full', ...extra });
|
|
323
|
+
const multiple = { emptyValue: () => [] };
|
|
324
|
+
|
|
325
|
+
// View 由当前渲染器直接展示;注册表保留标记以兼容自定义注册。
|
|
326
|
+
export const FieldView = defineComponent({ name: 'FormView', render: () => null });
|
|
327
|
+
|
|
328
|
+
// 占位:只占栅格,不渲染内容
|
|
329
|
+
const Blank = defineComponent({ name: 'FormBlank', render: () => null });
|
|
330
|
+
|
|
331
|
+
// model[0] => 控件,未注册的 model 走插槽
|
|
332
|
+
const registry: Record<string, FieldEntry> = {
|
|
333
|
+
Radio: option(Radio),
|
|
334
|
+
Gallery: option(Gallery, { view: true, emptyValue: (props: any) => props?.multiple ? [] : '' }),
|
|
335
|
+
Input: { component: markRaw(Input), class: 'w-full' },
|
|
336
|
+
InputRange: { component: markRaw(InputRange), class: 'w-full', emptyValue: () => [] },
|
|
337
|
+
Select: option(Select),
|
|
338
|
+
SelectList: option(SelectList),
|
|
339
|
+
SelectTags: option(SelectTags, multiple),
|
|
340
|
+
SelectPicker: option(SelectPicker),
|
|
341
|
+
SearchSelect: option(SearchSelect),
|
|
342
|
+
MoreSelect: option(MoreSelect, multiple),
|
|
343
|
+
MoreSelectList: option(MoreSelectList, multiple),
|
|
344
|
+
MoreSelectTags: option(MoreSelectTags, multiple),
|
|
345
|
+
MoreSelectPicker: option(MoreSelectPicker, multiple),
|
|
346
|
+
SearchMoreSelect: option(SearchMoreSelect, multiple),
|
|
347
|
+
MoreSelectTree: option(MoreSelectTree, multiple),
|
|
348
|
+
PageSelect: option(PageSelect),
|
|
349
|
+
SelectTree: option(SelectTree),
|
|
350
|
+
Cascader: option(Cascader, { emptyValue: (props: any) => (props?.multiple ? [] : '') }),
|
|
351
|
+
DatePicker: { component: markRaw(DatePicker), class: 'w-full' },
|
|
352
|
+
TimePicker: { component: markRaw(TimePicker), validate: timeError, class: 'w-full' },
|
|
353
|
+
DateRangePicker: { component: markRaw(DateRangePicker), class: 'w-full', emptyValue: () => [] },
|
|
354
|
+
ImgUpload: { component: markRaw(ImgUpload) },
|
|
355
|
+
Upload: { component: markRaw(Upload) },
|
|
356
|
+
Textarea: { component: markRaw(Textarea) },
|
|
357
|
+
Switch: { component: markRaw(Switch), emptyValue: false },
|
|
358
|
+
// Checkbox 按值是否为数组区分单选/多选:options 是 { label, value } 列表时初始化为 []
|
|
359
|
+
Checkbox: {
|
|
360
|
+
component: markRaw(Checkbox),
|
|
361
|
+
emptyValue: (props: any) => (Array.isArray(props?.options) && props.options.some((item: any) => item && typeof item === 'object' && 'label' in item) ? [] : false),
|
|
362
|
+
},
|
|
363
|
+
CheckboxGroup: { component: markRaw(CheckboxGroup), options: true, emptyValue: () => [] },
|
|
364
|
+
Tree: { component: markRaw(Tree), class: 'mt-sm', emptyValue: () => [] },
|
|
365
|
+
View: { component: markRaw(FieldView), options: true },
|
|
366
|
+
Text: { component: markRaw(FieldView) },
|
|
367
|
+
Html: { component: markRaw(FieldView) },
|
|
368
|
+
Button: { component: markRaw(FieldView) },
|
|
369
|
+
Blank: { component: markRaw(Blank) },
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
// 仅补名称别名;显式注册同名控件时仍优先使用注册项。
|
|
373
|
+
const fieldAliases: Record<string, string> = { TextArea: 'Textarea', view: 'View', SelectImg: 'Gallery' };
|
|
374
|
+
export const getField = (model: string = ''): FieldEntry | undefined => registry[model] || registry[fieldAliases[model] ?? ''];
|
|
375
|
+
|
|
376
|
+
// 注册自定义控件,比如业务里的图片库;注册后 model 直接写名字即可。富文本等重控件建议用插槽接入,不进注册表
|
|
377
|
+
export const registerField = (name: string, entry: FieldEntry) => {
|
|
378
|
+
registry[name] = { ...entry, component: markRaw(entry.component as any) };
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
// 字段缺失时的初始值
|
|
382
|
+
export const emptyValueOf = (model: any[] = []) => {
|
|
383
|
+
const entry = getField(model[0]);
|
|
384
|
+
const value = entry?.emptyValue;
|
|
385
|
+
if (typeof value === 'function') return value(model[1]);
|
|
386
|
+
return value === undefined ? '' : value;
|
|
387
|
+
};
|
|
388
|
+
</script>
|
|
389
|
+
<script lang="ts" setup>
|
|
390
|
+
// 单个字段的渲染:内置内容节点、注册控件或插槽,以及统一的错误提示
|
|
391
|
+
import { computed, ref, watch, nextTick, onMounted, onUpdated } from 'vue';
|
|
392
|
+
const props = defineProps<{
|
|
393
|
+
col: any;
|
|
394
|
+
entry?: FieldEntry;
|
|
395
|
+
interacted: boolean;
|
|
396
|
+
mode?: string;
|
|
397
|
+
ctx: FormContext;
|
|
398
|
+
custom?: boolean;
|
|
399
|
+
}>();
|
|
400
|
+
// 在选项树里按 value 找 label
|
|
401
|
+
const findLabel = (options: any[] = [], value: any): any => {
|
|
402
|
+
for (const option of isArray(options) ? options : []) {
|
|
403
|
+
if (option && typeof option === 'object') {
|
|
404
|
+
if (`${option.value}` === `${value}`) return option.label;
|
|
405
|
+
const found = findLabel(option.children, value);
|
|
406
|
+
if (found !== undefined) return found;
|
|
407
|
+
} else if (`${option}` === `${value}`) {
|
|
408
|
+
return option;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
return undefined;
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
// 原控件类型:model 为 View 时由 model[1].type 指定,否则就是 model[0]
|
|
415
|
+
const type = computed(() => (['View', 'view'].includes(props.col.model?.[0]) ? props.col.model?.[1]?.type : props.col.model?.[0]));
|
|
416
|
+
|
|
417
|
+
const text = computed(() => {
|
|
418
|
+
const { col } = props;
|
|
419
|
+
const value = displayOf(col, props.ctx);
|
|
420
|
+
const format = col.model?.[1]?.format;
|
|
421
|
+
if (isFunction(format)) return format(value, col);
|
|
422
|
+
if (isEmpty(value)) return col.model?.[1]?.empty ?? '-';
|
|
423
|
+
if (typeof value === 'boolean') return value ? '是' : '否';
|
|
424
|
+
const one = (v: any) => findLabel(col.enums, v) ?? v;
|
|
425
|
+
if (isArray(value)) {
|
|
426
|
+
// 日期范围等控件的空值是 ['', '']
|
|
427
|
+
const list = value.filter((v: any) => !isEmpty(v));
|
|
428
|
+
if (!list.length) return col.model?.[1]?.empty ?? '-';
|
|
429
|
+
const sep = type.value === 'DateRangePicker' ? ' ~ ' : type.value === 'Cascader' ? ' / ' : '、';
|
|
430
|
+
return list.map(one).join(sep);
|
|
431
|
+
}
|
|
432
|
+
return String(one(value));
|
|
433
|
+
});
|
|
434
|
+
// 配置里写死、由配置函数生成或经 value.get 转换的内容视为可信;直接显示 field 数据时按文本渲染,避免 XSS。
|
|
435
|
+
const trusted = computed(() => {
|
|
436
|
+
const { value } = props.col;
|
|
437
|
+
return value !== undefined && (!valueBinding(value) || isFunction(value.get));
|
|
438
|
+
});
|
|
439
|
+
const events = computed(() => eventsOf(props.col, props.ctx));
|
|
440
|
+
// 先写回数据再通知配置里的 update:modelValue,与插槽的 update 顺序一致。
|
|
441
|
+
const controlEvents = computed(() => {
|
|
442
|
+
const { 'update:modelValue': _, ...rest } = events.value;
|
|
443
|
+
return rest;
|
|
444
|
+
});
|
|
445
|
+
const update = (value: any) => {
|
|
446
|
+
props.col._touched = true;
|
|
447
|
+
writeValue(props.col, value, props.ctx);
|
|
448
|
+
events.value['update:modelValue']?.(value);
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
// FormLoader 自己的参数只传给声明了它们的控件,避免落到 Radio、Gallery 等控件的 DOM 上。
|
|
452
|
+
const formOnly = ['interacted', 'rules', 'verify', 'co', 'format', 'empty'];
|
|
453
|
+
const controlProps = computed(() => {
|
|
454
|
+
const { col, entry, mode } = props;
|
|
455
|
+
const options = col.model?.[1] || {};
|
|
456
|
+
const merged: Record<string, any> = {
|
|
457
|
+
interacted: props.interacted,
|
|
458
|
+
options: entry?.options ? col.enums : undefined,
|
|
459
|
+
...options,
|
|
460
|
+
rules: [],
|
|
461
|
+
verify: false,
|
|
462
|
+
co: col._error?.type || options.co,
|
|
463
|
+
readonly: mode === 'view' ? true : options.readonly,
|
|
464
|
+
disabled: mode === 'view' ? true : options.disabled,
|
|
465
|
+
};
|
|
466
|
+
const declared: any = (entry?.component as any)?.props;
|
|
467
|
+
// 没有声明 props 的自定义注册控件可能通过 $attrs 读取这些参数,保持透传。
|
|
468
|
+
const declares = (key: string) => !declared || (isArray(declared) ? declared.includes(key) : Object.prototype.hasOwnProperty.call(declared, key));
|
|
469
|
+
formOnly.forEach((key) => { if (!declares(key)) delete merged[key]; });
|
|
470
|
+
if (merged.options === undefined) delete merged.options;
|
|
471
|
+
return merged;
|
|
472
|
+
});
|
|
473
|
+
// 按钮和内容节点只接收普通属性
|
|
474
|
+
const contentAttrs = computed(() => {
|
|
475
|
+
const attrs = { ...(props.col.model?.[1] || {}) };
|
|
476
|
+
formOnly.forEach((key) => delete attrs[key]);
|
|
477
|
+
return attrs;
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
// 错误提示关联到实际输入元素,读屏软件才能读出;只移除自己加上的属性。
|
|
481
|
+
const root = ref<HTMLElement>();
|
|
482
|
+
const inputs = 'input:not([type=hidden]):not([type=search]), textarea, select, [contenteditable="true"], [role="radiogroup"]';
|
|
483
|
+
const describe = () => {
|
|
484
|
+
const el = root.value;
|
|
485
|
+
// 非 DOM 渲染环境(SSR、自定义渲染器)没有查询接口
|
|
486
|
+
if (!el || typeof el.querySelectorAll !== 'function') return;
|
|
487
|
+
const errorId = props.col._error && props.mode !== 'view' ? `${props.col._fid}-error` : '';
|
|
488
|
+
el.querySelectorAll<HTMLElement>(inputs).forEach((node) => {
|
|
489
|
+
if (errorId) {
|
|
490
|
+
node.setAttribute('aria-invalid', 'true');
|
|
491
|
+
node.setAttribute('aria-describedby', errorId);
|
|
492
|
+
node.dataset.formAria = '';
|
|
493
|
+
} else if (node.dataset.formAria !== undefined) {
|
|
494
|
+
node.removeAttribute('aria-invalid');
|
|
495
|
+
node.removeAttribute('aria-describedby');
|
|
496
|
+
delete node.dataset.formAria;
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
};
|
|
500
|
+
onMounted(describe);
|
|
501
|
+
onUpdated(describe);
|
|
502
|
+
watch(() => [props.col._error, props.mode], () => nextTick(describe));
|
|
503
|
+
</script>
|