vue-element-admin整合SpringBoot实现动态渲染基于角色的菜单资源踩坑录(前后端整合篇)

vue-element-admin整合SpringBoot实现动态渲染基于角色的菜单资源踩坑录(前后端整合篇)

0 引言

这篇文章自己准备了好几个周末,如果不是中间踩了太多的坑的话上上的周末就应该发表了,实在是因为踩坑太多而自己也笔记执拗,坚持要写出一篇解决掉遇到的99%以上的Bug,能经得起读者实践验证的项目实战文章,拖到今天才发布。笔者一直坚持文章质量重于数量,内容足够好的文章才会让更多的读者传阅。下面开始呈上内容干货!

这篇文章前端以开源项目vue-element-admin基础,后端以Vblog项目中后端项目blogserver为基础。为啥前端没用Vblog项目中的vueblog前端项目?因为vueblog项目中的很多组件没有,包括vuex, 还有很多组件版本过低,笔者当时安装完各种需要的依赖包之后发现项目都启动不了,还一直报错,短时间之内根本无法解决。而我之前有克隆过vue-element-admin项目的源码,里面大部分需要的前端组件和依赖包都有,最重要的是里面有mock模拟后台数据实现的用户登录和动态加载路由资源和初始化基于角色控制的菜单列表的实现。我们只需要在这个项目的基础上进行业务需求的修改即可,下面开始呈上笔者的代码实现。

1 后端代码修改

1.1 用户增加当前角色

public class User implements UserDetails {
    
    
     // 当前角色
    private Role currentRole;
    
     public Role getCurrentRole() {
    
    
        return currentRole;
    }

    public void setCurrentRole(Role currentRole) {
    
    
        this.currentRole = currentRole;
    }
    
    // 其他代码省略
}

1.2 UserService#loadUserByUsername方法修改

UserService.java

@Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    
    
        User user = userMapper.loadUserByUsername(username);
        if (user == null) {
    
    
            //避免返回null,这里返回一个不含有任何值的User对象,在后期的密码比对过程中一样会验证失败
            return new User();
        }
        //查询用户的角色信息,并返回存入user中
        List<Role> roles = rolesMapper.getRolesByUid(user.getId());
        // 权限大的角色排在前面
        roles.sort(Comparator.comparing(Role::getId));
        // 下面两行代码为新增设置当前角色代码
        user.setRoles(roles);
        user.setCurrentRole(roles.get(0));
        return user;
    }

1.3 新增根据角色ID查询路由ID集合接口

RouterResourceController.java

@Autowired
 private RoleRouterService roleRouterService;

@GetMapping("/currentRoleResourceIds")
    public RespBean getResourceIdsByRoleId(@RequestParam("roleId") Integer roleId){
    
    
        logger.info("roleId={}",roleId);
        List<String> data = roleRouterService.queryCurrentRoleResourceIds(roleId);
        RespBean respBean = new RespBean(ResponseStateConstant.SERVER_SUCCESS,"查询成功");
        respBean.setData(data);
        return respBean;
    }

RoleRouterService.java

public List<String> queryCurrentRoleResourceIds(Integer roleId){
    
    
        List<Integer> resourceIds = roleRouterMapper.queryRouteResourceIdsByRoleId(roleId);
        List<String> resultList = new ArrayList<>();
        for(Integer resourceId: resourceIds){
    
    
            resultList.add(String.valueOf(resourceId));
        }
        return resultList;
    }

RoleRouterMapper.java

List<Integer> queryRouteResourceIdsByRoleId(Integer roleId);

RolesMapper.xml

<select id="queryRouteResourceIdsByRoleId" parameterType="Integer" resultType="Integer">
         select resource_id from role_resources
          where role_id=#{
    
    roleId,jdbcType=INTEGER}
</select>

1.4 WebSecurityConfig 类中增加跨域配置

@Override
    protected void configure(HttpSecurity http) throws Exception {
    
    
        // 配置跨域
        http.cors().configurationSource(corsConfigurationSource());
        
        // 本方法其他代码省略,已传至个人gitee代码仓库,感兴趣的小伙伴可以克隆下来查看
        
    }

//配置跨域访问资源
    private CorsConfigurationSource corsConfigurationSource() {
    
    
        CorsConfigurationSource source =   new UrlBasedCorsConfigurationSource();
        CorsConfiguration corsConfiguration = new CorsConfiguration();
        corsConfiguration.addAllowedOrigin("http://localhost:3000");	//同源配置,*表示任何请求都视为同源,若需指定ip和端口可以改为如“localhost:8080”,多个以“,”分隔;
        corsConfiguration.addAllowedHeader("*");//header,允许哪些header,本案中使用的是token,此处可将*替换为token;
        corsConfiguration.addAllowedMethod("*");	//允许的请求方法,PSOT、GET等
        corsConfiguration.setAllowCredentials(true); // 允许cookie认证
        ((UrlBasedCorsConfigurationSource) source).registerCorsConfiguration("/**",corsConfiguration); //配置允许跨域访问的url
        return source;
    }
