@neatui/vue 4.0.2 → 4.2.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": "4.0.2",
3
+ "version": "4.2.0",
4
4
  "description": "basic component ui",
5
5
  "main": "./src/index.ts",
6
6
  "license": "MIT",
@@ -34,7 +34,7 @@
34
34
  "@fekit/scrollto": "^3.0.3",
35
35
  "@fekit/toast": "^2.7.2",
36
36
  "@fekit/utils": "^6.0.0",
37
- "@neatui/css": "^4.0.2",
37
+ "@neatui/css": "^4.2.0",
38
38
  "@rollup/plugin-commonjs": "^26.0.1",
39
39
  "@rollup/plugin-node-resolve": "^15.2.3",
40
40
  "@vue/compiler-sfc": "^3.4.30",
@@ -58,6 +58,8 @@
58
58
  };
59
59
 
60
60
  const SCROLL_LOCK_STATE = Symbol.for('@neatui/vue.Layer.scrollLock');
61
+ const ESCAPE_STACK = Symbol.for('@neatui/vue.Layer.escapeStack');
62
+ const ESCAPE_LISTENER = Symbol.for('@neatui/vue.Layer.escapeListener');
61
63
 
62
64
  const getScrollLockState = (): ScrollLockState => {
63
65
  if (!_tasks[SCROLL_LOCK_STATE]) {
@@ -71,6 +73,51 @@
71
73
  return _tasks[SCROLL_LOCK_STATE];
72
74
  };
73
75
 
76
+ const getEscapeStack = (): string[] => {
77
+ if (!_tasks[ESCAPE_STACK]) {
78
+ Object.defineProperty(_tasks, ESCAPE_STACK, {
79
+ configurable: true,
80
+ value: [] as string[],
81
+ });
82
+ }
83
+ return _tasks[ESCAPE_STACK];
84
+ };
85
+
86
+ const pushEscapeStack = (layerId: string) => {
87
+ if (!layerId) return;
88
+ const stack = getEscapeStack();
89
+ const index = stack.indexOf(layerId);
90
+ if (index !== -1) stack.splice(index, 1);
91
+ stack.push(layerId);
92
+ };
93
+
94
+ const removeEscapeStack = (layerId: string) => {
95
+ if (!layerId) return;
96
+ const stack = getEscapeStack();
97
+ const index = stack.indexOf(layerId);
98
+ if (index !== -1) stack.splice(index, 1);
99
+ };
100
+
101
+ // 全局只挂一次:ESC 仅关闭最后打开且已完全显示的一层
102
+ const ensureEscapeListener = () => {
103
+ if (!env.client || _tasks[ESCAPE_LISTENER]) return;
104
+ const onKeydown = (ev: KeyboardEvent) => {
105
+ if (ev.key !== 'Escape' || ev.defaultPrevented) return;
106
+ const stack = getEscapeStack();
107
+ const topId = stack[stack.length - 1];
108
+ if (!topId) return;
109
+ const layer = _tasks[topId];
110
+ if (!layer || layer.view !== 3 || layer.escClose === false) return;
111
+ layer.hide();
112
+ ev.preventDefault();
113
+ };
114
+ document.addEventListener('keydown', onKeydown);
115
+ Object.defineProperty(_tasks, ESCAPE_LISTENER, {
116
+ configurable: true,
117
+ value: onKeydown,
118
+ });
119
+ };
120
+
74
121
  const hasInlineProperty = (style: CSSStyleDeclaration, property: string) => {
75
122
  for (let index = 0; index < style.length; index += 1) {
76
123
  if (style.item(index) === property) return true;
@@ -230,6 +277,7 @@
230
277
  flex?: string;
231
278
  dark?: string | number;
232
279
  darkClickClose?: boolean;
280
+ escClose?: boolean;
233
281
  wrapStyle?: string;
234
282
  }
235
283
 
@@ -242,6 +290,7 @@
242
290
  flex: 'cm',
243
291
  dark: '',
244
292
  darkClickClose: true,
293
+ escClose: true,
245
294
  wrapStyle: '',
246
295
  });
