40 changed files with 1469 additions and 452 deletions
@ -0,0 +1,139 @@ |
|||
import { generatorMenus, generatorRoutes } from '@vben-core/helpers'; |
|||
import { preferences } from '@vben-core/preferences'; |
|||
import { useAccessStore } from '@vben-core/stores'; |
|||
import type { RouteLocationNormalized, Router } from 'vue-router'; |
|||
|
|||
import { LOGIN_PATH } from '@vben/constants'; |
|||
import { $t } from '@vben/locales'; |
|||
import { startProgress, stopProgress } from '@vben/utils'; |
|||
import { useTitle } from '@vueuse/core'; |
|||
|
|||
import { dynamicRoutes } from '@/router/routes'; |
|||
|
|||
/** |
|||
* 通用守卫配置 |
|||
* @param router |
|||
*/ |
|||
function configCommonGuard(router: Router) { |
|||
// 记录已经加载的页面
|
|||
const loadedPaths = new Set<string>(); |
|||
|
|||
router.beforeEach(async (to) => { |
|||
// 页面加载进度条
|
|||
if (preferences.transition.progress) { |
|||
startProgress(); |
|||
} |
|||
to.meta.loaded = loadedPaths.has(to.path); |
|||
return true; |
|||
}); |
|||
|
|||
router.afterEach((to) => { |
|||
// 记录页面是否加载,如果已经加载,后续的页面切换动画等效果不在重复执行
|
|||
loadedPaths.add(to.path); |
|||
|
|||
// 关闭页面加载进度条
|
|||
if (preferences.transition.progress) { |
|||
stopProgress(); |
|||
} |
|||
|
|||
// 动态修改标题
|
|||
if (preferences.app.dynamicTitle) { |
|||
const { title } = to.meta; |
|||
useTitle(`${$t(title)} - ${preferences.app.name}`); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
// 不需要权限的页面白名单
|
|||
const WHITE_ROUTE_NAMES = new Set<string>([]); |
|||
|
|||
/** |
|||
* 跳转登录页面 |
|||
* @param to |
|||
*/ |
|||
function loginPageMeta(to: RouteLocationNormalized) { |
|||
return { |
|||
path: LOGIN_PATH, |
|||
// 如不需要,直接删除 query
|
|||
query: { redirect: encodeURIComponent(to.fullPath) }, |
|||
// 携带当前跳转的页面,登录后重新跳转该页面
|
|||
replace: true, |
|||
}; |
|||
} |
|||
|
|||
/** |
|||
* 权限访问守卫配置 |
|||
* @param router |
|||
*/ |
|||
function configAccessGuard(router: Router) { |
|||
router.beforeEach(async (to, from) => { |
|||
const accessStore = useAccessStore(); |
|||
const accessToken = accessStore.getAccessToken; |
|||
|
|||
// accessToken 检查
|
|||
if (!accessToken) { |
|||
if (to.path === '/') { |
|||
return loginPageMeta(to); |
|||
} |
|||
|
|||
// 明确声明忽略权限访问权限,则可以访问
|
|||
if (to.meta.ignoreAccess) { |
|||
return true; |
|||
} |
|||
|
|||
// 白名单路由列表检查
|
|||
// TODO: 不是很需要,通过 ignoreAccess 也可以做到,考虑删除
|
|||
if (WHITE_ROUTE_NAMES.has(to.name as string)) { |
|||
return true; |
|||
} |
|||
|
|||
// 没有访问权限,跳转登录页面
|
|||
if (to.fullPath !== LOGIN_PATH) { |
|||
return loginPageMeta(to); |
|||
} |
|||
return to; |
|||
} |
|||
|
|||
const accessRoutes = accessStore.getAccessRoutes; |
|||
|
|||
// 是否已经生成过动态路由
|
|||
if (accessRoutes && accessRoutes.length > 0) { |
|||
return true; |
|||
} |
|||
|
|||
// 生成路由表
|
|||
// 当前登录用户拥有的角色标识列表
|
|||
const userRoles = accessStore.getUserRoles; |
|||
const routes = await generatorRoutes(dynamicRoutes, userRoles); |
|||
// 动态添加到router实例内
|
|||
routes.forEach((route) => router.addRoute(route)); |
|||
|
|||
const menus = await generatorMenus(routes, router); |
|||
|
|||
// 保存菜单信息和路由信息
|
|||
accessStore.setAccessMenus(menus); |
|||
accessStore.setAccessRoutes(routes); |
|||
const redirectPath = (from.query.redirect || to.path) as string; |
|||
const redirect = decodeURIComponent(redirectPath); |
|||
|
|||
return { |
|||
path: redirect, |
|||
replace: true, |
|||
}; |
|||
}); |
|||
} |
|||
|
|||
export { configAccessGuard }; |
|||
|
|||
/** |
|||
* 项目守卫配置 |
|||
* @param router |
|||
*/ |
|||
function createRouterGuard(router: Router) { |
|||
/** 通用 */ |
|||
configCommonGuard(router); |
|||
/** 权限访问 */ |
|||
configAccessGuard(router); |
|||
} |
|||
|
|||
export { createRouterGuard }; |
|||
@ -1,184 +0,0 @@ |
|||
import type { ExRouteRecordRaw, MenuRecordRaw } from '@vben/types'; |
|||
|
|||
import { useAccessStore } from '@vben-core/stores'; |
|||
import type { RouteRecordRaw, Router } from 'vue-router'; |
|||
|
|||
import { LOGIN_PATH } from '@vben/constants'; |
|||
import { filterTree, mapTree, traverseTreeValues } from '@vben/utils'; |
|||
|
|||
import { dynamicRoutes } from '@/router/routes'; |
|||
|
|||
// 不需要权限的页面白名单
|
|||
const WHITE_ROUTE_NAMES = new Set<string>([]); |
|||
|
|||
/** |
|||
* 权限访问守卫配置 |
|||
* @param router |
|||
*/ |
|||
function configAccessGuard(router: Router) { |
|||
router.beforeEach(async (to, from) => { |
|||
const accessStore = useAccessStore(); |
|||
const accessToken = accessStore.getAccessToken; |
|||
|
|||
// accessToken 检查
|
|||
if (!accessToken) { |
|||
// 明确声明忽略权限访问权限,则可以访问
|
|||
if (to.meta.ignoreAccess) { |
|||
return true; |
|||
} |
|||
|
|||
// 白名单路由列表检查
|
|||
// TODO: 不是很需要,通过 ignoreAccess 也可以做到,考虑删除
|
|||
if (WHITE_ROUTE_NAMES.has(to.name as string)) { |
|||
return true; |
|||
} |
|||
|
|||
// 没有访问权限,跳转登录页面
|
|||
if (to.fullPath !== LOGIN_PATH) { |
|||
return { |
|||
path: LOGIN_PATH, |
|||
// 如不需要,直接删除 query
|
|||
query: { redirect: encodeURIComponent(to.fullPath) }, |
|||
// 携带当前跳转的页面,登录后重新跳转该页面
|
|||
replace: true, |
|||
}; |
|||
} |
|||
return to; |
|||
} |
|||
|
|||
const accessRoutes = accessStore.getAccessRoutes; |
|||
|
|||
// 是否已经生成过动态路由
|
|||
if (accessRoutes && accessRoutes.length > 0) { |
|||
return true; |
|||
} |
|||
|
|||
// 生成路由表
|
|||
// 当前登录用户拥有的角色标识列表
|
|||
const userRoles = accessStore.getUserRoles; |
|||
const routes = await generatorRoutes(userRoles); |
|||
// 动态添加到router实例内
|
|||
routes.forEach((route) => router.addRoute(route)); |
|||
|
|||
const menus = await generatorMenus(routes, router); |
|||
|
|||
// 保存菜单信息和路由信息
|
|||
accessStore.setAccessMenus(menus); |
|||
accessStore.setAccessRoutes(routes); |
|||
const redirectPath = (from.query.redirect || to.path) as string; |
|||
const redirect = decodeURIComponent(redirectPath); |
|||
|
|||
return { |
|||
path: redirect, |
|||
replace: true, |
|||
}; |
|||
}); |
|||
} |
|||
|
|||
/** |
|||
* 动态生成路由 |
|||
*/ |
|||
async function generatorRoutes(roles: string[]): Promise<RouteRecordRaw[]> { |
|||
// 根据角色标识过滤路由表,判断当前用户是否拥有指定权限
|
|||
return filterTree(dynamicRoutes, (route) => { |
|||
return hasVisible(route) && hasAuthority(route, roles); |
|||
}); |
|||
} |
|||
|
|||
/** |
|||
* 根据 routes 生成菜单列表 |
|||
* @param routes |
|||
*/ |
|||
async function generatorMenus( |
|||
routes: RouteRecordRaw[], |
|||
router: Router, |
|||
): Promise<MenuRecordRaw[]> { |
|||
// 获取所有router最终的path及name
|
|||
const finalRoutes = traverseTreeValues( |
|||
router.getRoutes(), |
|||
({ name, path }) => { |
|||
return { |
|||
name, |
|||
path, |
|||
}; |
|||
}, |
|||
); |
|||
|
|||
const menus = mapTree<ExRouteRecordRaw, MenuRecordRaw>(routes, (route) => { |
|||
// 路由表的路径写法有多种,这里从router获取到最终的path并赋值
|
|||
const matchRoute = finalRoutes.find( |
|||
(finalRoute) => finalRoute.name === route.name, |
|||
); |
|||
|
|||
// 转换为菜单结构
|
|||
const path = matchRoute?.path ?? route.path; |
|||
const { meta, name: routeName, redirect, children } = route; |
|||
const { |
|||
badge, |
|||
badgeType, |
|||
badgeVariants, |
|||
hideChildrenInMenu = false, |
|||
icon, |
|||
orderNo, |
|||
target, |
|||
title = '', |
|||
} = meta || {}; |
|||
|
|||
const name = (title || routeName || '') as string; |
|||
|
|||
// 隐藏子菜单
|
|||
const resultChildren = hideChildrenInMenu |
|||
? [] |
|||
: (children as MenuRecordRaw[]); |
|||
|
|||
// 将菜单的所有父级和父级菜单记录到菜单项内
|
|||
if (resultChildren && resultChildren.length > 0) { |
|||
resultChildren.forEach((child) => { |
|||
child.parents = [...(route.parents || []), path]; |
|||
child.parent = path; |
|||
}); |
|||
} |
|||
// 隐藏子菜单
|
|||
const resultPath = hideChildrenInMenu ? redirect : target || path; |
|||
return { |
|||
badge, |
|||
badgeType, |
|||
badgeVariants, |
|||
icon, |
|||
name, |
|||
orderNo, |
|||
parent: route.parent, |
|||
parents: route.parents, |
|||
path: resultPath, |
|||
children: resultChildren, |
|||
}; |
|||
}); |
|||
|
|||
return menus; |
|||
} |
|||
|
|||
/** |
|||
* 判断路由是否有权限访问 |
|||
* @param route |
|||
* @param access |
|||
*/ |
|||
function hasAuthority(route: RouteRecordRaw, access: string[]) { |
|||
const authority = route.meta?.authority; |
|||
if (!authority) { |
|||
return true; |
|||
} |
|||
const authSet = new Set(authority); |
|||
return access.some((value) => { |
|||
return authSet.has(value); |
|||
}); |
|||
} |
|||
|
|||
/** |
|||
* 判断路由是否需要在菜单中显示 |
|||
* @param route |
|||
*/ |
|||
function hasVisible(route: RouteRecordRaw) { |
|||
return !route.meta?.hideInMenu; |
|||
} |
|||
|
|||
export { configAccessGuard }; |
|||
@ -1,55 +0,0 @@ |
|||
import { preferences } from '@vben-core/preferences'; |
|||
import type { Router } from 'vue-router'; |
|||
|
|||
import { $t } from '@vben/locales'; |
|||
import { startProgress, stopProgress } from '@vben/utils'; |
|||
import { useTitle } from '@vueuse/core'; |
|||
|
|||
import { configAccessGuard } from './access'; |
|||
|
|||
/** |
|||
* 通用守卫配置 |
|||
* @param router |
|||
*/ |
|||
function configCommonGuard(router: Router) { |
|||
// 记录已经加载的页面
|
|||
const loadedPaths = new Set<string>(); |
|||
|
|||
router.beforeEach(async (to) => { |
|||
// 页面加载进度条
|
|||
if (preferences.transition.progress) { |
|||
startProgress(); |
|||
} |
|||
to.meta.loaded = loadedPaths.has(to.path); |
|||
return true; |
|||
}); |
|||
|
|||
router.afterEach((to) => { |
|||
// 记录页面是否加载,如果已经加载,后续的页面切换动画等效果不在重复执行
|
|||
loadedPaths.add(to.path); |
|||
|
|||
// 关闭页面加载进度条
|
|||
if (preferences.transition.progress) { |
|||
stopProgress(); |
|||
} |
|||
|
|||
// 动态修改标题
|
|||
if (preferences.app.dynamicTitle) { |
|||
const { title } = to.meta; |
|||
useTitle(`${$t(title)} - ${preferences.app.name}`); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
/** |
|||
* 项目守卫配置 |
|||
* @param router |
|||
*/ |
|||
function createRouteGuard(router: Router) { |
|||
/** 通用 */ |
|||
configCommonGuard(router); |
|||
/** 权限访问 */ |
|||
configAccessGuard(router); |
|||
} |
|||
|
|||
export { createRouteGuard }; |
|||
@ -1,24 +1,17 @@ |
|||
import { createPinia, setActivePinia } from 'pinia'; |
|||
import { |
|||
// beforeEach,
|
|||
describe, |
|||
// expect,
|
|||
it, |
|||
} from 'vitest'; |
|||
import { beforeEach, describe, expect, it } from 'vitest'; |
|||
|
|||
// import { useAccessStore } from '../modules/access';
|
|||
import { useCounterStore } from './example'; |
|||
|
|||
describe('useCounterStore', () => { |
|||
it('app Name with test', () => { |
|||
beforeEach(() => { |
|||
setActivePinia(createPinia()); |
|||
// let referenceStore = usePreferencesStore();
|
|||
}); |
|||
|
|||
// beforeEach(() => {
|
|||
// referenceStore = usePreferencesStore();
|
|||
// });
|
|||
it('count test', () => { |
|||
setActivePinia(createPinia()); |
|||
const counterStore = useCounterStore(); |
|||
|
|||
// expect(referenceStore.appName).toBe('vben-admin');
|
|||
// referenceStore.setAppName('vbenAdmin');
|
|||
// expect(referenceStore.getAppName).toBe('vbenAdmin');
|
|||
expect(counterStore.count).toBe(0); |
|||
}); |
|||
}); |
|||
|
|||
@ -0,0 +1,171 @@ |
|||
import { describe, expect, it, vi } from 'vitest'; |
|||
|
|||
import { generatorMenus } from './generator-menus'; // 替换为您的实际路径
|
|||
import type { RouteRecordRaw } from 'vue-router'; |
|||
|
|||
// 模拟路由数据
|
|||
const mockRoutes = [ |
|||
{ |
|||
meta: { icon: 'home-icon', title: '首页' }, |
|||
name: 'home', |
|||
path: '/home', |
|||
}, |
|||
{ |
|||
meta: { hideChildrenInMenu: true, icon: 'about-icon', title: '关于' }, |
|||
name: 'about', |
|||
path: '/about', |
|||
children: [ |
|||
{ |
|||
path: 'team', |
|||
name: 'team', |
|||
meta: { icon: 'team-icon', title: '团队' }, |
|||
}, |
|||
], |
|||
}, |
|||
] as RouteRecordRaw[]; |
|||
|
|||
// 模拟 Vue 路由器实例
|
|||
const mockRouter = { |
|||
getRoutes: vi.fn(() => [ |
|||
{ name: 'home', path: '/home' }, |
|||
{ name: 'about', path: '/about' }, |
|||
{ name: 'team', path: '/about/team' }, |
|||
]), |
|||
}; |
|||
|
|||
// Nested route setup to test child inclusion and hideChildrenInMenu functionality
|
|||
|
|||
describe('generatorMenus', () => { |
|||
it('the correct menu list should be generated according to the route', async () => { |
|||
const expectedMenus = [ |
|||
{ |
|||
badge: undefined, |
|||
badgeType: undefined, |
|||
badgeVariants: undefined, |
|||
icon: 'home-icon', |
|||
name: '首页', |
|||
orderNo: undefined, |
|||
parent: undefined, |
|||
parents: undefined, |
|||
path: '/home', |
|||
children: [], |
|||
}, |
|||
{ |
|||
badge: undefined, |
|||
badgeType: undefined, |
|||
badgeVariants: undefined, |
|||
icon: 'about-icon', |
|||
name: '关于', |
|||
orderNo: undefined, |
|||
parent: undefined, |
|||
parents: undefined, |
|||
path: '/about', |
|||
children: [], |
|||
}, |
|||
]; |
|||
|
|||
const menus = await generatorMenus(mockRoutes, mockRouter as any); |
|||
expect(menus).toEqual(expectedMenus); |
|||
}); |
|||
|
|||
it('includes additional meta properties in menu items', async () => { |
|||
const mockRoutesWithMeta = [ |
|||
{ |
|||
meta: { icon: 'user-icon', orderNo: 1, title: 'Profile' }, |
|||
name: 'profile', |
|||
path: '/profile', |
|||
}, |
|||
] as RouteRecordRaw[]; |
|||
|
|||
const menus = await generatorMenus(mockRoutesWithMeta, mockRouter as any); |
|||
expect(menus).toEqual([ |
|||
{ |
|||
badge: undefined, |
|||
badgeType: undefined, |
|||
badgeVariants: undefined, |
|||
icon: 'user-icon', |
|||
name: 'Profile', |
|||
orderNo: 1, |
|||
parent: undefined, |
|||
parents: undefined, |
|||
path: '/profile', |
|||
children: [], |
|||
}, |
|||
]); |
|||
}); |
|||
|
|||
it('handles dynamic route parameters correctly', async () => { |
|||
const mockRoutesWithParams = [ |
|||
{ |
|||
meta: { icon: 'details-icon', title: 'User Details' }, |
|||
name: 'userDetails', |
|||
path: '/users/:userId', |
|||
}, |
|||
] as RouteRecordRaw[]; |
|||
|
|||
const menus = await generatorMenus(mockRoutesWithParams, mockRouter as any); |
|||
expect(menus).toEqual([ |
|||
{ |
|||
badge: undefined, |
|||
badgeType: undefined, |
|||
badgeVariants: undefined, |
|||
icon: 'details-icon', |
|||
name: 'User Details', |
|||
orderNo: undefined, |
|||
parent: undefined, |
|||
parents: undefined, |
|||
path: '/users/:userId', |
|||
children: [], |
|||
}, |
|||
]); |
|||
}); |
|||
|
|||
it('processes routes with redirects correctly', async () => { |
|||
const mockRoutesWithRedirect = [ |
|||
{ |
|||
name: 'redirectedRoute', |
|||
path: '/old-path', |
|||
redirect: '/new-path', |
|||
}, |
|||
{ |
|||
meta: { icon: 'path-icon', title: 'New Path' }, |
|||
name: 'newPath', |
|||
path: '/new-path', |
|||
}, |
|||
] as RouteRecordRaw[]; |
|||
|
|||
const menus = await generatorMenus( |
|||
mockRoutesWithRedirect, |
|||
mockRouter as any, |
|||
); |
|||
console.log(111, menus); |
|||
|
|||
expect(menus).toEqual([ |
|||
// Assuming your generatorMenus function excludes redirect routes from the menu
|
|||
{ |
|||
badge: undefined, |
|||
badgeType: undefined, |
|||
badgeVariants: undefined, |
|||
icon: undefined, |
|||
name: 'redirectedRoute', |
|||
orderNo: undefined, |
|||
parent: undefined, |
|||
parents: undefined, |
|||
path: '/old-path', |
|||
children: [], |
|||
}, |
|||
{ |
|||
badge: undefined, |
|||
badgeType: undefined, |
|||
badgeVariants: undefined, |
|||
icon: 'path-icon', |
|||
name: 'New Path', |
|||
orderNo: undefined, |
|||
parent: undefined, |
|||
parents: undefined, |
|||
path: '/new-path', |
|||
children: [], |
|||
}, |
|||
]); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,71 @@ |
|||
import type { ExRouteRecordRaw, MenuRecordRaw } from '@vben-core/typings'; |
|||
|
|||
import { mapTree } from '@vben-core/toolkit'; |
|||
import type { RouteRecordRaw, Router } from 'vue-router'; |
|||
|
|||
/** |
|||
* 根据 routes 生成菜单列表 |
|||
* @param routes |
|||
*/ |
|||
async function generatorMenus( |
|||
routes: RouteRecordRaw[], |
|||
router: Router, |
|||
): Promise<MenuRecordRaw[]> { |
|||
// 将路由列表转换为一个以 name 为键的对象映射
|
|||
// 获取所有router最终的path及name
|
|||
const finalRoutesMap: { [key: string]: string } = Object.fromEntries( |
|||
router.getRoutes().map(({ name, path }) => [name, path]), |
|||
); |
|||
|
|||
const menus = mapTree<ExRouteRecordRaw, MenuRecordRaw>(routes, (route) => { |
|||
// 路由表的路径写法有多种,这里从router获取到最终的path并赋值
|
|||
const path = finalRoutesMap[route.name as string] ?? route.path; |
|||
|
|||
// 转换为菜单结构
|
|||
// const path = matchRoute?.path ?? route.path;
|
|||
const { meta, name: routeName, redirect, children } = route; |
|||
const { |
|||
badge, |
|||
badgeType, |
|||
badgeVariants, |
|||
hideChildrenInMenu = false, |
|||
icon, |
|||
orderNo, |
|||
target, |
|||
title = '', |
|||
} = meta || {}; |
|||
|
|||
const name = (title || routeName || '') as string; |
|||
|
|||
// 隐藏子菜单
|
|||
const resultChildren = hideChildrenInMenu |
|||
? [] |
|||
: (children as MenuRecordRaw[]); |
|||
|
|||
// 将菜单的所有父级和父级菜单记录到菜单项内
|
|||
if (resultChildren && resultChildren.length > 0) { |
|||
resultChildren.forEach((child) => { |
|||
child.parents = [...(route.parents || []), path]; |
|||
child.parent = path; |
|||
}); |
|||
} |
|||
// 隐藏子菜单
|
|||
const resultPath = hideChildrenInMenu ? redirect || path : target || path; |
|||
return { |
|||
badge, |
|||
badgeType, |
|||
badgeVariants, |
|||
icon, |
|||
name, |
|||
orderNo, |
|||
parent: route.parent, |
|||
parents: route.parents, |
|||
path: resultPath as string, |
|||
children: resultChildren || [], |
|||
}; |
|||
}); |
|||
|
|||
return menus; |
|||
} |
|||
|
|||
export { generatorMenus }; |
|||
@ -0,0 +1,128 @@ |
|||
import type { RouteRecordRaw } from 'vue-router'; |
|||
|
|||
import { describe, expect, it } from 'vitest'; |
|||
|
|||
import { generatorRoutes, hasAuthority, hasVisible } from './generator-routes'; |
|||
|
|||
// Mock 路由数据
|
|||
const mockRoutes = [ |
|||
{ |
|||
meta: { |
|||
authority: ['admin', 'user'], |
|||
hideInMenu: false, |
|||
}, |
|||
path: '/dashboard', |
|||
children: [ |
|||
{ |
|||
path: '/dashboard/overview', |
|||
meta: { authority: ['admin'], hideInMenu: false }, |
|||
}, |
|||
{ |
|||
path: '/dashboard/stats', |
|||
meta: { authority: ['user'], hideInMenu: true }, |
|||
}, |
|||
], |
|||
}, |
|||
{ |
|||
meta: { authority: ['admin'], hideInMenu: false }, |
|||
path: '/settings', |
|||
}, |
|||
{ |
|||
meta: { hideInMenu: false }, |
|||
path: '/profile', |
|||
}, |
|||
] as RouteRecordRaw[]; |
|||
|
|||
describe('hasAuthority', () => { |
|||
it('should return true if there is no authority defined', () => { |
|||
expect(hasAuthority(mockRoutes[2], ['admin'])).toBe(true); |
|||
}); |
|||
|
|||
it('should return true if the user has the required authority', () => { |
|||
expect(hasAuthority(mockRoutes[0], ['admin'])).toBe(true); |
|||
}); |
|||
|
|||
it('should return false if the user does not have the required authority', () => { |
|||
expect(hasAuthority(mockRoutes[1], ['user'])).toBe(false); |
|||
}); |
|||
}); |
|||
|
|||
describe('hasVisible', () => { |
|||
it('should return true if hideInMenu is not set or false', () => { |
|||
expect(hasVisible(mockRoutes[0])).toBe(true); |
|||
expect(hasVisible(mockRoutes[2])).toBe(true); |
|||
}); |
|||
|
|||
it('should return false if hideInMenu is true', () => { |
|||
expect(hasVisible(mockRoutes[0].children?.[1])).toBe(false); |
|||
}); |
|||
}); |
|||
|
|||
describe('generatorRoutes', () => { |
|||
it('should filter routes based on authority and visibility', async () => { |
|||
const generatedRoutes = await generatorRoutes(mockRoutes, ['user']); |
|||
// The user should have access to /dashboard/stats, but it should be filtered out because it's not visible
|
|||
expect(generatedRoutes).toEqual([ |
|||
{ |
|||
meta: { authority: ['admin', 'user'], hideInMenu: false }, |
|||
path: '/dashboard', |
|||
children: [], |
|||
}, |
|||
// Note: We expect /settings to be filtered out because the user does not have 'admin' authority
|
|||
{ |
|||
meta: { hideInMenu: false }, |
|||
path: '/profile', |
|||
}, |
|||
]); |
|||
}); |
|||
|
|||
it('should handle routes without children', async () => { |
|||
const generatedRoutes = await generatorRoutes(mockRoutes, ['user']); |
|||
expect(generatedRoutes).toEqual( |
|||
expect.arrayContaining([ |
|||
expect.objectContaining({ |
|||
path: '/profile', // This route has no children and should be included
|
|||
}), |
|||
]), |
|||
); |
|||
}); |
|||
|
|||
it('should handle empty roles array', async () => { |
|||
const generatedRoutes = await generatorRoutes(mockRoutes, []); |
|||
expect(generatedRoutes).toEqual( |
|||
expect.arrayContaining([ |
|||
// Only routes without authority should be included
|
|||
expect.objectContaining({ |
|||
path: '/profile', |
|||
}), |
|||
]), |
|||
); |
|||
expect(generatedRoutes).not.toEqual( |
|||
expect.arrayContaining([ |
|||
expect.objectContaining({ |
|||
path: '/dashboard', |
|||
}), |
|||
expect.objectContaining({ |
|||
path: '/settings', |
|||
}), |
|||
]), |
|||
); |
|||
}); |
|||
|
|||
it('should handle missing meta fields', async () => { |
|||
const routesWithMissingMeta = [ |
|||
{ path: '/path1' }, // No meta
|
|||
{ meta: {}, path: '/path2' }, // Empty meta
|
|||
{ meta: { authority: ['admin'] }, path: '/path3' }, // Only authority
|
|||
]; |
|||
const generatedRoutes = await generatorRoutes( |
|||
routesWithMissingMeta as RouteRecordRaw[], |
|||
['admin'], |
|||
); |
|||
expect(generatedRoutes).toEqual([ |
|||
{ path: '/path1' }, |
|||
{ meta: {}, path: '/path2' }, |
|||
{ meta: { authority: ['admin'] }, path: '/path3' }, |
|||
]); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,40 @@ |
|||
import { filterTree } from '@vben-core/toolkit'; |
|||
import type { RouteRecordRaw } from 'vue-router'; |
|||
/** |
|||
* 动态生成路由 |
|||
*/ |
|||
async function generatorRoutes( |
|||
routes: RouteRecordRaw[], |
|||
roles: string[], |
|||
): Promise<RouteRecordRaw[]> { |
|||
// 根据角色标识过滤路由表,判断当前用户是否拥有指定权限
|
|||
return filterTree(routes, (route) => { |
|||
return hasVisible(route) && hasAuthority(route, roles); |
|||
}); |
|||
} |
|||
|
|||
/** |
|||
* 判断路由是否有权限访问 |
|||
* @param route |
|||
* @param access |
|||
*/ |
|||
function hasAuthority(route: RouteRecordRaw, access: string[]) { |
|||
const authority = route.meta?.authority; |
|||
|
|||
if (!authority) { |
|||
return true; |
|||
} |
|||
return access.some((value) => { |
|||
return authority.includes(value); |
|||
}); |
|||
} |
|||
|
|||
/** |
|||
* 判断路由是否需要在菜单中显示 |
|||
* @param route |
|||
*/ |
|||
function hasVisible(route?: RouteRecordRaw) { |
|||
return !route?.meta?.hideInMenu; |
|||
} |
|||
|
|||
export { generatorRoutes, hasAuthority, hasVisible }; |
|||
@ -1,2 +1,4 @@ |
|||
export * from './flatten-object'; |
|||
export * from './generator-menus'; |
|||
export * from './generator-routes'; |
|||
export * from './nested-object'; |
|||
|
|||
@ -1,5 +1,8 @@ |
|||
{ |
|||
"$schema": "https://json.schemastore.org/tsconfig", |
|||
"extends": "@vben/tsconfig/library.json", |
|||
"extends": "@vben/tsconfig/web.json", |
|||
"compilerOptions": { |
|||
"types": ["@vben-core/typings/vue-router"] |
|||
}, |
|||
"include": ["src"] |
|||
} |
|||
|
|||
@ -0,0 +1,268 @@ |
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'; |
|||
|
|||
import { defaultPreferences } from './config'; |
|||
import { PreferenceManager, isDarkTheme } from './preferences'; |
|||
|
|||
describe('preferences', () => { |
|||
let preferenceManager: PreferenceManager; |
|||
vi.mock('@vben-core/cache', () => { |
|||
return { |
|||
StorageManager: vi.fn().mockImplementation(() => { |
|||
return { |
|||
getItem: vi.fn(), |
|||
removeItem: vi.fn(), |
|||
setItem: vi.fn(), |
|||
}; |
|||
}), |
|||
}; |
|||
}); |
|||
|
|||
// 模拟 window.matchMedia 方法
|
|||
vi.stubGlobal( |
|||
'matchMedia', |
|||
vi.fn().mockImplementation((query) => ({ |
|||
addEventListener: vi.fn(), |
|||
addListener: vi.fn(), // Deprecated
|
|||
dispatchEvent: vi.fn(), |
|||
matches: query === '(prefers-color-scheme: dark)', |
|||
media: query, |
|||
onchange: null, |
|||
removeEventListener: vi.fn(), |
|||
removeListener: vi.fn(), // Deprecated
|
|||
})), |
|||
); |
|||
beforeEach(() => { |
|||
preferenceManager = new PreferenceManager(); |
|||
}); |
|||
|
|||
it('initPreferences should initialize preferences with overrides and namespace', async () => { |
|||
const overrides = { theme: { colorPrimary: 'hsl(211 91% 39%)' } }; |
|||
const namespace = 'testNamespace'; |
|||
|
|||
await preferenceManager.initPreferences({ namespace, overrides }); |
|||
|
|||
expect(preferenceManager.getPreferences().theme.colorPrimary).toBe( |
|||
overrides.theme.colorPrimary, |
|||
); |
|||
}); |
|||
|
|||
it('loads default preferences if no saved preferences found', () => { |
|||
const preferences = preferenceManager.getPreferences(); |
|||
expect(preferences).toEqual(defaultPreferences); |
|||
}); |
|||
|
|||
it('initializes preferences with overrides', async () => { |
|||
const overrides: any = { |
|||
app: { |
|||
locale: 'en-US', |
|||
themeMode: 'light', |
|||
}, |
|||
}; |
|||
await preferenceManager.initPreferences({ |
|||
namespace: 'testNamespace', |
|||
overrides, |
|||
}); |
|||
|
|||
// 等待防抖动操作完成
|
|||
// await new Promise((resolve) => setTimeout(resolve, 300)); // 等待100毫秒
|
|||
|
|||
const expected = { |
|||
...defaultPreferences, |
|||
app: { |
|||
...defaultPreferences.app, |
|||
...overrides.app, |
|||
}, |
|||
}; |
|||
|
|||
expect(preferenceManager.getPreferences()).toEqual(expected); |
|||
}); |
|||
|
|||
it('updates theme mode correctly', () => { |
|||
preferenceManager.updatePreferences({ |
|||
app: { themeMode: 'light' }, |
|||
}); |
|||
|
|||
expect(preferenceManager.getPreferences().app.themeMode).toBe('light'); |
|||
}); |
|||
|
|||
it('updates color modes correctly', () => { |
|||
preferenceManager.updatePreferences({ |
|||
app: { colorGrayMode: true, colorWeakMode: true }, |
|||
}); |
|||
|
|||
expect(preferenceManager.getPreferences().app.colorGrayMode).toBe(true); |
|||
expect(preferenceManager.getPreferences().app.colorWeakMode).toBe(true); |
|||
}); |
|||
|
|||
it('resets preferences to default', () => { |
|||
// 先更新一些偏好设置
|
|||
preferenceManager.updatePreferences({ |
|||
app: { themeMode: 'light' }, |
|||
}); |
|||
|
|||
// 然后重置偏好设置
|
|||
preferenceManager.resetPreferences(); |
|||
|
|||
expect(preferenceManager.getPreferences()).toEqual(defaultPreferences); |
|||
}); |
|||
|
|||
it('updates isMobile correctly', () => { |
|||
// 模拟移动端状态
|
|||
vi.stubGlobal( |
|||
'matchMedia', |
|||
vi.fn().mockImplementation((query) => ({ |
|||
addEventListener: vi.fn(), |
|||
addListener: vi.fn(), |
|||
dispatchEvent: vi.fn(), |
|||
matches: query === '(max-width: 768px)', |
|||
media: query, |
|||
onchange: null, |
|||
removeEventListener: vi.fn(), |
|||
removeListener: vi.fn(), |
|||
})), |
|||
); |
|||
|
|||
preferenceManager.updatePreferences({ |
|||
app: { isMobile: true }, |
|||
}); |
|||
|
|||
expect(preferenceManager.getPreferences().app.isMobile).toBe(true); |
|||
}); |
|||
|
|||
it('updates the locale preference correctly', () => { |
|||
preferenceManager.updatePreferences({ |
|||
app: { locale: 'en-US' }, |
|||
}); |
|||
|
|||
expect(preferenceManager.getPreferences().app.locale).toBe('en-US'); |
|||
}); |
|||
|
|||
it('updates the sidebar width correctly', () => { |
|||
preferenceManager.updatePreferences({ |
|||
sidebar: { width: 200 }, |
|||
}); |
|||
|
|||
expect(preferenceManager.getPreferences().sidebar.width).toBe(200); |
|||
}); |
|||
it('updates the sidebar collapse state correctly', () => { |
|||
preferenceManager.updatePreferences({ |
|||
sidebar: { collapse: true }, |
|||
}); |
|||
|
|||
expect(preferenceManager.getPreferences().sidebar.collapse).toBe(true); |
|||
}); |
|||
it('updates the navigation style type correctly', () => { |
|||
preferenceManager.updatePreferences({ |
|||
navigation: { styleType: 'flat' }, |
|||
} as any); |
|||
|
|||
expect(preferenceManager.getPreferences().navigation.styleType).toBe( |
|||
'flat', |
|||
); |
|||
}); |
|||
|
|||
it('resets preferences to default correctly', () => { |
|||
// 先更新一些偏好设置
|
|||
preferenceManager.updatePreferences({ |
|||
app: { locale: 'en-US', themeMode: 'light' }, |
|||
sidebar: { collapse: true, width: 200 }, |
|||
}); |
|||
|
|||
// 然后重置偏好设置
|
|||
preferenceManager.resetPreferences(); |
|||
|
|||
expect(preferenceManager.getPreferences()).toEqual(defaultPreferences); |
|||
}); |
|||
|
|||
it('does not update undefined preferences', () => { |
|||
const originalPreferences = preferenceManager.getPreferences(); |
|||
|
|||
preferenceManager.updatePreferences({ |
|||
app: { nonexistentField: 'value' }, |
|||
} as any); |
|||
|
|||
expect(preferenceManager.getPreferences()).toEqual(originalPreferences); |
|||
}); |
|||
|
|||
it('reverts to default when a preference field is deleted', () => { |
|||
preferenceManager.updatePreferences({ |
|||
app: { locale: 'en-US' }, |
|||
}); |
|||
|
|||
preferenceManager.updatePreferences({ |
|||
app: { locale: undefined }, |
|||
}); |
|||
|
|||
expect(preferenceManager.getPreferences().app.locale).toBe('en-US'); |
|||
}); |
|||
|
|||
it('ignores updates with invalid preference value types', () => { |
|||
const originalPreferences = preferenceManager.getPreferences(); |
|||
|
|||
preferenceManager.updatePreferences({ |
|||
app: { isMobile: 'true' as unknown as boolean }, // 错误类型
|
|||
}); |
|||
|
|||
expect(preferenceManager.getPreferences()).toEqual(originalPreferences); |
|||
}); |
|||
|
|||
it('merges nested preference objects correctly', () => { |
|||
preferenceManager.updatePreferences({ |
|||
app: { name: 'New App Name' }, |
|||
}); |
|||
|
|||
const expected = { |
|||
...defaultPreferences, |
|||
app: { |
|||
...defaultPreferences.app, |
|||
name: 'New App Name', |
|||
}, |
|||
}; |
|||
|
|||
expect(preferenceManager.getPreferences()).toEqual(expected); |
|||
}); |
|||
|
|||
it('applies updates immediately after initialization', async () => { |
|||
const overrides: any = { |
|||
app: { |
|||
locale: 'en-US', |
|||
}, |
|||
}; |
|||
|
|||
await preferenceManager.initPreferences(overrides); |
|||
|
|||
preferenceManager.updatePreferences({ |
|||
app: { themeMode: 'light' }, |
|||
}); |
|||
|
|||
expect(preferenceManager.getPreferences().app.themeMode).toBe('light'); |
|||
}); |
|||
}); |
|||
|
|||
describe('isDarkTheme', () => { |
|||
it('should return true for dark theme', () => { |
|||
expect(isDarkTheme('dark')).toBe(true); |
|||
}); |
|||
|
|||
it('should return false for light theme', () => { |
|||
expect(isDarkTheme('light')).toBe(false); |
|||
}); |
|||
|
|||
it('should return system preference for auto theme', () => { |
|||
vi.spyOn(window, 'matchMedia').mockImplementation((query) => ({ |
|||
addEventListener: vi.fn(), |
|||
addListener: vi.fn(), // Deprecated
|
|||
dispatchEvent: vi.fn(), |
|||
matches: query === '(prefers-color-scheme: dark)', |
|||
media: query, |
|||
onchange: null, |
|||
removeEventListener: vi.fn(), |
|||
removeListener: vi.fn(), // Deprecated
|
|||
})); |
|||
|
|||
expect(isDarkTheme('auto')).toBe(true); |
|||
expect(window.matchMedia).toHaveBeenCalledWith( |
|||
'(prefers-color-scheme: dark)', |
|||
); |
|||
}); |
|||
}); |
|||
@ -1,24 +1,85 @@ |
|||
import { createPinia, setActivePinia } from 'pinia'; |
|||
import { |
|||
// beforeEach,
|
|||
describe, |
|||
// expect,
|
|||
it, |
|||
} from 'vitest'; |
|||
import { beforeEach, describe, expect, it } from 'vitest'; |
|||
|
|||
// import { useAccessStore } from '../modules/access';
|
|||
import { useAccessStore } from './access'; |
|||
|
|||
describe('useAccessStore', () => { |
|||
it('app Name with test', () => { |
|||
beforeEach(() => { |
|||
setActivePinia(createPinia()); |
|||
// let referenceStore = usePreferencesStore();
|
|||
}); |
|||
|
|||
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 userInfo and userRoles state', () => { |
|||
const store = useAccessStore(); |
|||
expect(store.userInfo).toBeNull(); |
|||
expect(store.userRoles).toEqual([]); |
|||
|
|||
const userInfo: any = { name: 'John Doe', roles: [{ value: 'admin' }] }; |
|||
store.setUserInfo(userInfo); |
|||
|
|||
// beforeEach(() => {
|
|||
// referenceStore = usePreferencesStore();
|
|||
// });
|
|||
expect(store.userInfo).toEqual(userInfo); |
|||
expect(store.userRoles).toEqual(['admin']); |
|||
}); |
|||
|
|||
it('returns correct userInfo', () => { |
|||
const store = useAccessStore(); |
|||
const userInfo: any = { name: 'Jane Doe', roles: [{ value: 'user' }] }; |
|||
store.setUserInfo(userInfo); |
|||
expect(store.getUserInfo).toEqual(userInfo); |
|||
}); |
|||
|
|||
it('updates accessToken state correctly', () => { |
|||
const store = useAccessStore(); |
|||
expect(store.accessToken).toBeNull(); // 初始状态
|
|||
store.setAccessToken('abc123'); |
|||
expect(store.accessToken).toBe('abc123'); |
|||
}); |
|||
|
|||
// 测试重置用户信息时的行为
|
|||
it('clears userInfo and userRoles when setting null userInfo', () => { |
|||
const store = useAccessStore(); |
|||
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 the correct accessToken', () => { |
|||
const store = useAccessStore(); |
|||
store.setAccessToken('xyz789'); |
|||
expect(store.getAccessToken).toBe('xyz789'); |
|||
}); |
|||
|
|||
// 测试在没有用户角色时返回空数组
|
|||
it('returns an empty array for userRoles if not set', () => { |
|||
const store = useAccessStore(); |
|||
expect(store.getUserRoles).toEqual([]); |
|||
}); |
|||
|
|||
// 测试设置空的访问菜单列表
|
|||
it('handles empty accessMenus correctly', () => { |
|||
const store = useAccessStore(); |
|||
store.setAccessMenus([]); |
|||
expect(store.accessMenus).toEqual([]); |
|||
}); |
|||
|
|||
// expect(referenceStore.appName).toBe('vben-admin');
|
|||
// referenceStore.setAppName('vbenAdmin');
|
|||
// expect(referenceStore.getAppName).toBe('vbenAdmin');
|
|||
// 测试设置空的访问路由列表
|
|||
it('handles empty accessRoutes correctly', () => { |
|||
const store = useAccessStore(); |
|||
store.setAccessRoutes([]); |
|||
expect(store.accessRoutes).toEqual([]); |
|||
}); |
|||
}); |
|||
|
|||
@ -0,0 +1,309 @@ |
|||
import { createPinia, setActivePinia } from 'pinia'; |
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'; |
|||
import { createRouter, createWebHistory } from 'vue-router'; |
|||
|
|||
import { useTabsStore } from './tabs'; |
|||
|
|||
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 = useTabsStore(); |
|||
const tab: any = { |
|||
fullPath: '/home', |
|||
meta: {}, |
|||
name: 'Home', |
|||
path: '/home', |
|||
}; |
|||
store.addTab(tab); |
|||
expect(store.tabs.length).toBe(1); |
|||
expect(store.tabs[0]).toEqual(tab); |
|||
}); |
|||
|
|||
it('adds a new tab if it does not exist', () => { |
|||
const store = useTabsStore(); |
|||
const newTab: any = { |
|||
fullPath: '/new', |
|||
meta: {}, |
|||
name: 'New', |
|||
path: '/new', |
|||
}; |
|||
store.addTab(newTab); |
|||
expect(store.tabs).toContainEqual(newTab); |
|||
}); |
|||
|
|||
it('updates an existing tab instead of adding a new one', () => { |
|||
const store = useTabsStore(); |
|||
const initialTab: any = { |
|||
fullPath: '/existing', |
|||
meta: {}, |
|||
name: 'Existing', |
|||
path: '/existing', |
|||
query: {}, |
|||
}; |
|||
store.tabs.push(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 = useTabsStore(); |
|||
store.tabs = [ |
|||
{ fullPath: '/home', meta: {}, name: 'Home', path: '/home' }, |
|||
] as any; |
|||
router.replace = vi.fn(); // 使用 vitest 的 mock 函数
|
|||
|
|||
await store.closeAllTabs(router); |
|||
|
|||
expect(store.tabs.length).toBe(0); // 假设没有固定的标签页
|
|||
// expect(router.replace).toHaveBeenCalled();
|
|||
}); |
|||
|
|||
it('returns all tabs including affix tabs', () => { |
|||
const store = useTabsStore(); |
|||
store.tabs = [ |
|||
{ fullPath: '/home', meta: {}, name: 'Home', path: '/home' }, |
|||
] as any; |
|||
store.affixTabs = [ |
|||
{ meta: { hideInTab: false }, path: '/dashboard' }, |
|||
] as any; |
|||
|
|||
const result = store.getTabs; |
|||
expect(result.length).toBe(2); |
|||
expect(result.find((tab) => tab.path === '/dashboard')).toBeDefined(); |
|||
}); |
|||
|
|||
it('closes a non-affix tab', () => { |
|||
const store = useTabsStore(); |
|||
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 = useTabsStore(); |
|||
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 = useTabsStore(); |
|||
store.cacheTabs.add('Home'); |
|||
store.cacheTabs.add('About'); |
|||
expect(store.getCacheTabs).toEqual(['Home', 'About']); |
|||
}); |
|||
|
|||
it('returns all tabs, including affix tabs', () => { |
|||
const store = useTabsStore(); |
|||
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.getTabs).toContainEqual(affixTab);
|
|||
}); |
|||
|
|||
it('navigates to a specific tab', async () => { |
|||
const store = useTabsStore(); |
|||
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 = useTabsStore(); |
|||
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._bulkCloseByPaths(['/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 = useTabsStore(); |
|||
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', |
|||
}; |
|||
store.addTab(targetTab); |
|||
|
|||
await store.closeLeftTabs(targetTab); |
|||
|
|||
expect(store.tabs).toHaveLength(1); |
|||
expect(store.tabs[0].name).toBe('Contact'); |
|||
}); |
|||
|
|||
it('closes all tabs except the specified tab', async () => { |
|||
const store = useTabsStore(); |
|||
store.addTab({ |
|||
fullPath: '/home', |
|||
meta: {}, |
|||
name: 'Home', |
|||
path: '/home', |
|||
} as any); |
|||
const targetTab: any = { |
|||
fullPath: '/about', |
|||
meta: {}, |
|||
name: 'About', |
|||
path: '/about', |
|||
}; |
|||
store.addTab(targetTab); |
|||
store.addTab({ |
|||
fullPath: '/contact', |
|||
meta: {}, |
|||
name: 'Contact', |
|||
path: '/contact', |
|||
} as any); |
|||
|
|||
await store.closeOtherTabs(targetTab); |
|||
|
|||
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 = useTabsStore(); |
|||
const targetTab: any = { |
|||
fullPath: '/home', |
|||
meta: {}, |
|||
name: 'Home', |
|||
path: '/home', |
|||
}; |
|||
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(targetTab); |
|||
|
|||
expect(store.tabs).toHaveLength(1); |
|||
expect(store.tabs[0].name).toBe('Home'); |
|||
}); |
|||
|
|||
it('closes the tab with the specified key', async () => { |
|||
const store = useTabsStore(); |
|||
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 = useTabsStore(); |
|||
const currentTab: any = { |
|||
fullPath: '/dashboard', |
|||
meta: { name: 'Dashboard' }, |
|||
name: 'Dashboard', |
|||
path: '/dashboard', |
|||
}; |
|||
router.currentRoute.value = currentTab; |
|||
|
|||
await store.refreshTab(router); |
|||
|
|||
expect(store.excludeCacheTabs.has('Dashboard')).toBe(false); |
|||
expect(store.renderRouteView).toBe(true); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,90 @@ |
|||
interface RouteMeta { |
|||
/** |
|||
* 是否固定标签页 |
|||
* @default false |
|||
*/ |
|||
affixTab?: boolean; |
|||
/** |
|||
* 需要特定的角色标识才可以访问 |
|||
* @default [] |
|||
*/ |
|||
authority?: string[]; |
|||
/** |
|||
* 徽标 |
|||
*/ |
|||
badge?: string; |
|||
/** |
|||
* 徽标类型 |
|||
*/ |
|||
badgeType?: 'dot' | 'normal'; |
|||
/** |
|||
* 徽标颜色 |
|||
*/ |
|||
badgeVariants?: |
|||
| 'default' |
|||
| 'destructive' |
|||
| 'primary' |
|||
| 'success' |
|||
| 'warning' |
|||
| string; |
|||
/** |
|||
* 当前路由的子级在菜单中不展现 |
|||
* @default false |
|||
*/ |
|||
hideChildrenInMenu?: boolean; |
|||
/** |
|||
* 当前路由在面包屑中不展现 |
|||
* @default false |
|||
*/ |
|||
hideInBreadcrumb?: boolean; |
|||
/** |
|||
* 当前路由在菜单中不展现 |
|||
* @default false |
|||
*/ |
|||
hideInMenu?: boolean; |
|||
|
|||
/** |
|||
* 当前路由在标签页不展现 |
|||
* @default false |
|||
*/ |
|||
hideInTab?: boolean; |
|||
/** |
|||
* 路由跳转地址 |
|||
*/ |
|||
href?: string; |
|||
/** |
|||
* 图标(菜单/tab) |
|||
*/ |
|||
icon?: string; |
|||
/** |
|||
* iframe 地址 |
|||
*/ |
|||
iframeSrc?: string; |
|||
/** |
|||
* 忽略权限,直接可以访问 |
|||
* @default false |
|||
*/ |
|||
ignoreAccess?: boolean; |
|||
/** |
|||
* 开启KeepAlive缓存 |
|||
*/ |
|||
keepAlive?: boolean; |
|||
/** |
|||
* 路由是否已经加载过 |
|||
*/ |
|||
loaded?: boolean; |
|||
/** |
|||
* 用于路由->菜单排序 |
|||
*/ |
|||
orderNo?: number; |
|||
/** |
|||
* 外链-跳转路径 |
|||
*/ |
|||
target?: string; |
|||
/** |
|||
* 标题名称 |
|||
*/ |
|||
title: string; |
|||
} |
|||
|
|||
export type { RouteMeta }; |
|||
@ -0,0 +1,7 @@ |
|||
import 'vue-router'; |
|||
|
|||
import type { RouteMeta as IRouteMeta } from '@vben-core/typings'; |
|||
|
|||
declare module 'vue-router' { |
|||
interface RouteMeta extends IRouteMeta {} |
|||
} |
|||
@ -1 +1 @@ |
|||
export * from './use-request'; |
|||
export {}; |
|||
|
|||
@ -1 +0,0 @@ |
|||
export { default as useRequest } from 'vue-hooks-plus/es/useRequest'; |
|||
@ -0,0 +1,7 @@ |
|||
import { defineBuildConfig } from 'unbuild'; |
|||
|
|||
export default defineBuildConfig({ |
|||
clean: true, |
|||
declaration: true, |
|||
entries: ['src/index'], |
|||
}); |
|||
@ -0,0 +1,46 @@ |
|||
{ |
|||
"name": "@vben/request", |
|||
"version": "1.0.0", |
|||
"type": "module", |
|||
"license": "MIT", |
|||
"homepage": "https://github.com/vbenjs/vue-vben-admin", |
|||
"repository": { |
|||
"type": "git", |
|||
"url": "git+https://github.com/vbenjs/vue-vben-admin.git", |
|||
"directory": "packages/request" |
|||
}, |
|||
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues", |
|||
"scripts": { |
|||
"build": "pnpm unbuild", |
|||
"stub": "pnpm unbuild --stub" |
|||
}, |
|||
"files": [ |
|||
"dist" |
|||
], |
|||
"sideEffects": [ |
|||
"**/*.css" |
|||
], |
|||
"main": "./dist/index.mjs", |
|||
"module": "./dist/index.mjs", |
|||
"imports": { |
|||
"#*": "./src/*" |
|||
}, |
|||
"exports": { |
|||
".": { |
|||
"types": "./src/index.ts", |
|||
"development": "./src/index.ts", |
|||
"default": "./dist/index.mjs" |
|||
} |
|||
}, |
|||
"publishConfig": { |
|||
"exports": { |
|||
".": { |
|||
"types": "./dist/index.d.ts", |
|||
"default": "./dist/index.mjs" |
|||
} |
|||
} |
|||
}, |
|||
"dependencies": { |
|||
"vue-request": "^2.0.4" |
|||
} |
|||
} |
|||
@ -0,0 +1 @@ |
|||
export * from './use-request'; |
|||
@ -0,0 +1,11 @@ |
|||
// import { setGlobalOptions, } from 'vue-request';
|
|||
|
|||
// setGlobalOptions({
|
|||
// manual: true,
|
|||
// // ...
|
|||
// });
|
|||
|
|||
/** |
|||
* @see https://www.attojs.com/guide/documentation/globalOptions.html
|
|||
*/ |
|||
export * from 'vue-request'; |
|||
@ -0,0 +1,5 @@ |
|||
{ |
|||
"$schema": "https://json.schemastore.org/tsconfig", |
|||
"extends": "@vben/tsconfig/web.json", |
|||
"include": ["src"] |
|||
} |
|||
@ -1,92 +1,7 @@ |
|||
import 'vue-router'; |
|||
|
|||
declare module 'vue-router' { |
|||
interface RouteMeta { |
|||
/** |
|||
* 是否固定标签页 |
|||
* @default false |
|||
*/ |
|||
affixTab?: boolean; |
|||
/** |
|||
* 需要特定的角色标识才可以访问 |
|||
* @default [] |
|||
*/ |
|||
authority?: string[]; |
|||
/** |
|||
* 徽标 |
|||
*/ |
|||
badge?: string; |
|||
/** |
|||
* 徽标类型 |
|||
*/ |
|||
badgeType?: 'dot' | 'normal'; |
|||
/** |
|||
* 徽标颜色 |
|||
*/ |
|||
badgeVariants?: |
|||
| 'default' |
|||
| 'destructive' |
|||
| 'primary' |
|||
| 'success' |
|||
| 'warning' |
|||
| string; |
|||
/** |
|||
* 当前路由的子级在菜单中不展现 |
|||
* @default false |
|||
*/ |
|||
hideChildrenInMenu?: boolean; |
|||
/** |
|||
* 当前路由在面包屑中不展现 |
|||
* @default false |
|||
*/ |
|||
hideInBreadcrumb?: boolean; |
|||
/** |
|||
* 当前路由在菜单中不展现 |
|||
* @default false |
|||
*/ |
|||
hideInMenu?: boolean; |
|||
import type { RouteMeta as IRouteMeta } from '@vben-core/typings'; |
|||
|
|||
/** |
|||
* 当前路由在标签页不展现 |
|||
* @default false |
|||
*/ |
|||
hideInTab?: boolean; |
|||
/** |
|||
* 路由跳转地址 |
|||
*/ |
|||
href?: string; |
|||
/** |
|||
* 图标(菜单/tab) |
|||
*/ |
|||
icon?: string; |
|||
/** |
|||
* iframe 地址 |
|||
*/ |
|||
iframeSrc?: string; |
|||
/** |
|||
* 忽略权限,直接可以访问 |
|||
* @default false |
|||
*/ |
|||
ignoreAccess?: boolean; |
|||
/** |
|||
* 开启KeepAlive缓存 |
|||
*/ |
|||
keepAlive?: boolean; |
|||
/** |
|||
* 路由是否已经加载过 |
|||
*/ |
|||
loaded?: boolean; |
|||
/** |
|||
* 用于路由->菜单排序 |
|||
*/ |
|||
orderNo?: number; |
|||
/** |
|||
* 外链-跳转路径 |
|||
*/ |
|||
target?: string; |
|||
/** |
|||
* 标题名称 |
|||
*/ |
|||
title: string; |
|||
} |
|||
declare module 'vue-router' { |
|||
interface RouteMeta extends IRouteMeta {} |
|||
} |
|||
|
|||
Loading…
Reference in new issue