@neatui/vue 4.8.1 → 5.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/MIGRATION-5.0.md +66 -0
- package/README.md +3 -0
- package/package.json +16 -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/RichTextInput.vue +99 -0
- package/src/components/form/SearchMoreSelect.vue +3 -2
- package/src/components/form/Select.vue +4 -4
- 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 +7 -0
- package/src/components/loader/FormLoader/FormLoader.vue +908 -349
- package/src/components/loader/FormLoader/FormRender.vue +409 -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
|
@@ -1,14 +1,30 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div
|
|
2
|
+
<div
|
|
3
|
+
ref="scrollRef"
|
|
4
|
+
class="full"
|
|
5
|
+
ui-scroll=":x :y"
|
|
6
|
+
:scroll-side-l="scrollSide.l"
|
|
7
|
+
:scroll-side-r="scrollSide.r"
|
|
8
|
+
:style="virtualEnabled ? { overflowAnchor: 'none' } : undefined"
|
|
9
|
+
@scroll.passive="onScroll"
|
|
10
|
+
>
|
|
3
11
|
<!-- 桌面端 -->
|
|
4
|
-
<table ui-hide="<pad" :ui-table="theme" ref="tableRef">
|
|
12
|
+
<table v-if="showTable" :ui-hide="layout === 'auto' ? '<pad' : undefined" :ui-table="theme" ref="tableRef">
|
|
5
13
|
<thead class="table-head-fixed">
|
|
6
14
|
<tr>
|
|
7
|
-
<th v-if="selected" min-width col-fixed="1">
|
|
15
|
+
<th v-if="selected" :ref="(el: any) => bindFixedCell(el, '__select')" min-width col-fixed="1" :col-fixed-side="fixedSide('__select')" :style="fixedStyle('__select')">
|
|
8
16
|
<label class="mb-ss" ui-form="@a type:checkbox"><input type="checkbox" v-model="all" /><span></span></label>
|
|
9
17
|
</th>
|
|
10
18
|
<template v-for="(col, idx) in state.table" :key="idx">
|
|
11
|
-
<th
|
|
19
|
+
<th
|
|
20
|
+
v-if="!col.__close"
|
|
21
|
+
:ref="col.fixed ? (el: any) => bindFixedCell(el, col.field) : undefined"
|
|
22
|
+
v-bind="colSizeAttrs(col)"
|
|
23
|
+
:col-fixed="col.fixed"
|
|
24
|
+
:col-fixed-side="fixedSide(col.field)"
|
|
25
|
+
:style="fixedStyle(col.field)"
|
|
26
|
+
:data-field="col.field"
|
|
27
|
+
>
|
|
12
28
|
<slot :name="`thead-${col.field}`" :col="col" :idx="idx">
|
|
13
29
|
<div :class="!slots.operate && idx === state.__last ? 'ar' : ''" v-bind="col.thead">
|
|
14
30
|
<span class="ux-none" ui-flex="row rm" v-if="col.order === 0 || col.order === 1 || col.order === 2" @click="fSetOrder(col)">
|
|
@@ -43,7 +59,14 @@
|
|
|
43
59
|
</slot>
|
|
44
60
|
</th>
|
|
45
61
|
</template>
|
|
46
|
-
<th
|
|
62
|
+
<th
|
|
63
|
+
v-if="slots.operate"
|
|
64
|
+
:ref="(el: any) => bindFixedCell(el, '__operate')"
|
|
65
|
+
col-fixed="r"
|
|
66
|
+
:col-fixed-side="fixedSide('__operate')"
|
|
67
|
+
:style="fixedStyle('__operate')"
|
|
68
|
+
class="fs-sl nowrap ar"
|
|
69
|
+
>
|
|
47
70
|
<div ui-flex="row rm">
|
|
48
71
|
<span>{{ words['operate'] || '操作' }}</span>
|
|
49
72
|
<IFollowView v-if="tools" class="lh-xs" tipBoxClass="n-no">
|
|
@@ -74,8 +97,17 @@
|
|
|
74
97
|
</thead>
|
|
75
98
|
<tbody :ui-tbody-load="state.load">
|
|
76
99
|
<template v-if="lists.length">
|
|
77
|
-
<tr v-
|
|
78
|
-
<td
|
|
100
|
+
<tr v-if="virtualSlice.padTop" class="table-virtual-pad" aria-hidden="true">
|
|
101
|
+
<td :colspan="columnCount" :style="virtualPadStyle(virtualSlice.padTop)"></td>
|
|
102
|
+
</tr>
|
|
103
|
+
<tr
|
|
104
|
+
v-for="{ item, idx } in virtualSlice.rows"
|
|
105
|
+
:key="rowKeyOf(item, idx)"
|
|
106
|
+
:ref="virtualEnabled ? (el: any) => bindVirtualRow(el, item) : undefined"
|
|
107
|
+
v-bind="rows.attrs"
|
|
108
|
+
v-on="event(rows.event, item, 'td')"
|
|
109
|
+
>
|
|
110
|
+
<td v-if="selected" col-fixed="1" min-width :col-fixed-side="fixedSide('__select')" :style="fixedStyle('__select')">
|
|
79
111
|
<label class="mb-ss" ui-form="@a type:checkbox"><input type="checkbox" v-model="item.__selected" /><span></span></label>
|
|
80
112
|
</td>
|
|
81
113
|
<template v-for="(col, cid) in state.table" :key="cid">
|
|
@@ -84,23 +116,29 @@
|
|
|
84
116
|
:class="`${!slots.operate && cid === state.__last ? 'ar' : ''}`"
|
|
85
117
|
v-bind="colSizeAttrs(col)"
|
|
86
118
|
:col-fixed="col.fixed"
|
|
87
|
-
:col-fixed-side="col.
|
|
119
|
+
:col-fixed-side="fixedSide(col.field)"
|
|
120
|
+
:style="fixedStyle(col.field)"
|
|
88
121
|
:data-field="col.field"
|
|
89
122
|
>
|
|
90
123
|
<slot :name="col.field" :col="col" :item="item" :idx="idx"><TableRender :node="col" :data="item" /></slot>
|
|
91
124
|
</td>
|
|
92
125
|
</template>
|
|
93
|
-
<td v-if="slots.operate" col-fixed="r" class="ar"
|
|
126
|
+
<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>
|
|
128
|
+
</td>
|
|
129
|
+
</tr>
|
|
130
|
+
<tr v-if="virtualSlice.padBottom" class="table-virtual-pad" aria-hidden="true">
|
|
131
|
+
<td :colspan="columnCount" :style="virtualPadStyle(virtualSlice.padBottom)"></td>
|
|
94
132
|
</tr>
|
|
95
133
|
</template>
|
|
96
134
|
</tbody>
|
|
97
135
|
</table>
|
|
98
136
|
<!-- 移动端 -->
|
|
99
|
-
<div ui-hide=">mob">
|
|
137
|
+
<div v-if="showList" :ui-hide="layout === 'auto' ? '>mob' : undefined">
|
|
100
138
|
<ul>
|
|
101
139
|
<li class="bg-fore r-sm my-sl nx-ms ny-sl" ui-flex="row xm">
|
|
102
140
|
<div>
|
|
103
|
-
<label class="mb-ss" ui-form="@a type:checkbox"><input type="checkbox" v-model="all" /><span>全选</span></label>
|
|
141
|
+
<label v-if="selected" class="mb-ss" ui-form="@a type:checkbox"><input type="checkbox" v-model="all" /><span>全选</span></label>
|
|
104
142
|
</div>
|
|
105
143
|
<div>
|
|
106
144
|
<IFollowView>
|
|
@@ -120,7 +158,7 @@
|
|
|
120
158
|
</IFollowView>
|
|
121
159
|
</div>
|
|
122
160
|
</li>
|
|
123
|
-
<li v-for="(item, idx) in lists" :key="idx" class="bg-fore r-sm my-sl n-ms">
|
|
161
|
+
<li v-for="(item, idx) in lists" :key="rowKeyOf(item, idx)" class="bg-fore r-sm my-sl n-ms">
|
|
124
162
|
<table>
|
|
125
163
|
<tbody>
|
|
126
164
|
<template v-for="(col, idx) in state.table" :key="idx">
|
|
@@ -146,12 +184,12 @@
|
|
|
146
184
|
</div>
|
|
147
185
|
</template>
|
|
148
186
|
<script setup lang="ts">
|
|
149
|
-
import { computed, onMounted, reactive, watch, ref,
|
|
187
|
+
import { computed, nextTick, onMounted, reactive, watch, ref, onUnmounted, useSlots, getCurrentInstance } from 'vue';
|
|
150
188
|
import { useRoute, useRouter } from 'vue-router';
|
|
151
189
|
import IDraggable from '../../basic/IDraggable.vue';
|
|
152
190
|
import { IFollowView, Icon } from '../../basic';
|
|
153
|
-
import { itable } from '@fekit/itable';
|
|
154
191
|
import TableRender from './TableRender.vue';
|
|
192
|
+
import { firstRowEndingAfter, virtualOffsets, virtualRange } from './virtual';
|
|
155
193
|
import { isFunction, isObject, hasOwnProp, deepcopy, env } from '@fekit/utils';
|
|
156
194
|
|
|
157
195
|
const route = useRoute();
|
|
@@ -166,7 +204,7 @@
|
|
|
166
204
|
label: string | (() => string);
|
|
167
205
|
field: string | (() => string);
|
|
168
206
|
width?: 'min' | 'max' | string | (() => string);
|
|
169
|
-
fixed?: string | (() => string);
|
|
207
|
+
fixed?: string | number | boolean | (() => string);
|
|
170
208
|
paths?: string | (() => string);
|
|
171
209
|
thead?: Record<string, any> | ((data: any) => Record<string, any>);
|
|
172
210
|
tbody?: Record<string, any> | ((data: any) => Record<string, any>);
|
|
@@ -185,7 +223,7 @@
|
|
|
185
223
|
__order?: 0 | 1 | 2 | number;
|
|
186
224
|
__sort?: number;
|
|
187
225
|
close?: boolean | number | 'always';
|
|
188
|
-
fixed?: string | number;
|
|
226
|
+
fixed?: string | number | boolean;
|
|
189
227
|
thead?: any;
|
|
190
228
|
field: any;
|
|
191
229
|
label: any;
|
|
@@ -202,6 +240,11 @@
|
|
|
202
240
|
event?: { [key: string]: any };
|
|
203
241
|
};
|
|
204
242
|
allSelected?: boolean;
|
|
243
|
+
layout?: 'auto' | 'table' | 'list';
|
|
244
|
+
/** 行的稳定标识:字段名或函数,默认用下标 */
|
|
245
|
+
rowKey?: string | ((item: any, idx: number) => string | number);
|
|
246
|
+
/** 虚拟滚动(仅表格形态):只渲染可视区附近的行,需要容器限定高度 */
|
|
247
|
+
virtual?: boolean | { estimate?: number; overscan?: number };
|
|
205
248
|
}
|
|
206
249
|
// 外部入参
|
|
207
250
|
const props: any = withDefaults(defineProps<Props>(), {
|
|
@@ -213,6 +256,32 @@
|
|
|
213
256
|
rows: () => ({ attrs: {}, event: {} }),
|
|
214
257
|
theme: '@b sz:l',
|
|
215
258
|
allSelected: false,
|
|
259
|
+
layout: 'auto',
|
|
260
|
+
virtual: false,
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* 桌面表格与移动列表只渲染一套:两套都渲染再用 CSS 隐藏,
|
|
265
|
+
* 隐藏的那套照样要为每行每列创建组件和 DOM,大表开销翻倍。
|
|
266
|
+
* auto 模式下,服务端渲染和客户端激活时仍两套都出(交给 ui-hide),保证结构一致;
|
|
267
|
+
* 挂载后按与 ui-hide 相同的断点(<800px 为移动端)只保留一套。
|
|
268
|
+
*/
|
|
269
|
+
const mobileQuery = typeof window !== 'undefined' && window.matchMedia ? window.matchMedia('(max-width: 799px)') : null;
|
|
270
|
+
// 激活(hydration)时组件的 vnode 已指向服务端 DOM,这时不能提前裁掉任何一套
|
|
271
|
+
const hydrating = !!getCurrentInstance()?.vnode.el;
|
|
272
|
+
const isMobile = ref<boolean | null>(mobileQuery && !hydrating ? mobileQuery.matches : null);
|
|
273
|
+
const onMobileChange = (event: MediaQueryListEvent) => {
|
|
274
|
+
isMobile.value = event.matches;
|
|
275
|
+
};
|
|
276
|
+
const showTable = computed(() => props.layout === 'table' || (props.layout !== 'list' && isMobile.value !== true));
|
|
277
|
+
const showList = computed(() => props.layout === 'list' || (props.layout !== 'table' && isMobile.value !== false));
|
|
278
|
+
onMounted(() => {
|
|
279
|
+
if (!mobileQuery) return;
|
|
280
|
+
isMobile.value = mobileQuery.matches;
|
|
281
|
+
mobileQuery.addEventListener?.('change', onMobileChange);
|
|
282
|
+
});
|
|
283
|
+
onUnmounted(() => {
|
|
284
|
+
mobileQuery?.removeEventListener?.('change', onMobileChange);
|
|
216
285
|
});
|
|
217
286
|
|
|
218
287
|
// 内部数据
|
|
@@ -233,7 +302,7 @@
|
|
|
233
302
|
const name = computed(() => 'confs' + route.path?.replace(/\//g, '_')) || '';
|
|
234
303
|
|
|
235
304
|
const colSizeAttrs = (col: TableColumn) => {
|
|
236
|
-
const raw =
|
|
305
|
+
const raw = typeof col.width === 'function' ? col.width() : col.width;
|
|
237
306
|
const width = raw == null ? '' : String(raw).trim();
|
|
238
307
|
const attrs: Record<string, any> = {};
|
|
239
308
|
if (width === 'min' || (!width && col.fixed)) attrs['min-width'] = '';
|
|
@@ -242,23 +311,27 @@
|
|
|
242
311
|
return attrs;
|
|
243
312
|
};
|
|
244
313
|
|
|
245
|
-
//
|
|
314
|
+
// 全选状态。未开启多选时不读也不写 __selected,避免往业务数据里塞字段、对每行建依赖
|
|
246
315
|
const all = computed({
|
|
247
316
|
get() {
|
|
248
|
-
|
|
317
|
+
if (!props.selected) return false;
|
|
318
|
+
return props.lists.length > 0 && props.lists.every((item: any) => item.__selected === true);
|
|
249
319
|
},
|
|
250
320
|
set(value: boolean) {
|
|
321
|
+
if (!props.selected) return;
|
|
251
322
|
props.lists.forEach((item: any) => {
|
|
252
323
|
item.__selected = value;
|
|
253
324
|
});
|
|
254
325
|
},
|
|
255
326
|
});
|
|
256
327
|
|
|
257
|
-
//
|
|
328
|
+
// 向内同步:只在与当前状态不一致时改写。
|
|
329
|
+
// 挂载时 allSelected=false 不能把预选的行清掉;
|
|
330
|
+
// v-model:allSelected 下取消单行会回传 false,也不能借此清空其余行
|
|
258
331
|
watch(
|
|
259
332
|
() => props.allSelected,
|
|
260
333
|
(v) => {
|
|
261
|
-
all.value = v;
|
|
334
|
+
if (props.selected && v !== all.value) all.value = v;
|
|
262
335
|
},
|
|
263
336
|
{ immediate: true },
|
|
264
337
|
);
|
|
@@ -278,6 +351,9 @@
|
|
|
278
351
|
state.__last = 0;
|
|
279
352
|
state.table = deepcopy(props.table || [])
|
|
280
353
|
?.map((col: TableColumn, idx: number) => {
|
|
354
|
+
// fixed: true 等同左固定(否则排序时不会被归到左侧)
|
|
355
|
+
if (col.fixed === true) col.fixed = 'l';
|
|
356
|
+
|
|
281
357
|
// 标题处理
|
|
282
358
|
if (isFunction(col.label)) {
|
|
283
359
|
col.label = col.label(props.lists);
|
|
@@ -356,24 +432,254 @@
|
|
|
356
432
|
);
|
|
357
433
|
|
|
358
434
|
const tableRef: any = ref(null);
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
435
|
+
const scrollRef = ref<HTMLElement | null>(null);
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* 固定列:sticky 由 CSS 负责,这里只算多个固定列叠放时的 left / right 偏移,
|
|
439
|
+
* 并只给紧挨滚动区的那一列(左侧最后一列、右侧第一列)标 col-fixed-side 显示阴影。
|
|
440
|
+
* 列宽取自 ResizeObserver 条目,不调用 getBoundingClientRect,不强制重排。
|
|
441
|
+
* 原先由 @fekit/itable 完成:全局单例(多个表格互相覆盖)、按列位置而非 fixed 值判断左右,
|
|
442
|
+
* 并在回调里逐列 getBoundingClientRect。
|
|
443
|
+
*/
|
|
444
|
+
const fixedWidths = reactive<Record<string, number>>({});
|
|
445
|
+
const fixedCells = new Map<string, Element>();
|
|
446
|
+
const fixedKeyOf = new WeakMap<Element, string>();
|
|
447
|
+
let fixedObserver: ResizeObserver | undefined;
|
|
448
|
+
const bindFixedCell = (el: Element | null, key: string) => {
|
|
449
|
+
const previous = fixedCells.get(key);
|
|
450
|
+
if (previous === el) return;
|
|
451
|
+
if (previous) {
|
|
452
|
+
fixedObserver?.unobserve(previous);
|
|
453
|
+
fixedCells.delete(key);
|
|
454
|
+
}
|
|
455
|
+
if (!el || typeof ResizeObserver === 'undefined') return;
|
|
456
|
+
fixedObserver ||= new ResizeObserver((entries) => {
|
|
457
|
+
entries.forEach((entry) => {
|
|
458
|
+
const cellKey = fixedKeyOf.get(entry.target);
|
|
459
|
+
if (!cellKey) return;
|
|
460
|
+
const box = entry.borderBoxSize?.[0];
|
|
461
|
+
const width = box ? box.inlineSize : (entry.target as HTMLElement).offsetWidth;
|
|
462
|
+
if (Math.abs((fixedWidths[cellKey] || 0) - width) > 0.5) fixedWidths[cellKey] = width;
|
|
463
|
+
});
|
|
464
|
+
updateScrollSide();
|
|
465
|
+
});
|
|
466
|
+
fixedKeyOf.set(el, key);
|
|
467
|
+
fixedCells.set(key, el);
|
|
468
|
+
fixedObserver.observe(el);
|
|
469
|
+
};
|
|
470
|
+
const fixedLayout = computed(() => {
|
|
471
|
+
const cells: Array<{ key: string; side: 'l' | 'r' | '' }> = [];
|
|
472
|
+
if (props.selected) cells.push({ key: '__select', side: 'l' });
|
|
473
|
+
state.table.forEach((col) => {
|
|
474
|
+
if (col.__close) return;
|
|
475
|
+
cells.push({ key: col.field, side: col.fixed ? (col.fixed === 'r' ? 'r' : 'l') : '' });
|
|
364
476
|
});
|
|
477
|
+
if (slots.operate) cells.push({ key: '__operate', side: 'r' });
|
|
478
|
+
const offsets: Record<string, { left?: string; right?: string }> = {};
|
|
479
|
+
const sides: Record<string, 'l' | 'r'> = {};
|
|
480
|
+
let left = 0;
|
|
481
|
+
let lastLeft = '';
|
|
482
|
+
cells.forEach(({ key, side }) => {
|
|
483
|
+
if (side !== 'l') return;
|
|
484
|
+
offsets[key] = { left: `${left}px` };
|
|
485
|
+
left += fixedWidths[key] || 0;
|
|
486
|
+
lastLeft = key;
|
|
487
|
+
});
|
|
488
|
+
let right = 0;
|
|
489
|
+
let firstRight = '';
|
|
490
|
+
[...cells].reverse().forEach(({ key, side }) => {
|
|
491
|
+
if (side !== 'r') return;
|
|
492
|
+
offsets[key] = { right: `${right}px` };
|
|
493
|
+
right += fixedWidths[key] || 0;
|
|
494
|
+
firstRight = key;
|
|
495
|
+
});
|
|
496
|
+
if (lastLeft) sides[lastLeft] = 'l';
|
|
497
|
+
if (firstRight) sides[firstRight] = 'r';
|
|
498
|
+
return { offsets, sides };
|
|
365
499
|
});
|
|
500
|
+
const fixedStyle = (key: string) => fixedLayout.value.offsets[key];
|
|
501
|
+
const fixedSide = (key: string) => fixedLayout.value.sides[key];
|
|
502
|
+
|
|
503
|
+
// 滚到最左 / 最右时隐藏对应一侧固定列的阴影(样式见 [scroll-side-l / r])
|
|
504
|
+
const scrollSide = reactive({ l: '1', r: '1' });
|
|
505
|
+
let sideFrame = 0;
|
|
506
|
+
const updateScrollSide = () => {
|
|
507
|
+
const el = scrollRef.value;
|
|
508
|
+
if (!el) return;
|
|
509
|
+
const overflowing = el.scrollWidth > el.clientWidth;
|
|
510
|
+
const l = !overflowing || el.scrollLeft <= 0 ? '1' : '0';
|
|
511
|
+
const r = !overflowing || el.scrollLeft + el.clientWidth >= el.scrollWidth - 1 ? '1' : '0';
|
|
512
|
+
if (scrollSide.l !== l) scrollSide.l = l;
|
|
513
|
+
if (scrollSide.r !== r) scrollSide.r = r;
|
|
514
|
+
};
|
|
515
|
+
const onScroll = () => {
|
|
516
|
+
onVirtualScroll();
|
|
517
|
+
if (sideFrame) return;
|
|
518
|
+
sideFrame = requestAnimationFrame(() => {
|
|
519
|
+
sideFrame = 0;
|
|
520
|
+
updateScrollSide();
|
|
521
|
+
});
|
|
522
|
+
};
|
|
523
|
+
// 容器或表格尺寸变化(窗口缩放、列显隐、数据变化)都可能改变是否出现横向滚动
|
|
524
|
+
let sideObserver: ResizeObserver | undefined;
|
|
366
525
|
watch(
|
|
367
|
-
|
|
368
|
-
() => {
|
|
369
|
-
|
|
370
|
-
|
|
526
|
+
[scrollRef, tableRef],
|
|
527
|
+
([area, table]) => {
|
|
528
|
+
sideObserver?.disconnect();
|
|
529
|
+
if (typeof ResizeObserver === 'undefined' || !area) return;
|
|
530
|
+
sideObserver ||= new ResizeObserver(updateScrollSide);
|
|
531
|
+
sideObserver.observe(area);
|
|
532
|
+
if (table) sideObserver.observe(table);
|
|
533
|
+
},
|
|
534
|
+
{ flush: 'post', immediate: true },
|
|
535
|
+
);
|
|
536
|
+
onUnmounted(() => {
|
|
537
|
+
if (sideFrame) cancelAnimationFrame(sideFrame);
|
|
538
|
+
fixedObserver?.disconnect();
|
|
539
|
+
sideObserver?.disconnect();
|
|
540
|
+
fixedCells.clear();
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
// 行标识
|
|
544
|
+
const rowKeyOf = (item: any, idx: number) => {
|
|
545
|
+
const key = props.rowKey;
|
|
546
|
+
if (isFunction(key)) return key(item, idx);
|
|
547
|
+
if (key && item && typeof item === 'object') return item[key] ?? idx;
|
|
548
|
+
return idx;
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* 虚拟滚动:上下用占位行撑出滚动高度,只渲染可视区附近的行。
|
|
553
|
+
* 行高各不相同(单元格可能换行、放表单控件),按行对象缓存实测高度,
|
|
554
|
+
* 没测过的按已测平均值估算;行对象不变则删行、排序后缓存仍有效。
|
|
555
|
+
*/
|
|
556
|
+
const virtualEnabled = computed(() => !!props.virtual);
|
|
557
|
+
const virtualEstimate = computed(() => (isObject(props.virtual) && props.virtual.estimate) || 48);
|
|
558
|
+
const virtualOverscan = computed(() => (isObject(props.virtual) && props.virtual.overscan) || 600);
|
|
559
|
+
const scrollTop = ref(0);
|
|
560
|
+
// 拿到容器真实高度前先按一屏估算
|
|
561
|
+
const viewportHeight = ref(800);
|
|
562
|
+
const rowHeights = new WeakMap<object, number>();
|
|
563
|
+
const rowHeightsVersion = ref(0);
|
|
564
|
+
let measuredTotal = 0;
|
|
565
|
+
let measuredCount = 0;
|
|
566
|
+
const estimatedRowHeight = () => (measuredCount ? measuredTotal / measuredCount : virtualEstimate.value);
|
|
567
|
+
|
|
568
|
+
const columnCount = computed(
|
|
569
|
+
() => (props.selected ? 1 : 0) + state.table.filter((col) => !col.__close).length + (slots.operate ? 1 : 0),
|
|
570
|
+
);
|
|
571
|
+
const virtualPadStyle = (height: number) => ({ height: `${height}px`, padding: 0, border: 0, background: 'transparent' });
|
|
572
|
+
|
|
573
|
+
const virtualOffsetsAll = computed(() => {
|
|
574
|
+
void rowHeightsVersion.value;
|
|
575
|
+
const estimate = estimatedRowHeight();
|
|
576
|
+
return virtualOffsets(props.lists.map((item: any) => (item && typeof item === 'object' && rowHeights.get(item)) || estimate));
|
|
577
|
+
});
|
|
578
|
+
const virtualSlice = computed(() => {
|
|
579
|
+
if (!virtualEnabled.value) {
|
|
580
|
+
return { rows: props.lists.map((item: any, idx: number) => ({ item, idx })), padTop: 0, padBottom: 0 };
|
|
581
|
+
}
|
|
582
|
+
const offsets = virtualOffsetsAll.value;
|
|
583
|
+
const { start, end } = virtualRange(offsets, scrollTop.value, viewportHeight.value, virtualOverscan.value);
|
|
584
|
+
const rows = [];
|
|
585
|
+
for (let idx = start; idx < end; idx += 1) rows.push({ item: props.lists[idx], idx });
|
|
586
|
+
return { rows, padTop: offsets[start] || 0, padBottom: Math.max(0, offsets[offsets.length - 1] - (offsets[end] || 0)) };
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
let scrollFrame = 0;
|
|
590
|
+
const onVirtualScroll = () => {
|
|
591
|
+
if (!virtualEnabled.value || scrollFrame) return;
|
|
592
|
+
scrollFrame = requestAnimationFrame(() => {
|
|
593
|
+
scrollFrame = 0;
|
|
594
|
+
if (scrollRef.value) scrollTop.value = scrollRef.value.scrollTop;
|
|
595
|
+
});
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
// 行高取自 ResizeObserver 条目(布局之后回调,不强制重排)
|
|
599
|
+
const rowItems = new WeakMap<Element, object>();
|
|
600
|
+
let rowObserver: ResizeObserver | undefined;
|
|
601
|
+
const onRowResize = (entries: ResizeObserverEntry[]) => {
|
|
602
|
+
if (!virtualEnabled.value) return;
|
|
603
|
+
const before = virtualOffsetsAll.value;
|
|
604
|
+
let changed = false;
|
|
605
|
+
for (const entry of entries) {
|
|
606
|
+
const item = rowItems.get(entry.target);
|
|
607
|
+
if (!item) continue;
|
|
608
|
+
const box = entry.borderBoxSize?.[0];
|
|
609
|
+
const height = box ? box.blockSize : entry.contentRect.height;
|
|
610
|
+
if (!height) continue;
|
|
611
|
+
const previous = rowHeights.get(item);
|
|
612
|
+
if (previous !== undefined && Math.abs(previous - height) < 0.5) continue;
|
|
613
|
+
if (previous === undefined) measuredCount += 1;
|
|
614
|
+
measuredTotal += height - (previous ?? 0);
|
|
615
|
+
rowHeights.set(item, height);
|
|
616
|
+
changed = true;
|
|
617
|
+
}
|
|
618
|
+
if (!changed) return;
|
|
619
|
+
rowHeightsVersion.value += 1;
|
|
620
|
+
// 实测值和平均估算都会让上方偏移变化,按视口顶部那一行锚定补偿滚动位置,避免正在看的内容跳动
|
|
621
|
+
const el = scrollRef.value;
|
|
622
|
+
if (!el || el.scrollTop <= 0) return;
|
|
623
|
+
const anchor = firstRowEndingAfter(before, el.scrollTop);
|
|
624
|
+
const shift = virtualOffsetsAll.value[anchor] - before[anchor];
|
|
625
|
+
if (Math.abs(shift) < 0.5) return;
|
|
626
|
+
// 等占位行按新高度渲染后再改 scrollTop,否则可能被旧的滚动高度截断
|
|
627
|
+
nextTick(() => {
|
|
628
|
+
el.scrollTop += shift;
|
|
629
|
+
scrollTop.value = el.scrollTop;
|
|
630
|
+
});
|
|
631
|
+
};
|
|
632
|
+
// 按元素记录行对象:未设 rowKey 时同一个 <tr> 会被复用给别的行,按行对象反查会漏掉仍在显示的元素
|
|
633
|
+
const observedRows = new Set<Element>();
|
|
634
|
+
let pruneQueued = false;
|
|
635
|
+
const pruneRows = () => {
|
|
636
|
+
pruneQueued = false;
|
|
637
|
+
// 关掉虚拟滚动时 <tr> 还在文档里但不再绑定,也要一并释放
|
|
638
|
+
for (const row of observedRows) {
|
|
639
|
+
if (row.isConnected && virtualEnabled.value) continue;
|
|
640
|
+
rowObserver?.unobserve(row);
|
|
641
|
+
rowItems.delete(row);
|
|
642
|
+
observedRows.delete(row);
|
|
643
|
+
}
|
|
644
|
+
};
|
|
645
|
+
const bindVirtualRow = (el: Element | null, item: any) => {
|
|
646
|
+
// 卸载时 ref 回调拿到 null,元素此刻还在文档里,等更新完再清理
|
|
647
|
+
if (!el) {
|
|
648
|
+
if (!pruneQueued) {
|
|
649
|
+
pruneQueued = true;
|
|
650
|
+
nextTick(pruneRows);
|
|
651
|
+
}
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
if (!item || typeof item !== 'object' || rowItems.get(el) === item || typeof ResizeObserver === 'undefined') return;
|
|
655
|
+
rowObserver ||= new ResizeObserver(onRowResize);
|
|
656
|
+
rowItems.set(el, item);
|
|
657
|
+
// 重新 observe 会立即回调一次,换了行对象的元素也能测到新行的高度
|
|
658
|
+
rowObserver.unobserve(el);
|
|
659
|
+
rowObserver.observe(el);
|
|
660
|
+
observedRows.add(el);
|
|
661
|
+
};
|
|
662
|
+
|
|
663
|
+
let viewportObserver: ResizeObserver | undefined;
|
|
664
|
+
watch(
|
|
665
|
+
[virtualEnabled, scrollRef],
|
|
666
|
+
([enabled, el]) => {
|
|
667
|
+
viewportObserver?.disconnect();
|
|
668
|
+
viewportObserver = undefined;
|
|
669
|
+
if (!enabled || !el || typeof ResizeObserver === 'undefined') return;
|
|
670
|
+
scrollTop.value = el.scrollTop;
|
|
671
|
+
viewportObserver = new ResizeObserver(([entry]) => {
|
|
672
|
+
if (entry?.contentRect.height) viewportHeight.value = entry.contentRect.height;
|
|
371
673
|
});
|
|
674
|
+
viewportObserver.observe(el);
|
|
372
675
|
},
|
|
373
|
-
{
|
|
676
|
+
{ flush: 'post', immediate: true },
|
|
374
677
|
);
|
|
375
678
|
onUnmounted(() => {
|
|
376
|
-
|
|
679
|
+
if (scrollFrame) cancelAnimationFrame(scrollFrame);
|
|
680
|
+
viewportObserver?.disconnect();
|
|
681
|
+
rowObserver?.disconnect();
|
|
682
|
+
observedRows.clear();
|
|
377
683
|
});
|
|
378
684
|
|
|
379
685
|
// 设置字段升降序
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** 每行顶部的累计偏移,末尾多一项为总高度 */
|
|
2
|
+
export const virtualOffsets = (heights: ArrayLike<number>) => {
|
|
3
|
+
const offsets = new Float64Array(heights.length + 1);
|
|
4
|
+
for (let i = 0; i < heights.length; i += 1) offsets[i + 1] = offsets[i] + heights[i];
|
|
5
|
+
return offsets;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
/** 第一个底边越过 y 的行 */
|
|
9
|
+
export const firstRowEndingAfter = (offsets: Float64Array, y: number) => {
|
|
10
|
+
let low = 0;
|
|
11
|
+
let high = offsets.length - 1;
|
|
12
|
+
while (low < high) {
|
|
13
|
+
const mid = (low + high) >> 1;
|
|
14
|
+
if (offsets[mid + 1] > y) high = mid;
|
|
15
|
+
else low = mid + 1;
|
|
16
|
+
}
|
|
17
|
+
return low;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** 可视区(上下各留 overscan 像素)内的行区间 [start, end) */
|
|
21
|
+
export const virtualRange = (offsets: Float64Array, scrollTop: number, viewport: number, overscan: number) => {
|
|
22
|
+
const count = offsets.length - 1;
|
|
23
|
+
if (count <= 0) return { start: 0, end: 0 };
|
|
24
|
+
const start = Math.min(count - 1, firstRowEndingAfter(offsets, Math.max(0, scrollTop - overscan)));
|
|
25
|
+
const end = Math.max(start + 1, Math.min(count, firstRowEndingAfter(offsets, scrollTop + viewport + overscan) + 1));
|
|
26
|
+
return { start, end };
|
|
27
|
+
};
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import FormLoader from './FormLoader/FormLoader.vue';
|
|
2
|
+
export { registerField } from './FormLoader/FormRender.vue';
|
|
3
|
+
export type { FormValue, FormItem, FormArray, FormRule, FormContext, FormArrayContext, FormLinkage, FormError, FormValidateResult, FormLoaderExpose, FieldEntry } from './FormLoader/FormRender.vue';
|
|
2
4
|
import LimitLoader from './LimitLoader';
|
|
3
5
|
export * from './TableLoader/index';
|
|
4
6
|
import TableLoader from './TableLoader/TableLoader.vue';
|
package/src/index.ts
CHANGED
package/.prettierrc
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"$schema": "https://json.schemastore.org/prettierrc",
|
|
3
|
-
"semi": true,
|
|
4
|
-
"arrowParens": "always",
|
|
5
|
-
"trailingComma": "all",
|
|
6
|
-
"singleQuote": true,
|
|
7
|
-
"printWidth": 180,
|
|
8
|
-
"tabWidth": 2,
|
|
9
|
-
"bracketSameLine": false,
|
|
10
|
-
"vueIndentScriptAndStyle": true,
|
|
11
|
-
"htmlWhitespaceSensitivity": "ignore"
|
|
12
|
-
}
|
package/rollup.config.js
DELETED
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
const resolve = require('@rollup/plugin-node-resolve');
|
|
2
|
-
const commonjs = require('@rollup/plugin-commonjs');
|
|
3
|
-
const vue = require('rollup-plugin-vue');
|
|
4
|
-
const postcss = require('rollup-plugin-postcss');
|
|
5
|
-
const typescript = require('@rollup/plugin-typescript');
|
|
6
|
-
const ts = require('typescript');
|
|
7
|
-
const path = require('path');
|
|
8
|
-
const { pathToFileURL } = require('url');
|
|
9
|
-
const { terser } = require('rollup-plugin-terser');
|
|
10
|
-
const scss = require('postcss-scss');
|
|
11
|
-
|
|
12
|
-
const vueTypeScript = () => ({
|
|
13
|
-
name: 'vue-typescript',
|
|
14
|
-
transform(code, id) {
|
|
15
|
-
if (!/\.vue\?.*type=script.*lang\.ts$/.test(id)) return null;
|
|
16
|
-
|
|
17
|
-
const result = ts.transpileModule(code, {
|
|
18
|
-
fileName: id,
|
|
19
|
-
compilerOptions: {
|
|
20
|
-
target: ts.ScriptTarget.ESNext,
|
|
21
|
-
module: ts.ModuleKind.ESNext,
|
|
22
|
-
sourceMap: true
|
|
23
|
-
}
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
return {
|
|
27
|
-
code: result.outputText,
|
|
28
|
-
map: result.sourceMapText ? JSON.parse(result.sourceMapText) : null
|
|
29
|
-
};
|
|
30
|
-
}
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
module.exports = {
|
|
34
|
-
input: 'src/components/loader/index.ts',
|
|
35
|
-
output: {
|
|
36
|
-
file: 'dist/bundle.js',
|
|
37
|
-
format: 'es',
|
|
38
|
-
inlineDynamicImports: true,
|
|
39
|
-
sourcemap: true
|
|
40
|
-
},
|
|
41
|
-
plugins: [
|
|
42
|
-
resolve({
|
|
43
|
-
extensions: ['.js', '.ts', '.vue']
|
|
44
|
-
}),
|
|
45
|
-
commonjs(),
|
|
46
|
-
vue({
|
|
47
|
-
css: false,
|
|
48
|
-
compileTemplate: true,
|
|
49
|
-
preprocessStyles: true,
|
|
50
|
-
preprocessOptions: {
|
|
51
|
-
scss: {
|
|
52
|
-
importers: [
|
|
53
|
-
{
|
|
54
|
-
findFileUrl(url) {
|
|
55
|
-
if (url !== '@neatui/css') return null;
|
|
56
|
-
return pathToFileURL(path.resolve(__dirname, '../css/neatui.scss'));
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
]
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
}),
|
|
63
|
-
vueTypeScript(),
|
|
64
|
-
postcss({
|
|
65
|
-
extensions: ['.css', '.scss'],
|
|
66
|
-
extract: true,
|
|
67
|
-
minimize: true,
|
|
68
|
-
syntax: scss
|
|
69
|
-
}),
|
|
70
|
-
typescript({
|
|
71
|
-
tsconfig: './tsconfig.json'
|
|
72
|
-
}),
|
|
73
|
-
terser()
|
|
74
|
-
]
|
|
75
|
-
};
|
package/tsconfig.json
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"compilerOptions": {
|
|
3
|
-
"target": "esnext",
|
|
4
|
-
"module": "esnext",
|
|
5
|
-
"strict": true,
|
|
6
|
-
"jsx": "preserve",
|
|
7
|
-
"moduleResolution": "node",
|
|
8
|
-
"experimentalDecorators": true,
|
|
9
|
-
"skipLibCheck": true,
|
|
10
|
-
"esModuleInterop": true,
|
|
11
|
-
"allowSyntheticDefaultImports": true,
|
|
12
|
-
"forceConsistentCasingInFileNames": true,
|
|
13
|
-
"useDefineForClassFields": true,
|
|
14
|
-
"sourceMap": true,
|
|
15
|
-
"baseUrl": ".",
|
|
16
|
-
"paths": {
|
|
17
|
-
"@/*": ["src/*"]
|
|
18
|
-
},
|
|
19
|
-
"lib": ["esnext", "dom", "dom.iterable", "scripthost"]
|
|
20
|
-
},
|
|
21
|
-
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "src/shims-vue.d.ts"],
|
|
22
|
-
"exclude": ["node_modules"]
|
|
23
|
-
}
|