@neatui/vue 5.5.1 → 5.6.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 +2 -2
- package/src/components/basic/IFollowView.vue +188 -3
- package/src/components/form/Cascader.vue +2 -1
- package/src/components/form/DatePicker.vue +3 -1
- package/src/components/form/DateRangePicker.vue +3 -1
- package/src/components/form/DateView.vue +2 -2
- package/src/components/form/MoreSelect.vue +3 -1
- package/src/components/form/MoreSelectTree.vue +3 -1
- package/src/components/form/PageMoreSelect.vue +2 -1
- package/src/components/form/PageSelect.vue +2 -1
- package/src/components/form/SearchMoreSelect.vue +2 -1
- package/src/components/form/SearchSelect.vue +3 -1
- package/src/components/form/Select.vue +3 -1
- package/src/components/form/SelectTree/SelectTree.vue +3 -1
- package/src/components/loader/FormLoader/FormLoader.vue +56 -20
- package/src/components/loader/TableLoader/TableLoader.vue +11 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@neatui/vue",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.6.0",
|
|
4
4
|
"description": "basic component ui",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"license": "MIT",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"@fekit/scrollto": "^3.0.3",
|
|
37
37
|
"@fekit/toast": "^2.7.2",
|
|
38
38
|
"@fekit/utils": "^6.0.0",
|
|
39
|
-
"@neatui/css": "^5.
|
|
39
|
+
"@neatui/css": "^5.6.0",
|
|
40
40
|
"@rollup/plugin-commonjs": "^26.0.1",
|
|
41
41
|
"@rollup/plugin-node-resolve": "^15.2.3",
|
|
42
42
|
"@vue/compiler-sfc": "^3.4.30",
|
|
@@ -16,14 +16,23 @@
|
|
|
16
16
|
@mouseout="tipHover(0)"
|
|
17
17
|
@click="clickhide"
|
|
18
18
|
>
|
|
19
|
-
<div
|
|
20
|
-
|
|
19
|
+
<div
|
|
20
|
+
ref="box"
|
|
21
|
+
:ui-tips-box="state.pos"
|
|
22
|
+
:class="tipBoxClass"
|
|
23
|
+
:style="tipBoxStyle"
|
|
24
|
+
@touchstart="onSheetTouchStart"
|
|
25
|
+
@touchmove="onSheetTouchMove"
|
|
26
|
+
@touchend="onSheetTouchEnd"
|
|
27
|
+
@touchcancel="onSheetTouchCancel"
|
|
28
|
+
>
|
|
29
|
+
<div ui-hide=">mob" :class="`mob-follow-tips w-full nx-sl nt-sl ${closable ? 'nb-ss' : 'nb-sl'}`" ui-flex="row xm">
|
|
21
30
|
<div class="w-xs*s"></div>
|
|
22
31
|
<div>
|
|
23
32
|
<h5 class="bold">{{ title }}</h5>
|
|
24
33
|
</div>
|
|
25
34
|
<div class="w-xs*s" ui-flex="row rm">
|
|
26
|
-
<button ui-btn="@a none s :square" @click="toggle">
|
|
35
|
+
<button v-if="closable" ui-btn="@a none s :square" aria-label="关闭" @click="toggle">
|
|
27
36
|
<i ui-icon="close"></i>
|
|
28
37
|
</button>
|
|
29
38
|
</div>
|
|
@@ -41,6 +50,12 @@
|
|
|
41
50
|
// hover:Teleport 内容的鼠标进出不会冒泡到祖先,需要逐级通知。
|
|
42
51
|
type FollowContext = { children: Set<FollowBoundary>; hover: (on: boolean) => void };
|
|
43
52
|
const followContextKey = Symbol('follow-context');
|
|
53
|
+
// 按打开顺序记录已打开的浮层,Esc 只由最后打开的一层处理。
|
|
54
|
+
const openStack: FollowBoundary[] = [];
|
|
55
|
+
const removeOpen = (item: FollowBoundary) => {
|
|
56
|
+
const idx = openStack.indexOf(item);
|
|
57
|
+
if (idx > -1) openStack.splice(idx, 1);
|
|
58
|
+
};
|
|
44
59
|
</script>
|
|
45
60
|
<script setup lang="ts">
|
|
46
61
|
import { reactive, ref, onMounted, onUnmounted, watch, inject, provide } from 'vue';
|
|
@@ -66,6 +81,8 @@
|
|
|
66
81
|
dragTipHide?: boolean;
|
|
67
82
|
mobile?: boolean;
|
|
68
83
|
title?: string;
|
|
84
|
+
/** 移动端底部面板标题栏是否显示关闭按钮。 */
|
|
85
|
+
closable?: boolean;
|
|
69
86
|
zIndex?: string | number | null;
|
|
70
87
|
/** 桌面端是否放入浏览器 top layer(Popover API),盖住整页任意层级;移动端底部面板始终放入。 */
|
|
71
88
|
topLayer?: boolean;
|
|
@@ -88,6 +105,7 @@
|
|
|
88
105
|
dragTipHide: undefined,
|
|
89
106
|
mobile: true,
|
|
90
107
|
title: '',
|
|
108
|
+
closable: false,
|
|
91
109
|
zIndex: undefined,
|
|
92
110
|
topLayer: true,
|
|
93
111
|
});
|
|
@@ -162,8 +180,13 @@
|
|
|
162
180
|
() => state.show,
|
|
163
181
|
(show) => {
|
|
164
182
|
if (show) {
|
|
183
|
+
removeOpen(containsFollow);
|
|
184
|
+
openStack.push(containsFollow);
|
|
165
185
|
emits('onshow');
|
|
166
186
|
} else {
|
|
187
|
+
removeOpen(containsFollow);
|
|
188
|
+
// 拖动中被其他途径关闭时,清理拖动残留的内联样式。
|
|
189
|
+
if (sheetDrag) endSheetDrag(false);
|
|
167
190
|
children.forEach(item => item.hide());
|
|
168
191
|
emits('onhide');
|
|
169
192
|
}
|
|
@@ -359,6 +382,156 @@
|
|
|
359
382
|
};
|
|
360
383
|
const onTouchCancel = () => { outsideTouch = null; };
|
|
361
384
|
|
|
385
|
+
// 底部面板下滑关闭(对齐 iOS/Android 原生嵌套滚动):
|
|
386
|
+
// 标题栏随时可拖;内容区在手指下所有可滚动祖先都位于顶部时,向下拖动由面板接管;
|
|
387
|
+
// 同一手势中内容先滚回顶部、继续下拉也会接力为拖动;拖回原位后交还内容滚动。
|
|
388
|
+
const DRAG_SLOP = 4;
|
|
389
|
+
const SHEET_MASK_ALPHA = 0.5; // 与样式中暗层透明度一致
|
|
390
|
+
const VELOCITY_WINDOW = 100; // ms
|
|
391
|
+
type SheetDrag = {
|
|
392
|
+
id: number;
|
|
393
|
+
x: number;
|
|
394
|
+
y: number;
|
|
395
|
+
lastY: number;
|
|
396
|
+
anchor: number;
|
|
397
|
+
offset: number;
|
|
398
|
+
mode: 'pending' | 'drag' | 'scroll' | 'none';
|
|
399
|
+
scrollers: HTMLElement[];
|
|
400
|
+
samples: { y: number; t: number }[];
|
|
401
|
+
};
|
|
402
|
+
let sheetDrag: SheetDrag | null = null;
|
|
403
|
+
const isScrollableY = (el: HTMLElement) => /(auto|scroll|overlay)/.test(getComputedStyle(el).overflowY) && el.scrollHeight > el.clientHeight + 1;
|
|
404
|
+
const scrollersOf = (target: EventTarget | null) => {
|
|
405
|
+
const list: HTMLElement[] = [];
|
|
406
|
+
for (let el = target instanceof Element ? target : null; el && el !== box.value; el = el.parentElement) {
|
|
407
|
+
if (el instanceof HTMLElement && isScrollableY(el)) list.push(el);
|
|
408
|
+
}
|
|
409
|
+
return list;
|
|
410
|
+
};
|
|
411
|
+
// 留 3px 容差:惯性回弹或小数像素未完全归零时也视为顶部,被遮住的内容可忽略。
|
|
412
|
+
const TOP_TOLERANCE = 3;
|
|
413
|
+
const atTop = (d: SheetDrag) => d.scrollers.every(el => el.scrollTop < TOP_TOLERANCE);
|
|
414
|
+
const setSheetOffset = (offset: number | null) => {
|
|
415
|
+
const panel: HTMLElement | null = box.value;
|
|
416
|
+
const mask: HTMLElement | null = tip.value;
|
|
417
|
+
if (!panel || !mask) return;
|
|
418
|
+
if (offset === null) {
|
|
419
|
+
// 移除内联样式,交还 CSS 过渡:从当前位置动画到打开或关闭状态。
|
|
420
|
+
panel.style.removeProperty('transform');
|
|
421
|
+
panel.style.removeProperty('transition');
|
|
422
|
+
mask.style.removeProperty('background-color');
|
|
423
|
+
mask.style.removeProperty('transition');
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
const height = panel.offsetHeight || 1;
|
|
427
|
+
panel.style.setProperty('transition', 'none', 'important');
|
|
428
|
+
panel.style.setProperty('transform', `translate3d(0, ${offset}px, 0)`, 'important');
|
|
429
|
+
mask.style.setProperty('transition', 'none', 'important');
|
|
430
|
+
mask.style.setProperty('background-color', `rgba(0, 0, 0, ${SHEET_MASK_ALPHA * Math.max(0, 1 - offset / height)})`, 'important');
|
|
431
|
+
};
|
|
432
|
+
const findTouch = (list: TouchList, id: number) => [...list].find(item => item.identifier === id);
|
|
433
|
+
const endSheetDrag = (dismiss: boolean) => {
|
|
434
|
+
sheetDrag = null;
|
|
435
|
+
setSheetOffset(null);
|
|
436
|
+
if (dismiss) dismissOutside();
|
|
437
|
+
};
|
|
438
|
+
const onSheetTouchStart = (event: TouchEvent) => {
|
|
439
|
+
// 拖动中有新手指落下:放弃本次拖动并回弹,不开始新的拖动;
|
|
440
|
+
// 单指时视为上一手势的结束事件丢失,清理后正常开始。
|
|
441
|
+
if (sheetDrag) {
|
|
442
|
+
endSheetDrag(false);
|
|
443
|
+
if (event.touches.length > 1) return;
|
|
444
|
+
}
|
|
445
|
+
const touch = event.touches[0];
|
|
446
|
+
if (!state.show || !isSheet() || event.touches.length !== 1 || !touch) return;
|
|
447
|
+
// 滑块等自身需要拖动的控件不参与。
|
|
448
|
+
if (event.target instanceof Element && event.target.closest('input[type="range"], [ui-sheet-nodrag]')) return;
|
|
449
|
+
sheetDrag = {
|
|
450
|
+
id: touch.identifier,
|
|
451
|
+
x: touch.clientX,
|
|
452
|
+
y: touch.clientY,
|
|
453
|
+
lastY: touch.clientY,
|
|
454
|
+
anchor: touch.clientY,
|
|
455
|
+
offset: 0,
|
|
456
|
+
mode: 'pending',
|
|
457
|
+
scrollers: scrollersOf(event.target),
|
|
458
|
+
samples: [{ y: touch.clientY, t: event.timeStamp }],
|
|
459
|
+
};
|
|
460
|
+
};
|
|
461
|
+
const onSheetTouchMove = (event: TouchEvent) => {
|
|
462
|
+
const d = sheetDrag;
|
|
463
|
+
if (!d) return;
|
|
464
|
+
const touch = findTouch(event.touches, d.id);
|
|
465
|
+
if (!touch || event.touches.length !== 1) {
|
|
466
|
+
// 多指介入:放弃本次拖动并回弹。
|
|
467
|
+
if (d.mode === 'drag') endSheetDrag(false);
|
|
468
|
+
sheetDrag = null;
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
const y = touch.clientY;
|
|
472
|
+
if (d.mode === 'pending') {
|
|
473
|
+
const dx = touch.clientX - d.x;
|
|
474
|
+
const dy = y - d.y;
|
|
475
|
+
if (Math.abs(dx) < DRAG_SLOP && Math.abs(dy) < DRAG_SLOP) return;
|
|
476
|
+
if (Math.abs(dx) > Math.abs(dy)) d.mode = 'none';
|
|
477
|
+
else if (dy > 0 && atTop(d)) d.mode = 'drag';
|
|
478
|
+
else d.mode = 'scroll';
|
|
479
|
+
d.anchor = y;
|
|
480
|
+
} else if (d.mode === 'scroll' && y > d.lastY && atTop(d)) {
|
|
481
|
+
// 内容已滚到顶部仍在下拉:接力为拖动面板。
|
|
482
|
+
d.mode = 'drag';
|
|
483
|
+
d.anchor = y;
|
|
484
|
+
}
|
|
485
|
+
d.lastY = y;
|
|
486
|
+
if (d.mode !== 'drag') return;
|
|
487
|
+
if (event.cancelable) event.preventDefault();
|
|
488
|
+
const offset = y - d.anchor;
|
|
489
|
+
// 接管瞬间锚点即当前位置(offset 为 0),只有真正推过原位才交还。
|
|
490
|
+
if (offset < 0 && d.scrollers.length) {
|
|
491
|
+
// 拖回原位且继续上推:交还内容滚动。
|
|
492
|
+
d.offset = 0;
|
|
493
|
+
setSheetOffset(null);
|
|
494
|
+
d.mode = 'scroll';
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
d.offset = Math.max(0, offset);
|
|
498
|
+
setSheetOffset(d.offset);
|
|
499
|
+
d.samples.push({ y, t: event.timeStamp });
|
|
500
|
+
while (d.samples.length > 2 && event.timeStamp - d.samples[0]!.t > VELOCITY_WINDOW) d.samples.shift();
|
|
501
|
+
};
|
|
502
|
+
const onSheetTouchEnd = (event: TouchEvent) => {
|
|
503
|
+
const d = sheetDrag;
|
|
504
|
+
if (!d || !findTouch(event.changedTouches, d.id)) return;
|
|
505
|
+
sheetDrag = null;
|
|
506
|
+
if (d.mode !== 'drag' || d.offset <= 0) {
|
|
507
|
+
setSheetOffset(null);
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
const first = d.samples[0]!;
|
|
511
|
+
const last = d.samples[d.samples.length - 1]!;
|
|
512
|
+
const elapsed = event.timeStamp - last.t > VELOCITY_WINDOW ? Infinity : last.t - first.t;
|
|
513
|
+
const velocity = elapsed > 0 && elapsed !== Infinity ? (last.y - first.y) / elapsed : 0; // px/ms,向下为正
|
|
514
|
+
const height = box.value?.offsetHeight || 1;
|
|
515
|
+
// 按速度投射落点(快速下甩即关闭),明显上推时回弹。
|
|
516
|
+
const projected = d.offset + velocity * 200;
|
|
517
|
+
endSheetDrag(velocity > -0.2 && (projected > height / 2 || velocity > 0.6));
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
// 系统中断手势(来电、手势被接管等):只回弹,不做关闭判定。
|
|
521
|
+
const onSheetTouchCancel = (event: TouchEvent) => {
|
|
522
|
+
const d = sheetDrag;
|
|
523
|
+
if (!d || !findTouch(event.changedTouches, d.id)) return;
|
|
524
|
+
endSheetDrag(false);
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
// Esc 关闭最后打开的浮层;捕获阶段先于 Layer 的 Esc 处理,并以 preventDefault 阻止其关闭外层弹窗。
|
|
528
|
+
const onKeydown = (event: KeyboardEvent) => {
|
|
529
|
+
if (event.key !== 'Escape' || event.defaultPrevented || event.isComposing || !state.show) return;
|
|
530
|
+
if (openStack[openStack.length - 1] !== containsFollow) return;
|
|
531
|
+
event.preventDefault();
|
|
532
|
+
dismissOutside();
|
|
533
|
+
};
|
|
534
|
+
|
|
362
535
|
const ensureTeleportTarget = () => {
|
|
363
536
|
let area = document.querySelector('#ifollow');
|
|
364
537
|
if (!area) {
|
|
@@ -370,6 +543,8 @@
|
|
|
370
543
|
};
|
|
371
544
|
|
|
372
545
|
onMounted(() => {
|
|
546
|
+
// 初始即显示(modelValue=1)时 show 监听不会触发,补登记打开顺序。
|
|
547
|
+
if (state.show && !openStack.includes(containsFollow)) openStack.push(containsFollow);
|
|
373
548
|
ensureTeleportTarget();
|
|
374
549
|
document.addEventListener('pointerdown', onPressStart, true);
|
|
375
550
|
document.addEventListener('pointerup', onPressEnd, true);
|
|
@@ -380,6 +555,7 @@
|
|
|
380
555
|
document.addEventListener('touchmove', onTouchMove, { passive: false });
|
|
381
556
|
document.addEventListener('touchend', onTouchEnd, { passive: false });
|
|
382
557
|
document.addEventListener('touchcancel', onTouchCancel);
|
|
558
|
+
document.addEventListener('keydown', onKeydown, true);
|
|
383
559
|
});
|
|
384
560
|
|
|
385
561
|
const ex: any = ref(null);
|
|
@@ -433,6 +609,9 @@
|
|
|
433
609
|
document.removeEventListener('touchmove', onTouchMove);
|
|
434
610
|
document.removeEventListener('touchend', onTouchEnd);
|
|
435
611
|
document.removeEventListener('touchcancel', onTouchCancel);
|
|
612
|
+
document.removeEventListener('keydown', onKeydown, true);
|
|
613
|
+
sheetDrag = null;
|
|
614
|
+
removeOpen(containsFollow);
|
|
436
615
|
parentChildren?.delete(containsFollow);
|
|
437
616
|
releaseSheet();
|
|
438
617
|
outsideTouch = null;
|
|
@@ -542,6 +721,12 @@
|
|
|
542
721
|
[ui-tips-arrow] {
|
|
543
722
|
display: none;
|
|
544
723
|
}
|
|
724
|
+
|
|
725
|
+
// 内容滚到顶部后的继续下拉交给面板拖动,禁止滚动链与回弹抢手势。
|
|
726
|
+
&,
|
|
727
|
+
* {
|
|
728
|
+
overscroll-behavior-y: none;
|
|
729
|
+
}
|
|
545
730
|
}
|
|
546
731
|
}
|
|
547
732
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div :ui-form="`${theme} type:select sz:${sz}`" :ui-form-open="state.show ? 'show' : ''" class="mc-cascader">
|
|
3
|
-
<IFollowView :ui-tips="uiTips"
|
|
3
|
+
<IFollowView :ui-tips="uiTips" :closable="closable"
|
|
4
4
|
ref="ifollow"
|
|
5
5
|
:tipBoxStyle="`${state.flatOpts.length && state.flatOpts[0]?.length > 0 ? 'min-width: 10em;' : 'min-width: 100%'}`"
|
|
6
6
|
pos="bl"
|
|
@@ -96,6 +96,7 @@
|
|
|
96
96
|
const props = defineProps({
|
|
97
97
|
theme: { type: String, default: '@a' },
|
|
98
98
|
uiTips: { type: String, default: '@a' },
|
|
99
|
+
closable: { type: Boolean, default: false },
|
|
99
100
|
sz: { type: String, default: 'm' },
|
|
100
101
|
co: { type: String, default: '' },
|
|
101
102
|
block: { type: Boolean, default: true },
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div :ui-form="`${theme} type:select sz:${sz}`" ui-tips="@a co:well" :ui-tips-view="state.show">
|
|
3
|
-
<IFollowView :ui-tips="uiTips" ref="ifollow">
|
|
3
|
+
<IFollowView :ui-tips="uiTips" :closable="closable" ref="ifollow">
|
|
4
4
|
<Input
|
|
5
5
|
v-bind="{ placeholder, rules, theme, sz, co, block, ready, type, tips, maxlength, minlength, disabled, readonly, clearable, verify, prefix, suffix, interacted }"
|
|
6
6
|
:clearable="false"
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
rules?: Array<object>;
|
|
48
48
|
theme?: string;
|
|
49
49
|
uiTips?: string;
|
|
50
|
+
closable?: boolean;
|
|
50
51
|
sz?: '' | 'xs' | 's' | 'm' | 'l' | 'xl';
|
|
51
52
|
co?: string;
|
|
52
53
|
block?: boolean;
|
|
@@ -78,6 +79,7 @@
|
|
|
78
79
|
rules: () => [],
|
|
79
80
|
theme: '@a',
|
|
80
81
|
uiTips: '@a',
|
|
82
|
+
closable: false,
|
|
81
83
|
sz: '',
|
|
82
84
|
co: '',
|
|
83
85
|
block: true,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div :ui-form="`${theme} type:select sz:${sz}`" ui-tips="@a co:well" :ui-tips-view="state.show">
|
|
3
|
-
<IFollowView :ui-tips="uiTips" ref="ifollow">
|
|
3
|
+
<IFollowView :ui-tips="uiTips" :closable="closable" ref="ifollow">
|
|
4
4
|
<InputRange
|
|
5
5
|
v-bind="{ placeholder, rules, theme, sz, co, block, ready, type, tips, maxlength, minlength, disabled, readonly, clearable, verify, prefix, suffix, interacted }"
|
|
6
6
|
:clearable="false"
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
rules?: Array<object>;
|
|
41
41
|
theme?: string;
|
|
42
42
|
uiTips?: string;
|
|
43
|
+
closable?: boolean;
|
|
43
44
|
sz?: '' | 'xs' | 's' | 'm' | 'l' | 'xl';
|
|
44
45
|
co?: string;
|
|
45
46
|
block?: boolean;
|
|
@@ -71,6 +72,7 @@
|
|
|
71
72
|
rules: () => [],
|
|
72
73
|
theme: '@a',
|
|
73
74
|
uiTips: '@a',
|
|
75
|
+
closable: false,
|
|
74
76
|
sz: '',
|
|
75
77
|
co: '',
|
|
76
78
|
block: true,
|
|
@@ -173,8 +173,8 @@
|
|
|
173
173
|
<div> </div>
|
|
174
174
|
</div>
|
|
175
175
|
<div class="ml-ss-sub" ui-flex="row rm">
|
|
176
|
-
<button v-if="state.panel" ui-btn="@a
|
|
177
|
-
<button v-if="state.panel && !state.isNoTime" ui-btn="@a
|
|
176
|
+
<button v-if="state.panel" ui-btn="@a s none" class="nx-ss mob:nx-ms co-risk" @click="remove">清除</button>
|
|
177
|
+
<button v-if="state.panel && !state.isNoTime" ui-btn="@a s none" class="nx-ss mob:nx-ms co-main" @click="update">确定</button>
|
|
178
178
|
</div>
|
|
179
179
|
</div>
|
|
180
180
|
</div>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div :ui-form="`${theme} type:select ${sz ? 'sz:' + sz : ''}`">
|
|
3
|
-
<IFollowView :ui-tips="uiTips" ref="ifollow" tipBoxClass="ny-sm nx-ss" tipBoxStyle="min-width: 100%">
|
|
3
|
+
<IFollowView :ui-tips="uiTips" :closable="closable" ref="ifollow" tipBoxClass="ny-sm nx-ss" tipBoxStyle="min-width: 100%">
|
|
4
4
|
<slot>
|
|
5
5
|
<Input
|
|
6
6
|
v-bind="{ placeholder, rules, theme, sz, co, block, ready: true, type: 'text', tips, disabled, readonly, clearable: false, verify, prefix, suffix, interacted }"
|
|
@@ -108,6 +108,7 @@
|
|
|
108
108
|
rules?: Array<object>;
|
|
109
109
|
theme?: string;
|
|
110
110
|
uiTips?: string;
|
|
111
|
+
closable?: boolean;
|
|
111
112
|
sz?: '' | 'xs' | 's' | 'm' | 'l' | 'xl';
|
|
112
113
|
co?: string;
|
|
113
114
|
block?: boolean;
|
|
@@ -137,6 +138,7 @@
|
|
|
137
138
|
rules: () => [],
|
|
138
139
|
theme: '@a',
|
|
139
140
|
uiTips: '@a',
|
|
141
|
+
closable: false,
|
|
140
142
|
sz: '',
|
|
141
143
|
co: '',
|
|
142
144
|
block: true,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div :ui-form="`${theme} type:select sz:${sz}`" :ui-form-open="state.show ? 'show' : 'hide'">
|
|
3
|
-
<IFollowView :ui-tips="uiTips" ref="ifollow" v-model="state.show" tipBoxClass="ny-sm nx-ss" tipBoxStyle="min-width: 100%" @onshow="onshow" @onhide="onhide">
|
|
3
|
+
<IFollowView :ui-tips="uiTips" :closable="closable" ref="ifollow" v-model="state.show" tipBoxClass="ny-sm nx-ss" tipBoxStyle="min-width: 100%" @onshow="onshow" @onhide="onhide">
|
|
4
4
|
<Input
|
|
5
5
|
v-bind="{ placeholder, rules, theme, sz, co, block, ready, type: 'text', tips, disabled, readonly: true, clearable: false, verify, prefix, suffix, interacted }"
|
|
6
6
|
:modelValue="state.label"
|
|
@@ -103,6 +103,7 @@
|
|
|
103
103
|
rules?: Array<object>;
|
|
104
104
|
theme?: string;
|
|
105
105
|
uiTips?: string;
|
|
106
|
+
closable?: boolean;
|
|
106
107
|
sz?: '' | 'xs' | 's' | 'm' | 'l' | 'xl';
|
|
107
108
|
co?: string;
|
|
108
109
|
block?: boolean;
|
|
@@ -133,6 +134,7 @@
|
|
|
133
134
|
rules: () => [],
|
|
134
135
|
theme: '@a',
|
|
135
136
|
uiTips: '@a',
|
|
137
|
+
closable: false,
|
|
136
138
|
sz: '',
|
|
137
139
|
co: '',
|
|
138
140
|
block: true,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div :ui-form="`${theme} type:select ${sz ? 'sz:' + sz : ''}`" :ui-form-open="state.show ? 'show' : ''" ref="dom">
|
|
3
|
-
<IFollowView :ui-tips="uiTips" ref="ifollow" tipBoxClass="ny-sm nx-ss" tipBoxStyle="min-width: 100%">
|
|
3
|
+
<IFollowView :ui-tips="uiTips" :closable="closable" ref="ifollow" tipBoxClass="ny-sm nx-ss" tipBoxStyle="min-width: 100%">
|
|
4
4
|
<slot>
|
|
5
5
|
<Input v-bind="$props" :clearable="false" readonly v-model="state.selectd">
|
|
6
6
|
<template #suffix><i ui-form-select=""></i></template>
|
|
@@ -61,6 +61,7 @@
|
|
|
61
61
|
const props: any = defineProps({
|
|
62
62
|
theme: { type: String, default: '@a' },
|
|
63
63
|
uiTips: { type: String, default: '@a' },
|
|
64
|
+
closable: { type: Boolean, default: false },
|
|
64
65
|
sz: { type: String, default: '' },
|
|
65
66
|
co: { type: String, default: '' },
|
|
66
67
|
block: { type: Boolean, default: true },
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div :ui-form="`${theme} type:select sz:${sz}`" :ui-form-open="state.show ? 'show' : ''" ref="dom">
|
|
3
|
-
<IFollowView :ui-tips="uiTips" ref="ifollow" tipBoxClass="ny-sm nx-ss" tipBoxStyle="min-width: 100%">
|
|
3
|
+
<IFollowView :ui-tips="uiTips" :closable="closable" ref="ifollow" tipBoxClass="ny-sm nx-ss" tipBoxStyle="min-width: 100%">
|
|
4
4
|
<Input v-bind="$props" :clearable="false" readonly v-model="state.selectd.label" :interacted="interacted">
|
|
5
5
|
<template #suffix><i ui-form-select=""></i></template>
|
|
6
6
|
</Input>
|
|
@@ -56,6 +56,7 @@
|
|
|
56
56
|
},
|
|
57
57
|
theme: { type: String, default: '@a' },
|
|
58
58
|
uiTips: { type: String, default: '@a' },
|
|
59
|
+
closable: { type: Boolean, default: false },
|
|
59
60
|
sz: { type: String as PropType<'' | 'xs' | 's' | 'm' | 'l' | 'xl'>, default: 'm' },
|
|
60
61
|
co: { type: String, default: '' },
|
|
61
62
|
block: { type: Boolean, default: true },
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div :ui-form="`${theme} type:select sz:${sz}`" :ui-form-open="state.show ? 'show' : ''">
|
|
3
|
-
<IFollowView :ui-tips="uiTips" ref="ifollow" tipBoxClass="ny-sm nx-ss" tipBoxStyle="min-width: 100%" @onshow="onshow" @onhide="onhide">
|
|
3
|
+
<IFollowView :ui-tips="uiTips" :closable="closable" ref="ifollow" tipBoxClass="ny-sm nx-ss" tipBoxStyle="min-width: 100%" @onshow="onshow" @onhide="onhide">
|
|
4
4
|
<Input v-bind="$props" :clearable="false" readonly v-model="state.selectd" @click="toggle" :interacted="interacted">
|
|
5
5
|
<template #suffix><i ui-form-select=""></i></template>
|
|
6
6
|
</Input>
|
|
@@ -60,6 +60,7 @@
|
|
|
60
60
|
},
|
|
61
61
|
theme: { type: String, default: '@a' },
|
|
62
62
|
uiTips: { type: String, default: '@a' },
|
|
63
|
+
closable: { type: Boolean, default: false },
|
|
63
64
|
sz: { type: String as PropType<'' | 'xs' | 's' | 'm' | 'l' | 'xl'>, default: 'm' },
|
|
64
65
|
co: { type: String, default: '' },
|
|
65
66
|
block: { type: Boolean, default: true },
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div :ui-form="`${theme} type:select sz:${sz}`" :ui-form-open="state.show ? 'show' : ''">
|
|
3
|
-
<IFollowView :ui-tips="uiTips" ref="ifollow" tipBoxClass="ny-sm nx-ss" tipBoxStyle="min-width: 100%" @onshow="onshow" @onhide="onhide">
|
|
3
|
+
<IFollowView :ui-tips="uiTips" :closable="closable" ref="ifollow" tipBoxClass="ny-sm nx-ss" tipBoxStyle="min-width: 100%" @onshow="onshow" @onhide="onhide">
|
|
4
4
|
<Input
|
|
5
5
|
v-bind="{ placeholder, rules, theme, sz, co, block, ready, type, tips, disabled, readonly: true, clearable: false, verify, prefix, suffix, interacted }"
|
|
6
6
|
v-model="state.selectd.label"
|
|
@@ -101,6 +101,7 @@
|
|
|
101
101
|
rules?: Array<object>;
|
|
102
102
|
theme?: string;
|
|
103
103
|
uiTips?: string;
|
|
104
|
+
closable?: boolean;
|
|
104
105
|
sz?: '' | 'xs' | 's' | 'm' | 'l' | 'xl';
|
|
105
106
|
co?: string;
|
|
106
107
|
block?: boolean;
|
|
@@ -133,6 +134,7 @@
|
|
|
133
134
|
rules: () => [],
|
|
134
135
|
theme: '@a',
|
|
135
136
|
uiTips: '@a',
|
|
137
|
+
closable: false,
|
|
136
138
|
sz: '',
|
|
137
139
|
co: '',
|
|
138
140
|
block: true,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div :ui-form="`${theme} type:select sz:${sz}`" :ui-form-open="state.show ? 'show' : ''">
|
|
3
|
-
<IFollowView :ui-tips="uiTips" ref="ifollow" tipBoxClass="ny-sm nx-ss" tipBoxStyle="min-width: 100%" @onshow="onshow" @onhide="onhide">
|
|
3
|
+
<IFollowView :ui-tips="uiTips" :closable="closable" ref="ifollow" tipBoxClass="ny-sm nx-ss" tipBoxStyle="min-width: 100%" @onshow="onshow" @onhide="onhide">
|
|
4
4
|
<Input
|
|
5
5
|
v-bind="{ placeholder, rules, theme, sz, co, block, ready, type, tips, disabled, readonly: true, clearable: false, verify, prefix, suffix, interacted }"
|
|
6
6
|
v-model="state.displayLabel"
|
|
@@ -132,6 +132,7 @@
|
|
|
132
132
|
rules?: Array<object>;
|
|
133
133
|
theme?: string;
|
|
134
134
|
uiTips?: string;
|
|
135
|
+
closable?: boolean;
|
|
135
136
|
sz?: '' | 'xs' | 's' | 'm' | 'l' | 'xl';
|
|
136
137
|
co?: string;
|
|
137
138
|
block?: boolean;
|
|
@@ -167,6 +168,7 @@
|
|
|
167
168
|
rules: () => [],
|
|
168
169
|
theme: '@a',
|
|
169
170
|
uiTips: '@a',
|
|
171
|
+
closable: false,
|
|
170
172
|
sz: 'm',
|
|
171
173
|
co: '',
|
|
172
174
|
block: true,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div :ui-form="`${theme} type:select sz:${sz}`" :ui-form-open="state.show ? 'show' : 'hide'">
|
|
3
|
-
<IFollowView :ui-tips="uiTips" ref="ifollow" v-model="state.show" tipBoxClass="ny-sm nx-ss" tipBoxStyle="min-width: 100%" @onshow="onshow" @onhide="onhide">
|
|
3
|
+
<IFollowView :ui-tips="uiTips" :closable="closable" ref="ifollow" v-model="state.show" tipBoxClass="ny-sm nx-ss" tipBoxStyle="min-width: 100%" @onshow="onshow" @onhide="onhide">
|
|
4
4
|
<Input
|
|
5
5
|
v-bind="{ placeholder, rules, theme, sz, co, block, ready, type, tips, disabled, readonly: true, clearable: false, verify, prefix, suffix, interacted }"
|
|
6
6
|
v-model="state.label"
|
|
@@ -97,6 +97,7 @@
|
|
|
97
97
|
rules?: Array<object>;
|
|
98
98
|
theme?: string;
|
|
99
99
|
uiTips?: string;
|
|
100
|
+
closable?: boolean;
|
|
100
101
|
sz?: '' | 'xs' | 's' | 'm' | 'l' | 'xl';
|
|
101
102
|
co?: string;
|
|
102
103
|
block?: boolean;
|
|
@@ -132,6 +133,7 @@
|
|
|
132
133
|
rules: () => [],
|
|
133
134
|
theme: '@a',
|
|
134
135
|
uiTips: '@a',
|
|
136
|
+
closable: false,
|
|
135
137
|
sz: '',
|
|
136
138
|
co: '',
|
|
137
139
|
block: true,
|
|
@@ -29,16 +29,16 @@
|
|
|
29
29
|
:interacted="touched"
|
|
30
30
|
:debug="debug"
|
|
31
31
|
rows=""
|
|
32
|
-
:class="isVertical(col) ? 'w-full' : 'flex-fixed nr-sm'"
|
|
32
|
+
:class="[isVertical(col) ? 'w-full' : 'flex-fixed nr-sm', titleLayer(col)]"
|
|
33
33
|
:style="isVertical(col) ? undefined : labelStyle(col)"
|
|
34
34
|
:ui-flex="isVertical(col) ? 'row lm' : 'row rm'"
|
|
35
35
|
/>
|
|
36
|
-
<h6 v-else-if="labelText(col.label) && isVertical(col)" :style="showStar(col) ? 'text-indent: -1em' : undefined" :class="labelClass
|
|
36
|
+
<h6 v-else-if="labelText(col.label) && isVertical(col)" :style="showStar(col) ? 'text-indent: -1em' : undefined" :class="[labelClass, titleLayer(col)]" v-bind="labelAttrs(col.label)">
|
|
37
37
|
<slot v-if="labelSlot(col)" :name="labelSlot(col)" :col="col" :node="col" :data="col._data" :text="formatLabel(col)"></slot>
|
|
38
38
|
<template v-else-if="labelText(col.label) !== '*' && showStar(col)"><b class="co-risk pr" style="left: 0.1em">﹡</b>{{ formatLabel(col) }}</template>
|
|
39
39
|
<template v-else-if="labelText(col.label) !== '*'">{{ formatLabel(col) }}</template>
|
|
40
40
|
</h6>
|
|
41
|
-
<p v-else-if="labelText(col.label)" :class="[labelClass, clh({ ...(col.model[1] || {}) })]" :style="labelStyle(col)" class="ar nr-sm flex-fixed o-ls" v-bind="labelAttrs(col.label)">
|
|
41
|
+
<p v-else-if="labelText(col.label)" :class="[labelClass, clh({ ...(col.model[1] || {}) }), titleLayer(col)]" :style="labelStyle(col)" class="ar nr-sm flex-fixed o-ls" v-bind="labelAttrs(col.label)">
|
|
42
42
|
<slot v-if="labelSlot(col)" :name="labelSlot(col)" :col="col" :node="col" :data="col._data" :text="formatLabel(col)"></slot>
|
|
43
43
|
<template v-else-if="labelText(col.label) !== '*' && showStar(col)"><b class="co-risk">﹡</b>{{ formatLabel(col) }}</template>
|
|
44
44
|
<template v-else-if="labelText(col.label) !== '*'">{{ formatLabel(col) }}</template>
|
|
@@ -263,14 +263,19 @@
|
|
|
263
263
|
const checkRules = async (value: any, rules: FormRule[] = []) => {
|
|
264
264
|
if (!isArray(rules)) return null;
|
|
265
265
|
if (!isEmpty(value)) {
|
|
266
|
+
// warn 不阻断:记下第一条警告后继续检查,遇到阻断规则立即返回
|
|
267
|
+
let warning: { msg: string; type: string } | null = null;
|
|
266
268
|
for (const rule of rules) {
|
|
267
269
|
if (rule && isFunction(rule.validator) && !(await rule.validator!(value))) {
|
|
268
|
-
|
|
270
|
+
const type = rule.type || 'risk';
|
|
271
|
+
if (type !== 'warn') return { msg: rule.msg || '', type };
|
|
272
|
+
warning ||= { msg: rule.msg || '', type };
|
|
269
273
|
}
|
|
270
274
|
}
|
|
271
|
-
return
|
|
275
|
+
return warning;
|
|
272
276
|
}
|
|
273
|
-
const
|
|
277
|
+
const required = rules.filter((item) => item?.required);
|
|
278
|
+
const rule = required.find((item) => (item.type || 'risk') !== 'warn') || required[0];
|
|
274
279
|
return rule ? { msg: rule.msg || '', type: rule.type || 'risk' } : null;
|
|
275
280
|
};
|
|
276
281
|
|
|
@@ -627,6 +632,7 @@
|
|
|
627
632
|
});
|
|
628
633
|
|
|
629
634
|
// 各层级共享:Label 宽度、校验状态、隐藏字段暂存、已挂载的表单实例
|
|
635
|
+
type ServerError = { msg: string; type: string; input: any };
|
|
630
636
|
interface Shared {
|
|
631
637
|
state: { lw: number; validated: boolean };
|
|
632
638
|
cache: ClearCache;
|
|
@@ -640,8 +646,9 @@
|
|
|
640
646
|
validations: WeakMap<object, { input: any; epoch: number; pending: boolean; result: Promise<any> }>;
|
|
641
647
|
// 根数据任意变化时递增,供 watch: true 的联动共用
|
|
642
648
|
dataVersion: ShallowRef<number>;
|
|
643
|
-
//
|
|
644
|
-
|
|
649
|
+
// 外部回填的错误(如服务端校验),按「所在记录 → 行内路径」保存;字段值变化后自动失效。
|
|
650
|
+
// 外层 WeakMap 以记录对象为键,记录被删除并回收后错误随之释放;清空时整体替换,shallowRef 负责通知已渲染字段
|
|
651
|
+
serverErrors: ShallowRef<WeakMap<object, Map<string, ServerError>>>;
|
|
645
652
|
}
|
|
646
653
|
const FORM_SHARED = Symbol.for('neatui:form-loader');
|
|
647
654
|
const shared: Shared = (props.root && inject<Shared | null>(FORM_SHARED, null)) || {
|
|
@@ -656,7 +663,7 @@
|
|
|
656
663
|
cancelChecks: new Set(),
|
|
657
664
|
validations: new WeakMap(),
|
|
658
665
|
dataVersion: shallowRef(0),
|
|
659
|
-
serverErrors: reactive(new
|
|
666
|
+
serverErrors: shallowRef(reactive(new WeakMap())),
|
|
660
667
|
};
|
|
661
668
|
provide(FORM_SHARED, shared);
|
|
662
669
|
|
|
@@ -795,6 +802,8 @@
|
|
|
795
802
|
if (isCleared(col)) out.style = joinClass(own.style, { display: 'none' });
|
|
796
803
|
return out;
|
|
797
804
|
};
|
|
805
|
+
// 分组内容区的栅格负边距会伸进标题区域,标题提一层以保证按钮可点
|
|
806
|
+
const titleLayer = (col: any) => (isGroup(col) || col._array ? 'pr z-xs' : undefined);
|
|
798
807
|
// 有标题分组的子节点容器
|
|
799
808
|
const boxAttrs = (col: any) => {
|
|
800
809
|
const { attrs } = levelOf(col);
|
|
@@ -1024,7 +1033,7 @@
|
|
|
1024
1033
|
}
|
|
1025
1034
|
|
|
1026
1035
|
// 添加ID
|
|
1027
|
-
item._fid =
|
|
1036
|
+
item._fid = fieldIdOf(item);
|
|
1028
1037
|
|
|
1029
1038
|
// 数据路径处理
|
|
1030
1039
|
item._data = resolvePath(props.data, item.paths);
|
|
@@ -1042,6 +1051,9 @@
|
|
|
1042
1051
|
if (props.debug) console.debug(`[FormLoader] ${props.path || 'root'}`, deepcopy(toRaw(state.form)));
|
|
1043
1052
|
};
|
|
1044
1053
|
|
|
1054
|
+
// 字段 ID 随数据路径变化;行实例复用时由下方监听单独刷新,不触发重建
|
|
1055
|
+
const fieldIdOf = (item: any) => (item.field ? [baseIdx.value, fieldPath(item).replace(/\./g, '-')].filter(Boolean).join('-') : '');
|
|
1056
|
+
|
|
1045
1057
|
// 调试信息
|
|
1046
1058
|
const debugInfo = (col: any = {}) => [fieldPath(col) || '(group)', col.model?.[0] || (isGroup(col) ? 'child' : col._array ? 'array' : 'Input')].join(' · ');
|
|
1047
1059
|
watch(
|
|
@@ -1052,9 +1064,16 @@
|
|
|
1052
1064
|
{ deep: true, immediate: true },
|
|
1053
1065
|
);
|
|
1054
1066
|
watch([() => props.data, () => props.idx], ([data], [previous]) => {
|
|
1055
|
-
if (!props.root && data !== previous)
|
|
1067
|
+
if (!props.root && data !== previous) {
|
|
1068
|
+
captureInitial();
|
|
1069
|
+
clearValidate(); // 换了一条记录:上一条的服务端错误和校验状态不再适用
|
|
1070
|
+
}
|
|
1056
1071
|
rebuild(props.form);
|
|
1057
1072
|
});
|
|
1073
|
+
// 数组行增删、排序后行实例被复用,只刷新字段 ID,保留校验状态和联动
|
|
1074
|
+
watch(() => props.path, () => {
|
|
1075
|
+
state.form.forEach((item: any) => { item._fid = fieldIdOf(item); });
|
|
1076
|
+
});
|
|
1058
1077
|
|
|
1059
1078
|
// 隐藏的字段清空数据并暂存,重新显示时还原(restore: false 时只清空)
|
|
1060
1079
|
// 只在「隐藏 → 显示」的那一次和首次运行时还原,避免和分组内的子表单来回争抢同一个值
|
|
@@ -1112,8 +1131,19 @@
|
|
|
1112
1131
|
return keys.length === Object.keys(b).length && keys.every(key => Object.prototype.hasOwnProperty.call(b, key) && sameInput(a[key], b[key]));
|
|
1113
1132
|
};
|
|
1114
1133
|
// path 由收集字段的那一层计算(validate 在根表单调用时,嵌套字段的路径不能用根表单拼)
|
|
1134
|
+
// 回填错误以「所在记录(数组行)+ 行内路径」为键保存,不依赖完整路径:
|
|
1135
|
+
// 排序、删除前面的行后错误随原记录走;路径上换成别的记录时不显示;记录被删除后不再匹配
|
|
1136
|
+
const localPathOf = (col: any = {}) => [col.paths, col.field].filter(Boolean).join('.');
|
|
1137
|
+
const serverErrorOf = (col: any, data: any) => shared.serverErrors.value.get(toRaw(data))?.get(localPathOf(col));
|
|
1138
|
+
const deleteServerError = (col: any, data: any) => shared.serverErrors.value.get(toRaw(data))?.delete(localPathOf(col));
|
|
1139
|
+
const setServerError = (col: any, data: any, error: ServerError) => {
|
|
1140
|
+
const store = shared.serverErrors.value;
|
|
1141
|
+
const owner = toRaw(data);
|
|
1142
|
+
if (!store.has(owner)) store.set(owner, new Map());
|
|
1143
|
+
store.get(owner)!.set(localPathOf(col), error);
|
|
1144
|
+
};
|
|
1115
1145
|
const validateField = (col: any, ctx: FormContext, path: string, force = false): Promise<any> => {
|
|
1116
|
-
const server =
|
|
1146
|
+
const server = serverErrorOf(col, ctx.data);
|
|
1117
1147
|
if (server) return Promise.resolve({ msg: server.msg, type: server.type });
|
|
1118
1148
|
const input = deepcopy([displayOf(col, ctx), rulesOf(col), col.model]);
|
|
1119
1149
|
const previous = shared.validations.get(col);
|
|
@@ -1127,7 +1157,7 @@
|
|
|
1127
1157
|
error = message ? { msg: message, type: 'risk' } : await checkRules(input[0], input[1]);
|
|
1128
1158
|
} catch { error = { msg: '校验失败,请重试', type: 'risk' }; }
|
|
1129
1159
|
job.pending = false;
|
|
1130
|
-
if (shared.validations.get(col) === job && shared.validationEpoch === job.epoch && !
|
|
1160
|
+
if (shared.validations.get(col) === job && shared.validationEpoch === job.epoch && !serverErrorOf(col, ctx.data)) { col._error = error; col._checked = true; }
|
|
1131
1161
|
return error;
|
|
1132
1162
|
})();
|
|
1133
1163
|
return job.result;
|
|
@@ -1140,14 +1170,14 @@
|
|
|
1140
1170
|
deepcopy(col.model);
|
|
1141
1171
|
// 回填的错误在字段值变化前一直显示,值一变就交回正常校验
|
|
1142
1172
|
const path = col.field ? fieldPath(col) : '';
|
|
1143
|
-
const server = path ?
|
|
1173
|
+
const server = path ? serverErrorOf(col, props.data) : undefined;
|
|
1144
1174
|
if (server && !isCleared(col)) {
|
|
1145
1175
|
if (sameInput(server.input, value)) {
|
|
1146
1176
|
col._error = { msg: server.msg, type: server.type };
|
|
1147
1177
|
col._checked = true;
|
|
1148
1178
|
continue;
|
|
1149
1179
|
}
|
|
1150
|
-
|
|
1180
|
+
deleteServerError(col, props.data);
|
|
1151
1181
|
}
|
|
1152
1182
|
if (col.field && !isGroup(col) && !isCleared(col) && (touched.value || col._touched)) {
|
|
1153
1183
|
void validateField(col, context(), path);
|
|
@@ -1202,11 +1232,17 @@
|
|
|
1202
1232
|
if (shared.pending.size) await Promise.all([...shared.pending]);
|
|
1203
1233
|
} while (revision !== shared.revision || shared.pending.size);
|
|
1204
1234
|
revision = shared.revision;
|
|
1205
|
-
|
|
1235
|
+
const collected = fieldsOf().filter(({ path }) => withinPaths(path, paths));
|
|
1236
|
+
results = await Promise.all(collected.map(async ({ col, ctx, path }) => {
|
|
1206
1237
|
if (paths) col._touched = true;
|
|
1207
|
-
|
|
1208
|
-
return res ? { field: col.field, path, label: labelText(col.label), msg: res.msg, type: res.type, fid: col._fid } : null;
|
|
1238
|
+
return validateField(col, ctx, path, true);
|
|
1209
1239
|
}));
|
|
1240
|
+
// 校验期间 setErrors 回填的错误优先于本轮规则结果
|
|
1241
|
+
results = collected.map(({ col, path, ctx }, i) => {
|
|
1242
|
+
const server = serverErrorOf(col, ctx.data);
|
|
1243
|
+
const res = server ? { msg: server.msg, type: server.type } : results[i];
|
|
1244
|
+
return res ? { field: col.field, path, label: labelText(col.label), msg: res.msg, type: res.type, fid: col._fid } : null;
|
|
1245
|
+
});
|
|
1210
1246
|
if (epoch !== shared.validationEpoch) return cancelledResult();
|
|
1211
1247
|
} while (revision !== shared.revision);
|
|
1212
1248
|
const found = (results.filter(Boolean) as any[]).sort(byPosition);
|
|
@@ -1234,7 +1270,7 @@
|
|
|
1234
1270
|
|
|
1235
1271
|
const clearValidate = () => {
|
|
1236
1272
|
cancelValidation();
|
|
1237
|
-
shared.serverErrors.
|
|
1273
|
+
shared.serverErrors.value = reactive(new WeakMap());
|
|
1238
1274
|
shared.validations = new WeakMap();
|
|
1239
1275
|
shared.state.validated = false;
|
|
1240
1276
|
fieldsOf().forEach(({ col }) => { col._touched = false; col._error = null; col._checked = false; });
|
|
@@ -1251,7 +1287,7 @@
|
|
|
1251
1287
|
list.forEach(({ path, msg, type = 'risk' }) => {
|
|
1252
1288
|
const found = fields.find((item) => item.path === path);
|
|
1253
1289
|
if (!found) return missing.push(path);
|
|
1254
|
-
|
|
1290
|
+
setServerError(found.col, found.ctx.data, { msg, type, input: deepcopy(displayOf(found.col, found.ctx)) });
|
|
1255
1291
|
});
|
|
1256
1292
|
const first = list.find((item) => !missing.includes(item.path));
|
|
1257
1293
|
if (scroll && first) scrollToField(first.path);
|
|
@@ -210,7 +210,7 @@
|
|
|
210
210
|
</p>
|
|
211
211
|
</li>
|
|
212
212
|
<template v-if="treeEnabled && !item.__folding">
|
|
213
|
-
<template v-for="(child, cidx) in childrenOf(item)" :key="
|
|
213
|
+
<template v-for="(child, cidx) in childrenOf(item)" :key="childKeyOf(rowKeyOf(item, idx), child, cidx)">
|
|
214
214
|
<li v-if="childVisible(child)" class="table-tree-item bg-fore r-sm my-sl n-ms">
|
|
215
215
|
<table>
|
|
216
216
|
<tbody>
|
|
@@ -232,7 +232,7 @@
|
|
|
232
232
|
</p>
|
|
233
233
|
</li>
|
|
234
234
|
<template v-if="depth > 2 && !child.__folding">
|
|
235
|
-
<template v-for="(grand, gidx) in childrenOf(child)" :key="
|
|
235
|
+
<template v-for="(grand, gidx) in childrenOf(child)" :key="childKeyOf(childKeyOf(rowKeyOf(item, idx), child, cidx), grand, gidx)">
|
|
236
236
|
<li v-if="childVisible(grand)" class="table-tree-item table-tree-item-3 bg-fore r-sm my-sl n-ms">
|
|
237
237
|
<table>
|
|
238
238
|
<tbody>
|
|
@@ -652,6 +652,12 @@
|
|
|
652
652
|
if (key && item && typeof item === 'object') return item[key] ?? idx;
|
|
653
653
|
return idx;
|
|
654
654
|
};
|
|
655
|
+
// 子行标识:编码完整层级路径(设了 rowKey 时排序、增删后状态跟着记录走)。
|
|
656
|
+
// 用 JSON 数组而不是连字符拼接,避免「父 a + 子 b」与顶层 id「a-b」撞 key
|
|
657
|
+
const childKeyOf = (parentKey: any, item: any, idx: number) => {
|
|
658
|
+
const parent = typeof parentKey === 'string' && parentKey.startsWith('\u0000') ? JSON.parse(parentKey.slice(1)) : [parentKey];
|
|
659
|
+
return `\u0000${JSON.stringify([...parent, rowKeyOf(item, idx)])}`;
|
|
660
|
+
};
|
|
655
661
|
|
|
656
662
|
/**
|
|
657
663
|
* 虚拟滚动:上下用占位行撑出滚动高度,只渲染可视区附近的行。
|
|
@@ -705,11 +711,12 @@
|
|
|
705
711
|
const kids = childrenOf(item);
|
|
706
712
|
kids.forEach((child, cidx) => {
|
|
707
713
|
const last = cidx === kids.length - 1;
|
|
708
|
-
|
|
714
|
+
const childKey = childKeyOf(key, child, cidx);
|
|
715
|
+
out.push({ item: child, idx: cidx, level: 2, parent: item, key: childKey, last, parentLast: false });
|
|
709
716
|
if (props.depth < 3 || child?.__folding) return;
|
|
710
717
|
const grand = childrenOf(child);
|
|
711
718
|
grand.forEach((g, gidx) => {
|
|
712
|
-
out.push({ item: g, idx: gidx, level: 3, parent: child, key:
|
|
719
|
+
out.push({ item: g, idx: gidx, level: 3, parent: child, key: childKeyOf(childKey, g, gidx), last: gidx === grand.length - 1, parentLast: last });
|
|
713
720
|
});
|
|
714
721
|
});
|
|
715
722
|
});
|