功能点:
- 新增和删除页签
- 拖拽页签 需要引入插件
"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>