Vue3+ts+pinia实现活跃的tab栏
pinia 部分
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
export interface TabItem {
id: string
title: string
path: string
icon?: string
closable?: boolean
}
export const useTabsStore = defineStore('tabs', () => {
// 标签页列表
const tabs = ref<TabItem[]>([])
// 当前活跃标签页
const activeTabId = ref<string>('')
// 获取当前活跃标签页
const activeTab = computed(() => {
return tabs.value.find((tab) => tab.id === activeTabId.value)
})
// 添加标签页
const addTab = (tab: TabItem) => {
// 检查是否已存在相同路径的标签页
const existingTab = tabs.value.find((t) => t.path === tab.path)
if (existingTab) {
// 如果已存在,则激活该标签页
activeTabId.value = existingTab.id
return
}
// 添加新标签页
tabs.value.push(tab)
activeTabId.value = tab.id
}
// 关闭标签页
const closeTab = (tabId: string) => {
const index = tabs.value.findIndex((tab) => tab.id === tabId)
if (index === -1) return
// 如果只有一个标签页,不允许关闭
if (tabs.value.length <= 1) {
ElMessage.error('至少保留一个标签页')
return
}
// 如果关闭的是当前活跃标签页,需要切换到其他标签页
if (tabId === activeTabId.value) {
// 优先切换到右侧标签页,如果没有则切换到左侧
if (index < tabs.value.length - 1) {
activeTabId.value = tabs.value[index + 1]?.id || ''
} else if (index > 0) {
activeTabId.value = tabs.value[index - 1]?.id || ''
} else {
activeTabId.value = ''
}
}
// 移除标签页
tabs.value.splice(index, 1)
}
// 设置活跃标签页
const setActiveTab = (tabId: string) => {
const tab = tabs.value.find((t) => t.id === tabId)
if (tab) {
activeTabId.value = tabId
}
}
// 关闭其他标签页
const closeOtherTabs = (keepTabId: string) => {
tabs.value = tabs.value.filter((tab) => tab.id === keepTabId)
activeTabId.value = keepTabId
}
// 关闭所有标签页
const closeAllTabs = () => {
tabs.value = []
activeTabId.value = ''
}
// 关闭左侧标签页
const closeLeftTabs = (tabId: string) => {
const index = tabs.value.findIndex((tab) => tab.id === tabId)
if (index > 0) {
tabs.value.splice(0, index)
}
}
// 关闭右侧标签页
const closeRightTabs = (tabId: string) => {
const index = tabs.value.findIndex((tab) => tab.id === tabId)
if (index < tabs.value.length - 1) {
tabs.value.splice(index + 1)
}
}
return {
tabs,
activeTabId,
activeTab,
addTab,
closeTab,
setActiveTab,
closeOtherTabs,
closeAllTabs,
closeLeftTabs,
closeRightTabs,
}
})
tab 标签页
<template>
<div class="tabs-container">
<!-- 标签页列表 -->
<div class="tabs-list" ref="tabsListRef">
<div
v-for="tab in tabsStore.tabs"
:key="tab.id"
:class="['tab-item', { active: tab.id === tabsStore.activeTabId }]"
@click="handleTabClick(tab)"
@contextmenu.prevent="handleContextMenu($event, tab)"
>
<component v-if="tab.icon" :is="tab.icon" class="tab-icon" />
<span class="tab-title">{{ tab.title }}</span>
<button
v-if="tab.closable !== false"
class="tab-close"
@click.stop="handleCloseTab(tab.id)"
>
×
</button>
</div>
</div>
<!-- 右键菜单 -->
<div
v-if="contextMenu.visible"
class="context-menu"
:style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }"
@click.stop
>
<div class="menu-item" @click="handleCloseTab(contextMenu.tabId)">关闭标签页</div>
<div class="menu-item" @click="handleCloseOtherTabs(contextMenu.tabId)">关闭其他标签页</div>
<div class="menu-item" @click="handleCloseLeftTabs(contextMenu.tabId)">关闭左侧标签页</div>
<div class="menu-item" @click="handleCloseRightTabs(contextMenu.tabId)">关闭右侧标签页</div>
<div class="menu-item" @click="handleCloseAllTabs">关闭所有标签页</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useTabsStore, type TabItem } from '@/stores/tabs'
const router = useRouter()
const tabsStore = useTabsStore()
const tabsListRef = ref<HTMLElement>()
// 右键菜单状态
const contextMenu = ref({
visible: false,
x: 0,
y: 0,
tabId: '',
})
// 处理标签页点击
const handleTabClick = (tab: TabItem) => {
tabsStore.setActiveTab(tab.id)
router.push(tab.path)
}
// 处理关闭标签页
const handleCloseTab = (tabId: string) => {
const tab = tabsStore.tabs.find((t) => t.id === tabId)
if (!tab) return
tabsStore.closeTab(tabId)
// 如果关闭后还有标签页,导航到当前活跃标签页
if (tabsStore.activeTabId && tabsStore.activeTab) {
router.push(tabsStore.activeTab.path)
} else {
// 如果没有标签页了,导航到首页
router.push('/')
}
}
// 处理右键菜单
const handleContextMenu = (event: MouseEvent, tab: TabItem) => {
contextMenu.value = {
visible: true,
x: event.clientX,
y: event.clientY,
tabId: tab.id,
}
}
// 关闭其他标签页
const handleCloseOtherTabs = (tabId: string) => {
tabsStore.closeOtherTabs(tabId)
const tab = tabsStore.tabs.find((t) => t.id === tabId)
if (tab) {
router.push(tab.path)
}
hideContextMenu()
}
// 关闭左侧标签页
const handleCloseLeftTabs = (tabId: string) => {
tabsStore.closeLeftTabs(tabId)
hideContextMenu()
}
// 关闭右侧标签页
const handleCloseRightTabs = (tabId: string) => {
tabsStore.closeRightTabs(tabId)
hideContextMenu()
}
// 关闭所有标签页
const handleCloseAllTabs = () => {
tabsStore.closeAllTabs()
router.push('/')
hideContextMenu()
}
// 隐藏右键菜单
const hideContextMenu = () => {
contextMenu.value.visible = false
}
// 点击其他地方隐藏右键菜单
const handleClickOutside = () => {
hideContextMenu()
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
})
</script>
<style scoped>
.tabs-container {
position: relative;
background: #fff;
border-bottom: 1px solid #e5e7eb;
}
.tabs-list {
display: flex;
overflow-x: auto;
scrollbar-width: none;
-ms-overflow-style: none;
}
.tabs-list::-webkit-scrollbar {
display: none;
}
.tab-item {
display: flex;
align-items: center;
padding: 8px 16px;
min-width: 120px;
max-width: 200px;
border-right: 1px solid #e5e7eb;
border-bottom: 2px solid transparent;
cursor: pointer;
transition: all 0.2s;
position: relative;
white-space: nowrap;
background: #f9fafb;
}
.tab-item:hover {
background: #f3f4f6;
}
.tab-item.active {
background: #fff;
border-bottom: 2px solid #3b82f6;
color: #3b82f6;
}
.tab-icon {
width: 16px;
height: 16px;
margin-right: 8px;
flex-shrink: 0;
}
.tab-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
font-size: 14px;
}
.tab-close {
width: 18px;
height: 18px;
border: none;
background: none;
cursor: pointer;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-left: 8px;
font-size: 16px;
color: #6b7280;
transition: all 0.2s;
flex-shrink: 0;
}
.tab-close:hover {
background: #e5e7eb;
color: #374151;
}
.context-menu {
position: fixed;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 6px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
z-index: 1000;
min-width: 150px;
}
.menu-item {
padding: 8px 12px;
cursor: pointer;
font-size: 14px;
color: #374151;
transition: background-color 0.2s;
}
.menu-item:hover {
background: #f3f4f6;
}
.menu-item:first-child {
border-radius: 6px 6px 0 0;
}
.menu-item:last-child {
border-radius: 0 0 6px 6px;
}
</style>
router
import { createRouter, createWebHistory } from 'vue-router'
import { useTabsStore } from '@/stores/tabs'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
redirect: '/design-workspace',
},
{
path: '/design-workspace',
component: () => import('@/view/DesignWorkspace/DesignWorkspace.vue'),
meta: {
title: '设计工作区',
icon: 'BrushFilled',
keepAlive: true,
},
},
{
path: '/user-test',
component: () => import('@/view/UserTest/UserTest.vue'),
meta: {
title: '用户测试',
icon: 'Platform',
keepAlive: true,
},
},
{
path: '/design-specification',
component: () => import('@/view/DesignSpecification/DesignSpecification.vue'),
meta: {
title: '设计规范',
icon: 'List',
keepAlive: true,
},
},
{
path: '/audit-and-cooperation',
component: () => import('@/view/AuditAndCooperation/AuditAndCooperation.vue'),
meta: {
title: '审核对接',
icon: 'CircleCheckFilled',
keepAlive: true,
},
},
],
})
// 路由守卫 - 自动管理标签页
router.beforeEach((to, from, next) => {
// 跳过重定向路由
if (to.path === '/') {
next()
return
}
// 如果路由有 meta 信息,自动添加标签页
if (to.meta && to.meta.title) {
const tabsStore = useTabsStore()
const tabId = `tab-${to.path.replace(/\//g, '-')}`
tabsStore.addTab({
id: tabId,
title: to.meta.title as string,
path: to.path,
icon: to.meta.icon as string,
closable: true,
})
}
next()
})
export default router

浙公网安备 33010602011771号