第四十九节:TagsView 右键菜单(带三角箭头)给每个 tag 增加**鼠标右键菜单**(右键标签弹出:关闭、关闭其他、关闭全部)

第四十九节:TagsView 右键菜单(带三角箭头)给每个 tag 增加鼠标右键菜单(右键标签弹出:关闭、关闭其他、关闭全部)

🎯本节目标

给顶部标签页增加右键菜单:右键标签弹出菜单,包含:关闭当前、关闭其他、关闭全部;菜单在标签下方,带指向标签的三角箭头;第一个标签禁止关闭;点击页面空白处自动关闭菜单。

实现方案:fixed 固定定位弹出菜单(不嵌套在标签 DOM 里面,避免被容器裁剪、定位错乱,和若依原版思路一致)


原理说明

  1. @contextmenu.prevent:监听标签右键事件,prevent阻止浏览器原生右键菜单。
  2. getBoundingClientRect():获取当前点击标签相对于浏览器视口的坐标、宽高。
  3. position:fixed:菜单脱离文档流,直接基于浏览器窗口定位,不受父容器滚动、overflow 裁剪影响。
  4. 三角箭头:CSS 边框技巧制作小三角,动态计算 left,让箭头对准右键点击的标签中心。
  5. 全局点击监听: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>

🧪测试清单

  1. 右键任意标签,菜单弹出在标签下方,顶部白色三角箭头对准标签
  2. 右键第一个标签:菜单只显示【关闭其他、关闭全部】,隐藏【关闭当前】
  3. 点击菜单的选项,执行对应标签关闭,自动跳转页面
  4. 点击页面空白处,菜单关闭
  5. 多次右键不同标签,菜单会跟随切换位置
相关推荐
এ慕ོ冬℘゜1 小时前
从零开发移动端医院挂号页面:适配、交互与前端实现详解
前端·javascript
宁渡AI大模型1 小时前
AI 全栈面试新趋势:Vibe Coding、前端、Java 后端高频面试题深度解析|河南宁渡科技有限公司编程教程
java·javascript·人工智能·python·ai大模型
东风微鸣2 小时前
AI依赖更新别裸奔🚨
javascript
掘金挖土2 小时前
前端手摸手跑路之 AI 应用开发(六)
前端·后端
liuyt20222 小时前
【javaweb】day4
前端
研☆香2 小时前
echarts图表的使用方法
javascript
志尊宝2 小时前
Vue3 零基础每日笔记(024):组件的创建与使用——从 import 到自动导入
前端·vue.js·笔记·前端框架·html5
@PHARAOH2 小时前
WHAT - 从前端组件化思维到后端架构设计入门
前端·微服务·架构
是立不是利3 小时前
大文件导致请求被拒的连锁反应
开发语言·前端·javascript