vue3实现页签

news2025/3/1 23:04:32

在这里插入图片描述

功能点:

  • 新增和删除页签
  • 拖拽页签 需要引入插件"vue-draggable-plus": "^0.6.0", 代码已注释
  • 右键弹框操作页签
  • 左右点击滚动页签和鼠标滑轮滚动页签

注意点

  • useStore涉及的部分是pina的缓存,需要改成自己的;userStore.tabStore是获取缓存里的页签,
  • 忘pinia存储改变的页签代码
userStore.$patch(state => {
   state.tabStore = tabStoreList.value
 })  
  • 存储vue页面name用于缓存(这部分仅是参考,实际要写点逻辑,例如vue页的name和路由配置name要一致)
    // 存入需要缓存的页签的name
    userStore.$patch(state => {
      state.keepAliveNameList = newData.map(ele => ele.name)
    })
  • 注意我的页签是根据路由path来判断的,不是根据fullPath;可能有的需求是要使用fullPath;其中includes('editType')的判断是用来解决,一套路由不同title(新增、编辑、详情)
  • 使用了lodash库
  • import { VueDraggable } from "vue-draggable-plus" 使用了拖拽组件;不需要的话注释掉VueDraggable对用代码即可

代码可直接复制

<template>
  <div>
    <!-- <div>{{isAtLeft}} -{{isAtLeft ? '滚动条头在最左侧' : ""}}</div>
    <div>{{isAtRight}} -{{isAtRight ? '滚动条尾在最右侧' : ""}}</div>
    <div>{{tabsMenuIndex}}</div>
    <div>{{keepAliveNameArr}}</div> -->

    <!-- <div>{{tabsMenuValue}}</div>
    <pre>{{tabStoreList}}</pre> -->
  </div>
  <div class="box-all flex-row" v-if="tabStoreList.length">
    <div class="btn_box" v-if="!_.isEmpty(tabStoreList) && hasScroll && !isAtLeft" @click="scrollLeft">
      <el-icon class="btn_icon" size="16">
        <DArrowLeft />
      </el-icon>
    </div>
    <div class="tab_box" ref="tab_box">

      <!-- 拖拽 -->
      <!-- <VueDraggable ref="el" v-model="tabStoreList" :animation="150" ghostClass="ghost"> -->

        <template v-for="(item,index) in tabStoreList" :key="index">
          <el-popover ref="popoverRef" :visible="showPopoverPath === item.path" placement="bottom"
            :width="'fit-content'">
            <template #reference>
              <div :id="'_id_'+item.path" class="tab_item" :class="tabsMenuValue === item.path ? 'active_bgi' : ''"
                @click="clickItem(item,index)" @contextmenu.prevent="tabRightClick(item,index)">
                {{item.tabName}}
                <el-icon class="close_icon" size="16" style="position:relative;top:3px;"
                  @click.stop="deleteTab(item,index)">
                  <Close />
                </el-icon>
              </div>
            </template>
            <div class="popover_content">
              <div class="popover_btn" @click="closeRight(1,item,index)">关闭全部</div>
              <div class="popover_btn mt" @click="closeRight(2,item,index)">关闭其他</div>
              <div class="popover_btn mt" @click="closeRight(3,item,index)">关闭左侧</div>
              <div class="popover_btn mt" @click="closeRight(4,item,index)">关闭右侧</div>
            </div>
          </el-popover>
        </template>

      <!-- </VueDraggable> -->

    </div>

    <div class="btn_box" v-if="!_.isEmpty(tabStoreList) && hasScroll && !isAtRight" @click="scrollRight">
      <el-icon class="btn_icon" size="16">
        <DArrowRight />
      </el-icon>
    </div>

  </div>
</template>