1.5 数据准备

(1)参照vue-element-admin项目src/router目录下index文件中的动态路由数据执行blogserver项目下src/main/resources目录下router_resource_data.sql脚本文件中的sql脚本为路由资源表中添加vue-element-admin项目中的动态菜单路由资源。

(2)执行blogserver项目下src/main/resources目录下role_resources_data.sql给角色admin角色分配路由资源

(3)启动后台服务后通过postman的注册接口三个用户(由于用户数据入库时对用户登录密码进行了加密处理,因此不好执行sql添加,而用户注册的逻辑中恰好使用spring-security对用户登录密码进行了加密处理)

post http://localhost:8081/blog/user/reg
{
    
    
    "username": "sang",
    "nickname":"江南一点雨",
    "password": "sang123",
    "email": "[email protected]"
 }

在请求体中以此换成下面的数据完成用户注册

{
    
    
    "username": "zhangsan",
    "nickname":"张三",
    "password": "zhangsan123",
    "email": "[email protected]"
  }
 {
    
    
    "username": "heshengfu",
    "nickname":"程序员阿福",
    "password": "heshengfu123",
    "email": "[email protected]"
  }

2 前端vue-element-admin项目源码修改

2.1 修改src/router/index.js文件

给每个路由组件都加上id属性

export const constantRoutes = [
  {
    
    
    id: '1',
    path: '/redirect',
    component: Layout,
    hidden: true,
    children: [
      {
    
    
        id: '2',
        path: '/redirect/:path*',
        component: Redirect
      }
    ]
  },
  {
    
    
    id: '3',
    path: '/login',
    component: Login,
    hidden: true
  },
  {
    
    
    id: '4',
    path: '/auth-redirect',
    component: AuthRedirect,
    hidden: true
  },
  {
    
    
    id: '5',
    path: '/404',
    component: ErrorPage404,
    hidden: true
  },
  {
    
    
    id: '6',
    path: '/401',
    component: ErrorPage401,
    hidden: true
  },
  {
    
    
    id: '7',
    path: '/',
    component: Layout,
    redirect: '/dashboard',
    children: [
      {
    
    
        id: '8',
        path: 'dashboard',
        component: Dashboard,
        name: 'Dashboard',
        meta: {
    
     title: 'Dashboard', icon: 'dashboard', affix: true }
      }
    ]
  },
  {
    
    
    id: '9',
    path: '/documentation',
    component: Layout,
    children: [
      {
    
    
        id: '10',
        path: 'index',
        component: Document,
        name: 'Documentation',
        meta: {
    
     title: 'Documentation', icon: 'documentation', affix: true }
      }
    ]
  },
  {
    
    
    id: '11',
    path: '/guide',
    component: Layout,
    redirect: '/guide/index',
    children: [
      {
    
    
        id: '12',
        path: 'index',
        component: Guide,
        name: 'Guide',
        meta: {
    
     title: 'Guide', icon: 'guide', noCache: true }
      }
    ]
  },
  {
    
    
    id: '13',
    path: '/profile',
    component: Layout,
    redirect: '/profile/index',
    hidden: true,
    children: [
      {
    
    
        id: '14',
        path: 'index',
        component: Profile,
        name: 'Profile',
        meta: {
    
     title: 'Profile', icon: 'user', noCache: true }
      }
    ]
  }
]