247
296
 
@@ -268,12 +317,22 @@
268
317
  if (_tasks[props.id]) {
269
318
  _tasks[props.id].view = view;
270
319
  }
320
+ if (view === 2 || view === 3) pushEscapeStack(props.id);
321
+ else removeEscapeStack(props.id);
271
322
  if (view > 0) acquireScrollLock(scrollLockOwner);
272
323
  else releaseScrollLock(scrollLockOwner);
273
324
  },
274
325
  { deep: true, immediate: true },
275
326
  );
276
327
 
328
+ watch(
329
+ () => props.escClose,
330
+ (escClose) => {
331
+ if (_tasks[props.id]) _tasks[props.id].escClose = escClose;
332
+ },
333
+ { immediate: true },
334
+ );
335
+
277
336
  // 回调合集
278
337
  const on: any = {
279
338
  show: [],
@@ -367,13 +426,15 @@
367
426
  console.error('Layer 有重复 id', id);
368
427
  }
369
428
  if (id) {
370
- _tasks[id] = { init, show, hide, open, none };
429
+ _tasks[id] = { init, show, hide, open, none, view: state.view, escClose: props.escClose };
430
+ ensureEscapeListener();
371
431
  } else {
372
432
  console.error('Layer 必须填写全局统一的 id');
373
433
  }
374
434
 
375
435
  // 卸载弹层
376
436
  onBeforeUnmount(() => {
437
+ removeEscapeStack(id);
377
438
  releaseScrollLock(scrollLockOwner);
378
439
  delete _tasks[id];
379
440
  });
@@ -1,7 +1,16 @@
1
1
  <template>
2
2
  <div>
3
3
  <div :class="`mc-img ${imgClass}`" :style="imgStyle">
4
- <img :src="src[srcName]" :style="`object-fit: ${fit}`" @click.stop="showPreview" />
4
+ <img
5
+ ref="imgRef"
6
+ :src="displaySrc"
7
+ :alt="alt"
8
+ :loading="lazy ? 'lazy' : 'eager'"
9
+ :decoding="decoding"
10
+ :class="{ 'is-loaded': isLoaded }"
11
+ :style="`object-fit: ${fit}`"
12
+ @click.stop="showPreview"
13
+ />
5
14
  <div v-if="tools" class="mc-img-tools">
6
15
  <slot name="tools">
7
16
  <div class="mc-img-clean co-risk" @click="delImg"></div>
@@ -14,6 +23,7 @@
14
23
  </div>
15
24
  </template>
16
25
  <script setup lang="ts">
26
+ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
17
27
  import { Layer, LayerById } from '../basic';
18
28
  import PhotoViewer from './PhotoViewer.vue';
19
29
 
@@ -46,10 +56,108 @@
46
56
  type: Number,
47
57
  default: 0,
48
58
  },
59
+ // 是否开启懒加载
60
+ lazy: {
61
+ type: Boolean,
62
+ default: true,
63
+ },
64
+ // 懒加载占位图
65
+ placeholder: {
66
+ type: String,
67
+ default: '/assets/img/none.webp',
68
+ },
69
+ alt: {
70
+ type: String,
71
+ default: '',
72
+ },
73
+ decoding: {
74
+ type: String,
75
+ default: 'async',
76
+ },
77
+ // 提前加载边距,接近视口即触发(同 IntersectionObserver.rootMargin)
78
+ rootMargin: {
79
+ type: String,
80
+ default: '200px',
81
+ },
49
82
  });
50
83
 
51
84
  const emit = defineEmits(['delImg']);
52
85
  const layerId = Math.random().toString(36).slice(-6);
