| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740 |
- /**
- * 工作标签页状态管理模块
- *
- * 提供多标签页功能的完整状态管理
- *
- * ## 主要功能
- *
- * - 标签页打开和关闭
- * - 标签页固定和取消固定
- * - 批量关闭(左侧、右侧、其他、全部)
- * - 标签页缓存管理(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<WorkTab>
- opened: WorkTab[]
- keepAliveExclude: string[]
- }
- /** 同时打开的工作标签页数量上限(固定标签不计入淘汰,但计入总数) */
- const MAX_OPEN_TABS = 10
- /**
- * 工作台标签页管理 Store
- */
- export const useWorktabStore = defineStore(
- 'worktabStore',
- () => {
- // 状态定义
- const current = ref<Partial<WorkTab>>({})
- const opened = ref<WorkTab[]>([])
- const keepAliveExclude = ref<string[]>([])
- // 计算属性
- 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<WorkTab>): 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<null | (() => 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<WorkTab>): 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<WorkTab>): 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
- }
- }
- )
|