Vue + Spring Boot 项目实战(十七):后台角色、权限与菜单分配 - Go语言中文社区

Vue + Spring Boot 项目实战(十七):后台角色、权限与菜单分配


logo


重要链接:
「系列文章目录」

「项目源码(GitHub)」

前言

有感于公司旁边的兰州拉面进化成了兰州料理,咱们的小破项目也被我做了一次品牌升级,虽然并没有什么卵用。

近两年在深圳,发现评价高的店味道不一定好吃,但环境、服务一定ok,过去很火的苍蝇铺子小吃摊逐渐吃不开了。人们的喜好在随经济发展变迁,这种发展变迁其实是有迹可循的,从先发展的地方搬运理念,是一种很常见的赚钱思路。然而现在人人都明白这个事,想做好不那么容易,所以这两年到处在嚷嚷下沉经济、垂直领域。昨天看了一篇分析商业趋势的长文,里面讲“区域崛起与县域经济”下产生的隐性冠军企业,提到全世界 60% 的假发产自我的家乡河南许昌,据说远销非洲大陆,这我还是头一次听说,不知道各位老铁有没有相关需求,要是你们够给力,我就辞职回去当一波靠山吃山的地头蛇。


(韭菜分割线)

这篇文章主要讲在已经实现了菜单和功能访问控制的情况下,如何编写实现 角色、权限、菜单分配 功能的接口与页面,本质是增删改查、表单表格,但以下知识点需要拿出来讲一下:

  • JPA 如何自定义查询语句?
  • 如何使用 element-ui 的树组件?
  • 如何接收并处理没有实体类型对应的数据?
  • Vue 如何不刷新清除路由记录?

这里每一个点讲深了都值得单独写一篇文章,你们要是有分享知识的欲望又苦于没有题材,完全可以从我每篇教程列出来的点里随便挑一个扩充一下,只要排版排到位再起个好题目,肯定会有不少人看。

一、角色、权限分配

角色分配,也就是给用户指定角色。根据我们之前的数据库设计,本质是更新 admin_user_role 表。为了实现这个目的,我们需要进行一系列的开发。

1.用户信息表与行数据获取

首先,我们要有一个组件负责展示查询出来的用户信息,并提供编辑操作的入口。element 的 table 组件提供了表格能用到的很多功能,比如排序、筛选、选择、懒加载等等,用的时候尽管怎么骚怎么来。我反正一直是走简约风格的:
用户信息
这个组件具体展示哪些信息可以根据你的喜好,但是一般来说没人会在这儿展示用户密码吧?而且 Hash 过的密码展示了也没用啊。前端可以控制显示的字段,但最好还是对后端查询的接口做相应的改造,直接不查询相应的内容。

JPA 默认查询出来的就是全量的数据,如果想指定字段,就需要自定义查询语句了。自定义查询语句写在 JpaRepository 类的方法上面,也就是我们的 UserDAO.java 中的 list() 方法:

@Query(value = "select new User(u.id,u.username,u.name,u.phone,u.email,u.enabled) from User u")
List<User> list();

这是 JPQL 的写法,也可以写原生的 SQL 语句,指定 nativeQuery = true 即可。前端表格示例代码如下:

<el-table
  :data="users"
  stripe>
  <el-table-column
    prop="id"
    label="id"
    width="100">
  </el-table-column>
  <el-table-column
    prop="username"
    label="用户名"
    fit>
  </el-table-column>
  
  ······
  
  <el-table-column
    label="操作"
    width="120">
    <template slot-scope="scope">
      <el-button
        @click="editUser(scope.row)"
        type="text"
        size="small">
        编辑
      </el-button>
      <el-button
        type="text"
        size="small">
        移除
      </el-button>
    </template>
  </el-table-column>
</el-table>

通过 data 绑定表格对应的数据,并通过 prop 指定列对应的字段。若想对表格里某一行的数据进行操作,就要想办法获取当前的数据。观察 “操作” 一列的代码:

<el-table-column
  label="操作"
  width="120">
  <template slot-scope="scope">
    <el-button
      @click="editUser(scope.row)"
      type="text"
      size="small">
      编辑
    </el-button>
  </template>
