以前都是用别人现成的多页签组件,自己也想尝试下做个Vue3的版本,目前还只有基本功能,慢慢完善。
主要思路
- 使用 Pinia 记录页签数据、处理操作
- 初始状态没有页签数据,使用默认路由数据填充
- 右击页签,显示更多关闭操作
- 使用el-scrollbar 实现横向滚动
store/tags 处理页签
页签的数据和操作都在store中,
- list是页签数据
- nameList保存页签路由的name,用于布局文件的keep-alive
<keep-alive :include="tags.nameList">
<component :is="Component"></component>
</keep-alive>
- 对页签的基本操作:增加页签、关闭、关闭其他、关闭全部
引入了持久化插件pinia-plugin-persistedstate,只要设置persist即可在页面刷新时保持页签数据不丢失,具体可以看专栏上篇文章《从零开始Vu3+Element Plus后台管理系统(六)——状态管理Pinia和持久化》
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
interface ListItem {
name: string
path: string
title: string
}
export const useTagsStore = defineStore(
'tags',
() => {
let list = ref<ListItem[]>([])
let show = computed(() => {
return list.value.length > 0
})
let nameList = computed(() => {
return list.value.map((item: ListItem) => item.name)
})
function delTagsItem(index: number) {
list.value.splice(index, 1)
}
function setTagsItem(data: ListItem) {
list.value.push(data)
}
function clearTags() {
list.value = []
}
function closeTagsOther(data: ListItem[]) {
list.value = data
}
return { list, show, nameList, delTagsItem, setTagsItem, clearTags, closeTagsOther }
},
{
persist: {
storage: sessionStorage
}
}
)
页签组件页面
页签列表html
<template>
<div class="shadow mo-tags backdrop-blur-sm bg-white/75 dark:bg-black/75" v-if="tags.show">
<el-scrollbar>
<ul v-click-outside="onClickOutside">
<li
v-for="(item, index) in tags.list"
:key="item.path"
:class="isActive(item.path) ? 'active' : ''"
>
<span
class="cursor-pointer"
@click="changeTab(item.path)"
@contextmenu.prevent="openContext($event, index)"
>{{ item.title }}</span
>
<i-ep-close @click="removeTag(item.path)"></i-ep-close>
</li>
</ul>
</el-scrollbar>
<div
class="fixed flex flex-col px-4 py-2 text-xs leading-8 text-center bg-white rounded shadow-lg"
:style="{ left: `${contextmenuPositon.left}px`, top: `${contextmenuPositon.top}px` }"
v-show="contextmenuShow"
>
<div @click="closeOther">
<el-button :icon="Close" link size="small">关闭其他页签</el-button>
</div>
<div class="cursor-default" @click="closeAll">
<el-button :icon="Minus" link size="small">关闭所有页签</el-button>
</div>
</div>
</div>
</template>
TS
<script setup lang="ts">
import { ref } from 'vue'
import { ClickOutside as vClickOutside } from 'element-plus'
import { useTagsStore } from '~/store/tags'
import { useSidebarStore } from '~/store/sidebar'
import { onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
import { Close, Minus } from '@element-plus/icons-vue'
const route = useRoute()
const router = useRouter()
const tags = useTagsStore()
const siderbarStore = useSidebarStore()
const isActive = (path: string) => {
return path === route.fullPath
}
function changeTab(e: string) {
router.push(e)
}
let contextmenuShow = ref(false)
let contextmenuPositon = ref({ top: 0, left: 0 })
let currentIndex = ref(0)
function openContext(e: Event, index: number) {
contextmenuShow.value = true
currentIndex.value = index
const { top, left } = getParentOffset(e.target)
contextmenuPositon.value = {
top: top - 38,
left: left + e?.target?.clientWidth - (siderbarStore.collapse ? 64 : 200) - 84
}
}
// 获取父元素的相对位移
function getParentOffset(el: any) {
let offset = { top: 0, left: 0 }
offset.top = el.offsetTop
offset.left = el.offsetLeft
if (el.offsetParent != null) {
let offsetParent = getParentOffset(el.offsetParent)
offset.top += offsetParent.top
offset.left += offsetParent.left
}
return offset
}
const onClickOutside = () => {
contextmenuShow.value = false
}
function removeTag(e: string) {
const index = tags.list.findIndex((cur) => cur.path === e)
tags.delTagsItem(index)
const item = tags.list[index] ? tags.list[index] : tags.list[index - 1]
if (item) {
router.push(item.path)
} else {
router.push('/')
}
}
// 设置标签
const setTags = (route: any) => {
const isExist = tags.list.some((item) => {
return item.path === route.fullPath
})
if (!isExist) {
tags.setTagsItem({
name: route.name,
title: route.meta.title,
path: route.fullPath
})
}
}
setTags(route)
onBeforeRouteUpdate((to) => {
setTags(to)
})
// 关闭全部标签
const closeAll = () => {
tags.clearTags()
router.push('/')
setTags(route)
}
// 关闭其他标签
const closeOther = () => {
const curItem = tags.list.filter((item) => {
return item.path === route.fullPath
})
tags.closeTagsOther(curItem)
}
</script>
v-click-outside
Element Plus自带的指令v-click-outside是个好东西,优雅解决了点击元素以外区域关闭元素的问题
<ul v-click-outside="onClickOutside">
const onClickOutside = () => {
contextmenuShow.value = false
}
样式表
<style lang="scss">
.mo-tags {
position: fixed;
top: 60px;
z-index: 1001;
left: 200px;
right: 0;
height: 30px;
transition: left 0.3s ease-in-out, width 0.3s ease-in-out;
&.tag-collapse {
left: 64px;
}
ul {
display: flex;
li {
display: flex;
align-items: center;
flex-shrink: 0;
padding-right: 4px;
height: 24px;
margin-top: 3px;
font-size: 12px;
margin-right: 2px;
border: 1px solid var(--el-border-color);
background: var(--el-fill-color-blank);
border-radius: 2px;
> span {
padding: 0 4px 0 8px;
}
&.active {
color: var(--el-color-primary);
}
&:hover {
background-color: var(--el-bg-color-page);
}
}
}
}
</style>
写完之后觉得页签并不是很复杂,但是也在好几个地方卡住了
- 页签太多怎么办?限制页签显示数量,还是让它们滚起来,选择了使用el-scrollbar让它们横向滚动,但是体验感一般。 还有一个缺陷就是——滚动之后再打开新页面或者滚出去的页签,未自动滚回来。
- 关闭弹层的位置,一开始取的是鼠标点击的位置,但是这样显示就不是很整齐,所以改了半天找到了元素的位置来定位。偶然发现VSCODE右击文件也是跟随鼠标位置出现浮层(以前真没注意过),现在犹豫要不要改回来
- 点击元素之外区域隐藏元素,这是个老问题,以前一直给window增加事件监听来关闭,后来在我研究Element Plus的popover组件时,发现了v-click-outside,很好用! 本来打算用popover做这个关闭的浮层,virtual-ref可以做出来脱离popover的效果,但是碰到了无法解决的问题,作罢,自己写吧。
因为本项目引用了tailwindcss,代码中还有别的引入模块,所以需要看效果可能还需要把代码下载跑起来。聪明如你,应该改一改也可以自己跑起来😄
本项目GIT地址:github.com/lucidity99/…