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