diff --git a/vue-vben-admin/packages/stores/package.json b/vue-vben-admin/packages/stores/package.json new file mode 100644 index 0000000..d91761a --- /dev/null +++ b/vue-vben-admin/packages/stores/package.json @@ -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:" + } +} diff --git a/vue-vben-admin/packages/stores/shim-pinia.d.ts b/vue-vben-admin/packages/stores/shim-pinia.d.ts new file mode 100644 index 0000000..558d0ba --- /dev/null +++ b/vue-vben-admin/packages/stores/shim-pinia.d.ts @@ -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 }; diff --git a/vue-vben-admin/packages/stores/src/index.ts b/vue-vben-admin/packages/stores/src/index.ts new file mode 100644 index 0000000..41b3662 --- /dev/null +++ b/vue-vben-admin/packages/stores/src/index.ts @@ -0,0 +1,3 @@ +export * from './modules'; +export * from './setup'; +export { defineStore, storeToRefs } from 'pinia'; diff --git a/vue-vben-admin/packages/stores/src/modules/access.test.ts b/vue-vben-admin/packages/stores/src/modules/access.test.ts new file mode 100644 index 0000000..211e256 --- /dev/null +++ b/vue-vben-admin/packages/stores/src/modules/access.test.ts @@ -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([]); + }); +}); diff --git a/vue-vben-admin/packages/stores/src/modules/access.ts b/vue-vben-admin/packages/stores/src/modules/access.ts new file mode 100644 index 0000000..b88242e --- /dev/null +++ b/vue-vben-admin/packages/stores/src/modules/access.ts @@ -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)); +} diff --git a/vue-vben-admin/packages/stores/src/modules/index.ts b/vue-vben-admin/packages/stores/src/modules/index.ts new file mode 100644 index 0000000..bb9a66f --- /dev/null +++ b/vue-vben-admin/packages/stores/src/modules/index.ts @@ -0,0 +1,4 @@ +export * from './access'; +export * from './tabbar'; +export * from './timezone'; +export * from './user'; diff --git a/vue-vben-admin/packages/stores/src/modules/tabbar.test.ts b/vue-vben-admin/packages/stores/src/modules/tabbar.test.ts new file mode 100644 index 0000000..7c0fe7a --- /dev/null +++ b/vue-vben-admin/packages/stores/src/modules/tabbar.test.ts @@ -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); + }); +}); diff --git a/vue-vben-admin/packages/stores/src/modules/tabbar.ts b/vue-vben-admin/packages/stores/src/modules/tabbar.ts new file mode 100644 index 0000000..14af7be --- /dev/null +++ b/vue-vben-admin/packages/stores/src/modules/tabbar.ts @@ -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; + /** + * @zh_CN 当前打开的标签页列表缓存 + */ + cachedTabs: Set; + /** + * @zh_CN 拖拽结束的索引 + */ + dragEndIndex: number; + /** + * @zh_CN 需要排除缓存的标签页 + */ + excludeCachedTabs: Set; + /** + * @zh_CN 标签右键菜单列表 + */ + menuList: string[]; + /** + * @zh_CN 是否刷新 + */ + renderRouteView?: boolean; + /** + * @zh_CN 当前打开的标签页列表 + */ + tabs: TabDefinition[]; + /** + * @zh_CN 更新时间,用于一些更新场景,使用watch深度监听的话,会损耗性能 + */ + updateTime?: number; + /** + * @zh_CN 上一个标签页打开的标签 + */ + visitHistory: Stack; +} + +/** + * @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} title - 标题内容,支持静态字符串或计算属性 + * + * @example + * // 设置静态标题 + * setTabTitle(tab, '新标签页'); + * + * @example + * // 设置动态标题 + * setTabTitle(tab, computed(() => t('common.dashboard'))); + */ + async setTabTitle(tab: TabDefinition, title: ComputedRef | 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(); + + 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 { + 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(true, MAX_VISIT_HISTORY); + if (Array.isArray(raw.items)) { + stack.push(...raw.items); + } + parsed.visitHistory = stack; + } + return parsed; + }, + }, + }, + ], + state: (): TabbarState => ({ + visitHistory: createStack(true, MAX_VISIT_HISTORY), + cachedRoutes: new Map(), + 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 }; diff --git a/vue-vben-admin/packages/stores/src/modules/timezone.ts b/vue-vben-admin/packages/stores/src/modules/timezone.ts new file mode 100644 index 0000000..24180c6 --- /dev/null +++ b/vue-vben-admin/packages/stores/src/modules/timezone.ts @@ -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; + getTimezoneOptions?: () => Promise< + { + label: string; + value: string; + }[] + >; + setTimezone?: (timezone: string) => Promise; +} + +/** + * 默认时区处理模块 + * 时区存储基于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 = null; +const setTimezoneHandler = (handler: Partial) => { + 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)); +} diff --git a/vue-vben-admin/packages/stores/src/modules/user.test.ts b/vue-vben-admin/packages/stores/src/modules/user.test.ts new file mode 100644 index 0000000..3d8a22c --- /dev/null +++ b/vue-vben-admin/packages/stores/src/modules/user.test.ts @@ -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([]); + }); +}); diff --git a/vue-vben-admin/packages/stores/src/modules/user.ts b/vue-vben-admin/packages/stores/src/modules/user.ts new file mode 100644 index 0000000..9d37433 --- /dev/null +++ b/vue-vben-admin/packages/stores/src/modules/user.ts @@ -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)); +} diff --git a/vue-vben-admin/packages/stores/src/setup.ts b/vue-vben-admin/packages/stores/src/setup.ts new file mode 100644 index 0000000..b18c27e --- /dev/null +++ b/vue-vben-admin/packages/stores/src/setup.ts @@ -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(); + } +} diff --git a/vue-vben-admin/packages/stores/tsconfig.json b/vue-vben-admin/packages/stores/tsconfig.json new file mode 100644 index 0000000..3057820 --- /dev/null +++ b/vue-vben-admin/packages/stores/tsconfig.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@vben/tsconfig/web.json", + "include": ["src", "shim-pinia.d.ts"] +}