仰望星辰工作室

starmusic-pc

starmusic-pc/ frontend/src/components/UiContextMenu.vue 5.0 KB · 208 行 原始文件
1<script setup>
2/**
3 * UiContextMenu — 轻量右键/更多菜单
4 *
5 * 通过 fixed 定位浮在内容之上,自动做边缘避让;点击外部或 Esc 关闭。
6 * items: [{ key, label, icon, danger?, disabled?, divider? }]
7 */
8import { computed, onMounted, onBeforeUnmount, watch, ref, nextTick } from 'vue'
9import Icon from './Icon.vue'
10
11const props = defineProps({
12 visible: { type: Boolean, default: false },
13 x: { type: Number, default: 0 },
14 y: { type: Number, default: 0 },
15 items: { type: Array, default: () => [] },
16 width: { type: Number, default: 200 },
17})
18
19const emit = defineEmits(['select', 'close'])
20
21const menuRef = ref(null)
22const pos = ref({ x: 0, y: 0 })
23const ready = ref(false)
24
25/** 计算位置并做视口边缘避让 */
26const place = async () => {
27 ready.value = false
28 pos.value = { x: props.x, y: props.y }
29 await nextTick()
30 const el = menuRef.value
31 if (!el) return
32 const rect = el.getBoundingClientRect()
33 const maxX = window.innerWidth - 8
34 const maxY = window.innerHeight - 8
35 let x = props.x
36 let y = props.y
37 if (x + rect.width > maxX) x = Math.max(8, maxX - rect.width)
38 if (y + rect.height > maxY) y = Math.max(8, props.y - rect.height)
39 pos.value = { x, y }
40 ready.value = true
41}
42
43watch(
44 () => [props.visible, props.x, props.y],
45 () => {
46 if (props.visible) place()
47 },
48 { immediate: true },
49)
50
51const onDocDown = (e) => {
52 if (!props.visible) return
53 if (menuRef.value && menuRef.value.contains(e.target)) return
54 emit('close')
55}
56
57const onKey = (e) => {
58 if (!props.visible) return
59 if (e.key === 'Escape') emit('close')
60}
61
62const onScroll = () => {
63 if (props.visible) emit('close')
64}
65
66onMounted(() => {
67 document.addEventListener('mousedown', onDocDown, true)
68 document.addEventListener('keydown', onKey)
69 window.addEventListener('resize', onScroll)
70 document.addEventListener('scroll', onScroll, true)
71})
72
73onBeforeUnmount(() => {
74 document.removeEventListener('mousedown', onDocDown, true)
75 document.removeEventListener('keydown', onKey)
76 window.removeEventListener('resize', onScroll)
77 document.removeEventListener('scroll', onScroll, true)
78})
79
80const visibleItems = computed(() => props.items.filter((i) => i && !i.hidden))
81
82const pick = (item) => {
83 if (item.disabled) return
84 emit('select', item.key)
85 emit('close')
86}
87</script>
88
89<template>
90 <Teleport to="body">
91 <Transition name="menu">
92 <div
93 v-if="visible && visibleItems.length"
94 ref="menuRef"
95 class="ctx-menu"
96 :class="{ 'is-ready': ready }"
97 :style="{ left: `${pos.x}px`, top: `${pos.y}px`, width: `${width}px` }"
98 >
99 <template v-for="(item, i) in visibleItems" :key="item.key || i">
100 <div v-if="item.divider" class="ctx-divider" />
101 <button
102 v-else
103 class="ctx-item"
104 :class="{ 'is-danger': item.danger, 'is-disabled': item.disabled }"
105 @click="pick(item)"
106 >
107 <span class="ctx-icon"><Icon :name="item.icon" :size="15" /></span>
108 <span class="ctx-label ellipsis">{{ item.label }}</span>
109 <em v-if="item.hint" class="ctx-hint">{{ item.hint }}</em>
110 </button>
111 </template>
112 </div>
113 </Transition>
114 </Teleport>
115</template>
116
117<style scoped>
118.ctx-menu {
119 position: fixed;
120 z-index: 150;
121 padding: 5px;
122 border-radius: var(--r-md);
123 /* 浮层用不透明底色:背景图模式下 --surface-1 半透明,下层内容会透上来 */
124 background: var(--surface-pop);
125 border: 1px solid var(--line-1);
126 box-shadow: var(--sh-lg);
127 opacity: 0;
128 transform: scale(0.95) translateY(-4px);
129 transform-origin: top left;
130 transition: opacity var(--t-fast) var(--ease-out), transform var(--t-fast) var(--ease-spring);
131 animation: kFadeIn 1ms;
132}
133
134.ctx-menu.is-ready {
135 opacity: 1;
136 transform: scale(1) translateY(0);
137}
138
139.ctx-item {
140 display: flex;
141 align-items: center;
142 gap: 9px;
143 width: 100%;
144 height: 32px;
145 padding: 0 9px;
146 border-radius: var(--r-sm);
147 font-size: 13px;
148 color: var(--text-2);
149 transition: background-color var(--t-fast) var(--ease-out), color var(--t-fast) var(--ease-out);
150}
151
152.ctx-item:hover:not(.is-disabled) {
153 background: var(--theme-color-soft);
154 color: var(--theme-color-dark);
155}
156
157.ctx-item.is-danger {
158 color: var(--color-danger);
159}
160
161.ctx-item.is-danger:hover:not(.is-disabled) {
162 background: rgba(244, 63, 94, 0.1);
163 color: var(--color-danger);
164}
165
166.ctx-item.is-disabled {
167 opacity: 0.42;
168 cursor: not-allowed;
169}
170
171.ctx-icon {
172 display: grid;
173 place-items: center;
174 width: 17px;
175 flex: none;
176 color: inherit;
177}
178
179.ctx-label {
180 flex: 1;
181 min-width: 0;
182 text-align: left;
183}
184
185.ctx-hint {
186 flex: none;
187 font-style: normal;
188 font-size: 11px;
189 color: var(--text-5);
190}
191
192.ctx-divider {
193 height: 1px;
194 margin: 4px 6px;
195 background: var(--line-1);
196}
197
198.menu-enter-active,
199.menu-leave-active {
200 transition: opacity var(--t-fast) var(--ease-out), transform var(--t-fast) var(--ease-out);
201}
202
203.menu-enter-from,
204.menu-leave-to {
205 opacity: 0;
206 transform: scale(0.95) translateY(-4px);
207}
208</style>