iview-admin的菜单问题
-
菜单树无法显示的缺陷
作者研究的iview-admin是当前最新版本2.0.0,发现如果一个菜单的兄弟菜单当前用户无权访问,可能导致整颗side-menu菜单树无法显示。
例如,可将如下配置粘贴到src/router/routers.js的路由配置中进行测试:
{ path: '/multilevel', name: 'multilevel', meta: { icon: 'md-menu', title: '多级菜单' }, component: Main, children: [ { path: 'level_2_3', name: 'level_2_3', meta: { icon: 'md-funnel', title: '二级-3' }, component: () => import('@/view/multilevel/level-2-3.vue') } ] }, { path: '/common', name: 'common', meta: { hideInBread: true }, component: Main, children: [ { path: 'default', name: '一级功能', meta: { access: ['admin999'], icon: 'ios-book', title: '一级功能' }, component: () => import('@/view/common/default.vue') } ] },
"多级菜单"的整个菜单是任何用户都有权访问的,但是common下的"一级菜单"配置了当前用户无法访问的access,随即整个菜单树无法显示。
-
菜单权限无法动态控制
通常情况下,一个基于角色的权限控制系统会在服务端维护一个角色列表,并为每个角色分配菜单权限。当一个用户访问系统时,其能访问的菜单由该用户的角色而定。
Iview-admin也许只是出于演示目的,其菜单权限是通过菜单的access属性静态配置,我们可以把这个称之为允许访问的角色列表,如示例中的"一级功能",其限制了只有admin999这个角色可以访问。然而,对一个可维护的系统而言,一个菜单分配给了哪些角色是服务端后台动态配置。显然,这种静态配置无法支撑一个正常的生产系统。
iview-admin的路由和菜单
-
路由
Iview-admin的路由其实也是vue的路由,其在src/router/routers.js进行路由配置;
src/router/index.js引入路由配置并创建路由对象,该路由对象会在每次路由访问前根据当前用户的access标识(角色列表)和路由配置中的access进行匹配,达到基于角色控制菜单访问权限的目的;
Iview-admin的启动入口即src/main.js引入src/router/index.js创建的路由对象,将其传给vue,从而初始化整个前端。
-
菜单
Iview-admin的菜单由src/view/components/main/components/side-menu组件负责显示,其显示内容由menu-list属性指定;
src/view/components/main/main.vue引用该组件,通过store 的getter将定义在src/store/module/app.js中的menuList返回值指定为side-menu组件的menu-list;
menuList这个getter也是根据当前用户的角色以及src/router/routers.js的路由配置计算出用户的最终菜单。
定制思路
-
思路一:不做任何定制
开发期指定每个菜单允许访问的角色列表,在返回用户信息时返回用户角色,由iview-admin现有方式控制,但是会面临"iview-admin的菜单问题"中的两个问题,不仅有显示缺陷而且角色无法扩展,基本不可用。
-
思路二:基于后台的角色权限,初始化完整的菜单
从服务端获取所有角色,以及每个角色能访问的菜单,根据这些信息动态修改routers里菜单的access配置,使菜单的access等于完整的运行访问的角色列表。但是由于"iview-admin的菜单问题"的第一个问题的存在,导致该方法不可行。
-
思路三:基于后台的角色权限,初始化必要的菜单
如果菜单里是当前用户都能访问的菜单,那么可以称之为必要的菜单,经研究,这样的菜单可以绕开缺陷一。这样的菜单其access属性不需要配置,因为菜单的整个范围已经是当前用户可访问的范围。
当然,如果有时间和能力直接解决其无法展现菜单树的缺陷可以考虑思路二,但是,前端生成一颗经过权限过滤后的必要菜单将更干净纯粹。
定制方法
作者采用思路三。首先将路由配置一拆为二,分为系统默认路由配置和菜单路由配置;向后台获取当前用户信息时,获取用户所能访问的菜单,结合菜单路由配置生成一颗经过权限过滤后的菜单树。
-
拆分路由配置
从“iveiw-admin的路由和菜单”可知,src/router/routers.js文件里的配置肩负两个职责:一是vue路由配置,二是菜单配置,现在我们要将菜单部分独立出去。
将src/router/routers.js拆分成routers.js和menus.js两个文件,routers.js保留系统默认路由,menus.js为菜单路由。routers.js只负责login、home、error_401、error_500、error_404这几个路由,如下所示:
import Main from '@/components/main' export default [ { path: '/login', name: 'login', meta: { title: 'Login - 登录', hideInMenu: true }, component: () => import('@/view/login/login.vue') }, { path: '/', name: '_home', redirect: '/home', component: Main, meta: { hideInMenu: true, notCache: true }, children: [ { path: '/home', name: 'home', meta: { hideInMenu: true, title: '首页', notCache: true, icon: 'md-home' }, component: () => import('@/view/single-page/home') } ] }, { path: '/401', name: 'error_401', meta: { hideInMenu: true }, component: () => import('@/view/error-page/401.vue') }, { path: '/500', name: 'error_500', meta: { hideInMenu: true }, component: () => import('@/view/error-page/500.vue') }, { path: '*', name: 'error_404', meta: { hideInMenu: true }, component: () => import('@/view/error-page/404.vue') } ]
menus.js负责菜单相关的路由配置,此出的菜单不要配置任何access:如下所示:假如我们有系统功能,以及子功能菜单一览和角色管理
import Main from '@/components/main' export default [ { path: '/system', name: '系统管理', meta: { showAlways: false, icon: 'md-menu', title: '系统功能' }, component: Main, children: [ { path: 'menumanage', name: '菜单一览', meta: { icon: 'md-funnel', title: '菜单一览' }, component: () => import('@/view/system/menumng.vue') } ] } ]
-
路由对象接管所有路由
经过拆分后,路由配置就分成里两部分,所以还要改造路由对象使其管理所有路由。
src/router/index.js里引入menus.js,在new Router的地方向Router传入routers.js和menus.js的合并值,如:
import routes from './routers' import menus from './menus' let allMenus = routes.concat(menus) const router = new Router({ routes: allMenus, mode: 'history' })
-
基于状态更新菜单树
src/store/module/app.js里,在store里声明permission,该属性为当前用户所能访问的路由配置,并将其初始为系统默认路由配置;
从"iview-admin的路由和菜单"一节我们说过,side-menu菜单树内容是由app.js里的menuList这个getter来控制,所以调整menuList这个getter,使其基于permission生成菜单列表,如下所示:
state: { permission: routers }, getters: { menuList: (state, getters, rootState) => getMenuByRouter(state.permission, rootState.user.access) },
到此,我们做到了菜单树内容和permission状态绑定
-
根据权限动态更新菜单树
经过以上的工作,菜单树内容和表示用户权限范围的permission状态进行了绑定,我们还需要在用户登录时去动态更新该permission,使菜单内容跟着当前登录用户变化而变化。
首先,在src/store/module/app.js里添加修改permission状态的mutation
mutation如下,注意此处默认super_admin具有所有权限,可以根据自己业务修改:
setPermission (state, {name,permission}) { if(name=='super_admin'){ state.permission = routers.concat(menus) }else{ let newMenus = cloneMenus(menus) let filteredMenus = filterMenus(newMenus,permission) state.permission = routers.concat(filteredMenus) } }
其中setPermission这个mutation接收的参数permission为当前用户所能访问的菜单列表,如['菜单一览'],其逻辑为首先从menus.js克隆一份菜单路由配置,根据permission进行权限过滤,得到一个当前用户所能访问必要的菜单路由配置。
cloneMenus实现了对菜单路由配置的克隆,其逻辑如下仅供参考:
const cloneMenu = function (newMenus, {path, name, meta, component, children}) { let obj = {path,name,meta,component} newMenus.push(obj) if(children&&children.forEach){ obj.children = [] children.forEach(function (child) { cloneMenu(obj.children,child) }) } } const cloneMenus = function (menus) { let newMenus = [] menus.forEach(function (menu) { cloneMenu(newMenus,menu) }) return newMenus }
filterMenus对克隆出的菜单路由配置进行权限过滤,将不能访问的菜单进行删除,只保留最小集合:
const filterMenu = function (menu,targets) { if(menu.children){ for(let i=0;i<menu.children.length;i++){ let remain = filterMenu(menu.children[i],targets) if(remain===false){ menu.children.splice(i,1) i-- } } if(menu.children.length===0){ return false } }else if(!targets||targets.indexOf(menu.name)==-1){ return false } } const filterMenus = function (menus,targets) { for(let i=0;i<menus.length;i++){ let remain = filterMenu(menus[i],targets) if(remain===false){ menus.splice(i,1) i-- } } return menus }
其次,在获取用户信息时触发permission状态更新
在src/store/module/user.js里,在getUserInfo这个action实现的地方添加触发setPermission的逻辑,使菜单内容根据用户变化而变化,这里需要特别注意的是getUserInfo的服务端逻辑在user对象里一定要传permission,即用户能访问的菜单name组成的列表,如['菜单一览','角色管理']:
getUserInfo ({ state, commit }) { return new Promise((resolve, reject) => { try { getUserInfo(state.token).then(res => { const data = res.data commit('setAvatar', avatar) commit('setUserName', data.name) commit('setUserId', data.user_id) commit('setAccess', data.access||[]) commit('setHasGetInfo', true) commit('setPermission', data) resolve(data) }).catch(err => { reject(err) }) } catch (error) { reject(error) } }) }
-
调整路由权限过滤
路由对象在路由访问时都要先进行权限判断,这个判断在src/router/index.js的turnTo方法中:
const turnTo = (to, access, next) => { if (canTurnTo(to.name, access, routes)) next() // 有权限,可访问 else next({ replace: true, name: 'error_401' }) // 无权限,重定向到401页面 }
canTurnTo方法基于routes进行判断,此时应该改成状态管理里的permission即用户有权访问的所有路由,如下所示:
const turnTo = (to, access, next) => { if (canTurnTo(to.name, access, store.state.app.permission)) next() // 有权限,可访问 else next({ replace: true, name: 'error_401' }) // 无权限,重定向到401页面 }
-
其他
经过以上步骤前端改造就此完成。
需要注意是:
getUserInfo的服务端实现一定要根据用户的角色,在user对象里返回permission,即用户能访问的菜单name组成的列表;
本方法是根据用户权限动态生成权限范围内的菜单树,已经不是通过前端路由的access配置来完成的权限过滤,所以menus.js里的菜单不需要再配置access;
参考代码
没想到这么多人希望要源码
源码参考https://github.com/tracelessman/iview-admin-menumanage-baseonroles,如果有价值请给个star。
注意,代码中启用了前端的mock,这样我就不用把后端的代码也上传了,src/mock/login.js 给admin这个用户添加了permission:['菜单一览'],使其具有菜单一览这个菜单权限。