0 引言
这篇文章自己准备了好几个周末,如果不是中间踩了太多的坑的话上上的周末就应该发表了,实在是因为踩坑太多而自己也比较执拗,坚持要写出一篇解决掉遇到的99%以上的Bug,能经得起读者实践验证的项目实战文章,拖到今天才发布。笔者一直坚持文章质量重于数量,内容足够好的文章才会让更多的读者传阅。
这篇文章前端以开源项目vue-element-admin
基础,后端以Vblog项目中后端项目blogserver
为基础。为啥前端没用Vblog项目中的vueblog
前端项目?因为vueblog
项目中的很多组件没有,包括vuex, 还有很多组件版本过低,笔者当时安装完各种需要的依赖包之后发现项目都启动不了,还一直报错,短时间之内根本无法解决。而我之前有克隆过vue-element-admin
项目的源码,里面大部分需要的前端组件和依赖包都有,最重要的是里面有mock模拟后台数据实现的用户登录和动态加载路由资源和初始化基于角色控制的菜单列表的实现。我们只需要在这个项目的基础上进行业务需求的修改即可,下面开始呈上笔者的代码实现。
废话不多说,下面开始呈上内容干货!
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
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
<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;
}
除了上面这种方式解决解决集成spring-security的springboot项目的跨域问题之外,也可以通过configure(HttpSecurity http)方法中的http.addFilter(filter,afterFilter),找到spring容器中一个参照过滤器,并自定义一个过滤器,实现doFilter方法,在doFilter方法中对ServletRequest和
ServletResponse两个参数添加逻辑代码实现。
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对用户登录密码进行了加密处理)
本文后端代码已上传到笔者的gitee后端代码仓库地址:https://gitee.com/heshengfu1211/blogserver.git
感兴趣的小伙伴可以git代码下来实践一番
给每个路由组件都加上id属性,为了减少工作量,实现效果。我们先把router/modules目录下的路由数据先屏蔽掉,后面有需要再放开。
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.js
和resolve
函数回调实例化动态路由组件,但不知道是自己项目中的一些组件版本不对还是其他原因实例化后的组件一直有错误,无法渲染程菜单。后面改为在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.js
和src/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
必须加上以上headers
和transformRequest
,尤其是对入参的处理回调函数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
一起使用
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: {}
}
}
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.js
和vue.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
文件中的代理转发注释掉
// 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
到这里前台要改的文件也就改完了
先启动后台服务,需要注意的是在启动后台之前请先启动本地的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/
前端服务启动成功后会自动打开浏览器跳转到登录页面,如下图所示:
图 1 登录界面
点击Login按钮登录成功后即跳转到vue-element-admin
项目首页
图 2 登录成功后进入项目首页
登录的过程中我们可以通过点击鼠标右键->检查 进入开发者模式查看浏览器发起的网络请求,我们清楚地看到用户登录成功接口和根据角色ID查询路由资源列表接口
图 3 登录请求标头
图 4 登录请求预览
图 5 获取当前角色路由ID集合数据预检请求
图 6 获取当前角色路由ID集合数据GET请求
图 7 获取当前角色路由ID集合数据接口成功响应
进入首页后我们点击动态加载出来的路由Permission菜单下的子菜单Page Permission发现可以顺利进入权限控制页面,而没有出现从后台动态加载整个路由组件时出现的报404的问题。
图 8 进入动态控制菜单的字菜单Page Permission页面
至此,使用vue
和vue-router
整合合spring-boot技术实现基于角色动态加载菜单,并按权限访问页面的功能最难的一关已近闯过来了!后面笔者将再接再厉在此基础上实现给用户分配角色、给角色分配资源并结合spring-security
实现按钮粒度的权限控制等一整套权限控制体系,敬请期待!
vue-element-admin 项目是国内非常有名的一个开源项目,目前github
上的start数已经超过4万。项目作者是就职于国内知名互联网公司今日头条的作者PanJiaChen。作者的关于权限控制的专栏文章地址:手摸手,带你用vue撸后台 系列二(登录权限篇) (juejin.cn),感兴趣的读者可以好好看一看。
本文的功能实现依赖于对vue-element-admin
项目源码的深度研究,尤其对src
目录下的permission.js
、src/store/module
目录下的permission.js
和user.js
以及与菜单有关的src/layout
目录下的index.vue
及components/Sidebar/SidebarItem.vue
和conponents/AppMain.vue
等几个重要文件中的源码的深入研究。读者如果需要对vue-element-admin
项目进行改造,建议重点研读这几个文件中的源码。
5 推荐阅读
[2] 介绍一个开源博客项目VBlog并打包部署到已存在运行项目的Nginx服务器下
[3] 我想在同一个域名下部署多个项目怎么办?一文搞懂Nginx同域名下部署多个vue项目
[4] SpringBoot项目集成阿里云对象存储服务实现文件上传