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

@@ -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>