Browse Source

feat: Dynamically get the menu from the back end

vben 10 months ago
parent
commit
9572d1a1c5
71 changed files with 986 additions and 416 deletions
  1. 1 4
      .vscode/settings.json
  2. 6 0
      apps/backend-mock/http/menu.http
  3. 2 2
      apps/backend-mock/package.json
  4. 2 0
      apps/backend-mock/src/app.module.ts
  5. 9 0
      apps/backend-mock/src/models/dto/user.dto.ts
  6. 62 0
      apps/backend-mock/src/modules/menu/menu.controller.ts
  7. 10 0
      apps/backend-mock/src/modules/menu/menu.module.ts
  8. 4 0
      apps/backend-mock/src/modules/menu/menu.service.ts
  9. 2 1
      apps/backend-mock/src/modules/users/users.service.ts
  10. 5 0
      apps/backend-mock/src/utils/index.ts
  11. 1 1
      apps/web-antd/package.json
  12. BIN
      apps/web-antd/public/favicon.ico
  13. 1 0
      apps/web-antd/src/apis/modules/index.ts
  14. 12 0
      apps/web-antd/src/apis/modules/menu.ts
  15. 0 2
      apps/web-antd/src/apis/modules/user.ts
  16. 40 0
      apps/web-antd/src/forward/access.ts
  17. 11 0
      apps/web-antd/src/layouts/basic.vue
  18. 8 16
      apps/web-antd/src/router/guard.ts
  19. 1 1
      apps/web-antd/src/router/routes/_essentials.ts
  20. 96 2
      apps/web-antd/src/router/routes/modules/demos.ts
  21. 2 2
      apps/web-antd/src/store/index.ts
  22. 9 0
      apps/web-antd/src/views/demos/access/backend/button-control.vue
  23. 9 0
      apps/web-antd/src/views/demos/access/backend/index.vue
  24. 13 0
      apps/web-antd/src/views/demos/access/frontend/access-test-1.vue
  25. 13 0
      apps/web-antd/src/views/demos/access/frontend/access-test-2.vue
  26. 9 0
      apps/web-antd/src/views/demos/access/frontend/button-control.vue
  27. 45 0
      apps/web-antd/src/views/demos/access/frontend/index.vue
  28. 2 2
      internal/lint-configs/eslint-config/package.json
  29. 4 4
      package.json
  30. 0 2
      packages/@core/forward/helpers/src/index.ts
  31. 1 0
      packages/@core/forward/preferences/src/config.ts
  32. 5 0
      packages/@core/forward/preferences/src/types.ts
  33. 1 1
      packages/@core/forward/stores/src/modules/index.ts
  34. 17 17
      packages/@core/forward/stores/src/modules/tabbar.test.ts
  35. 3 3
      packages/@core/forward/stores/src/modules/tabbar.ts
  36. 1 1
      packages/@core/shared/colorful/package.json
  37. 4 0
      packages/@core/shared/design/src/tailwind.css
  38. 0 22
      packages/@core/shared/toolkit/src/hash.test.ts
  39. 0 31
      packages/@core/shared/toolkit/src/hash.ts
  40. 0 1
      packages/@core/shared/toolkit/src/index.ts
  41. 13 6
      packages/business/access/package.json
  42. 1 0
      packages/business/access/postcss.config.mjs
  43. 26 0
      packages/business/access/src/authority.vue
  44. 9 9
      packages/business/access/src/generate-menu-and-routes/generate-menus.test.ts
  45. 3 3
      packages/business/access/src/generate-menu-and-routes/generate-menus.ts
  46. 87 0
      packages/business/access/src/generate-menu-and-routes/generate-routes-backend.ts
  47. 14 6
      packages/business/access/src/generate-menu-and-routes/generate-routes-frontend.test.ts
  48. 6 6
      packages/business/access/src/generate-menu-and-routes/generate-routes-frontend.ts
  49. 76 0
      packages/business/access/src/generate-menu-and-routes/index.ts
  50. 4 0
      packages/business/access/src/index.ts
  51. 17 0
      packages/business/access/src/types.ts
  52. 28 0
      packages/business/access/src/use-access.ts
  53. 1 0
      packages/business/access/tailwind.config.mjs
  54. 4 1
      packages/business/access/tsconfig.json
  55. 3 0
      packages/business/access/vite.config.mts
  56. 16 1
      packages/business/chart-ui/src/echarts/use-echarts.ts
  57. 2 2
      packages/business/layouts/src/basic/content/content.vue
  58. 2 2
      packages/business/layouts/src/basic/tabbar/use-tabs.ts
  59. 2 2
      packages/business/layouts/src/iframe/iframe-router-view.vue
  60. 3 3
      packages/business/universal-ui/src/about/about.vue
  61. 1 3
      packages/business/universal-ui/src/dashboard/analysis/analysis-charts-tabs.vue
  62. 1 1
      packages/business/universal-ui/src/dashboard/workbench/workbench-header.vue
  63. 0 7
      packages/hooks/build.config.ts
  64. 0 1
      packages/hooks/src/index.ts
  65. 10 0
      packages/locales/src/langs/en-US.yaml
  66. 11 0
      packages/locales/src/langs/zh-CN.yaml
  67. 1 0
      packages/types/src/index.ts
  68. 13 0
      packages/types/src/router.ts
  69. 211 243
      pnpm-lock.yaml
  70. 6 1
      turbo.json
  71. 4 4
      vben-admin.code-workspace