<script setup>
import { ref, watch, onMounted, onBeforeUnmount, nextTick, } from "vue"
import useStore from "@/store/index.js"
import { useRouter, useRoute } from "vue-router"
import _ from "lodash"
// import { VueDraggable } from "vue-draggable-plus"
import { Close, DArrowLeft, DArrowRight } from "@element-plus/icons-vue"

const route = useRoute()
const router = useRouter()
const { userStore } = useStore()

let tabsMenuValue = ref('') // 当前选择的tab标签页
let tabsMenuIndex = ref('') // 当前选择的tab标签页对应下标
let tabStoreList = ref(userStore.tabStore || []) // 标签页集合 取自缓存 同时会更新缓存数据

// // 缓存的页签
// let keepAliveNameArr = computed(() => {
//   return userStore.keepAliveNameList || []
// })

// 点击切换
const clickItem = (item) => {
  // 特殊处理 一个页面通过editType区分成两页的情况 -- 共用一个页签改名称
  if (item?.fullPath.includes('editType')) {
    router.push({ path: item.path, query: { ...item.query } })
  } else {
    router.push({ path: item.path })
  }
}
// 删除
const deleteTab = (item, index) => {
  tabStoreList.value = tabStoreList.value.filter((it, i) => i !== index)
  userStore.$patch(state => {
    state.tabStore = tabStoreList.value
  })
  // 判断是否是点击删除当前高亮标签 高亮删除需跳新页面
  if (item.path === tabsMenuValue.value) {
    let nextIndex = index - 1
    if (nextIndex === -1) { // 说明删第一个tab
      if (tabStoreList.value.length === 0) { // 说明删了后面没有tab了
        // 跳转首页或不处理
      } else {
        let nextPath = tabStoreList.value[0].path
        router.push({ path: nextPath })
      }
    } else {
      let nextPath = tabStoreList.value[nextIndex].path
      router.push({ path: nextPath })
    }
  }
}

let popoverRef = ref(null)
let showPopoverPath = ref(false)
// 标签右键事件
const tabRightClick = (item) => {
  // console.log(item, index);
  showPopoverPath.value = item.path
}
// 弹出框右键事件
const closeRight = (type, item, index) => {
  // console.log(type, item, index);
  // 全部 其他 左侧 右侧
  if (type === 1) {
    tabStoreList.value = []
  } else if (type === 2) {
    tabStoreList.value = tabStoreList.value.filter((it, i) => i === index)
    let nextPath = tabStoreList.value[0].path
    router.push({ path: nextPath })
  } else if (type === 3) {
    if (tabsMenuIndex.value < index) {
      let nextPath = tabStoreList.value[index].path
      router.push({ path: nextPath })
    }
    tabStoreList.value = tabStoreList.value.filter((it, i) => i >= index)
  } else if (type === 4) {
    if (tabsMenuIndex.value > index) {
      let nextPath = tabStoreList.value[index].path
      router.push({ path: nextPath })
    }
    tabStoreList.value = tabStoreList.value.filter((it, i) => i <= index)
  }
  userStore.$patch(state => {
    state.tabStore = tabStoreList.value
  })
}

const tab_box = ref(null) // 滚动条元素
const hasScroll = ref(false) // 是否存在滚动条
const isAtLeft = ref(true); // 滚动条头在最左侧
const isAtRight = ref(false); // 滚动条尾在最右侧

// 左右控制滚动条移动
const scrollLeft = () => {
  tab_box.value.scrollLeft = tab_box.value.scrollLeft - 800
}
const scrollRight = () => {
  tab_box.value.scrollLeft = tab_box.value.scrollLeft + 800
}
// 处理滚动事件
const handleScroll = () => {
  if (tab_box.value) {
    // 判断是否滚动到最左侧
    isAtLeft.value = tab_box.value.scrollLeft === 0;


    if (hasScroll.value) {
      // 判断是否滚动到最右侧
      isAtRight.value = tab_box.value.scrollLeft + tab_box.value.clientWidth >= tab_box.value.scrollWidth;
    } else {
      isAtRight.value = false
    }
  }
};
// 处理鼠标滚轮事件
const handleWheel = (event) => {
  if (tab_box.value) {
    event.preventDefault(); // 防止默认的垂直滚动
    // 根据鼠标滚轮的方向调整 scrollLeft
    tab_box.value.scrollLeft += event.deltaY; // deltaY 表示垂直滚动的距离
    handleScroll(); // 更新滚动位置
  }
};


