【01/02 - 基础搭建】
1.了解vue-element-admin
**目标**: 学习和了解通用的vue后台集成方案**vue-element-admin**
vue-element-admin 是一个后台前端解决方案,它基于 vue 和 element-ui实现。它使用了最新的前端技术栈,内置了 i18 国际化解决方案,动态路由,权限验证,提炼了典型的业务模型,提供了丰富的功能组件,它可以帮助你快速搭建企业级中后台产品原型。
vue-element-admin 有一个成熟的集成方案,里面包含了所有的业务功能和场景,并不适合直接拿来进行二次开发, 但是可以通过该项目中的一个案例来进行学习和使用.
如果你想查看该项目的具体功能和效果,可以拉取代码,启动进行预览
$ git clone https://github.com/PanJiaChen/vue-element-admin.git #拉取代码$ cd vue-element-admin #切换到具体目录下$ npm run dev #启动开发调试模式 查看package.json文件的scripts可知晓启动命令
**注意**:当前项目下载速度如果过慢,可以直接下载代码的压缩包运行
集成方案并不适合我们直接拿来进行二次开发,基础模板则是一个更好的选择
基础模板, 包含了基本的 登录 / 鉴权 / 主页布局 的一些基础功能模板, 我们可以直接在该模板上进行功能的扩展和项目的二次开发
1.1项目模板启动和目录介绍
目标: 拉取项目的基础模板,并对目录进行介绍
git拉取基础项目模板
$ git clone https://github.com/PanJiaChen/vue-admin-template.git hrsaas #拉取基础模板到hrsaas目录
安装项目依赖(定位到项目目录下)
$ npm install #安装依赖
启动项目
$ npm run dev #启动开发模式的服务
项目运行完毕,浏览器会自动打开基础模板的登录页,如上图
目录结构
本项目已经为你生成了一个基本的开发框架,提供了涵盖中后台开发的各类功能和坑位,下面是整个项目的目录结构。
├── build # 构建相关
├── mock # 项目mock 模拟数据
├── public # 静态资源
│ │── favicon.ico # favicon图标
│ └── index.html # html模板
├── src # 源代码
│ ├── api # 所有请求
│ ├── assets # 主题 字体等静态资源
│ ├── components # 全局公用组件
│ ├── icons # 项目所有 svg icons
│ ├── layout # 全局 layout
│ ├── router # 路由
│ ├── store # 全局 store管理
│ ├── styles # 全局样式
│ ├── utils # 全局公用方法
│ ├── vendor # 公用vendor
│ ├── views # views 所有页面
│ ├── App.vue # 入口页面
│ ├── main.js # 入口文件 加载组件 初始化等
│ └── permission.js # 权限管理
│ └── settings.js # 配置文件
├── tests # 测试
├── .env.xxx # 环境变量配置
├── .eslintrc.js # eslint 配置项
├── .babelrc # babel-loader 配置
├── .travis.yml # 自动化CI配置
├── vue.config.js # vue-cli 配置
├── postcss.config.js # postcss 配置
└── package.json # package.json
1.2项目的运行机制和代码注释
目标: 了解当前模板的基本运行机制和基础架构
眼花缭乱的目录和文件到底是怎么工作的? 我们进行一下最基本的讲解,帮助大家更好的去理解和开发
├── src # 源代码
│ ├── api # 所有请求
│ ├── assets # 主题 字体等静态资源
│ ├── components # 全局公用组件
│ ├── icons # 项目所有 svg icons
│ ├── layout # 全局 layout
│ ├── router # 路由
│ ├── store # 全局 store管理
│ ├── styles # 全局样式
│ ├── utils # 全局公用方法
│ ├── vendor # 公用vendor
│ ├── views # views 所有页面
│ ├── App.vue # 入口页面
│ ├── main.js # 入口文件 加载组件 初始化等
│ └── permission.js # 权限管理
│ └── settings.js # 配置文件
main.js
App.vue
permission.js
permission.js 是控制页面登录权限的文件, 此处的代码没有经历构建过程会很难理解, 所以先将此处的代码进行注释,等我们构建权限功能时,再从0到1进行构建。
settings.js
配置文件
Vuex结构
当前的Vuex结构采用了模块形式进行管理共享状态,其架构如下