export const asyncRoutes = [
  {
    
    
    id: '15',
    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: [
      {
    
    
        id: '16',
        path: 'page',
        component: () => import('@/views/permission/page'),
        name: 'PagePermission',
        meta: {
    
    
          title: 'Page Permission',
          roles: ['admin'] // or you can only set roles in sub nav
        }
      },
      {
    
    
        id: '17',
        path: 'directive',
        component: () => import('@/views/permission/directive'),
        name: 'DirectivePermission',
        meta: {
    
    
          title: 'Directive Permission'
          // if do not set roles, means: this page does not require permission
        }
      },
      {
    
    
        id: '18',
        path: 'role',
        component: () => import('@/views/permission/role'),
        name: 'RolePermission',
        meta: {
    
    
          title: 'Role Permission',
          roles: ['admin']
        }
      }
    ]
  },
  {
    
    
    id: '19',
    path: '/icon',
    component: Layout,
    children: [
      {
    
    
        id: '20',
        path: 'index',
        component: () => import('@/views/icons/index'),
        name: 'Icons',
        meta: {
    
     title: 'Icons', icon: 'icon', noCache: true }
      }
    ]
  },
  // componentsRouter,
  // chartsRouter,
  // nestedRouter,
  // tableRouter,
  {
    
    
    id: '21',
    path: '/example',
    component: Layout,
    redirect: '/example/list',
    name: 'Example',
    meta: {
    
    
      title: 'Example',
      icon: 'example'
    },
    children: [
      {
    
    
        id: '22',
        path: 'create',
        component: () => import('@/views/example/create'),
        name: 'CreateArticle',
        meta: {
    
     title: 'Create Article', icon: 'edit' }
      },
      {
    
    
        id: '23',
        path: 'edit/:id(\\d+)',
        component: () => import('@/views/example/edit'),
        name: 'EditArticle',
        meta: {
    
     title: 'Edit Article', noCache: true, activeMenu: '/example/list' },
        hidden: true
      },
      {
    
    
        id: '24',
        path: 'list',
        component: () => import('@/views/example/list'),
        name: 'ArticleList',
        meta: {
    
     title: 'Article List', icon: 'list' }
      }
    ]
  },
  {
    
    
    id: '25',
    path: '/tab',
    component: Layout,
    children: [
      {
    
    
        id: '26',
        path: 'index',
        component: () => import('@/views/tab/index'),
        name: 'Tab',
        meta: {
    
     title: 'Tab', icon: 'tab' }
      }
    ]
  },
  {
    
    
    id: '27',
    path: '/error',
    component: Layout,
    redirect: 'noRedirect',
    name: 'ErrorPages',
    meta: {
    
    
      title: 'Error Pages',
      icon: '404'
    },
    children: [
      {
    
    
        id: '28',
        path: '401',
        component: () => import('@/views/error-page/401'),
        name: 'Page401',
        meta: {
    
     title: '401', noCache: true }
      },
      {
    
    
        id: '29',
        path: '404',
        component: () => import('@/views/error-page/404'),
        name: 'Page404',
        meta: {
    
     title: '404', noCache: true }
      }
    ]
  },

  {
    
    
    id: '30',
    path: '/error-log',
    component: Layout,
    children: [
      {
    
    
        id: '31',
        path: 'log',
        component: () => import('@/views/error-log/index'),
        name: 'ErrorLog',
        meta: {
    
     title: 'Error Log', icon: 'bug' }
      }
    ]
  },

  {
    
    
    id: '32',
    path: '/excel',
    component: Layout,
    redirect: '/excel/export-excel',
    name: 'Excel',
    meta: {
    
    
      title: 'Excel',
      icon: 'excel'
    },
    children: [
      {
    
    
        id: '33',
        path: 'export-excel',
        component: () => import('@/views/excel/export-excel'),
        name: 'ExportExcel',
        meta: {
    
     title: 'Export Excel' }
      },
      {
    
    
        id: '34',
        path: 'export-selected-excel',
        component: () => import('@/views/excel/select-excel'),
        name: 'SelectExcel',
        meta: {
    
     title: 'Export Selected' }
      },
      {
    
    
        id: '35',
        path: 'export-merge-header',
        component: () => import('@/views/excel/merge-header'),
        name: 'MergeHeader',
        meta: {
    
     title: 'Merge Header' }
      },
      {
    
    
        id: '36',
        path: 'upload-excel',
        component: () => import('@/views/excel/upload-excel'),
        name: 'UploadExcel',
        meta: {
    
     title: 'Upload Excel' }
      }
    ]
  },

  {
    
    
    id: '37',
    path: '/zip',
    component: Layout,
    redirect: '/zip/download',
    alwaysShow: true,
    name: 'Zip',
    meta: {
    
     title: 'Zip', icon: 'zip' },
    children: [
      {
    
    
        id: '38',
        path: 'download',
        component: () => import('@/views/zip/index'),
        name: 'ExportZip',
        meta: {
    
     title: 'Export Zip' }
      }
    ]
  },

  {
    
    
    id: '39',
    path: '/pdf',
    component: Layout,
    redirect: '/pdf/index',
    children: [
      {
    
    
        id: '40',
        path: 'index',
        component: () => import('@/views/pdf/index'),
        name: 'PDF',
        meta: {
    
     title: 'PDF', icon: 'pdf' }
      }
    ]
  },
  {
    
    
    id: '41',
    path: '/pdf/download',
    component: () => import('@/views/pdf/download'),
    hidden: true
  },

  {
    
    
    id: '42',
    path: '/theme',
    component: Layout,
    children: [
      {
    
    
        id: '43',
        path: 'index',
        component: () => import('@/views/theme/index'),
        name: 'Theme',
        meta: {
    
     title: 'Theme', icon: 'theme' }
      }
    ]
  },

  {
    
    
    id: '43',
    path: '/clipboard',
    component: Layout,
    children: [
      {
    
    
        id: '44',
        path: 'index',
        component: () => import('@/views/clipboard/index'),
        name: 'ClipboardDemo',
        meta: {
    
     title: 'Clipboard', icon: 'clipboard' }
      }
    ]
  },
  {
    
    
    id: '45',
    path: 'external-link',
    component: Layout,
    children: [
      {
    
    
        id: '46',
        path: 'https://github.com/PanJiaChen/vue-element-admin',
        meta: {
    
     title: 'External Link', icon: 'link' }
      }
    ]
  },
  {
    
     id: '47', path: '*', name: 'notFound', redirect: '/404', hidden: true }
]

