Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: 实现动态路由、动态菜单与权限指令 #211

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions src/api/hook-demo/use-dynamic-route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/** 菜单类型 */
export enum MenuType {
Menu = "menu",
Page = "page",
Button = "button",
Link = "link"
}

/** 菜单元数据 */
export interface MenuMeta {
/** 菜单标题 */
title: string
/** 菜单图标 */
icon?: string
/** 隐藏菜单 */
hidden?: boolean
/** 菜单总是可见 */
alwaysShow?: boolean
/** 菜单是否可用 */
roles?: string[]
/** 其它参数 */
[key: string]: any
}

/** 菜单详情 */
export interface MenuItem {
/** 菜单名称 */
name: string
/** 菜单类型 */
type: MenuType
/** 菜单路径 */
path: string
/** 重定向页面 */
redirect?: string
/** 组件页面 */
component?: string
/** 菜单元数据 */
meta: MenuMeta
/** 子菜单 */
children?: MenuItem[]
}

/**
* 动态路由
* 用来放置有权限 (Roles 属性) 的路由
* 必须带有 Name 属性
*/
const dynamicRoutes: MenuItem[] = [
{
path: "/permission",
redirect: "/permission/page",
name: "Permission",
type: MenuType.Menu,
meta: {
title: "权限",
icon: "lock",
roles: ["admin", "editor"], // 可以在根路由中设置角色
alwaysShow: true // 将始终显示根菜单
},
children: [
{
path: "page",
component: "/views/permission/page.vue",
name: "PagePermission",
type: MenuType.Page,
meta: {
title: "页面级",
roles: ["admin"] // 或者在子导航中设置角色
}
},
{
path: "directive",
component: "/views/permission/directive.vue",
name: "DirectivePermission",
type: MenuType.Page,
meta: {
title: "按钮级" // 如果未设置角色,则表示:该页面不需要权限,但会继承根路由的角色
},
children: [
{
path: "button",
name: "ButtonPermission",
type: MenuType.Button,
meta: {
title: "按钮权限"
}
}
]
}
]
}
]