其中app.js模块和settings.js模块,功能已经完备,不需要再进行修改。 user.js模块是我们后期需要重点开发的内容,所以这里我们将user.js里面的内容删除,并且导出一个默认配置
export default {namespaced: true,state: {},mutations: {},actions: {}}
同时,由于getters中引用了user中的状态,所以我们将getters中的状态改为
const getters = {sidebar: state => state.app.sidebar,device: state => state.app.device}export default getters
scss
该项目还使用了scss作为css的扩展语言,在
**styles**目录下,我们可以发现scss的相关文件,相关用法 我们下一小节 进行讲解
icons
2.Axios拦截器
**理解** 封装Axios拦截器,可以创建一个请求拦截器和响应拦截器,在请求的发送前和返回前做响应的操作。比如:可以在请求拦截器内载入Token ; 可以在响应拦截器内对数据进行结构(因为axios默认多包了一层data)**目标** 介绍API模块的单独请求和 request模块的封装
该项目采用了API的单独模块封装和axios拦截器的方式进行开发
axios的拦截器原理如下
axios拦截器
axios作为网络请求的第三方工具, 可以进行请求和响应的拦截
通过create创建了一个新的axios实例
// 创建了一个新的axios实例const service = axios.create({baseURL: process.env.VUE_APP_BASE_API, // url = base url + request url// withCredentials: true, // send cookies when cross-domain requeststimeout: 5000 // 超时时间})
请求拦截器
请求拦截器主要处理 token的**统一注入问题**
// axios的请求拦截器service.interceptors.request.use(config => {// do something before request is sentif (store.getters.token) {// let each request carry token// ['X-Token'] is a custom headers key// please modify it according to the actual situationconfig.headers['X-Token'] = getToken()}return config},error => {// do something with request errorconsole.log(error) // for debugreturn Promise.reject(error)})
响应拦截器
响应拦截器主要处理 返回的**数据异常** 和**数据结构**问题
// 响应拦截器service.interceptors.response.use(response => {const res = response.data// if the custom code is not 20000, it is judged as an error.if (res.code !== 20000) {Message({message: res.message || 'Error',type: 'error',duration: 5 * 1000})if (res.code === 50008 || res.code === 50012 || res.code === 50014) {// to re-loginMessageBox.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 debugMessage({message: error.message,type: 'error',duration: 5 * 1000})return Promise.reject(error)})
这里为了后续更清楚的书写代码,我们将原有代码注释掉,换成如下代码
// 导出一个axios的实例 而且这个实例要有请求拦截器 响应拦截器import axios from 'axios'const service = axios.create() // 创建一个axios的实例service.interceptors.request.use() // 请求拦截器service.interceptors.response.use() // 响应拦截器export default service // 导出axios实例
3.公共资源图片和统一样式
**目标** 将一些公共的图片和样式资源放入到 规定目录中
我们已经将整体的基础模块进行了简单的介绍,接下来,我们需要将该项目所用到的图片和样式进行统一的处理
图片资源
图片资源在课程资料的图片文件中,我们只需要将
**common**文件夹拷贝放置到**assets**目录即可
样式
样式资源在 资源/样式目录下
修改**variables.scss**
新增**common.scss**
我们在**variables.scss**添加了一些基础的变量值
我们提供了 一份公共的**common.scss**样式,里面内置了一部分内容的样式,在开发期间可以帮助我们快速的实现页面样式和布局
将两个文件放置到styles目录下,然后在**index.scss**中引入该样式
@import './common.scss'; //引入common.scss样式表
提交代码**本节注意**:注意在scss文件中,通过@import 引入其他样式文件,需要注意最后加分号,否则会报错**本节任务** 将公共资源的图片和样式放置到规定位置
【03-登录模块】
4.vue-element-admin里面对生产环境和开发环境的配置
**理解**: vue-element-admin项目中,将启动端口进行区分,分配在**.env.development**和**.env.production**两个文件里,并且将网页的 title 变量存储在不同的地方,**目标**: 设置统一的本地访问端口和网站title
在正式开发业务之前,先将项目的本地端口和网站名称进行一下调整
本地服务端口: 在**vue.config.js**中进行设置**vue.config.js** 就是vue项目相关的编译,配置,打包,启动服务相关的配置文件,它的核心在于webpack,但是又不同于webpack,相当于改良版的webpack, 文档地址
如图,是开发环境服务端口的位置

