小记——根据用户动态加载菜单

引用博客:Vue + Spring Boot 项目实战(十五):动态加载后台菜单

不同用户登录后菜单显示不同的实现,需要同时结合前端和后端。
后端主要实现:1. 数据库设计用户可以访问的菜单列表。2. 接收url请求,查询数据库返回允许的菜单列表。
前端主要实现:1. Vuex Store添加菜单数组,保存允许访问的菜单项。2. 配置路由,包括Router入口、利用前置守卫添加菜单项路由。 3. 编写前端界面。

后端:

  1. 数据库设计用户可以访问的菜单列表:
    访问控制采用RBAC,数据库涉及的表包括用户表user、角色表role、菜单表menu、用户-角色映射表user_role、角色-菜单映射表role_menu。
    sql文件链接是:blog.sql
    这5个表的表属性及内容截图:
    user.png
    user_content.png

role.png
role_content.png

menu.png
menu_content.png

user_role.png
user_role_cont.png

role_menu.png
role_menu_cont.png

  1. 后端接收url请求,查询数据库返回允许的菜单列表。
    需要设计menuService以及menuController,当接收"api/menu"请求后,从数据库中查询当前用户可以访问菜单列表,并返回给前端。
    注意:本文使用的ORM框架为Mybatis-Plus,相关CRUD请访问官网:CRUD 接口条件构造器

IMenuService:

1
2
3
public interface IMenuService extends IService<Menu> {
public List<Menu> getMenuByCurrentUser();
}

menuServiceImpl:
代码逻辑是:先根据当前用户查询对应的角色,再根据角色查询允许的菜单项。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
@Transactional
@Service
public class MenuServiceImpl extends ServiceImpl<MenuMapper, Menu> implements IMenuService {

@Autowired
IUserService userService;
@Autowired
IUserRoleService userRoleService;

@Autowired
IRoleMenuService roleMenuService;

@Override
public List<Menu> getMenuByCurrentUser() {
// 获取当前用户
String username = SecurityUtils.getSubject().getPrincipal().toString();
User user = userService.getUser(username);
System.out.println("CurrentUser:" + username);
// 查询UserRole表, 找到用户对应的role id列表
LambdaQueryWrapper<UserRole> query = Wrappers.<UserRole>lambdaQuery().eq(UserRole::getUid, user.getId());
List<Integer> rids = userRoleService.list(query).stream().map(UserRole::getRid).collect(Collectors.toList());
// 找出这些角色对应的菜单项
LambdaQueryWrapper<RoleMenu> query2 = Wrappers.<RoleMenu>lambdaQuery().in(RoleMenu::getRid, rids);
List<Integer> menuIds = roleMenuService.list(query2).stream().map(RoleMenu::getMid).collect(Collectors.toList());
List<Menu> menus = listByIds(menuIds).stream().distinct().collect(Collectors.toList());
//处理菜单项的结构
handleMenus(menus);
return menus;
}


public void handleMenus(List<Menu> menus) {
menus.forEach(menu -> {
// LambdaQueryWrapper<Menu> query = Wrappers.<Menu>lambdaQuery().eq(Menu::getParentId, menu.getId());
// List<Menu> children = list(query);
List<Menu> children = menus.stream().filter(m -> m.getParentId() == menu.getId()).collect(Collectors.toList());
menu.setChildren(children);
});
// 只是移除显示上的层次关系,但内部多级层次关系并没有删除
menus.removeIf(m -> m.getParentId() != 0);
}
}

menuController:
1
2
3
4
5
6
7
8
9
10
11
@RestController
public class MenuController {

@Autowired
IMenuService menuService;

@GetMapping("/api/menu")
public List<Menu> menu() {
return menuService.getMenuByCurrentUser();
}
}

