P17 | 管理台动态路由:后端返回菜单树,前端运行时注入
💰 付费文章 | 第三阶段:Web 管理台
动态路由原理
传统方式:前端写死所有路由 → 用户登录后显示/隐藏菜单
问题:前端代码暴露所有路由地址,不安全的路由也能直接访问
本项目方式:前端不写任何路由 → 登录后从后端拉取菜单树 → 运行时 addRoute()
优势:未授权的路由根本不存在,更安全
后端菜单树接口
@PostMapping("/menu/tree")
fun getMenuTree(): Result<*> {
val operator = AuthInterceptor.currentUser.get() ?: return Result.unauthorized()
// 1. 查询运营员的角色
val roleIds = operatorService.getRoleIds(operator.userId)
// 2. 根据角色查询有权限的菜单
val menuList = if (roleIds.contains("SUPER_ADMIN")) {
// 超级管理员看到所有菜单
menuMapper.selectList(LambdaQueryWrapper<PlatMenuEntity>().eq(PlatMenuEntity::flag, 1))
} else {
// 普通运营员只看到角色关联的菜单
roleMenuMapper.getMenusByRoleIds(roleIds)
}
// 3. 构建树形结构
val tree = buildTree(menuList)
return Result.ok(tree)
}
private fun buildTree(menus: List<PlatMenuEntity>): List<Map<String, Any?>> {
val menuMap = menus.associateBy { it.commId }
val rootMenus = menus.filter { it.parentId.isNullOrBlank() || it.parentId == "0" }
return rootMenus.map { menu ->
buildMenuNode(menu, menuMap)
}
}
private fun buildMenuNode(menu: PlatMenuEntity, menuMap: Map<String, PlatMenuEntity>): Map<String, Any?> {
val children = menuMapper.selectList(
LambdaQueryWrapper<PlatMenuEntity>()
.eq(PlatMenuEntity::parentId, menu.commId)
.eq(PlatMenuEntity::flag, 1)
.orderByAsc(PlatMenuEntity::sort)
)
return mapOf(
"menuId" to menu.commId,
"menuName" to menu.menuName,
"menuCode" to menu.menuCode,
"path" to menu.path,
"component" to menu.component,
"icon" to menu.icon,
"sort" to menu.sort,
"children" to children.map { buildMenuNode(it, menuMap) }
)
}
前端路由动态注入
// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import { post } from '@/api/pikachuNetwork'
// 静态路由(不需要权限的)
const staticRoutes = [
{ path: '/login', component: () => import('@/views/Login.vue') },
{ path: '/', redirect: '/dashboard' }
]
// 主布局
const layoutRoute = {
path: '/main',
component: () => import('@/views/Layout.vue'),
children: [] // 动态填充
}
const router = createRouter({
history: createWebHistory(),
routes: staticRoutes
})
// 加载动态路由
export async function loadDynamicRoutes() {
const menuTree = await post('/plat/menu/tree', {})
const dynamicRoutes = []
const views = import.meta.glob('../views/**/*.vue')
function processMenu(menus) {
menus.forEach(menu => {
if (menu.component && views[`../views/${menu.component}.vue`]) {
dynamicRoutes.push({
path: menu.path,
name: menu.menuCode,
component: views[`../views/${menu.component}.vue`],
meta: { title: menu.menuName, icon: menu.icon }
})
}
if (menu.children?.length) {
processMenu(menu.children)
}
})
}
processMenu(menuTree)
// 注册所有动态路由
dynamicRoutes.forEach(route => {
router.addRoute('main', route)
})
return menuTree // 返回菜单树,用于渲染侧边栏
}
export default router
侧边栏菜单渲染
<!-- Layout.vue -->
<template>
<el-container>
<el-aside width="220px">
<Sidebar :menuTree="menuTree" />
</el-aside>
<el-main>
<router-view />
</el-main>
</el-container>
</template>
<script>
export default {
data() {
return { menuTree: [] }
},
async created() {
this.menuTree = await loadDynamicRoutes()
}
}
</script>
按钮权限指令
// permission.js
const permissionStore = usePermissionStore()
app.directive('permission', {
mounted(el, binding) {
const requiredPermission = binding.value
if (!permissionStore.hasButton(requiredPermission)) {
el.parentNode?.removeChild(el) // 彻底移除,不只是隐藏
}
}
})
// 使用
<el-button v-permission="'attraction:add'" type="primary">新增景点</el-button>
<el-button v-permission="'attraction:delete'" type="danger">删除</el-button>