@neatui/vue 5.0.0 → 5.1.1
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 +17 -6
- package/package.json +2 -6
- package/src/components/basic/IRouterView.vue +1 -1
- package/src/components/form/Input.vue +1 -1
- package/src/components/form/InputNumber.vue +1 -1
- package/src/components/form/InputRange.vue +1 -1
- package/src/components/form/Select.vue +18 -24
- package/src/components/form/Textarea.vue +1 -1
- package/src/components/form/TimePicker.vue +1 -1
- package/src/components/form/index.ts +0 -2
- package/src/components/loader/FormLoader/FormLoader.vue +469 -201
- package/src/components/loader/FormLoader/FormRender.vue +149 -34
- package/src/components/loader/TableLoader/TableLoader.vue +233 -24
- package/src/components/loader/index.ts +1 -1
- package/src/components/form/RichTextInput.vue +0 -99
|
@@ -1,24 +1,20 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<
|
|
2
|
+
<button v-if="bare && col.model[0] === 'Button'" type="button" ui-btn="@a s" v-bind="contentAttrs" v-on="events"><template v-if="plain !== null">{{ plain }}</template><FieldExtra v-else :value="displayOf(col, ctx)" :trusted="trusted" /></button>
|
|
3
|
+
<div v-else-if="bare && col.model[0] === 'Html' && trusted" v-bind="contentAttrs" v-on="events" v-html="displayOf(col, ctx)"></div>
|
|
4
|
+
<div v-else-if="bare" v-bind="contentAttrs" v-on="events"><template v-if="plain !== null">{{ plain }}</template><FieldExtra v-else :value="displayOf(col, ctx)" :trusted="trusted" /></div>
|
|
5
|
+
<div v-else ref="root" class="flex-block pr" @focusout="col._touched = true">
|
|
3
6
|
<slot v-if="custom" />
|
|
4
|
-
<button v-else-if="col.model[0] === 'Button'" type="button" ui-btn="@a s" v-bind="
|
|
5
|
-
<div v-else-if="col.model[0] === 'Html'" v-bind="
|
|
6
|
-
<div v-else-if="col.model[0] === 'Text'" v-bind="
|
|
7
|
+
<button v-else-if="col.model[0] === 'Button'" type="button" ui-btn="@a s" v-bind="contentAttrs" v-on="events"><template v-if="plain !== null">{{ plain }}</template><FieldExtra v-else :value="displayOf(col, ctx)" :trusted="trusted" /></button>
|
|
8
|
+
<div v-else-if="col.model[0] === 'Html' && trusted" v-bind="contentAttrs" v-on="events" v-html="displayOf(col, ctx)"></div>
|
|
9
|
+
<div v-else-if="col.model[0] === 'Html' || col.model[0] === 'Text'" v-bind="contentAttrs" v-on="events"><template v-if="plain !== null">{{ plain }}</template><FieldExtra v-else :value="displayOf(col, ctx)" :trusted="trusted" /></div>
|
|
7
10
|
<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
11
|
<component
|
|
9
12
|
v-else-if="entry"
|
|
10
13
|
:is="entry.component"
|
|
11
14
|
:modelValue="displayOf(col, ctx)"
|
|
12
|
-
:interacted="interacted"
|
|
13
|
-
:options="entry.options ? col.enums : undefined"
|
|
14
15
|
:class="entry.class"
|
|
15
|
-
v-bind="
|
|
16
|
-
|
|
17
|
-
:verify="false"
|
|
18
|
-
:co="col._error?.type || col.model[1]?.co"
|
|
19
|
-
:readonly="mode === 'view' ? true : col.model[1]?.readonly"
|
|
20
|
-
:disabled="mode === 'view' ? true : col.model[1]?.disabled"
|
|
21
|
-
v-on="events"
|
|
16
|
+
v-bind="controlProps"
|
|
17
|
+
v-on="controlEvents"
|
|
22
18
|
@update:modelValue="update"
|
|
23
19
|
/>
|
|
24
20
|
<FieldMessage :col="col" :mode="mode" />
|
|
@@ -26,7 +22,7 @@
|
|
|
26
22
|
</template>
|
|
27
23
|
<script lang="ts">
|
|
28
24
|
import { defineComponent, h, isVNode, markRaw } from 'vue';
|
|
29
|
-
import type { Component } from 'vue';
|
|
25
|
+
import type { Component, VNode } from 'vue';
|
|
30
26
|
import { isArray, isObject, isString, isFunction } from '@fekit/utils';
|
|
31
27
|
|
|
32
28
|
/** 最近一层数组的操作上下文;行引用在排序后仍指向原行。 */
|
|
@@ -91,6 +87,9 @@
|
|
|
91
87
|
set?: (value: any, ctx: FormContext) => any;
|
|
92
88
|
}
|
|
93
89
|
|
|
90
|
+
/** 内容节点可以渲染的值 */
|
|
91
|
+
export type FormContent = string | number | boolean | null | VNode | { text?: string; html?: boolean; attrs?: Record<string, any> } | FormContent[];
|
|
92
|
+
|
|
94
93
|
/** 数组配置 */
|
|
95
94
|
export interface FormArray {
|
|
96
95
|
// 每一行的字段,兼容旧版 lists
|
|
@@ -112,12 +111,21 @@
|
|
|
112
111
|
item?: Record<string, any> | (() => Record<string, any>);
|
|
113
112
|
}
|
|
114
113
|
|
|
114
|
+
/** 子节点容器:attrs 描述这一层容器(可以是函数),lists / child 是子节点 */
|
|
115
|
+
export interface FormChild {
|
|
116
|
+
attrs?: Record<string, any> | ((data: any, item: any, ctx: FormContext) => Record<string, any>);
|
|
117
|
+
lists?: FormItem[];
|
|
118
|
+
child?: FormItem[];
|
|
119
|
+
}
|
|
120
|
+
|
|
115
121
|
/** 基础节点:标题和子节点复用同一套配置,不为装饰或交互增加专用字段。 */
|
|
116
122
|
export interface FormItem {
|
|
117
123
|
label?: Dynamic<string | [string, Record<string, any>] | FormItem[]>;
|
|
118
124
|
field?: string;
|
|
119
|
-
|
|
120
|
-
|
|
125
|
+
// 固定内容、{ text, html, attrs } 内容对象、VNode、数组;绑定字段时用 { default, get, set };也可以是函数或联动配置
|
|
126
|
+
value?: FormValue | FormContent | FormSetup | FormLinkage<FormContent>;
|
|
127
|
+
// 子节点;{ attrs, lists } 时 attrs 落在承载子节点的容器上(无标题分组即节点元素本身)
|
|
128
|
+
child?: Dynamic<FormItem[]> | FormChild;
|
|
121
129
|
attrs?: Dynamic<Record<string, any>>;
|
|
122
130
|
clear?: Dynamic<boolean | { value: boolean; restore?: boolean }>;
|
|
123
131
|
enums?: Dynamic<any[]>;
|
|
@@ -126,9 +134,29 @@
|
|
|
126
134
|
model?: Dynamic<string | [string, Record<string, any>?]>;
|
|
127
135
|
rules?: Dynamic<FormRule[]>;
|
|
128
136
|
// 兼容原有布局和数据路径;普通容器布局也可直接使用 attrs。
|
|
129
|
-
|
|
137
|
+
/** 自身 ui-col(只在所在层有栅格时输出);false 表示不用栅格:自身不带 ui-col,分组 / 数组的下一层也不带 ui-row */
|
|
138
|
+
media?: Dynamic<string | false>;
|
|
130
139
|
vertical?: Dynamic<boolean>;
|
|
131
140
|
paths?: string;
|
|
141
|
+
// 4.x 兼容:自定义插槽名;字段级冒号(false 不显示,string 自定义字符)
|
|
142
|
+
slot?: string;
|
|
143
|
+
colon?: boolean | string;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** 字段与控件插槽(${field}、model 名、${field}-value、${field}-row)的参数 */
|
|
147
|
+
export interface FormSlotScope {
|
|
148
|
+
col: any;
|
|
149
|
+
node: any;
|
|
150
|
+
// 当前层数据
|
|
151
|
+
data: any;
|
|
152
|
+
mode: string;
|
|
153
|
+
// 原始值(未经 value.get)
|
|
154
|
+
value: any;
|
|
155
|
+
ctx: FormContext;
|
|
156
|
+
// 写回:经过 value.set,查看模式下不写入
|
|
157
|
+
update: (value: any) => void;
|
|
158
|
+
// Vue 控件直接 v-bind:modelValue 已经过 value.get
|
|
159
|
+
bind: { modelValue: any; 'onUpdate:modelValue': (value: any) => void; disabled: boolean };
|
|
132
160
|
}
|
|
133
161
|
|
|
134
162
|
/** 校验结果 */
|
|
@@ -147,10 +175,20 @@
|
|
|
147
175
|
|
|
148
176
|
/** FormLoader 对外方法 */
|
|
149
177
|
export interface FormLoaderExpose {
|
|
150
|
-
|
|
178
|
+
/** 校验已显示字段;paths 只校验这些路径(含子路径) */
|
|
179
|
+
validate: (options?: { scroll?: boolean; paths?: string[] }) => Promise<FormValidateResult>;
|
|
151
180
|
reset: () => void;
|
|
152
181
|
clearValidate: () => void;
|
|
153
182
|
scrollToField: (path: string) => void;
|
|
183
|
+
/** 回填外部错误,返回没有匹配到字段的路径 */
|
|
184
|
+
setErrors: (errors: Record<string, string | { msg: string; type?: string }> | Array<{ path: string; msg: string; type?: string }>, options?: { scroll?: boolean }) => string[];
|
|
185
|
+
/** 与初始值相比发生变化的字段路径 */
|
|
186
|
+
getChanges: () => string[];
|
|
187
|
+
isDirty: (path?: string) => boolean;
|
|
188
|
+
/** 把当前数据设为新的初始值 */
|
|
189
|
+
setBaseline: () => void;
|
|
190
|
+
/** 数据副本;visibleOnly 去掉被 clear 隐藏的字段 */
|
|
191
|
+
getData: (options?: { visibleOnly?: boolean }) => any;
|
|
154
192
|
}
|
|
155
193
|
|
|
156
194
|
/** 动态属性任务 */
|
|
@@ -163,12 +201,14 @@
|
|
|
163
201
|
watch: string[] | true;
|
|
164
202
|
hasInitialResult: boolean;
|
|
165
203
|
initialResult: any;
|
|
204
|
+
// 函数属性的计算结果;变化时重新应用,替代整表深拷贝监听
|
|
205
|
+
source?: () => any;
|
|
166
206
|
}
|
|
167
207
|
|
|
168
208
|
/** 控件注册信息 */
|
|
169
209
|
export interface FieldEntry {
|
|
170
210
|
component: Component;
|
|
171
|
-
// 查看模式使用控件自身的 readonly
|
|
211
|
+
// 查看模式使用控件自身的 readonly 展示(如图片库)。
|
|
172
212
|
view?: boolean;
|
|
173
213
|
validate?: (value: any, props: any) => string | null | Promise<string | null>;
|
|
174
214
|
// 是否把 enums 作为 options 传入
|
|
@@ -225,43 +265,44 @@
|
|
|
225
265
|
return handlers;
|
|
226
266
|
};
|
|
227
267
|
|
|
228
|
-
//
|
|
229
|
-
const render = (value: any): any => {
|
|
268
|
+
// 内容渲染:字符串直接输出文本节点;VNode 原样渲染;{ text, html, attrs } 需要属性才包 span,只有 html 为 true 且内容来自配置时才按 HTML 渲染
|
|
269
|
+
const render = (value: any, trusted: boolean): any => {
|
|
230
270
|
if (value === null || value === undefined || value === '') return null;
|
|
231
271
|
if (isVNode(value)) return value;
|
|
232
|
-
if (isArray(value)) return value.map(render);
|
|
272
|
+
if (isArray(value)) return value.map((item: any) => render(item, trusted));
|
|
233
273
|
if (isObject(value)) {
|
|
234
274
|
const { text = '', html = false, attrs = {} } = value;
|
|
235
|
-
return html ? h('span', { ...attrs, innerHTML: text }) : h('span', attrs, text);
|
|
275
|
+
return html && trusted ? h('span', { ...attrs, innerHTML: text }) : h('span', trusted ? attrs : {}, String(text));
|
|
236
276
|
}
|
|
237
|
-
return
|
|
277
|
+
return String(value);
|
|
238
278
|
};
|
|
239
279
|
|
|
240
280
|
export const FieldMessage = defineComponent({
|
|
241
281
|
props: ['col', 'mode'],
|
|
282
|
+
// 没有内容时返回空数组:客户端渲染不留占位注释
|
|
242
283
|
setup: (props) => () => props.mode !== 'view' ? [
|
|
243
284
|
props.col.model?.[1]?.verify && props.col._checked
|
|
244
285
|
? h('i', { 'ui-form-verify': props.col._error ? 'no' : isEmpty(props.col._data?.[props.col.field]) ? '' : 'ok', 'aria-hidden': 'true' }) : null,
|
|
245
286
|
props.col._error ? h('p', { id: `${props.col._fid}-error`, 'data-field-error': props.col.field,
|
|
246
287
|
role: props.col._error.type === 'warn' ? 'status' : 'alert',
|
|
247
288
|
class: `mt-xs ${props.col._error.type === 'warn' ? 'co-warn' : 'co-risk'}` }, props.col._error.msg) : null,
|
|
248
|
-
] :
|
|
289
|
+
].filter(Boolean) : [],
|
|
249
290
|
});
|
|
250
291
|
|
|
251
292
|
export const FieldExtra = defineComponent({
|
|
252
293
|
name: 'FieldExtra',
|
|
253
294
|
props: {
|
|
254
295
|
value: { type: null, default: null },
|
|
296
|
+
trusted: { type: Boolean, default: false },
|
|
255
297
|
},
|
|
256
298
|
setup(props) {
|
|
257
|
-
return () => render(props.value);
|
|
299
|
+
return () => render(props.value, props.trusted);
|
|
258
300
|
},
|
|
259
301
|
});
|
|
260
302
|
|
|
261
303
|
import {
|
|
262
304
|
Radio,
|
|
263
305
|
Gallery,
|
|
264
|
-
RichTextInput,
|
|
265
306
|
Input,
|
|
266
307
|
InputRange,
|
|
267
308
|
Switch,
|
|
@@ -295,7 +336,7 @@
|
|
|
295
336
|
const multiple = { emptyValue: () => [] };
|
|
296
337
|
|
|
297
338
|
// View 由当前渲染器直接展示;注册表保留标记以兼容自定义注册。
|
|
298
|
-
const FieldView = defineComponent({ name: 'FormView', render: () => null });
|
|
339
|
+
export const FieldView = defineComponent({ name: 'FormView', render: () => null });
|
|
299
340
|
|
|
300
341
|
// 占位:只占栅格,不渲染内容
|
|
301
342
|
const Blank = defineComponent({ name: 'FormBlank', render: () => null });
|
|
@@ -304,7 +345,6 @@
|
|
|
304
345
|
const registry: Record<string, FieldEntry> = {
|
|
305
346
|
Radio: option(Radio),
|
|
306
347
|
Gallery: option(Gallery, { view: true, emptyValue: (props: any) => props?.multiple ? [] : '' }),
|
|
307
|
-
RichTextInput: { component: markRaw(RichTextInput), view: true, class: 'w-full' },
|
|
308
348
|
Input: { component: markRaw(Input), class: 'w-full' },
|
|
309
349
|
InputRange: { component: markRaw(InputRange), class: 'w-full', emptyValue: () => [] },
|
|
310
350
|
Select: option(Select),
|
|
@@ -344,9 +384,9 @@
|
|
|
344
384
|
|
|
345
385
|
// 仅补名称别名;显式注册同名控件时仍优先使用注册项。
|
|
346
386
|
const fieldAliases: Record<string, string> = { TextArea: 'Textarea', view: 'View', SelectImg: 'Gallery' };
|
|
347
|
-
export const getField = (model: string = ''): FieldEntry | undefined => registry[model] || registry[fieldAliases[model]];
|
|
387
|
+
export const getField = (model: string = ''): FieldEntry | undefined => registry[model] || registry[fieldAliases[model] ?? ''];
|
|
348
388
|
|
|
349
|
-
//
|
|
389
|
+
// 注册自定义控件,比如业务里的图片库;注册后 model 直接写名字即可。富文本等重控件建议用插槽接入,不进注册表
|
|
350
390
|
export const registerField = (name: string, entry: FieldEntry) => {
|
|
351
391
|
registry[name] = { ...entry, component: markRaw(entry.component as any) };
|
|
352
392
|
};
|
|
@@ -358,10 +398,10 @@
|
|
|
358
398
|
if (typeof value === 'function') return value(model[1]);
|
|
359
399
|
return value === undefined ? '' : value;
|
|
360
400
|
};
|
|
361
|
-
|
|
362
401
|
</script>
|
|
363
402
|
<script lang="ts" setup>
|
|
364
|
-
|
|
403
|
+
// 单个字段的渲染:内置内容节点、注册控件或插槽,以及统一的错误提示
|
|
404
|
+
import { computed, ref, watch, nextTick, onMounted, onUpdated } from 'vue';
|
|
365
405
|
const props = defineProps<{
|
|
366
406
|
col: any;
|
|
367
407
|
entry?: FieldEntry;
|
|
@@ -369,6 +409,8 @@
|
|
|
369
409
|
mode?: string;
|
|
370
410
|
ctx: FormContext;
|
|
371
411
|
custom?: boolean;
|
|
412
|
+
// 无标题的内容节点:不包字段容器,直接输出内容元素,节点 attrs 透传到这个元素上(模板根部不能有注释)
|
|
413
|
+
bare?: boolean;
|
|
372
414
|
}>();
|
|
373
415
|
// 在选项树里按 value 找 label
|
|
374
416
|
const findLabel = (options: any[] = [], value: any): any => {
|
|
@@ -404,6 +446,79 @@
|
|
|
404
446
|
}
|
|
405
447
|
return String(one(value));
|
|
406
448
|
});
|
|
407
|
-
|
|
449
|
+
// 配置里写死、由配置函数生成或经 value.get 转换的内容视为可信;直接显示 field 数据时按文本渲染,避免 XSS。
|
|
450
|
+
const trusted = computed(() => {
|
|
451
|
+
const { value } = props.col;
|
|
452
|
+
return value !== undefined && (!valueBinding(value) || isFunction(value.get));
|
|
453
|
+
});
|
|
454
|
+
// 字符串、数字、布尔直接输出文本(空值不留占位注释);VNode、{ text, html }、数组交给 FieldExtra
|
|
455
|
+
const plain = computed(() => {
|
|
456
|
+
const value = displayOf(props.col, props.ctx);
|
|
457
|
+
if (value === null || value === undefined) return '';
|
|
458
|
+
return typeof value === 'object' ? null : String(value);
|
|
459
|
+
});
|
|
408
460
|
const events = computed(() => eventsOf(props.col, props.ctx));
|
|
461
|
+
// 先写回数据再通知配置里的 update:modelValue,与插槽的 update 顺序一致。
|
|
462
|
+
const controlEvents = computed(() => {
|
|
463
|
+
const { 'update:modelValue': _, ...rest } = events.value;
|
|
464
|
+
return rest;
|
|
465
|
+
});
|
|
466
|
+
const update = (value: any) => {
|
|
467
|
+
props.col._touched = true;
|
|
468
|
+
writeValue(props.col, value, props.ctx);
|
|
469
|
+
events.value['update:modelValue']?.(value);
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
// FormLoader 自己的参数只传给声明了它们的控件,避免落到 Radio、Gallery 等控件的 DOM 上。
|
|
473
|
+
const formOnly = ['interacted', 'rules', 'verify', 'co', 'format', 'empty'];
|
|
474
|
+
const controlProps = computed(() => {
|
|
475
|
+
const { col, entry, mode } = props;
|
|
476
|
+
const options = col.model?.[1] || {};
|
|
477
|
+
const merged: Record<string, any> = {
|
|
478
|
+
interacted: props.interacted,
|
|
479
|
+
options: entry?.options ? col.enums : undefined,
|
|
480
|
+
...options,
|
|
481
|
+
rules: [],
|
|
482
|
+
verify: false,
|
|
483
|
+
co: col._error?.type || options.co,
|
|
484
|
+
readonly: mode === 'view' ? true : options.readonly,
|
|
485
|
+
disabled: mode === 'view' ? true : options.disabled,
|
|
486
|
+
};
|
|
487
|
+
const declared: any = (entry?.component as any)?.props;
|
|
488
|
+
// 没有声明 props 的自定义注册控件可能通过 $attrs 读取这些参数,保持透传。
|
|
489
|
+
const declares = (key: string) => !declared || (isArray(declared) ? declared.includes(key) : Object.prototype.hasOwnProperty.call(declared, key));
|
|
490
|
+
formOnly.forEach((key) => { if (!declares(key)) delete merged[key]; });
|
|
491
|
+
if (merged.options === undefined) delete merged.options;
|
|
492
|
+
return merged;
|
|
493
|
+
});
|
|
494
|
+
// 按钮和内容节点只接收普通属性
|
|
495
|
+
const contentAttrs = computed(() => {
|
|
496
|
+
const attrs = { ...(props.col.model?.[1] || {}) };
|
|
497
|
+
formOnly.forEach((key) => delete attrs[key]);
|
|
498
|
+
return attrs;
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
// 错误提示关联到实际输入元素,读屏软件才能读出;只移除自己加上的属性。
|
|
502
|
+
const root = ref<HTMLElement>();
|
|
503
|
+
const inputs = 'input:not([type=hidden]):not([type=search]), textarea, select, [contenteditable="true"], [role="radiogroup"]';
|
|
504
|
+
const describe = () => {
|
|
505
|
+
const el = root.value;
|
|
506
|
+
// 非 DOM 渲染环境(SSR、自定义渲染器)没有查询接口
|
|
507
|
+
if (!el || typeof el.querySelectorAll !== 'function') return;
|
|
508
|
+
const errorId = props.col._error && props.mode !== 'view' ? `${props.col._fid}-error` : '';
|
|
509
|
+
el.querySelectorAll<HTMLElement>(inputs).forEach((node) => {
|
|
510
|
+
if (errorId) {
|
|
511
|
+
node.setAttribute('aria-invalid', 'true');
|
|
512
|
+
node.setAttribute('aria-describedby', errorId);
|
|
513
|
+
node.dataset.formAria = '';
|
|
514
|
+
} else if (node.dataset.formAria !== undefined) {
|
|
515
|
+
node.removeAttribute('aria-invalid');
|
|
516
|
+
node.removeAttribute('aria-describedby');
|
|
517
|
+
delete node.dataset.formAria;
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
};
|
|
521
|
+
onMounted(describe);
|
|
522
|
+
onUpdated(describe);
|
|
523
|
+
watch(() => [props.col._error, props.mode], () => nextTick(describe));
|
|
409
524
|
</script>
|
|
@@ -101,14 +101,15 @@
|
|
|
101
101
|
<td :colspan="columnCount" :style="virtualPadStyle(virtualSlice.padTop)"></td>
|
|
102
102
|
</tr>
|
|
103
103
|
<tr
|
|
104
|
-
v-for="
|
|
105
|
-
:key="
|
|
106
|
-
:ref="virtualEnabled ? (el: any) => bindVirtualRow(el, item) : undefined"
|
|
104
|
+
v-for="row in bodyRows"
|
|
105
|
+
:key="row.key"
|
|
106
|
+
:ref="virtualEnabled ? (el: any) => bindVirtualRow(el, row.item) : undefined"
|
|
107
107
|
v-bind="rows.attrs"
|
|
108
|
-
v-on="event(rows.event, item, 'td')"
|
|
108
|
+
v-on="event(rows.event, row.item, 'td')"
|
|
109
|
+
:data-level="treeEnabled ? row.level : undefined"
|
|
109
110
|
>
|
|
110
111
|
<td v-if="selected" col-fixed="1" min-width :col-fixed-side="fixedSide('__select')" :style="fixedStyle('__select')">
|
|
111
|
-
<label class="mb-ss" ui-form="@a type:checkbox"><input type="checkbox" v-model="item.__selected" /><span></span></label>
|
|
112
|
+
<label v-if="row.level === 1" class="mb-ss" ui-form="@a type:checkbox"><input type="checkbox" v-model="row.item.__selected" /><span></span></label>
|
|
112
113
|
</td>
|
|
113
114
|
<template v-for="(col, cid) in state.table" :key="cid">
|
|
114
115
|
<td
|
|
@@ -119,12 +120,41 @@
|
|
|
119
120
|
:col-fixed-side="fixedSide(col.field)"
|
|
120
121
|
:style="fixedStyle(col.field)"
|
|
121
122
|
:data-field="col.field"
|
|
123
|
+
:data-tree="row.level > 1 && col.field === treeField ? 1 : undefined"
|
|
122
124
|
>
|
|
123
|
-
<
|
|
125
|
+
<div v-if="treeEnabled && col.field === treeField && row.level === 1" class="table-tree-node" ui-flex="row lm">
|
|
126
|
+
<i
|
|
127
|
+
v-if="childrenOf(row.item).length"
|
|
128
|
+
class="table-tree-toggle ux-none"
|
|
129
|
+
:data-fold="row.item.__folding ? 1 : 0"
|
|
130
|
+
@click.stop="row.item.__folding = !row.item.__folding"
|
|
131
|
+
></i>
|
|
132
|
+
<i v-else class="table-tree-toggle-pad"></i>
|
|
133
|
+
<slot :name="col.field" :col="col" :item="row.item" :idx="row.idx" :parent="row.parent" :level="1"><TableRender :node="col" :data="row.item" /></slot>
|
|
134
|
+
</div>
|
|
135
|
+
<div v-else-if="treeEnabled && col.field === treeField && row.level === 2" class="table-tree-child" :data-last="row.last ? 1 : 0">
|
|
136
|
+
<div v-if="depth > 2" class="table-tree-node" ui-flex="row lm">
|
|
137
|
+
<i
|
|
138
|
+
v-if="childrenOf(row.item).length"
|
|
139
|
+
class="table-tree-toggle ux-none"
|
|
140
|
+
:data-fold="row.item.__folding ? 1 : 0"
|
|
141
|
+
@click.stop="row.item.__folding = !row.item.__folding"
|
|
142
|
+
></i>
|
|
143
|
+
<i v-else class="table-tree-toggle-pad"></i>
|
|
144
|
+
<slot :name="col.field" :col="col" :item="row.item" :idx="row.idx" :parent="row.parent" :level="2"><TableRender v-if="childHas(col, row.item)" :node="col" :data="row.item" /></slot>
|
|
145
|
+
</div>
|
|
146
|
+
<slot v-else :name="col.field" :col="col" :item="row.item" :idx="row.idx" :parent="row.parent" :level="2"><TableRender v-if="childHas(col, row.item)" :node="col" :data="row.item" /></slot>
|
|
147
|
+
</div>
|
|
148
|
+
<div v-else-if="treeEnabled && col.field === treeField" class="table-tree-child" data-pass="1" :data-last="row.parentLast ? 1 : 0">
|
|
149
|
+
<div class="table-tree-child" :data-last="row.last ? 1 : 0">
|
|
150
|
+
<slot :name="col.field" :col="col" :item="row.item" :idx="row.idx" :parent="row.parent" :level="row.level"><TableRender v-if="childHas(col, row.item)" :node="col" :data="row.item" /></slot>
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
<slot v-else :name="col.field" :col="col" :item="row.item" :idx="row.idx" :parent="row.parent" :level="row.level"><TableRender v-if="row.level === 1 || childHas(col, row.item)" :node="col" :data="row.item" /></slot>
|
|
124
154
|
</td>
|
|
125
155
|
</template>
|
|
126
156
|
<td v-if="slots.operate" col-fixed="r" class="ar" :col-fixed-side="fixedSide('__operate')" :style="fixedStyle('__operate')">
|
|
127
|
-
<slot name="operate" :item="item" :idx="idx"></slot>
|
|
157
|
+
<slot name="operate" :item="row.item" :idx="row.idx" :parent="row.parent" :level="row.level"></slot>
|
|
128
158
|
</td>
|
|
129
159
|
</tr>
|
|
130
160
|
<tr v-if="virtualSlice.padBottom" class="table-virtual-pad" aria-hidden="true">
|
|
@@ -158,21 +188,71 @@
|
|
|
158
188
|
</IFollowView>
|
|
159
189
|
</div>
|
|
160
190
|
</li>
|
|
161
|
-
<
|
|
162
|
-
<
|
|
163
|
-
<
|
|
164
|
-
<
|
|
165
|
-
<
|
|
166
|
-
<
|
|
167
|
-
|
|
168
|
-
<
|
|
169
|
-
|
|
170
|
-
|
|
191
|
+
<template v-for="(item, idx) in lists" :key="rowKeyOf(item, idx)">
|
|
192
|
+
<li class="bg-fore r-sm my-sl n-ms">
|
|
193
|
+
<table>
|
|
194
|
+
<tbody>
|
|
195
|
+
<template v-for="(col, cid) in state.table" :key="cid">
|
|
196
|
+
<tr v-if="!col.__close">
|
|
197
|
+
<td class="nowrap vt n-ss">{{ col.label }}:</td>
|
|
198
|
+
<td class="n-ss">
|
|
199
|
+
<TableRender :node="col" :data="item" />
|
|
200
|
+
</td>
|
|
201
|
+
</tr>
|
|
202
|
+
</template>
|
|
203
|
+
</tbody>
|
|
204
|
+
</table>
|
|
205
|
+
<div v-if="slots.operate"><slot name="operate" :item="item" :idx="idx" :parent="noParent" :level="1"></slot></div>
|
|
206
|
+
<p v-if="treeEnabled && childrenOf(item).length" class="mt-ss">
|
|
207
|
+
<span class="ux-none co-main fs-sm" @click="item.__folding = !item.__folding">
|
|
208
|
+
{{ item.__folding ? words['common.expand'] || '展开' : words['common.collapse'] || '收起' }}
|
|
209
|
+
</span>
|
|
210
|
+
</p>
|
|
211
|
+
</li>
|
|
212
|
+
<template v-if="treeEnabled && !item.__folding">
|
|
213
|
+
<template v-for="(child, cidx) in childrenOf(item)" :key="`${rowKeyOf(item, idx)}-${cidx}`">
|
|
214
|
+
<li v-if="childVisible(child)" class="table-tree-item bg-fore r-sm my-sl n-ms">
|
|
215
|
+
<table>
|
|
216
|
+
<tbody>
|
|
217
|
+
<template v-for="(col, cid) in state.table" :key="cid">
|
|
218
|
+
<tr v-if="!col.__close && childHas(col, child)">
|
|
219
|
+
<td class="nowrap vt n-ss">{{ col.label }}:</td>
|
|
220
|
+
<td class="n-ss">
|
|
221
|
+
<TableRender :node="col" :data="child" />
|
|
222
|
+
</td>
|
|
223
|
+
</tr>
|
|
224
|
+
</template>
|
|
225
|
+
</tbody>
|
|
226
|
+
</table>
|
|
227
|
+
<div v-if="slots.operate"><slot name="operate" :item="child" :idx="cidx" :parent="item" :level="2"></slot></div>
|
|
228
|
+
<p v-if="depth > 2 && childrenOf(child).length" class="mt-ss">
|
|
229
|
+
<span class="ux-none co-main fs-sm" @click="child.__folding = !child.__folding">
|
|
230
|
+
{{ child.__folding ? words['common.expand'] || '展开' : words['common.collapse'] || '收起' }}
|
|
231
|
+
</span>
|
|
232
|
+
</p>
|
|
233
|
+
</li>
|
|
234
|
+
<template v-if="depth > 2 && !child.__folding">
|
|
235
|
+
<template v-for="(grand, gidx) in childrenOf(child)" :key="`${rowKeyOf(item, idx)}-${cidx}-${gidx}`">
|
|
236
|
+
<li v-if="childVisible(grand)" class="table-tree-item table-tree-item-3 bg-fore r-sm my-sl n-ms">
|
|
237
|
+
<table>
|
|
238
|
+
<tbody>
|
|
239
|
+
<template v-for="(col, cid) in state.table" :key="cid">
|
|
240
|
+
<tr v-if="!col.__close && childHas(col, grand)">
|
|
241
|
+
<td class="nowrap vt n-ss">{{ col.label }}:</td>
|
|
242
|
+
<td class="n-ss">
|
|
243
|
+
<TableRender :node="col" :data="grand" />
|
|
244
|
+
</td>
|
|
245
|
+
</tr>
|
|
246
|
+
</template>
|
|
247
|
+
</tbody>
|
|
248
|
+
</table>
|
|
249
|
+
<div v-if="slots.operate"><slot name="operate" :item="grand" :idx="gidx" :parent="child" :level="3"></slot></div>
|
|
250
|
+
</li>
|
|
251
|
+
</template>
|
|
171
252
|
</template>
|
|
172
|
-
</
|
|
173
|
-
</
|
|
174
|
-
|
|
175
|
-
</li>
|
|
253
|
+
</template>
|
|
254
|
+
</template>
|
|
255
|
+
</template>
|
|
176
256
|
</ul>
|
|
177
257
|
</div>
|
|
178
258
|
<div v-if="!lists.length" class="ny-ls o-mm" ui-flex="col cm">
|
|
@@ -245,6 +325,12 @@
|
|
|
245
325
|
rowKey?: string | ((item: any, idx: number) => string | number);
|
|
246
326
|
/** 虚拟滚动(仅表格形态):只渲染可视区附近的行,需要容器限定高度 */
|
|
247
327
|
virtual?: boolean | { estimate?: number; overscan?: number };
|
|
328
|
+
/** 子行:字段名或函数,返回子行数组;子行沿用同一套列和插槽,折叠状态记在行的 __folding 上 */
|
|
329
|
+
children?: string | ((item: any) => any[]);
|
|
330
|
+
/** 折叠按钮和树线所在列的 field,默认第一个可见的非固定列 */
|
|
331
|
+
tree?: string;
|
|
332
|
+
/** 树的层数:默认 2(父行 + 子行);3 时子行再按 children 取第三层 */
|
|
333
|
+
depth?: number;
|
|
248
334
|
}
|
|
249
335
|
// 外部入参
|
|
250
336
|
const props: any = withDefaults(defineProps<Props>(), {
|
|
@@ -258,6 +344,7 @@
|
|
|
258
344
|
allSelected: false,
|
|
259
345
|
layout: 'auto',
|
|
260
346
|
virtual: false,
|
|
347
|
+
depth: 2,
|
|
261
348
|
});
|
|
262
349
|
|
|
263
350
|
/**
|
|
@@ -347,7 +434,7 @@
|
|
|
347
434
|
watch(
|
|
348
435
|
() => props.table,
|
|
349
436
|
() => {
|
|
350
|
-
const _cache = env.client ? JSON.parse(window.localStorage.getItem(`${name}_table`) || '{}') : {};
|
|
437
|
+
const _cache = env.client ? JSON.parse(window.localStorage.getItem(`${name.value}_table`) || '{}') : {};
|
|
351
438
|
state.__last = 0;
|
|
352
439
|
state.table = deepcopy(props.table || [])
|
|
353
440
|
?.map((col: TableColumn, idx: number) => {
|
|
@@ -426,7 +513,7 @@
|
|
|
426
513
|
state.__last = idx;
|
|
427
514
|
}
|
|
428
515
|
});
|
|
429
|
-
if (env.client) window.localStorage.setItem(`${name}_table`, JSON.stringify(_table));
|
|
516
|
+
if (env.client) window.localStorage.setItem(`${name.value}_table`, JSON.stringify(_table));
|
|
430
517
|
},
|
|
431
518
|
{ deep: true },
|
|
432
519
|
);
|
|
@@ -540,6 +627,25 @@
|
|
|
540
627
|
fixedCells.clear();
|
|
541
628
|
});
|
|
542
629
|
|
|
630
|
+
// 子行
|
|
631
|
+
const treeEnabled = computed(() => !!props.children);
|
|
632
|
+
const childrenOf = (item: any): any[] => {
|
|
633
|
+
const key = props.children;
|
|
634
|
+
const list = isFunction(key) ? key(item) : key && item ? item[key] : undefined;
|
|
635
|
+
return Array.isArray(list) ? list : [];
|
|
636
|
+
};
|
|
637
|
+
// 父行的 parent 为空;声明成 any,插槽里两层行的 parent 类型才一致
|
|
638
|
+
const noParent: any = undefined;
|
|
639
|
+
// 子行常常只有部分字段,没有的列留空,不显示占位符
|
|
640
|
+
const childHas = (col: TableColumn, child: any) =>
|
|
641
|
+
(!!child && typeof child === 'object' && hasOwnProp(child, col.field)) || (col.value !== undefined && col.value !== null) || !!(col as any).child || !!(col as any).popup;
|
|
642
|
+
const childVisible = (child: any) => state.table.some((col) => !col.__close && childHas(col, child));
|
|
643
|
+
const treeField = computed(() => {
|
|
644
|
+
if (props.tree) return props.tree;
|
|
645
|
+
const visible = state.table.filter((col) => !col.__close);
|
|
646
|
+
return (visible.find((col) => !col.fixed) || visible[0])?.field;
|
|
647
|
+
});
|
|
648
|
+
|
|
543
649
|
// 行标识
|
|
544
650
|
const rowKeyOf = (item: any, idx: number) => {
|
|
545
651
|
const key = props.rowKey;
|
|
@@ -553,7 +659,8 @@
|
|
|
553
659
|
* 行高各不相同(单元格可能换行、放表单控件),按行对象缓存实测高度,
|
|
554
660
|
* 没测过的按已测平均值估算;行对象不变则删行、排序后缓存仍有效。
|
|
555
661
|
*/
|
|
556
|
-
|
|
662
|
+
// 行高按父行测量,展开的子行会让偏移失真,开了子行就不走虚拟滚动
|
|
663
|
+
const virtualEnabled = computed(() => !!props.virtual && !props.children);
|
|
557
664
|
const virtualEstimate = computed(() => (isObject(props.virtual) && props.virtual.estimate) || 48);
|
|
558
665
|
const virtualOverscan = computed(() => (isObject(props.virtual) && props.virtual.overscan) || 600);
|
|
559
666
|
const scrollTop = ref(0);
|
|
@@ -586,6 +693,30 @@
|
|
|
586
693
|
return { rows, padTop: offsets[start] || 0, padBottom: Math.max(0, offsets[offsets.length - 1] - (offsets[end] || 0)) };
|
|
587
694
|
});
|
|
588
695
|
|
|
696
|
+
/** 桌面表格逐行渲染:开了子行时把树按展开状态摊平,记下层级、上级和是否同级最后一行(画树线用) */
|
|
697
|
+
const bodyRows = computed(() => {
|
|
698
|
+
if (!treeEnabled.value) {
|
|
699
|
+
return virtualSlice.value.rows.map(({ item, idx }: any) => ({ item, idx, level: 1, parent: noParent, key: rowKeyOf(item, idx), last: false, parentLast: false }));
|
|
700
|
+
}
|
|
701
|
+
const out: any[] = [];
|
|
702
|
+
props.lists.forEach((item: any, idx: number) => {
|
|
703
|
+
const key = rowKeyOf(item, idx);
|
|
704
|
+
out.push({ item, idx, level: 1, parent: noParent, key, last: false, parentLast: false });
|
|
705
|
+
if (item?.__folding) return;
|
|
706
|
+
const kids = childrenOf(item);
|
|
707
|
+
kids.forEach((child, cidx) => {
|
|
708
|
+
const last = cidx === kids.length - 1;
|
|
709
|
+
out.push({ item: child, idx: cidx, level: 2, parent: item, key: `${key}-${cidx}`, last, parentLast: false });
|
|
710
|
+
if (props.depth < 3 || child?.__folding) return;
|
|
711
|
+
const grand = childrenOf(child);
|
|
712
|
+
grand.forEach((g, gidx) => {
|
|
713
|
+
out.push({ item: g, idx: gidx, level: 3, parent: child, key: `${key}-${cidx}-${gidx}`, last: gidx === grand.length - 1, parentLast: last });
|
|
714
|
+
});
|
|
715
|
+
});
|
|
716
|
+
});
|
|
717
|
+
return out;
|
|
718
|
+
});
|
|
719
|
+
|
|
589
720
|
let scrollFrame = 0;
|
|
590
721
|
const onVirtualScroll = () => {
|
|
591
722
|
if (!virtualEnabled.value || scrollFrame) return;
|
|
@@ -706,3 +837,81 @@
|
|
|
706
837
|
return { ..._event };
|
|
707
838
|
};
|
|
708
839
|
</script>
|
|
840
|
+
<style lang="scss" scoped>
|
|
841
|
+
.table-tree-node {
|
|
842
|
+
flex-wrap: nowrap;
|
|
843
|
+
}
|
|
844
|
+
.table-tree-toggle,
|
|
845
|
+
.table-tree-toggle-pad {
|
|
846
|
+
flex: none;
|
|
847
|
+
position: relative;
|
|
848
|
+
width: 1em;
|
|
849
|
+
height: 1em;
|
|
850
|
+
margin-right: 0.25em;
|
|
851
|
+
}
|
|
852
|
+
.table-tree-toggle {
|
|
853
|
+
cursor: pointer;
|
|
854
|
+
&::after {
|
|
855
|
+
position: absolute;
|
|
856
|
+
content: '';
|
|
857
|
+
top: 50%;
|
|
858
|
+
left: 50%;
|
|
859
|
+
border-width: 0.35em 0 0.35em 0.35em;
|
|
860
|
+
border-style: solid;
|
|
861
|
+
border-color: transparent transparent transparent currentColor;
|
|
862
|
+
border-radius: 30%;
|
|
863
|
+
transform: translate(-30%, -50%) rotate(90deg);
|
|
864
|
+
transition: transform 0.25s;
|
|
865
|
+
}
|
|
866
|
+
&[data-fold='1']::after {
|
|
867
|
+
transform: translate(-30%, -50%) rotate(0deg);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// 树线画在内层,纵向裁到单元格高度,横向不裁,固定列的阴影照常显示
|
|
872
|
+
td[data-tree] {
|
|
873
|
+
clip-path: inset(0 -100vw);
|
|
874
|
+
&:not([col-fixed]) {
|
|
875
|
+
position: relative;
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
.table-tree-child {
|
|
879
|
+
position: relative;
|
|
880
|
+
padding-left: 1.75em;
|
|
881
|
+
&::before,
|
|
882
|
+
&::after {
|
|
883
|
+
position: absolute;
|
|
884
|
+
content: '';
|
|
885
|
+
left: 0.5em;
|
|
886
|
+
background: var(--co-line);
|
|
887
|
+
}
|
|
888
|
+
&::before {
|
|
889
|
+
width: 1px;
|
|
890
|
+
top: -100vh;
|
|
891
|
+
bottom: -100vh;
|
|
892
|
+
}
|
|
893
|
+
&[data-last='1']::before {
|
|
894
|
+
bottom: 50%;
|
|
895
|
+
}
|
|
896
|
+
&::after {
|
|
897
|
+
width: 0.85em;
|
|
898
|
+
height: 1px;
|
|
899
|
+
top: 50%;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
// 第三层:外层只画上一层的竖线(上一层是最后一行就不画),内层画自己的
|
|
903
|
+
.table-tree-child[data-pass='1'] {
|
|
904
|
+
&::after {
|
|
905
|
+
display: none;
|
|
906
|
+
}
|
|
907
|
+
&[data-last='1']::before {
|
|
908
|
+
display: none;
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
.table-tree-item {
|
|
912
|
+
margin-left: 1.5em;
|
|
913
|
+
}
|
|
914
|
+
.table-tree-item-3 {
|
|
915
|
+
margin-left: 3em;
|
|
916
|
+
}
|
|
917
|
+
</style>
|