+ 1 - 4
.vscode/settings.json

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

+ 6 - 0
apps/backend-mock/http/menu.http

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

+ 2 - 2
apps/backend-mock/package.json

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

+ 2 - 0
apps/backend-mock/src/app.module.ts

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

+ 9 - 0
apps/backend-mock/src/models/dto/user.dto.ts

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

+ 62 - 0
apps/backend-mock/src/modules/menu/menu.controller.ts

@@ -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 ?? [];
+  }
+}

+ 10 - 0
apps/backend-mock/src/modules/menu/menu.module.ts

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

+ 4 - 0
apps/backend-mock/src/modules/menu/menu.service.ts

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

+ 2 - 1
apps/backend-mock/src/modules/users/users.service.ts

@@ -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);
   }

+ 5 - 0
apps/backend-mock/src/utils/index.ts

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

+ 1 - 1
apps/web-antd/package.json

@@ -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:*",

BIN
apps/web-antd/public/favicon.ico


+ 1 - 0
apps/web-antd/src/apis/modules/index.ts

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

+ 12 - 0
apps/web-antd/src/apis/modules/menu.ts

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

+ 0 - 2
apps/web-antd/src/apis/modules/user.ts

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

+ 40 - 0
apps/web-antd/src/forward/access.ts

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

+ 11 - 0
apps/web-antd/src/layouts/basic.vue

@@ -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');

+ 8 - 16
apps/web-antd/src/router/guard.ts

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

+ 1 - 1
apps/web-antd/src/router/routes/_essentials.ts

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

+ 96 - 2
apps/web-antd/src/router/routes/modules/demos.ts

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

+ 2 - 2
apps/web-antd/src/store/index.ts

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

+ 9 - 0
apps/web-antd/src/views/demos/access/backend/button-control.vue

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

+ 9 - 0
apps/web-antd/src/views/demos/access/backend/index.vue

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

+ 13 - 0
apps/web-antd/src/views/demos/access/frontend/access-test-1.vue

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

+ 13 - 0
apps/web-antd/src/views/demos/access/frontend/access-test-2.vue

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

+ 9 - 0
apps/web-antd/src/views/demos/access/frontend/button-control.vue

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

+ 45 - 0
apps/web-antd/src/views/demos/access/frontend/index.vue

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

+ 2 - 2
internal/lint-configs/eslint-config/package.json

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

+ 4 - 4
package.json

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

+ 0 - 2
packages/@core/forward/helpers/src/index.ts

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

+ 1 - 0
packages/@core/forward/preferences/src/config.ts

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

+ 5 - 0
packages/@core/forward/preferences/src/types.ts

@@ -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,
 };

+ 1 - 1
packages/@core/forward/stores/src/modules/index.ts

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

