00:00:00
右键菜单
此组件可以美化浏览器原生的菜单右键,支持自定义跳转
创建 ContextMenu 文件夹
ContextMenu.vue 主要组件
MenuData.ts 菜单数据
SvgList.ts svg集合
SvgRender.vue svg渲染组件
useFullscreen.ts 全屏方法
以下是具体代码
ContextMenu.vue 主要组件
vue
<template>
<div
v-show="isVisible"
ref="menuDom"
class="context-menu-var context-menu"
:style="{
top: `${y}px`,
left: `${x}px`
}"
>
<div class="menu-container">
<!-- 头部区域 -->
<div class="menu-header">
<div class="menu-title-icon" v-html="menuData.header?.svg"></div>
<span style="color: var(--title-text-color)">{{ menuData.header?.title }}</span>
<!-- 全屏svg -->
<div
class="menu-title-icon screen"
@click="
toggleFullscreen();
hideMenu();
"
>
<SvgRender v-show="isFullScreen" :svg="FullScreen" />
<SvgRender v-show="!isFullScreen" :svg="NonFullScreen" />
</div>
</div>
<!-- 菜单项列表 -->
<ul class="menu-items">
<li
:class="item.subMenu ? 'menu-item has-submenu' : 'menu-item'"
v-for="item in menuData.body"
:key="item.text"
@click="!item.subMenu && handleClick(item, frontmatter, navigateTo)"
>
<div class="menu-item-icon" v-html="item.svg"></div>
<span>{{ item.text }}</span>
<!-- 如果有子菜单则显示箭头 -->
<div class="menu-item-arrow" v-if="item.subMenu" v-html="Arrow"></div>
<ul class="submenu" v-if="item.subMenu">
<li
class="submenu-item"
v-for="subItem in item?.subMenu"
:key="subItem.text"
@click.stop="handleClick(subItem, frontmatter, navigateTo)"
>
<div class="submenu-item-icon" v-html="subItem.svg"></div>
<span>{{ subItem.text }}</span>
</li>
</ul>
</li>
<!-- 分割线 -->
<li class="menu-divider"></li>
<!-- 刷新页面 - 特殊样式 -->
<li v-if="menuData.footer.copy" class="menu-item menu-footer-item" @click="copy">
<SvgRender class="menu-item-icon" :svg="Copy" />
<span>复制</span>
</li>
<li v-if="menuData.footer.refresh" class="menu-item menu-footer-item" @click="handleRefresh">
<SvgRender class="menu-item-icon" :svg="Refresh" />
<span>刷新页面</span>
</li>
</ul>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import { useData, useRouter, withBase } from "vitepress"; // 引入 VitePress 路由
import { menuData } from "./MenuData";
import { useFullscreen } from "./useFullscreen";
import SvgRender from "./SvgRender.vue";
import { Arrow, Copy, FullScreen, NonFullScreen, Refresh } from "./SvgList";
const { frontmatter } = useData();
// 自定义处理全屏事件hook
const { isFullScreen, toggleFullscreen } = useFullscreen();
const menuDom = ref<HTMLDivElement | null>(null);
const router = useRouter(); // 获取 VitePress 路由实例
const isVisible = ref(false);
const x = ref(0);
const y = ref(0);
// 检查是否在客户端环境
const isClient = typeof window !== "undefined" || typeof document !== "undefined";
// 显示菜单
const showMenu = (event: MouseEvent) => {
if (!isClient) return null;
// 阻止默认菜单行为
event.preventDefault();
const element = menuDom.value;
if (!element) return;
// 先显示菜单以获取准确尺寸(但不可见)
element.style.visibility = "hidden";
// 获取菜单和子菜单的精确尺寸
const menuRect = element.getBoundingClientRect();
const submenuWidth = 180; // 子菜单宽度(假设固定)
// 初始位置为鼠标位置
let posX = event.clientX;
let posY = event.clientY;
// 计算安全区域内的位置
const rightEdge = window.innerWidth - 10; // 右侧安全边距
const bottomEdge = window.innerHeight - 10; // 底部安全边距
// 处理右侧溢出
if (posX + menuRect.width + submenuWidth > rightEdge) {
posX = Math.max(10, rightEdge - menuRect.width);
}
// 处理底部溢出
if (posY + menuRect.height > bottomEdge) {
posY = Math.max(10, bottomEdge - menuRect.height);
}
// 应用位置样式
x.value = posX;
y.value = posY;
isVisible.value = true;
// 延迟显示以确保位置计算完成
setTimeout(() => {
element.style.visibility = "visible";
}, 0);
// 确保子菜单位置正确显示
adjustSubmenuPositions();
};
// 调整子菜单位置,防止溢出屏幕
const adjustSubmenuPositions = () => {
if (!isClient) return null;
const submenus = document.querySelectorAll(".submenu") as NodeListOf<HTMLElement>;
// 使用 requestAnimationFrame 批量处理样式更新
requestAnimationFrame(() => {
submenus.forEach(submenu => {
const parentItem = submenu.closest(".has-submenu") as HTMLElement;
// 重置样式以便获取初始位置
submenu.style.left = "";
submenu.style.right = "";
submenu.style.top = "";
// 获取父菜单项和子菜单的边界矩形
const parentRect = parentItem.getBoundingClientRect();
const submenuRect = submenu.getBoundingClientRect();
// 计算可用空间
const availableRight = window.innerWidth - parentRect.right;
const availableLeft = parentRect.left;
const availableBottom = window.innerHeight - parentRect.bottom;
const availableTop = parentRect.top;
// 水平方向定位
if (submenuRect.width > availableRight && availableLeft > availableRight) {
// 右侧空间不足且左侧空间更大,向左显示
submenu.style.right = "100%";
submenu.style.left = "auto";
} else {
// 默认向右显示
submenu.style.left = "100%";
submenu.style.right = "auto";
}
// 垂直方向定位
if (submenuRect.height > availableBottom && availableTop > availableBottom) {
// 底部空间不足且顶部空间更大,向上显示
submenu.style.top = `-${submenuRect.height - parentRect.height}px`;
} else {
// 默认向下显示
submenu.style.top = "0";
}
});
});
};
// 窗口大小变化时重新定位菜单
const handleResize = () => {
if (isVisible.value) {
adjustSubmenuPositions();
}
};
// 隐藏菜单
const hideMenu = () => {
isVisible.value = false;
};
// 导航到指定路由
const navigateTo = (path: string) => {
if (!isClient) return null;
// 判断是否为外部链接
const isExternal = /^https?:\/\//.test(path);
if (isExternal) {
// 外部链接使用 window.open 或 window.location.href
window.open(path, "_blank");
hideMenu();
return;
}
const targetPath = withBase(path);
const currentPath = router.route.path;
console.log(currentPath, "===>", targetPath);
// 如果当前路径和目标路径相同,则不执行导航
if (currentPath === targetPath) {
hideMenu();
return;
}
router.go(targetPath); // 使用 VitePress 路由的 goTo 方法
hideMenu();
};
// 复制选中的文本内容到剪贴板
const copy = async () => {
try {
// 获取用户选中的文本
const selectedText = window.getSelection()?.toString();
if (!selectedText) {
console.log("没有选中任何内容");
return false;
}
// 使用 Clipboard API 复制文本
await navigator.clipboard.writeText(selectedText);
console.log("复制成功:", selectedText);
return true;
} catch (err) {
// 处理复制失败的情况(如浏览器不支持或无权限)
console.error("复制失败:", err);
return false;
} finally {
// 隐藏菜单
hideMenu();
}
};
// 刷新页面
const handleRefresh = () => {
if (!isClient) return null;
window.location.reload();
hideMenu();
};
// 点击其他地方时隐藏菜单
const clickOutsideHideMenu = (event: MouseEvent) => {
const element = menuDom.value;
if (isVisible.value && element && !element.contains(event.target as Node)) {
hideMenu();
}
};
// 键盘ESC键关闭菜单
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
hideMenu();
}
};
// 统一的菜单点击处理函数(包装器)
const handleClick = (item: any, frontmatter: any, navigateTo: Function) => {
try {
// 1. 执行子项原有的点击逻辑
if (typeof item.click === "function") {
item.click(frontmatter, navigateTo);
}
} finally {
// 2. 执行统一的后置操作:隐藏菜单
hideMenu();
}
};
onMounted(() => {
document.addEventListener("contextmenu", showMenu);
document.addEventListener("click", clickOutsideHideMenu);
document.addEventListener("keydown", handleKeydown);
window.addEventListener("resize", handleResize);
});
onUnmounted(() => {
document.removeEventListener("contextmenu", showMenu);
document.removeEventListener("click", clickOutsideHideMenu);
document.removeEventListener("keydown", handleKeydown);
window.removeEventListener("resize", handleResize);
});
</script>
<style scoped lang="scss">
.context-menu-var {
// 主题色
--theme-color: #a78bfa;
// 标题颜色
--title-text-color: #8b5cf6;
// 头部背景色
--header-bg-color: linear-gradient(to right, #f9f5ff, #f5f3ff);
// 文本颜色
--text-color: #4a4158;
// 菜单背景颜色
--menu-bg-color: #ffffff;
// 菜单项鼠标悬停背景颜色
--menu-hover-bg-color: #f5f3ff;
// 分割线颜色
--divider-bg-color: #f0e6ff;
}
html.dark .context-menu-var {
// 主题色
--theme-color: #8b5cf6;
// 标题颜色
--title-text-color: #8b5cf6;
// 头部背景色
--header-bg-color: linear-gradient(to right, #312a48, #2d2644);
// 文本颜色
--text-color: #ffffff;
// 菜单背景颜色
--menu-bg-color: #1e1b2d;
// 菜单项鼠标悬停背景颜色
--menu-hover-bg-color: #352e54;
// 分割线颜色
--divider-bg-color: #372f52;
}
.context-menu {
position: fixed;
z-index: 100000;
user-select: none;
animation: fadeIn 0.2s ease-out;
filter: drop-shadow(0 4px 20px rgba(0, 0, 0, 0.15));
}
// 设置svg颜色
.context-menu :deep(svg) {
stroke: var(--theme-color);
}
@keyframes fadeIn {
from {
opacity: 0;
transform: scale(0.98);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* 菜单容器 */
.menu-container {
background-color: var(--menu-bg-color);
border-radius: 12px;
box-shadow: 0 4px 20px rgba(139, 92, 246, 0.15);
border: none;
min-width: 240px;
color: var(--text-color);
position: relative;
transition: all 0.2s ease;
animation: slideIn 0.2s ease;
overflow: visible;
}
html.dark .menu-container {
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
/* 菜单头部 */
.menu-header {
display: flex;
align-items: center;
padding: 10px 16px;
border-bottom: 1px solid var(--border-light-color);
background: var(--header-bg-color);
border-top-left-radius: 12px;
border-top-right-radius: 12px;
position: relative;
font-weight: bold;
}
.menu-title-icon {
display: flex;
align-items: center;
justify-content: center;
margin-right: 10px;
width: 20px;
height: 20px;
}
.screen {
margin-left: auto;
transition: transform 0.3s;
}
.screen:hover {
stroke: var(--vp-c-brand-1);
transform: scale(1.15);
}
/* 菜单项 */
.menu-items {
list-style: none;
margin: 0;
padding: 10px;
}
/* 分隔线 */
.menu-divider {
height: 1px;
margin: 10px 0;
background: var(--divider-bg-color);
}
.menu-item {
display: flex;
align-items: center;
padding: 5px 14px;
position: relative;
font-size: 0.9rem;
margin: 4px 0;
border-radius: 10px;
cursor: pointer;
transition: all 0.2s ease;
overflow: visible;
}
/* 子菜单箭头旋转 */
.has-submenu:hover .menu-item-arrow {
transform: rotate(90deg);
}
/* 菜单hover背景等 */
.menu-item:hover {
background-color: var(--menu-hover-bg-color);
transform: translateX(2px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.menu-item-icon {
display: flex;
align-items: center;
justify-content: center;
margin-right: 12px;
width: 18px;
height: 18px;
transition: transform 0.3s;
}
/* 图标放大加旋转 */
.menu-item:hover .menu-item-icon {
transform: scale(1.15) rotate(8deg);
}
/* 鼠标悬停变色逻辑,带有refresh-item类的除外 */
.menu-item:not(.menu-footer-item):hover .menu-item-icon :deep(svg) {
stroke: var(--vp-c-brand-1);
}
.submenu-item:hover .submenu-item-icon :deep(svg) {
stroke: var(--vp-c-brand-1);
}
.menu-item-arrow {
margin-left: auto;
width: 16px;
height: 16px;
opacity: 0.7;
transition: transform 0.2s ease;
}
/* 子菜单 */
.submenu {
position: absolute;
top: 0;
left: 100%;
background-color: var(--menu-bg-color);
border-radius: 12px;
box-shadow: 0 4px 16px rgba(139, 92, 246, 0.12);
border: none;
min-width: 180px;
opacity: 0;
transform: translateX(-5px);
transition: all 0.2s ease;
z-index: 1000;
padding: 8px;
}
.has-submenu:hover > .submenu {
opacity: 1;
transform: translateX(0);
}
html.dark .submenu {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
}
.submenu-item {
display: flex;
align-items: center;
padding: 10px 14px;
cursor: pointer;
font-size: 0.85rem;
border-radius: 8px;
margin: 3px 0;
transition: all 0.2s ease;
}
.submenu-item:hover {
background-color: var(--menu-hover-bg-color);
transform: translateX(2px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.submenu-item-icon {
display: flex;
align-items: center;
justify-content: center;
margin-right: 10px;
width: 16px;
height: 16px;
}
.menu-footer-item {
margin: 6px 8px;
background: linear-gradient(135deg, #8b5cf6, #ec4899);
box-shadow: 0 3px 12px rgba(139, 92, 246, 0.2);
color: white;
transition: all 0.2s ease;
border: none;
border-radius: 8px;
}
.menu-footer-item:hover {
background: linear-gradient(135deg, #a78bfa, #f472b6);
box-shadow:
0 5px 15px rgba(139, 92, 246, 0.4),
0 2px 5px rgba(236, 72, 153, 0.3);
//transform: translateY(-2px) translateX(2px);
}
</style>1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
MenuData.ts 菜单数据
ts
import {
About,
Exclamation,
FriendshipLinks,
Home,
MessageBoard,
Music,
Other,
PhotoAlbum,
Refresh,
TreeHollow
} from "./SvgList";
export const menuData = {
header: {
title: "威威 Blog",
svg: Exclamation
},
body: [
{
text: "首页",
svg: Home,
click(frontmatter: any, navigateTo: Function) {
navigateTo("/");
}
},
{
text: "关于我",
svg: About,
click(frontmatter: any, navigateTo: Function) {
navigateTo("/about-me");
}
},
{
text: "其他",
svg: Other,
subMenu: [
{
text: "树洞",
svg: TreeHollow,
click(frontmatter: any, navigateTo: Function) {
console.log("树洞");
}
},
{
text: "留言板",
svg: MessageBoard,
click(frontmatter: any, navigateTo: Function) {
console.log("留言板");
}
}
]
},
{
text: "友链",
svg: FriendshipLinks,
click(frontmatter: any, navigateTo: Function) {}
},
{
text: "音乐",
svg: Music,
click(frontmatter: any, navigateTo: Function) {}
},
{
text: "相册",
svg: PhotoAlbum,
click(frontmatter: any, navigateTo: Function) {}
}
],
footer: {
copy: true,
refresh: true
}
};1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
SvgList.ts svg集合
ts
const Exclamation = `<svg
viewBox="0 0 24 24"
width="24"
height="24"
stroke-width="2"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="12"></line>
<line x1="12" y1="16" x2="12.01" y2="16"></line>
</svg>`;
const Home = `<svg
viewBox="0 0 24 24"
width="24"
height="24"
stroke-width="2"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path>
<polyline points="9 22 9 12 15 12 15 22"></polyline>
</svg>`;
const About = `<svg
viewBox="0 0 24 24"
width="24"
height="24"
stroke-width="2"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M12 19.2c-2.5 0-4.71-1.28-6-3.2c.03-2 4-3.1 6-3.1s5.97 1.1 6 3.1a7.23 7.23 0 0 1-6 3.2M12 5a3 3 0 0 1 3 3a3 3 0 0 1-3 3a3 3 0 0 1-3-3a3 3 0 0 1 3-3m0-3A10 10 0 0 0 2 12a10 10 0 0 0 10 10a10 10 0 0 0 10-10c0-5.53-4.5-10-10-10"
/>
</svg>`;
const Other = `<svg
viewBox="0 0 24 24"
width="24"
height="24"
stroke-width="2"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M4 5h13" />
<path d="M4 10h10" />
<path d="M4 15h16" />
<path d="M4 20h13" />
</svg>`;
const TreeHollow = `<svg
viewBox="0 0 24 24"
width="24"
height="24"
stroke-width="2"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M18 8h1a4 4 0 0 1 0 8h-1"></path>
<path d="M2 8h16v9a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4V8z"></path>
<line x1="6" y1="1" x2="6" y2="4"></line>
<line x1="10" y1="1" x2="10" y2="4"></line>
<line x1="14" y1="1" x2="14" y2="4"></line>
</svg>`;
const MessageBoard = `<svg
viewBox="0 0 24 24"
width="24"
height="24"
stroke-width="2"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
</svg>`;
const FriendshipLinks = `<svg
viewBox="0 0 24 24"
width="24"
height="24"
stroke-width="2"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
</svg>`;
const Music = `<svg
viewBox="0 0 24 24"
width="24"
height="24"
stroke-width="2"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M9 18V5l12-2v13"></path>
<circle cx="6" cy="18" r="3"></circle>
<circle cx="18" cy="16" r="3"></circle>
</svg>`;
const PhotoAlbum = `<svg
viewBox="0 0 24 24"
width="24"
height="24"
stroke-width="2"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<circle cx="8.5" cy="8.5" r="1.5"></circle>
<polyline points="21 15 16 10 5 21"></polyline>
</svg>`;
const FullScreen = `<svg
viewBox="0 0 24 24"
width="24"
height="24"
stroke-width="2"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3"></path>
</svg>`;
const NonFullScreen = `<svg
viewBox="0 0 24 24"
width="24"
height="24"
stroke-width="2"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M20 9V6.616q0-.231-.192-.424T19.385 6H17V5h2.385q.69 0 1.152.463T21 6.616V9zM3 9V6.616q0-.691.463-1.153T4.615 5H7v1H4.616q-.231 0-.424.192T4 6.616V9zm14 10v-1h2.385q.23 0 .423-.192t.192-.424V15h1v2.385q0 .69-.462 1.152T19.385 19zM4.615 19q-.69 0-1.153-.462T3 17.384V15h1v2.385q0 .23.192.423t.423.192H7v1zm2.231-3.846V8.846h10.308v6.308zm1-1h8.308V9.846H7.846zm0 0V9.846z"
/>
</svg>`;
const Arrow = `<svg
viewBox="0 0 24 24"
width="24"
height="24"
stroke-width="2"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="9 18 15 12 9 6"></polyline>
</svg>`;
const Refresh = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" >
<rect width="7.33" height="7.33" x="1" y="1" fill="currentColor">
<animate
id="svgSpinnersBlocksWave0"
attributeName="x"
begin="0;svgSpinnersBlocksWave1.end+0.3s"
dur="0.9s"
values="1;4;1"
/>
<animate attributeName="y" begin="0;svgSpinnersBlocksWave1.end+0.3s" dur="0.9s" values="1;4;1" />
<animate
attributeName="width"
begin="0;svgSpinnersBlocksWave1.end+0.3s"
dur="0.9s"
values="7.33;1.33;7.33"
/>
<animate
attributeName="height"
begin="0;svgSpinnersBlocksWave1.end+0.3s"
dur="0.9s"
values="7.33;1.33;7.33"
/>
</rect>
<rect width="7.33" height="7.33" x="8.33" y="1" fill="currentColor">
<animate
attributeName="x"
begin="svgSpinnersBlocksWave0.begin+0.15s"
dur="0.9s"
values="8.33;11.33;8.33"
/>
<animate attributeName="y" begin="svgSpinnersBlocksWave0.begin+0.15s" dur="0.9s" values="1;4;1" />
<animate
attributeName="width"
begin="svgSpinnersBlocksWave0.begin+0.15s"
dur="0.9s"
values="7.33;1.33;7.33"
/>
<animate
attributeName="height"
begin="svgSpinnersBlocksWave0.begin+0.15s"
dur="0.9s"
values="7.33;1.33;7.33"
/>
</rect>
<rect width="7.33" height="7.33" x="1" y="8.33" fill="currentColor">
<animate attributeName="x" begin="svgSpinnersBlocksWave0.begin+0.15s" dur="0.9s" values="1;4;1" />
<animate
attributeName="y"
begin="svgSpinnersBlocksWave0.begin+0.15s"
dur="0.9s"
values="8.33;11.33;8.33"
/>
<animate
attributeName="width"
begin="svgSpinnersBlocksWave0.begin+0.15s"
dur="0.9s"
values="7.33;1.33;7.33"
/>
<animate
attributeName="height"
begin="svgSpinnersBlocksWave0.begin+0.15s"
dur="0.9s"
values="7.33;1.33;7.33"
/>
</rect>
<rect width="7.33" height="7.33" x="15.66" y="1" fill="currentColor">
<animate
attributeName="x"
begin="svgSpinnersBlocksWave0.begin+0.3s"
dur="0.9s"
values="15.66;18.66;15.66"
/>
<animate attributeName="y" begin="svgSpinnersBlocksWave0.begin+0.3s" dur="0.9s" values="1;4;1" />
<animate
attributeName="width"
begin="svgSpinnersBlocksWave0.begin+0.3s"
dur="0.9s"
values="7.33;1.33;7.33"
/>
<animate
attributeName="height"
begin="svgSpinnersBlocksWave0.begin+0.3s"
dur="0.9s"
values="7.33;1.33;7.33"
/>
</rect>
<rect width="7.33" height="7.33" x="8.33" y="8.33" fill="currentColor">
<animate
attributeName="x"
begin="svgSpinnersBlocksWave0.begin+0.3s"
dur="0.9s"
values="8.33;11.33;8.33"
/>
<animate
attributeName="y"
begin="svgSpinnersBlocksWave0.begin+0.3s"
dur="0.9s"
values="8.33;11.33;8.33"
/>
<animate
attributeName="width"
begin="svgSpinnersBlocksWave0.begin+0.3s"
dur="0.9s"
values="7.33;1.33;7.33"
/>
<animate
attributeName="height"
begin="svgSpinnersBlocksWave0.begin+0.3s"
dur="0.9s"
values="7.33;1.33;7.33"
/>
</rect>
<rect width="7.33" height="7.33" x="1" y="15.66" fill="currentColor">
<animate attributeName="x" begin="svgSpinnersBlocksWave0.begin+0.3s" dur="0.9s" values="1;4;1" />
<animate
attributeName="y"
begin="svgSpinnersBlocksWave0.begin+0.3s"
dur="0.9s"
values="15.66;18.66;15.66"
/>
<animate
attributeName="width"
begin="svgSpinnersBlocksWave0.begin+0.3s"
dur="0.9s"
values="7.33;1.33;7.33"
/>
<animate
attributeName="height"
begin="svgSpinnersBlocksWave0.begin+0.3s"
dur="0.9s"
values="7.33;1.33;7.33"
/>
</rect>
<rect width="7.33" height="7.33" x="15.66" y="8.33" fill="currentColor">
<animate
attributeName="x"
begin="svgSpinnersBlocksWave0.begin+0.45s"
dur="0.9s"
values="15.66;18.66;15.66"
/>
<animate
attributeName="y"
begin="svgSpinnersBlocksWave0.begin+0.45s"
dur="0.9s"
values="8.33;11.33;8.33"
/>
<animate
attributeName="width"
begin="svgSpinnersBlocksWave0.begin+0.45s"
dur="0.9s"
values="7.33;1.33;7.33"
/>
<animate
attributeName="height"
begin="svgSpinnersBlocksWave0.begin+0.45s"
dur="0.9s"
values="7.33;1.33;7.33"
/>
</rect>
<rect width="7.33" height="7.33" x="8.33" y="15.66" fill="currentColor">
<animate
attributeName="x"
begin="svgSpinnersBlocksWave0.begin+0.45s"
dur="0.9s"
values="8.33;11.33;8.33"
/>
<animate
attributeName="y"
begin="svgSpinnersBlocksWave0.begin+0.45s"
dur="0.9s"
values="15.66;18.66;15.66"
/>
<animate
attributeName="width"
begin="svgSpinnersBlocksWave0.begin+0.45s"
dur="0.9s"
values="7.33;1.33;7.33"
/>
<animate
attributeName="height"
begin="svgSpinnersBlocksWave0.begin+0.45s"
dur="0.9s"
values="7.33;1.33;7.33"
/>
</rect>
<rect width="7.33" height="7.33" x="15.66" y="15.66" fill="currentColor">
<animate
id="svgSpinnersBlocksWave1"
attributeName="x"
begin="svgSpinnersBlocksWave0.begin+0.6s"
dur="0.9s"
values="15.66;18.66;15.66"
/>
<animate
attributeName="y"
begin="svgSpinnersBlocksWave0.begin+0.6s"
dur="0.9s"
values="15.66;18.66;15.66"
/>
<animate
attributeName="width"
begin="svgSpinnersBlocksWave0.begin+0.6s"
dur="0.9s"
values="7.33;1.33;7.33"
/>
<animate
attributeName="height"
begin="svgSpinnersBlocksWave0.begin+0.6s"
dur="0.9s"
values="7.33;1.33;7.33"
/>
</rect>
</svg>`;
const Copy = `<svg viewBox="0 0 1024 1024"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="#ffffff"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round">
<path d="M96.1 575.7a32.2 32.1 0 1 0 64.4 0 32.2 32.1 0 1 0-64.4 0Z"></path>
<path d="M742.1 450.7l-269.5-2.1c-14.3-0.1-26 13.8-26 31s11.7 31.3 26 31.4l269.5 2.1c14.3 0.1 26-13.8 26-31s-11.7-31.3-26-31.4zM742.1 577.7l-269.5-2.1c-14.3-0.1-26 13.8-26 31s11.7 31.3 26 31.4l269.5 2.1c14.3 0.2 26-13.8 26-31s-11.7-31.3-26-31.4z"></path>
<path d="M736.1 63.9H417c-70.4 0-128 57.6-128 128h-64.9c-70.4 0-128 57.6-128 128v128c-0.1 17.7 14.4 32 32.2 32 17.8 0 32.2-14.4 32.2-32.1V320c0-35.2 28.8-64 64-64H289v447.8c0 70.4 57.6 128 128 128h255.1c-0.1 35.2-28.8 63.8-64 63.8H224.5c-35.2 0-64-28.8-64-64V703.5c0-17.7-14.4-32.1-32.2-32.1-17.8 0-32.3 14.4-32.3 32.1v128.3c0 70.4 57.6 128 128 128h384.1c70.4 0 128-57.6 128-128h65c70.4 0 128-57.6 128-128V255.9l-193-192z m0.1 63.4l127.7 128.3H800c-35.2 0-64-28.8-64-64v-64.3h0.2z m64 641H416.1c-35.2 0-64-28.8-64-64v-513c0-35.2 28.8-64 64-64H671V191c0 70.4 57.6 128 128 128h65.2v385.3c0 35.2-28.8 64-64 64z"></path>
</svg>`;
export {
Exclamation,
Home,
About,
Other,
TreeHollow,
MessageBoard,
FriendshipLinks,
Music,
PhotoAlbum,
FullScreen,
NonFullScreen,
Arrow,
Refresh,
Copy
};1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
SvgRender.vue svg渲染组件
vue
<template>
<div class="svg-render">
<!-- 检测是否为SVG字符串(包含<svg标签) -->
<template v-if="isSvgString(svg)">
<div v-html="svg"></div>
</template>
<!-- 否则视为图片地址 -->
<template v-else>
<img :src="svg" :alt="alt || '菜单图标'" class="svg-image" />
</template>
</div>
</template>
<script setup lang="ts">
// 定义Props类型
defineProps<{
svg: string; // 可能是SVG字符串或图片地址
color?: string;
alt?: string; // 图片替代文本(可选)
}>();
// 判断是否为SVG字符串(简单检测是否包含<svg标签)
const isSvgString = (content: string) => {
if (!content) return false;
// 检测是否包含<svg标签(不区分大小写)
return content.trim().startsWith("<svg");
};
</script>
<style scoped>
.svg-render {
}
/* 确保v-html插入的SVG正确显示 */
.svg-render :deep(svg) {
width: 100%;
height: 100%;
}
</style>1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
useFullscreen.ts 全屏方法
ts
import { ref, onMounted, onUnmounted } from "vue";
// 检查是否在客户端环境
const isClient = typeof window !== "undefined" && typeof document !== "undefined";
// 定义 fullscreenApi 结构
let fullscreenApi: any = null;
if (isClient) {
fullscreenApi = {
request:
document.documentElement.requestFullscreen ||
(document.documentElement as any).webkitRequestFullscreen ||
(document.documentElement as any).mozRequestFullScreen ||
(document.documentElement as any).msRequestFullscreen,
exit: (
document.exitFullscreen ||
(document.documentElement as any).webkitExitFullscreen ||
(document.documentElement as any).mozCancelFullScreen ||
(document.documentElement as any).msExitFullscreen
)?.bind(document),
element: () =>
document.fullscreenElement ||
(document as any).webkitFullscreenElement ||
(document as any).mozFullScreenElement ||
(document as any).msFullscreenElement,
changeEvent: () => {
if (document.fullscreenElement !== undefined) return "fullscreenchange";
if ((document as any).webkitFullscreenElement !== undefined) return "webkitfullscreenchange";
if ((document as any).mozFullScreenElement !== undefined) return "mozfullscreenchange";
if ((document as any).msFullscreenElement !== undefined) return "MSFullscreenChange";
return "fullscreenchange";
}
};
}
export function useFullscreen() {
const isFullScreen = ref(false);
// 用组件内部变量存储事件引用(替代window挂载)
let eventRefs: {
changeEvent: string | null;
handleFullscreenChange: (() => void) | null;
handleKeydown: ((e: KeyboardEvent) => void) | null;
} = {
changeEvent: null,
handleFullscreenChange: null,
handleKeydown: null
};
const checkFullscreen = () => {
if (!isClient || !fullscreenApi) return false;
return !!fullscreenApi.element();
};
const updateStatus = () => {
if (!isClient || !fullscreenApi) return;
isFullScreen.value = checkFullscreen();
};
const toggle = () => {
if (!isClient || !fullscreenApi) return;
if (checkFullscreen()) {
fullscreenApi.exit?.();
} else {
const element = document.documentElement;
fullscreenApi.request?.call(element);
}
};
// 定义事件处理函数(用变量存储以便卸载)
const handleFullscreenChange = () => {
updateStatus();
};
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === "F11" || e.keyCode === 122) {
requestAnimationFrame(updateStatus);
}
};
onMounted(() => {
updateStatus();
if (isClient && fullscreenApi) {
const changeEvent = fullscreenApi.changeEvent();
// 存储事件引用到内部变量
eventRefs = {
changeEvent,
handleFullscreenChange,
handleKeydown
};
// 绑定事件
document.addEventListener(changeEvent, handleFullscreenChange);
window.addEventListener("keydown", handleKeydown);
}
});
onUnmounted(() => {
if (isClient) {
// 从内部变量获取事件引用并卸载
const { changeEvent, handleFullscreenChange, handleKeydown } = eventRefs;
if (changeEvent && handleFullscreenChange) {
document.removeEventListener(changeEvent, handleFullscreenChange);
}
if (handleKeydown) {
window.removeEventListener("keydown", handleKeydown);
}
// 清空内部变量
eventRefs = {
changeEvent: null,
handleFullscreenChange: null,
handleKeydown: null
};
}
});
return {
isFullScreen: isFullScreen,
toggleFullscreen: isClient ? toggle : () => {},
updateFullscreenStatus: isClient ? updateStatus : () => {}
};
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126