打造一款适合自己的快速开发框架-前端篇之登录与路由模块化

4,785 阅读7分钟

前言

后端篇一阶段内容已经完成得差不多了,前端篇还没正式开始,这段时间会先转前端篇,就着之前搭建的前端脚手架搭建继续添加后续的内容。本文重点讲一下前端登录与路由模块化思路,顺便会讲一下elementui-admin脚后架的自定义图标的使用。

登录

做为后台管理系统,登录模块是不可缺少的。这里简单说明一下登录模块需要做的事及注意事项。

用户状态

  1. 未登录

未登录用户,访问所有页面,都会重定向到登录页。

  1. 已登录

已登录的用户只能看到自己拥有的权限的菜单、按钮,并可对其进行对应操作。

用户状态存储

前后端分离后,已登录的用户需要在本地存储会话信息,常见的方式是使用Cookies存储token。本框架这里也是使用Cookies。当然,除了使用Cookies外,还可以使用h5中的window.localStorage等。

用户状态清除

已登录的用户,如下情况下会清除用户登录状态

  1. 用户主动退出;
  2. 接口返回token过期状态码

出现如上两种情况,会调用代码清除本地会话信息,并重定向到登录页重新登录。

登录接口文档

请求地址:

{{api_base_url}}/sys/login

数据类型:

application/json

请求示例:

{
	"password": "123456",
	"userName": "admin"
}

响应示例:

{
  "code": 0,					// 返回状态码0,成功
  "msg": "登录成功",			 // 消息描述
  "data": {
    "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJtbGRvbmciLCJleHAiOjE1OTI0OTIyMDEsInVzZXJOYW1lIjoiYWRtaW4iLCJpYXQiOjE1OTI0ODUwMDEsInVzZXJJZCI6MX0.HldmyCcL2EV8rtIeIiYsei963Cb3qIDHJRMOYo0iXkU",				   // 临时令牌,登录后,其他接口都需要携带该参数
    "userId": 1,				// 用户id
    "userName": "admin",		// 用户名
    "realName": "蒙立东",		  // 用户姓名
    "avatar": "",               // 用户头像
    "accessList": [],           // 权限标识
    "menuList": []				// 菜单集合
  }
}

登录模块涉及到改动的文件

  • src/views/login.vue

登录页面,登录表单,代码片段。

 handleLogin() {
     // 在这里做表单校验
     this.$refs.loginForm.validate(valid => {
         if (valid) {
             this.loading = true
             // 校验通过,调用action->user/login,对应src/store/modules/user.js的action.login方法
             this.$store.dispatch('user/login', this.loginForm).then(() => {
                 this.$router.push({ path: this.redirect || '/' })
                 this.loading = false
             }).catch(() => {
                 this.loading = false
             })
         } else {
             console.log('error submit!!')
             return false
         }
     })
 }
  • src/store/modules/user.js

vue状态管理,代码片段

const getDefaultState = () => {
  return {
    token: getToken(),
    name: '',
    avatar: ''
  }
}

const state = getDefaultState()

const mutations = {
  RESET_STATE: (state) => {
    Object.assign(state, getDefaultState())
  },
  SET_TOKEN: (state, token) => {
    state.token = token
  },
  SET_NAME: (state, name) => {
    state.name = name
  },
  SET_AVATAR: (state, avatar) => {
    state.avatar = avatar
  }
}
const actions = {
  // user login
  login({ commit }, userInfo) {
    // 这个就是登录那边调用的方法 this.$store.dispatch('user/login', this.loginForm)
    const { username, password } = userInfo
    return new Promise((resolve, reject) => {
      // 这里调用src/api/user.js的login
      login({ username: username.trim(), password: password }).then(response => {
        const { data } = response
        // 设置token
        commit('SET_TOKEN', data.token)
        // 设置cookies,这里调用的是src/utils/auth.js文件的setToken方法
        setToken(data.token)
        resolve()
      }).catch(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 { userName, avatar } = data

        commit('SET_NAME', userName)
        commit('SET_AVATAR', avatar)
        resolve(data)
      }).catch(error => {
        reject(error)
      })
    })
  },
  // 用户登录系统
  logout({ commit, state }) {
    return new Promise((resolve, reject) => {
      logout(state.token).then(() => {
        removeToken() // must remove  token  first
        resetRouter()
        commit('RESET_STATE')
        resolve()
      }).catch(error => {
        reject(error)
      })
    })
  },
  • src/utils/auth.js

cookies相关操作

import Cookies from 'js-cookie'

const TokenKey = 'vue_admin_template_token'
// 获取 token
export function getToken() {
  return Cookies.get(TokenKey)
}
// 设置 token
export function setToken(token) {
  return Cookies.set(TokenKey, token)
}
// 删除 token
export function removeToken() {
  return Cookies.remove(TokenKey)
}

  • src/api/user.js

