系列文章目录
《SpringBoot整合SpringSecurity实现权限控制(一):实现原理》
《SpringBoot整合SpringSecurity实现权限控制(二):权限数据基本模型设计》
《SpringBoot整合SpringSecurity实现权限控制(三):前端动态装载路由与菜单》
《SpringBoot整合SpringSecurity实现权限控制(四):角色管理》
《SpringBoot整合SpringSecurity实现权限控制(五):用户管理》
《SpringBoot整合SpringSecurity实现权限控制(六):菜单管理》
本文目录
一、实现原理
- 在前面的系列文章中,我们已经实现了用户管理、角色管理与菜单管理,接下来我们把这三个功能串起来:
- 通过权限分配把让角色关联到各个功能菜单。比如让系统管理员角色关联到用户管理、角色管理等菜单。
- 通过角色设置让用户关联到单个或多个角色。比如让张三用户关联到系统管理员角色。
- 用户关联到角色,角色关联到菜单,也即让用户间接拥有了系统的功能。
二、后端实现
2.1 创建权限表
- 该表存储角色id与菜单id的关联信息
/**
* 权限表
*
* @author zhuhuix
* @date 2021-10-26
*/
@ApiModel(value = "权限信息")
@Data
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
@TableName("sys_permission")
public class SysPermission {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
// 角色id
private Long roleId;
// 菜单id
private Long menuId;
private Timestamp createTime;
public SysPermission(Long roleId, Long menuId, Timestamp createTime) {
this.roleId = roleId;
this.menuId = menuId;
this.createTime = createTime;
}
}
2.2 添加操作权限表的Mapper接口
- 通过继承mybatis-plus的BaseMapper接口创建操作权限表的DAO接口,该BaseMapper接口已经包含了基本的增删改查操作。
/**
* 权限表DAO接口
*
* @author zhuhuix
* @date 2021-10-26
*/
@Mapper
public interface SysPermissionMapper extends BaseMapper<SysPermission> {
// BaseMapper接口已经默认实现了基本的增删改查操作
}
2.3 实现角色权限分配的服务
- 在原有角色管理的服务接口中加入获取角色权限与保存角色权限的接口.
/**
* 角色信息接口
*
* @author zhuhuix
* @date 2021-09-13
* @date 2021-10-26 增加getPermission,savePermission
*/
public interface SysRoleService {
....
/**
* 获取角色权限
*
* @param roleId 角色id
* @return 角色权限列表
*/
List<SysPermission> getPermission(Long roleId);
/**
* 保存角色权限
*
* @param roleId 角色id
* @param menus 权限表
* @return 是否成功
*/
Boolean savePermission(Long roleId,Set<Long> menus);
}
- 实现获取角色权限与保存角色权限的接口
/**
* 角色实现类
*
* @author zhuhuix
* @date 2021-09-13
* @date 2021-10-26 实现getPermission,savePermission接口
*/
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(propagation = Propagation.SUPPORTS, readOnly = true, rollbackFor = Exception.class)
public class SysRoleServiceImpl implements SysRoleService {
private final SysRoleMapper sysRoleMapper;
private final SysPermissionMapper sysPermissionMapper;
...
@Override
public List<SysPermission> getPermission(Long roleId) {
QueryWrapper<SysPermission> queryWrapper = new QueryWrapper<>();
queryWrapper.lambda().eq(SysPermission::getRoleId, roleId);
return sysPermissionMapper.selectList(queryWrapper);
}
@Override
@Transactional(rollbackFor = Exception.class)
public Boolean savePermission(Long roleId, Set<Long> menus) {
// 先根据roleId删除原有权限
QueryWrapper<SysPermission> queryWrapper = new QueryWrapper<>();
queryWrapper.lambda().eq(SysPermission::getRoleId, roleId);
sysPermissionMapper.delete(queryWrapper);
// 再插入roleId新权限
for (Long menu : menus) {
int rowCount = sysPermissionMapper.insert(
new SysPermission(roleId, menu, Timestamp.valueOf(LocalDateTime.now())));
if (rowCount <= 0) {
throw new RuntimeException("保存权限失败");
}
}
return true;
}
}
2.4 在Controller层添加访问API接口
/**
* 角色信息api
*
* @author zhuhuix
* @date 2021-09-13
* @date 2021-10-26 增加getPermission,savePermission API接口
*/
@Slf4j
@RestController
@AllArgsConstructor
@RequestMapping("/api/role")
@Api(tags = "角色信息接口")
public class SysRoleController {
private final SysRoleService sysRoleService;
....
@ApiOperation("获取角色权限信息")
@GetMapping("{roleId}/permission")
public ResponseEntity<Object> getPermission(@PathVariable Long roleId) {
return ResponseEntity.ok(sysRoleService.getPermission(roleId));
}
@ApiOperation("保存角色权限信息")
@PostMapping("{roleId}/permission")
public ResponseEntity<Object> savePermission(@PathVariable Long roleId,@RequestBody Set<Long> menus) {
return ResponseEntity.ok(sysRoleService.savePermission(roleId,menus));
}
}
三、前端实现
3.1 添加角色权限api访问接口
- 根据后端API,在角色信息api访问中添加读取权限与保存权限的接口
/**
* 角色访问后端api
* 2021-10-26 添加getPermission,savePermission
*/
import request from '@/utils/request'
...
export function getPermission(roleId) {
return request({
url: '/api/role/' + roleId + '/permission',
method: 'get'
})
}
export function savePermission(roleId, data) {
return request({
url: '/api/role/' + roleId + '/permission',
method: 'post',
data
})
}
3.2 添加角色分配权限的页面
- 我们需要在原有角色信息的卡片列表中增加分配权限的按钮,用户点击按钮后弹出对应角色分配权限的操作页面。
- 在弹出的操作页面上引用TreeSelect组件,加载系统的功能菜单,供用户选择。
- 权限分配页面的完整实现代码
/**
* role/index.vue
* 2021-10-27 增加角色权限分配操作页面
*/
<template>
<div class="app-container">
<!--工具栏-->
<div class="head-container">
<!-- 搜索 -->
<el-input
v-model="roleName"
size="small"
clearable
placeholder="输入角色名称搜索"
style="width: 200px"
class="filter-item"
@keyup.enter.native="doQuery"
/>
<el-date-picker
v-model="createTime"
:default-time="['00:00:00', '23:59:59']"
type="daterange"
range-separator=":"
size="small"
class="date-item"
value-format="yyyy-MM-dd HH:mm:ss"
start-placeholder="开始日期"
end-placeholder="结束日期"
/>
<el-button
class="filter-item"
size="mini"
type="success"
icon="el-icon-search"
@click="doQuery"
>搜索</el-button>
<el-button
class="filter-item"
size="mini"
type="primary"
icon="el-icon-circle-plus-outline"
@click="doAdd"
>新增</el-button>
</div>
<!-- 表单渲染 -->
<el-dialog
append-to-body
:close-on-click-modal="false"
:before-close="doBeforeClose"
:visible.sync="showDialog"
width="520px"
>
<el-form
ref="form"
:inline="true"
:model="form"
:rules="rules"
size="small"
label-width="80px"
>
<el-form-item label="角色编码" prop="roleCode">
<el-input v-model="form.roleCode" style="width: 380px" />
</el-form-item>
<el-form-item label="角色名称" prop="roleName">
<el-input v-model="form.roleName" style="width: 380px" />
</el-form-item>
<el-form-item label="描述信息" prop="description">
<el-input
v-model="form.description"
style="width: 380px"
rows="5"
type="textarea"
/>
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button type="text" @click="doCancel">取消</el-button>
<el-button
:loading="formLoading"
type="primary"
@click="doSubmit(form)"
>确认</el-button>
</div>
</el-dialog>
<el-row>
<el-col
v-for="item in roles"
:key="item.id"
:span="5"
style="margin-bottom: 10px"
:offset="1"
>
<el-card>
<div slot="header" class="clearfix">
<i class="el-icon-user" /><span style="margin-left: 5px">{{ item.roleName }}</span>
<div style="display: inline-block; float: right; cursor: pointer" @click="doEdit(item.id)">
<el-tooltip effect="dark" content="编辑角色" placement="top">
<i class="el-icon-edit-outline" style="margin-left: 15px" />
</el-tooltip>
</div>
</div>
<div>
<ul class="role-info">
<li>
<div class="role-left">描述信息:{{ item.description }}</div>
</li>
<li>
<div class="role-left">
创建时间:{{ parseTime(item.createTime) }}
</div>
</li>
</ul>
</div>
<div style="display: inline-block; float: left; cursor: pointer" @click="doAssignPemission(item.id,item.roleName)">
<el-tooltip effect="dark" content="权限分配" placement="bottom">
<i class="el-icon-menu" />
</el-tooltip>
</div>
<div style="display: inline-block; float: right; cursor: pointer" @click="doDelete(item.id)">
<el-tooltip effect="dark" content="删除角色" placement="bottom">
<i class="el-icon-delete" style="margin-left: 15px" />
</el-tooltip>
</div>
</el-card>
</el-col>
</el-row>
<!-- 分配权限表单 -->
<el-dialog
append-to-body
:close-on-click-modal="false"
:visible.sync="showPermissionDialog"
:title="permission.roleName"
width="520px"
>
<treeselect
v-model="permission.menus"
:options="menuTree"
:show-count="true"
style="width: 480px"
:multiple="true"
:sort-value-by="sortValueBy"
:value-consists-of="valueConsistsOf"
:default-expand-level="1"
placeholder="请选择或搜索菜单进行权限分配"
/>
<div slot="footer" class="dialog-footer">
<el-button type="text" @click="doPemissionCancel">取消</el-button>
<el-button
type="primary"
@click="doSubmitPemission(permission)"
>确认</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { parseTime } from '@/utils/index'
import { getRoleList, getRole, saveRole, deleteRole, getPermission, savePermission } from '@/api/role'
import { getMenuList } from '@/api/menu'
import Treeselect from '@riophae/vue-treeselect'
import '@riophae/vue-treeselect/dist/vue-treeselect.css'
export default {
name: 'Role',
components: { Treeselect },
data() {
return {
showDialog: false,
loading: false,
formLoading: true,
form: {},
roles: [],
roleName: '',
createTime: null,
rules: {
roleCode: [
{ required: true, message: '请输入角色编码', trigger: 'blur' }
],
roleName: [
{ required: true, message: '请输入角色名称', trigger: 'blur' }
]
},
showPermissionDialog: false,
permission: {},
menuTree: [],
valueConsistsOf: 'ALL_WITH_INDETERMINATE',
sortValueBy: 'INDEX'
}
},
created() {
},
methods: {
parseTime,
doQuery() {
var param = { roleName: this.roleName }
if (this.createTime != null) {
param.createTimeStart = Date.parse(this.createTime[0])
param.createTimeEnd = Date.parse(this.createTime[1])
}
getRoleList(param).then(res => {
if (res) {
this.roles = res
}
})
},
doAdd() {
this.showDialog = true
this.formLoading = false
this.form = {}
},
doEdit(id) {
this.showDialog = true
getRole(id).then(res => {
if (res) {
this.form = res
this.formLoading = false
}
})
},
doCancel() {
this.showDialog = false
this.formLoading = true
this.form = {}
},
doSubmit(role) {
this.$refs.form.validate(valid => {
if (valid) {
this.formLoading = true
saveRole(role).then(res => {
if (res) {
this.showDialog = false
this.$notify({
title: '保存成功',
type: 'success',
duration: 2500
})
this.doQuery()
}
}).catch(() => {
this.formLoading = false
})
}
})
},
doDelete(id) {
this.$confirm(`确认删除此条数据?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() =>
deleteRole([id]).then(res => {
if (res) {
this.$notify({
title: '删除成功',
type: 'success',
duration: 2500
})
this.doQuery()
}
})
).catch(() => {
})
},
doBeforeClose() {
this.showDialog = true
},
doAssignPemission(roleId, roleName) {
var param = { name: '' }
getMenuList(param).then(res => {
if (res) {
this.menuTree = this.ArrayToTreeData(res)
getPermission(roleId).then(res => {
if (res) {
const menus = []
res.forEach(element => {
menus.push(element.menuId)
})
this.permission = { roleId: roleId, roleName: roleName, menus: menus }
this.showPermissionDialog = true
}
})
}
})
},
doPemissionCancel() {
this.showPermissionDialog = false
this.permission = {}
},
doSubmitPemission(permission) {
console.log(permission)
savePermission(permission.roleId, permission.menus).then(res => {
if (res) {
this.showPermissionDialog = false
this.$notify({
title: '配置权限成功',
type: 'success',
duration: 2500
})
}
})
},
ArrayToTreeData(data) {
const cloneData = JSON.parse(JSON.stringify(data)) // 对源数据深度克隆
return cloneData.filter(father => {
const branchArr = cloneData.filter(child => father.id === child.pid) // 返回每一项的子级数组
branchArr.length > 0 ? father.children = branchArr : '' // 如果存在子级,则给父级添加一个children属性,并赋值
const parentArr = cloneData.filter(parent => parent.id === father.pid) // 判断该菜单的父级菜单是否存在
if (parentArr.length === 0) { return father } // 如果该菜单的父级菜单不存在,则直接返回该菜单
return father.pid === null // 返回第一层
})
}
}
}
</script>
<style rel="stylesheet/scss" lang="scss">
.role-span {
font-weight: bold;
color: #303133;
font-size: 15px;
}
.role-info {
margin-top: 0;
padding-top: 0;
padding-left: 0;
list-style: none;
li {
border-bottom: 1px solid #f0f3f4;
padding: 11px 0;
font-size: 12px;
}
.role-left {
color: rgb(148, 137, 137);
overflow: hidden;
white-space: nowrap;
text-align: left;
text-overflow: ellipsis;
}
.line{
width: 100%;
height: 1px;
border-top: 1px solid #ccc;
}
}
</style>
<style rel="stylesheet/scss" lang="scss" scoped>
::v-deep .el-input-number .el-input__inner {
text-align: left;
}
::v-deep .vue-treeselect__multi-value {
margin-bottom: 0;
}
::v-deep .vue-treeselect__multi-value-item {
border: 0;
padding: 0;
}
</style>
3.3 根据用户权限动态生成菜单栏
- 权限信息最终要渲染成菜单栏,一般需要以下步骤:
- 当用户登录后,根据用户角色获取权限信息,根据权限信息生成最终用户可访问的路由表。
/**
* src/permission.js
* 登录获取用户角色与权限信息并动态加载路由表
*/
import router from './router'
import store from './store'
import { Message } from 'element-ui'
import NProgress from 'nprogress' // progress bar
import 'nprogress/nprogress.css' // progress bar style
import { getToken } from '@/utils/auth' // get token from cookie
import getPageTitle from '@/utils/get-page-title'
NProgress.configure({ showSpinner: false }) // NProgress Configuration
const whiteList = ['/login', '/register'] // no redirect whitelist
router.beforeEach(async(to, from, next) => {
// start progress bar
NProgress.start()
// set page title
document.title = getPageTitle(to.meta.title)
// determine whether the user has logged in
const hasToken = getToken()
if (hasToken) {
if (to.path === '/login' && store.getters.user.userName !== undefined) {
// if is logged in, redirect to the home page
next({ path: '/' })
NProgress.done()
} else {
if (store.getters.user.userName === undefined) {
try {
// 首次登录需要获取用户信息
store.dispatch('getInfo').then(() => {
// 根据用户信息获取用户权限并动态加载
store.dispatch('permission/generateRoutes').then(() => next({ ...to, replace: true }))
})
} catch (error) {
store.dispatch('user/resetToken')
Message.error(error || 'Has Error')
next(`/login?redirect=${to.path}`)
NProgress.done()
}
} else {
// 如果未装载过,则需要装载
if (!store.getters.menuLoaded) {
store.dispatch('permission/generateRoutes').then(() => next())
} else {
next()
}
}
}
} else {
/* has no token*/
if (whiteList.indexOf(to.path) !== -1) {
// in the free login whitelist, go directly
next()
} else {
// other pages that do not have permission to access are redirected to the login page.
next(`/login?redirect=${to.path}`)
NProgress.done()
}
}
})
router.afterEach(() => {
// finish progress bar
NProgress.done()
})
- 通过router.addRoutes动态挂载路由,并使用vuex存储路由表
/**
* store/modules/permission.js
* 动态加载路由表
*/
import { constantRoutes, router } from '@/router'
import { getUserPermission } from '@/api/user'
import store from '@/store'
import Layout from '@/layout/index'
export const filterAsyncRouter = (routers) => { // 遍历后台传来的路由字符串,转换为组件对象
return routers.filter(router => {
if (router.component) {
if (router.component === 'Layout') { // Layout组件特殊处理
router.component = Layout
} else {
const component = router.component
router.component = loadView(component)
}
}
router.meta = { title: router.name, icon: router.icon, noCache: !router.cache }
if (router.children && router.children.length) {
router.children = filterAsyncRouter(router.children)
}
return true
})
}
export const loadView = (view) => {
return (resolve) => require([`@/views/${view}`], resolve)
}
const state = {
routes: [],
addRoutes: [],
menuLoaded: false
}
const mutations = {
SET_ROUTES: (state, routes) => {
state.addRoutes = routes
state.routes = constantRoutes.concat(routes)
console.log('addRoutes routes', state.routes)
// 增加动态路由
router.addRoutes(routes)
},
SET_MENULOADED: (state, menuLoaded) => {
state.menuLoaded = menuLoaded
// console.log('menuLoaded', state.menuLoaded)
}
}
const actions = {
generateRoutes({ commit }) {
return new Promise(resolve => {
let accessedRoutes
getUserPermission(store.getters.user.id).then(res => {
console.log('res', res)
accessedRoutes = ArrayToTreeData(res)
console.log('accessedRoutes', accessedRoutes)
let asyncRouter = []
if (accessedRoutes && accessedRoutes.length) {
asyncRouter = filterAsyncRouter(accessedRoutes)
console.log('asyncRouter', asyncRouter)
}
asyncRouter.push({ path: '*', redirect: '/404', hidden: true })
commit('SET_ROUTES', asyncRouter)
commit('SET_MENULOADED', true)
resolve(asyncRouter)
})
})
},
setMenuLoaded({ commit }, munuLoaded) {
return new Promise(resolve => {
commit('SET_MENULOADED', munuLoaded)
})
}
}
function ArrayToTreeData(data) {
const cloneData = JSON.parse(JSON.stringify(data)) // 对源数据深度克隆
return cloneData.filter(father => {
const branchArr = cloneData.filter(child => father.id === child.pid) // 返回每一项的子级数组
branchArr.length > 0 ? father.children = branchArr : '' // 如果存在子级,则给父级添加一个children属性,并赋值
return father.pid === null // 返回第一层
})
}
export default {
namespaced: true,
state,
mutations,
actions
}
- 根据vuex中可访问的路由渲染侧边菜单栏。
/**
* 侧边栏
* Siderbar/index.vue
*/
<template>
<div :class="{'has-logo':showLogo}">
<logo v-if="showLogo" :collapse="isCollapse" />
<el-scrollbar wrap-class="scrollbar-wrapper">
<el-menu
:default-active="activeMenu"
:collapse="isCollapse"
:background-color="variables.menuBg"
:text-color="variables.menuText"
:unique-opened="false"
:active-text-color="variables.menuActiveText"
:collapse-transition="false"
mode="vertical"
>
<sidebar-item v-for="route in permission_routes" :key="route.path" :item="route" :base-path="route.path" />
</el-menu>
</el-scrollbar>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
import Logo from './Logo'
import SidebarItem from './SidebarItem'
import variables from '@/styles/variables.scss'
export default {
components: { SidebarItem, Logo },
computed: {
...mapGetters([
'permission_routes',
'sidebar'
]),
activeMenu() {
const route = this.$route
const { meta, path } = route
// if set path, the sidebar will highlight the path you set
if (meta.activeMenu) {
return meta.activeMenu
}
return path
},
showLogo() {
return this.$store.state.settings.sidebarLogo
},
variables() {
return variables
},
isCollapse() {
return !this.sidebar.opened
}
}
}
</script>
/**
* 菜单组件
* Siderbar/SiderbarItem.vue
*/
<template>
<div v-if="!item.hidden">
<template v-if="hasOneShowingChild(item.children,item) && (!onlyOneChild.children||onlyOneChild.noShowingChildren)&&!item.alwaysShow">
<app-link v-if="onlyOneChild.meta" :to="resolvePath(onlyOneChild.path)">
<el-menu-item :index="resolvePath(onlyOneChild.path)" :class="{'submenu-title-noDropdown':!isNest}">
<item :icon="onlyOneChild.meta.icon||(item.meta&&item.meta.icon)" :title="onlyOneChild.meta.title" />
</el-menu-item>
</app-link>
</template>
<el-submenu v-else ref="subMenu" :index="resolvePath(item.path)" popper-append-to-body>
<template slot="title">
<item v-if="item.meta" :icon="item.meta && item.meta.icon" :title="item.meta.title" />
</template>
<sidebar-item
v-for="child in item.children"
:key="child.path"
:is-nest="true"
:item="child"
:base-path="resolvePath(child.path)"
class="nest-menu"
/>
</el-submenu>
</div>
</template>
<script>
import path from 'path'
import { isExternal } from '@/utils/validate'
import Item from './Item'
import AppLink from './Link'
import FixiOSBug from './FixiOSBug'
export default {
name: 'SidebarItem',
components: { Item, AppLink },
mixins: [FixiOSBug],
props: {
// route object
item: {
type: Object,
required: true
},
isNest: {
type: Boolean,
default: false
},
basePath: {
type: String,
default: ''
}
},
data() {
// To fix https://github.com/PanJiaChen/vue-admin-template/issues/237
// TODO: refactor with render function
this.onlyOneChild = null
return {}
},
methods: {
hasOneShowingChild(children = [], parent) {
const showingChildren = children.filter(item => {
if (item.hidden) {
return false
} else {
// Temp set(will be used if only has one showing child)
this.onlyOneChild = item
return true
}
})
// When there is only one child router, the child router is displayed by default
// zhuhuix 2021-10-27去除 父菜单下只有一个子菜单,也显示父菜单;(以下注释取消的效果是父菜单下只有一个子菜单,不显示父菜单,直接显示子菜单)
// if (showingChildren.length === 1) {
// return true
// }
// Show parent if there are no child router to display
if (showingChildren.length === 0) {
this.onlyOneChild = { ... parent, path: '', noShowingChildren: true }
return true
}
return false
},
resolvePath(routePath) {
if (isExternal(routePath)) {
return routePath
}
if (isExternal(this.basePath)) {
return this.basePath
}
return path.resolve(this.basePath, routePath)
}
}
}
</script>
四、效果演示
五、源码
- 前端
https://gitee.com/zhuhuix/startup-frontend
https://github.com/zhuhuix/startup-frontend - 后端
https://gitee.com/zhuhuix/startup-backend
https://github.com/zhuhuix/startup-backend