若依管理系统前端实践
若依管理系统是一套基于若依框架开发的后台管理系统,它是一个前后端分离的项目,前端使用vue, Element, 后端使用Spring Boot & Security。这篇随笔中将记录一下自己在使用过程中前端使用上的一些收获和问题。
目录
1. 路由控制
1.1 简述
首先是路由控制。若依管理系统前端路由控制的核心在src/permission.js文件中。其主要逻辑在router.beforeEach中,如下面的流程图所示:
其核心代码如下:
router.beforeEach(async(to, from, next) => {
NProgress.start() // 开启进度条
document.title = getPageTitle(to.meta.title) // 设置页面标题
const hasToken = getToken()
// 判断是否有token,如果有则获取角色与权限
if (hasToken) {
if (to.path === '/login') {
// 如果已经有token,且访问的是登录页面,则跳转到首页
next({ path: '/' })
NProgress.done()
} else {
// 从store中获取角色与权限
const hasRoles = store.getters.roles && store.getters.roles.length > 0
if (hasRoles) {
next()
} else {
try {
// 获取用户信息
const { roles } = await store.dispatch('user/getInfo')
// 生成路由
const accessRoutes = await store.dispatch('permission/generateRoutesByRequiredList', roles)
router.addRoutes(accessRoutes)
next({ ...to, replace: true })
} catch (error) {
// 遇到错误则直接重置token,跳转到登录页面
await store.dispatch('user/resetToken')
Message.error(error || 'Has Error')
next(`/login?redirect=${to.path}`)
NProgress.done()
}
}
}
} else {
// 没有token则直接跳转到登录页面
if (whiteList.indexOf(to.path) !== -1) {
// 无需登录的页面则写进whiteList中,可以直接访问
next()
} else {
next(`/login?redirect=${to.path}`)
NProgress.done()
}
}
})
router.afterEach(() => {
NProgress.done() // 结束进度条
/* 由https://github.com/PanJiaChen/vue-element-admin/pull/2939可知
* afterEach并不总是会被调用,例如在首页手动跳转到登录页面,上面代码在登录的情况下会跳转到首页,则afterEach不会被调用
* 所以即使在这里写了NProgress.done(),在某些情况下也要在beforeEach中单独手动调用NProgress.done()
*/
})
在这其中使用了NProgress
来显示页面加载进度条,NProgress
是一个轻量级的进度条插件,只需要在路由跳转前调用NProgress.start()
,在路由跳转后调用NProgress.done()
即可。
1.2 token的检验
在每次路由跳转前先判断了是否有token,如果有token则获取用户角色与权限并进一步生成路由,如果没有token则跳转到登录页面。这里token的检验进入到src/utils/auth.js文件中查看:
import Cookies from 'js-cookie'
const TokenKey = 'Token'
export function getToken() {
return Cookies.get(TokenKey)
}
export function setToken(token) {
return Cookies.set(TokenKey, token)
}
export function removeToken() {
return Cookies.remove(TokenKey)
}
可以看到,token的存储、获取与删除是使用了js-cookie
这个插件,这个插件可以方便地操作cookie。在这里,token的存储是使用了cookie,也可以使用localStorage或者sessionStorage。
1.3 获取角色权限
判断是否有角色权限的语句为const hasRoles = store.getters.roles && store.getters.roles.length
,这里的store.getters.roles
的实际具体位置在src/store/modules/user.js文件中,而获取角色的store.dispatch('user/getInfo')
也同样是在src/store/modules/user.js文件中,这是用了vuex的模块化管理,将不同的模块分别放在不同的文件中,这样可以使得代码更加清晰,方便管理。在src/store/modules/user.js文件中,可以看到如下代码。
首先是state,用于存储用户信息:
const state = {
token: getToken(),
name: '',
avatar: '',
introduction: '',
roles: []
}
然后是mutations,用于修改state中的数据,使用时需要使用store.commit('SET_ROLES', roles)
的形式:
const mutations = {
SET_TOKEN: (state, token) => {
state.token = token
},
SET_INTRODUCTION: (state, introduction) => {
state.introduction = introduction
},
SET_NAME: (state, name) => {
state.name = name
},
SET_AVATAR: (state, avatar) => {
state.avatar = avatar
},
SET_ROLES: (state, roles) => {
state.roles = roles
}
}
最后是actions,用于异步修改state中的数据,使用时需要使用store.dispatch('user/getInfo')
的形式,这里主要列一下获取用户信息的代码:
const actions = {
// get user info
getInfo({ commit, state }) {
return new Promise((resolve, reject) => {
// getInfo(state.token).then(response => {
const data = {
roles: ['admin'],
introduction: 'I am a super administrator',
avatar: 'xxx',
name: 'Super Admin' }
if (!data) {
reject('Verification failed, please Login again.')
}
const { avatar, roles, introduction } = data
// roles must be a non-empty array
if (!roles || roles.length <= 0) {
reject('getInfo: roles must be a non-null array!')
}
commit('SET_AVATAR', avatar)
commit('SET_ROLES', roles)
commit('SET_INTRODUCTION', introduction)
resolve(data)
}).catch(error => {
// eslint-disable-next-line no-undef
// reject(error)
console.log(error)
})
// })
},
}
上述代码中的roles
是写死的['admin']
,实际使用时需要根据后端返回的数据进行修改。
1.4 生成路由
在获取角色权限后,需要根据角色权限生成路由。生成路由的代码在src/store/modules/permission.js中:
const actions = {
generateRoutes({ commit }, roles) {
return new Promise(resolve => {
const accessedRoutes = filterAsyncRoutes(asyncRoutes, roles)
commit('SET_ROUTES', accessedRoutes)
resolve(accessedRoutes)
})
}
}
调用了filterAsyncRoutes
函数进行从路由列表中根据角色权限筛选出符合条件的路由,然后将筛选出的路由添加到路由中,最后返回筛选出的路由。
function filterAsyncRoutes(routes, roles) {
const res = []
routes.forEach(route => {
const tmp = { ...route }
if (hasRole(roles, tmp)) {
if (tmp.children) {
tmp.children = filterAsyncRoutes(tmp.children, roles)
}
res.push(tmp)
}
})
return res
}
判断路由是否符合条件的函数为hasRole
,主要通过路由文件中的meta
中的roles
来判断:
function hasRole(roles, route) {
if (route.meta && route.meta.roles) {
return roles.some(role => route.meta.roles.includes(role))
} else {
return true
}
}
需要让hasRole
正确运行则需要在router/index.js中为每个路由设置好对应的角色权限:
{
path: '/permission',
component: Layout,
redirect: '/permission/page',
alwaysShow: true, // will always show the root menu
name: 'Permission',
meta: {
title: 'Permission',
icon: 'lock',
roles: ['admin', 'editor'] // 可以在根导航中设置角色
},
children: [
{
path: 'page',
component: () => import('@/views/permission/page'),
name: 'PagePermission',
meta: {
title: 'Page Permission',
roles: ['admin'] // 或者在子导航中设置角色
}
},
{
path: 'directive',
component: () => import('@/views/permission/directive'),
name: 'DirectivePermission',
meta: {
title: 'Directive Permission'
// 如果不设置角色,则表示:此页不需要权限
}
},
{
path: 'role',
component: () => import('@/views/permission/role'),
name: 'RolePermission',
meta: {
title: 'Role Permission',
roles: ['admin']
}
}
]
}
在router/index.js中有两组路由,一组是constantRoutes
,一组是asyncRoutes
,constantRoutes
是不需要权限的路由,asyncRoutes
是需要权限的路由。后续生成路由时则是在asyncRoutes
中进行筛选的。
2. 管理系统的几个组件
这里主要说一下左侧菜单栏和顶部TagsView。
2.1 生成菜单
在生成路由后,需要根据路由生成菜单。菜单的生成是在src/layout/components/Sidebar/index.vue中进行的,代码如下:
<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>
这里的permission_routes
是用了vuex
的mapGetters
来获取的,代码如下:
import { mapGetters } from 'vuex'
export default {
computed: {
...mapGetters(['permission_routes'])
}
}
mapGetters
的作用是将store
中的getters
映射到组件的computed
中,这样就可以在组件中直接使用this.permission_routes
来获取store
中的permission_routes
。
2.2 TagsView
TagsView是用来显示当前打开的页面的,可以缓存之前打开过的页面,效果如下如所示:
访问过以及缓存的路由存储在store/modules/tagsView.js
中:
const state = {
visitedViews: [],
cachedViews: []
}
上述代码中的visitedViews
用来存储访问过的路由,cachedViews
用来存储缓存的路由。如果在路由的meta
中设置了noCache
为true
,则不会缓存该路由。
tagsView.js
中的其他代码主要用于添加、删除、清空路由等操作,如addView
、delView
、delOthersViews
等,这里不再赘述。
TagsView
对应的组件在src/layout/components/TagsView/index.vue
中,代码如下:
<div id="tags-view-container" class="tags-view-container">
<scroll-pane ref="scrollPane" class="tags-view-wrapper"@scroll="handleScroll">
<router-link
v-for="tag in visitedViews"
ref="tag"
:key="tag.path"
:class="isActive(tag)?'active':''"
:to="{ path: tag.path, query: tag.query, fullPath: tag.fullPath }"
tag="span"
class="tags-view-item"
@click.middle.native="!isAffix(tag)?closeSelectedTag(tag):''"
@contextmenu.prevent.native="openMenu(tag,$event)"
>
{{ tag.title }}
<span v-if="!isAffix(tag)" class="el-icon-close" @click.prevent.stop="closeSelectedTag(tag)" />
</router-link>
</scroll-pane>
<ul v-show="visible" :style="{left:left+'px',top:top+'px'}"class="contextmenu">
<li @click="refreshSelectedTag(selectedTag)">Refresh</li>
<li v-if="!isAffix(selectedTag)" @click="closeSelectedTag(selectedTag)">Close</li>
<li @click="closeOthersTags">Close Others</li>
<li @click="closeAllTags(selectedTag)">Close All</li>
</ul>
</div>
获取visitedViews
是在computed
中获取的:
computed: {
visitedViews() {
return this.$store.state.tagsView.visitedViews
},
routes() {
return this.$store.state.permission.routes
}
}
3. 实际实践中自己做出的一些修改
自己在实践时的代码放在了github上:
https://github.com/lxmghct/UserRolePermission
3.1 路由控制
可以看到若依管理的前端是通过角色来控制路由生成的,而在我实践的项目中,将权限分为了三级:模块权限、页面权限、操作权限,路由则是由页面权限直接控制的。所以需要进行一些修改。在实际使用中尝试了以下两种修改方式,
3.1.1 由后端存储路由并分配权限
我尝试的第一种是直接将对应页面的路由存储在数据库的权限相应的字段中,当用户登录时,后端将用户所能访问的路由全部返回给前端,前端据此生成对应的路由。在router/index.js中则无需设置路由对应的角色或权限。
在store/modules/user.js中,用户登录时将后端返回的路由component
存进localStorage或者sessionStorage中,这一步是必要的,否则在刷新页面时,路由会丢失,导致重新跳转到登录页面。
const actions = {
// user login
login({ commit }, userInfo) {
const params = new URLSearchParams()
params.append('userName', userInfo.username)
params.append('password', md5(userInfo.password))
return new Promise((resolve, reject) => {
login(params).then(response => {
commit('SET_TOKEN', 'admin-token')
// 获取后端返回的路由
// 将路由的每一级都存储在一个set中,用于生成路由
const componentSet = new Set()
if (response.data.component && response.data.component.length > 0) {
response.data.component.forEach(item => {
const temp = item.replace(/^\//, '').replace(/\/$/, '').replace(/^system\//, '')
let index = temp.indexOf('/')
while (index > 0) {
componentSet.add(temp.substring(0, index))
index = temp.indexOf('/', index + 1)
}
componentSet.add(temp)
})
}
componentSet.add('')
localStorage.setItem('component', Array.from(componentSet))
resolve()
}).catch(error => {
reject(error)
})
})
}
然后再getInfo
中,拿到component
存储在state
中,并作为返回值返回给src/permissio.js对应的语句,并传递给生成路由的函数。这里我另外定义了一个generateRoutesByRequiredList
,代码如下:
const actions = {
generateRoutesByRequiredList({ commit }, routeList) {
return new Promise(resolve => {
const accessedRoutes = filterAsyncRoutesInRequiredList(asyncRoutes, routeList, '')
commit('SET_ROUTES', accessedRoutes)
resolve(accessedRoutes)
})
}
}
function filterAsyncRoutesInRequiredList(routes, routeList, base) {
const res = []
// 过滤掉不在requiredList中的路由
routes.forEach(route => {
const tmp = { ...route }
// 这里对path格式统一处理去掉开头和结尾的'/',便于判断
let path = tmp.path.replace(/^\//, '').replace(/\/$/, '')
path = base === '' ? path : base + '/' + path
path = path.replace(/^\//, '').replace(/^\//, '')
if (routeList.includes(path)) {
if (tmp.children) {
// 考虑到子路由的情况,递归调用
tmp.children = filterAsyncRoutesInRequiredList(tmp.children, routeList, base + '/' + path)
}
res.push(tmp)
}
})
return res
}
这种策略在项目结构极为简单的情况下勉强可以使用。在一般情况下实际使用中有很多弊端。
- 首先是前端的路由名称与后端数据库要同步,这对新增路由和修改路由名称都会带来很大的麻烦。
- 其次是由于每个页面都单独对应了一个路由,在管理页面权限的时候,就不得不给每一个页面都加上一个权限,会使权限控制出现大量不必要的重复功能的权限。虽然可以考虑在数据库存储时每个权限可以存储多个路由,但实际上已经将问题复杂化了。
3.1.2 直接将路由中的角色替换为权限
直接将若依管理系统前端路由部分涉及角色的地方替换为权限,或者另外增加一个变量去存储权限。这种方法显然实现起来更容易且更可靠。一开始我尝试采用3.1.1中的方法确实有些多此一举了。
src/store/modules/user.js中在登录时将后端返回的permission存储在sessionStorage或localStorage中,这一步是必要的,否则在刷新页面时,路由会丢失,导致重新跳转到登录页面。这里permission中存储的是当前用户所拥有的的所有权限代码的列表。
const actions = {
// user login
login({ commit }, userInfo) {
const params = new URLSearchParams()
params.append('username', userInfo.username)
params.append('password', userInfo.password)
return new Promise((resolve, reject) => {
login(params).then(response => {
commit('SET_TOKEN', 'admin-token')
localStorage.setItem('userId', response.data.user.id)
sessionStorage.setItem('permission', response.data.user.permissions || [])
sessionStorage.setItem('loginInformation', JSON.stringify(response.data))
resolve()
}).catch(error => {
reject(error)
})
})
},
// get user info
getInfo({ commit, state }) {
return new Promise((resolve, reject) => {
const permissions = sessionStorage.getItem('permission')
const data = {
roles: permissions ? permissions.split(',') : [], // 这里为图方便就直接将权限赋值给角色了,省的多建一个变量
introduction: 'I am a super administrator',
avatar: 'xxxx',
name: 'Super Admin' }
if (!data) {
reject('Verification failed, please Login again.')
}
const { avatar, roles, introduction } = data
// roles must be a non-empty array
if (!roles || roles.length <= 0) {
reject('getInfo: roles must be a non-null array!')
}
commit('SET_AVATAR', avatar)
commit('SET_ROLES', roles)
console.log(state.roles)
commit('SET_INTRODUCTION', introduction)
resolve(data)
}).catch(error => {
// eslint-disable-next-line no-undef
// reject(error)
console.log(error)
})
// })
}
}
在生成路由的时候,将路由筛选的条件替换为permission,在store/modules/permission.js中修改如下:
function hasPermission(permissions, route) {
if (route.meta && route.meta.permissions) {
return permissions.some(permission => route.meta.permissions.includes(permission))
} else {
return true
}
}
function filterAsyncRoutes(routes, permissions) {
const res = []
routes.forEach(route => {
const tmp = { ...route }
if (hasPermission(permissions, tmp)) {
if (tmp.children) {
tmp.children = filterAsyncRoutes(tmp.children, permissions)
}
res.push(tmp)
}
})
return res
}
const actions = {
generateRoutes({ commit }, permissions) {
return new Promise(resolve => {
const accessedRoutes = filterAsyncRoutes(asyncRoutes, permissions)
commit('SET_ROUTES', accessedRoutes)
resolve(accessedRoutes)
})
}
}
相应的在src/router/index.js中将meta中的roles换为permissions,这里就不多赘述。
3.2 权限管理
由于在实际项目中将权限分成了模块权限、页面权限、操作权限三级,所以权限管理的时候就把所有权限用树形结构展示出来,这样就可以很方便的管理权限了。这里就不多赘述了。
4. 若依管理路由控制的其他应用
从这次若依管理系统的路由控制的实现过程中,我也学到了这样一种动态生成路由的方式。事实上这种方式也可以应用于除了管理系统以外的一般的前端项目,只需将菜单等其他部分忽略,将路由控制的那部分提取出来即可。
这部分代码也放在了github上,
https://github.com/lxmghct/UserRolePermission
这里我没有去使用vuex
和js-cookie
。
4.1 router
首先在router/index.js中,仍然保留constantRoutes
和asyncRoutes
两个变量,同时也保留createRouter
和resetRouter
两个函数。由于不需要生成菜单,所以meta
这一属性也可以去掉了,权限的判断直接在每个路由加上permissions
属性即可。
import Vue from 'vue'
import Router from 'vue-router'
Vue.use(Router)
export const constantRoutes = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/Login')
}
]
export const asyncRoutes = [
{
path: '/home',
name: 'Home',
component: () => import('@/views/Home'),
permissions: ['TEST_MAIN']
},
{
path: '/page1',
name: 'Page1',
component: () => import('@/views/Page1'),
permissions: ['TEST_PAGE1']
},
{
path: '/page2',
name: 'Page2',
component: () => import('@/views/Page2'),
permissions: ['TEST_MAIN']
}
]
const createRouter = () => new Router({
// mode: 'history', // require service support
scrollBehavior: () => ({ y: 0 }),
routes: constantRoutes
})
const router = createRouter()
export function resetRouter () {
const newRouter = createRouter()
router.matcher = newRouter.matcher // reset router
}
export default router
4.2 store
在store中只要用到user.js,所以只保留这部分在store/user.js中。
import { resetRouter } from '@/router'
export const userStore = {
roles: [],
permissions: []
}
export const UserUtils = {
resetUserStore () {
userStore.roles = []
userStore.permissions = []
resetRouter()
}
}
由于没使用vuex
,所以也无需创建getters.js。
4.3 permission.js
我把路由守卫以及路由生成的代码都统一放在了src/permission.js中。没使用vuex
,不过直接import对应的变量也可以获取到需要的值。没使用js-cookie
,所以直接使用sessionStorage
来存储用户信息。之所以要另外再弄个user.js将sessionStorage
的操作封装起来,我觉得可能是有两个原因,一个是用于判断用户是否已经生成过了路由,还有一个作用就是将用户的权限信息存在内存中,即使用户修改了sessionStorage
中的值,也不会影响到用户的权限信息。当然如果想要修改也是能做到的,因为user.js中的内容会在刷新后消失,所以修改sessionStorage
中的值后,刷新页面就会重新生成路由了。
import router, { asyncRoutes } from './router'
import { userStore, UserUtils } from './store/user'
function hasPermission (permissions, route) {
if (route.permissions) {
return permissions.some(permission => route.permissions.includes(permission))
} else {
return true
}
}
function filterAsyncRoutes (routes, permissions) {
const res = []
routes.forEach(route => {
const tmp = { ...route }
if (hasPermission(permissions, tmp)) {
if (tmp.children) {
tmp.children = filterAsyncRoutes(tmp.children, permissions)
}
res.push(tmp)
}
})
return res
}
const whiteList = ['/login'] // no redirect whitelist
router.beforeEach(async (to, from, next) => {
const loginInfo = sessionStorage.getItem('loginInformation')
if (loginInfo) {
if (to.path === '/login') {
// if is logged in, redirect to the home page
next({ path: '/home' })
} else {
const hasPermission = userStore.permissions && userStore.permissions.length > 0
if (hasPermission) {
if (router.getRoutes().map(item => item.path).includes(to.path)) {
next()
} else {
next({ path: '/login' })
}
} else {
try {
userStore.permissions = JSON.parse(loginInfo).user.permissions
// generate accessible routes map
const accessRoutes = filterAsyncRoutes(asyncRoutes, userStore.permissions)
if (accessRoutes.length === 0 || userStore.permissions.length === 0) {
throw new Error('No permission')
}
// dynamically add accessible routes
accessRoutes.forEach(item => { router.addRoute(item) })
// set the replace: true, so the navigation will not leave a history record
next({ ...to, replace: true })
} catch (error) {
sessionStorage.removeItem('loginInformation')
UserUtils.resetUserStore()
next(`/login?redirect=${to.path}`)
}
}
}
} else {
/* not logged in */
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}`)
}
}
})
4.4 登录登出
登录时将用户信息存储在sessionStorage
中, 登出时将sessionStorage
中的用户信息清除。
sessionStorage.setItem('loginInformation', JSON.stringify(res.data));
// sessionStorage.removeItem('loginInformation');
4.5 操作权限的控制
操作权限最主要还是要通过后端去控制,不过前端也可以根据权限去选择隐藏或禁用一些不允许使用的按钮,通过v-if
或:disabled
之类的方式来控制。
可以在main.js中进行相应的配置使权限写起来更方便一些。
首先是在4.2中的user.js中添加一个hasPermission
方法,用于判断用户是否有权限。
export const hasPermission = (permissions) => {
// 为了写起来方便,这里同时支持传入数组和字符串
if (Array.isArray(permissions)) {
return permissions.some(permission => userStore.permissions.includes(permission))
} else {
return userStore.permissions.includes(permissions)
}
}
然后在main.js中全局注册判断权限的方法。
import { hasPermission } from '@/store/user'
Vue.prototype.$permission = { has: hasPermission }
这样在组件中就可以通过this.$permission.has('TEST_MAIN')
或this.$permission.has(['TEST_MAIN', 'TEST_SUB'])
来判断用户是否有权限了。
<el-button v-if="$permission.has('TEST_MAIN')" type="primary" @click="handleAdd">新增</el-button>