</el-table-column>

scope.row 便是点击编辑按钮所获取到的该行的数据。这里实际上利用的是作用域插槽,通过 <el-table-column> 组件获取到了数据。关于插槽,如果有不清楚的地方,可以看看下面这篇文章:

「深入理解vue中的slot与slot-scope」

OK,我们通过点击事件触发 editUser 方法并传入了该行的数据,接下来会发生什么事,要看你怎么设计。我的思路是弹出对话框,并通过表单组件实现单用户信息的显示与修改,效果如下:
修改信息
这里可以复用图书编辑弹出框,相同的内容就不赘述了,下面重点讲一下角色分配一栏。

2.角色分配

因为我们的用户和角色是多对多的关系,所以这里使用了多选框组件。为了正确显示用户对应的角色,我们需要先把所有的角色信息查询出来,再根据用户信息选中相应的角色。

查询出所有角色的方法很简单,在 mounted() 方法中调用即可。关键是如何选中当前用户对应的角色。

首先,我们需要查询出当前用户的角色。这时有两种思路:

  • 第一种,可以以用户名或 id 为参数向后端发送请求,查询出对应的角色值并返回
  • 第二种,改造后端查询用户信息的接口,直接在查询用户信息时就把角色信息查询出来

为了前后端传递参数更方便一些,我选用了第二种方法。使用这种方法需要在 User 实体类中添加属性来存放角色信息,但是由于数据库中并没有相应定义,所以我们要加上 @Transient 注释。

    @Transient
    List<AdminRole> roles;

	// getter and setter
    public List<AdminRole> getRoles() {
        return roles;
    }

    public void setRoles(List<AdminRole> roles) {
        this.roles = roles;
    }

相应地在 UserService 中修改列出所有用户的方法:

    public List<User> list() {
        List<User> users =  userDAO.list();
        List<AdminRole> roles;
        for (User user : users) {
            roles = adminRoleService.listRolesByUser(user.getUsername());
            user.setRoles(roles);
        }
        return users;
    }

AdminRoleService 中添加 listRolesByUser() 方法:

    public List<AdminRole> listRolesByUser(String username) {
        int uid =  userService.findByUserName(username).getId();
        List<AdminRole> roles = new ArrayList<>();
        List<AdminUserRole> urs = adminUserRoleService.listAllByUid(uid);
        for (AdminUserRole ur: urs) {
            roles.add(adminRoleDAO.findById(ur.getRid()));
        }
        return roles;
    }

这样查询出的用户就会带上角色信息了。

之后,我们使用 <el-checkbox-group>,也就是多选框组来实现角色的显示与编辑。观察如下代码:

<el-form-item label="角色分配" label-width="120px" prop="roles">
  <el-checkbox-group v-model="selectedRolesIds">
      <el-checkbox v-for="(role,i) in roles" :key="i" :label="role.id">{{role.nameZh}}</el-checkbox>
  </el-checkbox-group>
</el-form-item>

roles 是从后端查询到的所有角色信息,我们通过遍历渲染出角色的中文名称,并指定多选框的 label 值为 role.id 。selectedRolesIds 是一个 Array 类型的变量,也就是当前用户对应的角色的 id。组件会根据其中的数据匹配 label 值,并选中相应的内容。

可以在点击编辑按钮时调用处理方法,获取当前用户对应的角色的 id:

 editUser (user) {
   this.dialogFormVisible = true
   this.selectedUser = user
   let roleIds = []
   for (let i = 0; i < user.roles.length; i++) {
     roleIds.push(user.roles[i].id)
   }
   this.selectedRolesIds = roleIds
 }

这样,对话框中的角色信息就能正确渲染了。由于 selectedRolesIds 是与多选框双向绑定的,我们通过点选就可以改变这个 Array 的值。还是为了后端接收方便,我们在提交更改时可以做一些处理,通过这些角色 id 获得角色本身并传递。