watch(route, val => {
  const { path, matched } = val
  if (!matched.length) return
  const flatPermissionList = userStore.flatPermissionList
  console.log('页签监听', path, val, matched,)
  console.log('权限全路径', flatPermissionList);
  let matchedTitle = matched[matched.length - 1]?.meta?.title || ''
  let tabName = flatPermissionList.find(it => it.permissionUrl === path)?.permissionName || matchedTitle
  console.log('tabName', tabName);
  // let keepAliveName = matched[matched.length - 1].name
  // console.log('keepAliveName', keepAliveName);

  // 现在是根据路由的path来查找tabStore中是否存在相同的path,如果存在则不添加,如果不存在则添加(可能要改成fullPath判断 解决编辑新增的特殊页)
  tabsMenuValue.value = path
  let hasTabName = userStore.tabStore.findIndex(it => it.path === path) != -1

  // 特殊处理 一个页面通过editType区分成两页的情况 -- 共用一个页签改名称
  if (val.fullPath.includes('editType') && hasTabName) {
    userStore.tabStore.forEach(ele => {
      if (ele.path === path) {
        ele.tabName = tabName
      }
    })
  }
  // console.log(userStore.tabStore.findIndex(it => it.path === path), hasTabName);

  if (!hasTabName) {
    userStore.$patch(state => {
      state.tabStore.push({
        // ...val,

        tabName: tabName,
        // keepAliveName: keepAliveName,
        path: val.path,

        name: val.name, // 用于缓存的 keepAliveName
        // params: val.params,
        query: val.query,
        // hash: val.hash,
        fullPath: val.fullPath,
        // meta: val.meta,

        // close: true, // 是否支持关闭

      })
    })
  }

  tabStoreList.value = userStore.tabStore

  // 找到当前路由下标
  tabsMenuIndex.value = tabStoreList.value.findIndex(it => it.path === path)
  if (tab_box.value) {
    // 手动滑动到当前路由下标
    setTimeout(() => {
      // console.log('手动滑动到当前路由下标', tabsMenuIndex.value * 180, hasScroll.value);
      tab_box.value.scrollLeft = tabsMenuIndex.value * 180
    }, 300);
  }
}, { immediate: true, deep: true })

watch(() => tabStoreList.value, (newData) => {
  nextTick(() => {
    if (tab_box.value) {
      hasScroll.value = tab_box.value.scrollWidth > tab_box.value.clientWidth
      // tab_box.value.scrollLeft = tabsMenuIndex.value * 180 // 可以解决新增后 新增末尾页签的没展示出问题 但效果不好
    }

    // 当没有滚动条时候,一定是在最左侧
    if (!hasScroll.value) {
      isAtLeft.value = true
      isAtRight.value = false
    }
    // // 同时页签变化时候 时刻计算滚动条位置
    // handleScroll()

    // 存入需要缓存的页签的name
    userStore.$patch(state => {
      state.keepAliveNameList = newData.map(ele => ele.name)
    })
  })
}, { immediate: true, deep: true })

const handleClickOutside = () => {
  showPopoverPath.value = null
};

// 在组件挂载时添加全局点击事件监听器
onMounted(() => {
  window.addEventListener('click', handleClickOutside);
  if (tab_box.value) {
    tab_box.value.addEventListener('scroll', handleScroll);
    tab_box.value.addEventListener('wheel', handleWheel); // 添加鼠标滚轮事件监听
  }
});