对所有用户开放的路由组件如登录组件、首页组件可放在constantRoutes中,而需要做权限控制的路由则放在asyncRoutes

说明:之所以给路由组件加上id属性是为了方便前端通过后台加载的当前角色下路由ID集合对asyncRoutes中的数据进行过滤。之前尝试通过从后台加载所有需要做权限控制的路由数据,然后在前端通过require.jsresolve函数回调实例化动态路由组件,但不知道是自己项目中的一些组件版本不对还是其他原因实例化后的组件一直有错误,无法渲染程菜单。后面改为在router/index.js文件中通过componentUrl作为key映射实例化后端动态组件后发现可以动态渲染菜单,但是点击动态菜单的子菜单后却一直拿不到路由信息导致点击几乎所有动态加载的子菜单页面时都报404,这是一个很严重的Bug,所有后来最终改成了通过动态路由的id属性来控制动态加载要做权限控制的路由和菜单资源。

2.2 修改src/utils/request.js

import axios from 'axios'
// import { MessageBox, Message } from 'element-ui'
import store from '@/store'
import {
    
     getToken } from '@/utils/auth'

axios.defaults.withCredentials = true
// create an axios instance
const service = axios.create({
    
    
  baseURL: process.env.VUE_APP_BASE_API, // url = base url + request url
  // withCredentials: true, // send cookies when cross-domain requests
  timeout: 5000 // request timeout
})

// request interceptor
service.interceptors.request.use(
  config => {
    
    
    // do something before request is sent

    if (store.getters.token) {
    
    
      // let each request carry token
      // ['X-Token'] is a custom headers key
      // please modify it according to the actual situation
      config.headers['X-Token'] = getToken()
    }
    return config
  },
  error => {
    
    
    // do something with request error
    console.log(error) // for debug
    return Promise.reject(error)
  }
)

// response interceptor
/**
service.interceptors.response.use(
  response => {
    const res = response.data
    if (res.code !== 20000) {
      Message({
        message: res.message || 'Error',
        type: 'error',
        duration: 5 * 1000
      })
      if (res.code === 50008 || res.code === 50012 || res.code === 50014) {
        MessageBox.confirm('You have been logged out, you can cancel to stay on this page, or log in again', 'Confirm logout', {
          confirmButtonText: 'Re-Login',
          cancelButtonText: 'Cancel',
          type: 'warning'
        }).then(() => {
          store.dispatch('user/resetToken').then(() => {
            location.reload()
          })
        })
      }
      return Promise.reject(new Error(res.message || 'Error'))
    } else {
      return res
    }
  },
  error => {
    console.log('err' + error) // for debug
    Message({
      message: error.message,
      type: 'error',
      duration: 5 * 1000
    })
    return Promise.reject(error)
  }
) */

export default service

这里需要注释对接口相应体的拦截,因为我们后台借口出参的状态码成功时并不是2000

2.3 修改src/api/user.jssrc/api/role.js两个文件

(1) user.js

// 修改登录接口函数
export function login(data) {
    
    
  return request({
    
    
    url: '/user/login',
    method: 'post',
    headers: {
    
    
      'Content-Type': 'application/x-www-form-urlencoded'
    },
    data,
    transformRequest: [function(data) {
    
    
      // Do whatever you want to transform the data
      let ret = ''
      for (const it in data) {
    
    
        ret += encodeURIComponent(it) + '=' + encodeURIComponent(data[it]) + '&'
      }
      return ret
    }]
  })
}

注意:所有post请求类型的api必须加上以上headerstransformRequest,尤其是对入参的处理回调函数transformRequest,不加上的化登录的时候后台拿到的用户名一直为空字符串,用户认证无法通过。

(2) role.js

role.js文件中增加根据角色ID查询动态路由集合的接口函数

export function getRouteIds(roleId) {
    
    
  return request({
    
    
    url: `/routerResource/currentRoleResourceIds?roleId=${
      
      roleId}`,
    method: 'get',
    headers: {
    
    
      'Content-Type': 'application/json'
    }
  })
}

2.3 修改src/store/modules/user.js文件

import { login, logout } from '@/api/user'
import { getRouteIds, getRoutes } from '@/api/role'
import { getToken, setToken, removeToken } from '@/utils/auth'
import { resetRouter } from '@/router'
import { Message } from 'element-ui'

const state = {
  token: getToken(),
  userBase: null,
  name: '',
  avatar: '',
  // introduction: '',
  roles: [],
  currentRole: null
}

const mutations = {
  SET_TOKEN: (state, token) => {
    state.token = token
  },
  // SET_INTRODUCTION: (state, introduction) => {
  //   state.introduction = introduction
  // },
  SET_NAME: (state, name) => {
    state.name = name
  },
  SET_USER_BASE: (state, userBase) => {
    state.userBase = userBase
  },
  SET_AVATAR: (state, avatar) => {
    state.avatar = avatar
  },
  SET_ROLES: (state, roles) => {
    state.roles = roles
  },
  SET_CURRENT_ROLE: (state, currentRole) => {
    state.currentRole = currentRole
  }
}

