AI 摘要

文章基于 Solon + EasyQuery + ElementPlus 实现后台管理系统的动态菜单与动态路由。后端根据角色返回启用且排序的权限列表,前端将其转为树形菜单并渲染侧边栏;同时把菜单数据转换为路由规则,登录或刷新时通过路由守卫动态 addRoute,解决刷新失效问题,并把权限数据存入 Pinia 仓库,避免重复请求。

动态菜单

后端 API

核心注意事项:

  • 后端 API 中,根据当前登录用户对应的角色,获取角色关联的权限列表。
  • 如果当前登录用户对应的角色 ID 为 1 即最高级默认角色,获取所有权限列表。
  • 查询权限列表时,只查询启用的权限,不查询隐藏的权限,且根据排序 ID 进行排序,越小越靠前。
  • 查询权限列表,可以借助 SaToken Session 进行存取,减少查询压力。
  • 角色关联的权限,在数据库中以字符串 1,2,3,4 的形式存在,可以将其格式化成数组,使用 SQL IN 语句进行查询。

编写权限认证控制器(cn.duozai.sadmin.controller.PassportController),提供获取当前登录用户权限列表方法:

/**
 * 权限认证控制器
 * @visduo
 *
 * @Valid:请求参数校验
 * 参考文档:https://solon.noear.org/article/49
 */
@Valid
@Mapping("/passport")
@Controller
public class PassportController {

    // ...

    /**
     * 获取当前登录用户权限列表
     * @visduo
     *
     * @return 当前登录用户权限列表
     */
    @Get
    @Mapping("/currentPerms")
    public ResponseResult currentPerms() {
        // 从会话中获取权限列表
        List<PermsEntity> permsList = (List<PermsEntity>) StpUtil.getSession().get("perms");
        if (permsList != null) {
            // 权限列表不为空,返回权限列表
            return ResponseResult.success("查询成功", permsList);
        }

        // 获取当前登录用户对象及对应角色
        UsersEntity usersEntity = (UsersEntity) StpUtil.getSession().get("currentUser");
        RoleEntity roleEntity = usersEntity.getRole();

        if(roleEntity.getId() == 1) {
            // 默认角色返回所有权限
            permsList = easyEntityQuery.queryable(PermsEntity.class)
                    .where(p -> {
                        // 只查询启用的权限,不查询隐藏的权限
                        p.status().eq(1);
                    })
                    .orderBy(p -> {
                        // 根据排序ID进行排序,越小越靠前
                        p.sortId().asc();
                    })
                    .toList();
        } else {
            // 非默认角色返回对应角色权限
            // 数据库中存储的角色权限列表为1,2,3,4格式,将其格式化成数组
            String[] permsIds = roleEntity.getPerms().split(",");
            // 将字符串数组转换成整数数组
            Integer[] permsIdsInt = Arrays.stream(permsIds).map(Integer::parseInt).toArray(Integer[]::new);

            permsList = easyEntityQuery.queryable(PermsEntity.class)
                    .where(p -> {
                        // 只查询启用的权限,不查询隐藏的权限
                        p.status().eq(1);
                        // 只查询角色授权的权限列表
                        p.id().in(permsIdsInt);
                    })
                    .orderBy(p -> {
                        // 根据排序ID进行排序,越小越靠前
                        p.sortId().asc();
                    })
                    .toList();
        }

        // 将权限列表存入会话
        StpUtil.getSession().set("perms", permsList);

        return ResponseResult.success("查询成功", permsList);
    }

}

前端组件

核心注意事项:

  • 前端发送请求获取获取当前登录用户权限列表,并将权限列表转换成树形结构数组。
  • 转换成树形结构数组时,不需要转换操作类型的权限,因为操作类型的权限不会展示在侧边栏菜单中,因此可以优化树形结构转换工具类方法,过滤操作类型的权限。
  • 使用菜单组件渲染侧边栏多级菜单,注意路径拼接等问题。

编写树形结构工具类(plugins/TreeUtil.js),优化树形结构转换方法:

/**
 * 将扁平结构的权限数据数组,转换为支持无限级嵌套的树形结构数组
 * @param {Array} flatList 扁平结构的数据数组,每个元素需要包含id和parentId字段
 * @package {filter} 是否过滤操作类型的权限,默认值为false
 * @returns {Array} 转换后的树形结构数组,每个节点包含原节点的所有字段及children字段
 */
export function toTreeList(flatList, filter = false) {
    // ...

    // 构建树形结构
    flatList.forEach(item => {
        const node = nodeMap.get(item.id);
        if (filter && node.type == 2) {
            // 过滤操作类型的权限
            return;
        }
        // ...
    });

    return result;
}

export default {
    toTreeList
};

编写布局组件(views/Layouts.vue),完善左侧菜单栏:

<script setup>
// ...

// 模拟菜单数据
const menuData = ref([])

// 发送请求,获取登录用户权限列表
axios.get('/passport/currentPerms').then((res) => {
    // 将扁平数据转换为树形结构
    menuData.value = toTreeList(res.data, true)
})