前端:

  1. Vuex Store添加菜单数组,保存允许访问的菜单项。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
     export default new Vuex.Store({
    state: {
    user: {
    username: window.localStorage.getItem('user' || '[]') == null ? '' : JSON.parse(window.localStorage.getItem('user' || '[]')).username
    },
    // 新增的用来保存可访问菜单项的数组
    adminMenus: []
    },
    mutations: {
    login (state, user) {
    state.user = {username: user.username}
    window.localStorage.setItem('user', JSON.stringify(user))
    },
    logout (state) {
    state.user = []
    window.localStorage.removeItem('user')
    },
    // 新增的菜单数组驱动
    initMenu (state, menus) {
    state.adminMenus = menus
    }
    }
    })
  2. 配置路由,包括Router入口、利用前置守卫添加菜单项路由
    1. 首先配置router下的index.js,新增'/admin'路由,作为展示菜单界面的入口。
      router/index.js:
      1
      2
      3
      4
      5
      6
      7
      8
      {
      path: '/admin',
      name: 'Admin',
      component: AdminIndex,
      meta: {
      requireAuth: true
      }
      }
      1. 利用Vue-Router前置守卫,在真正发出url请求之前初始话菜单,包括1. 将后端返回的菜单项path添加到路由,2. 更新store的adminMenus。这部分代码是在main.js中书写。
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        24
        25
        26
        27
        28
        29
        30
        31
        32
        33
        34
        35
        36
        37
        38
        39
        40
        41
        42
        43
        44
        45
        46
        47
        48
        49
        50
        51
        52
        53
        54
        55
        56
        57
        58
        59
        60
        61
        62
        63
        64
        65
        66
        67
        68
        69
        70
        71
        72
        73
        74
        75
        router.beforeEach((to, from, next) => {
        if (store.state.user.username && to.path.startsWith('/admin')) {
        // console.log('initMenu')
        initMenu(router, store)
        }
        // 已登录状态下访问login直接跳转到后台首页
        if (store.state.user.username && to.path.startsWith('/login')) {
        next({
        path: 'admin/dashboard'
        })
        }
        // 登录部分
        if (to.meta.requireAuth) {
        // console.log(store.state.user.username)
        if (store.state.user) {
        axios.get('/authentication')
        .then(resp => {
        if (resp.data) next()
        // resp.data为空代表后端拦截器判断是未认证、未RememberMe,但这时候依然有resp
        else {
        next({
        path: 'login',
        // path后缀, 以path?xxx=yyy附加拼接, redirect代表拼接字符xxx, 可以自定义
        // 该URL=login?redirect=%2Findex
        query: {redirect: to.fullPath}
        })
        }
        })
        } else {
        next({
        path: 'login',
        query: {redirect: to.fullPath}
        })
        }
        } else {
        next()
        }
        })
        //初始化菜单
        const initMenu = (router, store) => {
        if (store.state.adminMenus.length > 0) {
        return
        }
        axios.get('/menu')
        .then(resp => {
        if (resp && resp.status === 200) {
        // 把后端返回的菜单列表进行拼接
        var fmtRoutes = formatRoutes(resp.data)
        // 并添加到Router
        router.addRoutes(fmtRoutes)
        store.commit('initMenu', fmtRoutes)
        }
        })
        }
        //拼接菜单项路由
        const formatRoutes = (routes) => {
        let fmtRoutes = []
        routes.forEach(route => {
        if (route.children) {
        route.children = formatRoutes(route.children)
        }
        let fmtRoute = {
        path: route.path,
        component: resolve => {
        require(['./components/administration/' + route.component + '.vue'], resolve)
        },
        name: route.name,
        nameZh: route.nameZh,
        iconCls: route.iconCls,
        children: route.children
        }
        fmtRoutes.push(fmtRoute)
        })
        return fmtRoutes
        }
  3. 编写前端界面。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    <template>
    <div>
    <el-menu
    :default-active="currentPath"
    class="el-menu-admin"
    router
    mode="vertical"
    background-color="#545c64"
    text-color="#fff"
    active-text-color="#ffd04b"
    :collapse="isCollapse">
    <div style="height: 80px;"></div>
    <!--index 没有用但是必需字段且为 string -->
    <el-submenu v-for="(item,i) in adminMenus" :key="i" :index="(i).toString()" style="text-align: left">
    <span slot="title" style="font-size: 17px;">
    <i :class="item.iconCls"></i>
    {{ item.nameZh }}
    </span>
    <el-menu-item v-for="child in item.children" :key="child.path" :index="child.path">
    <i :class="child.icon"></i>
    {{ child.nameZh }}
    </el-menu-item>
    </el-submenu>
    </el-menu>
    </div>
    </template>

    <script>
    export default {
    name: 'AdminMenu',
    data () {
    return {
    isCollapse: false
    }
    },
    computed: {
    adminMenus () {
    return this.$store.state.adminMenus
    },
    currentPath () {
    return this.$route.path
    }
    }
    }
    </script>

    <style scoped>
    .el-menu-admin {
    border-radius: 5px;
    height: 100%;
    }
    </style>

总结步骤:1. 后端设计数据库。2. 后端设计service和controller,接收请求返回当前用户允许的菜单列表。3. 前端设计Vuex.store,新添菜单数组。4. 前端设计Router,包括router/index.js入口路由、main.js中动态添加菜单项路由。 5. 编写Vue组件AdminMenu.vue