const actions = {
  // user login
  login({ commit }, userInfo) {
    const { username, password } = userInfo
    return new Promise((resolve, reject) => {
      login({ username: username, password: password }).then(response => {
        if (response.status === 200 && response.data) {
          const data = response.data.userInfo
          const useBaseInfo = {
            username: data.username,
            nickname: data.nickname,
            email: data.email
          }
          window.sessionStorage.setItem('userInfo', JSON.stringify(useBaseInfo))
          const { roles, currentRole } = data
          commit('SET_TOKEN', useBaseInfo)
          commit('SET_NAME', useBaseInfo.username)
          setToken(currentRole.id)
          commit('SET_ROLES', roles)
          window.sessionStorage.setItem('roles', JSON.stringify(roles))
          commit('SET_CURRENT_ROLE', currentRole)
          window.sessionStorage.setItem('currentRole', currentRole)
          const avtar = '@/assets/avtars/avtar1.jpg'
          commit('SET_AVATAR', avtar)
          getRouteIds(currentRole.id).then(response => {
            if (response.status === 200 && response.data.status === 200) {
              const routeIds = response.data['data']
              window.sessionStorage.setItem('routeData', JSON.stringify(routeIds))
            } else {
              Message.error('response.status=' + response.status + 'response.text=' + response.text)
            }
          })
          resolve(useBaseInfo)
        } else {
          Message.error('user login failed')
          resolve()
        }
      }).catch(error => {
        console.error(error)
        reject(error)
      })
    })
  },

  // 获取用户信息,已弃用
  /**
  getInfo({ commit, state }) {
    return new Promise((resolve, reject) => {
      getInfo(state.token).then(response => {
        const { data } = response

        if (!data) {
          reject('Verification failed, please Login again.')
        }

        const { roles, name, avatar, 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_ROLES', roles)
        commit('SET_NAME', name)
        commit('SET_AVATAR', avatar)
        commit('SET_INTRODUCTION', introduction)
        resolve(data)
      }).catch(error => {
        reject(error)
      })
    })
  }, */

  // 用户登出
  logout({ commit, state, dispatch }) {
    return new Promise((resolve, reject) => {
      logout(state.token).then(() => {
        commit('SET_TOKEN', '')
        commit('SET_ROLES', [])
        commit('SET_NAME', '')
        commit('SET_CURRENT_ROLE', null)
        window.sessionStorage.removeItem('userInfo')
        window.sessionStorage.removeItem('routeIds')
        window.sessionStorage.removeItem('roles')
        window.sessionStorage.removeItem('currentRole')
        removeToken()
        resetRouter()
        // reset visited views and cached views
        // to fixed https://github.com/PanJiaChen/vue-element-admin/issues/2485
        dispatch('tagsView/delAllViews', null, { root: true })
        resolve()
      }).catch(error => {
        reject(error)
      })
    })
  },

  // remove token
  resetToken({ commit }) {
    return new Promise(resolve => {
      commit('SET_TOKEN', '')
      commit('SET_ROLES', [])
      removeToken()
      resolve()
    })
  },

  // 切换角色
  changeRoles({ dispatch }, roleId) {
    return new Promise(async resolve => {
      resetRouter()
      // generate accessible routes map based on roles
      getRoutes(roleId).then(response => {
        if (response.status === 200 && response.data.status === 200) {
          const dynamicRouteData = response.data['data']
          window.sessionStorage.setItem('routeData', JSON.stringify(dynamicRouteData))
          // dynamically add accessible routes
          dispatch('permission/generateRoutes', dynamicRouteData)
          // reset visited views and cached views
          dispatch('tagsView/delAllViews', null)
        } else {
          Message.error('response.status=' + response.status + 'response.text=' + response.text)
        }
      })
      resolve()
    })
  }
}

export default {
  namespaced: true,
  state,
  mutations,
  actions
}

vuex存储全局共享数据需要结合sessionStorage一起使用

2.4 修改src/store/modules/permission.js文件

import {
    
     constantRoutes } from '@/router'
// import { getRoutes } from '@/api/role'
import {
    
     Message } from 'element-ui'
import {
    
     allRouteComponentMap, asyncRoutes } from '@/router/index'
/**
 * Use meta.role to determine if the current user has permission
 * @param roles
 * @param route
 */
/**
function hasPermission(roles, route) {
  if (route.meta && route.meta.roles) {
    return roles.some(role => route.meta.roles.includes(role))
  } else {
    return true
  }
}*/

/**
 * Filter asynchronous routing tables by recursion
 * @param routes asyncRoutes
 * @param roles
 */
/**
export function filterAsyncRoutes(routes, roles) {
  const res = []
  routes.forEach(route => {
    const tmp = { ...route }
    if (hasPermission(roles, tmp)) {
      if (tmp.children) {
        tmp.children = filterAsyncRoutes(tmp.children, roles)
      }
      res.push(tmp)
    }
  })

  return res
}*/