// 在组件卸载时移除全局点击事件监听器
onBeforeUnmount(() => {
  window.removeEventListener('click', handleClickOutside);
  if (tab_box.value) {
    tab_box.value.removeEventListener('scroll', handleScroll);
    tab_box.value.removeEventListener('wheel', handleWheel); // 移除鼠标滚轮事件监听
  }
});
</script>

<style lang="scss" scoped>
.box-all {
  width: 100%;
  max-width: 100%;
  .btn_box {
    display: inline-block;
    width: 46px;
    // height: 38px;
    background: #f9f9f9;
    border-radius: 0px 0px 0px 0px;
    cursor: pointer;
    .btn_icon {
      position: relative;
      top: 11px;
      left: 12px;
    }
    .btn_icon:hover {
      color: #00706b;
    }
  }
  .btn_box:first-child {
    box-shadow: 15px 0px 17px 1px rgba(0, 0, 0, 0.1);
    z-index: 0;
  }
  .btn_box:last-child {
    box-shadow: -15px 0px 17px 1px rgba(0, 0, 0, 0.1);
  }
}
.tab_box {
  // padding: 0 30px; // 可以看到到头的空白
  width: 100%;
  max-width: 100%;
  // height: 40px !important;
  // min-height: 40px !important;
  // max-height: 40px !important;
  white-space: nowrap;
  overflow-x: auto;
  // overflow-y: none;
  background-color: transparent;

  // transition: transform 0.5s ease;
  transition: scroll-left 0.3s ease; /* 添加过渡效果 */
  scroll-behavior: smooth; /* 添加平滑滚动效果 */
  .tab_item {
    line-height: 39px;
    cursor: pointer;
    display: inline-block;
    width: 180px;
    text-align: center;
    background: #f6f6f6;
    border-radius: 0px 0px 0px 0px;
    border-right: 1px solid #edecec;
    .close_icon:hover {
      color: red;
    }
  }
  .tab_item:hover {
    color: #00706b;
    font-weight: 700;
    // background-color: #edf6f6;
    border-radius: 8px 8px 0 0;
  }
  .active_bgi {
    color: #00706b;
    font-weight: 700;
    background-color: #fff;
    border-radius: 8px 8px 0 0;
  }
}

/* 滚动条整体样式 */
.tab_box::-webkit-scrollbar {
  width: 1px; /* 竖直滚动条宽度 */
  height: 1px; /* 水平滚动条高度 */
}
/* 滚动条滑块 */
.tab_box::-webkit-scrollbar-thumb {
  background: transparent; /* 设置滑块为完全透明 */
  border-radius: 4px; /* 圆角滑块 */
}
/* 滚动条滑块在悬停时的样式 */
.tab_box::-webkit-scrollbar-thumb:hover {
  background: transparent; /* 在悬停时略微可见 */
}

/* 滚动条轨道(背景) */
.tab_box::-webkit-scrollbar-track {
  background: transparent; /* 设置轨道为完全透明 */
}
::-webkit-scrollbar-track-piece {
  background-color: #fff; /* 设置轨道为完全透明 不设置不生效 */
}

.popover_content {
  text-align: center;
  .popover_btn {
    cursor: pointer;
  }
  .popover_btn:hover {
    color: #00706b;
    font-weight: 700;
  }
}

.ghost {
  opacity: 0.8;
  background: #aed1d1 !important;
}
</style>

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2260710.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

ARCGIS国土超级工具集1.2更新说明

ARCGIS国土超级工具集V1.2版本&#xff0c;功能已增加至47 个。在V1.1的基础上修复了若干使用时发现的BUG&#xff0c;新增了"矢量分割工具"菜单&#xff0c;同时增加及更新了了若干功能&#xff0c;新工具使用说明如下&#xff1a; 一、勘测定界工具栏更新界址点成果…

element-ui实现table表格的嵌套(table表格嵌套)功能实现

