71 changed files with 1028 additions and 504 deletions
@ -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 }} |
|||
@ -0,0 +1,9 @@ |
|||
class CreateUserDto { |
|||
id: number; |
|||
password: string; |
|||
realName: string; |
|||
roles: string[]; |
|||
username: string; |
|||
} |
|||
|
|||
export { CreateUserDto }; |
|||
@ -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 ?? []; |
|||
} |
|||
} |
|||
@ -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 {} |
|||
@ -0,0 +1,4 @@ |
|||
import { Injectable } from '@nestjs/common'; |
|||
|
|||
@Injectable() |
|||
export class MenuService {} |
|||
@ -0,0 +1,5 @@ |
|||
function sleep(ms: number) { |
|||
return new Promise((resolve) => setTimeout(resolve, ms)); |
|||
} |
|||
|
|||
export { sleep }; |
|||
|
Before Width: | Height: | Size: 894 B After Width: | Height: | Size: 5.3 KiB |
@ -1 +1,2 @@ |
|||
export * from './menu'; |
|||
export * from './user'; |
|||
|
|||
@ -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,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 }; |
|||
@ -0,0 +1,9 @@ |
|||
<script lang="ts" setup> |
|||
import { Fallback } from '@vben/universal-ui'; |
|||
|
|||
defineOptions({ name: 'AccessBackendButtonControl' }); |
|||
</script> |
|||
|
|||
<template> |
|||
<Fallback status="comming-soon" /> |
|||
</template> |
|||
@ -0,0 +1,9 @@ |
|||
<script lang="ts" setup> |
|||
import { Fallback } from '@vben/universal-ui'; |
|||
|
|||
defineOptions({ name: 'AccessFrontend' }); |
|||
</script> |
|||
|
|||
<template> |
|||
<Fallback status="comming-soon" /> |
|||
</template> |
|||
@ -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> |
|||
@ -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> |
|||
@ -0,0 +1,9 @@ |
|||
<script lang="ts" setup> |
|||
import { Fallback } from '@vben/universal-ui'; |
|||
|
|||
defineOptions({ name: 'AccessFrontendButtonControl' }); |
|||
</script> |
|||
|
|||
<template> |
|||
<Fallback status="comming-soon" /> |
|||
</template> |
|||
@ -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> |
|||
@ -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,2 +1,2 @@ |
|||
export * from './access'; |
|||
export * from './tabs'; |
|||
export * from './tabbar'; |
|||
|
|||
@ -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); |
|||
}); |
|||
}); |
|||
@ -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,0 +1 @@ |
|||
export { default } from '@vben/tailwind-config/postcss'; |
|||
@ -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> |
|||
@ -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 }; |
|||
@ -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 }; |
|||
@ -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'; |
|||
@ -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 }; |
|||
@ -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 }; |
|||
@ -0,0 +1 @@ |
|||
export { default } from '@vben/tailwind-config'; |
|||
@ -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"] |
|||
} |
|||
@ -0,0 +1,3 @@ |
|||
import { defineConfig } from '@vben/vite-config'; |
|||
|
|||
export default defineConfig(); |
|||
@ -1,7 +0,0 @@ |
|||
import { defineBuildConfig } from 'unbuild'; |
|||
|
|||
export default defineBuildConfig({ |
|||
clean: true, |
|||
declaration: true, |
|||
entries: ['src/index'], |
|||
}); |
|||
@ -1 +0,0 @@ |
|||
export {}; |
|||
@ -1,3 +1,4 @@ |
|||
export type * from './router'; |
|||
export type * from './ui'; |
|||
export type * from './user'; |
|||
export type * from '@vben-core/typings'; |
|||
|
|||
@ -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
Loading…
Reference in new issue