用户登录、退出、获取个人信息接口服务,根据后端接口情况进行修改。

import request from '@/utils/request'
// 登录
export function login(data) {
  return request({
    url: '/sys/login',
    method: 'post',
    data: {
        // 这里需要简单的修改一下入参username->userName
        userName: data.username,
        password: data.password
    }
  })
}
// 获取用户个人信息
export function getInfo(token) {
  return request({
    url: '/sys/user/info',
    method: 'post'
  })
}
// 登录系统
export function logout() {
  return request({
    url: '/sys/logout',
    method: 'post'
  })
}

src/utils/request.js

http请求工具类,在这里做全局的请求头、请求参数、返回数据处理

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

// 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) {
      // 存在token,就放到请求头中
      // 这里修改一下请求头与后端一致,X-Token->Auth-Token
      config.headers['Auth-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

    // 如果状态码不为0,则异常,.
    if (res.code !== 0) {
      Message({
        message: res.msg || '服务器异常',
        type: 'error',
        duration: 5 * 1000
      })

      // 这里的状态码可根据后端状态码进行修改
      if (res.code === 401) {
        // to re-login
        MessageBox.confirm('您已经退出了,将离开该页面,确定退出?', {
          confirmButtonText: '重新登录',
          cancelButtonText: '取消',
          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

路由模块化

脚手架把大多数功能都做了,但是对于路由的模块化,还有功能模块的目录分层还是没有做的,这需要我们根据自己项目的情况进行划分,下面是我自己的分层方案,这里只涉及到路由和页面,下一篇讲到具体的CURD样例时才会有完整的分层。

目录结构

├── src/router
	├── cms.router.js	内容管理路由
	├──	index.js		路由主入口
	├── oms.router.js	订单管理路由
	├── pms.router.js	商品管理路由
	└── sys.router.js	系统管理路由
└── views/modules
		├──	cms	内容管理模块
			├── article
				└──	index.vue
			├──	category
				└──	index.vue
			├── model
				└──	index.vue
			└── modelFiled
				└──	index.vue
		├──	oms	订单管理模块
			├── order
				└──	index.vue
			└──	orderSetting
				└──	index.vue
		├──	pms	商品管理模块
			├── brand
				└──	index.vue
			├──	product
				└──	index.vue
			└── productCategory
				└──	index.vue
		├──	sys	系统管理模块
			├── dict
				└──	index.vue
			├──	dictItem
				└──	index.vue
			├──	menu
				└──	index.vue
			├──	role
				└──	index.vue
			└── user
				└──	index.vue

上面那么多个模块并不是说要立马实现,只是想讲解一下路由模块化与页面模块化。

样例

  • src/router/sys.router.js
import Layout from '@/layout'

export default [
  {
    path: '/sys',   // 这里的模块化标识与后台菜单管理的权限标识一致
    name: 'sys',
    meta: {
      icon: 'sys',
      title: '系统设置',
      access: ['admin', 'sys'],
      notCache: true,
      showAlways: true
    },
    component: Layout,
    children: [
      {
        path: '/sys/menu/index',	
        name: 'sys:menu:index',		// 与菜单管理权限标识一致
        meta: {
          icon: '',
          title: '菜单管理',
          access: ['admin', 'sys:menu:index'],
          notCache: true
        },
        component: (resolve) => {
          import('@/views/modules/sys/menu/index.vue').then(m => {
            resolve(m)
          })
        }
      },
      {
        path: '/sys/user/index',
        name: 'sys:user:index',
        meta: {
          icon: '',
          title: '用户管理',
          access: ['admin', 'sys:user:index'],
          notCache: true
        },
        component: (resolve) => {
          import('@/views/modules/sys/user/index.vue').then(m => {
            resolve(m)
          })
        }
      },
      {
        path: '/sys/role/index',
        name: 'sys:role:index',
        meta: {
          icon: '',
          title: '角色管理',
          access: ['admin', 'sys:role:index'],
          notCache: true
        },
        component: (resolve) => {
          import('@/views/modules/sys/role/index.vue').then(m => {
            resolve(m)
          })
        }
      },
      {
        path: '/sys/dict/index',
        name: 'sys:dict:index',
        meta: {
          icon: '',
          title: '字典管理',
          access: ['admin', 'sys:dict:index'],
          notCache: true
        },
        component: (resolve) => {
          import('@/views/modules/sys/dict/index.vue').then(m => {
            resolve(m)
          })
        }
      }
    ]
  }
]
  • src/router/index.js

路由管理主入口,这里使用了webpack_require动态加载路由文件

import Vue from 'vue'
import Router from 'vue-router'

Vue.use(Router)

/* Layout */
import Layout from '@/layout'

/**
 * Note: sub-menu only appear when route children.length >= 1
 * Detail see: https://panjiachen.github.io/vue-element-admin-site/guide/essentials/router-and-nav.html
 *
 * hidden: true                   if set true, item will not show in the sidebar(default is false)
 * alwaysShow: true               if set true, will always show the root menu
 *                                if not set alwaysShow, when item has more than one children route,
 *                                it will becomes nested mode, otherwise not show the root menu
 * redirect: noRedirect           if set noRedirect will no redirect in the breadcrumb
 * name:'router-name'             the name is used by <keep-alive> (must set!!!)
 * meta : {
    roles: ['admin','editor']    control the page roles (you can set multiple roles)
    title: 'title'               the name show in sidebar and breadcrumb (recommend set)
    icon: 'svg-name'             the icon show in the sidebar
    breadcrumb: false            if set false, the item will hidden in breadcrumb(default is true)
    activeMenu: '/example/list'  if set path, the sidebar will highlight the path you set
  }
 */

/**
 * constantRoutes
 * a base page that does not have permission requirements
 * all roles can be accessed
 */
export const constantRoutes = [
  {
    path: '/login',
    component: () => import('@/views/login/index'),
    hidden: true
  },

  {
    path: '/404',
    component: () => import('@/views/404'),
    hidden: true
  },

  {
    path: '/',
    component: Layout,
    redirect: '/dashboard',
    hidden: true,
    children: [{
      path: 'dashboard',
      name: 'Dashboard',
      component: () => import('@/views/dashboard/index'),
      meta: { title: '首页', icon: 'dashboard' }
    }]
  }
]
/**
 * webpack_require动态加载路由文件
 */
const routersFiles = require.context('./', true, /\.js$/)
const routerList = []
const routers = routersFiles.keys().reduce((modules, routerPath) => {
  // set './app.js' => 'app'
  const routerName = routerPath.replace(/^\.\/(.*)\.\w+$/, '$1')
  const value = routersFiles(routerPath)
  if (routerName !== 'index') {
    routerList.push(...value.default)
  }
  return routers
}, {})
/**
 * 异步路由,需要动态router.addRoutes(accessRoutes)
 */
export const asyncRoutes = [
  ... routerList,
  // 404 page 一定要放在这最后,要不然异步加载,刷新页面没找到就直接跑去constantRoutes的404了
  { path: '*', redirect: {
    name: 'm404'
  }, hidden: true }
]
const createRouter = () => new Router({
  // mode: 'history', // require service support
  scrollBehavior: () => ({ y: 0 }),
  routes: [
    ... constantRoutes,
    // 因为还没做到权限,这里先直接放在这,后面做到权限时,再修改
    ... asyncRoutes
  ]
})

const router = createRouter()

export function resetRouter() {
  const newRouter = createRouter()
  router.matcher = newRouter.matcher // reset router
}

export default router

路由拦截器

路由拦截器与请求拦截器差不多,在该脚手架中,主要放在

  • 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'] // no redirect whitelist

// 进入页面前拦截
router.beforeEach(async(to, from, next) => {
  // 进度条开始
  NProgress.start()

  // 重设页面标题
  document.title = getPageTitle(to.meta.title)

  // 获取token,判断是否已经登录
  const hasToken = getToken()

  if (hasToken) {
    if (to.path === '/login') {
      // 如果已经登录且是登录页,则重定向到首页
      next({ path: '/' })
      NProgress.done()
    } else {
      const hasGetUserInfo = store.getters.name
      // 判断用户信息是否存在,如果已经存在,则可以进入页面
      if (hasGetUserInfo) {
        next()
      } else {
        try {
          // 用户信息不存在,需要去获取
          await store.dispatch('user/getInfo')
          // 获取成功,则进入页面
          next()
        } catch (error) {
          // 获取用户信息失败,则删除会话信息并跳转到登录页
          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) {
      // 是白名单的页面,则可以进入页面
      next()
    } else {
      // 非白名单,则跳转到登录页
      next(`/login?redirect=${to.path}`)
      NProgress.done()
    }
  }
})

// 进入页面后
router.afterEach(() => {
  // 进度条完成
  NProgress.done()
})

关于自定义图标

左侧菜单使用的自定义图标使用的是svg文件定义的,这个可以去阿里的矢量图标库去获取。

www.iconfont.cn/

图标存放目录

src/icons/svg/xxxx.svg

效果图

效果图

小结

本文只是简单的介绍了登录模块及路由模块化分层,基本的分层思想还是借鉴于后端。权限这块因为涉及内容太多,所以并没有在该篇文章中展开,后续会专门出一篇来详细说明前后端分离后的权限管理。

项目源码地址

  • 后端

gitee.com/mldong/mldo…

  • 前端

gitee.com/mldong/mldo…

相关文章

打造一款适合自己的快速开发框架-先导篇

打造一款适合自己的快速开发框架-前端脚手架搭建