+ 17 - 17
packages/@core/forward/stores/src/modules/tabs.test.ts → packages/@core/forward/stores/src/modules/tabbar.test.ts

@@ -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' },

+ 3 - 3
packages/@core/forward/stores/src/modules/tabs.ts → packages/@core/forward/stores/src/modules/tabbar.ts

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

+ 1 - 1
packages/@core/shared/colorful/package.json

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

+ 4 - 0
packages/@core/shared/design/src/tailwind.css

@@ -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;
+  }
 }

+ 0 - 22
packages/@core/shared/toolkit/src/hash.test.ts

@@ -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);
-  });
-});

+ 0 - 31
packages/@core/shared/toolkit/src/hash.ts

@@ -1,31 +0,0 @@
-/**
- * 生成一个UUID(通用唯一标识符)。
- *
- * UUID是一种用于软件构建的标识符,其目的是能够生成一个唯一的ID,以便在全局范围内标识信息。
- * 此函数用于生成一个符合version 4的UUID,这种UUID是随机生成的。
- *
- * 生成的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 };

+ 0 - 1
packages/@core/shared/toolkit/src/index.ts

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

+ 13 - 6
packages/hooks/package.json → packages/business/access/package.json

@@ -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:*"
   }
 }

+ 1 - 0
packages/business/access/postcss.config.mjs

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

+ 26 - 0
packages/business/access/src/authority.vue

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

+ 9 - 9
packages/@core/forward/helpers/src/generator-menus.test.ts → packages/business/access/src/generate-menu-and-routes/generate-menus.test.ts

@@ -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([]);
   });
 });

+ 3 - 3
packages/@core/forward/helpers/src/generator-menus.ts → packages/business/access/src/generate-menu-and-routes/generate-menus.ts

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

+ 87 - 0
packages/business/access/src/generate-menu-and-routes/generate-routes-backend.ts

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

+ 14 - 6
packages/@core/forward/helpers/src/generator-routes.test.ts → packages/business/access/src/generate-menu-and-routes/generate-routes-frontend.test.ts

@@ -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'],
     );

+ 6 - 6
packages/@core/forward/helpers/src/generator-routes.ts → packages/business/access/src/generate-menu-and-routes/generate-routes-frontend.ts

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

+ 76 - 0
packages/business/access/src/generate-menu-and-routes/index.ts

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

+ 4 - 0
packages/business/access/src/index.ts

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

+ 17 - 0
packages/business/access/src/types.ts

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

+ 28 - 0
packages/business/access/src/use-access.ts

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

+ 1 - 0
packages/business/access/tailwind.config.mjs

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

+ 4 - 1
packages/hooks/tsconfig.json → packages/business/access/tsconfig.json

@@ -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"]
 }

+ 3 - 0
packages/business/access/vite.config.mts

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

+ 16 - 1
packages/business/chart-ui/src/echarts/use-echarts.ts

@@ -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();

+ 2 - 2
packages/business/layouts/src/basic/content/content.vue

@@ -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();
 

+ 2 - 2
packages/business/layouts/src/basic/tabbar/use-tabs.ts

@@ -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(() => {

+ 2 - 2
packages/business/layouts/src/iframe/iframe-router-view.vue

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

+ 3 - 3
packages/business/universal-ui/src/about/about.vue

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

+ 1 - 3
packages/business/universal-ui/src/dashboard/analysis/analysis-charts-tabs.vue

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

+ 1 - 1
packages/business/universal-ui/src/dashboard/workbench/workbench-header.vue

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

+ 0 - 7
packages/hooks/build.config.ts

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

+ 0 - 1
packages/hooks/src/index.ts

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

+ 10 - 0
packages/locales/src/langs/en-US.yaml

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

+ 11 - 0
packages/locales/src/langs/zh-CN.yaml

@@ -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: 哎呀!未找到页面

+ 1 - 0
packages/types/src/index.ts

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

+ 13 - 0
packages/types/src/router.ts

@@ -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
+ 211 - 243
pnpm-lock.yaml


+ 6 - 1
turbo.json

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

+ 4 - 4
vben-admin.code-workspace

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

Some files were not shown because too many files changed in this diff