Merge branch 'main' of https://github.com/vbenjs/vue-vben-admin into dev
						commit
						2f5758d4dd
					
				|  | @ -339,6 +339,10 @@ interface RouteMeta { | |||
|     | 'success' | ||||
|     | 'warning' | ||||
|     | string; | ||||
|   /** | ||||
|    * 路由的完整路径作为key(默认true) | ||||
|    */ | ||||
|   fullPathKey?: boolean; | ||||
|   /** | ||||
|    * 当前路由的子级在菜单中不展现 | ||||
|    * @default false | ||||
|  | @ -502,6 +506,13 @@ interface RouteMeta { | |||
| 
 | ||||
| 用于配置页面的徽标颜色。 | ||||
| 
 | ||||
| ### fullPathKey | ||||
| 
 | ||||
| - 类型:`boolean` | ||||
| - 默认值:`true` | ||||
| 
 | ||||
| 是否将路由的完整路径作为tab key(默认true) | ||||
| 
 | ||||
| ### activePath | ||||
| 
 | ||||
| - 类型:`string` | ||||
|  | @ -602,3 +613,32 @@ const { refresh } = useRefresh(); | |||
| refresh(); | ||||
| </script> | ||||
| ``` | ||||
| 
 | ||||
| ## 标签页与路由控制 | ||||
| 
 | ||||
| 在某些场景下,需要单个路由打开多个标签页,或者修改路由的query不打开新的标签页 | ||||
| 
 | ||||
| 每个标签页Tab使用唯一的key标识,设置Tab key有三种方式,优先级由高到低: | ||||
| 
 | ||||
| - 使用路由query参数pageKey | ||||
| 
 | ||||
| ```vue | ||||
| <script setup lang="ts"> | ||||
| import { useRouter } from 'vue-router'; | ||||
| // 跳转路由 | ||||
| const router = useRouter(); | ||||
| router.push({ | ||||
|   path: 'path', | ||||
|   query: { | ||||
|     pageKey: 'key', | ||||
|   }, | ||||
| }); | ||||
| ``` | ||||
| 
 | ||||
| - 路由的完整路径作为key | ||||
| 
 | ||||
| `meta` 属性中的 `fullPathKey`不为false,则使用路由`fullPath`作为key | ||||
| 
 | ||||
| - 路由的path作为key | ||||
| 
 | ||||
| `meta` 属性中的 `fullPathKey`为false,则使用路由`path`作为key | ||||
|  |  | |||
|  | @ -12,7 +12,7 @@ | |||
|   "license": "MIT", | ||||
|   "type": "module", | ||||
|   "scripts": { | ||||
|     "stub": "pnpm unbuild" | ||||
|     "stub": "pnpm unbuild --stub" | ||||
|   }, | ||||
|   "files": [ | ||||
|     "dist" | ||||
|  |  | |||
|  | @ -1,3 +1,8 @@ | |||
| import type { RouteLocationNormalized } from 'vue-router'; | ||||
| 
 | ||||
| export type TabDefinition = RouteLocationNormalized; | ||||
| export interface TabDefinition extends RouteLocationNormalized { | ||||
|   /** | ||||
|    * 标签页的key | ||||
|    */ | ||||
|   key?: string; | ||||
| } | ||||
|  |  | |||
|  | @ -43,6 +43,10 @@ interface RouteMeta { | |||
|     | 'success' | ||||
|     | 'warning' | ||||
|     | string; | ||||
|   /** | ||||
|    * 路由的完整路径作为key(默认true) | ||||
|    */ | ||||
|   fullPathKey?: boolean; | ||||
|   /** | ||||
|    * 当前路由的子级在菜单中不展现 | ||||
|    * @default false | ||||
|  |  | |||
|  | @ -36,7 +36,7 @@ export interface VbenButtonGroupProps | |||
|   btnClass?: any; | ||||
|   gap?: number; | ||||
|   multiple?: boolean; | ||||
|   options?: { label: CustomRenderType; value: ValueType }[]; | ||||
|   options?: { [key: string]: any; label: CustomRenderType; value: ValueType }[]; | ||||
|   showIcon?: boolean; | ||||
|   size?: 'large' | 'middle' | 'small'; | ||||
| } | ||||
|  |  | |||
|  | @ -119,7 +119,7 @@ async function onBtnClick(value: ValueType) { | |||
|         <CircleCheckBig v-else-if="innerValue.includes(btn.value)" /> | ||||
|         <Circle v-else /> | ||||
|       </div> | ||||
|       <slot name="option" :label="btn.label" :value="btn.value"> | ||||
|       <slot name="option" :label="btn.label" :value="btn.value" :data="btn"> | ||||
|         <VbenRenderContent :content="btn.label" /> | ||||
|       </slot> | ||||
|     </Button> | ||||
|  | @ -127,6 +127,9 @@ async function onBtnClick(value: ValueType) { | |||
| </template> | ||||
| <style lang="scss" scoped> | ||||
| .vben-check-button-group { | ||||
|   display: flex; | ||||
|   flex-wrap: wrap; | ||||
| 
 | ||||
|   &:deep(.size-large) button { | ||||
|     .icon-wrapper { | ||||
|       margin-right: 0.3rem; | ||||
|  | @ -159,5 +162,16 @@ async function onBtnClick(value: ValueType) { | |||
|       } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   &.no-gap > :deep(button):nth-of-type(1) { | ||||
|     border-right-width: 0; | ||||
|   } | ||||
| 
 | ||||
|   &.no-gap { | ||||
|     :deep(button + button) { | ||||
|       margin-right: -1px; | ||||
|       border-left-width: 1px; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </style> | ||||
|  |  | |||
|  | @ -224,15 +224,20 @@ defineExpose({ | |||
|         :class=" | ||||
|           cn('cursor-pointer', getNodeClass?.(item), { | ||||
|             'data-[selected]:bg-accent': !multiple, | ||||
|             'cursor-not-allowed': disabled, | ||||
|           }) | ||||
|         " | ||||
|         v-bind=" | ||||
|           Object.assign(item.bind, { | ||||
|             onfocus: disabled ? 'this.blur()' : undefined, | ||||
|           }) | ||||
|         " | ||||
|         v-bind="item.bind" | ||||
|         @select=" | ||||
|           (event) => { | ||||
|             if (event.detail.originalEvent.type === 'click') { | ||||
|               event.preventDefault(); | ||||
|             } | ||||
|             onSelect(item, event.detail.isSelected); | ||||
|             !disabled && onSelect(item, event.detail.isSelected); | ||||
|           } | ||||
|         " | ||||
|         @toggle=" | ||||
|  | @ -240,7 +245,7 @@ defineExpose({ | |||
|             if (event.detail.originalEvent.type === 'click') { | ||||
|               event.preventDefault(); | ||||
|             } | ||||
|             onToggle(item); | ||||
|             !disabled && onToggle(item); | ||||
|           } | ||||
|         " | ||||
|         class="tree-node focus:ring-grass8 my-0.5 flex items-center rounded px-2 py-1 outline-none focus:ring-2" | ||||
|  | @ -262,10 +267,11 @@ defineExpose({ | |||
|         <Checkbox | ||||
|           v-if="multiple" | ||||
|           :checked="isSelected" | ||||
|           :disabled="disabled" | ||||
|           :indeterminate="isIndeterminate" | ||||
|           @click=" | ||||
|             () => { | ||||
|               handleSelect(); | ||||
|               !disabled && handleSelect(); | ||||
|               // onSelect(item, !isSelected); | ||||
|             } | ||||
|           " | ||||
|  | @ -276,7 +282,7 @@ defineExpose({ | |||
|             (_event) => { | ||||
|               // $event.stopPropagation(); | ||||
|               // $event.preventDefault(); | ||||
|               handleSelect(); | ||||
|               !disabled && handleSelect(); | ||||
|               // onSelect(item, !isSelected); | ||||
|             } | ||||
|           " | ||||
|  |  | |||
|  | @ -40,14 +40,14 @@ const style = computed(() => { | |||
| 
 | ||||
| const tabsView = computed(() => { | ||||
|   return props.tabs.map((tab) => { | ||||
|     const { fullPath, meta, name, path } = tab || {}; | ||||
|     const { fullPath, meta, name, path, key } = tab || {}; | ||||
|     const { affixTab, icon, newTabTitle, tabClosable, title } = meta || {}; | ||||
|     return { | ||||
|       affixTab: !!affixTab, | ||||
|       closable: Reflect.has(meta, 'tabClosable') ? !!tabClosable : true, | ||||
|       fullPath, | ||||
|       icon: icon as string, | ||||
|       key: fullPath || path, | ||||
|       key, | ||||
|       meta, | ||||
|       name, | ||||
|       path, | ||||
|  |  | |||
|  | @ -47,14 +47,14 @@ const typeWithClass = computed(() => { | |||
| 
 | ||||
| const tabsView = computed(() => { | ||||
|   return props.tabs.map((tab) => { | ||||
|     const { fullPath, meta, name, path } = tab || {}; | ||||
|     const { fullPath, meta, name, path, key } = tab || {}; | ||||
|     const { affixTab, icon, newTabTitle, tabClosable, title } = meta || {}; | ||||
|     return { | ||||
|       affixTab: !!affixTab, | ||||
|       closable: Reflect.has(meta, 'tabClosable') ? !!tabClosable : true, | ||||
|       fullPath, | ||||
|       icon: icon as string, | ||||
|       key: fullPath || path, | ||||
|       key, | ||||
|       meta, | ||||
|       name, | ||||
|       path, | ||||
|  |  | |||
|  | @ -1,3 +1,4 @@ | |||
| import type { ComputedRef } from 'vue'; | ||||
| import type { RouteLocationNormalized } from 'vue-router'; | ||||
| 
 | ||||
| import { useRoute, useRouter } from 'vue-router'; | ||||
|  | @ -53,7 +54,24 @@ export function useTabs() { | |||
|     await tabbarStore.closeTabByKey(key, router); | ||||
|   } | ||||
| 
 | ||||
|   async function setTabTitle(title: string) { | ||||
|   /** | ||||
|    * 设置当前标签页的标题 | ||||
|    * | ||||
|    * @description 支持设置静态标题字符串或动态计算标题 | ||||
|    * @description 动态标题会在每次渲染时重新计算,适用于多语言或状态相关的标题 | ||||
|    * | ||||
|    * @param title - 标题内容 | ||||
|    *   - 静态标题: 直接传入字符串 | ||||
|    *   - 动态标题: 传入 ComputedRef | ||||
|    * | ||||
|    * @example | ||||
|    * // 静态标题
 | ||||
|    * setTabTitle('标签页') | ||||
|    * | ||||
|    * // 动态标题(多语言)
 | ||||
|    * setTabTitle(computed(() => t('page.title'))) | ||||
|    */ | ||||
|   async function setTabTitle(title: ComputedRef<string> | string) { | ||||
|     tabbarStore.setUpdateTime(); | ||||
|     await tabbarStore.setTabTitle(route, title); | ||||
|   } | ||||
|  |  | |||
|  | @ -38,7 +38,7 @@ const { authPanelCenter, authPanelLeft, authPanelRight, isDark } = | |||
| 
 | ||||
| <template> | ||||
|   <div | ||||
|     :class="[isDark]" | ||||
|     :class="[isDark ? 'dark' : '']" | ||||
|     class="flex min-h-full flex-1 select-none overflow-x-hidden" | ||||
|   > | ||||
|     <template v-if="toolbar"> | ||||
|  |  | |||
|  | @ -1 +1,2 @@ | |||
| export { default as AuthPageLayout } from './authentication.vue'; | ||||
| export * from './types'; | ||||
|  |  | |||
|  | @ -9,7 +9,7 @@ import { computed } from 'vue'; | |||
| import { RouterView } from 'vue-router'; | ||||
| 
 | ||||
| import { preferences, usePreferences } from '@vben/preferences'; | ||||
| import { storeToRefs, useTabbarStore } from '@vben/stores'; | ||||
| import { getTabKey, storeToRefs, useTabbarStore } from '@vben/stores'; | ||||
| 
 | ||||
| import { IFrameRouterView } from '../../iframe'; | ||||
| 
 | ||||
|  | @ -115,13 +115,13 @@ function transformComponent( | |||
|             :is="transformComponent(Component, route)" | ||||
|             v-if="renderRouteView" | ||||
|             v-show="!route.meta.iframeSrc" | ||||
|             :key="route.fullPath" | ||||
|             :key="getTabKey(route)" | ||||
|           /> | ||||
|         </KeepAlive> | ||||
|         <component | ||||
|           :is="Component" | ||||
|           v-else-if="renderRouteView" | ||||
|           :key="route.fullPath" | ||||
|           :key="getTabKey(route)" | ||||
|         /> | ||||
|       </Transition> | ||||
|       <template v-else> | ||||
|  | @ -134,13 +134,13 @@ function transformComponent( | |||
|             :is="transformComponent(Component, route)" | ||||
|             v-if="renderRouteView" | ||||
|             v-show="!route.meta.iframeSrc" | ||||
|             :key="route.fullPath" | ||||
|             :key="getTabKey(route)" | ||||
|           /> | ||||
|         </KeepAlive> | ||||
|         <component | ||||
|           :is="Component" | ||||
|           v-else-if="renderRouteView" | ||||
|           :key="route.fullPath" | ||||
|           :key="getTabKey(route)" | ||||
|         /> | ||||
|       </template> | ||||
|     </RouterView> | ||||
|  |  | |||
|  | @ -140,7 +140,10 @@ function useMixedMenu() { | |||
|   watch( | ||||
|     () => route.path, | ||||
|     (path) => { | ||||
|       const currentPath = (route?.meta?.activePath as string) ?? path; | ||||
|       const currentPath = route?.meta?.activePath ?? route?.meta?.link ?? path; | ||||
|       if (willOpenedByWindow(currentPath)) { | ||||
|         return; | ||||
|       } | ||||
|       calcSideMenus(currentPath); | ||||
|       if (rootMenuPath.value) | ||||
|         defaultSubMap.set(rootMenuPath.value, currentPath); | ||||
|  |  | |||
|  | @ -30,7 +30,7 @@ const { | |||
| } = useTabbar(); | ||||
| 
 | ||||
| const menus = computed(() => { | ||||
|   const tab = tabbarStore.getTabByPath(currentActive.value); | ||||
|   const tab = tabbarStore.getTabByKey(currentActive.value); | ||||
|   const menus = createContextMenus(tab); | ||||
|   return menus.map((item) => { | ||||
|     return { | ||||
|  |  | |||
|  | @ -22,7 +22,7 @@ import { | |||
|   X, | ||||
| } from '@vben/icons'; | ||||
| import { $t, useI18n } from '@vben/locales'; | ||||
| import { useAccessStore, useTabbarStore } from '@vben/stores'; | ||||
| import { getTabKey, useAccessStore, useTabbarStore } from '@vben/stores'; | ||||
| import { filterTree } from '@vben/utils'; | ||||
| 
 | ||||
| export function useTabbar() { | ||||
|  | @ -44,8 +44,11 @@ export function useTabbar() { | |||
|     toggleTabPin, | ||||
|   } = useTabs(); | ||||
| 
 | ||||
|   /** | ||||
|    * 当前路径对应的tab的key | ||||
|    */ | ||||
|   const currentActive = computed(() => { | ||||
|     return route.fullPath; | ||||
|     return getTabKey(route); | ||||
|   }); | ||||
| 
 | ||||
|   const { locale } = useI18n(); | ||||
|  | @ -73,7 +76,8 @@ export function useTabbar() { | |||
| 
 | ||||
|   // 点击tab,跳转路由
 | ||||
|   const handleClick = (key: string) => { | ||||
|     router.push(key); | ||||
|     const { fullPath, path } = tabbarStore.getTabByKey(key); | ||||
|     router.push(fullPath || path); | ||||
|   }; | ||||
| 
 | ||||
|   // 关闭tab
 | ||||
|  | @ -100,7 +104,7 @@ export function useTabbar() { | |||
|   ); | ||||
| 
 | ||||
|   watch( | ||||
|     () => route.path, | ||||
|     () => route.fullPath, | ||||
|     () => { | ||||
|       const meta = route.matched?.[route.matched.length - 1]?.meta; | ||||
|       tabbarStore.addTab({ | ||||
|  |  | |||
|  | @ -22,12 +22,13 @@ describe('useAccessStore', () => { | |||
|     const tab: any = { | ||||
|       fullPath: '/home', | ||||
|       meta: {}, | ||||
|       key: '/home', | ||||
|       name: 'Home', | ||||
|       path: '/home', | ||||
|     }; | ||||
|     store.addTab(tab); | ||||
|     const addNewTab = store.addTab(tab); | ||||
|     expect(store.tabs.length).toBe(1); | ||||
|     expect(store.tabs[0]).toEqual(tab); | ||||
|     expect(store.tabs[0]).toEqual(addNewTab); | ||||
|   }); | ||||
| 
 | ||||
|   it('adds a new tab if it does not exist', () => { | ||||
|  | @ -38,20 +39,22 @@ describe('useAccessStore', () => { | |||
|       name: 'New', | ||||
|       path: '/new', | ||||
|     }; | ||||
|     store.addTab(newTab); | ||||
|     expect(store.tabs).toContainEqual(newTab); | ||||
|     const addNewTab = store.addTab(newTab); | ||||
|     expect(store.tabs).toContainEqual(addNewTab); | ||||
|   }); | ||||
| 
 | ||||
|   it('updates an existing tab instead of adding a new one', () => { | ||||
|     const store = useTabbarStore(); | ||||
|     const initialTab: any = { | ||||
|       fullPath: '/existing', | ||||
|       meta: {}, | ||||
|       meta: { | ||||
|         fullPathKey: false, | ||||
|       }, | ||||
|       name: 'Existing', | ||||
|       path: '/existing', | ||||
|       query: {}, | ||||
|     }; | ||||
|     store.tabs.push(initialTab); | ||||
|     store.addTab(initialTab); | ||||
|     const updatedTab = { ...initialTab, query: { id: '1' } }; | ||||
|     store.addTab(updatedTab); | ||||
|     expect(store.tabs.length).toBe(1); | ||||
|  | @ -60,9 +63,12 @@ describe('useAccessStore', () => { | |||
| 
 | ||||
|   it('closes all tabs', async () => { | ||||
|     const store = useTabbarStore(); | ||||
|     store.tabs = [ | ||||
|       { fullPath: '/home', meta: {}, name: 'Home', path: '/home' }, | ||||
|     ] as any; | ||||
|     store.addTab({ | ||||
|       fullPath: '/home', | ||||
|       meta: {}, | ||||
|       name: 'Home', | ||||
|       path: '/home', | ||||
|     } as any); | ||||
|     router.replace = vi.fn(); | ||||
| 
 | ||||
|     await store.closeAllTabs(router); | ||||
|  | @ -157,7 +163,7 @@ describe('useAccessStore', () => { | |||
|       path: '/contact', | ||||
|     } as any); | ||||
| 
 | ||||
|     await store._bulkCloseByPaths(['/home', '/contact']); | ||||
|     await store._bulkCloseByKeys(['/home', '/contact']); | ||||
| 
 | ||||
|     expect(store.tabs).toHaveLength(1); | ||||
|     expect(store.tabs[0]?.name).toBe('About'); | ||||
|  | @ -183,9 +189,8 @@ describe('useAccessStore', () => { | |||
|       name: 'Contact', | ||||
|       path: '/contact', | ||||
|     }; | ||||
|     store.addTab(targetTab); | ||||
| 
 | ||||
|     await store.closeLeftTabs(targetTab); | ||||
|     const addTargetTab = store.addTab(targetTab); | ||||
|     await store.closeLeftTabs(addTargetTab); | ||||
| 
 | ||||
|     expect(store.tabs).toHaveLength(1); | ||||
|     expect(store.tabs[0]?.name).toBe('Contact'); | ||||
|  | @ -205,7 +210,7 @@ describe('useAccessStore', () => { | |||
|       name: 'About', | ||||
|       path: '/about', | ||||
|     }; | ||||
|     store.addTab(targetTab); | ||||
|     const addTargetTab = store.addTab(targetTab); | ||||
|     store.addTab({ | ||||
|       fullPath: '/contact', | ||||
|       meta: {}, | ||||
|  | @ -213,7 +218,7 @@ describe('useAccessStore', () => { | |||
|       path: '/contact', | ||||
|     } as any); | ||||
| 
 | ||||
|     await store.closeOtherTabs(targetTab); | ||||
|     await store.closeOtherTabs(addTargetTab); | ||||
| 
 | ||||
|     expect(store.tabs).toHaveLength(1); | ||||
|     expect(store.tabs[0]?.name).toBe('About'); | ||||
|  | @ -227,7 +232,7 @@ describe('useAccessStore', () => { | |||
|       name: 'Home', | ||||
|       path: '/home', | ||||
|     }; | ||||
|     store.addTab(targetTab); | ||||
|     const addTargetTab = store.addTab(targetTab); | ||||
|     store.addTab({ | ||||
|       fullPath: '/about', | ||||
|       meta: {}, | ||||
|  | @ -241,7 +246,7 @@ describe('useAccessStore', () => { | |||
|       path: '/contact', | ||||
|     } as any); | ||||
| 
 | ||||
|     await store.closeRightTabs(targetTab); | ||||
|     await store.closeRightTabs(addTargetTab); | ||||
| 
 | ||||
|     expect(store.tabs).toHaveLength(1); | ||||
|     expect(store.tabs[0]?.name).toBe('Home'); | ||||
|  |  | |||
|  | @ -1,4 +1,9 @@ | |||
| import type { Router, RouteRecordNormalized } from 'vue-router'; | ||||
| import type { ComputedRef } from 'vue'; | ||||
| import type { | ||||
|   RouteLocationNormalized, | ||||
|   Router, | ||||
|   RouteRecordNormalized, | ||||
| } from 'vue-router'; | ||||
| 
 | ||||
| import type { TabDefinition } from '@vben-core/typings'; | ||||
| 
 | ||||
|  | @ -52,23 +57,23 @@ export const useTabbarStore = defineStore('core-tabbar', { | |||
|     /** | ||||
|      * Close tabs in bulk | ||||
|      */ | ||||
|     async _bulkCloseByPaths(paths: string[]) { | ||||
|       this.tabs = this.tabs.filter((item) => { | ||||
|         return !paths.includes(getTabPath(item)); | ||||
|       }); | ||||
|     async _bulkCloseByKeys(keys: string[]) { | ||||
|       const keySet = new Set(keys); | ||||
|       this.tabs = this.tabs.filter( | ||||
|         (item) => !keySet.has(getTabKeyFromTab(item)), | ||||
|       ); | ||||
| 
 | ||||
|       this.updateCacheTabs(); | ||||
|       await this.updateCacheTabs(); | ||||
|     }, | ||||
|     /** | ||||
|      * @zh_CN 关闭标签页 | ||||
|      * @param tab | ||||
|      */ | ||||
|     _close(tab: TabDefinition) { | ||||
|       const { fullPath } = tab; | ||||
|       if (isAffixTab(tab)) { | ||||
|         return; | ||||
|       } | ||||
|       const index = this.tabs.findIndex((item) => item.fullPath === fullPath); | ||||
|       const index = this.tabs.findIndex((item) => equalTab(item, tab)); | ||||
|       index !== -1 && this.tabs.splice(index, 1); | ||||
|     }, | ||||
|     /** | ||||
|  | @ -101,14 +106,17 @@ export const useTabbarStore = defineStore('core-tabbar', { | |||
|      * @zh_CN 添加标签页 | ||||
|      * @param routeTab | ||||
|      */ | ||||
|     addTab(routeTab: TabDefinition) { | ||||
|       const tab = cloneTab(routeTab); | ||||
|     addTab(routeTab: TabDefinition): TabDefinition { | ||||
|       let tab = cloneTab(routeTab); | ||||
|       if (!tab.key) { | ||||
|         tab.key = getTabKey(routeTab); | ||||
|       } | ||||
|       if (!isTabShown(tab)) { | ||||
|         return; | ||||
|         return tab; | ||||
|       } | ||||
| 
 | ||||
|       const tabIndex = this.tabs.findIndex((tab) => { | ||||
|         return getTabPath(tab) === getTabPath(routeTab); | ||||
|       const tabIndex = this.tabs.findIndex((item) => { | ||||
|         return equalTab(item, tab); | ||||
|       }); | ||||
| 
 | ||||
|       if (tabIndex === -1) { | ||||
|  | @ -154,10 +162,11 @@ export const useTabbarStore = defineStore('core-tabbar', { | |||
|             mergedTab.meta.newTabTitle = curMeta.newTabTitle; | ||||
|           } | ||||
|         } | ||||
| 
 | ||||
|         tab = mergedTab; | ||||
|         this.tabs.splice(tabIndex, 1, mergedTab); | ||||
|       } | ||||
|       this.updateCacheTabs(); | ||||
|       return tab; | ||||
|     }, | ||||
|     /** | ||||
|      * @zh_CN 关闭所有标签页 | ||||
|  | @ -173,65 +182,63 @@ export const useTabbarStore = defineStore('core-tabbar', { | |||
|      * @param tab | ||||
|      */ | ||||
|     async closeLeftTabs(tab: TabDefinition) { | ||||
|       const index = this.tabs.findIndex( | ||||
|         (item) => getTabPath(item) === getTabPath(tab), | ||||
|       ); | ||||
|       const index = this.tabs.findIndex((item) => equalTab(item, tab)); | ||||
| 
 | ||||
|       if (index < 1) { | ||||
|         return; | ||||
|       } | ||||
| 
 | ||||
|       const leftTabs = this.tabs.slice(0, index); | ||||
|       const paths: string[] = []; | ||||
|       const keys: string[] = []; | ||||
| 
 | ||||
|       for (const item of leftTabs) { | ||||
|         if (!isAffixTab(item)) { | ||||
|           paths.push(getTabPath(item)); | ||||
|           keys.push(item.key as string); | ||||
|         } | ||||
|       } | ||||
|       await this._bulkCloseByPaths(paths); | ||||
|       await this._bulkCloseByKeys(keys); | ||||
|     }, | ||||
|     /** | ||||
|      * @zh_CN 关闭其他标签页 | ||||
|      * @param tab | ||||
|      */ | ||||
|     async closeOtherTabs(tab: TabDefinition) { | ||||
|       const closePaths = this.tabs.map((item) => getTabPath(item)); | ||||
|       const closeKeys = this.tabs.map((item) => getTabKeyFromTab(item)); | ||||
| 
 | ||||
|       const paths: string[] = []; | ||||
|       const keys: string[] = []; | ||||
| 
 | ||||
|       for (const path of closePaths) { | ||||
|         if (path !== tab.fullPath) { | ||||
|           const closeTab = this.tabs.find((item) => getTabPath(item) === path); | ||||
|       for (const key of closeKeys) { | ||||
|         if (key !== tab.key) { | ||||
|           const closeTab = this.tabs.find( | ||||
|             (item) => getTabKeyFromTab(item) === key, | ||||
|           ); | ||||
|           if (!closeTab) { | ||||
|             continue; | ||||
|           } | ||||
|           if (!isAffixTab(closeTab)) { | ||||
|             paths.push(getTabPath(closeTab)); | ||||
|             keys.push(closeTab.key as string); | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|       await this._bulkCloseByPaths(paths); | ||||
|       await this._bulkCloseByKeys(keys); | ||||
|     }, | ||||
|     /** | ||||
|      * @zh_CN 关闭右侧标签页 | ||||
|      * @param tab | ||||
|      */ | ||||
|     async closeRightTabs(tab: TabDefinition) { | ||||
|       const index = this.tabs.findIndex( | ||||
|         (item) => getTabPath(item) === getTabPath(tab), | ||||
|       ); | ||||
|       const index = this.tabs.findIndex((item) => equalTab(item, tab)); | ||||
| 
 | ||||
|       if (index !== -1 && index < this.tabs.length - 1) { | ||||
|         const rightTabs = this.tabs.slice(index + 1); | ||||
| 
 | ||||
|         const paths: string[] = []; | ||||
|         const keys: string[] = []; | ||||
|         for (const item of rightTabs) { | ||||
|           if (!isAffixTab(item)) { | ||||
|             paths.push(getTabPath(item)); | ||||
|             keys.push(item.key as string); | ||||
|           } | ||||
|         } | ||||
|         await this._bulkCloseByPaths(paths); | ||||
|         await this._bulkCloseByKeys(keys); | ||||
|       } | ||||
|     }, | ||||
| 
 | ||||
|  | @ -242,15 +249,14 @@ export const useTabbarStore = defineStore('core-tabbar', { | |||
|      */ | ||||
|     async closeTab(tab: TabDefinition, router: Router) { | ||||
|       const { currentRoute } = router; | ||||
| 
 | ||||
|       // 关闭不是激活选项卡
 | ||||
|       if (getTabPath(currentRoute.value) !== getTabPath(tab)) { | ||||
|       if (getTabKey(currentRoute.value) !== getTabKeyFromTab(tab)) { | ||||
|         this._close(tab); | ||||
|         this.updateCacheTabs(); | ||||
|         return; | ||||
|       } | ||||
|       const index = this.getTabs.findIndex( | ||||
|         (item) => getTabPath(item) === getTabPath(currentRoute.value), | ||||
|         (item) => getTabKeyFromTab(item) === getTabKey(currentRoute.value), | ||||
|       ); | ||||
| 
 | ||||
|       const before = this.getTabs[index - 1]; | ||||
|  | @ -277,7 +283,7 @@ export const useTabbarStore = defineStore('core-tabbar', { | |||
|     async closeTabByKey(key: string, router: Router) { | ||||
|       const originKey = decodeURIComponent(key); | ||||
|       const index = this.tabs.findIndex( | ||||
|         (item) => getTabPath(item) === originKey, | ||||
|         (item) => getTabKeyFromTab(item) === originKey, | ||||
|       ); | ||||
|       if (index === -1) { | ||||
|         return; | ||||
|  | @ -290,12 +296,12 @@ export const useTabbarStore = defineStore('core-tabbar', { | |||
|     }, | ||||
| 
 | ||||
|     /** | ||||
|      * 根据路径获取标签页 | ||||
|      * @param path | ||||
|      * 根据tab的key获取tab | ||||
|      * @param key | ||||
|      */ | ||||
|     getTabByPath(path: string) { | ||||
|     getTabByKey(key: string) { | ||||
|       return this.getTabs.find( | ||||
|         (item) => getTabPath(item) === path, | ||||
|         (item) => getTabKeyFromTab(item) === key, | ||||
|       ) as TabDefinition; | ||||
|     }, | ||||
|     /** | ||||
|  | @ -311,22 +317,19 @@ export const useTabbarStore = defineStore('core-tabbar', { | |||
|      * @param tab | ||||
|      */ | ||||
|     async pinTab(tab: TabDefinition) { | ||||
|       const index = this.tabs.findIndex( | ||||
|         (item) => getTabPath(item) === getTabPath(tab), | ||||
|       ); | ||||
|       if (index !== -1) { | ||||
|         const oldTab = this.tabs[index]; | ||||
|         tab.meta.affixTab = true; | ||||
|         tab.meta.title = oldTab?.meta?.title as string; | ||||
|         // this.addTab(tab);
 | ||||
|         this.tabs.splice(index, 1, tab); | ||||
|       const index = this.tabs.findIndex((item) => equalTab(item, tab)); | ||||
|       if (index === -1) { | ||||
|         return; | ||||
|       } | ||||
|       const oldTab = this.tabs[index]; | ||||
|       tab.meta.affixTab = true; | ||||
|       tab.meta.title = oldTab?.meta?.title as string; | ||||
|       // this.addTab(tab);
 | ||||
|       this.tabs.splice(index, 1, tab); | ||||
|       // 过滤固定tabs,后面更改affixTabOrder的值的话可能会有问题,目前行464排序affixTabs没有设置值
 | ||||
|       const affixTabs = this.tabs.filter((tab) => isAffixTab(tab)); | ||||
|       // 获得固定tabs的index
 | ||||
|       const newIndex = affixTabs.findIndex( | ||||
|         (item) => getTabPath(item) === getTabPath(tab), | ||||
|       ); | ||||
|       const newIndex = affixTabs.findIndex((item) => equalTab(item, tab)); | ||||
|       // 交换位置重新排序
 | ||||
|       await this.sortTabs(index, newIndex); | ||||
|     }, | ||||
|  | @ -371,9 +374,7 @@ export const useTabbarStore = defineStore('core-tabbar', { | |||
|       if (tab?.meta?.newTabTitle) { | ||||
|         return; | ||||
|       } | ||||
|       const findTab = this.tabs.find( | ||||
|         (item) => getTabPath(item) === getTabPath(tab), | ||||
|       ); | ||||
|       const findTab = this.tabs.find((item) => equalTab(item, tab)); | ||||
|       if (findTab) { | ||||
|         findTab.meta.newTabTitle = undefined; | ||||
|         await this.updateCacheTabs(); | ||||
|  | @ -401,13 +402,24 @@ export const useTabbarStore = defineStore('core-tabbar', { | |||
| 
 | ||||
|     /** | ||||
|      * @zh_CN 设置标签页标题 | ||||
|      * @param tab | ||||
|      * @param title | ||||
|      * | ||||
|      * @zh_CN 支持设置静态标题字符串或计算属性作为动态标题 | ||||
|      * @zh_CN 当标题为计算属性时,标题会随计算属性值变化而自动更新 | ||||
|      * @zh_CN 适用于需要根据状态或多语言动态更新标题的场景 | ||||
|      * | ||||
|      * @param {TabDefinition} tab - 标签页对象 | ||||
|      * @param {ComputedRef<string> | string} title - 标题内容,支持静态字符串或计算属性 | ||||
|      * | ||||
|      * @example | ||||
|      * // 设置静态标题
 | ||||
|      * setTabTitle(tab, '新标签页'); | ||||
|      * | ||||
|      * @example | ||||
|      * // 设置动态标题
 | ||||
|      * setTabTitle(tab, computed(() => t('common.dashboard'))); | ||||
|      */ | ||||
|     async setTabTitle(tab: TabDefinition, title: string) { | ||||
|       const findTab = this.tabs.find( | ||||
|         (item) => getTabPath(item) === getTabPath(tab), | ||||
|       ); | ||||
|     async setTabTitle(tab: TabDefinition, title: ComputedRef<string> | string) { | ||||
|       const findTab = this.tabs.find((item) => equalTab(item, tab)); | ||||
| 
 | ||||
|       if (findTab) { | ||||
|         findTab.meta.newTabTitle = title; | ||||
|  | @ -448,17 +460,15 @@ export const useTabbarStore = defineStore('core-tabbar', { | |||
|      * @param tab | ||||
|      */ | ||||
|     async unpinTab(tab: TabDefinition) { | ||||
|       const index = this.tabs.findIndex( | ||||
|         (item) => getTabPath(item) === getTabPath(tab), | ||||
|       ); | ||||
| 
 | ||||
|       if (index !== -1) { | ||||
|         const oldTab = this.tabs[index]; | ||||
|         tab.meta.affixTab = false; | ||||
|         tab.meta.title = oldTab?.meta?.title as string; | ||||
|         // this.addTab(tab);
 | ||||
|         this.tabs.splice(index, 1, tab); | ||||
|       const index = this.tabs.findIndex((item) => equalTab(item, tab)); | ||||
|       if (index === -1) { | ||||
|         return; | ||||
|       } | ||||
|       const oldTab = this.tabs[index]; | ||||
|       tab.meta.affixTab = false; | ||||
|       tab.meta.title = oldTab?.meta?.title as string; | ||||
|       // this.addTab(tab);
 | ||||
|       this.tabs.splice(index, 1, tab); | ||||
|       // 过滤固定tabs,后面更改affixTabOrder的值的话可能会有问题,目前行464排序affixTabs没有设置值
 | ||||
|       const affixTabs = this.tabs.filter((tab) => isAffixTab(tab)); | ||||
|       // 获得固定tabs的index,使用固定tabs的下一个位置也就是活动tabs的第一个位置
 | ||||
|  | @ -591,11 +601,49 @@ function isTabShown(tab: TabDefinition) { | |||
| } | ||||
| 
 | ||||
| /** | ||||
|  * @zh_CN 获取标签页路径 | ||||
|  * 从route获取tab页的key | ||||
|  * @param tab | ||||
|  */ | ||||
| function getTabPath(tab: RouteRecordNormalized | TabDefinition) { | ||||
|   return decodeURIComponent((tab as TabDefinition).fullPath || tab.path); | ||||
| function getTabKey(tab: RouteLocationNormalized | RouteRecordNormalized) { | ||||
|   const { | ||||
|     fullPath, | ||||
|     path, | ||||
|     meta: { fullPathKey } = {}, | ||||
|     query = {}, | ||||
|   } = tab as RouteLocationNormalized; | ||||
|   // pageKey可能是数组(查询参数重复时可能出现)
 | ||||
|   const pageKey = Array.isArray(query.pageKey) | ||||
|     ? query.pageKey[0] | ||||
|     : query.pageKey; | ||||
|   let rawKey; | ||||
|   if (pageKey) { | ||||
|     rawKey = pageKey; | ||||
|   } else { | ||||
|     rawKey = fullPathKey === false ? path : (fullPath ?? path); | ||||
|   } | ||||
|   try { | ||||
|     return decodeURIComponent(rawKey); | ||||
|   } catch { | ||||
|     return rawKey; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * 从tab获取tab页的key | ||||
|  * 如果tab没有key,那么就从route获取key | ||||
|  * @param tab | ||||
|  */ | ||||
| function getTabKeyFromTab(tab: TabDefinition): string { | ||||
|   return tab.key ?? getTabKey(tab); | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * 比较两个tab是否相等 | ||||
|  * @param a | ||||
|  * @param b | ||||
|  */ | ||||
| function equalTab(a: TabDefinition, b: TabDefinition) { | ||||
|   return getTabKeyFromTab(a) === getTabKeyFromTab(b); | ||||
| } | ||||
| 
 | ||||
| function routeToTab(route: RouteRecordNormalized) { | ||||
|  | @ -603,5 +651,8 @@ function routeToTab(route: RouteRecordNormalized) { | |||
|     meta: route.meta, | ||||
|     name: route.name, | ||||
|     path: route.path, | ||||
|     key: getTabKey(route), | ||||
|   } as TabDefinition; | ||||
| } | ||||
| 
 | ||||
| export { getTabKey }; | ||||
|  |  | |||
|  | @ -19,7 +19,7 @@ const checkValue = ref(['a', 'b']); | |||
| 
 | ||||
| const options = [ | ||||
|   { label: '选项1', value: 'a' }, | ||||
|   { label: '选项2', value: 'b' }, | ||||
|   { label: '选项2', value: 'b', num: 999 }, | ||||
|   { label: '选项3', value: 'c' }, | ||||
|   { label: '选项4', value: 'd' }, | ||||
|   { label: '选项5', value: 'e' }, | ||||
|  | @ -168,10 +168,11 @@ function onBtnClick(value: any) { | |||
|           :options="options" | ||||
|           v-bind="compProps" | ||||
|         > | ||||
|           <template #option="{ label, value }"> | ||||
|           <template #option="{ label, value, data }"> | ||||
|             <div class="flex items-center"> | ||||
|               <span>{{ label }}</span> | ||||
|               <span class="ml-2 text-gray-400">{{ value }}</span> | ||||
|               <span v-if="data.num" class="white ml-2">{{ data.num }}</span> | ||||
|             </div> | ||||
|           </template> | ||||
|         </VbenCheckButtonGroup> | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue
	
	 xingyu4j
						xingyu4j