/** * 工作标签页状态管理模块 * * 提供多标签页功能的完整状态管理 * * ## 主要功能 * * - 标签页打开和关闭 * - 标签页固定和取消固定 * - 批量关闭(左侧、右侧、其他、全部) * - 标签页缓存管理(KeepAlive) * - 标签页标题自定义 * - 标签页路由验证 * - 动态路由参数处理 * * ## 使用场景 * * - 多标签页导航 * - 页面缓存控制 * - 标签页右键菜单 * - 固定常用页面 * - 批量关闭标签 * * ## 核心特性 * * - 智能标签页复用(同路由名称复用) * - 固定标签页保护(不可关闭) * - KeepAlive 缓存排除管理 * - 路由有效性验证 * - 同时最多打开 10 个标签(超出时自动关闭最早的可关闭标签) * - 首页自动保留 * * ## 持久化 * - 使用 localStorage 存储 * - 存储键:sys-v{version}-worktab * - 刷新页面保持标签状态 * * @module store/modules/worktab * @author Art Design Pro Team */ import { defineStore } from 'pinia' import { ref, computed } from 'vue' import { HOME_PAGE_PATH, router } from '@/router' import { LocationQueryRaw, Router } from 'vue-router' import { WorkTab } from '@/types' import { MenuTypeEnum } from '@/enums/appEnum' import { useCommon } from '@/hooks/core/useCommon' import { useMenuStore } from '@/store/modules/menu' import { isWorkbenchRoute } from '@/utils/navigation/route' import { getPortalDefaultHomePath, getPortalDefaultPathForRoute } from '@/utils/router/portal-menu' import { useSettingStore } from '@/store/modules/setting' export type OpenTabOptions = { activate?: boolean } interface WorktabState { current: Partial opened: WorkTab[] keepAliveExclude: string[] } /** 同时打开的工作标签页数量上限(固定标签不计入淘汰,但计入总数) */ const MAX_OPEN_TABS = 10 /** * 工作台标签页管理 Store */ export const useWorktabStore = defineStore( 'worktabStore', () => { // 状态定义 const current = ref>({}) const opened = ref([]) const keepAliveExclude = ref([]) // 计算属性 const hasOpenedTabs = computed(() => opened.value.length > 0) const hasMultipleTabs = computed(() => opened.value.length > 1) const currentTabIndex = computed(() => current.value.path ? opened.value.findIndex((tab) => tab.path === current.value.path) : -1 ) /** * 查找标签页索引 */ const findTabIndex = (path: string): number => { return opened.value.findIndex((tab) => tab.path === path) } /** * 获取标签页 */ const getTab = (path: string): WorkTab | undefined => { return opened.value.find((tab) => tab.path === path) } /** * 检查标签页是否可关闭 */ const isTabClosable = (tab: WorkTab): boolean => { return !tab.fixedTab } /** * 过滤不应展示在 worktab 中的父级占位路由。 * `/system` 仅用于承载与重定向,真实标签页为其子路由(如 `/system/tenant`)。 */ const shouldIgnoreTab = (tab: Partial): boolean => { if (tab.path === '/system') { return true } if (isWorkbenchRoute(tab.path)) { return true } const title = String(tab.title ?? tab.customTitle ?? '') return title === '工作台' || title === 'menus.dashboard.console' } /** * 标签全部关闭后的默认落点:当前一级模块首个二级菜单(无则回退首页路径) */ const isPortalMultiTabMode = (): boolean => { const setting = useSettingStore() return setting.menuType === MenuTypeEnum.PORTAL && setting.showWorkTab } const resolveEmptyTabsPath = (): string => { const { homePath } = useCommon() const home = homePath.value || HOME_PAGE_PATH || '/home' if (isPortalMultiTabMode()) { return home } const menuStore = useMenuStore() const currentPath = String( router.currentRoute.value.meta.activePath || router.currentRoute.value.path ) const fromModule = getPortalDefaultPathForRoute(menuStore.menuList, currentPath) if (fromModule) { return fromModule } const fromMenu = getPortalDefaultHomePath(menuStore.menuList) if (fromMenu) { return fromMenu } if (home && !isWorkbenchRoute(home)) { return home } return '/' } const overflowCheckHandler = ref boolean)>(null) const registerOverflowChecker = (fn: (() => boolean) | null) => { overflowCheckHandler.value = fn } const evictUntilNoOverflow = (): void => { if (!isPortalMultiTabMode()) return const checker = overflowCheckHandler.value if (!checker) return let guard = 0 while (checker() && guard++ < 50) { if (!evictOldestClosableTab()) break } if (current.value.path && findTabIndex(String(current.value.path)) === -1) { const fallback = opened.value[opened.value.length - 1] if (fallback?.path) { current.value = fallback safeRouterPush(fallback) } } } /** * 安全的路由跳转 */ const safeRouterPush = (tab: Partial): void => { if (!tab.path) { console.warn('尝试跳转到无效路径的标签页') return } try { router.push({ path: tab.path, query: tab.query as LocationQueryRaw }) } catch (error) { console.error('路由跳转失败:', error) } } /** 严格单页:showWorkTab 为 false 时仅保留当前页面并清理缓存 */ const isSinglePageMode = (): boolean => !useSettingStore().showWorkTab /** * 将已打开标签压至单页(保留当前激活页,其余移出 KeepAlive) */ const enforceSinglePageTabs = (): void => { if (!isSinglePageMode() || opened.value.length <= 1) { return } const activePath = current.value.path || router.currentRoute.value.meta.activePath || router.currentRoute.value.path const activeTab = opened.value.find((t) => t.path === activePath) ?? opened.value[opened.value.length - 1] const toRemove = opened.value.filter((t) => t.path !== activeTab?.path) markTabsToRemove(toRemove) opened.value = activeTab ? [activeTab] : [] if (activeTab) { current.value = activeTab } } /** * 打开或激活一个选项卡 * @param options.activate 默认 true;登录预开收藏时传 false,避免切走当前页 */ const openTab = (tab: WorkTab, options: OpenTabOptions = {}): void => { if (!tab.path) { console.warn('尝试打开无效的标签页') return } if (shouldIgnoreTab(tab)) return const activate = options.activate !== false const singlePage = isSinglePageMode() const portalMulti = isPortalMultiTabMode() // 从 keepAlive 排除列表中移除 if (tab.name) { removeKeepAliveExclude(tab.name) } // 先根据路由名称查找(应对动态路由参数导致的多开问题),找不到再根据路径查找 let existingIndex = -1 if (tab.name) { existingIndex = opened.value.findIndex((t) => t.name === tab.name) } if (existingIndex === -1) { existingIndex = findTabIndex(tab.path) } if (existingIndex === -1) { const newTab = { ...tab, fixedTab: singlePage ? false : tab.fixedTab } if (singlePage) { markTabsToRemove(opened.value.filter((t) => t.path !== newTab.path)) opened.value = [newTab] current.value = newTab return } // 非门户:保留固定数量上限;门户改由 DOM 溢出淘汰 if (!portalMulti && !ensureCapacityForNewTab()) { return } const insertIndex = tab.fixedTab ? findFixedTabInsertIndex() : opened.value.length if (tab.fixedTab) { opened.value.splice(insertIndex, 0, newTab) } else { opened.value.push(newTab) } if (activate || !current.value.path) { current.value = newTab } } else { // 更新现有标签页(当动态路由参数或查询变更时,复用同一标签) const existingTab = opened.value[existingIndex] opened.value[existingIndex] = { ...existingTab, path: tab.path, params: tab.params, query: tab.query, title: tab.title || existingTab.title, fixedTab: singlePage ? false : (tab.fixedTab ?? existingTab.fixedTab), keepAlive: tab.keepAlive ?? existingTab.keepAlive, name: tab.name || existingTab.name, icon: tab.icon || existingTab.icon } if (activate) { current.value = opened.value[existingIndex] } if (singlePage) { enforceSinglePageTabs() } } } /** * 查找固定标签页的插入位置 */ const findFixedTabInsertIndex = (): number => { let insertIndex = 0 for (let i = 0; i < opened.value.length; i++) { if (opened.value[i].fixedTab) { insertIndex = i + 1 } else { break } } return insertIndex } /** * 关闭指定的选项卡 */ const removeTab = (path: string): void => { const targetTab = getTab(path) const targetIndex = findTabIndex(path) if (targetIndex === -1) { console.warn(`尝试关闭不存在的标签页: ${path}`) return } if (targetTab && !isTabClosable(targetTab)) { console.warn(`尝试关闭固定标签页: ${path}`) return } // 从标签页列表中移除 opened.value.splice(targetIndex, 1) // 处理缓存排除 if (targetTab?.name) { addKeepAliveExclude(targetTab) } // 如果关闭后无标签页,跳转侧栏第一个二级菜单 if (!hasOpenedTabs.value) { current.value = {} safeRouterPush({ path: resolveEmptyTabsPath() }) return } // 如果关闭的是当前激活标签,需要激活其他标签 if (current.value.path === path) { const newIndex = targetIndex >= opened.value.length ? opened.value.length - 1 : targetIndex current.value = opened.value[newIndex] safeRouterPush(current.value) } } /** * 关闭左侧选项卡 */ const removeLeft = (path: string): void => { const targetIndex = findTabIndex(path) if (targetIndex === -1) { console.warn(`尝试关闭左侧标签页,但目标标签页不存在: ${path}`) return } // 获取左侧可关闭的标签页 const leftTabs = opened.value.slice(0, targetIndex) const closableLeftTabs = leftTabs.filter(isTabClosable) if (closableLeftTabs.length === 0) { console.warn('左侧没有可关闭的标签页') return } // 标记为缓存排除 markTabsToRemove(closableLeftTabs) // 移除左侧可关闭的标签页 opened.value = opened.value.filter( (tab, index) => index >= targetIndex || !isTabClosable(tab) ) // 确保当前标签是激活状态 const targetTab = getTab(path) if (targetTab) { current.value = targetTab } } /** * 关闭右侧选项卡 */ const removeRight = (path: string): void => { const targetIndex = findTabIndex(path) if (targetIndex === -1) { console.warn(`尝试关闭右侧标签页,但目标标签页不存在: ${path}`) return } // 获取右侧可关闭的标签页 const rightTabs = opened.value.slice(targetIndex + 1) const closableRightTabs = rightTabs.filter(isTabClosable) if (closableRightTabs.length === 0) { console.warn('右侧没有可关闭的标签页') return } // 标记为缓存排除 markTabsToRemove(closableRightTabs) // 移除右侧可关闭的标签页 opened.value = opened.value.filter( (tab, index) => index <= targetIndex || !isTabClosable(tab) ) // 确保当前标签是激活状态 const targetTab = getTab(path) if (targetTab) { current.value = targetTab } } /** * 关闭其他选项卡 */ const removeOthers = (path: string): void => { const targetTab = getTab(path) if (!targetTab) { console.warn(`尝试关闭其他标签页,但目标标签页不存在: ${path}`) return } // 获取其他可关闭的标签页 const otherTabs = opened.value.filter((tab) => tab.path !== path) const closableTabs = otherTabs.filter(isTabClosable) if (closableTabs.length === 0) { console.warn('没有其他可关闭的标签页') return } // 标记为缓存排除 markTabsToRemove(closableTabs) // 只保留当前标签和固定标签 opened.value = opened.value.filter((tab) => tab.path === path || !isTabClosable(tab)) // 确保当前标签是激活状态 current.value = targetTab } /** * 关闭所有可关闭的标签页 */ const removeAll = (): void => { const closableTabs = opened.value.filter(isTabClosable) if (closableTabs.length === 0) { console.warn('没有可关闭的标签页') return } markTabsToRemove(closableTabs) opened.value = opened.value.filter((tab) => !isTabClosable(tab)) const fallbackPath = resolveEmptyTabsPath() if (!hasOpenedTabs.value) { current.value = {} safeRouterPush({ path: fallbackPath }) return } const targetTab = opened.value[0] current.value = targetTab safeRouterPush(targetTab) } /** * 将指定选项卡添加到 keepAlive 排除列表中 */ const addKeepAliveExclude = (tab: WorkTab): void => { if (!tab.keepAlive || !tab.name) return if (!keepAliveExclude.value.includes(tab.name)) { keepAliveExclude.value.push(tab.name) } } /** * 从 keepAlive 排除列表中移除指定组件名称 */ const removeKeepAliveExclude = (name: string): void => { if (!name) return keepAliveExclude.value = keepAliveExclude.value.filter((item) => item !== name) } /** * 关闭最早打开的可关闭标签(数组中最靠前的非 fixedTab) */ const evictOldestClosableTab = (): boolean => { const index = opened.value.findIndex(isTabClosable) if (index === -1) { return false } const [removed] = opened.value.splice(index, 1) if (removed?.name) { addKeepAliveExclude(removed) } return true } /** * 新开标签前:保证 opened.length < MAX_OPEN_TABS */ const ensureCapacityForNewTab = (): boolean => { while (opened.value.length >= MAX_OPEN_TABS) { if (!evictOldestClosableTab()) { console.warn(`工作标签已达上限 ${MAX_OPEN_TABS} 个,且均为固定标签,无法打开新页面`) return false } } return true } /** * 持久化恢复或校验后:将数量压回上限以内 */ const trimTabsToMaxOpen = (): void => { while (opened.value.length > MAX_OPEN_TABS) { if (!evictOldestClosableTab()) { break } } } /** * 将传入的一组选项卡的组件名称标记为排除缓存 */ const markTabsToRemove = (tabs: WorkTab[]): void => { tabs.forEach((tab) => { if (tab.name) { addKeepAliveExclude(tab) } }) } /** * 切换指定标签页的固定状态 */ const toggleFixedTab = (path: string): void => { const targetIndex = findTabIndex(path) if (targetIndex === -1) { console.warn(`尝试切换不存在标签页的固定状态: ${path}`) return } const tab = { ...opened.value[targetIndex] } tab.fixedTab = !tab.fixedTab // 移除原位置 opened.value.splice(targetIndex, 1) if (tab.fixedTab) { // 固定标签插入到所有固定标签的末尾 const firstNonFixedIndex = opened.value.findIndex((t) => !t.fixedTab) const insertIndex = firstNonFixedIndex === -1 ? opened.value.length : firstNonFixedIndex opened.value.splice(insertIndex, 0, tab) } else { // 非固定标签插入到所有固定标签后 const fixedCount = opened.value.filter((t) => t.fixedTab).length opened.value.splice(fixedCount, 0, tab) } // 更新当前标签引用 if (current.value.path === path) { current.value = tab } } /** * 验证工作台标签页的路由有效性 */ const validateWorktabs = (routerInstance: Router): void => { try { // 动态路由校验:优先使用路由 name 判断有效性;否则用 resolve 匹配参数化路径 const isTabRouteValid = (tab: Partial): boolean => { try { if (tab.name) { const routes = routerInstance.getRoutes() if (routes.some((r) => r.name === tab.name)) return true } if (tab.path) { const resolved = routerInstance.resolve({ path: tab.path, query: (tab.query as LocationQueryRaw) || undefined }) return resolved.matched.length > 0 } return false } catch { return false } } // 过滤出有效的标签页 const validTabs = opened.value.filter( (tab) => !shouldIgnoreTab(tab) && isTabRouteValid(tab) ) if (validTabs.length !== opened.value.length) { console.warn('发现无效的标签页路由,已自动清理') opened.value = validTabs } if (isSinglePageMode()) { enforceSinglePageTabs() } else { trimTabsToMaxOpen() } // 验证当前激活标签的有效性 const isCurrentValid = current.value && !shouldIgnoreTab(current.value) && isTabRouteValid(current.value) if (!isCurrentValid && validTabs.length > 0) { console.warn('当前激活标签无效,已自动切换') current.value = validTabs[0] } else if (!isCurrentValid) { current.value = {} } const activePath = routerInstance.currentRoute.value.path if (isWorkbenchRoute(activePath) || shouldIgnoreTab(current.value)) { const fallback = resolveEmptyTabsPath() if (fallback && fallback !== activePath) { safeRouterPush({ path: fallback }) } } } catch (error) { console.error('验证工作台标签页失败:', error) } } /** * 清空所有状态(用于登出等场景) */ const clearAll = (): void => { current.value = {} opened.value = [] keepAliveExclude.value = [] } /** * 获取状态快照(用于持久化存储) */ const getStateSnapshot = (): WorktabState => { return { current: { ...current.value }, opened: [...opened.value], keepAliveExclude: [...keepAliveExclude.value] } } /** * 获取标签页标题 */ const getTabTitle = (path: string): WorkTab | undefined => { const tab = getTab(path) return tab } /** * 更新标签页标题 */ const updateTabTitle = (path: string, title: string): void => { const tab = getTab(path) if (tab) { tab.customTitle = title } } /** * 重置标签页标题 */ const resetTabTitle = (path: string): void => { const tab = getTab(path) if (tab) { tab.customTitle = '' } } return { // 状态 current, opened, keepAliveExclude, // 计算属性 hasOpenedTabs, hasMultipleTabs, currentTabIndex, // 方法 openTab, removeTab, removeLeft, removeRight, removeOthers, removeAll, toggleFixedTab, validateWorktabs, enforceSinglePageTabs, registerOverflowChecker, evictUntilNoOverflow, clearAll, getStateSnapshot, // 工具方法 findTabIndex, getTab, isTabClosable, addKeepAliveExclude, removeKeepAliveExclude, markTabsToRemove, getTabTitle, updateTabTitle, resetTabTitle } }, { persist: { key: 'worktab', storage: localStorage } } )