@neatui/vue 5.6.1 → 5.7.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.6.1",
3
+ "version": "5.7.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.6.1",
39
+ "@neatui/css": "^5.7.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",
@@ -0,0 +1,480 @@
1
+ <template>
2
+ <div ref="root" class="ui-sidemenu flex-fixed" :data-mode="mode" :data-open="isOpen ? 1 : 0" :data-rail="rail ? 1 : 0" :data-opening="opening ? 1 : 0">
3
+ <div class="ui-sidemenu-mask" @click="setOpen(false)"></div>
4
+ <aside class="ui-sidemenu-panel" ui-flex="col xy">
5
+ <div class="ui-sidemenu-header flex-fixed" ui-flex="row xm">
6
+ <div class="ui-sidemenu-title flex-block">
7
+ <slot name="header" :rail="rail" :mode="mode">
8
+ <h5 ui-omit="1"><b>{{ title }}</b></h5>
9
+ </slot>
10
+ </div>
11
+ <button v-if="toggle" :aria-expanded="isOpen" :aria-label="toggleLabel" :title="toggleLabel" ui-btn="@a s none :square" type="button" @click="setOpen(!isOpen)">
12
+ <i class="icon icon-menu"></i>
13
+ </button>
14
+ </div>
15
+ <div class="flex-block" ui-scroll="x:hidden :y">
16
+ <ul class="ui-sidemenu-list">
17
+ <li v-for="(item, idx) in list" :key="item.__key" :class="{ active: item.__active }" :data-open="item.__open ? 1 : 0" >
18
+ <IFollowView v-model="railMap[item.__key]" btn-class="block" event="hover" pos="rb" tip-class="mt-ss">
19
+ <div class="ui-sidemenu-item bg-weak:hover" @click.stop="onItem(item, idx)">
20
+ <component
21
+ :is="item.children ? 'button' : linkTag"
22
+ v-bind="item.children ? { type: 'button', 'aria-expanded': rail ? !!railMap[item.__key] : !!item.__open } : linkProps(item)"
23
+ :aria-label="rail ? item.name : undefined"
24
+ ui-flex="row lm"
25
+ >
26
+ <div ui-flex="row cm">
27
+ <div ui-btn="@a s none :square">
28
+ <slot name="icon" :item="item"><i :class="`fs++ icon ${item.icon || ''}`"></i></slot>
29
+ </div>
30
+ <span class="ui-sidemenu-name">{{ item.name }}</span>
31
+ </div>
32
+ <i v-if="item.children" class="ui-sidemenu-arrow icon icon-dropdown"></i>
33
+ <span v-else-if="item.badge" class="ui-sidemenu-badge bg-risk co-fore lh-xs nx-sm ny-ss r-full">{{ badgeText(item.badge) }}</span>
34
+ </component>
35
+ </div>
36
+ <div v-if="item.children" class="ui-sidemenu-sub-wrap" :inert="rail || !item.__open">
37
+ <ul class="ui-sidemenu-sub">
38
+ <li v-for="sub in item.children" :key="sub.__key" :class="{ 'co-main': sub.__active }">
39
+ <component :is="linkTag" v-bind="linkProps(sub)" @click="onLeaf(sub)">{{ sub.name }}</component>
40
+ </li>
41
+ </ul>
42
+ </div>
43
+ <template v-if="item.children && rail" #tips>
44
+ <ul :id="`${uid}-${item.__key}`" class="n-sl" ui-wrap="no">
45
+ <li v-for="sub in item.children" :key="sub.__key" :class="{ 'co-main': sub.__active }" class="ux-hover r-sm nx-sl ny-ss">
46
+ <component :is="linkTag" v-bind="linkProps(sub)" class="block" @click="onLeaf(sub)">{{ sub.name }}</component>
47
+ </li>
48
+ </ul>
49
+ </template>
50
+ </IFollowView>
51
+ </li>
52
+ </ul>
53
+ </div>
54
+ <div v-if="$slots.footer" class="ui-sidemenu-footer flex-fixed">
55
+ <slot name="footer" :rail="rail" :mode="mode"></slot>
56
+ </div>
57
+ </aside>
58
+ </div>
59
+ </template>
60
+
61
+ <script setup lang="ts">
62
+ import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, useId, watch } from 'vue';
63
+ import { RouterLink, useRoute } from 'vue-router';
64
+ import IFollowView from './IFollowView.vue';
65
+
66
+ export interface SideMenuItem {
67
+ id?: string | number;
68
+ name: string;
69
+ path?: string;
70
+ icon?: string;
71
+ badge?: number | string;
72
+ children?: SideMenuItem[];
73
+ [key: string]: any;
74
+ }
75
+ type Mode = 'mob' | 'pad' | 'dpc';
76
+
77
+ const props = withDefaults(
78
+ defineProps<{
79
+ menus?: SideMenuItem[];
80
+ /** v-model:open;不传时组件自管,默认电脑展开、平板/手机收起 */
81
+ open?: boolean | number;
82
+ /** 当前路径,不传取 vue-router 当前路由 */
83
+ active?: string;
84
+ /** 子路由(/a/b)也高亮 /a */
85
+ prefix?: boolean;
86
+ title?: string;
87
+ toggle?: boolean;
88
+ toggleLabel?: string;
89
+ /** 同时只展开一个分组 */
90
+ accordion?: boolean;
91
+ }>(),
92
+ { menus: () => [], open: undefined, active: undefined, prefix: true, title: '', toggle: true, toggleLabel: '切换侧边栏', accordion: true },
93
+ );
94
+ const emit = defineEmits<{
95
+ (e: 'update:open', v: boolean): void;
96
+ (e: 'select', item: SideMenuItem): void;
97
+ (e: 'mode', mode: Mode): void;
98
+ }>();
99
+
100
+ // ---- 三端 ----
101
+ // 断点同 @neatui/css query:mob < 800 ≤ pad < 1280 ≤ dpc
102
+ const mode = ref<Mode>('dpc');
103
+ let mqMob: MediaQueryList | undefined;
104
+ let mqPad: MediaQueryList | undefined;
105
+ const readMode = () => {
106
+ mode.value = mqMob?.matches ? 'mob' : mqPad?.matches ? 'pad' : 'dpc';
107
+ };
108
+
109
+ // ---- 展开 ----
110
+ const inner = ref(true);
111
+ const isOpen = computed(() => (props.open === undefined ? inner.value : !!props.open));
112
+ const rail = computed(() => mode.value !== 'mob' && !isOpen.value);
113
+ const opening = ref(false);
114
+ let openingTimer: ReturnType<typeof setTimeout> | undefined;
115
+ const setOpen = (v: boolean) => {
116
+ inner.value = v;
117
+ emit('update:open', v);
118
+ };
119
+ watch(isOpen, (v) => {
120
+ // 展开过程中延迟展开子菜单,避免宽度动画时文字换行跳动
121
+ if (openingTimer) clearTimeout(openingTimer);
122
+ opening.value = v;
123
+ if (v) openingTimer = setTimeout(() => (opening.value = false), 400);
124
+ });
125
+ watch(mode, (m, old) => {
126
+ emit('mode', m);
127
+ // 换端时回到该端的默认态:电脑展开,平板/手机收起
128
+ if (old) setOpen(m === 'dpc');
129
+ });
130
+
131
+ const onKey = (e: KeyboardEvent) => {
132
+ if (e.key === 'Escape' && isOpen.value && mode.value !== 'dpc') setOpen(false);
133
+ };
134
+
135
+ onMounted(() => {
136
+ mqMob = window.matchMedia('(max-width: 799px)');
137
+ mqPad = window.matchMedia('(max-width: 1279px)');
138
+ readMode();
139
+ mqMob.addEventListener('change', readMode);
140
+ mqPad.addEventListener('change', readMode);
141
+ emit('mode', mode.value);
142
+ if (props.open === undefined) inner.value = mode.value === 'dpc';
143
+ else if (mode.value !== 'dpc' && props.open) setOpen(false);
144
+ window.addEventListener('keydown', onKey);
145
+ });
146
+ onBeforeUnmount(() => {
147
+ mqMob?.removeEventListener('change', readMode);
148
+ mqPad?.removeEventListener('change', readMode);
149
+ window.removeEventListener('keydown', onKey);
150
+ if (openingTimer) clearTimeout(openingTimer);
151
+ });
152
+
153
+ // ---- 菜单 ----
154
+ const route = useRoute();
155
+ const current = computed(() => props.active ?? route?.path ?? '');
156
+ const hit = (path?: string) => {
157
+ if (!path) return false;
158
+ const p = current.value.replace(/\/+$/, '') || '/';
159
+ const b = path.replace(/\/+$/, '') || '/';
160
+ return p === b || (props.prefix && b !== '/' && p.startsWith(b + '/'));
161
+ };
162
+ const keyOf = (item: SideMenuItem, idx: number) => String(item.id ?? item.path ?? idx);
163
+ const openMap = reactive<Record<string, boolean>>({});
164
+ // 收起态悬浮子菜单的显示状态
165
+ const railMap = reactive<Record<string, number>>({});
166
+ const uid = useId() || 'sidemenu';
167
+
168
+ const list = computed(() =>
169
+ props.menus.map((item, idx) => {
170
+ const __key = keyOf(item, idx);
171
+ if (!item.children?.length) return { ...item, children: undefined, __key, __active: hit(item.path), __open: false };
172
+ let __active = false;
173
+ const children = item.children.map((sub, i) => {
174
+ const a = hit(sub.path);
175
+ if (a) __active = true;
176
+ return { ...sub, __key: keyOf(sub, i), __active: a };
177
+ });
178
+ return { ...item, children, __key, __active, __open: openMap[__key] ?? false };
179
+ }),
180
+ );
181
+
182
+ // 当前路由所在分组自动展开(菜单异步到达、直接输网址都生效)
183
+ const activeGroup = computed(() => list.value.find((m) => m.children && m.__active)?.__key || '');
184
+ watch(
185
+ activeGroup,
186
+ (k) => {
187
+ if (!k) return;
188
+ if (props.accordion) Object.keys(openMap).forEach((o) => (openMap[o] = false));
189
+ openMap[k] = true;
190
+ },
191
+ { immediate: true },
192
+ );
193
+
194
+ const onItem = (item: any, _idx: number) => {
195
+ if (!item.children) return onLeaf(item);
196
+ // 收起态用悬浮子菜单:鼠标 hover 打开,键盘/点击切换并把焦点移入第一项
197
+ if (rail.value) {
198
+ const next = !railMap[item.__key];
199
+ railMap[item.__key] = next ? 1 : 0;
200
+ if (next) nextTick(() => document.getElementById(`${uid}-${item.__key}`)?.querySelector<HTMLElement>('a')?.focus());
201
+ return;
202
+ }
203
+ const next = !item.__open;
204
+ if (props.accordion) Object.keys(openMap).forEach((o) => (openMap[o] = false));
205
+ openMap[item.__key] = next;
206
+ };
207
+ const onLeaf = (item: SideMenuItem) => {
208
+ emit('select', item);
209
+ // 平板/手机选完收起浮层
210
+ if (mode.value !== 'dpc' && isOpen.value) setOpen(false);
211
+ };
212
+
213
+ const linkTag = computed(() => (route ? RouterLink : 'a'));
214
+ const linkProps = (item: SideMenuItem) => (route ? { to: item.path || '' } : { href: item.path || '' });
215
+ const badgeText = (b: number | string) => (typeof b === 'number' && b > 99 ? '99+' : b);
216
+
217
+ defineExpose({ mode, open: isOpen, setOpen });
218
+ </script>
219
+
220
+ <style lang="scss">
221
+ $rail: 5rem;
222
+ $full: 18rem;
223
+ $ease: cubic-bezier(0.32, 0.72, 0, 1);
224
+
225
+ .ui-sidemenu {
226
+ position: relative;
227
+ height: 100%;
228
+ width: $full;
229
+ z-index: 20;
230
+ transition: width 0.2s;
231
+
232
+ &[data-rail='1'] {
233
+ width: $rail;
234
+ }
235
+ // 平板:始终占轨道宽,展开时浮在内容上
236
+ &[data-mode='pad'] {
237
+ width: $rail;
238
+ }
239
+ // 手机:不占位,抽屉
240
+ &[data-mode='mob'] {
241
+ width: 0;
242
+ }
243
+ }
244
+
245
+ .ui-sidemenu-panel {
246
+ position: relative;
247
+ width: 100%;
248
+ height: 100%;
249
+ background: var(--co-fore);
250
+ box-shadow: 0 0 0.5rem color-mix(in srgb, var(--co-deep) 95%, transparent);
251
+ transition: width 0.2s, transform 0.4s $ease, box-shadow 0.4s $ease;
252
+
253
+ [data-mode='pad'] > & {
254
+ position: absolute;
255
+ left: 0;
256
+ top: 0;
257
+ width: $rail;
258
+ }
259
+ [data-mode='pad'][data-open='1'] > & {
260
+ width: $full;
261
+ }
262
+ [data-mode='mob'] > & {
263
+ position: fixed;
264
+ left: 0;
265
+ top: 0;
266
+ bottom: 0;
267
+ width: min(#{$full}, 85vw);
268
+ z-index: 40;
269
+ transform: translate3d(-100%, 0, 0);
270
+ box-shadow: none;
271
+ }
272
+ [data-mode='mob'][data-open='1'] > & {
273
+ transform: translate3d(0, 0, 0);
274
+ box-shadow: 0 0 1rem rgba(0, 0, 0, 0.2);
275
+ }
276
+ }
277
+
278
+ .ui-sidemenu-mask {
279
+ position: fixed;
280
+ inset: 0;
281
+ background: rgba(0, 0, 0, 0.35);
282
+ opacity: 0;
283
+ pointer-events: none;
284
+ transition: opacity 0.4s $ease;
285
+ z-index: 39;
286
+
287
+ [data-mode='pad'] > & {
288
+ background: transparent;
289
+ }
290
+ [data-mode='mob'][data-open='1'] > &,
291
+ [data-mode='pad'][data-open='1'] > & {
292
+ opacity: 1;
293
+ pointer-events: auto;
294
+ }
295
+ }
296
+ [data-mode='pad'] > .ui-sidemenu-panel {
297
+ z-index: 40;
298
+ }
299
+
300
+ .ui-sidemenu-header {
301
+ height: 4rem;
302
+ line-height: 2rem;
303
+ padding: 1rem 0.75rem 1rem 1rem;
304
+ white-space: nowrap;
305
+ box-shadow: 0 1px 0 var(--co-back);
306
+ transition: padding 0.2s;
307
+ }
308
+ .ui-sidemenu-footer {
309
+ padding: 1rem;
310
+ transition: padding 0.2s;
311
+ }
312
+
313
+ .ui-sidemenu-title,
314
+ .ui-sidemenu-name {
315
+ opacity: 1;
316
+ transition: opacity 0.2s 0.2s;
317
+ vertical-align: middle;
318
+ overflow: hidden;
319
+ }
320
+
321
+ .ui-sidemenu-list {
322
+ & > li {
323
+ position: relative;
324
+ white-space: nowrap;
325
+ padding: 0.25rem;
326
+ user-select: none;
327
+ transition: padding 0.2s;
328
+
329
+ &.active {
330
+ color: var(--co-main);
331
+ &::before {
332
+ position: absolute;
333
+ content: '';
334
+ width: 2px;
335
+ height: 2.5rem;
336
+ left: 0;
337
+ top: 0.25rem;
338
+ background: var(--co-main);
339
+ }
340
+ }
341
+ }
342
+ a {
343
+ cursor: pointer;
344
+ color: inherit;
345
+ }
346
+ }
347
+ .ui-sidemenu-item {
348
+ position: relative;
349
+ display: block;
350
+ padding: 0.25em;
351
+ border-radius: 0.5rem;
352
+ cursor: pointer;
353
+ }
354
+
355
+ // 分组标题用原生按钮,去掉默认样式
356
+ .ui-sidemenu-item > button {
357
+ width: 100%;
358
+ padding: 0;
359
+ border: 0;
360
+ background: none;
361
+ color: inherit;
362
+ font: inherit;
363
+ text-align: left;
364
+ cursor: pointer;
365
+ }
366
+
367
+ .ui-sidemenu-arrow {
368
+ position: absolute;
369
+ width: 2rem;
370
+ height: 2rem;
371
+ line-height: 2rem;
372
+ right: 0;
373
+ top: 50%;
374
+ margin-top: -1rem;
375
+ text-align: center;
376
+ transform: rotate(-90deg);
377
+ transition: transform 0.2s, opacity 0.2s 0.1s;
378
+ }
379
+ [data-open='1'] > * > .ui-sidemenu-item .ui-sidemenu-arrow {
380
+ transform: rotate(-180deg);
381
+ }
382
+
383
+ .ui-sidemenu-badge {
384
+ position: absolute;
385
+ top: 50%;
386
+ right: 1rem;
387
+ transform: translate3d(50%, -50%, 0) scale(0.5);
388
+ pointer-events: none;
389
+ }
390
+
391
+ // 二级(展开态)
392
+ .ui-sidemenu-sub-wrap {
393
+ display: grid;
394
+ grid-template-rows: 0fr;
395
+ opacity: 0;
396
+ transition: grid-template-rows 0.2s, opacity 0.2s;
397
+ }
398
+ .ui-sidemenu-list > [data-open='1'] .ui-sidemenu-sub-wrap {
399
+ grid-template-rows: 1fr;
400
+ opacity: 1;
401
+ }
402
+ .ui-sidemenu[data-opening='1'] .ui-sidemenu-list > [data-open='1'] .ui-sidemenu-sub-wrap {
403
+ transition-delay: 0.2s;
404
+ }
405
+ .ui-sidemenu-sub {
406
+ position: relative;
407
+ min-height: 0;
408
+ overflow: hidden;
409
+ padding-left: 2.5rem;
410
+ line-height: 2.5rem;
411
+ color: var(--co-read);
412
+
413
+ & > li {
414
+ position: relative;
415
+ height: 2.5rem;
416
+ padding: 0 0.5em;
417
+ border-radius: 0.5rem;
418
+ &:hover {
419
+ background: var(--co-weak);
420
+ }
421
+ a {
422
+ display: block;
423
+ }
424
+ // 树线
425
+ &::before {
426
+ position: absolute;
427
+ content: '';
428
+ width: 2px;
429
+ height: 100%;
430
+ top: 0;
431
+ left: -0.5em;
432
+ border-right: 1px solid var(--co-line);
433
+ }
434
+ &:last-child::before {
435
+ height: 50%;
436
+ }
437
+ &::after {
438
+ position: absolute;
439
+ content: '';
440
+ width: 0.6em;
441
+ height: 1px;
442
+ top: 50%;
443
+ left: -0.5em;
444
+ background: var(--co-line);
445
+ }
446
+ }
447
+ }
448
+
449
+ // 收起轨道态(电脑收起 / 平板默认)
450
+ .ui-sidemenu[data-rail='1'] {
451
+ .ui-sidemenu-header,
452
+ .ui-sidemenu-footer {
453
+ padding: 1rem 1.5rem;
454
+ }
455
+ .ui-sidemenu-title {
456
+ position: absolute;
457
+ left: 0.5rem;
458
+ opacity: 0;
459
+ visibility: hidden;
460
+ transition: none;
461
+ }
462
+ .ui-sidemenu-name {
463
+ opacity: 0;
464
+ pointer-events: none;
465
+ transition: opacity 0.15s;
466
+ }
467
+ .ui-sidemenu-list > li {
468
+ padding: 0.25rem 1.25rem;
469
+ }
470
+ .ui-sidemenu-sub-wrap,
471
+ .ui-sidemenu-arrow {
472
+ display: none;
473
+ }
474
+ .ui-sidemenu-badge {
475
+ top: 0;
476
+ right: 0;
477
+ transform: translate3d(40%, -30%, 0) scale(0.5);
478
+ }
479
+ }
480
+ </style>
@@ -0,0 +1,488 @@
1
+ <template>
2
+ <div class="ui-sideview-wrap flex-fixed" :data-side="isOpen ? 'open' : 'hide'">
3
+ <div :class="{ 'ui-sideview-opening': opening }" class="ui-sideview">
4
+ <div class="ui-sideview-mob-hide"></div>
5
+ <component :is="linkTag" v-bind="linkProps({ path: home })" :ui-hide="isOpen ? '' : '<pad'" class="ui-sideview-header" ui-flex="row lm">
6
+ <slot name="header" :open="isOpen">
7
+ <h5 class="ui-sideview-header-title nl-ss"><b>{{ title }}</b></h5>
8
+ </slot>
9
+ </component>
10
+ <div :ui-hide="isOpen ? '' : '<pad'" class="ui-sideview-middle" ui-scroll="x:hidden :y">
11
+ <ul class="ui-sideview-list">
12
+ <li v-for="item in list" :key="item.__key" :class="{ active: item.__active }" :data-open="item.__open ? 1 : 0">
13
+ <IFollowView btn-class="block" event="hover" pos="rb" tip-class="mt-ss">
14
+ <div class="bg-weak:hover" @click.stop="toggleGroup(item)">
15
+ <div v-if="item.children" ui-flex="row lm">
16
+ <div ui-flex="row cm">
17
+ <div ui-btn="@a s none :square">
18
+ <slot name="icon" :item="item"><i :class="`fs++ icon ${item.icon || ''}`"></i></slot>
19
+ </div>
20
+ <span class="ui-sideview-list-item-name">{{ item.name }}</span>
21
+ </div>
22
+ <i class="ui-sideview-list-item-sown icon icon-dropdown"></i>
23
+ </div>
24
+ <component :is="linkTag" v-else v-bind="linkProps(item)" ui-flex="row lm" @click="emit('select', item)">
25
+ <div ui-flex="row cm">
26
+ <div ui-btn="@a s none :square">
27
+ <slot name="icon" :item="item"><i :class="`fs++ icon ${item.icon || ''}`"></i></slot>
28
+ </div>
29
+ <span class="ui-sideview-list-item-name">{{ item.name }}</span>
30
+ </div>
31
+ </component>
32
+ </div>
33
+ <div v-if="item.children" class="ui-sideview-list-tow-height">
34
+ <ul class="ui-sideview-list-tow">
35
+ <li v-for="sub in item.children" :key="sub.__key" :class="{ 'co-main': sub.__active }">
36
+ <component :is="linkTag" v-bind="linkProps(sub)" @click="emit('select', sub)">{{ sub.name }}</component>
37
+ </li>
38
+ </ul>
39
+ </div>
40
+ <template v-if="item.children && !isOpen" #tips>
41
+ <ul class="ui-sideview-list-tow n-sl" ui-wrap="no">
42
+ <li v-for="sub in item.children" :key="sub.__key" :class="{ 'co-main': sub.__active }" class="ux-hover r-sm nx-sl ny-ss">
43
+ <component :is="linkTag" v-bind="linkProps(sub)" @click="emit('select', sub)">{{ sub.name }}</component>
44
+ </li>
45
+ </ul>
46
+ </template>
47
+ </IFollowView>
48
+ </li>
49
+ </ul>
50
+ </div>
51
+ </div>
52
+ </div>
53
+ </template>
54
+
55
+ <script setup lang="ts">
56
+ import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue';
57
+ import { RouterLink, useRoute } from 'vue-router';
58
+ import IFollowView from './IFollowView.vue';
59
+ import type { SideMenuItem } from './SideMenu.vue';
60
+
61
+ // 经典侧栏:展开 18rem / 收起 5rem 图标栏(悬浮子菜单);手机收起时宽 0
62
+ const props = withDefaults(
63
+ defineProps<{
64
+ menus?: SideMenuItem[];
65
+ /** v-model:open,不传时组件自管,默认展开 */
66
+ open?: boolean | number;
67
+ /** 当前路径,不传取 vue-router 当前路由 */
68
+ active?: string;
69
+ /** 子路由(/a/b)也高亮 /a */
70
+ prefix?: boolean;
71
+ title?: string;
72
+ /** 标题链接地址 */
73
+ home?: string;
74
+ }>(),
75
+ { menus: () => [], open: undefined, active: undefined, prefix: true, title: '', home: '/' },
76
+ );
77
+ const emit = defineEmits<{
78
+ (e: 'update:open', v: boolean): void;
79
+ (e: 'select', item: SideMenuItem): void;
80
+ }>();
81
+
82
+ const inner = ref(true);
83
+ const isOpen = computed(() => (props.open === undefined ? inner.value : !!props.open));
84
+ const setOpen = (v: boolean) => {
85
+ inner.value = v;
86
+ emit('update:open', v);
87
+ };
88
+
89
+ // 展开侧栏时延迟展开子菜单,避免宽度动画过程中文字换行跳动
90
+ const opening = ref(false);
91
+ let timer: ReturnType<typeof setTimeout> | undefined;
92
+ watch(isOpen, (v) => {
93
+ if (timer) clearTimeout(timer);
94
+ opening.value = v;
95
+ if (v) timer = setTimeout(() => (opening.value = false), 400);
96
+ });
97
+ onBeforeUnmount(() => timer && clearTimeout(timer));
98
+
99
+ const route = useRoute();
100
+ const current = computed(() => props.active ?? route?.path ?? '');
101
+ const hit = (path?: string) => {
102
+ if (!path) return false;
103
+ const p = current.value;
104
+ return p === path || (props.prefix && p.startsWith(path + '/'));
105
+ };
106
+ const keyOf = (item: SideMenuItem, idx: number) => String(item.id ?? item.path ?? idx);
107
+
108
+ // 展开状态单独存放;未手动操作过时,默认展开当前路由所在分组
109
+ const openMap = reactive<Record<string, boolean>>({});
110
+ const list = computed(() =>
111
+ props.menus.map((item, idx) => {
112
+ const __key = keyOf(item, idx);
113
+ if (!item.children?.length) return { ...item, children: undefined, __key, __active: hit(item.path), __open: false };
114
+ let __active = false;
115
+ const children = item.children.map((sub, i) => {
116
+ const a = hit(sub.path);
117
+ if (a) __active = true;
118
+ return { ...sub, __key: keyOf(sub, i), __active: a };
119
+ });
120
+ return { ...item, children, __key, __active, __open: openMap[__key] ?? __active };
121
+ }),
122
+ );
123
+ const toggleGroup = (item: any) => {
124
+ if (!item.children) return;
125
+ const next = !item.__open;
126
+ list.value.forEach((m) => (openMap[m.__key] = false));
127
+ openMap[item.__key] = next;
128
+ };
129
+
130
+ const linkTag = computed(() => (route ? RouterLink : 'a'));
131
+ const linkProps = (item: { path?: string }) => (route ? { to: item.path || '' } : { href: item.path || '' });
132
+
133
+ defineExpose({ open: isOpen, setOpen });
134
+ </script>
135
+
136
+ <style lang="scss">
137
+ .ui-sideview-wrap {
138
+ height: 100%;
139
+ }
140
+ // 侧边栏
141
+ .ui-sideview {
142
+ position: relative;
143
+ left: 0;
144
+ top: 0;
145
+ // width: 5rem;
146
+ padding-top: 4rem;
147
+ // @include med('=mob') {
148
+ // width: 18rem;
149
+ // transform: translate3d(-100%, 0, 0);
150
+ // }
151
+ height: 100%;
152
+ z-index: 15;
153
+
154
+ background: var(--co-fore);
155
+ box-shadow: 0 0 0.5rem
156
+ color-mix(in srgb, var(--co-deep) calc(0.95 * 100%), transparent);
157
+
158
+ transition: width 0.3s;
159
+ }
160
+
161
+ // 移动端暗层
162
+ .ui-sideview-mob-hide {
163
+ position: absolute;
164
+ width: 0rem;
165
+ height: 100%;
166
+ opacity: 0;
167
+ pointer-events: none;
168
+ }
169
+ // 标题
170
+ .ui-sideview-header {
171
+ position: absolute;
172
+ width: 100%;
173
+ height: 4rem;
174
+ left: 0;
175
+ top: 0;
176
+ line-height: 2rem;
177
+ padding: 1rem 0.75rem;
178
+ white-space: nowrap;
179
+
180
+ box-shadow: 0 1px 0 var(--co-back);
181
+
182
+ transition: all 0.3s;
183
+ }
184
+
185
+ .ui-sideview-list {
186
+ & > * {
187
+ position: relative;
188
+ white-space: nowrap;
189
+ padding: 0.25rem;
190
+ user-select: none;
191
+ transition: all 0.3s;
192
+
193
+ a {
194
+ cursor: pointer;
195
+ }
196
+
197
+ & > * {
198
+ position: relative;
199
+ display: block;
200
+ cursor: pointer;
201
+
202
+ & > * {
203
+ position: relative;
204
+ display: block;
205
+ padding: 0.25em;
206
+ border-radius: 0.5rem;
207
+ }
208
+ }
209
+
210
+ &:hover {
211
+ .ui-sideview-list-tow {
212
+ opacity: 1;
213
+ transform: translate3d(0, 0, 0);
214
+ pointer-events: auto;
215
+
216
+ background: var(--co-fore);
217
+ }
218
+ }
219
+
220
+ &.active {
221
+ -webkit-font-smoothing: auto;
222
+
223
+ color: var(--co-main);
224
+
225
+ &::before {
226
+ position: absolute;
227
+ content: "";
228
+ width: 2px;
229
+ height: 2.5rem;
230
+ left: 0;
231
+ top: 0.25rem;
232
+
233
+ background: var(--co-main);
234
+ }
235
+ }
236
+ }
237
+ }
238
+ .ui-sideview-header-title,
239
+ .ui-sideview-list-item-name {
240
+ transition: opacity 0.25s;
241
+ opacity: 0;
242
+ vertical-align: middle;
243
+ pointer-events: none;
244
+ }
245
+
246
+ .ui-sideview-middle {
247
+ position: relative;
248
+ height: 100%;
249
+ & > * {
250
+ height: 100%;
251
+ }
252
+ }
253
+
254
+ // 隐藏时
255
+ [data-side~="hide"] {
256
+ .ui-sideview-header {
257
+ padding: 1rem 1.5rem;
258
+ }
259
+ .ui-sideview {
260
+ width: 5rem;
261
+ }
262
+ @media screen and (max-width: 799px) {
263
+ .ui-sideview {
264
+ width: 0;
265
+ }
266
+ }
267
+
268
+ .ui-sideview-header-title {
269
+ position: absolute;
270
+ left: 0.5rem;
271
+ opacity: 0;
272
+ visibility: hidden;
273
+ transition: none;
274
+ }
275
+
276
+ .ui-sideview-list {
277
+ & > * {
278
+ padding: 0.25rem 1.25rem;
279
+ }
280
+ }
281
+
282
+ .ui-sideview-list-tow-height {
283
+ display: none;
284
+ }
285
+
286
+ .ui-sideview-list-tow {
287
+ position: absolute;
288
+ width: 12rem;
289
+ top: -1rem;
290
+ left: 8rem;
291
+ padding: 1rem 1rem;
292
+ border-radius: 0.3rem;
293
+ line-height: 3rem;
294
+ opacity: 0;
295
+ transform: translate3d(1rem, 0, 0);
296
+ pointer-events: none;
297
+
298
+ color: var(--co-read);
299
+
300
+ transition: none;
301
+ &::before {
302
+ position: absolute;
303
+ content: "";
304
+ display: block;
305
+ width: 0;
306
+ height: 0;
307
+ box-sizing: content-box;
308
+ border: 6px solid transparent;
309
+ right: 99.9%;
310
+ top: 2.5rem;
311
+ margin-top: -6px;
312
+
313
+ border-right-color: var(--co-fore);
314
+ }
315
+ &::after {
316
+ position: absolute;
317
+ content: "";
318
+ display: block;
319
+ width: 1.5rem;
320
+ top: 0;
321
+ bottom: 0;
322
+ right: 100%;
323
+ }
324
+
325
+ & > * {
326
+ padding: 0 1rem;
327
+ border-radius: 0.3rem;
328
+ &:hover {
329
+ background: var(--co-weak);
330
+ }
331
+ }
332
+ }
333
+ .ui-sideview-list-item-sown {
334
+ pointer-events: none;
335
+ opacity: 0;
336
+ }
337
+ }
338
+
339
+ // 展开时
340
+ [data-side~="open"] {
341
+ & {
342
+ // padding-left: 15rem;
343
+
344
+ // @include med('=mob') {
345
+ // padding-left: 0;
346
+ // transform: translate3d(18rem, 0, 0);
347
+ // }
348
+
349
+ .ui-sideview {
350
+ width: 18rem;
351
+ .ui-sideview-header-title,
352
+ .ui-sideview-list-item-name {
353
+ opacity: 1;
354
+ pointer-events: auto;
355
+ vertical-align: middle;
356
+ transition: opacity 0.3s 0.2s;
357
+ }
358
+ }
359
+ }
360
+
361
+ .ui-sideview-list-tow-height {
362
+ display: grid;
363
+ grid-template-rows: 0fr;
364
+ opacity: 0;
365
+ transition:
366
+ grid-template-rows 0.2s 0s,
367
+ opacity 0.2s 0s;
368
+ }
369
+
370
+ .ui-sideview-list-tow {
371
+ position: relative;
372
+ width: 100%;
373
+ min-height: 0;
374
+ padding-left: 2.5rem;
375
+ line-height: 2.5rem;
376
+ overflow: hidden;
377
+
378
+ color: var(--co-read);
379
+
380
+ & > * {
381
+ position: relative;
382
+ height: 2.5rem;
383
+ padding: 0 0.5em;
384
+ border-radius: 0.5rem;
385
+
386
+ &:hover {
387
+ background: var(--co-weak);
388
+ }
389
+ a {
390
+ display: block;
391
+ }
392
+ &.active {
393
+ color: var(--co-main);
394
+ }
395
+
396
+ &::before {
397
+ position: absolute;
398
+ content: "";
399
+ display: block;
400
+ width: 1px;
401
+ height: 100%;
402
+ top: 0;
403
+ left: -0.5em;
404
+
405
+ background: var(--co-line);
406
+ }
407
+ &:last-child {
408
+ &::before {
409
+ height: 50%;
410
+ }
411
+ }
412
+
413
+ &::after {
414
+ position: absolute;
415
+ content: "";
416
+ display: block;
417
+ width: 0.6em;
418
+ height: 1px;
419
+ top: 50%;
420
+ left: -0.5em;
421
+
422
+ background-color: var(--co-line);
423
+ }
424
+ }
425
+
426
+ &::before {
427
+ position: absolute;
428
+ content: "";
429
+ display: none;
430
+ width: 0;
431
+ height: 0;
432
+ box-sizing: content-box;
433
+ border: 6px solid transparent;
434
+ right: 99.9%;
435
+ top: 2.5rem;
436
+ margin-top: -6px;
437
+
438
+ border-right-color: var(--co-fore);
439
+ }
440
+ &::after {
441
+ position: absolute;
442
+ content: "";
443
+ display: none;
444
+ width: 1rem;
445
+ top: 0;
446
+ bottom: 0;
447
+ right: 100%;
448
+ }
449
+ }
450
+
451
+ .ui-sideview-list-item-sown {
452
+ position: absolute;
453
+ display: block;
454
+ width: 2rem;
455
+ height: 2rem;
456
+ line-height: 2rem;
457
+ right: 0;
458
+ top: 50%;
459
+ text-align: center;
460
+ margin-top: -1rem;
461
+ transform-origin: 50% 50%;
462
+ transform: rotate(-90deg) scale3d(1, 1, 1);
463
+ opacity: 1;
464
+ transition:
465
+ transform 0.3s,
466
+ opacity 0.3s 0.2s;
467
+ pointer-events: auto;
468
+ }
469
+
470
+ .ui-sideview-list > *[data-open="1"] {
471
+ .ui-sideview-list-tow-height {
472
+ grid-template-rows: 1fr;
473
+ opacity: 1;
474
+ }
475
+
476
+ .ui-sideview-list-item-sown {
477
+ transform: rotate(-180deg) scale3d(1, 1, 1);
478
+ }
479
+ }
480
+
481
+ .ui-sideview.ui-sideview-opening .ui-sideview-list > *[data-open="1"] {
482
+ .ui-sideview-list-tow-height {
483
+ transition-delay: 0.2s;
484
+ }
485
+ }
486
+ }
487
+
488
+ </style>
@@ -4,4 +4,7 @@ import IFollowView from './IFollowView.vue';
4
4
  import IPickerView from './IPickerView.vue';
5
5
  import IScrollView from './IScrollView.vue';
6
6
  import IRouterView from './IRouterView.vue';
7
- export { Icon, IRouterView, IFollowView, IPickerView, IScrollView, Layer, LayerById };
7
+ import SideMenu from './SideMenu.vue';
8
+ import SideView from './SideView.vue';
9
+ export type { SideMenuItem } from './SideMenu.vue';
10
+ export { Icon, IRouterView, IFollowView, IPickerView, IScrollView, Layer, LayerById, SideMenu, SideView };
@@ -1,10 +1,10 @@
1
1
  <template>
2
2
  <div ui-flex="row lm :wrap" class="neatui-upload mb-sm-sub mr-sm-sub" v-bind="{ ...attrs }">
3
3
  <div v-for="(file, idx) in state.files" :key="idx" ui-flex="col cm" class="flex-fixed pr" :style="`width: ${isNumber(width) ? width + 'em' : width}`" v-bind="itemAttrs">
4
- <div class="w-full pr" :style="`box-sizing: content-box; padding-top: ${ratio}%`">
5
- <div class="pa full ot-no ol-no bg-fore n-sm b-solid b-case b-xs r-sm" ui-flex="col cm">
4
+ <div class="w-full pr" :style="boxStyle">
5
+ <div :class="`${ratio ? 'pa full n-sm' : 'w-full n-ms min-h-ms*s'} ot-no ol-no bg-fore b-solid b-case b-xs r-sm ${canOpen(file) ? 'ux-none' : ''}`" ui-flex="col cm" @click="openFile(file)">
6
6
  <!-- 删除按钮 -->
7
- <div v-if="!disabled" class="pa w-sl h-sl ot-ss or-ss bg-fore co-risk lh-xs r-xl sub-hover z-ls" ui-flex="row cm" @click="remove(idx)">
7
+ <div v-if="!disabled" class="pa w-sl h-sl ot-ss or-ss bg-fore co-risk lh-xs r-xl sub-hover z-ls ux-none" ui-flex="row cm" @click.stop="remove(idx)">
8
8
  <svg class="block" style="width: 1.2em; height: 1.2em" viewBox="0 0 1024 1024">
9
9
  <path
10
10
  d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm165.4 618.2l-66 0.3L512 563.4l-99.3 118.4-66.1-0.3c-4.4 0-8-3.5-8-8 0-1.9 0.7-3.7 1.9-5.2l130.1-155L340.5 359c-1.2-1.5-1.9-3.3-1.9-5.2 0-4.4 3.6-8 8-8l66.1 0.3L512 464.6l99.3-118.4 66-0.3c4.4 0 8 3.5 8 8 0 1.9-0.7 3.7-1.9 5.2L553.5 514l130 155c1.2 1.5 1.9 3.3 1.9 5.2 0 4.4-3.6 8-8 8z"
@@ -17,7 +17,7 @@
17
17
  <img v-if="file.type === 'image'" class="r-ss" :src="getImageUrl(file)" alt="" />
18
18
  <video v-else-if="file.type === 'video'" class="full r-ss" :src="getImageUrl(file)" controls muted></video>
19
19
  <template v-else-if="file.type === 'pdf'">
20
- <svg t="1705645350800" class="w-ls h-ls" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1913">
20
+ <svg t="1705645350800" :class="iconClass" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1913">
21
21
  <path
22
22
  d="M205.5 64H665l232 232v639c0 13.807-11.193 25-25 25H205.5c-13.807 0-25-11.193-25-25V89c0-13.807 11.193-25 25-25z m449.145 25H205.5v846H872V306.355L654.645 89z"
23
23
  fill="#B7B7BD"
@@ -38,7 +38,7 @@
38
38
  </svg>
39
39
  </template>
40
40
  <template v-else-if="file.type === 'xlsx'">
41
- <svg t="1705645380770" class="w-ls h-ls" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2060">
41
+ <svg t="1705645380770" :class="iconClass" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2060">
42
42
  <path
43
43
  d="M205.799024 64.936585H664.850732l231.773658 231.773659v638.376585c0 13.79353-11.18208 24.97561-24.97561 24.97561H205.799024c-13.79353 0-24.97561-11.18208-24.975609-24.97561V89.912195c0-13.79353 11.18208-24.97561 24.975609-24.97561z m448.70681 24.97561H205.799024v845.174634H871.64878V307.055141L654.505834 89.912195z"
44
44
  fill="#B7B7BD"
@@ -87,7 +87,7 @@
87
87
  </svg>
88
88
  </template>
89
89
  <template v-else-if="file.type === 'docx'">
90
- <svg t="1705645401993" class="w-ls h-ls" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2207">
90
+ <svg t="1705645401993" :class="iconClass" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2207">
91
91
  <path
92
92
  d="M205.5 64H665l232 232v639c0 13.807-11.193 25-25 25H205.5c-13.807 0-25-11.193-25-25V89c0-13.807 11.193-25 25-25z m449.145 25H205.5v846H872V306.355L654.645 89z"
93
93
  fill="#B7B7BD"
@@ -108,7 +108,7 @@
108
108
  </svg>
109
109
  </template>
110
110
  <template v-else-if="file.type === 'pptx'">
111
- <svg t="1705645414803" class="w-ls h-ls" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2354">
111
+ <svg t="1705645414803" :class="iconClass" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2354">
112
112
  <path
113
113
  d="M205.5 64H665l232 232v639c0 13.807-11.193 25-25 25H205.5c-13.807 0-25-11.193-25-25V89c0-13.807 11.193-25 25-25z m449.145 25H205.5v846H872V306.355L654.645 89z"
114
114
  fill="#B7B7BD"
@@ -129,7 +129,7 @@
129
129
  </svg>
130
130
  </template>
131
131
  <template v-else>
132
- <svg t="1705645436666" class="w-ls h-ls" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2501">
132
+ <svg t="1705645436666" :class="iconClass" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2501">
133
133
  <path
134
134
  d="M205.5 64H665l232 232v639c0 13.807-11.193 25-25 25H205.5c-13.807 0-25-11.193-25-25V89c0-13.807 11.193-25 25-25z m449.145 25H205.5v846H872V306.355L654.645 89z"
135
135
  fill="#B7B7BD"
@@ -150,6 +150,9 @@
150
150
  </svg>
151
151
  </template>
152
152
 
153
+ <p v-if="file.name && file.type !== 'image' && file.type !== 'video'" class="w-full mt-ss ac" ui-omit="1" :title="file.name">{{ file.name }}</p>
154
+ <p v-if="viewText || text" class="w-full co-note fs-sm ac" ui-omit="1">{{ viewText || text }}</p>
155
+
153
156
  <!-- 上传状态 -->
154
157
  <div class="upload-status" :data-status="file.status">
155
158
  <div class="neatui-upload-icon"><i></i></div>
@@ -162,25 +165,26 @@
162
165
  <template v-if="showUploadButton">
163
166
  <slot name="btn" :disabled="disabled">
164
167
  <label
165
- class="pr flex-block"
166
- :class="{ 'ux-disable': disabled }"
168
+ class="pr flex-fixed"
169
+ :class="disabled ? 'ux-disable' : 'ux-none'"
167
170
  :ui-form="`${theme} type:upload tips:hover`"
168
171
  :style="`width: ${isNumber(width) ? width + 'em' : width}`"
169
172
  v-bind="itemAttrs"
170
173
  >
171
- <div class="w-full pr" :style="`box-sizing: content-box; padding-top: ${ratio}%`">
172
- <div ui-flex="row cm" :class="`pa ot-no ol-no full upload n-sl bg-fore b-solid b-case b-xs r-sm ${uploadClass}`" :style="uploadStyle">
174
+ <div class="w-full pr" :style="boxStyle">
175
+ <div ui-flex="row cm" :class="`${ratio ? 'pa full n-sl' : 'w-full n-ms min-h-ms*s'} ot-no ol-no upload bg-fore b-solid b-case b-xs r-sm ${uploadClass}`" :style="uploadStyle">
173
176
  <div v-if="tips" ui-form-tips>
174
177
  {{ tips }}
175
178
  </div>
176
179
  <input style="display: none" type="file" class="pa input" :multiple="!max || max > 1" :accept="accept" :disabled="disabled" @change="change" />
177
- <div class="co-note ac input-icon">
180
+ <div class="co-note ac input-icon" ui-flex="col cm">
178
181
  <svg t="1705642180833" style="width: 1em; height: 1em" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4215">
179
182
  <path
180
183
  d="M906.212134 565.732986 565.732986 565.732986 565.732986 906.212134C565.732986 926.013685 541.666486 959.972 511.97312 959.972 482.297674 959.972 458.213254 926.013685 458.213254 906.212134L458.213254 565.732986 117.734106 565.732986C97.950475 565.732986 63.97424 541.666486 63.97424 511.97312 63.97424 482.279754 97.950475 458.213254 117.734106 458.213254L458.213254 458.213254 458.213254 117.734106C458.213254 97.950475 482.297674 63.97424 511.97312 63.97424 541.666486 63.97424 565.732986 97.950475 565.732986 117.734106L565.732986 458.213254 906.212134 458.213254C925.995765 458.213254 959.972 482.279754 959.972 511.97312 959.972 541.666486 925.995765 565.732986 906.212134 565.732986Z"
181
184
  p-id="4216"
182
185
  ></path>
183
186
  </svg>
187
+ <p v-if="text" class="fs-sm mt-ss">{{ text }}</p>
184
188
  </div>
185
189
  </div>
186
190
  </div>
@@ -202,13 +206,22 @@
202
206
  uploadClass?: string;
203
207
  uploadStyle?: string;
204
208
  tips?: string;
209
+ /** 未上传时,+ 号下方的文字 */
210
+ text?: string;
211
+ /** 已上传时文件名下方的文字;不传则仍用 text */
212
+ viewText?: string;
213
+ /** 宽高比(高 / 宽 %);0 = 不定比例,高度随内容 */
214
+ ratio?: number;
215
+ /** 已上传时点整块打开文件(PDF、图片、视频新窗口看,其它下载);传 false 关掉 */
216
+ view?: boolean;
217
+ /** 接管「打开文件」:传了就不再新窗口打开 / 下载,交给它(如加展示水印预览) */
218
+ preview?: (file: { url: string; name: string; type: string }) => void;
205
219
  accept?: string | 'image/*' | 'video/*' | 'audio/*';
206
220
  upload?: (formData: FormData, fileExtension?: string) => Promise<any>;
207
221
  maxSize?: number;
208
222
  compress?: boolean;
209
223
  disabled?: boolean;
210
224
  width?: number | string;
211
- ratio?: number;
212
225
  theme?: string;
213
226
  }