/**
 * 后台路由数据转路由组件数组(这是之前的方案转换路由数据为路由组件的函数,最后的方案没用)
 * @param routes
 * @returns {Array}
 */
export function transferDynamicRoutes(routes) {
    
    
  const routeVos = []
  if (!routes || routes.length === 0) return routeVos
  const length = routes.length
  for (let i = 0; i < length; i++) {
    
    
    const item = routes[i]
    let routeComponent
    if (item.componentUrl) {
    
    
      if (allRouteComponentMap[item.componentUrl]) {
    
    
        routeComponent = allRouteComponentMap[item.componentUrl]
      } else {
    
    
        routeComponent = allRouteComponentMap['@/views/error-page/404']
      }
    } else {
    
    
      routeComponent = null
    }
    const routeVo = {
    
     id: item.id, path: item.path, redirect: item.redirect ? item.redirect : 'noRedirect',
      name: item.name, alwaysShow: item.name === 'Permission',
      hidden: item.hidden,
      meta: {
    
     title: item.title,
        icon: item.icon,
        noCache: true
      },
      children: [],
      component: routeComponent
    }
    if (item.children.length > 0) {
    
    
      routeVo.children = transferDynamicRoutes(item.children)
    }
    routeVos.push(routeVo)
  }
  return routeVos
}

const state = {
    
    
  routes: [],
  addRoutes: []
}

const mutations = {
    
    
  SET_ROUTES: (state, routes) => {
    
    
    state.addRoutes = routes
    state.routes = constantRoutes.concat(routes)
  }
}

const actions = {
    
    
  generateRoutes({
    
     commit }, routeIds) {
    
    
    const routeIdMap = {
    
    }
    for (let i = 0; i < routeIds.length; i++) {
    
    
      routeIdMap[routeIds[i]] = routeIds[i]
    }
    return new Promise((resolve) => {
    
    
      if (routeIds && routeIds.length > 0) {
    
    
        const dynamicRoutes = filterPermissionRoutes(routeIdMap, asyncRoutes)
        commit('SET_ROUTES', dynamicRoutes)
        resolve(dynamicRoutes)
      } else {
    
    
        // throw new Error('transferDynamicRoutes error')
        Message.error('transferDynamicRoutes error')
        resolve([])
      }
    })
  }
}

/**
 * 后台返回组装的routeIdMap获取过滤后的动态路由集合
 * @param {Object} routeIdMap
 * @param {Array} dynamicRoutes
 * @returns permissionRoutes
 */
export function filterPermissionRoutes(routeIdMap, dynamicRoutes) {
    
    
  const permissionRoutes = []
  for (let i = 0; i < dynamicRoutes.length; i++) {
    
    
    const routeItem = dynamicRoutes[i]
    if (routeIdMap[routeItem.id]) {
    
    
      const permissionRouteItem = {
    
    
        id: routeItem.id,
        path: routeItem.path,
        name: routeItem.name,
        alwaysShow: routeItem.alwaysShow != null && routeItem.alwaysShow,
        redirect: routeItem.redirect,
        meta: routeItem.meta,
        hidden: routeItem.hidden != null && routeItem.hidden,
        component: routeItem.component,
        children: []
      }
      permissionRoutes.push(permissionRouteItem)
      if (routeItem.children && routeItem.children.length > 0) {
    
    
        permissionRouteItem.children = filterPermissionRoutes(routeIdMap, routeItem.children)
      }
    }
  }
  return permissionRoutes
}
export default {
    
    
  namespaced: true,
  state,
  mutations,
  actions
}

2.5 修改 src/store/getter.js文件

const getters = {
    
    
  sidebar: state => state.app.sidebar,
  size: state => state.app.size,
  device: state => state.app.device,
  visitedViews: state => state.tagsView.visitedViews,
  cachedViews: state => state.tagsView.cachedViews,
  token: state => state.user.token,
  avatar: state => state.user.avatar,
  name: state => state.user.name,
  // introduction: state => state.user.introduction,
  roles: state => state.user.roles,
  permission_routes: state => state.permission.routes,
  dynamicRoutes: state => state.permission.addRoutes,
  errorLogs: state => state.errorLog.logs
}
export default getters

2.6 修改src/permission.js文件

import router from './router'
import {
    
     constantRoutes } from './router'
import store from './store'
import NProgress from 'nprogress' // progress bar
import 'nprogress/nprogress.css' // progress bar style
import {
    
     getToken, removeToken } from '@/utils/auth' // get token from cookie
import getPageTitle from '@/utils/get-page-title'
import {
    
     getRouteIds } from '@/api/role'
import {
    
     Message } from 'element-ui'

NProgress.configure({
    
     showSpinner: false }) // NProgress Configuration