86
+ const imgRef = ref<HTMLImageElement | null>(null);
87
+ const isLoaded = ref(!props.lazy);
88
+ let observer: IntersectionObserver | null = null;
89
+
90
+ const realSrc = computed(() => props.src?.[props.srcName] || '');
91
+ const displaySrc = computed(() => {
92
+ if (!props.lazy || isLoaded.value) {
93
+ return realSrc.value || props.placeholder;
94
+ }
95
+ return props.placeholder;
96
+ });
97
+
98
+ const stopObserver = () => {
99
+ if (observer) {
100
+ observer.disconnect();
101
+ observer = null;
102
+ }
103
+ };
104
+
105
+ const startObserver = () => {
106
+ stopObserver();
107
+ if (!props.lazy || isLoaded.value) return;
108
+ const el = imgRef.value;
109
+ if (!el) {
110
+ isLoaded.value = true;
111
+ return;
112
+ }
113
+ if (typeof IntersectionObserver === 'undefined') {
114
+ isLoaded.value = true;
115
+ return;
116
+ }
117
+ observer = new IntersectionObserver(
118
+ (entries) => {
119
+ if (entries.some((entry) => entry.isIntersecting)) {
120
+ isLoaded.value = true;
121
+ stopObserver();
122
+ }
123
+ },
124
+ { rootMargin: props.rootMargin },
125
+ );
126
+ observer.observe(el);
127
+ };
128
+
129
+ onMounted(() => {
130
+ if (!props.lazy) {
131
+ isLoaded.value = true;
132
+ return;
133
+ }
134
+ startObserver();
135
+ });
136
+
137
+ watch(
138
+ () => props.lazy,
139
+ (lazy) => {
140
+ if (!lazy) {
141
+ isLoaded.value = true;
142
+ stopObserver();
143
+ return;
144
+ }
145
+ isLoaded.value = false;
146
+ startObserver();
147
+ },
148
+ );
149
+
150
+ watch(
151
+ () => props.rootMargin,
152
+ () => {
153
+ if (!props.lazy || isLoaded.value) return;
154
+ startObserver();
155
+ },
156
+ );
157
+
158
+ onBeforeUnmount(() => {
159
+ stopObserver();
160
+ });
53
161
 
54
162
  //预览
55
163
  const showPreview = () => {
@@ -77,6 +185,12 @@
77
185
  height: 100%;
78
186
  transform: translate3d(0, 0, 0);
79
187
  border-radius: 0.15em;
188
+ opacity: 0.55;
189
+ transition: opacity 0.25s ease;
190
+
191
+ &.is-loaded {
192
+ opacity: 1;
193
+ }
80
194
  }
81
195
 
82
196
  &-tools {
@@ -1,7 +1,7 @@
1
1
  <template>
2
2
  <div :class="`${frame ? 'b-solid b-line b-xs r-sm' : ''}`" ui-flex="col xy" style="width: 41em">
3
3
  <div class="flex-block" ui-flex="row xy">
4
- <div class="w-ss*m flex-fixed" ui-flex="col xy">
4
+ <div class="w-sm*m flex-fixed" ui-flex="col xy">
5
5
  <div ui-date-head="" class="nx-sl mt-ss ny-sm b-solid b-back" ui-flex="row xm">
6
6
  <div ui-flex="row lm" class="o-lm">
7
7
  <div ui-btn="@a s none :square" @click="prev(1, { y: -1 })">
@@ -75,7 +75,7 @@
75
75
  </div>
76
76
  </div>
77
77
  <div class="flex-block"></div>
78
- <div class="w-ss*m flex-fixed" ui-flex="col xy">
78
+ <div class="w-sm*m flex-fixed" ui-flex="col xy">
79
79
  <div ui-date-head="" class="nx-sl mt-ss ny-sm b-solid b-back" ui-flex="row xm">
80
80
  <div ui-flex="row lm">
81
81
  <div v-if="idate(sta.panel).time < idate(end.panel).update({ y: -1 }).time" ui-btn="@a s none :square" @click="prev(2, { y: -1 })">
@@ -28,7 +28,11 @@
28
28
  />
29
29
  </slot>
30
30
  <div ui-form-suffix="">
31
- <i ui-form-clean="" v-if="clearable && props.modelValue && !readonly && !preview && !disabled" @click.stop="clear"></i>
31
+ <i
32
+ v-if="clearable && !readonly && !preview && !disabled"
33
+ :ui-form-clean="hasClearValue ? '' : 'empty'"
34
+ @click.stop="hasClearValue && clear()"
35
+ ></i>
32
36
  <div
33
37
  class="o-ls"
34
38
  v-if="type === 'password' && cipher && !preview"
@@ -127,6 +131,8 @@
127
131
  interacted: null,
128
132
  });