最近在做电商类型的官网&#xff0c;希望实现的布局如下&#xff1a;有表头和表身&#xff0c;所以我首先想到的就是table表格组件。 表格组件中常见的就是&#xff1a;标题和内容一一对应&#xff1a; 像效果图中的效果&#xff0c;只用基础的表格布局是不行的&#xff0c;因…

图像分割数据集石头rock分割数据集labelme格式2602张3类别

数据集格式&#xff1a;labelme格式(不包含mask文件&#xff0c;仅仅包含jpg图片和对应的json文件) 图片数量(jpg文件个数)&#xff1a;2602 标注数量(json文件个数)&#xff1a;2602 标注类别数&#xff1a;3 标注类别名称:["claystone","silt","…

语音芯片赋能可穿戴设备:开启个性化音频新体验

在科技日新月异的今天&#xff0c;语音芯片与可穿戴设备的携手合作&#xff0c;正引领我们步入一个前所未有的个性化音频时代。这一创新融合&#xff0c;用户可以享受到更加个性化、沉浸式的音频体验。下面将详细介绍语音芯片与可穿戴设备合作的优点和具体应用。 1. 定制化音效…

数据挖掘之聚类分析

聚类分析&#xff08;Clustering Analysis&#xff09; 是数据挖掘中的一项重要技术&#xff0c;旨在根据对象间的相似性或差异性&#xff0c;将对象分为若干组&#xff08;簇&#xff09;。同一簇内的对象相似性较高&#xff0c;而不同簇间的对象差异性较大。聚类分析广泛应用…

【iOS】OC高级编程 iOS多线程与内存管理阅读笔记——自动引用计数(四)

目录 ARC规则 规则 对象型变量不能作为C语言结构体的成员 显式转换id和void* 属性 数组 ARC规则 规则 在ARC有效的情况下编译源代码必须遵守一定的规则&#xff1a; 主要解释一下最后两条 对象型变量不能作为C语言结构体的成员 要把对象型变量加入到结构体成员中时&a…

Java-25 深入浅出 Spring - 实现简易Ioc-01 Servlet介绍 基本代码编写

点一下关注吧&#xff01;&#xff01;&#xff01;非常感谢&#xff01;&#xff01;持续更新&#xff01;&#xff01;&#xff01; 大数据篇正在更新&#xff01;https://blog.csdn.net/w776341482/category_12713819.html 目前已经更新到了&#xff1a; MyBatis&#xff…

微服务SpringCloud链路追踪之Micrometer+Zipkin

视频教程&#xff1a; https://www.bilibili.com/video/BV12LBFYjEvR 效果演示 当我们发送一个请求给 Gateway 的时候&#xff0c;由 Micrometer trace 进行链路追踪和数据收集&#xff0c;由 Zipkin 进行数据展示。可以清楚的看到微服务的调用过程&#xff0c;以及每个微服务…

【Java】Iterator迭代器相关API

Iterator 是 Java 集合框架中用于遍历集合&#xff08;List、Set 等&#xff09;的工具&#xff0c;它提供了访问集合中每个元素的统一接口&#xff0c;避免直接操作集合的实现细节。 Iterator的基本使用和方法 基本方法 hasNext()&#xff1a;检查是否还有元素可供迭代。ne…

Android 系统应用重名install安装失败分析解决

Android 系统应用重名install安装失败分析解决 文章目录 Android 系统应用重名install安装失败分析解决一、前言1、Android Persistent apps 简单介绍 二、系统 persistent 应用直接安装需求分析解决1、系统应用安装报错返回的信息2、分析解决 三、其他1、persistent系统应用in…

java基础概念49-数据结构2

一、树 1-1、树的基本概念 1、树的节点 2、二叉树 3、树的高度 1-2、二叉查找树 普通二叉树没有规律&#xff0c;不方便查找&#xff0c;没什么作用。 1、基本概念 2、添加节点 此时&#xff0c;该方式添加形成的二叉查找树&#xff0c;根节点就是第一个节点。 3、查找节点 4…