const whiteList = ['/login', '/auth-redirect'] // 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 permissionRoutes = store.getters.permission_routes
  const dynamicRoutes = store.getters.dynamicRoutes
  const roleId = getToken()
  if (!permissionRoutes || permissionRoutes.length === 0) {
    
    
    // 如果固定路由还没有添加到路由对象中则先添加固定路由列表
    router.addRoutes(constantRoutes)
  }
  if (dynamicRoutes && dynamicRoutes.length > 0) {
    
    
    // 用户已登录,如果是继续进入登录页面则直接进入首页
    if (to.path === '/login') {
    
    
      next({
    
     path: '/' })
      NProgress.done()
    } else {
    
    
      next()
    }
  } else {
    
    
    /* 动态路由列表尚未添加到路由对象中*/
    if (whiteList.indexOf(to.path) !== -1) {
    
    
      // 白名单路由直接进入
      next()
    } else {
    
    
      // 用户已登录
      if (roleId) {
    
    
        // 判断sessionStorage是否已保存路由数据
        const routeIdsJson = window.sessionStorage.getItem('routeIds')
        if (routeIdsJson) {
    
    
          const routeIds = JSON.parse(routeIdsJson)
          store.dispatch('permission/generateRoutes', routeIds).then(response => {
    
    
            if (response && response.length > 0) {
    
    
              const dynamicRoutes = response
              router.addRoutes(dynamicRoutes)
              next()
            } else {
    
    
              // 拿到角色的动态路由数组为空
              window.sessionStorage.removeItem('routeData')
              Message.warning('the permission routes belong to the current role is empty')
              next()
            }
          })
        } else {
    
    
          getRouteIds(roleId).then(response => {
    
    
            if (response.status === 200 && response.data.data.length > 0) {
    
    
              const routeIds = response.data.data
              window.sessionStorage.setItem('routeIds', JSON.stringify(routeIds))
              store.dispatch('permission/generateRoutes', routeIds).then(response => {
    
    
                if (response && response.length > 0) {
    
    
                  const dynamicRoutes = response
                  router.addRoutes(dynamicRoutes)
                  next()
                } else {
    
    
                  // 拿到角色的动态路由数组为空
                  window.sessionStorage.removeItem('routeIds')
                  Message.warning('the permission routes belong to the current role is empty')
                  next()
                }
              })
            } else {
    
    
              // 获取角色动态路由失败
              window.sessionStorage.removeItem('routeIds')
              Message.warning('failed to get permission routes belong to the current role ')
              next()
            }
          }).catch(error => {
    
    
            // 调用获取角色下的动态路由列表接口失败,需要重新登录
            Message.error(error)
            removeToken()
            if (window.sessionStorage.getItem('userInfo')) {
    
    
              window.sessionStorage.removeItem('userInfo')
            // eslint-disable-next-line no-trailing-spaces
            }    
            next(`/login?redirect=${
      
      to.path}`)
            NProgress.done()
          })
        }
      } else {
    
    
      // 用户未登录,重定向到登录界面
        removeToken()
        next(`/login?redirect=${
      
      to.path}`)
        NProgress.done()
      }
    }
  }
})

router.afterEach(() => {
    
    
  // finish progress bar
  NProgress.done()
})

动态加载路由菜单的逻辑都在router.beforeEach守卫函数中实现,这个文件中的修改是实现动态渲染菜单的关键,笔者也是通过一步步debug调试,踩了很多坑才最终修改好的。

2.7 修改src/views/index.vue文件

修改登录组件中的用户名和密码为之前自己通过postman调用注册接口时的值

 data() {
    
    
    const validateUsername = (rule, value, callback) => {
    
    
      if (!validUsername(value)) {
    
    
        callback(new Error('Please enter the correct user name'))
      } else {
    
    
        callback()
      }
    }
    const validatePassword = (rule, value, callback) => {
    
    
      if (value.length < 6) {
    
    
        callback(new Error('The password can not be less than 6 digits'))
      } else {
    
    
        callback()
      }
    }
    return {
    
    
     // loginForm中的username和password对应的值为修改的内容
     // 用户不修改的化也可以在输入框中删除原来的用户名和密码后再输入正确的用户名和密码 
     loginForm: {
    
    
        username: 'heshengfu',
        password: 'heshengfu123'
      },
      loginRules: {
    
    
        username: [{
    
     required: true, trigger: 'blur', validator: validateUsername }],
        password: [{
    
     required: true, trigger: 'blur', validator: validatePassword }]
      },
      passwordType: 'password',
      capsTooltip: false,
      loading: false,
      showDialog: false,
      redirect: undefined,
      otherQuery: {
    
    }
    }
  }
    

2.8 修改 src/utils/validate.js文件

修改其中的validUsername方法,原项目中限制了只能是admin和editor两个用户

export function validUsername(username) {
    
    
  if (username == null || username.trim() === '') {
    
    
    Message.error('用户名不能为空')
    return false
  }
  return true
}

2.9 修改build/index.jsvue.config.js文件

(1)将build/index.js中的端口号改为3000,读者也可以改为任意安全且没有被占用的端口