214
227
 
@@ -219,9 +232,12 @@
219
232
  attrs: () => ({}),
220
233
  itemAttrs: () => ({}),
221
234
  tips: '',
235
+ text: '',
236
+ viewText: '',
237
+ view: true,
222
238
  accept: '*',
223
239
  upload: async () => null,
224
- maxSize: 10,
240
+ maxSize: 500,
225
241
  compress: true,
226
242
  max: 0,
227
243
  disabled: false,
@@ -230,6 +246,10 @@
230
246
  theme: '@a',
231
247
  });
232
248
 
249
+ /** 定比例:外层 padding-top 撑高、内层铺满;不定比例:高度随内容 */
250
+ const boxStyle = computed(() => (props.ratio ? `box-sizing: content-box; padding-top: ${props.ratio}%` : ''));
251
+ const iconClass = computed(() => (props.ratio ? 'w-ls h-ls' : 'w-lm h-lm'));
252
+
233
253
  interface UploadFile {
234
254
  name: string;
235
255
  type: string;
@@ -265,6 +285,51 @@
265
285
  return file.path;
266
286
  };
267
287
 
288
+ // 已上传的文件整块打开;上传中、失败不打开
289
+ const canOpen = (file: UploadFile) => props.view && (file.status === 'yes' || file.status === 'end') && !!getImageUrl(file);
290
+ // 浏览器能直接看的新窗口打开,Word、Excel 等下载
291
+ const VIEWABLE = ['pdf', 'image', 'video'];
292
+ const fileNameOf = (file: UploadFile, url: string) => file.name || decodeURIComponent(url.split(/[?#]/)[0]?.split('/').pop() || '') || 'file';
293
+ // data URL(mock)不能直接顶层打开,转成 blob URL
294
+ const dataToBlobUrl = (url: string) => {
295
+ const [head = '', body = ''] = url.split(',');
296
+ const mime = head.match(/^data:([^;,]+)/)?.[1] || 'application/octet-stream';
297
+ const bin = /;base64/i.test(head) ? atob(body) : decodeURIComponent(body);
298
+ const bytes = new Uint8Array(bin.length);
299
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
300
+ return URL.createObjectURL(new Blob([bytes], { type: mime }));
301
+ };
302
+ const download = async (url: string, name: string) => {
303
+ let href = url;
304
+ try {
305
+ // 跨域地址 a[download] 不生效,先取成 blob 才能按原文件名存
306
+ if (!/^(blob|data):/i.test(url)) {
307
+ const res = await fetch(url);
308
+ // 403/404/500 不会抛异常,不检查会把错误页按原文件名存下来
309
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
310
+ href = URL.createObjectURL(await res.blob());
311
+ }
312
+ } catch {
313
+ // 失败交给浏览器直接打开,由服务端显示真实错误
314
+ window.open(url, '_blank', 'noopener');
315
+ return;
316
+ }
317
+ const a = document.createElement('a');
318
+ a.href = href;
319
+ a.download = name;
320
+ document.body.appendChild(a);
321
+ a.click();
322
+ a.remove();
323
+ if (href !== url) setTimeout(() => URL.revokeObjectURL(href), 1000);
324
+ };
325
+ const openFile = (file: UploadFile) => {
326
+ if (!canOpen(file)) return;
327
+ const url = getImageUrl(file);
328
+ if (props.preview) return props.preview({ url, name: fileNameOf(file, url), type: file.type });
329
+ if (!VIEWABLE.includes(file.type)) return download(url, fileNameOf(file, url));
330
+ window.open(/^data:/i.test(url) ? dataToBlobUrl(url) : url, '_blank', 'noopener');
331
+ };
332
+
268
333
  // 计算属性
269
334
  const showUploadButton = computed(() => {
270
335
  const currentCount = state.files.length;
@@ -338,6 +403,9 @@
338
403
  if (props.accept && props.accept !== '*') {
339
404
  const acceptTypes = props.accept.split(',').map((type) => type.trim());
340
405
  const isValidType = acceptTypes.some((type) => {
406
+ if (type.startsWith('.')) {
407
+ return file.name.toLowerCase().endsWith(type.toLowerCase());
408
+ }
341
409
  if (type.endsWith('/*')) {
342
410
  return file.type.startsWith(type.replace('/*', ''));
343
411
  }
@@ -385,11 +453,12 @@
385
453
  clearFileTimer(file);
386
454
  file.timer = window.setTimeout(() => {
387
455
  const fileIndex = state.files.findIndex((f) => f.path === file.path);
388
- if (fileIndex !== -1) {
456
+ const hit = state.files[fileIndex];
457
+ if (hit) {
389
458
  if (file.status === 'yes') {
390
459
  // yes 状态 3 秒后变为 end
391
- state.files[fileIndex].status = 'end';
392
- clearFileTimer(state.files[fileIndex]);
460
+ hit.status = 'end';
461
+ clearFileTimer(hit);
393
462
  } else if (file.status === 'err') {
394
463
  // err 状态 3 秒后清除占位(删除文件)
395
464
  removeFile(fileIndex, true);
@@ -476,8 +545,7 @@
476
545
  // 步骤1:立即添加所有文件到UI,显示占位图
477
546
  const validFiles: { file: File; uploadFile: UploadFile }[] = [];
478
547
 
479
- for (let i = 0; i < selectedFiles.length; i++) {
480
- const file = selectedFiles[i];
548
+ for (const file of Array.from(selectedFiles)) {
481
549
 
482
550
  // 验证文件
483
551
  const validationError = validateFile(file);
@@ -538,21 +606,21 @@
538
606
  const result = await props.upload(formData, fileExtension);
539
607
 
540
608
  // 更新文件状态
541
- const fileIndex = state.files.findIndex((f) => f.path === uploadFile.path);
542
- if (fileIndex !== -1) {
609
+ const hit = state.files.find((f) => f.path === uploadFile.path);
610
+ if (hit) {
543
611
  if (result) {
544
612
  // 直接保存上传函数的返回值
545
- state.files[fileIndex].result = result;
546
- state.files[fileIndex].status = 'yes';
613
+ hit.result = result;
614
+ hit.status = 'yes';
547
615
  // 设置3秒后转换为end状态的定时器
548
- setStatusTransitionTimer(state.files[fileIndex]);
616
+ setStatusTransitionTimer(hit);
549
617
  // 每个文件完成后更新外部数据源
550
618
  emitUpdateEvents();
551
619
  } else {
552
620
  // 上传失败时先显示错误状态,3秒后移除文件
553
- state.files[fileIndex].status = 'err';
621
+ hit.status = 'err';
554
622
  // 设置3秒后删除文件的定时器
555
- setStatusTransitionTimer(state.files[fileIndex]);
623
+ setStatusTransitionTimer(hit);
556
624
  // 发射错误事件
557
625
  emit('error', `文件 ${file.name} 上传失败: 上传函数返回空值`);
558
626
  }
@@ -561,11 +629,11 @@
561
629
  console.error('文件处理失败:', error);
562
630
 
563
631
  // 上传失败时先显示错误状态,3秒后移除文件
564
- const fileIndex = state.files.findIndex((f) => f.path === uploadFile.path);
565
- if (fileIndex !== -1) {
566
- state.files[fileIndex].status = 'err';
632
+ const hit = state.files.find((f) => f.path === uploadFile.path);
633
+ if (hit) {
634
+ hit.status = 'err';
567
635
  // 设置3秒后删除文件的定时器
568
- setStatusTransitionTimer(state.files[fileIndex]);
636
+ setStatusTransitionTimer(hit);
569
637
  }
570
638
 
571
639
  emit('error', `文件 ${file.name} 上传失败: ${error}`);
@@ -586,7 +654,7 @@
586
654
  // 如果是字符串URL,尝试从URL判断类型
587
655
  const urlType = getFileTypeFromUrl(item);
588
656
  return {
589
- name: 'file',
657
+ name: '',
590
658
  type: urlType,
591
659
  path: item,
592
660
  status: 'end' as const,
@@ -595,7 +663,7 @@
595
663
  } else {
596
664
  // 如果是对象
597
665
  return {
598
- name: item.name || 'file',
666
+ name: item.name || '',
599
667
  type: item.type || 'file',
600
668
  path: item.url || item.path || '',
601
669
  status: 'end' as const,
@@ -607,7 +675,7 @@
607
675
  const urlType = getFileTypeFromUrl(newValue);
608
676
  state.files = [
609
677
  {
610
- name: 'file',
678
+ name: '',
611
679
  type: urlType,
612
680
  path: newValue,
613
681
  status: 'end',
@@ -620,7 +688,7 @@
620
688
  const objType = objValue.type || (objPath ? getFileTypeFromUrl(objPath) : 'file');
621
689
  state.files = [
622
690
  {
623
- name: objValue.name || 'file',
691
+ name: objValue.name || '',
624
692
  type: objType,
625
693
  path: objPath,
626
694
  status: 'end',