飞道的博客

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

364人阅读  评论(0)


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

「项目源码(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 即可。

如果你习惯用 DTO(数据传输对象),也可以直接控制传递给 DTO 的参数。

前端表格示例代码如下:

<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()
    }
  })
}

后端对应的代码如下:

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

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,处理时稍微费劲一点:

// RoleController
@PutMapping("/api/admin/role/menu")
public void updateRoleMenu(@RequestParam int rid, @RequestBody LinkedHashMap menusIds) {
    adminRoleMenuService.updateRoleMenu(rid, menusIds);
}
// AdminRoleMenuService
@Modifying
@Transactional
public void deleteAllByRid(int rid) {
    adminRoleMenuDAO.deleteAllByRid(rid);
}

public void updateRoleMenu(int rid, LinkedHashMap menusIds) {
    deleteAllByRid(rid);
    for (Object value : menusIds.values()) {
        for (int mid : (List<Integer>)value) {
            AdminRoleMenu rm = new AdminRoleMenu();
            rm.setRid(rid);
            rm.setMid(mid);
            adminRoleMenuDAO.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;
}

此外还有一个问题,后台切换用户后前端会报路由重复的错误,所以我们要在注销登录时把原本的路由信息清空。比较容易想到的是通过刷新页面清空路由,但是这样页面就会有明显的卡顿或空白,所以我们尝试利用别的方法清空路由。

常见的方法是:

  • 新建一个路由,包含不需动态加载的默认路由信息
  • 设置当前路由的 matcher 为新路由的 matcher

网上关于替换 matcher 的解释如下:

替换当前路由实例的 matcher 之所以能实现删除动态添加的路由,是因为替换当前路由的 matcher 本质 上是 替换了现有的路由实例的路由映射容器。新的 matcher 始终 仅仅 包含路由实例化时的路由,而 不会包含 后期被 addRoutes 方法添加的路由,那么替换当前路由的 matcher 就可实现删除通过 addRoutes 添加的路由。

ok,首先我们在 router\index.js 中添加一个新建默认路由的方法:

// 用于创建默认路由
export const createRouter = routes => new Router({
  mode: 'history',
  routes: [
    {
      path: '/',
      name: 'Default',
      redirect: '/home',
      component: Home
    },
    {
      path: '/home',
      name: 'Home',
      component: Home,
      redirect: '/index',
      children: [
        {
          path: '/index',
          name: 'AppIndex',
          component: AppIndex
        },
        {
          path: '/jotter',
          name: 'Jotter',
          component: JotterIndex
        },
        {
          path: '/editor',
          name: 'Editor',
          component: Editor,
          meta: {
            requireAuth: true
          }
        },
        {
          path: '/library',
          name: 'Library',
          component: LibraryIndex
        }
      ]
    },
    {
      path: '/login',
      name: 'Login',
      component: Login
    },
    {
      path: '/register',
      name: 'Register',
      component: Register
    },
    {
      path: '/admin',
      name: 'Admin',
      component: AdminIndex,
      meta: {
        requireAuth: true
      },
      children: [
        {
          path: '/admin/dashboard',
          name: 'dashboard',
          component: DashBoard,
          meta: {
            requireAuth: true
          }
        }
      ]
    }
  ]
})

再在执行 logout 方法时调用,记得导入

<script>
  import {createRouter} from '../../router'

  export default {
    name: 'Header',
    methods: {
      logout () {
        var _this = this
        this.$axios.get('/logout').then(resp => {
          if (resp.data.code === 200) {
            _this.$store.commit('logout')
            _this.$router.replace('/index')
            // 清空路由,防止路由重复加载
            const newRouter = createRouter()
            _this.$router.matcher = newRouter.matcher
          }
        }).catch(failResponse => {})
      }
    }
  }
</script>

这样就不会报错了。

至此,角色、权限、菜单的分配功能就完成了。基本上有了这些,一个后台管理系统就成形了。此外我还开发了批量添加用户、新建角色、用户与角色状态变更等功能,各位可以参照源码自行实现。

下一步

我打算开发一些实用的模块,比如文章(新闻)、轮播图等内容的发布和管理,完成后这个项目就可以改造为个人主页或者门户网站之类的应用发布了。至于数据级访问控制的实现可能要先鸽一下,因为这个东西目前没有特别好的示例场景,若只是针对图书管理这一功能,要讲的东西跟前面基本是一回事。

CSDN 的 APP 推荐各位下载一下,今年改版了好多次,出了很多新功能,比如可以发 Blink 动态,一般我发布文章后都会臭不要脸来一条,这样你们就能知道我这逼又更新了。说实话,我觉得我能获得这么多关注主要是因为赶上了平台发展的好时候,你们也赶快上车啊。

咱们这个项目毕竟是开源的,我偷懒的时候你们不妨也尝试提交下代码,就算只是挑挑错误拼写熟悉熟悉 git 使用也挺有意义的,不要不好意思,我这儿很好通过的,咱们要充分发挥这个破玩意儿的价值。

头两天有读者发邮件问我多大了、干什么的、工作几年了,我寻思这语气是要给我介绍对象啊,我女票可是随时会看我邮箱的,咱们下次找个隐秘渠道私聊哈。实不相瞒,我就是个做 PPT 的,95 后老大叔,不喜欢折腾,但应该比百分之九十的同龄人努力,每周平均工作六天,每天平均学习 4 小时,没啥目标,就想挣它一个亿。我可能是为数不多的实现 2019 年小目标的人吧,2020 年继续加油喽。


转载:https://blog.csdn.net/Neuf_Soleil/article/details/103603726
查看评论
* 以上用户言论只代表其个人观点,不代表本网站的观点或立场