数据仓库工具箱—读书笔记01(数据仓库、商业智能及维度建模初步)

数据仓库、商业智能及维度建模初步 记录一下读《数据仓库工具箱》时的思考&#xff0c;摘录一些书中关于维度建模比较重要的思想与大家分享&#x1f923;&#x1f923;&#x1f923; 博主在这里先把这本书"变薄"~有时间的小伙伴可以亲自再读一读&#xff0c;感受一下…

说说你对java lambda表达式的理解?

大家好&#xff0c;我是锋哥。今天分享关于【说说你对java lambda表达式的理解?】面试题。希望对大家有帮助&#xff1b; 说说你对java lambda表达式的理解? 1000道 互联网大厂Java工程师 精选面试题-Java资源分享网 Java Lambda 表达式是 Java 8 引入的一项重要特性&#…

ambari-server页面错位问题解决

背景&#xff1a; 项目新安装的ambari集群页面错位如下 解决办法&#xff08;临时&#xff09;&#xff1a; 修改ambari-server的前端文件&#xff1a;/usr/lib/ambari-server/web/javascripts/app.js 原代码&#xff1a; initNavigationBar: function () {if (App.get(r…

高效数据集成:钉钉与企业系统无缝对接

钉钉数据集成案例分享&#xff1a;鸿巢基础资料-供应商账号(删除操作) 在企业信息化管理中&#xff0c;数据的准确性和及时性至关重要。本文将聚焦于一个具体的系统对接集成案例——钉钉数据集成到钉钉&#xff0c;详细探讨如何通过轻易云数据集成平台实现“鸿巢基础资料-供应…

第六届地博会开幕,世界酒中国菜美食文化节同期启幕推动地标发展

第六届知交会暨地博会开幕&#xff0c;辽黔欧三地馆亮点纷呈&#xff0c;世界酒中国菜助力地理标志产品发展 第六届知交会暨地博会盛大开幕&#xff0c;多地展馆亮点频出&#xff0c;美食文化节同期启幕推动地标产业发展 12月9日&#xff0c;第六届粤港澳大湾区知识产权交易博…

CVMJ 2024 | StyleDiffusion: 基于Prompt嵌入的真实图像反演和文本编辑

论文&#xff1a;《StyleDiffusion: Prompt-Embedding Inversion for Text-Based Editing》 代码&#xff1a;https://github.com/sen-mao/StyleDiffusion​https://github.com/sen-mao/StyleDiffusion​ 问题背景 已有一些工作利用预训练扩散模型进行真实图像的编辑。这些方…

Cisco Packet Tarcer配置计网实验笔记

文章目录 概要整体架构流程网络设备互连基础拓扑图拓扑说明配置步骤 RIP/OSPF混合路由拓扑图拓扑说明配置步骤 BGP协议拓扑图拓扑说明配置步骤 ACL访问控制拓扑图拓扑说明配置步骤 HSRP冗余网关拓扑图拓扑说明配置步骤 小结 概要 一些环境配置笔记 整体架构流程 网络设备互连…

conda学习

参考: Anaconda 官网教程 https://freelearning.anaconda.cloud/get-started-with-anaconda/18202conda配置虚拟环境/conda环境迁移/python环境迁移 https://blog.csdn.net/qq_43369406/article/details/127140839 环境&#xff1a; macOS 15.2Anaconda Navigator 2.4.2 x.1…

C/C++中的宏定义

在C程序中&#xff0c;可以用宏代码提高执行效率。宏代码本身不是函数&#xff0c;但使用起来像函数。预处理器用复制宏代码的方式代替函数调用&#xff0c;省去了参数压栈、生成汇编语言的CALL调用、返回参数、执行return等过程&#xff0c;从而提高了速度&#xff0c;避免函数…