feat: Dynamically get the menu from the back end

This commit is contained in:
vben 2024-06-30 15:03:37 +08:00
parent 1d70d71537
commit 9572d1a1c5
71 changed files with 1033 additions and 509 deletions

View File

@ -171,10 +171,7 @@
"packages/@vben-core/shared/design-tokens/src/**/*.css"
],
"i18n-ally.localesPaths": [
"packages/locales/src/langs",
"packages/@core/shared/i18n/src/langs"
],
"i18n-ally.localesPaths": ["packages/locales/src/langs"],
"i18n-ally.enabledParsers": ["json", "ts", "js", "yaml"],
"i18n-ally.sourceLanguage": "en",
"i18n-ally.displayLanguage": "zh-CN",

View File

@ -0,0 +1,6 @@
@port = 5320
@type = application/json
@token = Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MCwicm9sZXMiOlsiYWRtaW4iXSwidXNlcm5hbWUiOiJ2YmVuIiwiaWF0IjoxNzE5ODkwMTEwLCJleHAiOjE3MTk5NzY1MTB9.eyAFsQ2Jk_mAQGvrEL1jF9O6YmLZ_PSYj5aokL6fCuU
GET http://localhost:{{port}}/api/menu/getAll HTTP/1.1
content-type: {{ type }}
Authorization: {{ token }}

View File

@ -36,8 +36,8 @@
"typeorm": "^0.3.20"
},
"devDependencies": {
"@nestjs/cli": "^10.3.2",
"@nestjs/schematics": "^10.1.1",
"@nestjs/cli": "^10.4.0",
"@nestjs/schematics": "^10.1.2",
"@types/express": "^4.17.21",
"@types/node": "^20.14.9",
"nodemon": "^3.1.4",

View File

@ -7,6 +7,7 @@ import Joi from 'joi';
import { AuthModule } from './modules/auth/auth.module';
import { DatabaseModule } from './modules/database/database.module';
import { HealthModule } from './modules/health/health.module';
import { MenuModule } from './modules/menu/menu.module';
import { UsersModule } from './modules/users/users.module';
@Module({
@ -34,6 +35,7 @@ import { UsersModule } from './modules/users/users.module';
AuthModule,
UsersModule,
DatabaseModule,
MenuModule,
],
})
export class AppModule {}

View File

@ -0,0 +1,9 @@
class CreateUserDto {
id: number;
password: string;
realName: string;
roles: string[];
username: string;
}
export { CreateUserDto };

View File

@ -0,0 +1,62 @@
import { sleep } from '@/utils';
import { Controller, Get, HttpCode, HttpStatus, Request } from '@nestjs/common';
@Controller('menu')
export class MenuController {
/**
*
*/
@Get('getAll')
@HttpCode(HttpStatus.OK)
async getAll(@Request() req: Request) {
// 模拟请求延迟
await sleep(1000);
// 请求用户的id
const userId = req.user.id;
// TODO: 改为表方式获取
const dashboardMenus = [
{
component: 'BasicLayout',
meta: {
order: -1,
title: 'page.dashboard.title',
},
name: 'Dashboard',
path: '/',
redirect: '/analytics',
children: [
{
name: 'Analytics',
path: '/analytics',
component: '/dashboard/analytics/index',
meta: {
affixTab: true,
title: 'page.dashboard.analytics',
},
},
{
name: 'Workspace',
path: '/workspace',
component: '/dashboard/workspace/index',
meta: {
title: 'page.dashboard.workspace',
},
},
],
},
];
const MOCK_MENUS = [
{
menus: [...dashboardMenus],
userId: 0,
},
{
menus: [...dashboardMenus],
userId: 1,
},
];
return MOCK_MENUS.find((item) => item.userId === userId)?.menus ?? [];
}
}

View File

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { MenuController } from './menu.controller';
import { MenuService } from './menu.service';
@Module({
controllers: [MenuController],
providers: [MenuService],
})
export class MenuModule {}

View File

@ -0,0 +1,4 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class MenuService {}

View File