onSubmit (user) {
  let _this = this
  // 根据视图绑定的角色 id 向后端传送角色信息
  let roles = []
  for (let i = 0; i < _this.selectedRolesIds.length; i++) {
    for (let j = 0; j < _this.roles.length; j++) {
      if (_this.selectedRolesIds[i] === _this.roles[j].id) {
        roles.push(_this.roles[j])
      }
    }
  }
  this.$axios.put('/admin/user', {
    username: user.username,
    name: user.name,
    phone: user.phone,
    email: user.email,
    roles: roles
  }).then(resp => {
    if (resp && resp.status === 200) {
      this.$alert('用户信息修改成功')
      this.dialogFormVisible = false
      // 修改角色后重新请求用户信息,实现视图更新
      this.listUsers()
    }
  })
}

后端对应的接口代码如下:

@PutMapping("/api/admin/user")
public Result editUser(@RequestBody User requestUser) {
    User user = userService.findByUserName(requestUser.getUsername());
    user.setName(requestUser.getName());
    user.setPhone(requestUser.getPhone());
    user.setEmail(requestUser.getEmail());
    userService.addOrUpdate(user);
    adminUserRoleService.saveRoleChanges(user.getId(),requestUser.getRoles());
    String message = "修改用户信息成功";
    return ResultFactory.buildSuccessResult(message);
}

saveRoleChanges() 方法即修改 admin_user_role 表里相应的内容。修改的思路是先删除原有用户(uid)对应的所有行,再根据新传递的数据做插入操作。这样的缺点是比较费自增 id(其实无所谓),但省去了比对的麻烦。

@Transactional
public void saveRoleChanges(int uid, List<AdminRole> roles) {
    adminUserRoleDAO.deleteAllByUid(uid);
    for (AdminRole role : roles) {
        AdminUserRole ur = new AdminUserRole();
        ur.setUid(uid);
        ur.setRid(role.getId());
        adminUserRoleDAO.save(ur);
    }
}

因为我们执行了删除操作,所以需要加上 @Transactional 注释开启事务,以保证数据的一致性。(不加是会跑出异常的哈)

OK,这样我们就完成了角色分配功能的开发。

3.权限分配

权限分配即为角色指定对应的权限。模仿上面的步骤,开发一套角色信息列表、编辑框即可。
权限分配
值得一讲的是菜单配置功能的实现。

二、菜单分配

本来菜单分配也是一样的逻辑,但谁让人家是树结构呢。

首先在前端显示上,我们得使用 「树形控件」,同时还要加上选择功能,好在这些 Element 都想到了。我们来看一下前端的代码:

<el-form-item label="菜单配置" label-width="120px" prop="menus">
  <el-tree
    :data="menus"
    :props="props"
    show-checkbox
    :default-checked-keys="selectedMenusIds"
    node-key="id"
    ref="tree">
  </el-tree>
</el-form-item>

各属性的作用如下:

  • data 指定了数据源为向后端查询到的菜单信息
  • props 指定树显示数据源的哪些属性
  • show-checkbox 开启了选择功能
  • :default-checked-keys 设置树第一次加载时默认选中的节点
  • node-key 指定树节点关联的属性为 id
  • ref 指定树的引用名,以方便调用相关方法

menus 同样可以在 mouted() 中调用查询方法获取。我们可以在后端定义根据角色查询 id 的方法:

public List<AdminMenu> getMenusByRoleId(int rid) {
    List<AdminMenu> menus = new ArrayList<>();
    List<AdminRoleMenu> rms = adminRoleMenuService.findAllByRid(rid);
    for (AdminRoleMenu rm : rms) {
        menus.add(adminMenuDAO.findById(rm.getMid()));
    }
    handleMenus(menus);
    return menus;
}

// 处理树结构的代码
public void handleMenus(List<AdminMenu> menus) {
    for (AdminMenu menu : menus) {
        menu.setChildren(getAllByParentId(menu.getId()));
    }

    Iterator<AdminMenu> iterator = menus.iterator();
    while (iterator.hasNext()) {
        AdminMenu menu = iterator.next();
        if (menu.getParentId() != 0) {
            iterator.remove();
        }
    }
}

再根据系统管理员角色的 id 进行查询:

