13 changed files with 1590 additions and 0 deletions
@ -0,0 +1,32 @@ |
|||||
|
{ |
||||
|
"name": "@vben/stores", |
||||
|
"version": "5.6.0", |
||||
|
"homepage": "https://github.com/vbenjs/vue-vben-admin", |
||||
|
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues", |
||||
|
"repository": { |
||||
|
"type": "git", |
||||
|
"url": "git+https://github.com/vbenjs/vue-vben-admin.git", |
||||
|
"directory": "packages/stores" |
||||
|
}, |
||||
|
"license": "MIT", |
||||
|
"type": "module", |
||||
|
"sideEffects": [ |
||||
|
"**/*.css" |
||||
|
], |
||||
|
"exports": { |
||||
|
".": { |
||||
|
"types": "./src/index.ts", |
||||
|
"default": "./src/index.ts" |
||||
|
} |
||||
|
}, |
||||
|
"dependencies": { |
||||
|
"@vben-core/preferences": "workspace:*", |
||||
|
"@vben-core/shared": "workspace:*", |
||||
|
"@vben-core/typings": "workspace:*", |
||||
|
"pinia": "catalog:", |
||||
|
"pinia-plugin-persistedstate": "catalog:", |
||||
|
"secure-ls": "catalog:", |
||||
|
"vue": "catalog:", |
||||
|
"vue-router": "catalog:" |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,9 @@ |
|||||
|
// https://github.com/vuejs/pinia/issues/2098
|
||||
|
declare module 'pinia' { |
||||
|
export function acceptHMRUpdate( |
||||
|
initialUseStore: any | StoreDefinition, |
||||
|
hot: any, |
||||
|
): (newModule: any) => any; |
||||
|
} |
||||
|
|
||||
|
export { acceptHMRUpdate }; |
||||
@ -0,0 +1,3 @@ |
|||||
|
export * from './modules'; |
||||
|
export * from './setup'; |
||||
|
export { defineStore, storeToRefs } from 'pinia'; |
||||
@ -0,0 +1,46 @@ |
|||||
|
import { createPinia, setActivePinia } from 'pinia'; |
||||
|
import { beforeEach, describe, expect, it } from 'vitest'; |
||||
|
|
||||
|
import { useAccessStore } from './access'; |
||||
|
|
||||
|
describe('useAccessStore', () => { |
||||
|
beforeEach(() => { |
||||
|
setActivePinia(createPinia()); |
||||
|
}); |
||||
|
|
||||
|
it('updates accessMenus state', () => { |
||||
|
const store = useAccessStore(); |
||||
|
expect(store.accessMenus).toEqual([]); |
||||
|
store.setAccessMenus([{ name: 'Dashboard', path: '/dashboard' }]); |
||||
|
expect(store.accessMenus).toEqual([ |
||||
|
{ name: 'Dashboard', path: '/dashboard' }, |
||||
|
]); |
||||
|
}); |
||||
|
|
||||
|
it('updates accessToken state correctly', () => { |
||||
|
const store = useAccessStore(); |
||||
|
expect(store.accessToken).toBeNull(); // 初始状态
|
||||
|
store.setAccessToken('abc123'); |
||||
|
expect(store.accessToken).toBe('abc123'); |
||||
|
}); |
||||
|
|
||||
|
it('returns the correct accessToken', () => { |
||||
|
const store = useAccessStore(); |
||||
|
store.setAccessToken('xyz789'); |
||||
|
expect(store.accessToken).toBe('xyz789'); |
||||
|
}); |
||||
|
|
||||
|
// 测试设置空的访问菜单列表
|
||||
|
it('handles empty accessMenus correctly', () => { |
||||
|
const store = useAccessStore(); |
||||
|
store.setAccessMenus([]); |
||||
|
expect(store.accessMenus).toEqual([]); |
||||
|
}); |
||||
|
|
||||
|
// 测试设置空的访问路由列表
|
||||
|
it('handles empty accessRoutes correctly', () => { |
||||
|
const store = useAccessStore(); |
||||
|
store.setAccessRoutes([]); |
||||
|
expect(store.accessRoutes).toEqual([]); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,129 @@ |
|||||
|
import type { RouteRecordRaw } from 'vue-router'; |
||||
|
|
||||
|
import type { MenuRecordRaw } from '@vben-core/typings'; |
||||
|
|
||||
|
import { acceptHMRUpdate, defineStore } from 'pinia'; |
||||
|
|
||||
|
type AccessToken = null | string; |
||||
|
|
||||
|
interface AccessState { |
||||
|
/** |
||||
|
* 权限码 |
||||
|
*/ |
||||
|
accessCodes: string[]; |
||||
|
/** |
||||
|
* 可访问的菜单列表 |
||||
|
*/ |
||||
|
accessMenus: MenuRecordRaw[]; |
||||
|
/** |
||||
|
* 可访问的路由列表 |
||||
|
*/ |
||||
|
accessRoutes: RouteRecordRaw[]; |
||||
|
/** |
||||
|
* 登录 accessToken |
||||
|
*/ |
||||
|
accessToken: AccessToken; |
||||
|
/** |
||||
|
* 是否已经检查过权限 |
||||
|
*/ |
||||
|
isAccessChecked: boolean; |
||||
|
/** |
||||
|
* 是否锁屏状态 |
||||
|
*/ |
||||
|
isLockScreen: boolean; |
||||
|
/** |
||||
|
* 锁屏密码 |
||||
|
*/ |
||||
|
lockScreenPassword?: string; |
||||
|
/** |
||||
|
* 登录是否过期 |
||||
|
*/ |
||||
|
loginExpired: boolean; |
||||
|
/** |
||||
|
* 登录 accessToken |
||||
|
*/ |
||||
|
refreshToken: AccessToken; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* @zh_CN 访问权限相关 |
||||
|
*/ |
||||
|
export const useAccessStore = defineStore('core-access', { |
||||
|
actions: { |
||||
|
getMenuByPath(path: string) { |
||||
|
function findMenu( |
||||
|
menus: MenuRecordRaw[], |
||||
|
path: string, |
||||
|
): MenuRecordRaw | undefined { |
||||
|
for (const menu of menus) { |
||||
|
if (menu.path === path) { |
||||
|
return menu; |
||||
|
} |
||||
|
if (menu.children) { |
||||
|
const matched = findMenu(menu.children, path); |
||||
|
if (matched) { |
||||
|
return matched; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
return findMenu(this.accessMenus, path); |
||||
|
}, |
||||
|
lockScreen(password: string) { |
||||
|
this.isLockScreen = true; |
||||
|
this.lockScreenPassword = password; |
||||
|
}, |
||||
|
setAccessCodes(codes: string[]) { |
||||
|
this.accessCodes = codes; |
||||
|
}, |
||||
|
setAccessMenus(menus: MenuRecordRaw[]) { |
||||
|
this.accessMenus = menus; |
||||
|
}, |
||||
|
setAccessRoutes(routes: RouteRecordRaw[]) { |
||||
|
this.accessRoutes = routes; |
||||
|
}, |
||||
|
setAccessToken(token: AccessToken) { |
||||
|
this.accessToken = token; |
||||
|
}, |
||||
|
setIsAccessChecked(isAccessChecked: boolean) { |
||||
|
this.isAccessChecked = isAccessChecked; |
||||
|
}, |
||||
|
setLoginExpired(loginExpired: boolean) { |
||||
|
this.loginExpired = loginExpired; |
||||
|
}, |
||||
|
setRefreshToken(token: AccessToken) { |
||||
|
this.refreshToken = token; |
||||
|
}, |
||||
|
unlockScreen() { |
||||
|
this.isLockScreen = false; |
||||
|
this.lockScreenPassword = undefined; |
||||
|
}, |
||||
|
}, |
||||
|
persist: { |
||||
|
// 持久化
|
||||
|
pick: [ |
||||
|
'accessToken', |
||||
|
'refreshToken', |
||||
|
'accessCodes', |
||||
|
'isLockScreen', |
||||
|
'lockScreenPassword', |
||||
|
], |
||||
|
}, |
||||
|
state: (): AccessState => ({ |
||||
|
accessCodes: [], |
||||
|
accessMenus: [], |
||||
|
accessRoutes: [], |
||||
|
accessToken: null, |
||||
|
isAccessChecked: false, |
||||
|
isLockScreen: false, |
||||
|
lockScreenPassword: undefined, |
||||
|
loginExpired: false, |
||||
|
refreshToken: null, |
||||
|
}), |
||||
|
}); |
||||
|
|
||||
|
// 解决热更新问题
|
||||
|
const hot = import.meta.hot; |
||||
|
if (hot) { |
||||
|
hot.accept(acceptHMRUpdate(useAccessStore, hot)); |
||||
|
} |
||||
@ -0,0 +1,4 @@ |
|||||
|
export * from './access'; |
||||
|
export * from './tabbar'; |
||||
|
export * from './timezone'; |
||||
|
export * from './user'; |
||||
@ -0,0 +1,300 @@ |
|||||
|
import { createRouter, createWebHistory } from 'vue-router'; |
||||
|
|
||||
|
import { createPinia, setActivePinia } from 'pinia'; |
||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'; |
||||
|
|
||||
|
import { useTabbarStore } from './tabbar'; |
||||
|
|
||||
|
describe('useAccessStore', () => { |
||||
|
const router = createRouter({ |
||||
|
history: createWebHistory(), |
||||
|
routes: [], |
||||
|
}); |
||||
|
router.push = vi.fn(); |
||||
|
router.replace = vi.fn(); |
||||
|
beforeEach(() => { |
||||
|
setActivePinia(createPinia()); |
||||
|
vi.clearAllMocks(); |
||||
|
}); |
||||
|
|
||||
|
it('adds a new tab', () => { |
||||
|
const store = useTabbarStore(); |
||||
|
const tab: any = { |
||||
|
fullPath: '/home', |
||||
|
meta: {}, |
||||
|
key: '/home', |
||||
|
name: 'Home', |
||||
|
path: '/home', |
||||
|
}; |
||||
|
const addNewTab = store.addTab(tab); |
||||
|
expect(store.tabs.length).toBe(1); |
||||
|
expect(store.tabs[0]).toEqual(addNewTab); |
||||
|
}); |
||||
|
|
||||
|
it('adds a new tab if it does not exist', () => { |
||||
|
const store = useTabbarStore(); |
||||
|
const newTab: any = { |
||||
|
fullPath: '/new', |
||||
|
meta: {}, |
||||
|
name: 'New', |
||||
|
path: '/new', |
||||
|
}; |
||||
|
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: { |
||||
|
fullPathKey: false, |
||||
|
}, |
||||
|
name: 'Existing', |
||||
|
path: '/existing', |
||||
|
query: {}, |
||||
|
}; |
||||
|
store.addTab(initialTab); |
||||
|
const updatedTab = { ...initialTab, query: { id: '1' } }; |
||||
|
store.addTab(updatedTab); |
||||
|
expect(store.tabs.length).toBe(1); |
||||
|
expect(store.tabs[0]?.query).toEqual({ id: '1' }); |
||||
|
}); |
||||
|
|
||||
|
it('closes all tabs', async () => { |
||||
|
const store = useTabbarStore(); |
||||
|
store.addTab({ |
||||
|
fullPath: '/home', |
||||
|
meta: {}, |
||||
|
name: 'Home', |
||||
|
path: '/home', |
||||
|
} as any); |
||||
|
router.replace = vi.fn(); |
||||
|
|
||||
|
await store.closeAllTabs(router); |
||||
|
|
||||
|
expect(store.tabs.length).toBe(1); |
||||
|
}); |
||||
|
|
||||
|
it('closes a non-affix tab', () => { |
||||
|
const store = useTabbarStore(); |
||||
|
const tab: any = { |
||||
|
fullPath: '/closable', |
||||
|
meta: {}, |
||||
|
name: 'Closable', |
||||
|
path: '/closable', |
||||
|
}; |
||||
|
store.tabs.push(tab); |
||||
|
store._close(tab); |
||||
|
expect(store.tabs.length).toBe(0); |
||||
|
}); |
||||
|
|
||||
|
it('does not close an affix tab', () => { |
||||
|
const store = useTabbarStore(); |
||||
|
const affixTab: any = { |
||||
|
fullPath: '/affix', |
||||
|
meta: { affixTab: true }, |
||||
|
name: 'Affix', |
||||
|
path: '/affix', |
||||
|
}; |
||||
|
store.tabs.push(affixTab); |
||||
|
store._close(affixTab); |
||||
|
expect(store.tabs.length).toBe(1); // Affix tab should not be closed
|
||||
|
}); |
||||
|
|
||||
|
it('returns all cache tabs', () => { |
||||
|
const store = useTabbarStore(); |
||||
|
store.cachedTabs.add('Home'); |
||||
|
store.cachedTabs.add('About'); |
||||
|
expect(store.getCachedTabs).toEqual(['Home', 'About']); |
||||
|
}); |
||||
|
|
||||
|
it('returns all tabs, including affix tabs', () => { |
||||
|
const store = useTabbarStore(); |
||||
|
const normalTab: any = { |
||||
|
fullPath: '/normal', |
||||
|
meta: {}, |
||||
|
name: 'Normal', |
||||
|
path: '/normal', |
||||
|
}; |
||||
|
const affixTab: any = { |
||||
|
fullPath: '/affix', |
||||
|
meta: { affixTab: true }, |
||||
|
name: 'Affix', |
||||
|
path: '/affix', |
||||
|
}; |
||||
|
store.tabs.push(normalTab); |
||||
|
store.affixTabs.push(affixTab); |
||||
|
expect(store.getTabs).toContainEqual(normalTab); |
||||
|
expect(store.affixTabs).toContainEqual(affixTab); |
||||
|
}); |
||||
|
|
||||
|
it('navigates to a specific tab', async () => { |
||||
|
const store = useTabbarStore(); |
||||
|
const tab: any = { meta: {}, name: 'Dashboard', path: '/dashboard' }; |
||||
|
|
||||
|
await store._goToTab(tab, router); |
||||
|
|
||||
|
expect(router.replace).toHaveBeenCalledWith({ |
||||
|
params: {}, |
||||
|
path: '/dashboard', |
||||
|
query: {}, |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it('closes multiple tabs by paths', async () => { |
||||
|
const store = useTabbarStore(); |
||||
|
store.addTab({ |
||||
|
fullPath: '/home', |
||||
|
meta: {}, |
||||
|
name: 'Home', |
||||
|
path: '/home', |
||||
|
} as any); |
||||
|
store.addTab({ |
||||
|
fullPath: '/about', |
||||
|
meta: {}, |
||||
|
name: 'About', |
||||
|
path: '/about', |
||||
|
} as any); |
||||
|
store.addTab({ |
||||
|
fullPath: '/contact', |
||||
|
meta: {}, |
||||
|
name: 'Contact', |
||||
|
path: '/contact', |
||||
|
} as any); |
||||
|
|
||||
|
await store._bulkCloseByKeys(['/home', '/contact']); |
||||
|
|
||||
|
expect(store.tabs).toHaveLength(1); |
||||
|
expect(store.tabs[0]?.name).toBe('About'); |
||||
|
}); |
||||
|
|
||||
|
it('closes all tabs to the left of the specified tab', async () => { |
||||
|
const store = useTabbarStore(); |
||||
|
store.addTab({ |
||||
|
fullPath: '/home', |
||||
|
meta: {}, |
||||
|
name: 'Home', |
||||
|
path: '/home', |
||||
|
} as any); |
||||
|
store.addTab({ |
||||
|
fullPath: '/about', |
||||
|
meta: {}, |
||||
|
name: 'About', |
||||
|
path: '/about', |
||||
|
} as any); |
||||
|
const targetTab: any = { |
||||
|
fullPath: '/contact', |
||||
|
meta: {}, |
||||
|
name: 'Contact', |
||||
|
path: '/contact', |
||||
|
}; |
||||
|
const addTargetTab = store.addTab(targetTab); |
||||
|
await store.closeLeftTabs(addTargetTab); |
||||
|
|
||||
|
expect(store.tabs).toHaveLength(1); |
||||
|
expect(store.tabs[0]?.name).toBe('Contact'); |
||||
|
}); |
||||
|
|
||||
|
it('closes all tabs except the specified tab', async () => { |
||||
|
const store = useTabbarStore(); |
||||
|
store.addTab({ |
||||
|
fullPath: '/home', |
||||
|
meta: {}, |
||||
|
name: 'Home', |
||||
|
path: '/home', |
||||
|
} as any); |
||||
|
const targetTab: any = { |
||||
|
fullPath: '/about', |
||||
|
meta: {}, |
||||
|
name: 'About', |
||||
|
path: '/about', |
||||
|
}; |
||||
|
const addTargetTab = store.addTab(targetTab); |
||||
|
store.addTab({ |
||||
|
fullPath: '/contact', |
||||
|
meta: {}, |
||||
|
name: 'Contact', |
||||
|
path: '/contact', |
||||
|
} as any); |
||||
|
|
||||
|
await store.closeOtherTabs(addTargetTab); |
||||
|
|
||||
|
expect(store.tabs).toHaveLength(1); |
||||
|
expect(store.tabs[0]?.name).toBe('About'); |
||||
|
}); |
||||
|
|
||||
|
it('closes all tabs to the right of the specified tab', async () => { |
||||
|
const store = useTabbarStore(); |
||||
|
const targetTab: any = { |
||||
|
fullPath: '/home', |
||||
|
meta: {}, |
||||
|
name: 'Home', |
||||
|
path: '/home', |
||||
|
}; |
||||
|
const addTargetTab = store.addTab(targetTab); |
||||
|
store.addTab({ |
||||
|
fullPath: '/about', |
||||
|
meta: {}, |
||||
|
name: 'About', |
||||
|
path: '/about', |
||||
|
} as any); |
||||
|
store.addTab({ |
||||
|
fullPath: '/contact', |
||||
|
meta: {}, |
||||
|
name: 'Contact', |
||||
|
path: '/contact', |
||||
|
} as any); |
||||
|
|
||||
|
await store.closeRightTabs(addTargetTab); |
||||
|
|
||||
|
expect(store.tabs).toHaveLength(1); |
||||
|
expect(store.tabs[0]?.name).toBe('Home'); |
||||
|
}); |
||||
|
|
||||
|
it('closes the tab with the specified key', async () => { |
||||
|
const store = useTabbarStore(); |
||||
|
const keyToClose = '/about'; |
||||
|
store.addTab({ |
||||
|
fullPath: '/home', |
||||
|
meta: {}, |
||||
|
name: 'Home', |
||||
|
path: '/home', |
||||
|
} as any); |
||||
|
store.addTab({ |
||||
|
fullPath: keyToClose, |
||||
|
meta: {}, |
||||
|
name: 'About', |
||||
|
path: '/about', |
||||
|
} as any); |
||||
|
store.addTab({ |
||||
|
fullPath: '/contact', |
||||
|
meta: {}, |
||||
|
name: 'Contact', |
||||
|
path: '/contact', |
||||
|
} as any); |
||||
|
|
||||
|
await store.closeTabByKey(keyToClose, router); |
||||
|
|
||||
|
expect(store.tabs).toHaveLength(2); |
||||
|
expect( |
||||
|
store.tabs.find((tab) => tab.fullPath === keyToClose), |
||||
|
).toBeUndefined(); |
||||
|
}); |
||||
|
|
||||
|
it('refreshes the current tab', async () => { |
||||
|
const store = useTabbarStore(); |
||||
|
const currentTab: any = { |
||||
|
fullPath: '/dashboard', |
||||
|
meta: { name: 'Dashboard' }, |
||||
|
name: 'Dashboard', |
||||
|
path: '/dashboard', |
||||
|
}; |
||||
|
router.currentRoute.value = currentTab; |
||||
|
|
||||
|
await store.refresh(router); |
||||
|
|
||||
|
expect(store.excludeCachedTabs.has('Dashboard')).toBe(false); |
||||
|
expect(store.renderRouteView).toBe(true); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,769 @@ |
|||||
|
import type { ComputedRef, VNode } from 'vue'; |
||||
|
import type { |
||||
|
RouteLocationNormalized, |
||||
|
RouteLocationNormalizedLoaded, |
||||
|
RouteLocationNormalizedLoadedGeneric, |
||||
|
Router, |
||||
|
RouteRecordNormalized, |
||||
|
} from 'vue-router'; |
||||
|
|
||||
|
import type { TabDefinition } from '@vben-core/typings'; |
||||
|
|
||||
|
import { markRaw, toRaw } from 'vue'; |
||||
|
|
||||
|
import { preferences } from '@vben-core/preferences'; |
||||
|
import { |
||||
|
createStack, |
||||
|
openRouteInNewWindow, |
||||
|
Stack, |
||||
|
startProgress, |
||||
|
stopProgress, |
||||
|
} from '@vben-core/shared/utils'; |
||||
|
|
||||
|
import { acceptHMRUpdate, defineStore } from 'pinia'; |
||||
|
|
||||
|
interface RouteCached { |
||||
|
component: VNode; |
||||
|
key: string; |
||||
|
route: RouteLocationNormalizedLoadedGeneric; |
||||
|
} |
||||
|
|
||||
|
interface TabbarState { |
||||
|
cachedRoutes: Map<string, RouteCached>; |
||||
|
/** |
||||
|
* @zh_CN 当前打开的标签页列表缓存 |
||||
|
*/ |
||||
|
cachedTabs: Set<string>; |
||||
|
/** |
||||
|
* @zh_CN 拖拽结束的索引 |
||||
|
*/ |
||||
|
dragEndIndex: number; |
||||
|
/** |
||||
|
* @zh_CN 需要排除缓存的标签页 |
||||
|
*/ |
||||
|
excludeCachedTabs: Set<string>; |
||||
|
/** |
||||
|
* @zh_CN 标签右键菜单列表 |
||||
|
*/ |
||||
|
menuList: string[]; |
||||
|
/** |
||||
|
* @zh_CN 是否刷新 |
||||
|
*/ |
||||
|
renderRouteView?: boolean; |
||||
|
/** |
||||
|
* @zh_CN 当前打开的标签页列表 |
||||
|
*/ |
||||
|
tabs: TabDefinition[]; |
||||
|
/** |
||||
|
* @zh_CN 更新时间,用于一些更新场景,使用watch深度监听的话,会损耗性能 |
||||
|
*/ |
||||
|
updateTime?: number; |
||||
|
/** |
||||
|
* @zh_CN 上一个标签页打开的标签 |
||||
|
*/ |
||||
|
visitHistory: Stack<string>; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* @zh_CN 访问历史记录最大数量 |
||||
|
*/ |
||||
|
const MAX_VISIT_HISTORY = 50; |
||||
|
|
||||
|
/** |
||||
|
* @zh_CN 访问权限相关 |
||||
|
*/ |
||||
|
export const useTabbarStore = defineStore('core-tabbar', { |
||||
|
actions: { |
||||
|
/** |
||||
|
* Close tabs in bulk |
||||
|
*/ |
||||
|
async _bulkCloseByKeys(keys: string[]) { |
||||
|
const keySet = new Set(keys); |
||||
|
this.tabs = this.tabs.filter( |
||||
|
(item) => !keySet.has(getTabKeyFromTab(item)), |
||||
|
); |
||||
|
if (isVisitHistory()) { |
||||
|
this.visitHistory.remove(...keys); |
||||
|
} |
||||
|
|
||||
|
await this.updateCacheTabs(); |
||||
|
}, |
||||
|
/** |
||||
|
* @zh_CN 关闭标签页 |
||||
|
* @param tab |
||||
|
*/ |
||||
|
_close(tab: TabDefinition) { |
||||
|
if (isAffixTab(tab)) { |
||||
|
return; |
||||
|
} |
||||
|
const index = this.tabs.findIndex((item) => equalTab(item, tab)); |
||||
|
index !== -1 && this.tabs.splice(index, 1); |
||||
|
}, |
||||
|
/** |
||||
|
* @zh_CN 跳转到默认标签页 |
||||
|
*/ |
||||
|
async _goToDefaultTab(router: Router) { |
||||
|
if (this.getTabs.length <= 0) { |
||||
|
return; |
||||
|
} |
||||
|
const firstTab = this.getTabs[0]; |
||||
|
if (firstTab) { |
||||
|
await this._goToTab(firstTab, router); |
||||
|
} |
||||
|
}, |
||||
|
/** |
||||
|
* @zh_CN 跳转到标签页 |
||||
|
* @param tab |
||||
|
* @param router |
||||
|
*/ |
||||
|
async _goToTab(tab: TabDefinition, router: Router) { |
||||
|
const { params, path, query } = tab; |
||||
|
const toParams = { |
||||
|
params: params || {}, |
||||
|
path, |
||||
|
query: query || {}, |
||||
|
}; |
||||
|
await router.replace(toParams); |
||||
|
}, |
||||
|
/** |
||||
|
* @zh_CN 添加标签页 |
||||
|
* @param routeTab |
||||
|
*/ |
||||
|
addTab(routeTab: TabDefinition): TabDefinition { |
||||
|
let tab = cloneTab(routeTab); |
||||
|
if (!tab.key) { |
||||
|
tab.key = getTabKey(routeTab); |
||||
|
} |
||||
|
if (!isTabShown(tab)) { |
||||
|
return tab; |
||||
|
} |
||||
|
|
||||
|
const tabIndex = this.tabs.findIndex((item) => { |
||||
|
return equalTab(item, tab); |
||||
|
}); |
||||
|
|
||||
|
if (tabIndex === -1) { |
||||
|
const maxCount = preferences.tabbar.maxCount; |
||||
|
// 获取动态路由打开数,超过 0 即代表需要控制打开数
|
||||
|
const maxNumOfOpenTab = (routeTab?.meta?.maxNumOfOpenTab ?? |
||||
|
-1) as number; |
||||
|
// 如果动态路由层级大于 0 了,那么就要限制该路由的打开数限制了
|
||||
|
// 获取到已经打开的动态路由数, 判断是否大于某一个值
|
||||
|
if ( |
||||
|
maxNumOfOpenTab > 0 && |
||||
|
this.tabs.filter((tab) => tab.name === routeTab.name).length >= |
||||
|
maxNumOfOpenTab |
||||
|
) { |
||||
|
// 关闭第一个
|
||||
|
const index = this.tabs.findIndex( |
||||
|
(item) => item.name === routeTab.name, |
||||
|
); |
||||
|
index !== -1 && this.tabs.splice(index, 1); |
||||
|
} else if (maxCount > 0 && this.tabs.length >= maxCount) { |
||||
|
// 关闭第一个
|
||||
|
const index = this.tabs.findIndex( |
||||
|
(item) => |
||||
|
!Reflect.has(item.meta, 'affixTab') || !item.meta.affixTab, |
||||
|
); |
||||
|
index !== -1 && this.tabs.splice(index, 1); |
||||
|
} |
||||
|
this.tabs.push(tab); |
||||
|
} else { |
||||
|
// 页面已经存在,不重复添加选项卡,只更新选项卡参数
|
||||
|
const currentTab = toRaw(this.tabs)[tabIndex]; |
||||
|
const mergedTab = { |
||||
|
...currentTab, |
||||
|
...tab, |
||||
|
meta: { ...currentTab?.meta, ...tab.meta }, |
||||
|
}; |
||||
|
if (currentTab) { |
||||
|
const curMeta = currentTab.meta; |
||||
|
if (Reflect.has(curMeta, 'affixTab')) { |
||||
|
mergedTab.meta.affixTab = curMeta.affixTab; |
||||
|
} |
||||
|
if (Reflect.has(curMeta, 'newTabTitle')) { |
||||
|
mergedTab.meta.newTabTitle = curMeta.newTabTitle; |
||||
|
} |
||||
|
} |
||||
|
tab = mergedTab; |
||||
|
this.tabs.splice(tabIndex, 1, mergedTab); |
||||
|
} |
||||
|
this.updateCacheTabs(); |
||||
|
// 添加访问历史记录
|
||||
|
if (isVisitHistory()) { |
||||
|
this.visitHistory.push(tab.key as string); |
||||
|
} |
||||
|
return tab; |
||||
|
}, |
||||
|
/** |
||||
|
* @zh_CN 关闭所有标签页 |
||||
|
*/ |
||||
|
async closeAllTabs(router: Router) { |
||||
|
const newTabs = this.tabs.filter((tab) => isAffixTab(tab)); |
||||
|
this.tabs = newTabs.length > 0 ? newTabs : [...this.tabs].splice(0, 1); |
||||
|
// 设置访问历史记录
|
||||
|
if (isVisitHistory()) { |
||||
|
this.visitHistory.retain( |
||||
|
this.tabs.map((item) => getTabKeyFromTab(item)), |
||||
|
); |
||||
|
} |
||||
|
await this._goToDefaultTab(router); |
||||
|
this.updateCacheTabs(); |
||||
|
}, |
||||
|
/** |
||||
|
* @zh_CN 关闭左侧标签页 |
||||
|
* @param tab |
||||
|
*/ |
||||
|
async closeLeftTabs(tab: TabDefinition) { |
||||
|
const index = this.tabs.findIndex((item) => equalTab(item, tab)); |
||||
|
|
||||
|
if (index < 1) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const leftTabs = this.tabs.slice(0, index); |
||||
|
const keys: string[] = []; |
||||
|
|
||||
|
for (const item of leftTabs) { |
||||
|
if (!isAffixTab(item)) { |
||||
|
keys.push(item.key as string); |
||||
|
} |
||||
|
} |
||||
|
await this._bulkCloseByKeys(keys); |
||||
|
}, |
||||
|
/** |
||||
|
* @zh_CN 关闭其他标签页 |
||||
|
* @param tab |
||||
|
*/ |
||||
|
async closeOtherTabs(tab: TabDefinition) { |
||||
|
const closeKeys = this.tabs.map((item) => getTabKeyFromTab(item)); |
||||
|
|
||||
|
const keys: string[] = []; |
||||
|
|
||||
|
for (const key of closeKeys) { |
||||
|
if (key !== getTabKeyFromTab(tab)) { |
||||
|
const closeTab = this.tabs.find( |
||||
|
(item) => getTabKeyFromTab(item) === key, |
||||
|
); |
||||
|
if (!closeTab) { |
||||
|
continue; |
||||
|
} |
||||
|
if (!isAffixTab(closeTab)) { |
||||
|
keys.push(closeTab.key as string); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
await this._bulkCloseByKeys(keys); |
||||
|
}, |
||||
|
/** |
||||
|
* @zh_CN 关闭右侧标签页 |
||||
|
* @param tab |
||||
|
*/ |
||||
|
async closeRightTabs(tab: TabDefinition) { |
||||
|
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 keys: string[] = []; |
||||
|
for (const item of rightTabs) { |
||||
|
if (!isAffixTab(item)) { |
||||
|
keys.push(item.key as string); |
||||
|
} |
||||
|
} |
||||
|
await this._bulkCloseByKeys(keys); |
||||
|
} |
||||
|
}, |
||||
|
|
||||
|
/** |
||||
|
* @zh_CN 关闭标签页 |
||||
|
* @param tab |
||||
|
* @param router |
||||
|
*/ |
||||
|
async closeTab(tab: TabDefinition, router: Router) { |
||||
|
const { currentRoute } = router; |
||||
|
const currentTabKey = getTabKey(currentRoute.value); |
||||
|
// 关闭不是激活选项卡
|
||||
|
if (currentTabKey !== getTabKeyFromTab(tab)) { |
||||
|
this._close(tab); |
||||
|
this.updateCacheTabs(); |
||||
|
// 移除访问历史记录
|
||||
|
if (isVisitHistory()) { |
||||
|
this.visitHistory.remove(getTabKeyFromTab(tab)); |
||||
|
} |
||||
|
return; |
||||
|
} |
||||
|
if (this.getTabs.length <= 1) { |
||||
|
console.error('Failed to close the tab; only one tab remains open.'); |
||||
|
return; |
||||
|
} |
||||
|
// 从访问历史记录中移除当前关闭的tab
|
||||
|
if (isVisitHistory()) { |
||||
|
this.visitHistory.remove(currentTabKey); |
||||
|
this._close(tab); |
||||
|
|
||||
|
let previousTab: TabDefinition | undefined; |
||||
|
let previousTabKey: string | undefined; |
||||
|
while (true) { |
||||
|
previousTabKey = this.visitHistory.pop(); |
||||
|
if (!previousTabKey) { |
||||
|
break; |
||||
|
} |
||||
|
previousTab = this.getTabByKey(previousTabKey); |
||||
|
if (previousTab) { |
||||
|
break; |
||||
|
} |
||||
|
} |
||||
|
await (previousTab |
||||
|
? this._goToTab(previousTab, router) |
||||
|
: this._goToDefaultTab(router)); |
||||
|
return; |
||||
|
} |
||||
|
// 未开启访问历史记录,直接跳转下一个或上一个tab
|
||||
|
const index = this.getTabs.findIndex( |
||||
|
(item) => getTabKeyFromTab(item) === getTabKey(currentRoute.value), |
||||
|
); |
||||
|
|
||||
|
const before = this.getTabs[index - 1]; |
||||
|
const after = this.getTabs[index + 1]; |
||||
|
|
||||
|
// 下一个tab存在,跳转到下一个
|
||||
|
if (after) { |
||||
|
this._close(tab); |
||||
|
await this._goToTab(after, router); |
||||
|
// 上一个tab存在,跳转到上一个
|
||||
|
} else if (before) { |
||||
|
this._close(tab); |
||||
|
await this._goToTab(before, router); |
||||
|
} |
||||
|
}, |
||||
|
|
||||
|
/** |
||||
|
* @zh_CN 通过key关闭标签页 |
||||
|
* @param key |
||||
|
* @param router |
||||
|
*/ |
||||
|
async closeTabByKey(key: string, router: Router) { |
||||
|
const originKey = decodeURIComponent(key); |
||||
|
const index = this.tabs.findIndex( |
||||
|
(item) => getTabKeyFromTab(item) === originKey, |
||||
|
); |
||||
|
if (index === -1) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const tab = this.tabs[index]; |
||||
|
if (tab) { |
||||
|
await this.closeTab(tab, router); |
||||
|
} |
||||
|
}, |
||||
|
|
||||
|
/** |
||||
|
* 根据tab的key获取tab |
||||
|
* @param key |
||||
|
*/ |
||||
|
getTabByKey(key: string) { |
||||
|
return this.getTabs.find( |
||||
|
(item) => getTabKeyFromTab(item) === key, |
||||
|
) as TabDefinition; |
||||
|
}, |
||||
|
/** |
||||
|
* @zh_CN 新窗口打开标签页 |
||||
|
* @param tab |
||||
|
*/ |
||||
|
async openTabInNewWindow(tab: TabDefinition) { |
||||
|
openRouteInNewWindow(tab.fullPath || tab.path); |
||||
|
}, |
||||
|
|
||||
|
/** |
||||
|
* @zh_CN 固定标签页 |
||||
|
* @param tab |
||||
|
*/ |
||||
|
async pinTab(tab: TabDefinition) { |
||||
|
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) => equalTab(item, tab)); |
||||
|
// 交换位置重新排序
|
||||
|
await this.sortTabs(index, newIndex); |
||||
|
}, |
||||
|
|
||||
|
/** |
||||
|
* 刷新标签页 |
||||
|
*/ |
||||
|
async refresh(router: Router | string) { |
||||
|
// 如果是Router路由,那么就根据当前路由刷新
|
||||
|
// 如果是string字符串,为路由名称,则定向刷新指定标签页,不能是当前路由名称,否则不会刷新
|
||||
|
if (typeof router === 'string') { |
||||
|
return await this.refreshByName(router); |
||||
|
} |
||||
|
|
||||
|
const { currentRoute } = router; |
||||
|
const { name } = currentRoute.value; |
||||
|
|
||||
|
this.excludeCachedTabs.add(name as string); |
||||
|
this.renderRouteView = false; |
||||
|
startProgress(); |
||||
|
|
||||
|
await new Promise((resolve) => setTimeout(resolve, 200)); |
||||
|
|
||||
|
this.excludeCachedTabs.delete(name as string); |
||||
|
this.renderRouteView = true; |
||||
|
stopProgress(); |
||||
|
}, |
||||
|
|
||||
|
/** |
||||
|
* 根据路由名称刷新指定标签页 |
||||
|
*/ |
||||
|
async refreshByName(name: string) { |
||||
|
this.excludeCachedTabs.add(name); |
||||
|
await new Promise((resolve) => setTimeout(resolve, 200)); |
||||
|
this.excludeCachedTabs.delete(name); |
||||
|
}, |
||||
|
|
||||
|
/** |
||||
|
* @zh_CN 重置标签页标题 |
||||
|
*/ |
||||
|
async resetTabTitle(tab: TabDefinition) { |
||||
|
if (tab?.meta?.newTabTitle) { |
||||
|
return; |
||||
|
} |
||||
|
const findTab = this.tabs.find((item) => equalTab(item, tab)); |
||||
|
if (findTab) { |
||||
|
findTab.meta.newTabTitle = undefined; |
||||
|
await this.updateCacheTabs(); |
||||
|
} |
||||
|
}, |
||||
|
|
||||
|
/** |
||||
|
* 设置固定标签页 |
||||
|
* @param tabs |
||||
|
*/ |
||||
|
setAffixTabs(tabs: RouteRecordNormalized[]) { |
||||
|
for (const tab of tabs) { |
||||
|
tab.meta.affixTab = true; |
||||
|
this.addTab(routeToTab(tab)); |
||||
|
} |
||||
|
}, |
||||
|
|
||||
|
/** |
||||
|
* @zh_CN 更新菜单列表 |
||||
|
* @param list |
||||
|
*/ |
||||
|
setMenuList(list: string[]) { |
||||
|
this.menuList = list; |
||||
|
}, |
||||
|
|
||||
|
/** |
||||
|
* @zh_CN 设置标签页标题 |
||||
|
* |
||||
|
* @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: ComputedRef<string> | string) { |
||||
|
const findTab = this.tabs.find((item) => equalTab(item, tab)); |
||||
|
|
||||
|
if (findTab) { |
||||
|
findTab.meta.newTabTitle = title; |
||||
|
|
||||
|
await this.updateCacheTabs(); |
||||
|
} |
||||
|
}, |
||||
|
setUpdateTime() { |
||||
|
this.updateTime = Date.now(); |
||||
|
}, |
||||
|
/** |
||||
|
* @zh_CN 设置标签页顺序 |
||||
|
* @param oldIndex |
||||
|
* @param newIndex |
||||
|
*/ |
||||
|
async sortTabs(oldIndex: number, newIndex: number) { |
||||
|
const currentTab = this.tabs[oldIndex]; |
||||
|
if (!currentTab) { |
||||
|
return; |
||||
|
} |
||||
|
this.tabs.splice(oldIndex, 1); |
||||
|
this.tabs.splice(newIndex, 0, currentTab); |
||||
|
this.dragEndIndex = this.dragEndIndex + 1; |
||||
|
}, |
||||
|
|
||||
|
/** |
||||
|
* @zh_CN 切换固定标签页 |
||||
|
* @param tab |
||||
|
*/ |
||||
|
async toggleTabPin(tab: TabDefinition) { |
||||
|
const affixTab = tab?.meta?.affixTab ?? false; |
||||
|
|
||||
|
await (affixTab ? this.unpinTab(tab) : this.pinTab(tab)); |
||||
|
}, |
||||
|
|
||||
|
/** |
||||
|
* @zh_CN 取消固定标签页 |
||||
|
* @param tab |
||||
|
*/ |
||||
|
async unpinTab(tab: TabDefinition) { |
||||
|
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的第一个位置
|
||||
|
const newIndex = affixTabs.length; |
||||
|
// 交换位置重新排序
|
||||
|
await this.sortTabs(index, newIndex); |
||||
|
}, |
||||
|
/** |
||||
|
* 根据当前打开的选项卡更新缓存 |
||||
|
*/ |
||||
|
async updateCacheTabs() { |
||||
|
const cacheMap = new Set<string>(); |
||||
|
|
||||
|
for (const tab of this.tabs) { |
||||
|
// 跳过不需要持久化的标签页
|
||||
|
const keepAlive = tab.meta?.keepAlive; |
||||
|
if (!keepAlive) { |
||||
|
continue; |
||||
|
} |
||||
|
(tab.matched || []).forEach((t, i) => { |
||||
|
if (i > 0) { |
||||
|
cacheMap.add(t.name as string); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
const name = tab.name as string; |
||||
|
cacheMap.add(name); |
||||
|
} |
||||
|
this.cachedTabs = cacheMap; |
||||
|
}, |
||||
|
/** |
||||
|
* 添加缓存的route |
||||
|
* @param component |
||||
|
* @param route |
||||
|
*/ |
||||
|
addCachedRoute(component: VNode, route: RouteLocationNormalizedLoaded) { |
||||
|
const key = getTabKey(route); |
||||
|
if (this.cachedRoutes.has(key)) { |
||||
|
return; |
||||
|
} |
||||
|
this.cachedRoutes.set(key, { |
||||
|
key, |
||||
|
component: markRaw(component), |
||||
|
route: markRaw(route), |
||||
|
}); |
||||
|
}, |
||||
|
removeCachedRoute(key: string) { |
||||
|
this.cachedRoutes.delete(key); |
||||
|
}, |
||||
|
}, |
||||
|
getters: { |
||||
|
affixTabs(): TabDefinition[] { |
||||
|
const affixTabs = this.tabs.filter((tab) => isAffixTab(tab)); |
||||
|
|
||||
|
return affixTabs.toSorted((a, b) => { |
||||
|
const orderA = (a.meta?.affixTabOrder ?? 0) as number; |
||||
|
const orderB = (b.meta?.affixTabOrder ?? 0) as number; |
||||
|
return orderA - orderB; |
||||
|
}); |
||||
|
}, |
||||
|
getCachedTabs(): string[] { |
||||
|
return [...this.cachedTabs]; |
||||
|
}, |
||||
|
getExcludeCachedTabs(): string[] { |
||||
|
return [...this.excludeCachedTabs]; |
||||
|
}, |
||||
|
getMenuList(): string[] { |
||||
|
return this.menuList; |
||||
|
}, |
||||
|
getTabs(): TabDefinition[] { |
||||
|
const normalTabs = this.tabs.filter((tab) => !isAffixTab(tab)); |
||||
|
return [...this.affixTabs, ...normalTabs].filter(Boolean); |
||||
|
}, |
||||
|
getCachedRoutes(): Map<string, RouteCached> { |
||||
|
return this.cachedRoutes; |
||||
|
}, |
||||
|
}, |
||||
|
persist: [ |
||||
|
// tabs不需要保存在localStorage
|
||||
|
{ |
||||
|
pick: ['tabs', 'visitHistory'], |
||||
|
storage: sessionStorage, |
||||
|
serializer: { |
||||
|
serialize: JSON.stringify, |
||||
|
deserialize(value: string) { |
||||
|
const parsed = JSON.parse(value); |
||||
|
// Stack 类实例经 JSON 序列化后会变成普通对象 {dedup, items, maxSize},
|
||||
|
// 丢失所有方法和 getter,需要重新构建 Stack 实例
|
||||
|
if (parsed.visitHistory && !(parsed.visitHistory instanceof Stack)) { |
||||
|
const raw = parsed.visitHistory; |
||||
|
const stack = createStack<string>(true, MAX_VISIT_HISTORY); |
||||
|
if (Array.isArray(raw.items)) { |
||||
|
stack.push(...raw.items); |
||||
|
} |
||||
|
parsed.visitHistory = stack; |
||||
|
} |
||||
|
return parsed; |
||||
|
}, |
||||
|
}, |
||||
|
}, |
||||
|
], |
||||
|
state: (): TabbarState => ({ |
||||
|
visitHistory: createStack<string>(true, MAX_VISIT_HISTORY), |
||||
|
cachedRoutes: new Map<string, RouteCached>(), |
||||
|
cachedTabs: new Set(), |
||||
|
dragEndIndex: 0, |
||||
|
excludeCachedTabs: new Set(), |
||||
|
menuList: [ |
||||
|
'close', |
||||
|
'affix', |
||||
|
'maximize', |
||||
|
'reload', |
||||
|
'open-in-new-window', |
||||
|
'close-left', |
||||
|
'close-right', |
||||
|
'close-other', |
||||
|
'close-all', |
||||
|
], |
||||
|
renderRouteView: true, |
||||
|
tabs: [], |
||||
|
updateTime: Date.now(), |
||||
|
}), |
||||
|
}); |
||||
|
|
||||
|
// 解决热更新问题
|
||||
|
const hot = import.meta.hot; |
||||
|
if (hot) { |
||||
|
hot.accept(acceptHMRUpdate(useTabbarStore, hot)); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* @zh_CN 克隆路由,防止路由被修改 |
||||
|
* @param route |
||||
|
*/ |
||||
|
function cloneTab(route: TabDefinition): TabDefinition { |
||||
|
if (!route) { |
||||
|
return route; |
||||
|
} |
||||
|
const { matched, meta, ...opt } = route; |
||||
|
return { |
||||
|
...opt, |
||||
|
matched: (matched |
||||
|
? matched.map((item) => ({ |
||||
|
meta: item.meta, |
||||
|
name: item.name, |
||||
|
path: item.path, |
||||
|
})) |
||||
|
: undefined) as RouteRecordNormalized[], |
||||
|
meta: { |
||||
|
...meta, |
||||
|
newTabTitle: meta.newTabTitle, |
||||
|
}, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* @zh_CN 是否是固定标签页 |
||||
|
* @param tab |
||||
|
*/ |
||||
|
function isAffixTab(tab: TabDefinition) { |
||||
|
return tab?.meta?.affixTab ?? false; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* @zh_CN 是否显示标签 |
||||
|
* @param tab |
||||
|
*/ |
||||
|
function isTabShown(tab: TabDefinition) { |
||||
|
const matched = tab?.matched ?? []; |
||||
|
return !tab.meta.hideInTab && matched.every((item) => !item.meta.hideInTab); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 从route获取tab页的key |
||||
|
* @param tab |
||||
|
*/ |
||||
|
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; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* @zh_CN 是否开启访问历史记录 |
||||
|
*/ |
||||
|
function isVisitHistory() { |
||||
|
return preferences.tabbar.visitHistory; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 从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) { |
||||
|
return { |
||||
|
meta: route.meta, |
||||
|
name: route.name, |
||||
|
path: route.path, |
||||
|
key: getTabKey(route), |
||||
|
} as TabDefinition; |
||||
|
} |
||||
|
|
||||
|
export { getTabKey }; |
||||
@ -0,0 +1,132 @@ |
|||||
|
import { ref, unref } from 'vue'; |
||||
|
|
||||
|
import { DEFAULT_TIME_ZONE_OPTIONS } from '@vben-core/preferences'; |
||||
|
import { |
||||
|
getCurrentTimezone, |
||||
|
setCurrentTimezone, |
||||
|
} from '@vben-core/shared/utils'; |
||||
|
|
||||
|
import { acceptHMRUpdate, defineStore } from 'pinia'; |
||||
|
|
||||
|
interface TimezoneHandler { |
||||
|
getTimezone?: () => Promise<null | string | undefined>; |
||||
|
getTimezoneOptions?: () => Promise< |
||||
|
{ |
||||
|
label: string; |
||||
|
value: string; |
||||
|
}[] |
||||
|
>; |
||||
|
setTimezone?: (timezone: string) => Promise<void>; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 默认时区处理模块 |
||||
|
* 时区存储基于pinia存储插件 |
||||
|
*/ |
||||
|
const getDefaultTimezoneHandler = (): TimezoneHandler => { |
||||
|
return { |
||||
|
getTimezoneOptions: () => { |
||||
|
return Promise.resolve( |
||||
|
DEFAULT_TIME_ZONE_OPTIONS.map((item) => { |
||||
|
return { |
||||
|
label: item.label, |
||||
|
value: item.timezone, |
||||
|
}; |
||||
|
}), |
||||
|
); |
||||
|
}, |
||||
|
}; |
||||
|
}; |
||||
|
|
||||
|
/** |
||||
|
* 自定义时区处理模块 |
||||
|
*/ |
||||
|
let customTimezoneHandler: null | Partial<TimezoneHandler> = null; |
||||
|
const setTimezoneHandler = (handler: Partial<TimezoneHandler>) => { |
||||
|
customTimezoneHandler = handler; |
||||
|
}; |
||||
|
|
||||
|
/** |
||||
|
* 获取时区处理模块 |
||||
|
*/ |
||||
|
const getTimezoneHandler = () => { |
||||
|
return { |
||||
|
...getDefaultTimezoneHandler(), |
||||
|
...customTimezoneHandler, |
||||
|
}; |
||||
|
}; |
||||
|
|
||||
|
/** |
||||
|
* timezone支持模块 |
||||
|
*/ |
||||
|
const useTimezoneStore = defineStore( |
||||
|
'core-timezone', |
||||
|
() => { |
||||
|
const timezoneRef = ref(getCurrentTimezone()); |
||||
|
|
||||
|
/** |
||||
|
* 初始化时区 |
||||
|
* Initialize the timezone |
||||
|
*/ |
||||
|
async function initTimezone() { |
||||
|
const timezoneHandler = getTimezoneHandler(); |
||||
|
const timezone = await timezoneHandler.getTimezone?.(); |
||||
|
if (timezone) { |
||||
|
timezoneRef.value = timezone; |
||||
|
} |
||||
|
// 设置dayjs默认时区
|
||||
|
setCurrentTimezone(unref(timezoneRef)); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 设置时区 |
||||
|
* Set the timezone |
||||
|
* @param timezone 时区字符串 |
||||
|
*/ |
||||
|
async function setTimezone(timezone: string) { |
||||
|
const timezoneHandler = getTimezoneHandler(); |
||||
|
await timezoneHandler.setTimezone?.(timezone); |
||||
|
timezoneRef.value = timezone; |
||||
|
// 设置dayjs默认时区
|
||||
|
setCurrentTimezone(timezone); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 获取时区选项 |
||||
|
* Get the timezone options |
||||
|
*/ |
||||
|
async function getTimezoneOptions() { |
||||
|
const timezoneHandler = getTimezoneHandler(); |
||||
|
return (await timezoneHandler.getTimezoneOptions?.()) || []; |
||||
|
} |
||||
|
|
||||
|
initTimezone().catch((error) => { |
||||
|
console.error('Failed to initialize timezone during store setup:', error); |
||||
|
}); |
||||
|
|
||||
|
function $reset() { |
||||
|
timezoneRef.value = getCurrentTimezone(); |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
timezone: timezoneRef, |
||||
|
setTimezone, |
||||
|
getTimezoneOptions, |
||||
|
$reset, |
||||
|
}; |
||||
|
}, |
||||
|
{ |
||||
|
persist: { |
||||
|
// 持久化
|
||||
|
pick: ['timezone'], |
||||
|
}, |
||||
|
}, |
||||
|
); |
||||
|
|
||||
|
export { setTimezoneHandler, useTimezoneStore }; |
||||
|
|
||||
|
// 解决热更新问题
|
||||
|
const hot = import.meta.hot; |
||||
|
if (hot) { |
||||
|
hot.accept(acceptHMRUpdate(useTimezoneStore, hot)); |
||||
|
} |
||||
@ -0,0 +1,37 @@ |
|||||
|
import { createPinia, setActivePinia } from 'pinia'; |
||||
|
import { beforeEach, describe, expect, it } from 'vitest'; |
||||
|
|
||||
|
import { useUserStore } from './user'; |
||||
|
|
||||
|
describe('useUserStore', () => { |
||||
|
beforeEach(() => { |
||||
|
setActivePinia(createPinia()); |
||||
|
}); |
||||
|
|
||||
|
it('returns correct userInfo', () => { |
||||
|
const store = useUserStore(); |
||||
|
const userInfo: any = { name: 'Jane Doe', roles: [{ value: 'user' }] }; |
||||
|
store.setUserInfo(userInfo); |
||||
|
expect(store.userInfo).toEqual(userInfo); |
||||
|
}); |
||||
|
|
||||
|
// 测试重置用户信息时的行为
|
||||
|
it('clears userInfo and userRoles when setting null userInfo', () => { |
||||
|
const store = useUserStore(); |
||||
|
store.setUserInfo({ |
||||
|
roles: [{ roleName: 'User', value: 'user' }], |
||||
|
} as any); |
||||
|
expect(store.userInfo).not.toBeNull(); |
||||
|
expect(store.userRoles.length).toBeGreaterThan(0); |
||||
|
|
||||
|
store.setUserInfo(null as any); |
||||
|
expect(store.userInfo).toBeNull(); |
||||
|
expect(store.userRoles).toEqual([]); |
||||
|
}); |
||||
|
|
||||
|
// 测试在没有用户角色时返回空数组
|
||||
|
it('returns an empty array for userRoles if not set', () => { |
||||
|
const store = useUserStore(); |
||||
|
expect(store.userRoles).toEqual([]); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,64 @@ |
|||||
|
import { acceptHMRUpdate, defineStore } from 'pinia'; |
||||
|
|
||||
|
interface BasicUserInfo { |
||||
|
[key: string]: any; |
||||
|
/** |
||||
|
* 头像 |
||||
|
*/ |
||||
|
avatar: string; |
||||
|
/** |
||||
|
* 用户昵称 |
||||
|
*/ |
||||
|
realName: string; |
||||
|
/** |
||||
|
* 用户角色 |
||||
|
*/ |
||||
|
roles?: string[]; |
||||
|
/** |
||||
|
* 用户id |
||||
|
*/ |
||||
|
userId: string; |
||||
|
/** |
||||
|
* 用户名 |
||||
|
*/ |
||||
|
username: string; |
||||
|
} |
||||
|
|
||||
|
interface AccessState { |
||||
|
/** |
||||
|
* 用户信息 |
||||
|
*/ |
||||
|
userInfo: BasicUserInfo | null; |
||||
|
/** |
||||
|
* 用户角色 |
||||
|
*/ |
||||
|
userRoles: string[]; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* @zh_CN 用户信息相关 |
||||
|
*/ |
||||
|
export const useUserStore = defineStore('core-user', { |
||||
|
actions: { |
||||
|
setUserInfo(userInfo: BasicUserInfo | null) { |
||||
|
// 设置用户信息
|
||||
|
this.userInfo = userInfo; |
||||
|
// 设置角色信息
|
||||
|
const roles = userInfo?.roles ?? []; |
||||
|
this.setUserRoles(roles); |
||||
|
}, |
||||
|
setUserRoles(roles: string[]) { |
||||
|
this.userRoles = roles; |
||||
|
}, |
||||
|
}, |
||||
|
state: (): AccessState => ({ |
||||
|
userInfo: null, |
||||
|
userRoles: [], |
||||
|
}), |
||||
|
}); |
||||
|
|
||||
|
// 解决热更新问题
|
||||
|
const hot = import.meta.hot; |
||||
|
if (hot) { |
||||
|
hot.accept(acceptHMRUpdate(useUserStore, hot)); |
||||
|
} |
||||
@ -0,0 +1,60 @@ |
|||||
|
import type { Pinia } from 'pinia'; |
||||
|
|
||||
|
import type { App } from 'vue'; |
||||
|
|
||||
|
import { createPinia } from 'pinia'; |
||||
|
import SecureLS from 'secure-ls'; |
||||
|
|
||||
|
let pinia: Pinia; |
||||
|
|
||||
|
export interface InitStoreOptions { |
||||
|
/** |
||||
|
* @zh_CN 应用名,由于 @vben/stores 是公用的,后续可能有多个app,为了防止多个app缓存冲突,可在这里配置应用名,应用名将被用于持久化的前缀 |
||||
|
*/ |
||||
|
namespace: string; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* @zh_CN 初始化pinia |
||||
|
*/ |
||||
|
export async function initStores(app: App, options: InitStoreOptions) { |
||||
|
const { createPersistedState } = await import('pinia-plugin-persistedstate'); |
||||
|
pinia = createPinia(); |
||||
|
const { namespace } = options; |
||||
|
const ls = new SecureLS({ |
||||
|
encodingType: 'aes', |
||||
|
encryptionSecret: import.meta.env.VITE_APP_STORE_SECURE_KEY, |
||||
|
isCompression: true, |
||||
|
// @ts-ignore secure-ls does not have a type definition for this
|
||||
|
metaKey: `${namespace}-secure-meta`, |
||||
|
}); |
||||
|
pinia.use( |
||||
|
createPersistedState({ |
||||
|
// key $appName-$store.id
|
||||
|
key: (storeKey) => `${namespace}-${storeKey}`, |
||||
|
storage: import.meta.env.DEV |
||||
|
? localStorage |
||||
|
: { |
||||
|
getItem(key) { |
||||
|
return ls.get(key); |
||||
|
}, |
||||
|
setItem(key, value) { |
||||
|
ls.set(key, value); |
||||
|
}, |
||||
|
}, |
||||
|
}), |
||||
|
); |
||||
|
app.use(pinia); |
||||
|
return pinia; |
||||
|
} |
||||
|
|
||||
|
export function resetAllStores() { |
||||
|
if (!pinia) { |
||||
|
console.error('Pinia is not installed'); |
||||
|
return; |
||||
|
} |
||||
|
const allStores = (pinia as any)._s; |
||||
|
for (const [_key, store] of allStores) { |
||||
|
store.$reset(); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,5 @@ |
|||||
|
{ |
||||
|
"$schema": "https://json.schemastore.org/tsconfig", |
||||
|
"extends": "@vben/tsconfig/web.json", |
||||
|
"include": ["src", "shim-pinia.d.ts"] |
||||
|
} |
||||
Loading…
Reference in new issue