@neatui/vue 5.6.1 → 5.8.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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# @neatui/vue 更新记录
|
|
2
2
|
|
|
3
|
+
## 5.8.0
|
|
4
|
+
|
|
5
|
+
### IRouterView
|
|
6
|
+
- 新增 `source` 属性:`query`(默认)从 `route.query` 读详情 ID,`params` 从 `route.params` 读(路由声明为 `/project/:id?` 这类形状)。
|
|
7
|
+
- `source="params"` 时点遮罩返回列表:按匹配到的路由模式截掉 `:id` 及其后的路径段,query 原样保留,`/project/123/products/4?page=2` 回到 `/project?page=2`;不依赖路由 name,alias 路由同样适用。
|
|
8
|
+
- 默认行为不变:不传 `source` 仍按 query 工作,老项目零改动。
|
|
9
|
+
- Nuxt 页面用 `source="params"` 时需 `definePageMeta({ key })` 固定页面 key:`NuxtPage` 默认按参数生成 key,id 变化会整页重挂载。
|
|
10
|
+
|
|
3
11
|
## 5.5.1
|
|
4
12
|
|
|
5
13
|
- 无组件改动,版本号与 `@neatui/css` 同步。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@neatui/vue",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.8.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.8.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",
|
|
@@ -24,6 +24,11 @@
|
|
|
24
24
|
animate?: boolean | 0 | 1;
|
|
25
25
|
mode?: 'default' | 'preview' | 'pop-up' | 'window';
|
|
26
26
|
queryName?: string;
|
|
27
|
+
/**
|
|
28
|
+
* 详情 ID 从哪里读:query(?id=1)或 params(/project/1,路由需声明 :id?)。
|
|
29
|
+
* params 模式下 Nuxt 页面需用 definePageMeta({ key }) 固定页面 key,否则 id 变化会整页重挂载。
|
|
30
|
+
*/
|
|
31
|
+
source?: 'query' | 'params';
|
|
27
32
|
listClass?: string;
|
|
28
33
|
listStyle?: string;
|
|
29
34
|
itemClass?: string;
|
|
@@ -35,6 +40,7 @@
|
|
|
35
40
|
animate: 1,
|
|
36
41
|
mode: 'default',
|
|
37
42
|
queryName: 'id',
|
|
43
|
+
source: 'query',
|
|
38
44
|
listClass: 'full',
|
|
39
45
|
listStyle: '',
|
|
40
46
|
itemClass: 'full',
|
|
@@ -71,8 +77,11 @@
|
|
|
71
77
|
};
|
|
72
78
|
};
|
|
73
79
|
|
|
80
|
+
// 详情 ID 的容器:query(?id=1)或 params(/project/1)
|
|
81
|
+
const itemSource = () => (props.source === 'params' ? route.params : route.query);
|
|
82
|
+
|
|
74
83
|
const hasItemQuery = () => {
|
|
75
|
-
const value =
|
|
84
|
+
const value = itemSource()[props.queryName];
|
|
76
85
|
if (Array.isArray(value)) return value.some((item) => item !== undefined && item !== null && item !== '');
|
|
77
86
|
return value !== undefined && value !== null && value !== '';
|
|
78
87
|
};
|
|
@@ -122,12 +131,24 @@
|
|
|
122
131
|
provide('onlist', evs('list', 'item'));
|
|
123
132
|
provide('onitem', evs('item', 'list'));
|
|
124
133
|
if (env.client) {
|
|
125
|
-
watch(() =>
|
|
134
|
+
watch(() => itemSource()[props.queryName], a);
|
|
126
135
|
a();
|
|
127
136
|
}
|
|
128
137
|
|
|
138
|
+
// params 模式回列表:按匹配到的路由模式找到 :id 段,截掉它和它之后的路径段,query 原样保留
|
|
139
|
+
// /project/123/products/4?page=2 → /project?page=2;不依赖路由 name,alias 路由也适用
|
|
140
|
+
const listLocation = () => {
|
|
141
|
+
const pattern = route.matched[route.matched.length - 1]?.path || '';
|
|
142
|
+
const idSegment = new RegExp(`^:${props.queryName}(\\(.*\\))?[?*+]?$`);
|
|
143
|
+
const index = pattern.split('/').findIndex((seg) => idSegment.test(seg));
|
|
144
|
+
if (index > 0) return { path: route.path.split('/').slice(0, index).join('/') || '/', query: route.query };
|
|
145
|
+
// :id 不是独立路径段(如 /project-:id?)时,按当前命名路由把它置空
|
|
146
|
+
return { params: { ...route.params, [props.queryName]: '' }, query: route.query };
|
|
147
|
+
};
|
|
148
|
+
|
|
129
149
|
// 取消
|
|
130
150
|
const cancel = () => {
|
|
151
|
+
if (props.source === 'params') return router.push(listLocation());
|
|
131
152
|
const _query = { ...route.query };
|
|
132
153
|
delete _query[props.queryName];
|
|
133
154
|
router.push({ path: route.path, query: _query });
|
|
@@ -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
|
-
|
|
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="
|
|
5
|
-
<div class="pa full ot-no ol-no bg-fore
|
|
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="
|
|
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="
|
|
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="
|
|
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="
|
|
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="
|
|
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-
|
|
166
|
-
:class="
|
|
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="
|
|
172
|
-
<div ui-flex="row cm" :class="
|
|
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:
|
|
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
|
-
|
|
456
|
+
const hit = state.files[fileIndex];
|
|
457
|
+
if (hit) {
|
|
389
458
|
if (file.status === 'yes') {
|
|
390
459
|
// yes 状态 3 秒后变为 end
|
|
391
|
-
|
|
392
|
-
clearFileTimer(
|
|
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 (
|
|
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
|
|
542
|
-
if (
|
|
609
|
+
const hit = state.files.find((f) => f.path === uploadFile.path);
|
|
610
|
+
if (hit) {
|
|
543
611
|
if (result) {
|
|
544
612
|
// 直接保存上传函数的返回值
|
|
545
|
-
|
|
546
|
-
|
|
613
|
+
hit.result = result;
|
|
614
|
+
hit.status = 'yes';
|
|
547
615
|
// 设置3秒后转换为end状态的定时器
|
|
548
|
-
setStatusTransitionTimer(
|
|
616
|
+
setStatusTransitionTimer(hit);
|
|
549
617
|
// 每个文件完成后更新外部数据源
|
|
550
618
|
emitUpdateEvents();
|
|
551
619
|
} else {
|
|
552
620
|
// 上传失败时先显示错误状态,3秒后移除文件
|
|
553
|
-
|
|
621
|
+
hit.status = 'err';
|
|
554
622
|
// 设置3秒后删除文件的定时器
|
|
555
|
-
setStatusTransitionTimer(
|
|
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
|
|
565
|
-
if (
|
|
566
|
-
|
|
632
|
+
const hit = state.files.find((f) => f.path === uploadFile.path);
|
|
633
|
+
if (hit) {
|
|
634
|
+
hit.status = 'err';
|
|
567
635
|
// 设置3秒后删除文件的定时器
|
|
568
|
-
setStatusTransitionTimer(
|
|
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: '
|
|
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 || '
|
|
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: '
|
|
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 || '
|
|
691
|
+
name: objValue.name || '',
|
|
624
692
|
type: objType,
|
|
625
693
|
path: objPath,
|
|
626
694
|
status: 'end',
|