@GetMapping("/api/admin/role/menu")
public List<AdminMenu> listAllMenus() {
    List<AdminMenu> menus = adminMenuService.getMenusByRoleId(1);
    return menus;
}

即可获得全量的菜单数据。

前端树控件的 props 设定如下:

props: {
  id: 'id',
  label: 'nameZh',
  children: 'children'
}

左边是树组件的属性,右边是我们数据的属性。为了正确地根据 id 加载选中项,在点击编辑按钮时我们需要执行如下操作:

editRole (role) {

  ...
  
  let menuIds = []
  for (let i = 0; i < role.menus.length; i++) {
    menuIds.push(role.menus[i].id)
    for (let j = 0; j < role.menus[i].children.length; j++) {
      menuIds.push(role.menus[i].children[j].id)
    }
  }
  this.selectedMenusIds = menuIds
  // 判断树是否已经加载
  // 第一次打开对话框前树不存在,无法调用方法,需要通过设置 default-checked 正确选中菜单项
  if (this.$refs.tree) {
    this.$refs.tree.setCheckedKeys(menuIds)
  }
}

通过视图选择相应菜单项后,我们可以向后端发送更新请求,然鹅跟前两个不同的是,这次我们只发 id 就好,因为树结构比对起来比较麻烦。请求的代码如下:

this.$axios.put('/admin/role/menu?rid=' + role.id, {
  menusIds: this.$refs.tree.getCheckedKeys()
}).then(resp => {
  if (resp && resp.status === 200) {
    console.log(resp.data.data)
  }
})

getCheckedKeys() 是 tree 组件提供的,可以很方便地发送选中的数据(根据 node-key 的设置获取数据)。后端接收到的是一个 Map,处理时稍微费劲一点:

@PutMapping("/api/admin/role/menu")
public void updateRoleMenu(@RequestParam int rid, @RequestBody LinkedHashMap menusIds) {
    adminRoleMenuService.deleteAllByRid(rid);
    for (Object value : menusIds.values()) {
        for (int mid : (List<Integer>)value) {
            AdminRoleMenu rm = new AdminRoleMenu();
            rm.setRid(rid);
            rm.setMid(mid);
            adminRoleMenuService.save(rm);
        }
    }
}

同样是执行了删除操作,要为 AdminRoleMenuService 中的删除方法开启事务。处理数据的过程不通用,所以就不往 Service 层放了。

如果一个用户有多个角色,按照之前的方法会导致加载重复的菜单。可以修改一下根据当前用户获得菜单的方法 getMenusByCurrentUser(),避免添加重复的菜单项。

public List<AdminMenu> getMenusByCurrentUser() {
    String username = SecurityUtils.getSubject().getPrincipal().toString();
    User user = userService.findByUserName(username);
    List<AdminUserRole> userRoleList = adminUserRoleService.listAllByUid(user.getId());
    List<AdminMenu> menus = new ArrayList<>();
    for (AdminUserRole userRole : userRoleList) {
        List<AdminRoleMenu> rms = adminRoleMenuService.findAllByRid(userRole.getRid());
        for (AdminRoleMenu rm : rms) {
            // 增加防止多角色状态下菜单重复的逻辑
            AdminMenu menu = adminMenuDAO.findById(rm.getMid());
            boolean isExist = false;
            for (AdminMenu m : menus) {
                if (m.getId() == menu.getId()) {
                    isExist = true;
                }
            }
            if (!isExist) {
                menus.add(menu);
            }
        }
    }
    handleMenus(menus);
    return menus;
}

此外还有一个问题,后台切换用户后前端会报路由重复的错误,所以我们要在注销登录时把原本的路由信息清空。比较容易

版权声明:本文来源CSDN,感谢博主原创文章,遵循 CC 4.0 by-sa 版权协议,转载请附上原文出处链接和本声明。
原文链接:https://blog.csdn.net/neuf_soleil/article/details/103603726
站方申明:本站部分内容来自社区用户分享,若涉及侵权,请联系站方删除。
  • 发表于 2020-02-02 18:17:17
  • 阅读 ( 1779 )
  • 分类:前端

0 条评论

请先 登录 后评论

官方社群

GO教程

猜你喜欢