第四十九节:TagsView 右键菜单(带三角箭头)给每个 tag 增加鼠标右键菜单(右键标签弹出:关闭、关闭其他、关闭全部)
🎯本节目标
给顶部标签页增加右键菜单:右键标签弹出菜单,包含:关闭当前、关闭其他、关闭全部;菜单在标签下方,带指向标签的三角箭头;第一个标签禁止关闭;点击页面空白处自动关闭菜单。
实现方案:fixed 固定定位弹出菜单(不嵌套在标签 DOM 里面,避免被容器裁剪、定位错乱,和若依原版思路一致)
原理说明
@contextmenu.prevent:监听标签右键事件,prevent阻止浏览器原生右键菜单。getBoundingClientRect():获取当前点击标签相对于浏览器视口的坐标、宽高。position:fixed:菜单脱离文档流,直接基于浏览器窗口定位,不受父容器滚动、overflow 裁剪影响。- 三角箭头:CSS 边框技巧制作小三角,动态计算 left,让箭头对准右键点击的标签中心。
- 全局点击监听:
document.addEventListener('click'),点击页面空白区域关闭菜单。
完整代码 src/layout/components/TagsView.vue
<template>
<div class="tags-view-wrap">
<div class="tags-view-scroll" ref="scrollRef">
<div
v-for="(tag, index) in visitedViews"
:key="tag.path"
class="tag-wrapper"
>
<el-tag
:type="isActive(tag) ? 'primary' : 'info'"
:closable="index !== 0"
effect="plain"
class="tag-item"
@click="handleClick(tag)"
@close="handleClose(tag)"
@contextmenu.prevent="handleRightClick($event, tag, index)"
>
{{ tag.title }}
</el-tag>
</div>
</div>
<!-- 独立弹出层:fixed固定定位,渲染在页面顶层 -->
<div
v-show="showMenu"
class="context-menu"
:style="menuStyle"
@click.stop
>
<!-- 三角箭头,指向标签 -->
<span class="arrow" :style="{ left: arrowLeft + 'px' }"></span>
<!-- index===0 第一个标签,隐藏关闭当前 -->
<div
v-if="currentTagIndex !== 0"
class="menu-item"
@click="menuClick('closeCurrent')"
>
关闭当前
</div>
<div class="menu-item" @click="menuClick('closeOthers')">关闭其他</div>
<div class="menu-item" @click="menuClick('closeAll')">关闭全部</div>
</div>
<!-- 右上角下拉操作按钮 -->
<el-dropdown
trigger="click"
class="tags-view-actions"
@command="handleCommand"
>
<span class="actions-btn">操作 ▾</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="closeOthers">关闭其他</el-dropdown-item>
<el-dropdown-item command="closeAll">关闭全部</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
<script setup>
import { computed, ref, watch, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useTagsViewStore } from '@/stores/tagsView'
const route = useRoute()
const router = useRouter()
const tagsViewStore = useTagsViewStore()
// 从pinia获取标签列表
const visitedViews = computed(() => tagsViewStore.visitedViews)
// 右键菜单状态
const showMenu = ref(false)
const currentTag = ref(null)
const currentTagIndex = ref(-1)
const menuStyle = ref({
left: '0px',
top: '0px'
})
// 箭头距离菜单左侧的偏移量
const arrowLeft = ref(0)
// 监听路由,自动新增标签
watch(
() => route.path,
() => {
if (route.meta?.title) {
tagsViewStore.addView(route)
}
},
{ immediate: true }
)
// 判断当前标签是否激活
const isActive = (tag) => tag.path === route.path
// 点击标签,切换路由
const handleClick = (tag) => {
if (tag.path !== route.path) {
router.push(tag.path)
}
}
// 点击标签关闭按钮
const handleClose = (tag) => {
const nextView = tagsViewStore.delView(tag)
if (tag.path === route.path) {
if (nextView) {
router.push(nextView.path)
} else {
router.push('/dashboard/dashboard')
}
}
}
// 关闭右键菜单
const closeMenu = () => {
showMenu.value = false
currentTag.value = null
currentTagIndex.value = -1
document.removeEventListener('click', closeMenu)
}
// 核心:计算菜单和箭头的坐标
const positionMenuBelowTag = (tagEl) => {
// 获取标签在浏览器视口的位置信息
const tagRect = tagEl.getBoundingClientRect()
const menuWidth = 120
// 标签和菜单之间的间距
const gap = 8
// 菜单水平居中在标签下方,超出屏幕自动贴边
let left = tagRect.left + tagRect.width / 2 - menuWidth / 2
if (left < 8) left = 8
if (left + menuWidth > window.innerWidth - 8) {
left = window.innerWidth - menuWidth - 8
}
// 菜单垂直位置:标签底部 + 间距
const top = tagRect.bottom + gap
menuStyle.value = {
left: left + 'px',
top: top + 'px'
}
// 计算箭头位置:箭头对准标签中心点
arrowLeft.value = tagRect.left + tagRect.width / 2 - left
}
// 标签右键事件
const handleRightClick = (e, tag, index) => {
// 阻止浏览器原生右键菜单
e.preventDefault()
// 重复右键同一个标签,直接关闭菜单
if (currentTag.value?.path === tag.path) {
closeMenu()
return
}
// 关闭上一次打开的菜单
closeMenu()
currentTag.value = tag
currentTagIndex.value = index
// 计算坐标
const target = e.currentTarget
if (!target) return
positionMenuBelowTag(target)
// 展示菜单
showMenu.value = true
// 延迟绑定全局点击,避免右键瞬间直接关闭菜单
setTimeout(() => {
document.addEventListener('click', closeMenu)
}, 100)
}
// 点击菜单项执行操作
const menuClick = (cmd) => {
if (cmd === 'closeCurrent') {
handleClose(currentTag.value)
} else if (cmd === 'closeOthers') {
tagsViewStore.delOthersViews(currentTag.value)
router.push(currentTag.value.path)
} else if (cmd === 'closeAll') {
tagsViewStore.delAllViews()
router.push('/dashboard/dashboard')
}
closeMenu()
}
// 右上角操作下拉菜单
const handleCommand = (command) => {
if (command === 'closeOthers') {
tagsViewStore.delOthersViews(route)
} else if (command === 'closeAll') {
tagsViewStore.delAllViews()
router.push('/dashboard/dashboard')
}
}
// 组件销毁,移除全局监听,防止内存泄漏
onUnmounted(() => {
document.removeEventListener('click', closeMenu)
})
</script>
<style scoped>
.tags-view-wrap {
display: flex;
align-items: center;
height: 42px;
padding: 0 12px;
background: #fff;
border-bottom: 1px solid #e4e7ed;
}
.tags-view-scroll {
flex: 1;
display: flex;
gap: 6px;
overflow-x: auto;
}
.tag-wrapper {
flex-shrink: 0;
}
.tag-item {
cursor: pointer;
}
/* 右键菜单样式 fixed固定定位 */
.context-menu {
position: fixed;
z-index: 9999;
min-width: 120px;
background: #fff;
border-radius: 4px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
padding: 4px 0;
margin-top: 4px;
}
/* 三角箭头 CSS边框实现 */
.context-menu .arrow {
position: absolute;
top: -7px;
width: 0;
height: 0;
border-left: 7px solid transparent;
border-right: 7px solid transparent;
border-bottom: 7px solid #ffffff;
z-index: 10000;
/* 给箭头增加阴影,解决白色箭头隐形问题 */
filter: drop-shadow(0 -1px 1px rgba(0,0,0,0.08));
pointer-events: none;
}
.menu-item {
padding: 8px 16px;
font-size: 14px;
cursor: pointer;
white-space: nowrap;
}
.menu-item:hover {
background-color: #ecf5ff;
color: #409eff;
}
.tags-view-actions {
margin-left: 12px;
}
.actions-btn {
cursor: pointer;
font-size: 13px;
color: #606266;
}
</style>
🧪测试清单
- 右键任意标签,菜单弹出在标签下方,顶部白色三角箭头对准标签
- 右键第一个标签:菜单只显示【关闭其他、关闭全部】,隐藏【关闭当前】
- 点击菜单的选项,执行对应标签关闭,自动跳转页面
- 点击页面空白处,菜单关闭
- 多次右键不同标签,菜单会跟随切换位置