// ...
</script>

<template>
    <el-container class="layout-container">
        <!-- 左侧菜单栏 -->
        <el-aside width="200px">
            <!--
                router="true":启用该模式会在激活导航时以index作为path进行路由跳转
                参考文档:https://element-plus.org/zh-CN/component/menu#menu-attributes
            -->
            <el-menu default-active="1" class="el-menu-vertical" :router="true" background-color="#545c64" text-color="#fff" active-text-color="#ffd04b">
                <el-menu-item index="/admin/index">
                    <span>SAdmin后台管理系统</span>
                </el-menu-item>

                <template v-for="item in menuData" :key="item.id">
                    <el-sub-menu v-if="item.children && item.children.length > 0" :index="item.id">
                        <template #title>
                            <span>{{ item.name }}</span>
                        </template>
                        <!--
                            index注意路径拼接前缀/admin/
                        -->
                        <el-menu-item v-for="child in item.children" :key="child.id" :index="'/admin/' + child.path">
                            {{ child.name }}
                        </el-menu-item>
                    </el-sub-menu>

                    <el-menu-item v-else :index="'/admin/' + item.path">
                        <template #title>{{ item.name }}</template>
                    </el-menu-item>
                </template>
            </el-menu>
        </el-aside>

        <!-- ... -->
    </el-container>
</template>

<style>
/** ... **/
</style>

在浏览器中测试:

动态路由

动态路由概述

动态路由核心是根据用户权限动态生成路由规则,实现不同权限用户看到不同菜单、访问不同页面的目标。

动态菜单 ≠ 动态路由:

维度动态菜单动态路由
本质控制侧边栏显示哪些菜单(视觉层)控制 URL 能访问哪些页面(访问层)
实现方式后端返回菜单列表,前端渲染侧边栏后端返回路由规则,前端 addRoute 动态添加
核心作用让用户看到能点的让用户点了能访问,且防止手动输 URL,绕过权限访问
失效场景手动输入 URL 可绕过菜单直接访问无匹配路由彻底拦截

路由分类:

  • 静态路由:无需权限的公共页面(如登录页、404 页),直接在路由表中定义。
  • 动态路由:需权限管控的页面(如系统管理、用户管理),从后端获取用户权限菜单后,动态添加到路由表中。

前端组件

核心注意事项:

  • 路由配置不再写死配置项,改为从后端获取授权的路由信息后,动态加载路由。
  • 加载动态路由,可以编写工具类进行获取、转换、添加。
  • 加载动态路由时,目录、操作类型的权限一般不需要添加到路由配置中,因为目录、操作类型的权限没有路由路径和路由组件。
  • 加载动态路由时,需要移除原有的、遗留的旧的路由配置信息,防止动态路由表污染。

工具类提示词:

编写权限工具类src/plugins/PermsUtil,工具类中包含方法作用为:
1、传入扁平权限数组,将所有的菜单转换为路由规则。
2、动态添加路由,请求/passport/currentPerms获取扁平结构的权限列表,调用上述方法将其转换为路由规则。

编写权限工具类(plugins/PermsUtil.js),提供动态路由方法:

import router from '@/router/index.js';

/**
 * 将扁平权限数组转换为路由规则
 * @param {Array} flatPerms 扁平权限数组
 * @returns {Array} 路由规则数组
 */
export function permsToRoutes(flatPerms) {
    const routes = [];

    // 直接遍历扁平权限数组,只处理菜单类型(type=1)的权限
    // 目录、操作类型一般不需要处理,因为目录、操作类型的权限没有路由路径和路由组件
    flatPerms.forEach(perm => {
        // 只处理菜单类型的权限(type=1)
        if (perm.type === 1) {
            const route = {};

            // 设置路由路径
            if (perm.path) {
                route.path = perm.path;
            }

            // 设置组件
            if (perm.component) {
                // 动态导入组件
                route.component = () => import(`@/views/${perm.component}.vue`);
            }

            // 只有当路径和组件都存在时才添加路由
            if (route.path && route.component) {
                routes.push(route);
            }
        }
    });

    return routes;
}

/**
 * 动态添加路由
 * 请求 /passport/currentPerms 得到扁平结构的权限列表,调用 permsToRoutes 方法将其转换为路由规则
 */
export async function loadPermsRoutes() {
    // 请求获取扁平结构的权限列表
    const response = await axios.get('/passport/currentPerms');

    // 将扁平权限结构转换为路由规则
    const routes = permsToRoutes(response.data);

    // 先移除原有的admin路由
    router.removeRoute('admin')
    
    // 添加新的 admin 路由
    router.addRoute({
        path: '/admin',
        name: 'admin',
        component: () => import('@/views/Layout.vue'),
        children: [] // 设置空的子路由
    })

    // 动态添加路由到 admin 路由下(添加到 /admin 路由的 children 中)
    routes.forEach(route => {
        router.addRoute('admin', route);
    });
}