if (process.env.npm_config_preview || rawArgv.includes('--preview')) {
    
    
  const report = rawArgv.includes('--report')

  run(`vue-cli-service build ${
      
      args}`)
  // 端口设置为3000
  const port = 3000
  const publicPath = config.publicPath

  var connect = require('connect')
  var serveStatic = require('serve-static')
  const app = connect()
  app.use(
    publicPath,
    serveStatic('./dist', {
    
    
      index: ['index.html', '/']
    })
  )

(2) 将vue.config.js文件中的代理转发和mock.js注释掉

// All configuration item explanations can be find in https://cli.vuejs.org/config/
module.exports = {
    
    
  /**
   * You will need to set publicPath if you plan to deploy your site under a sub path,
   * for example GitHub Pages. If you plan to deploy your site to https://foo.github.io/bar/,
   * then publicPath should be set to "/bar/".
   * In most cases please use '/' !!!
   * Detail: https://cli.vuejs.org/config/#publicpath
   */
  publicPath: '/',
  outputDir: 'dist',
  assetsDir: 'static',
  lintOnSave: process.env.NODE_ENV === 'development',
  productionSourceMap: false,
  devServer: {
    
    
    port: port,
    open: true,
    overlay: {
    
    
      warnings: false,
      errors: true
    }
    // proxy: {
    
    
    //   '/api': {
    
    
    //     target: 'http://localhost:8081/blog',
    //     changeOrigin: true,
    //     pathRewrite: {
    
    
    //       '^/api': 'http://localhost:8081/blog'
    //     }
    //   }
    // }
    // after: require('./mock/mock-server.js')
  },

因为我们的后台服务做了跨域设置,也就不需要代理转发了

2.10 修改.env.development文件

# base api
#VUE_APP_BASE_API = '/api'
VUE_APP_BASE_API = 'http://localhost:8081/blog'
port = 3000

VUE_APP_BASE_API改为后台API的请求前缀,即http://localhost:8081/blog

到这里前端要改的文件也就改完了。

3 效果体验

3.1 启动前后台服务

先启动后台服务,需要注意的是在启动后台之前请先启动本地的mysql服务,防止程序连接不上mysql数据库而报错

修改好vue-element-admin项目中的js文件后,在vue-element-admin项目的根目录下右键->git bash ,在弹出的控制台中输入npm run dev

控制台出现如下信息代表前端服务启动成功:

App running at:
  - Local:   http://localhost:3000/
  - Network: http://192.168.1.235:3000/
3.2 登录并进入首页

前端服务启动成功后会自动打开浏览器跳转到登录页面,如下图所示:
login
图 1 登录界面

点击Login按钮登录成功后即跳转到vue-element-admin项目首页

homePage
图 2 项目首页

登录的过程中我们可以通过点击鼠标右键->检查 进入开发者模式查看浏览器发起的网络请求,我们清楚地看到用户登录成功接口和根据角色ID查询路由资源列表接口
login_api_request
图 3 登录请求标头

login_request_response
图 4 登录请求响应数据
access_routerIds_request_option
图 5 获取当前角色路由ID集合数据预检请求

access_routerIds_request_get
图 6 获取当前角色路由ID集合数据GET请求

进入首页后我们点击动态加载出来的路由Permission菜单下的子菜单Page Permission发现可以顺利进入权限控制页面,而没有出现从后台动态加载整个路由组件时出现的报404的问题。

permission_page
至此,使用vuevue-router整合合spring-boot技术实现基于角色动态加载菜单,并按权限访问页面的功能最难的一关已近闯过来了!后面笔者将再接再厉在此基础上实现给用户分配角色、给角色分配资源并结合spring-security实现按钮粒度的权限控制等一整套权限控制体系,敬请期待!

4 结语

vue-element-admin 项目是国内非常有名的一个开源项目,目前github上的start数已经超过4万。项目作者是就职于国内知名互联网公司今日头条的作者PanJiaChen。作者的关于权限控制的专栏文章地址:手摸手,带你用vue撸后台 系列二(登录权限篇) (juejin.cn),感兴趣的读者可以好好看一看。

本文的功能实现依赖于对vue-element-admin项目源码的深度研究,尤其对src目录下的permission.jssrc/store/module目录下的permission.jsuser.js以及与菜单有关的src/layout目录下的index.vue

components/Sidebar/SidebarItem.vueconponents/AppMain.vue等几个重要文件中的源码的深入研究。读者如果需要对vue-element-admin项目进行改造,建议重点研读这几个文件中的源码。

本文首发个人微信公众号阿福谈Java技术栈,后端代码已上传至个人gitee仓库: https://gitee.com/heshengfu1211/blogserver.git

需要获得修改后的vue-element-admin项目源码的读者请关注笔者的个人微信公众号阿福谈Java技术栈,然后在消息对话框中输入关键字【vue-element-admin】接口获得代码仓库地址。欢迎读者关注笔者的个人微信公众号,让我们一起在项目实战的路上让自己的开发技术得到不断成长!
在这里插入图片描述

—END—

猜你喜欢

转载自blog.csdn.net/heshengfu1211/article/details/118469694