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

posted @ 2025-10-23 09:36  韩德才  阅读(7)  评论(0)    收藏  举报