@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neatui/vue",
3
- "version": "5.5.1",
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.5.1",
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 ref="box" :ui-tips-box="state.pos" :class="tipBoxClass" :style="tipBoxStyle">
20
- <div ui-hide=">mob" class="mob-follow-tips w-full nx-sl nt-sl nb-ss" ui-flex="row xm">
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>&nbsp;</div>
174
174
  </div>
175
175
  <div class="ml-ss-sub" ui-flex="row rm">
176
- <button v-if="state.panel" ui-btn="@a xs none" class="nx-ss co-risk" @click="remove">清除</button>
177
- <button v-if="state.panel && !state.isNoTime" ui-btn="@a xs none" class="nx-ss co-main" @click="update">确定</button>
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 || undefined" v-bind="labelAttrs(col.label)">
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
- return { msg: rule.msg || '', type: rule.type || 'risk' };
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 null;
275
+ return warning;
272
276
  }
273
- const rule = rules.find((item) => item?.required);
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
- serverErrors: Map<string, { msg: string; type: string; input: any }>;
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 Map()),
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 = item.field ? [baseIdx.value, fieldPath(item).replace(/\./g, '-')].filter(Boolean).join('-') : '';
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) captureInitial();
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 = shared.serverErrors.get(path);
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 && !shared.serverErrors.has(path)) { col._error = error; col._checked = true; }
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 ? shared.serverErrors.get(path) : undefined;
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
- shared.serverErrors.delete(path);
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
- results = await Promise.all(fieldsOf().filter(({ path }) => withinPaths(path, paths)).map(async ({ col, path, ctx }) => {
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
- const res = await validateField(col, ctx, path, true);
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.clear();
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
- shared.serverErrors.set(path, { msg, type, input: deepcopy(displayOf(found.col, found.ctx)) });
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="`${rowKeyOf(item, idx)}-${cidx}`">
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="`${rowKeyOf(item, idx)}-${cidx}-${gidx}`">
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
- out.push({ item: child, idx: cidx, level: 2, parent: item, key: `${key}-${cidx}`, last, parentLast: false });
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: `${key}-${cidx}-${gidx}`, last: gidx === grand.length - 1, parentLast: last });
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
  });