129
133
 
134
+ const hasClearValue = computed(() => props.modelValue !== '' && props.modelValue !== null && props.modelValue !== undefined);
135
+
130
136
  const state: any = reactive({
131
137
  // 用户是否交互
132
138
  interacted: null,
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <div :ui-row="rows" class="n-sl-sub">
2
+ <div :ui-row="rows" :class="rootClass">
3
3
  <template v-for="(col, idx) in state.form" :key="idx">
4
4
  <div v-if="!col.clear" :id="col._fid" :ui-col="col.media" :ui-flex="`${vertical ? 'col xm' : 'row lt'}`">
5
5
  <template v-if="col.label">
@@ -182,7 +182,7 @@
182
182
  </div>
183
183
  </template>
184
184
  <script lang="ts" setup>
185
- import { watch, reactive, onBeforeUnmount, nextTick } from 'vue';
185
+ import { watch, reactive, onBeforeUnmount, nextTick, useAttrs, computed } from 'vue';
186
186
  import { useRoute, useRouter } from 'vue-router';
187
187
  import { isString, isObject, isFunction, deepcopy, isArray } from '@fekit/utils';
188
188
  import {
@@ -212,6 +212,9 @@
212
212
 
213
213
  const route = useRoute();
214
214
  const router = useRouter();
215
+ const attrs = useAttrs();
216
+ // 外部写了 class 时去掉默认根 class,由外部接管
217
+ const rootClass = computed(() => (attrs.class ? undefined : 'n-sl-sub'));
215
218
 
216
219
  // 计算Label行高
217
220
  const clh = ({ sz = '' }: any = {}) => {
@@ -5,7 +5,7 @@
5
5
  ><sup v-if="state.len" class="sup">{{ state.len }}</sup></i
6
6
  >
7
7
  </button>
8
- <div ui-tips-box="tl" class="w-sm*m mob:w-ss*m n-ms" :ux-view="state.show">
8
+ <div ui-tips-box="tl" class="w-ms*m mob:w-sm*m n-ms" :ux-view="state.show">
9
9
  <div class="fekit-layer-close" ui-flex="row cm" @click="state.show = 0"><i class="icon icon-close fs-sm"></i></div>
10
10
  <h4>温馨提示</h4>
11
11
  <p class="ny-sm" v-if="state.len">本条记录有未保存的草稿,是否使用草稿?</p>
@@ -5,7 +5,7 @@
5
5
  <i v-else class="icon icon-verify-ok co-well"></i>
6
6
  <span v-if="verify?.length">{{ verify?.length }}</span>
7
7
  </button>
8
- <div ui-tips-box="tl" :ux-view="state.show" class="w-sm*m mob:w-ss*m n-ms">
8
+ <div ui-tips-box="tl" :ux-view="state.show" class="w-ms*m mob:w-sm*m n-ms">
9
9
  <div class="fekit-layer-close" ui-flex="row cm" @click="state.show = 0"><i class="icon icon-close fs-sm"></i></div>
10
10
  <h4>温馨提示</h4>
11
11
  <p v-if="verify?.length" class="ny-sm">本条记录疑似有{{ verify?.length }}个错误,您再检查一遍吧~</p>