/** 模拟加载菜单接口 */
export function getMenuDataApi() {
return new Promise<typeof dynamicRoutes>((resolve, reject) => {
// 模拟接口响应时间 1s
setTimeout(() => {
// 模拟接口调用成功
if (Math.random() < 1) {
resolve(dynamicRoutes)
} else {
// 模拟接口调用出错
reject(new Error("接口发生错误"))
}
}, 1000)
})
}
14 changes: 12 additions & 2 deletions src/directives/permission/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,19 @@ import { useUserStoreHook } from "@/store/modules/user"
export const permission: Directive = {
mounted(el, binding) {
const { value: permissionRoles } = binding
const { roles } = useUserStoreHook()
const { permission } = useUserStoreHook()

if (Array.isArray(permissionRoles) && permissionRoles.length > 0) {
const hasPermission = roles.some((role) => permissionRoles.includes(role))
let hasPermission = false
permissionRoles.forEach((item) => {
const res = (item as string).split(":")
if (permission.has(res[0])) {
if (permission.get(res[0])?.includes(res[1])) {
hasPermission = true
}
}
})

// hasPermission || (el.style.display = "none") // 隐藏
hasPermission || el.parentNode?.removeChild(el) // 销毁
} else {
Expand Down
2 changes: 1 addition & 1 deletion src/router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { type RouteRecordRaw, createRouter } from "vue-router"
import { history, flatMultiLevelRoutes } from "./helper"
import routeSettings from "@/config/route"

const Layouts = () => import("@/layouts/index.vue")
export const Layouts = () => import("@/layouts/index.vue")

/**
* 常驻路由
Expand Down
3 changes: 2 additions & 1 deletion src/router/permission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,11 @@ router.beforeEach(async (to, _from, next) => {
// 否则要重新获取权限角色
try {
await userStore.getInfo()
await userStore.getMenu()
// 注意:角色必须是一个数组! 例如: ["admin"] 或 ["developer", "editor"]
const roles = userStore.roles
// 生成可访问的 Routes
routeSettings.dynamic ? permissionStore.setRoutes(roles) : permissionStore.setAllRoutes()
routeSettings.dynamic ? permissionStore.setRoutes(roles, userStore.menus) : permissionStore.setAllRoutes()
// 将 "有访问权限的动态路由" 添加到 Router 中
permissionStore.addRoutes.forEach((route) => router.addRoute(route))
// 确保添加路由已完成
Expand Down
46 changes: 43 additions & 3 deletions src/store/modules/permission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@ import { ref } from "vue"
import store from "@/store"
import { defineStore } from "pinia"
import { type RouteRecordRaw } from "vue-router"
import { constantRoutes, dynamicRoutes } from "@/router"
import { constantRoutes, dynamicRoutes, Layouts } from "@/router"
import { flatMultiLevelRoutes } from "@/router/helper"
import routeSettings from "@/config/route"
import { MenuItem, MenuType } from "@/api/hook-demo/use-dynamic-route"

const modules = import.meta.glob(["@/views/*.vue", "@/views/**/*.vue"])

const hasPermission = (roles: string[], route: RouteRecordRaw) => {
const routeRoles = route.meta?.roles
Expand All @@ -25,15 +28,52 @@ const filterDynamicRoutes = (routes: RouteRecordRaw[], roles: string[]) => {
return res
}

function transformMenuToRoute(menuItem: MenuItem): RouteRecordRaw {
const childrenRoute: RouteRecordRaw[] = []
const vuePath = "/src" + (menuItem.component ?? "")
const vuePage = menuItem.type == MenuType.Menu ? Layouts : modules[vuePath]

// 如果有 children,则需要递归添加 children 到 route
if (menuItem.children && menuItem.children.length > 0) {
for (let i = 0; i < menuItem.children.length; i++) {
if (menuItem.children[i].type == MenuType.Page) {
childrenRoute.push(transformMenuToRoute(menuItem.children[i]))
}
}
}

const routeItem: RouteRecordRaw = {
path: menuItem.path,
name: menuItem.name,
component: vuePage,
meta: {
svgIcon: menuItem.meta?.icon,
...menuItem.meta
},
children: childrenRoute.length > 0 ? childrenRoute : undefined
}

if (menuItem.redirect) {
routeItem.redirect = menuItem.redirect
}

return routeItem
}

export const usePermissionStore = defineStore("permission", () => {
/** 可访问的路由 */
const routes = ref<RouteRecordRaw[]>([])
/** 有访问权限的动态路由 */
const addRoutes = ref<RouteRecordRaw[]>([])

/** 根据角色生成可访问的 Routes(可访问的路由 = 常驻路由 + 有访问权限的动态路由) */
const setRoutes = (roles: string[]) => {
const accessedRoutes = filterDynamicRoutes(dynamicRoutes, roles)
const setRoutes = (roles: string[], menus: MenuItem[]) => {
const menuRoute: RouteRecordRaw[] = []
for (let i = 0; i < menus.length; i++) {
menuRoute.push(transformMenuToRoute(menus[i]))
}

const accessedRoutes = filterDynamicRoutes(menuRoute, roles)
_set(accessedRoutes)
}

Expand Down
58 changes: 56 additions & 2 deletions src/store/modules/user.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ref } from "vue"
import { ref, reactive } from "vue"
import store from "@/store"
import { defineStore } from "pinia"
import { useTagsViewStore } from "./tags-view"
Expand All @@ -7,11 +7,53 @@ import { getToken, removeToken, setToken } from "@/utils/cache/cookies"
import { resetRouter } from "@/router"
import { loginApi, getUserInfoApi } from "@/api/login"
import { type LoginRequestData } from "@/api/login/types/login"
import { MenuItem, getMenuDataApi } from "@/api/hook-demo/use-dynamic-route"
import routeSettings from "@/config/route"

/**
* 从菜单生成权限资源
*
* @param menus 授予用户的菜单列表
* @returns Map<string, string[]> 权限资源
*/
function buildMenuPermission(menus: MenuItem[]) {
const ret = new Map<string, string[]>()

menus.forEach((item) => {
if (item.type === "menu" && item.children && item.children.length > 0) {
const tmp = buildMenuPermission(item.children)
if (tmp.size > 0) {
tmp.forEach((value, key) => {
if (ret.has(key)) {
ret.set(key, [...new Set([...(ret.get(key) as string[]), ...value])])
} else {
ret.set(key, value)
}
})
}
} else if (item.type === "page" && item.children && item.children.length > 0) {
const res: string[] = []

item.children?.forEach((child) => {
if (child.name != "") {
res.push(child.name)
}
})

if (res.length > 0) {
ret.set(item.name, res)
}
}
})

return ret
}

export const useUserStore = defineStore("user", () => {
const token = ref<string>(getToken() || "")
const roles = ref<string[]>([])
const menus = reactive<MenuItem[]>([])
const permission = reactive<Map<string, string[]>>(new Map())
const username = ref<string>("")

const tagsViewStore = useTagsViewStore()
Expand All @@ -30,6 +72,18 @@ export const useUserStore = defineStore("user", () => {
// 验证返回的 roles 是否为一个非空数组,否则塞入一个没有任何作用的默认角色,防止路由守卫逻辑进入无限循环
roles.value = data.roles?.length > 0 ? data.roles : routeSettings.defaultRoles
}
/** 获取菜单 */
const getMenu = async () => {
const data = await getMenuDataApi()
if (data && data.length > 0) {
menus.push(...data)

const permissionMap = buildMenuPermission(data)
permissionMap.forEach((value, key) => {
permission.set(key, value)
})
}
}
/** 模拟角色变化 */
const changeRoles = async (role: string) => {
const newToken = "token-" + role
Expand Down Expand Up @@ -60,7 +114,7 @@ export const useUserStore = defineStore("user", () => {
}
}

return { token, roles, username, login, getInfo, changeRoles, logout, resetToken }
return { token, roles, menus, permission, username, login, getInfo, getMenu, changeRoles, logout, resetToken }
})

/** 在 setup 外使用 */
Expand Down
14 changes: 12 additions & 2 deletions src/utils/permission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,18 @@ import { useUserStoreHook } from "@/store/modules/user"
/** 全局权限判断函数,和权限指令 v-permission 功能类似 */
export const checkPermission = (permissionRoles: string[]): boolean => {
if (Array.isArray(permissionRoles) && permissionRoles.length > 0) {
const { roles } = useUserStoreHook()
return roles.some((role) => permissionRoles.includes(role))
const { permission } = useUserStoreHook()
let hasPermission = false
permissionRoles.forEach((item) => {
const res = (item as string).split(":")
if (permission.has(res[0])) {
if (permission.get(res[0])?.includes(res[1])) {
hasPermission = true
}
}
})

return hasPermission
} else {
console.error("need roles! Like checkPermission(['admin','editor'])")
return false
Expand Down
18 changes: 18 additions & 0 deletions src/views/permission/directive.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ import SwitchRoles from "./components/SwitchRoles.vue"
<SwitchRoles />
<!-- v-permission 示例 -->
<div class="margin-top-30">
<div>
权限指令说明:权限指令内容由页面名与下级资源名组成<br />如:DirectivePermission:ButtonPermission 指令中的
DirectivePermission 为菜单配置中页面的 name 属性, ButtonPermission 为页面 children 中的下级资源 name 属性值<br />
详情请参考 src/api/hook-demo/use-dynamic-route.ts 文件中的菜单配置
</div>
<div>
<el-tag v-permission="['admin']" type="success" size="large" effect="plain">
这里采用了 v-permission="['admin']" 所以只有 admin 可以看见这句话
Expand All @@ -23,6 +28,12 @@ import SwitchRoles from "./components/SwitchRoles.vue"
这里采用了 v-permission="['admin', 'editor']" 所以 admin 和 editor 都可以看见这句话
</el-tag>
</div>
<div class="margin-top-15">
<el-tag v-permission="['DirectivePermission:ButtonPermission']" type="success" size="large" effect="plain">
这里采用了 v-permission="['DirectivePermission:ButtonPermission']" 所以只有
DirectivePermission:ButtonPermission 权限才可以看见这句话
</el-tag>
</div>
</div>
<!-- checkPermission 示例 -->
<div class="margin-top-30">
Expand All @@ -40,6 +51,13 @@ import SwitchRoles from "./components/SwitchRoles.vue"
<el-tab-pane v-if="checkPermission(['admin', 'editor'])" label="admin 和 editor">
这里采用了 <el-tag>v-if="checkPermission(['admin', 'editor'])"</el-tag> 所以 admin 和 editor 都可以看见这句话
</el-tab-pane>
<el-tab-pane
v-if="checkPermission(['DirectivePermission:ButtonPermission'])"
label="DirectivePermission:ButtonPermission 按钮权限测试"
>
这里采用了 <el-tag>v-if="checkPermission(['DirectivePermission:ButtonPermission'])"</el-tag> 所以只有
DirectivePermission:ButtonPermission 权限才可以看见这句话
</el-tab-pane>
</el-tabs>
</div>
</div>
Expand Down