export default {
    permsToRoutes,
    loadPermsRoutes
};

编写路由配置文件(router/index.js),完善路由信息:

const router = createRouter({
    history: createWebHistory(import.meta.env.BASE_URL),
    routes: [
        {
            // 根路径跳转到登录路由
            path: '',
            redirect: '/login'
        },
        {
            // 登录
            path: '/login',
            component: () => import('@/views/Login.vue'),
        }
        // 移除原有的后台路由,改为动态加载
        // 登录路由为公共路由,不需要权限
    ],
})

编写登录组件(views/Login.vue),完善登录方法:

// 登录处理函数
const handleLogin = async () => {
    if (!loginFormRef.value) return

    await loginFormRef.value.validate((valid) => {
        if (valid) {
            // 这里执行实际的登录逻辑
            // 发送Axios请求
            axios.post('/passport/login', qs.stringify(loginForm)).then(async (res) => {
                ElMessage.success('登录成功')
                // 将Token存储到localStorage中
                // Axios响应拦截器处理,res即后端响应给前端的数据,不再是res.data.data
                localStorage.setItem('authtoken', res.data)
                // 加载动态路由,await 是异步方法,必须等待动态路由加载完成,再跳转页面
                await loadPermsRoutes()
                // 跳转到后台首页
                router.push('/admin/index')
            })
        } else {
            ElMessage.error('请正确填写表单信息')
            return false
        }
    })
}

在浏览器中测试:

路由丢失问题

动态路由目前存在问题:浏览器刷新后,路由丢失,无法访问对应的页面组件。

原因在于:动态路由是在登录成功后,调用 PermsUtil 工具类通过 addRoute 临时添加的,刷新页面会导致前端应用重新初始化,所有运行时添加的动态路由都会被清空。

解决思路:在路由守卫中,每次初始化后(包括刷新),自动检测 已登录但动态路由未加载,重新请求后端并添加动态路由。

路由守卫优化

核心注意事项:

  • 在路由守卫中,如果访问登录页面,先清空一次动态加载的路由信息(即移除掉整个后台的 admin 路由),防止动态路由表污染。
  • 如果 Token 存在(即已登录)且未加载过动态路由(不存在 admin 路由),先调用 PermsUtil 工具类加载动态路由。
  • 等待路由加载完成,重新触发路由守卫,避免首次跳转动态路由出现 404 的情况。

编写路由配置文件(router/index.js),完善路由守卫:

// ...

// 前置守卫
router.beforeEach(async (to, from, next) => {
    let authtoken = localStorage.getItem("authtoken")
    // 通过to.path获取访问的路由地址,并进行判断
    if (to.path === '/login' && authtoken) {
        // 如果Token存在且访问了登录路由,跳转到首页页面
        ElMessage.success('欢迎回来')
        next({path: '/admin/index'})
    } else if (to.path === '/login') {
        // 先移除原有的admin路由
        router.removeRoute('admin')

        // 跳过登录路由
        next()
    } else if (!authtoken) {
        // 如果Token不存在,即未登录,跳转到登录页面
        ElMessage.error('请先登录')
        next({path: '/login'})
    }

    // Token存在且未加载过动态路由,先加载动态路由
    if (authtoken && !router.hasRoute('admin')) {
        await loadPermsRoutes()
        // 重新触发守卫,避免首次跳转动态路由404
        next({ ...to, replace: true })
    }

    // 其他正常情况,继续访问
    next()
})

export default router

动态菜单优化

核心注意事项:

  • 路由守卫中已经调用 PermsUtil 工具类发送了一次请求获取当前登录用户权限列表,并将其转换为动态路由。
  • 在 PermsUtil 工具类中,可以将当前登录用户权限列表转换为树形结构数组后,保存到仓库中,在布局组件中直接从仓库中取出渲染,不需要重复请求。

编写登录用户数据仓库(store/currentUser.js),存储当前登录用户权限列表:

import {ref, computed, reactive} from 'vue'
import { defineStore } from 'pinia'

export const useCurrentUserStore = defineStore('currentUser', () => {
    // 登录用户信息实体
    let currentUser = reactive({})

    // 当前用户菜单列表
    let currentMenu = reactive([])

    // 设置登录用户信息实体
    function setCurrentUser(currentUser) {
        this.currentUser = currentUser
    }
    
    // 设置当前用户菜单列表
    function setCurrentMenu(currentMenu) {
        this.currentMenu = currentMenu
    }

    return { currentUser, setCurrentUser, currentMenu, setCurrentMenu }
})

编写权限工具类(plugins/PermsUtil.js),优化动态路由方法:

// 将扁平权限结构转换为路由规则
const routes = permsToRoutes(response.data);

// 将用户权限菜单存储到仓库中
useCurrentUserStore().setCurrentMenu(toTreeList(response.data, true))

编写布局组件(views/Layouts.vue),优化左侧菜单栏数据的获取:

// 模拟菜单数据
const menuData = useCurrentUserStore().currentMenu