我们看到上面的 **process.env.port**实际上是一个nodejs服务下的环境变量,该变量在哪里设置呢?
在项目下, 我们发现了**.env.development**和**.env.production**两个文件
development => 开发环境
production => 生产环境
当我们运行npm run dev进行开发调试的时候,此时会加载执行**.env.development**文件内容
当我们运行npm run build:prod进行生产环境打包的时候,会加载执行**.env.production**文件内容
所以,如果想要设置开发环境的接口,直接在**.env.development**中写入对于port变量的赋值即可
# 设置端口号port = 8888
**本节注意**:修改服务的配置文件,想要生效的话,必须要重新启动服务,值‘8888’后面不能留有空格
网站名称
网站名称实际在configureWebpack选项中的name选项,通过阅读代码,我们会发现name实际上来源于src目录下**settings.js**文件
所以,我们可以将网站名称改成”**人力资源管理平台**“
**本节注意**:修改服务的配置文件,想要生效的话,必须要重新启动服务,值‘8888’后面不能留有空格
5.el-form登录校验的先决条件
6.request.js中环境变量和异常的处理
**理解**: 区分不同的开发环境下的请求地址,分别在 **.env.development**和**.env.production** 两个文件中定义了变量**VUE_APP_BASE_API**,作为axios请求的基本地址。在开发环境中,通过设置webpack的反向代理与服务器进行沟通。
为什么会有环境变量之分? 如图
从上图可以看出,开发环境实际上就是在自己的本地开发或者要求不那么高的环境,但是一旦进入生产,就是
**真实的数据**。 拿银行作比喻,如果你在开发环境拿生产环境的接口做测试,银行系统就会发生很大的风险。
前端主要区分两个环境,**开发环境**,**生产环境**
也就是两个环境发出的请求地址是不同的,用什么区分呢?
环境变量
$ process.env.NODE_ENV # 当为production时为生产环境 为development时为开发环境
环境文件
我们可以在**.env.development**和**.env.production**定义变量,变量自动就为当前环境的值
基础模板在以上文件定义了变量**VUE_APP_BASE_API**,该变量可以作为axios请求的**baseURL**
我们会发现,在模板中,两个值分别为**/dev-api**和**/prod-api**
但是我们的开发环境代理是**/api**,所以可以统一下
# 开发环境的基础地址和代理对应VUE_APP_BASE_API = '/api'
# 这里配置了/api,意味着需要在Nginx服务器上为该服务配置 nginx的反向代理对应/prod-api的地址VUE_APP_BASE_API = '/prod-api'
**本节注意**:我们这里生产环境和开发环境设置了不同的值,后续我们还会在生产环境部署的时候,去配置该值所对应的反向代理,反向代理指向哪个地址,完全由我们自己决定,不会和开发环境冲突
在request中设置baseUrl
const service = axios.create({// 如果执行 npm run dev 值为 /api 正确 /api 这个代理只是给开发环境配置的代理// 如果执行 npm run build 值为 /prod-api 没关系 运维应该在上线的时候 给你配置上 /prod-api的代理baseURL: process.env.VUE_APP_BASE_API, // 设置axios请求的基础的基础地址timeout: 5000 // 定义5秒超时}) // 创建一个axios的实例
【04-主页模块】
7.主页的token拦截处理
理解:后台系统中,对是否token权限的一个参考流程
目标:根据token处理主页的访问权限问题
权限拦截的流程图
我们已经完成了登录的过程,并且存储了token,但是此时主页并没有因为token的有无而被控制访问权限
接下来我们需要实现以下如下的流程图
在基础框架阶段,我们已经知道
**src/permission.js**是专门处理路由权限的,所以我们在这里处理
流程图转化代码
流程图转化的代码
// 权限拦截 导航守卫 路由守卫 routerimport router from '@/router' // 引入路由实例import store from '@/store' // 引入vuex store实例import NProgress from 'nprogress' // 引入一份进度条插件import 'nprogress/nprogress.css' // 引入进度条样式const whiteList = ['/login', '/404'] // 定义白名单 所有不受权限控制的页面// 路由的前置守卫router.beforeEach(function(to, from, next) {NProgress.start() // 开启进度条// 首先判断有无tokenif (store.getters.token) {// 如果有token 继续判断是不是去登录页if (to.path === '/login') {// 表示去的是登录页next('/') // 跳到主页} else {next() // 直接放行}} else {// 如果没有tokenif (whiteList.indexOf(to.path) > -1) {// 如果找到了 表示在在名单里面next()} else {next('/login') // 跳到登录页}}NProgress.done() // 手动强制关闭一次 为了解决 手动切换地址时 进度条的不关闭的问题})// 后置守卫router.afterEach(function() {NProgress.done() // 关闭进度条})
在导航守卫的位置,我们添加了NProgress的插件,可以完成进入时的进度条效果
**本节任务**:完成主页中根据有无token,进行页面访问的处理
8.获取用户资料接口和token注入
理解:在axios的请求拦截器中注入token**目标** 封装获取用户资料的资料信息
上小节中,我们完成了头部菜单的基本布局,但是用户的头像和名称并没有,我们需要通过接口调用的方式获取当前用户的资料信息
获取用户资料接口
在**src/api/user.js**中封装获取用户资料的方法
/*** 获取用户的基本资料** **/export function getUserInfo() {return request({url: '/sys/profile',method: 'post'})}
我们忽略了一个问题!我们的headers参数并没有在这里传入,为什么呢
headers中的Authorization相当于我们开门(调用接口)时**钥匙(token)**,我们在打开任何带安全权限的门的时候都需要**钥匙(token)** 如图
每次在接口中携带**钥匙(token)**很麻烦,所以我们可以在axios拦截器中统一注入token
统一注入token **src/utils/request.js**
service.interceptors.request.use(config => {// 在这个位置需要统一的去注入tokenif (store.getters.token) {// 如果token存在 注入tokenconfig.headers['Authorization'] = `Bearer ${store.getters.token}`}return config // 必须返回配置}, error => {return Promise.reject(error)})
9.权限拦截处调用获取资料action
**目标**在权限拦截处调用aciton
权限拦截器调用action
在上小节中,我们完成了用户资料的整个流程,那么这个action在哪里调用呢?
用户资料有个硬性要求,**必须有token**才可以获取,那么我们就可以在确定有token的位置去获取用户资料
由上图可以看出,一旦确定我们进行了放行,就可以获取用户资料

调用action **src/permission.js**
if(!store.state.user.userInfo.userId) {await store.dispatch('user/getUserInfo')}
如果我们觉得获取用户id的方式写了太多层级,可以在vuex中的getters中设置一个映射 **src/store/getters.js**
userId: state => state.user.userInfo.userId // 建立用户id的映射
代码就变成了
if (!store.getters.userId) {// 如果没有id这个值 才会调用 vuex的获取资料的actionawait store.dispatch('user/getUserInfo')// 为什么要写await 因为我们想获取完资料再去放行}
此时,我们可以通过dev-tools工具在控制台清楚的看到数据已经获取
最后一步,只需要将头部菜单中的名称换成真实的用户名即可
10.自定义指令-解决异常图片情况
**目标**: 通过自定义指令的形式解决异常图片的处理
注册自定义指令
Vue.directive('指令名称', {// 会在当前指令作用的dom元素 插入之后执行// options 里面是指令的表达式inserted: function (dom,options) {}})
自定义指令可以采用统一的文件来管理
**src/directives/index.js**,这个文件负责管理所有的自定义指令
首先定义第一个自定义指令 **v-imagerror**
export const imagerror = {// 指令对象 会在当前的dom元素插入到节点之后执行inserted(dom, options) {// options是 指令中的变量的解释 其中有一个属性叫做 value// dom 表示当前指令作用的dom对象// dom认为此时就是图片// 当图片有地址 但是地址没有加载成功的时候 会报错 会触发图片的一个事件 => onerrordom.onerror = function() {// 当图片出现异常的时候 会将指令配置的默认图片设置为该图片的内容// dom可以注册error事件dom.src = options.value // 这里不能写死}}}
在main.js完成自定义指令全局注册
然后,在**main.js**中完成对于该文件中所有指令的全局注册
import * as directives from '@/directives'// 注册自定义指令// 遍历所有的导出的指令对象 完成自定义全局注册Object.keys(directives).forEach(key => {// 注册自定义指令Vue.directive(key, directives[key])})
针对上面的引入语法 **import * as 变量** 得到的是一个对象**{ 变量1:对象1,变量2: 对象2 ... }**, 所以可以采用对象遍历的方法进行处理
指令注册成功,可以在**navbar.vue**中直接使用了
<img v-imageerror="defaultImg" :src="staffPhoto" class="user-avatar">
data() {return {defaultImg: require('@/assets/common/head.jpg')}},
**本节任务**:实现一个自定义指令,解决图片加载异常的问题
12.Token失效的主动介入
理解:在设置登录的时候,当登录成功获取到token => 设置token的同时 存储一个当前的时间戳。 当请求时,若当前存在token => 则判断当前的时间戳是否超时 => 若超时 就删除用户信息和token,然后退出到登录页。**目标**: 处理当token失效时业务
主动介入token处理的业务逻辑
开门的钥匙不是一直有效的,如果一直有效,会有安全风险,所以我们尝试在客户端进行一下token的时间检查
流程图转化代码
流程图转化代码 **src/utils/auth.js**
const timeKey = 'hrsaas-timestamp-key' // 设置一个独一无二的key// 获取时间戳export function getTimeStamp() {return Cookies.get(timeKey)}// 设置时间戳export function setTimeStamp() {Cookies.set(timeKey, Date.now())}
**src/utils/request.js**
import axios from 'axios'import store from '@/store'import router from '@/router'import { Message } from 'element-ui'import { getTimeStamp } from '@/utils/auth'const TimeOut = 3600 // 定义超时时间const service = axios.create({// 当执行 npm run dev => .evn.development => /api => 跨域代理baseURL: process.env.VUE_APP_BASE_API, // npm run dev => /api npm run build => /prod-apitimeout: 5000 // 设置超时时间})// 请求拦截器service.interceptors.request.use(config => {// config 是请求的配置信息// 注入tokenif (store.getters.token) {// 只有在有token的情况下 才有必要去检查时间戳是否超时if (IsCheckTimeOut()) {// 如果它为true表示 过期了// token没用了 因为超时了store.dispatch('user/logout') // 登出操作// 跳转到登录页router.push('/login')return Promise.reject(new Error('token超时了'))}config.headers['Authorization'] = `Bearer ${store.getters.token}`}return config // 必须要返回的}, error => {return Promise.reject(error)})// 响应拦截器service.interceptors.response.use(response => {// axios默认加了一层dataconst { success, message, data } = response.data// 要根据success的成功与否决定下面的操作if (success) {return data} else {// 业务已经错误了 还能进then ? 不能 ! 应该进catchMessage.error(message) // 提示错误消息return Promise.reject(new Error(message))}}, error => {Message.error(error.message) // 提示错误信息return Promise.reject(error) // 返回执行错误 让当前的执行链跳出成功 直接进入 catch})// 是否超时// 超时逻辑 (当前时间 - 缓存中的时间) 是否大于 时间差function IsCheckTimeOut() {var currentTime = Date.now() // 当前时间戳var timeStamp = getTimeStamp() // 缓存时间戳return (currentTime - timeStamp) / 1000 > TimeOut}export default service
**本节注意**:我们在调用登录接口的时候 一定是没有token的,所以token检查不会影响登录接口的调用
同理,在登录的时候,如果登录成功,我们应该设置时间戳
// 定义login action 也需要参数 调用action时 传递过来的参数// async 标记的函数其实就是一个异步函数 -> 本质是还是 一个promiseasync login(context, data) {// 经过响应拦截器的处理之后 这里的result实际上就是 tokenconst result = await login(data) // 实际上就是一个promise result就是执行的结果// axios默认给数据加了一层data// 表示登录接口调用成功 也就是意味着你的用户名和密码是正确的// 现在有用户token// actions 修改state 必须通过mutationscontext.commit('setToken', result)// 写入时间戳setTimeStamp() // 将当前的最新时间写入缓存}
提交代码
有主动处理就有被动处理,也就是后端告诉我们超时了,我们被迫做出反应,如果后端接口没有做处理,主动介入就是一种简单的方式
**本节任务**:完成token超时的主动介入
13.Token失效的被动处理
理解:当token失效的时候,在服务器返回的报文中,会返回状态码:10002 (token过时),被动处理就是在服务器返回失败的状态码之后,在响应拦截器中判断状态码,如果状态为10002则进行被动的登出操作,作为主动处理的备用处理,防止主动处理出错。**目标**: 实现token失效的被动处理
除了token的主动介入之外,我们还可以对token进行被动的处理,如图

token超时的错误码是**10002**
代码实现 **src/utils/request.js**
error => {// error 信息 里面 response的对象if (error.response && error.response.data && error.response.data.code === 10002) {// 当等于10002的时候 表示 后端告诉我token超时了store.dispatch('user/logout') // 登出action 删除tokenrouter.push('/login')} else {Message.error(error.message) // 提示错误信息}return Promise.reject(error)}
无论是主动介入还是被动处理,这些操作都是为了更好地处理token,减少错误异常的可能性
**本节任务** Token失效的被动处理
【其它补充】
[06-组织架构]_新增部门的规则校验
表单内容
部门名称(name):必填 1-50个字符 / 同级部门中禁止出现重复部门
部门编码(code):必填 1-50个字符 / 部门编码在整个模块中都不允许重复
部门负责人(manager):必填
部门介绍 ( introduce):必填 1-300个字符
定义数据结构
formData: {name: '', // 部门名称code: '', // 部门编码manager: '', // 部门管理者introduce: '' // 部门介绍},
完成表单校验需要的前置条件
- el-form配置model和rules属性
- el-form-item配置prop属性
- 表单进行v-model双向绑定
配置表单的基本校验规则
data() {return {// 定义表单数据formData: {name: '', // 部门名称code: '', // 部门编码manager: '', // 部门管理者introduce: '' // 部门介绍},// 定义校验规则rules: {name: [{ required: true, message: '部门名称不能为空', trigger: 'blur' },{ min: 1, max: 50, message: '部门名称要求1-50个字符', trigger: 'blur' }],code: [{ required: true, message: '部门编码不能为空', trigger: 'blur' },{ min: 1, max: 50, message: '部门编码要求1-50个字符', trigger: 'blur' }],manager: [{ required: true, message: '部门负责人不能为空', trigger: 'blur' }],introduce: [{ required: true, message: '部门介绍不能为空', trigger: 'blur' },{ trigger: 'blur', min: 1, max: 300, message: '部门介绍要求1-50个字符' }]}}}
部门名称和部门编码的自定义校验
**注意**:部门名称和部门编码的规则 有两条我们需要通过**自定义校验函数validator**来实现
首先,在校验名称和编码时,要获取最新的组织架构,这也是我们这里trigger采用blur的原因,因为change对于访问的频率过高,我们需要控制访问频率
// 首先获取最新的组织架构数据const { depts } = await getDepartments()
部门名称不能和
**同级别**的重复,这里注意,我们需要找到所有同级别的数据,进行校验,所以还需要另一个参数pid
props: {// 用来控制窗体是否显示或者隐藏showDialog: {type: Boolean,default: false},// 当前操作的节点treeNode: {type: Object,default: null}},<add-dept :show-dialog="showDialog" :tree-node="node" />
根据当前部门id,找到所有子部门相关的数据,判断是否重复
// 现在定义一个函数 这个函数的目的是 去找 同级部门下 是否有重复的部门名称const checkNameRepeat = async(rule, value, callback) => {// 先要获取最新的组织架构数据const { depts } = await getDepartments()// depts是所有的部门数据// 如何去找技术部所有的子节点const isRepeat = depts.filter(item => item.pid === this.treeNode.id).some(item => item.name === value)isRepeat ? callback(new Error(`同级部门下已经有${value}的部门了`)) : callback()}
检查部门编码的过程同理
// 检查编码重复const checkCodeRepeat = async(rule, value, callback) => {// 先要获取最新的组织架构数据const { depts } = await getDepartments()const isRepeat = depts.some(item => item.code === value && value) // 这里加一个 value不为空 因为我们的部门有可能没有codeisRepeat ? callback(new Error(`组织架构中已经有部门使用${value}编码`)) : callback()}
在规则中定义
// 定义校验规则rules: {name: [{ required: true, message: '部门名称不能为空', trigger: 'blur' },{ min: 1, max: 50, message: '部门名称要求1-50个字符', trigger: 'blur' }, {trigger: 'blur',validator: checkNameRepeat // 自定义函数的形式校验}],code: [{ required: true, message: '部门编码不能为空', trigger: 'blur' },{ min: 1, max: 50, message: '部门编码要求1-50个字符', trigger: 'blur' }, {trigger: 'blur',validator: checkCodeRepeat}],manager: [{ required: true, message: '部门负责人不能为空', trigger: 'blur' }],introduce: [{ required: true, message: '部门介绍不能为空', trigger: 'blur' },{ trigger: 'blur', min: 1, max: 300, message: '部门介绍要求1-50个字符' }]}
[08-员工管理]_员工资料excel的导入导出
目的:根据员工的excel资料批量导入员工,或批量导出员工数据
Excel 的导入导出都是依赖于js-xlsx来实现的。
在 js-xlsx的基础上又封装了Export2Excel.js来方便导出数据。
[09-权限设计与管理]_前端实现动态权限
权限受控的主体思路
在上面的几个小节中,我们已经把给用户分配了角色, 给角色分配了权限,那么在用户登录获取资料的时候,会自动查出该用户拥有哪些权限,这个权限需要和我们的菜单还有路由有效结合起来
在权限管理页面中,我们设置了一个标识, 这个标识可以和我们的路由模块进行关联,也就是说,如果用户拥有这个标识,那么用户就可以拥有这个路由模块,如果没有这个标识,就不能访问路由模块,这样子我们只要根据后端返回的标识进行查询,动态的对路由进行添加就可以实现 动态路由
用什么来实现?
vue-router提供了一个叫做addRoutes的API方法,这个方法的含义是动态添加路由规则,思路如下
新建Vuex中管理权限的模块
在主页模块章节中,我们将用户的资料设置到vuex中,其中便有权限数据,我们可以就此进行操作
我们可以在vuex中新增一个permission模块**src/store/modules/permission.js**
// vuex的权限模块import { constantRoutes } from '@/router'// vuex中的permission模块用来存放当前的 静态路由 + 当前用户的 权限路由const state = {routes: constantRoutes // 所有人默认拥有静态路由}const mutations = {// newRoutes可以认为是 用户登录 通过权限所得到的动态路由的部分setRoutes(state, newRoutes) {// 下面这么写不对 不是语法不对 是业务不对// state.routes = [...state.routes, ...newRoutes]// 有一种情况 张三 登录 获取了动态路由 追加到路由上 李四登录 4个动态路由// 应该是每次更新 都应该在静态路由的基础上进行追加state.routes = [...constantRoutes, ...newRoutes]}}const actions = {}export default {namespaced: true,state,mutations,actions}
在Vuex管理模块中引入permisson模块
import permission from './modules/permission'const store = new Vuex.Store({modules: {// 子模块 $store.state.app.// mapGetters([])app,settings,user,permission},getters})
Vuex筛选权限路由
OK, 那么我们在哪将用户的标识和权限进行关联呢 ?

我们可以在这张图中,进一步的进行操作
访问权限的数据在用户属性**menus**中,**menus**中的标识该怎么和路由对应呢?
可以将路由模块的根节点
**name**属性命名和权限标识一致,这样只要标识能对上,就说明用户拥有了该权限
这一步,在我们命名路由的时候已经操作过了
接下来, vuex的permission中提供一个action,进行关联
import { asyncRoutes, constantRoutes } from '@/router'const actions = {// 筛选权限路由// 第二个参数为当前用户的所拥有的菜单权限// menus: ["settings","permissions"]// asyncRoutes是所有的动态路由// asyncRoutes [{path: 'setting',name: 'setting'},{}]filterRoutes(context, menus) {const routes = []// 筛选出 动态路由中和menus中能够对上的路由menus.forEach(key => {// key就是标识// asyncRoutes 找 有没有对象中的name属性等于 key的 如果找不到就没权限 如果找到了 要筛选出来routes.push(...asyncRoutes.filter(item => item.name === key)) // 得到一个数组 有可能 有元素 也有可能是空数组})// 得到的routes是所有模块中满足权限要求的路由数组// routes就是当前用户所拥有的 动态路由的权限context.commit('setRoutes', routes) // 将动态路由提交给mutationsreturn routes // 这里为什么还要return state数据 是用来 显示左侧菜单用的 return 是给路由addRoutes用的}
权限拦截出调用筛选权限Action
在拦截的位置,调用关联action, 获取新增routes,并且addRoutes
// 权限拦截在路由跳转 导航守卫import router from '@/router'import store from '@/store' // 引入store实例 和组件中的this.$store是一回事import nprogress from 'nprogress' // 引入进度条import 'nprogress/nprogress.css' // 引入进度条样式// 不需要导出 因为只需要让代码执行即可// 前置守卫// next是前置守卫必须必须必须执行的钩子 next必须执行 如果不执行 页面就死了// next() 放过// next(false) 跳转终止// next(地址) 跳转到某个地址const whiteList = ['/login', '/404'] // 定义白名单router.beforeEach(async(to, from, next) => {nprogress.start() // 开启进度条的意思if (store.getters.token) {// 只有有token的情况下 才能获取资料// 如果有tokenif (to.path === '/login') {// 如果要访问的是 登录页next('/') // 跳到主页 // 有token 用处理吗?不用} else {// 只有放过的时候才去获取用户资料// 是每次都获取吗// 如果当前vuex中有用户的资料的id 表示 已经有资料了 不需要获取了 如果没有id才需要获取if (!store.getters.userId) {// 如果没有id才表示当前用户资料没有获取过// async 函数所return的内容 用 await就可以接收到const { roles } = await store.dispatch('user/getUserInfo')// 如果说后续 需要根据用户资料获取数据的话 这里必须改成 同步// 筛选用户的可用路由// actions中函数 默认是Promise对象 调用这个对象 想要获取返回的值话 必须 加 await或者是then// actions是做异步操作的const routes = await store.dispatch('permission/filterRoutes', roles.menus)// routes就是筛选得到的动态路由// 动态路由 添加到 路由表中 默认的路由表 只有静态路由 没有动态路由// addRoutes 必须 用 next(地址) 不能用next()router.addRoutes(routes) // 添加动态路由到路由表 铺路// 添加完动态路由之后next(to.path) // 相当于跳到对应的地址 相当于多做一次跳转 为什么要多做一次跳转// 进门了,但是进门之后我要去的地方的路还没有铺好,直接走,掉坑里,多做一次跳转,再从门外往里进一次,跳转之前 把路铺好,再次进来的时候,路就铺好了} else {next()}}} else {// 没有token的情况下if (whiteList.indexOf(to.path) > -1) {// 表示要去的地址在白名单next()} else {next('/login')}}nprogress.done() // 解决手动切换地址时 进度条不关闭的问题})// 后置守卫router.afterEach(() => {nprogress.done() // 关闭进度条})
静态路由动态路由解除合并
注意: 这里有个非常容易出问题的位置,当我们判断用户是否已经添加路由的前后,不能都是用next(),
在添加路由之后应该使用 next(to.path), 否则会使刷新页面之后 权限消失,这属于一个vue-router的已知缺陷
同时,不要忘记,我们将原来的静态路由 + 动态路由合体的模式 改成 只有静态路由 **src/router/index.js**
此时,我们已经完成了权限设置的一半, 此时我们发现左侧菜单失去了内容,这是因为左侧菜单读取的是固定的路由,我们要把它换成实时的最新路由
在**src/store/getters.js**配置导出routes
const getters = {sidebar: state => state.app.sidebar,device: state => state.app.device,token: state => state.user.token,name: state => state.user.userInfo.username, // 建立用户名称的映射userId: state => state.user.userInfo.userId, // 建立用户id的映射companyId: state => state.user.userInfo.companyId, // 建立用户的公司Id映射routes: state => state.permission.routes // 导出当前的路由}export default getters
在左侧菜单组件中, 引入routes
computed: {...mapGetters(['sidebar', 'routes']),
OK,到现在为止,我们已经可以实现不同用户登录的时候,菜单是动态的了
[12]_全屏插件
目标:实现页面的全屏功能
全屏功能可以借助一个插件来实现
第一步,安装全局插件screenfull
$ npm i screenfull
第二步,封装全屏显示的插件·· **src/components/ScreenFull/index.vue**
<template><!-- 放置一个图标 --><div><!-- 放置一个svg的图标 --><svg-icon icon-class="fullscreen" style="color:#fff; width: 20px; height: 20px" @click="changeScreen" /></div></template><script>import ScreenFull from 'screenfull'export default {methods: {// 改变全屏changeScreen() {if (!ScreenFull.isEnabled) {// 此时全屏不可用this.$message.warning('此时全屏组件不可用')return}// document.documentElement.requestFullscreen() 原生js调用// 如果可用 就可以全屏ScreenFull.toggle()}}}</script><style></style>
然后将组件引入即可使用
[12]_多语言 vue-I18n初体验
初始化多语言包
本项目使用国际化 i18n 方案。通过 vue-i18n而实现。
第一步,我们需要首先国际化的包
$ npm i vue-i18n
第二步,需要单独一个多语言的实例化文件 **src/lang/index.js**
import Vue from 'vue' // 引入Vueimport VueI18n from 'vue-i18n' // 引入国际化的包import Cookie from 'js-cookie' // 引入cookie包import elementEN from 'element-ui/lib/locale/lang/en' // 引入饿了么的英文包import elementZH from 'element-ui/lib/locale/lang/zh-CN' // 引入饿了么的中文包Vue.use(VueI18n) // 全局注册国际化包export default new VueI18n({locale: Cookie.get('language') || 'zh', // 从cookie中获取语言类型 获取不到就是中文messages: {en: {...elementEN // 将饿了么的英文语言包引入},zh: {...elementZH // 将饿了么的中文语言包引入}}})
上面的代码的作用是将Element的两种语言导入了
第三步,在main.js中对挂载 i18n的插件,并设置element为当前的语言
// 设置element为当前的语言Vue.use(ElementUI, {i18n: (key, value) => i18n.t(key, value)})new Vue({el: '#app',router,store,i18n,render: h => h(App)})
引入自定义语言包
此时,element已经变成了zh,也就是中文,但是我们常规的内容怎么根据当前语言类型显示?
这里,针对英文和中文,我们可以提供两个不同的语言包 **src/lang/zh.js , src/lang/en.js**
该语言包,我们已经在资源中提供
第四步,在index.js中同样引入该语言包
import customZH from './zh' // 引入自定义中文包import customEN from './en' // 引入自定义英文包Vue.use(VueI18n) // 全局注册国际化包export default new VueI18n({locale: Cookie.get('language') || 'zh', // 从cookie中获取语言类型 获取不到就是中文messages: {en: {...elementEN, // 将饿了么的英文语言包引入...customEN},zh: {...elementZH, // 将饿了么的中文语言包引入...customZH}}})
在左侧菜单中应用多语言包
自定义语言包的内容怎么使用?
第五步,在左侧菜单应用
当我们全局注册i18n的时候,每个组件都会拥有一个**$t**的方法,它会根据传入的key,自动的去寻找当前语言的文本,我们可以将左侧菜单变成多语言展示文本**layout/components/SidebarItem.vue**
<item :icon="onlyOneChild.meta.icon||(item.meta&&item.meta.icon)" :title="$t('route.'+onlyOneChild.name)" />
注意:当文本的值为嵌套时,可以通过**$t(key1.key2.key3...)**的方式获取
现在,我们已经完成了多语言的接入,现在封装切换多语言的组件
封装多语言插件
第六步,封装多语言组件 **src/components/lang/index.vue**
<template><el-dropdown trigger="click" @command="changeLanguage"><!-- 这里必须加一个div --><div><svg-icon style="color:#fff;font-size:20px" icon-class="language" /></div><el-dropdown-menu slot="dropdown"><el-dropdown-item command="zh" :disabled="'zh'=== $i18n.locale ">中文</el-dropdown-item><el-dropdown-item command="en" :disabled="'en'=== $i18n.locale ">en</el-dropdown-item></el-dropdown-menu></el-dropdown></template><script>import Cookie from 'js-cookie'export default {methods: {changeLanguage(lang) {Cookie.set('language', lang) // 切换多语言this.$i18n.locale = lang // 设置给本地的i18n插件this.$message.success('切换多语言成功')}}}</script>
第七步,在Navbar组件中引入
<!-- 放置切换多语言 --><lang class="right-menu-item" /><!-- 放置主题 --><theme-picker class="right-menu-item" /><!-- 放置全屏插件 --><screen-full class="right-menu-item" />
[13-打包上线]_打包之前的路由模式
**目标**配置打包之前的路由模式
在SPA单页应用中,有两种路由模式
hash模式 : #后面是路由路径,特点是前端访问,#后面的变化不会经过服务器
history模式:正常的/访问模式,特点是后端访问,任意地址的变化都会访问服务器
开发到现在,我们一直都在用hash模式,打包我们尝试用history模式
改成history模式非常简单,只需要将路由的mode类型改成history即可
const createRouter = () => new Router({mode: 'history', // require service supportscrollBehavior: () => ({ y: 0 }), // 管理滚动行为 如果出现滚动 切换就让 让页面回到顶部routes: [...constantRoutes] // 改成只有静态路由})
假设我们的地址是这样的
**www.xxxx/com/hr**/a**www.xxxx/com/hr**/b
我们会发现,其实域名是**www.xxxx/com**,hr是特定的前缀地址,此时我们可以配置一个base属性,配置为hr
const createRouter = () => new Router({mode: 'history', // require service supportbase: '/hr/', // 配置项目的基础地址scrollBehavior: () => ({ y: 0 }), // 管理滚动行为 如果出现滚动 切换就让 让页面回到顶部routes: [...constantRoutes] // 改成只有静态路由})
此时,我们会发现地址已经变成我们想要的样子了
[13-打包上线]_性能分析和CDN的应用(性能优化)
**分析**:先进行打包的性能分析,系统分析占用空间较大的几个模块,然后利用webpack的排除打包将几个大模块的包排除,然后利用webpack的CDN文件配置,配置对应的CDN资源。**目标**: 对开发的应用进行性能分析和CDN的应用
性能分析
我们集成了 功能,写了很多组件,最终都会打包成一堆文件,那么真实运行的性能如何呢?
我们可以使用vue-cli本身提供的性能分析工具,对我们开发的所有功能进行打包分析
它的应用非常简单
$ npm run preview -- --report
这个命令会从我们的**入口main.js**进行依赖分析,分析出最大的包,方便我们进行观察和优化
执行完这个命令,我们会看到如下的页面
如图所以,方块越大,说明该文件占用的文件越大,文件越大,对于网络带宽和访问速度的要求就越高,这也就是我们优化的方向
像这种情况,我们怎么优化一下呢
webpack排除打包
CDN是一个比较好的方式
文件不是大吗?我们就不要把这些大的文件和那些小的文件打包到一起了,像这种xlsx,element这种功能性很全的插件,我们可以放到CDN服务器上,一来,减轻整体包的大小,二来CDN的加速服务可以加快我们对于插件的访问速度
使用方式
先找到 vue.config.js, 添加 externals 让 webpack 不打包 xlsx 和 element**vue.config.js**
// 排除 elementUI xlsx 和 vueexternals:{'vue': 'Vue','element-ui': 'ELEMENT','xlsx': 'XLSX'}
CDN文件配置
但是,没有被打包的几个模块怎么处理?
可以采用CDN的方式,在页面模板中预先引入**vue.config.js**
const cdn = {css: [// element-ui css'https://unpkg.com/element-ui/lib/theme-chalk/index.css' // 样式表],js: [// vue must at first!'https://unpkg.com/vue/dist/vue.js', // vuejs// element-ui js'https://unpkg.com/element-ui/lib/index.js', // elementUI'https://cdn.jsdelivr.net/npm/xlsx@0.16.6/dist/jszip.min.js','https://cdn.jsdelivr.net/npm/xlsx@0.16.6/dist/xlsx.full.min.js']}
但是请注意,这时的配置实际上是对开发环境和生产环境都生效的,在开发环境时,没有必要使用CDN,此时我们可以使用环境变量来进行区分
let cdn = { css: [], js: [] }// 通过环境变量 来区分是否使用cdnconst isProd = process.env.NODE_ENV === 'production' // 判断是否是生产环境let externals = {}if (isProd) {// 如果是生产环境 就排除打包 否则不排除externals = {// key(包名) / value(这个值 是 需要在CDN中获取js, 相当于 获取的js中 的该包的全局的对象的名字)'vue': 'Vue', // 后面的名字不能随便起 应该是 js中的全局对象名'element-ui': 'ELEMENT', // 都是js中全局定义的'xlsx': 'XLSX' // 都是js中全局定义的}cdn = {css: ['https://unpkg.com/element-ui/lib/theme-chalk/index.css' // 提前引入elementUI样式], // 放置css文件目录js: ['https://unpkg.com/vue/dist/vue.js', // vuejs'https://unpkg.com/element-ui/lib/index.js', // element'https://cdn.jsdelivr.net/npm/xlsx@0.16.6/dist/xlsx.full.min.js', // xlsx 相关'https://cdn.jsdelivr.net/npm/xlsx@0.16.6/dist/jszip.min.js' // xlsx 相关] // 放置js文件目录}}
注入CDN文件到模板
之后通过 html-webpack-plugin注入到 index.html之中:
config.plugin('html').tap(args => {args[0].cdn = cdnreturn args})
找到 public/index.html。通过你配置的CDN Config 依次注入 css 和 js。
<head><!-- 引入样式 --><% for(var css of htmlWebpackPlugin.options.cdn.css) { %><link rel="stylesheet" href="<%=css%>"><% } %></head><!-- 引入JS --><% for(var js of htmlWebpackPlugin.options.cdn.js) { %><script src="<%=js%>"></script><% } %>
最后,进行打包
$ npm run build:prod
[13-打包上线]-在node环境中应用并代理跨域
**目标**将打包好的代码打包上线,并在nodejs中代理跨域
使用koa框架部署项目
到现在为止,我们已经完成了一个前端工程师的开发流程,按照常规的做法,此时,运维会将我们的代码部署到阿里云的ngix服务上,对于我们而言,我们可以将其部署到本机的nodejs环境中
部署 自动化部署 /手动部署
第一步,建立web服务文件夹 **hrServer**
$ mkdir hrServer #建立hrServer文件夹
第二步,在该文件夹下,初始化npm
$ npm init -y
第三步,安装服务端框架koa(也可以采用express或者egg)
$ npm i koa koa-static
第四步,拷贝上小节打包的dist目录到**hrServer/public**下
第五步,在根目录下创建app.js,代码如下
const Koa = require('koa')const serve = require('koa-static');const app = new Koa();app.use(serve(__dirname + "/public")); //将public下的代码静态化app.listen(3333, () => {console.log('人资项目启动')})
此时,我们可以访问,http://localhost:3333
解决history页面访问问题
但是,此时存在两个问题,
- 当我们刷新页面,发现404
这是因为我们采用了history的模式,地址的变化会引起服务器的刷新,我们只需要在app.js对所有的地址进行一下处理即可
安装 koa中间件
$ npm i koa2-connect-history-api-fallback #专门处理history模式的中间件
注册中间件
const Koa = require('koa')const serve = require('koa-static');const { historyApiFallback } = require('koa2-connect-history-api-fallback');const path = require('path')const app = new Koa();// 这句话 的意思是除接口之外所有的请求都发送给了 index.htmlapp.use(historyApiFallback({whiteList: ['/prod-api']})); // 这里的whiteList是 白名单的意思app.use(serve(__dirname + "/public")); //将public下的代码静态化app.listen(3333, () => {console.log('人资项目启动')})
解决生产环境跨域问题
- 当点击登录时,发现接口404
前面我们讲过,vue-cli的代理只存在于开发期,当我们上线到node环境或者ngix环境时,需要我们再次在环境中代理
在nodejs中代理
安装跨域代理中间件
$ npm i koa2-proxy-middleware
配置跨越代理
const proxy = require('koa2-proxy-middleware')app.use(proxy({targets: {// (.*) means anything'/prod-api/(.*)': {target: 'http://ihrm-java.itheima.net/api', //后端服务器地址changeOrigin: true,pathRewrite: {'/prod-api': ""}}}}))
注意:这里之所以用了pathRewrite,是因为生产环境的请求基础地址是 /prod-api,需要将该地址去掉
此时,我们的项目就可以跨域访问了!