@ -1,3 +1,4 @@
import type { CreateUserDto } from '@/models/dto/user.dto';
import type { Repository } from 'typeorm';
import { UserEntity } from '@/models/entity/user.entity';
@ -12,7 +13,7 @@ export class UsersService {
private usersRepository: Repository<UserEntity>,
) {}
async create(user: UserEntity): Promise<UserEntity> {
async create(user: CreateUserDto): Promise<UserEntity> {
user.password = await bcrypt.hash(user.password, 10); // 密码哈希
return this.usersRepository.save(user);
}

View File

@ -0,0 +1,5 @@
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export { sleep };

View File

@ -31,10 +31,10 @@
"@vben-core/stores": "workspace:*",
"@vben/chart-ui": "workspace:*",
"@vben/constants": "workspace:*",
"@vben/hooks": "workspace:*",
"@vben/icons": "workspace:*",
"@vben/layouts": "workspace:*",
"@vben/locales": "workspace:*",
"@vben/access": "workspace:*",
"@vben/styles": "workspace:*",
"@vben/types": "workspace:*",
"@vben/universal-ui": "workspace:*",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 894 B

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@ -1 +1,2 @@
export * from './menu';
export * from './user';

View File

@ -0,0 +1,12 @@
import type { RouteRecordStringComponent } from '@vben/types';
import { requestClient } from '#/forward';
/**
*
*/
async function getAllMenus() {
return requestClient.get<RouteRecordStringComponent[]>('/menu/getAll');
}
export { getAllMenus };

View File

@ -19,5 +19,3 @@ async function getUserInfo() {
}
export { getUserInfo, userLogin };
export * from './user';

View File

@ -0,0 +1,40 @@
import type { GeneratorMenuAndRoutesOptions } from '@vben/access';
import type { ComponentRecordType } from '@vben/types';
import { generateMenusAndRoutes } from '@vben/access';
import { $t } from '@vben/locales';
import { preferences } from '@vben-core/preferences';
import { message } from 'ant-design-vue';
import { getAllMenus } from '#/apis';
import { BasicLayout, IFrameView } from '#/layouts';
const forbiddenPage = () => import('#/views/_essential/fallback/forbidden.vue');
async function generateAccess(options: GeneratorMenuAndRoutesOptions) {
const pageMap: ComponentRecordType = import.meta.glob('../views/**/*.vue');
const layoutMap: ComponentRecordType = {
BasicLayout,
IFrameView,
};
return await generateMenusAndRoutes(preferences.app.accessMode, {
...options,
fetchMenuListAsync: async () => {
message.loading({
content: `${$t('common.loading-menu')}...`,
duration: 1.5,
});
return await getAllMenus();
},
// 可以指定没有权限跳转403页面
forbiddenComponent: forbiddenPage,
// 如果 route.meta.menuVisibleWithForbidden = true
layoutMap,
pageMap,
});
}
export { generateAccess };

View File

@ -10,8 +10,11 @@ import { $t } from '@vben/locales';
import { openWindow } from '@vben/utils';
import { Notification, UserDropdown } from '@vben/widgets';
import { preferences } from '@vben-core/preferences';
import { useRequest } from '@vben-core/request';
import { useAccessStore } from '@vben-core/stores';
import { getUserInfo } from '#/apis';
// https://avatar.vercel.sh/vercel.svg?text=Vaa
// https://avatar.vercel.sh/1
// https://avatar.vercel.sh/nextjs
@ -80,6 +83,14 @@ const menus = computed(() => [
const accessStore = useAccessStore();
const router = useRouter();
const { runAsync: runGetUserInfo } = useRequest(getUserInfo, {
manual: true,
});
runGetUserInfo().then((userInfo) => {
accessStore.setUserInfo(userInfo);
});
function handleLogout() {
accessStore.$reset();
router.replace('/auth/login');

View File

@ -3,16 +3,14 @@ import type { Router } from 'vue-router';
import { LOGIN_PATH } from '@vben/constants';
import { $t } from '@vben/locales';
import { startProgress, stopProgress } from '@vben/utils';
import { generatorMenus, generatorRoutes } from '@vben-core/helpers';
import { preferences } from '@vben-core/preferences';
import { useAccessStore } from '@vben-core/stores';
import { useTitle } from '@vueuse/core';
import { generateAccess } from '#/forward/access';
import { dynamicRoutes, essentialsRouteNames } from '#/router/routes';
const forbiddenPage = () => import('#/views/_essential/fallback/forbidden.vue');
/**
*
* @param router
@ -96,22 +94,16 @@ function setupAccessGuard(router: Router) {
// 当前登录用户拥有的角色标识列表
const userRoles = accessStore.getUserRoles;
const accessibleRoutes = await generatorRoutes(
dynamicRoutes,
userRoles,
// 如果 route.meta.menuVisibleWithForbidden = true
// 生成菜单和路由
const { accessibleMenus, accessibleRoutes } = await generateAccess({
roles: userRoles,
router,
// 则会在菜单中显示但是访问会被重定向到403
// 这里可以指定403页面
forbiddenPage,
);
// 动态添加到router实例内
accessibleRoutes.forEach((route) => router.addRoute(route));
// 生成菜单
const menus = await generatorMenus(accessibleRoutes, router);
routes: dynamicRoutes,
});
// 保存菜单信息和路由信息
accessStore.setAccessMenus(menus);
accessStore.setAccessMenus(accessibleMenus);
accessStore.setAccessRoutes(accessibleRoutes);
const redirectPath = (from.query.redirect ?? to.path) as string;

View File

@ -15,7 +15,7 @@ const fallbackNotFoundRoute: RouteRecordRaw = {
hideInTab: true,
title: '404',
},
name: 'Fallback',
name: 'FallbackNotFound',
path: '/:path(.*)*',
};

View File

@ -15,14 +15,108 @@ const routes: RouteRecordRaw[] = [
},
name: 'Demos',
path: '/demos',
redirect: '/demos/fallback/403',
redirect: '/demos/access/frontend',
children: [
{
meta: {
icon: 'mdi:shield-key-outline',
title: $t('page.demos.access.title'),
},
name: 'Access',
path: '/access',
redirect: '/access/frontend',
children: [
{
name: 'AccessFrontend',
path: 'frontend',
meta: {
icon: 'mdi:table-key',
title: $t('page.demos.access.frontend-control'),
},
children: [
{
name: 'AccessFrontendPageControl',
path: 'page-control',
component: () =>
import('#/views/demos/access/frontend/index.vue'),
meta: {
icon: 'mdi:page-previous-outline',
title: $t('page.demos.access.page'),
},
},
{
name: 'AccessFrontendButtonControl',
path: 'button-control',
component: () =>
import('#/views/demos/access/frontend/button-control.vue'),
meta: {
icon: 'mdi:button-cursor',
title: $t('page.demos.access.button'),
},
},
{
name: 'AccessFrontendTest1',
path: 'access-test-1',
component: () =>
import('#/views/demos/access/frontend/access-test-1.vue'),
meta: {
authority: ['admin'],
icon: 'mdi:button-cursor',
title: $t('page.demos.access.access-test-1'),
},
},
{
name: 'AccessFrontendTest2',
path: 'access-test-2',
component: () =>
import('#/views/demos/access/frontend/access-test-2.vue'),
meta: {
authority: ['user'],
icon: 'mdi:button-cursor',
title: $t('page.demos.access.access-test-2'),
},
},
],
},
{
name: 'AccessBackend',
path: 'backend',
component: () => import('#/views/demos/access/backend/index.vue'),
meta: {
icon: 'mdi:cloud-key-outline',
title: $t('page.demos.access.backend-control'),
},
children: [
{
name: 'AccessBackendPageControl',
path: 'page-control',
component: () =>
import('#/views/demos/access/frontend/index.vue'),
meta: {
icon: 'mdi:page-previous-outline',
title: $t('page.demos.access.page'),
},
},
{
name: 'AccessBackendButtonControl',
path: 'button-control',
component: () =>
import('#/views/demos/access/frontend/button-control.vue'),
meta: {
icon: 'mdi:button-cursor',
title: $t('page.demos.access.button'),
},
},
],
},
],
},
{
meta: {
icon: 'mdi:lightbulb-error-outline',
title: $t('page.demos.fallback.title'),
},
name: 'FallbackLayout',
name: 'Fallback',
path: '/fallback',
redirect: '/fallback/403',
children: [

View File

@ -2,7 +2,7 @@ import type { InitStoreOptions } from '@vben-core/stores';
import type { App } from 'vue';
import { initStore, useAccessStore, useTabsStore } from '@vben-core/stores';
import { initStore, useAccessStore, useTabbarStore } from '@vben-core/stores';
/**
* @zh_CN pinia
@ -13,4 +13,4 @@ async function setupStore(app: App, options: InitStoreOptions) {
app.use(pinia);
}
export { setupStore, useAccessStore, useTabsStore };
export { setupStore, useAccessStore, useTabbarStore };

View File

@ -0,0 +1,9 @@
<script lang="ts" setup>
import { Fallback } from '@vben/universal-ui';
defineOptions({ name: 'AccessBackendButtonControl' });
</script>
<template>
<Fallback status="comming-soon" />
</template>

View File

@ -0,0 +1,9 @@
<script lang="ts" setup>
import { Fallback } from '@vben/universal-ui';
defineOptions({ name: 'AccessFrontend' });
</script>
<template>
<Fallback status="comming-soon" />
</template>

View File

@ -0,0 +1,13 @@
<script lang="ts" setup>
import { Fallback } from '@vben/universal-ui';
defineOptions({ name: 'AccessFrontendAccessTest1' });
</script>
<template>
<Fallback
description="当前页面仅 Admin 角色可见"
status="comming-soon"
title="页面访问测试"
/>
</template>

View File

@ -0,0 +1,13 @@
<script lang="ts" setup>
import { Fallback } from '@vben/universal-ui';
defineOptions({ name: 'AccessFrontendAccessTest2' });
</script>
<template>
<Fallback
description="当前页面仅 User 角色可见"
status="comming-soon"
title="页面访问测试"
/>
</template>

View File

@ -0,0 +1,9 @@
<script lang="ts" setup>
import { Fallback } from '@vben/universal-ui';
defineOptions({ name: 'AccessFrontendButtonControl' });
</script>
<template>
<Fallback status="comming-soon" />
</template>

View File

@ -0,0 +1,45 @@
<script lang="ts" setup>
import { useAccess } from '@vben/access';
import { useAccessStore } from '@vben-core/stores';
import { Button } from 'ant-design-vue';
defineOptions({ name: 'AccessBackend' });
const { currentAccessMode } = useAccess();
const accessStore = useAccessStore();
function roleButtonType(role: string) {
return accessStore.getUserRoles.includes(role) ? 'primary' : 'default';
}
</script>
<template>
<div class="p-5">
<div class="card-box p-5">
<h1 class="text-xl font-semibold">前端页面访问演示</h1>
<div class="text-foreground/80 mt-2">
由于刷新的时候会请求用户信息接口会根据接口重置角色信息所以刷新后界面会恢复原样如果不需要可以注释对应的代码
</div>
</div>
<template v-if="currentAccessMode === 'frontend'">
<div class="card-box mt-5 p-5 font-semibold">
当前权限模式:
<span class="text-primary mx-4">{{ currentAccessMode }}</span>
<Button type="primary">切换权限模式</Button>
</div>
<div class="card-box mt-5 p-5 font-semibold">
当前用户角色:
<span class="text-primary mx-4">{{ accessStore.getUserRoles }}</span>
<Button :type="roleButtonType('admin')"> 切换为 Admin 角色 </Button>
<Button :type="roleButtonType('user')" class="mx-4">
切换为 User 角色
</Button>
<div class="text-foreground/80 mt-2">角色后请查看左侧菜单变化</div>
</div>
</template>
</div>
</template>

View File

@ -48,8 +48,8 @@
"eslint-plugin-unicorn": "^54.0.0",
"eslint-plugin-unused-imports": "^4.0.0",
"eslint-plugin-vitest": "^0.5.4",
"eslint-plugin-vue": "^9.26.0",
"globals": "^15.7.0",
"eslint-plugin-vue": "^9.27.0",
"globals": "^15.8.0",
"jsonc-eslint-parser": "^2.4.0",
"vue-eslint-parser": "^9.4.3"
}

View File

@ -34,7 +34,7 @@
"check:type": "turbo run typecheck",
"clean": "vsh clean",
"commit": "czg",
"dev": "turbo run dev --parallel",
"dev": "cross-env TURBO_UI=1 turbo run dev --parallel",
"docs:dev": "pnpm -F @vben/website run docs:dev",
"format": "vsh lint --format",
"lint": "vsh lint",
@ -63,12 +63,12 @@
"@vben/vsh": "workspace:*",
"@vue/test-utils": "^2.4.6",
"cross-env": "^7.0.3",
"cspell": "^8.9.1",
"cspell": "^8.10.0",
"husky": "^9.0.11",
"is-ci": "^3.0.1",
"jsdom": "^24.1.0",
"rimraf": "^5.0.7",
"taze": "^0.14.0",
"taze": "^0.14.1",
"turbo": "^2.0.6",
"typescript": "^5.5.3",
"unbuild": "^2.0.0",
@ -83,7 +83,7 @@
"packageManager": "pnpm@9.4.0",
"pnpm": {
"overrides": {
"@ant-design/colors": "^7.0.2",
"@ant-design/colors": "^7.1.0",
"@ctrl/tinycolor": "^4.1.0",
"clsx": "^2.1.1",
"vue": "^3.4.31"

View File

@ -1,6 +1,4 @@
export * from './find-menu-by-path';
export * from './flatten-object';
export * from './generator-menus';
export * from './generator-routes';
export * from './merge-route-modules';
export * from './nested-object';

View File

@ -2,6 +2,7 @@ import type { Preferences } from './types';
const defaultPreferences: Preferences = {
app: {
accessMode: 'frontend',
aiAssistant: true,
authPageLayout: 'panel-right',
colorGrayMode: false,

View File

@ -9,6 +9,8 @@ import type {
type BreadcrumbStyleType = 'background' | 'normal';
type accessModeType = 'allow-all' | 'backend' | 'frontend';
type NavigationStyleType = 'plain' | 'rounded';
type PageTransitionType = 'fade' | 'fade-down' | 'fade-slide' | 'fade-up';
@ -16,6 +18,8 @@ type PageTransitionType = 'fade' | 'fade-down' | 'fade-slide' | 'fade-up';
type AuthPageLayoutType = 'panel-center' | 'panel-left' | 'panel-right';
interface AppPreferences {
/** 权限模式 */
accessMode: accessModeType;
/** 是否开启vben助手 */
aiAssistant: boolean;
/** 登录注册页面布局 */
@ -208,4 +212,5 @@ export type {
ThemeModeType,
ThemePreferences,
TransitionPreferences,
accessModeType,
};

View File

@ -1,2 +1,2 @@
export * from './access';
export * from './tabs';
export * from './tabbar';

View File

@ -3,7 +3,7 @@ import { createRouter, createWebHistory } from 'vue-router';
import { createPinia, setActivePinia } from 'pinia';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useTabsStore } from './tabs';
import { useTabbarStore } from './tabbar';
describe('useAccessStore', () => {
const router = createRouter({
@ -18,7 +18,7 @@ describe('useAccessStore', () => {
});
it('adds a new tab', () => {
const store = useTabsStore();
const store = useTabbarStore();
const tab: any = {
fullPath: '/home',
meta: {},
@ -31,7 +31,7 @@ describe('useAccessStore', () => {
});
it('adds a new tab if it does not exist', () => {
const store = useTabsStore();
const store = useTabbarStore();
const newTab: any = {
fullPath: '/new',
meta: {},
@ -43,7 +43,7 @@ describe('useAccessStore', () => {
});
it('updates an existing tab instead of adding a new one', () => {
const store = useTabsStore();
const store = useTabbarStore();
const initialTab: any = {
fullPath: '/existing',
meta: {},
@ -59,7 +59,7 @@ describe('useAccessStore', () => {
});
it('closes all tabs', async () => {
const store = useTabsStore();
const store = useTabbarStore();
store.tabs = [
{ fullPath: '/home', meta: {}, name: 'Home', path: '/home' },
] as any;
@ -72,7 +72,7 @@ describe('useAccessStore', () => {
});
it('returns all tabs including affix tabs', () => {
const store = useTabsStore();
const store = useTabbarStore();
store.tabs = [
{ fullPath: '/home', meta: {}, name: 'Home', path: '/home' },
] as any;
@ -86,7 +86,7 @@ describe('useAccessStore', () => {
});
it('closes a non-affix tab', () => {
const store = useTabsStore();
const store = useTabbarStore();
const tab: any = {
fullPath: '/closable',
meta: {},
@ -99,7 +99,7 @@ describe('useAccessStore', () => {
});
it('does not close an affix tab', () => {
const store = useTabsStore();
const store = useTabbarStore();
const affixTab: any = {
fullPath: '/affix',
meta: { affixTab: true },
@ -112,14 +112,14 @@ describe('useAccessStore', () => {
});
it('returns all cache tabs', () => {
const store = useTabsStore();
const store = useTabbarStore();
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 store = useTabbarStore();
const normalTab: any = {
fullPath: '/normal',
meta: {},
@ -139,7 +139,7 @@ describe('useAccessStore', () => {
});
it('navigates to a specific tab', async () => {
const store = useTabsStore();
const store = useTabbarStore();
const tab: any = { meta: {}, name: 'Dashboard', path: '/dashboard' };
await store._goToTab(tab, router);
@ -152,7 +152,7 @@ describe('useAccessStore', () => {
});
it('closes multiple tabs by paths', async () => {
const store = useTabsStore();
const store = useTabbarStore();
store.addTab({
fullPath: '/home',
meta: {},
@ -179,7 +179,7 @@ describe('useAccessStore', () => {
});
it('closes all tabs to the left of the specified tab', async () => {
const store = useTabsStore();
const store = useTabbarStore();
store.addTab({
fullPath: '/home',
meta: {},
@ -207,7 +207,7 @@ describe('useAccessStore', () => {
});
it('closes all tabs except the specified tab', async () => {
const store = useTabsStore();
const store = useTabbarStore();
store.addTab({
fullPath: '/home',
meta: {},
@ -235,7 +235,7 @@ describe('useAccessStore', () => {
});
it('closes all tabs to the right of the specified tab', async () => {
const store = useTabsStore();
const store = useTabbarStore();
const targetTab: any = {
fullPath: '/home',
meta: {},
@ -263,7 +263,7 @@ describe('useAccessStore', () => {
});
it('closes the tab with the specified key', async () => {
const store = useTabsStore();
const store = useTabbarStore();
const keyToClose = '/about';
store.addTab({
fullPath: '/home',
@ -293,7 +293,7 @@ describe('useAccessStore', () => {
});
it('refreshes the current tab', async () => {
const store = useTabsStore();
const store = useTabbarStore();
const currentTab: any = {
fullPath: '/dashboard',
meta: { name: 'Dashboard' },

View File

@ -62,7 +62,7 @@ interface TabsState {
/**
* @zh_CN 访
*/
const useTabsStore = defineStore('tabs', {
const useTabbarStore = defineStore('tabbar', {
actions: {
/**
* Close tabs in bulk
@ -395,7 +395,7 @@ const useTabsStore = defineStore('tabs', {
// 解决热更新问题
const hot = import.meta.hot;
if (hot) {
hot.accept(acceptHMRUpdate(useTabsStore, hot));
hot.accept(acceptHMRUpdate(useTabbarStore, hot));
}
export { useTabsStore };
export { useTabbarStore };

View File

@ -36,7 +36,7 @@
}
},
"dependencies": {
"@ant-design/colors": "^7.0.2",
"@ant-design/colors": "^7.1.0",
"@ctrl/tinycolor": "4.1.0"
}
}

View File

@ -36,4 +36,8 @@
.outline-box:not(.outline-box-active):hover::after {
@apply outline-primary left-0 top-0 h-full w-full p-1 opacity-100;
}
.card-box {
@apply bg-card text-card-foreground border-border rounded-xl border shadow;
}
}

View File

@ -1,22 +0,0 @@
import { describe, expect, it } from 'vitest';
import { generateUUID } from './hash';
describe('generateUUID', () => {
it('should return a string', () => {
const uuid = generateUUID();
expect(typeof uuid).toBe('string');
});
it('should be length 32', () => {
const uuid = generateUUID();
expect(uuid.length).toBe(36);
});
it('should have the correct format', () => {
const uuid = generateUUID();
const uuidRegex =
/^[\da-f]{8}-[\da-f]{4}-4[\da-f]{3}-[89ab][\da-f]{3}-[\da-f]{12}$/i;
expect(uuidRegex.test(uuid)).toBe(true);
});
});

View File

@ -1,31 +0,0 @@
/**
* UUID
*
* UUID是一种用于软件构建的标识符ID便
* version 4UUIDUUID是随机生成的
*
* UUID的格式为xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
* x是任意16进制数字y是一个16进制数字[8, b]
*
* @returns {string} UUID
*/
function generateUUID(): string {
let d = Date.now();
if (
typeof performance !== 'undefined' &&
typeof performance.now === 'function'
) {
d += performance.now(); // use high-precision timer if available
}
const uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replaceAll(
/[xy]/g,
(c) => {
const r = Math.trunc((d + Math.random() * 16) % 16);
d = Math.floor(d / 16);
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
},
);
return uuid;
}
export { generateUUID };

View File

@ -1,7 +1,6 @@
export * from './cn';
export * from './diff';
export * from './dom';
export * from './hash';
export * from './inference';
export * from './letter';
export * from './merge';

View File

@ -1,18 +1,18 @@
{
"name": "@vben/hooks",
"name": "@vben/access",
"version": "5.0.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/hooks"
"directory": "packages/business/permissions"
},
"license": "MIT",
"type": "module",
"scripts": {
"build": "pnpm unbuild",
"stub": "pnpm unbuild --stub"
"build": "pnpm vite build",
"prepublishOnly": "npm run build"
},
"files": [
"dist"
@ -32,12 +32,19 @@
"publishConfig": {
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.mjs"
}
}
},
"dependencies": {
"vue": "^3.4.31"
"@vben-core/preferences": "workspace:*",
"@vben-core/stores": "workspace:*",
"@vben-core/toolkit": "workspace:*",
"@vben/locales": "workspace:*",
"vue": "^3.4.31",
"vue-router": "^4.4.0"
},
"devDependencies": {
"@vben/types": "workspace:*"
}
}

View File

@ -0,0 +1 @@
export { default } from '@vben/tailwind-config/postcss';

View File

@ -0,0 +1,26 @@
<!--
Access control component for fine-grained access control.
-->
<script lang="ts" setup>
interface Props {
/**
* Specified role is visible
* - When the permission mode is 'frontend', the value can be a role value.
* - When the permission mode is 'backend', the value can be a code permission value.
* @default ''
*/
value?: string[];
}
defineOptions({
name: 'Authority',
});
withDefaults(defineProps<Props>(), {
value: undefined,
});
</script>
<template>
<slot></slot>
</template>

View File

@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest';
import { generatorMenus } from './generator-menus'; // 替换为您的实际路径
import { generateMenus } from './generate-menus'; // 替换为您的实际路径
import {
type RouteRecordRaw,
type Router,
@ -10,7 +10,7 @@ import {
// Nested route setup to test child inclusion and hideChildrenInMenu functionality
describe('generatorMenus', () => {
describe('generateMenus', () => {
// 模拟路由数据
const mockRoutes = [
{
@ -69,7 +69,7 @@ describe('generatorMenus', () => {
},
];
const menus = await generatorMenus(mockRoutes, mockRouter as any);
const menus = await generateMenus(mockRoutes, mockRouter as any);
expect(menus).toEqual(expectedMenus);
});
@ -82,7 +82,7 @@ describe('generatorMenus', () => {
},
] as RouteRecordRaw[];
const menus = await generatorMenus(mockRoutesWithMeta, mockRouter as any);
const menus = await generateMenus(mockRoutesWithMeta, mockRouter as any);
expect(menus).toEqual([
{
badge: undefined,
@ -108,7 +108,7 @@ describe('generatorMenus', () => {
},
] as RouteRecordRaw[];
const menus = await generatorMenus(mockRoutesWithParams, mockRouter as any);
const menus = await generateMenus(mockRoutesWithParams, mockRouter as any);
expect(menus).toEqual([
{
badge: undefined,
@ -139,12 +139,12 @@ describe('generatorMenus', () => {
},
] as RouteRecordRaw[];
const menus = await generatorMenus(
const menus = await generateMenus(
mockRoutesWithRedirect,
mockRouter as any,
);
expect(menus).toEqual([
// Assuming your generatorMenus function excludes redirect routes from the menu
// Assuming your generateMenus function excludes redirect routes from the menu
{
badge: undefined,
badgeType: undefined,
@ -191,7 +191,7 @@ describe('generatorMenus', () => {
});
it('should generate menu list with correct order', async () => {
const menus = await generatorMenus(routes, router);
const menus = await generateMenus(routes, router);
const expectedMenus = [
{
badge: undefined,
@ -224,7 +224,7 @@ describe('generatorMenus', () => {
it('should handle empty routes', async () => {
const emptyRoutes: any[] = [];
const menus = await generatorMenus(emptyRoutes, router);
const menus = await generateMenus(emptyRoutes, router);
expect(menus).toEqual([]);
});
});

View File

@ -1,4 +1,4 @@
import type { ExRouteRecordRaw, MenuRecordRaw } from '@vben-core/typings';
import type { ExRouteRecordRaw, MenuRecordRaw } from '@vben/types';
import type { RouteRecordRaw, Router } from 'vue-router';
import { mapTree } from '@vben-core/toolkit';
@ -7,7 +7,7 @@ import { mapTree } from '@vben-core/toolkit';
* routes
* @param routes
*/
async function generatorMenus(
async function generateMenus(
routes: RouteRecordRaw[],
router: Router,
): Promise<MenuRecordRaw[]> {
@ -70,4 +70,4 @@ async function generatorMenus(
return menus;
}
export { generatorMenus };
export { generateMenus };

View File

@ -0,0 +1,87 @@
import type {
ComponentRecordType,
RouteRecordStringComponent,
} from '@vben/types';
import type { RouteRecordRaw } from 'vue-router';
import type { GeneratorMenuAndRoutesOptions } from '../types';
import { $t } from '@vben/locales';
import { mapTree } from '@vben-core/toolkit';
/**
* -
*/
async function generateRoutesByBackend(
options: GeneratorMenuAndRoutesOptions,
): Promise<RouteRecordRaw[]> {
const { fetchMenuListAsync, layoutMap, pageMap } = options;
try {
const menuRoutes = await fetchMenuListAsync?.();
if (!menuRoutes) {
return [];
}
const normalizePageMap: ComponentRecordType = {};
for (const [key, value] of Object.entries(pageMap)) {
normalizePageMap[normalizeViewPath(key)] = value;
}
const routes = convertRoutes(menuRoutes, layoutMap, normalizePageMap);
return routes;
} catch (error) {
console.error(error);
return [];
}
}
function convertRoutes(
routes: RouteRecordStringComponent[],
layoutMap: ComponentRecordType,
pageMap: ComponentRecordType,
): RouteRecordRaw[] {
return mapTree(routes, (node) => {
const route = node as unknown as RouteRecordRaw;
const { component, name } = node;
if (!name) {
console.error('route name is required', route);
}
// layout转换
if (component && layoutMap[component]) {
route.component = layoutMap[component];
// 页面组件转换
} else if (component) {
const normalizePath = normalizeViewPath(component);
route.component =
pageMap[
normalizePath.endsWith('.vue')
? normalizePath
: `${normalizePath}.vue`
];
}
// 国际化转化
if (route.meta?.title) {
route.meta.title = $t(route.meta.title);
}
return route;
});
}
function normalizeViewPath(path: string): string {
// 去除相对路径前缀
const normalizedPath = path.replace(/^(\.\/|\.\.\/)+/, '');
// 确保路径以 '/' 开头
const viewPath = normalizedPath.startsWith('/')
? normalizedPath
: `/${normalizedPath}`;
return viewPath.replace(/^\/views/, '');
}
export { generateRoutesByBackend };

View File

@ -2,7 +2,11 @@ import type { RouteRecordRaw } from 'vue-router';
import { describe, expect, it } from 'vitest';
import { generatorRoutes, hasAuthority, hasVisible } from './generator-routes';
import {
generateRoutesByFrontend,
hasAuthority,
hasVisible,
} from './generate-routes-frontend';
// Mock 路由数据
const mockRoutes = [
@ -58,9 +62,11 @@ describe('hasVisible', () => {
});
});
describe('generatorRoutes', () => {
describe('generateRoutesByFrontend', () => {
it('should filter routes based on authority and visibility', async () => {
const generatedRoutes = await generatorRoutes(mockRoutes, ['user']);
const generatedRoutes = await generateRoutesByFrontend(mockRoutes, [
'user',
]);
// The user should have access to /dashboard/stats, but it should be filtered out because it's not visible
expect(generatedRoutes).toEqual([
{
@ -77,7 +83,9 @@ describe('generatorRoutes', () => {
});
it('should handle routes without children', async () => {
const generatedRoutes = await generatorRoutes(mockRoutes, ['user']);
const generatedRoutes = await generateRoutesByFrontend(mockRoutes, [
'user',
]);
expect(generatedRoutes).toEqual(
expect.arrayContaining([
expect.objectContaining({
@ -88,7 +96,7 @@ describe('generatorRoutes', () => {
});
it('should handle empty roles array', async () => {
const generatedRoutes = await generatorRoutes(mockRoutes, []);
const generatedRoutes = await generateRoutesByFrontend(mockRoutes, []);
expect(generatedRoutes).toEqual(
expect.arrayContaining([
// Only routes without authority should be included
@ -115,7 +123,7 @@ describe('generatorRoutes', () => {
{ meta: {}, path: '/path2' }, // Empty meta
{ meta: { authority: ['admin'] }, path: '/path3' }, // Only authority
];
const generatedRoutes = await generatorRoutes(
const generatedRoutes = await generateRoutesByFrontend(
routesWithMissingMeta as RouteRecordRaw[],
['admin'],
);

View File

@ -2,26 +2,26 @@ import type { RouteRecordRaw } from 'vue-router';
import { filterTree, mapTree } from '@vben-core/toolkit';
/**
*
* -
*/
async function generatorRoutes(
async function generateRoutesByFrontend(
routes: RouteRecordRaw[],
roles: string[],
forbiddenPage?: RouteRecordRaw['component'],
forbiddenComponent?: RouteRecordRaw['component'],
): Promise<RouteRecordRaw[]> {
// 根据角色标识过滤路由表,判断当前用户是否拥有指定权限
const finalRoutes = filterTree(routes, (route) => {
return hasVisible(route) && hasAuthority(route, roles);
});
if (!forbiddenPage) {
if (!forbiddenComponent) {
return finalRoutes;
}
// 如果有禁止访问的页面将禁止访问的页面替换为403页面
return mapTree(finalRoutes, (route) => {
if (menuHasVisibleWithForbidden(route)) {
route.component = forbiddenPage;
route.component = forbiddenComponent;
}
return route;
});
@ -60,4 +60,4 @@ function menuHasVisibleWithForbidden(route: RouteRecordRaw) {
return !!route.meta?.menuVisibleWithForbidden;
}
export { generatorRoutes, hasAuthority, hasVisible };
export { generateRoutesByFrontend, hasAuthority, hasVisible };

View File

@ -0,0 +1,76 @@
import type { accessModeType } from '@vben-core/preferences';
import type { RouteRecordRaw } from 'vue-router';
import type { GeneratorMenuAndRoutesOptions } from '../types';
import { generateMenus } from './generate-menus';
import { generateRoutesByBackend } from './generate-routes-backend';
import { generateRoutesByFrontend } from './generate-routes-frontend';
async function generateMenusAndRoutes(
mode: accessModeType,
options: GeneratorMenuAndRoutesOptions,
) {
const { router } = options;
// 生成路由
const accessibleRoutes = await generateRoutes(mode, options);
// 动态添加到router实例内
accessibleRoutes.forEach((route) => router.addRoute(route));
// 生成菜单
const accessibleMenus = await generateMenus1(mode, accessibleRoutes, options);
return { accessibleMenus, accessibleRoutes };
}
/**
* Generate routes
* @param mode
*/
async function generateRoutes(
mode: accessModeType,
options: GeneratorMenuAndRoutesOptions,
) {
const { forbiddenComponent, roles, routes } = options;
switch (mode) {
// 允许所有路由访问,不做任何过滤处理
case 'allow-all': {
return routes;
}
case 'frontend': {
return await generateRoutesByFrontend(
routes,
roles || [],
forbiddenComponent,
);
}
case 'backend': {
return await generateRoutesByBackend(options);
}
default: {
return routes;
}
}
}
async function generateMenus1(
mode: accessModeType,
routes: RouteRecordRaw[],
options: GeneratorMenuAndRoutesOptions,
) {
const { router } = options;
switch (mode) {
case 'allow-all':
case 'frontend':
case 'backend': {
return await generateMenus(routes, router);
}
default: {
return [];
}
}
}
export { generateMenusAndRoutes };

View File

@ -0,0 +1,4 @@
export { default as Authority } from './authority.vue';
export * from './generate-menu-and-routes';
export type * from './types';
export * from './use-access';

View File

@ -0,0 +1,17 @@
import type {
ComponentRecordType,
RouteRecordStringComponent,
} from '@vben/types';
import type { RouteRecordRaw, Router } from 'vue-router';
interface GeneratorMenuAndRoutesOptions {
fetchMenuListAsync?: () => Promise<RouteRecordStringComponent[]>;
forbiddenComponent?: RouteRecordRaw['component'];
layoutMap?: ComponentRecordType;
pageMap?: ComponentRecordType;
roles?: string[];
router: Router;
routes: RouteRecordRaw[];
}
export type { GeneratorMenuAndRoutesOptions };

View File

@ -0,0 +1,28 @@
import { computed } from 'vue';
import { preferences } from '@vben-core/preferences';
import { useAccessStore } from '@vben-core/stores';
function useAccess() {
const accessStore = useAccessStore();
const currentAccessMode = computed(() => {
return preferences.app.accessMode;
});
/**
*
* @param roles
*/
async function changeRoles(roles: string[]): Promise<void> {
if (preferences.app.accessMode !== 'frontend') {
throw new Error(
'The current access mode is not frontend, so the role cannot be changed',
);
}
accessStore.setUserRoles(roles);
}
return { changeRoles, currentAccessMode };
}
export { useAccess };

View File

@ -0,0 +1 @@
export { default } from '@vben/tailwind-config';

View File

@ -1,6 +1,9 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@vben/tsconfig/library.json",
"extends": "@vben/tsconfig/web.json",
"compilerOptions": {
"types": ["@vben/types/global"]
},
"include": ["src"],
"exclude": ["node_modules"]
}

View File

@ -0,0 +1,3 @@
import { defineConfig } from '@vben/vite-config';
export default defineConfig();

View File

@ -5,7 +5,7 @@ import type EchartsUI from './echarts-ui.vue';
import type { Ref } from 'vue';
import { computed, nextTick, watch } from 'vue';
import { usePreferences } from '@vben-core/preferences';
import { preferences, usePreferences } from '@vben-core/preferences';
import {
tryOnUnmounted,
@ -91,9 +91,24 @@ function useEcharts(chartRef: Ref<EchartsUIType>) {
chartInstance.dispose();
initCharts();
renderEcharts(cacheOptions);
resize();
}
});
watch(
[
() => preferences.sidebar.collapsed,
() => preferences.sidebar.extraCollapse,
() => preferences.sidebar.hidden,
],
() => {
// 折叠动画200ms
setTimeout(() => {
resize();
}, 200);
},
);
tryOnUnmounted(() => {
// 销毁实例,释放资源
chartInstance?.dispose();

View File

@ -3,14 +3,14 @@ import type { RouteLocationNormalizedLoaded } from 'vue-router';
import { preferences, usePreferences } from '@vben-core/preferences';
import { Spinner } from '@vben-core/shadcn-ui';
import { storeToRefs, useTabsStore } from '@vben-core/stores';
import { storeToRefs, useTabbarStore } from '@vben-core/stores';
import { IFrameRouterView } from '../../iframe';
import { useContentSpinner } from './use-content-spinner';
defineOptions({ name: 'LayoutContent' });
const tabsStore = useTabsStore();
const tabsStore = useTabbarStore();
const { keepAlive } = usePreferences();
const { spinning } = useContentSpinner();

View File

@ -19,14 +19,14 @@ import {
MdiPin,
MdiPinOff,
} from '@vben-core/iconify';
import { storeToRefs, useAccessStore, useTabsStore } from '@vben-core/stores';
import { storeToRefs, useAccessStore, useTabbarStore } from '@vben-core/stores';
import { filterTree } from '@vben-core/toolkit';
function useTabs() {
const router = useRouter();
const route = useRoute();
const accessStore = useAccessStore();
const tabsStore = useTabsStore();
const tabsStore = useTabbarStore();
const { accessMenus } = storeToRefs(accessStore);
const currentActive = computed(() => {

View File

@ -6,12 +6,12 @@ import { useRoute } from 'vue-router';
import { preferences } from '@vben-core/preferences';
import { Spinner } from '@vben-core/shadcn-ui';
import { useTabsStore } from '@vben-core/stores';
import { useTabbarStore } from '@vben-core/stores';
defineOptions({ name: 'IFrameRouterView' });
const spinningList = ref<boolean[]>([]);
const tabsStore = useTabsStore();
const tabsStore = useTabbarStore();
const route = useRoute();
const enableTabbar = computed(() => preferences.tabbar.enable);

View File

@ -107,7 +107,7 @@ const devDependenciesItems = Object.keys(devDependencies).map((key) => ({
<template>
<div class="m-5">
<div class="bg-card border-border rounded-md border p-5 shadow">
<div class="card-box p-5">
<div>
<h3 class="text-foreground text-2xl font-semibold leading-7">
{{ title }}
@ -135,7 +135,7 @@ const devDependenciesItems = Object.keys(devDependencies).map((key) => ({
</div>
</div>
<div class="bg-card border-border mt-6 rounded-md border p-5">
<div class="card-box mt-6 p-5">
<div>
<h5 class="text-foreground text-lg">生产环境依赖</h5>
</div>
@ -154,7 +154,7 @@ const devDependenciesItems = Object.keys(devDependencies).map((key) => ({
</dl>
</div>
</div>
<div class="bg-card border-border mt-6 rounded-md border p-5">
<div class="card-box mt-6 p-5">
<div>
<h5 class="text-foreground text-lg">开发环境依赖</h5>
</div>

View File

@ -23,9 +23,7 @@ const defaultValue = computed(() => {
</script>
<template>
<div
class="bg-card border-border w-full rounded-xl border px-4 pb-5 pt-3 shadow"
>
<div class="card-box w-full px-4 pb-5 pt-3 shadow">
<Tabs :default-value="defaultValue">
<TabsList>
<template v-for="tab in tabs" :key="tab.label">

View File

@ -14,7 +14,7 @@ withDefaults(defineProps<Props>(), {
});
</script>
<template>
<div class="bg-card border-border rounded-xl p-4 py-6 shadow lg:flex">
<div class="card-box p-4 py-6 lg:flex">
<VbenAvatar :src="avatar" class="size-20" />
<div
v-if="$slots.title || $slots.description"

View File

@ -1,7 +0,0 @@
import { defineBuildConfig } from 'unbuild';
export default defineBuildConfig({
clean: true,
declaration: true,
entries: ['src/index'],
});

View File

@ -1 +0,0 @@
export {};

View File

@ -1,6 +1,16 @@
page:
demos:
title: Demos
access:
title: Access Control
frontend-control: Front-end Control
# menuVisibleWithForbidden: Menu is visible
backend-control: Backend Control
page: Page visit
button: Button control
loading-menu: In the loading menu
access-test-1: Access test page 1
access-test-2: Access test page 2
nested:
title: Nested Menu
menu1: Menu 1

View File

@ -1,6 +1,16 @@
page:
demos:
title: 演示
access:
title: 访问控制
frontend-control: 前端控制
# menuVisibleWithForbidden: 菜单可见
backend-control: 后端控制
page: 页面访问
button: 按钮控制
access-test-1: 权限测试页1
access-test-2: 权限测试页2
nested:
title: 嵌套菜单
menu1: 菜单 1
@ -40,6 +50,7 @@ common:
confirm: 确认
not-data: 暂无数据
refresh: 刷新
loading-menu: 加载菜单中
fallback:
page-not-found: 哎呀!未找到页面

View File

@ -1,3 +1,4 @@
export type * from './router';
export type * from './ui';
export type * from './user';
export type * from '@vben-core/typings';

View File

@ -0,0 +1,13 @@
import type { RouteRecordRaw } from 'vue-router';
import type { Component } from 'vue';
// 定义递归类型以将 RouteRecordRaw 的 component 属性更改为 string
type RouteRecordStringComponent<T = string> = {
children?: RouteRecordStringComponent<T>[];
component: T;
} & Omit<RouteRecordRaw, 'children' | 'component'>;
type ComponentRecordType = Record<string, () => Promise<Component>>;
export type { ComponentRecordType, RouteRecordStringComponent };

File diff suppressed because it is too large Load Diff

View File

@ -21,7 +21,12 @@
"stub": {},
"dev": {
"dependsOn": ["^build"],
"outputs": [""],
"outputs": [],
"cache": false,
"persistent": true
},
"@vben/backend#dev": {
"outputs": [],
"cache": false,
"persistent": true
},

View File

@ -104,6 +104,10 @@
"name": "@vben-core/tabs-ui",
"path": "packages/@core/ui-kit/tabs-ui",
},
{
"name": "@vben/access",
"path": "packages/business/access",
},
{
"name": "@vben/chart-ui",
"path": "packages/business/chart-ui",
@ -124,10 +128,6 @@
"name": "@vben/constants",
"path": "packages/constants",
},
{
"name": "@vben/hooks",
"path": "packages/hooks",
},
{
"name": "@vben/icons",
"path": "packages/icons",