一、vue-admin-template

1.项目简介

官网:https://github.com/PanJiaChen

vue-element-admin是含有丰富的组件,vue-admin-template是一个基础的单页面应用的框架,适合在vue-admin-template上二次开发,开发需要的组件就可以直接的从vue-element-admin里面拷贝上去。适合于后台管理的中小型项目,内部的架构非常的完善,简单易上手。

1.2下载项目

vue-element-admin
官网

vue-admin-template
官网

img

2.目录结构

本项目已经为你生成了一个基本的开发框架,提供了涵盖中后台开发的各类功能和坑位,下面是整个项目的目录结构。

├── 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

3.解决页面刷新vuex值消失

3.1安装vuex-persistedstate
npm install vuex-persistedstate
3.2在store目录下index引入
import createPersistedState from 'vuex-persistedstate'
3.3配置vuex-persistedstate
// 创建PERSIST_PATHS变量 存储要持久化的模块
const PERSIST_PATHS = ['user']
const store = new Vuex.Store({
  state: {},
  modules: {
    app,
    settings,
    user,
    permission,
    tagsView
  },
  getters,
  // 新增规则保存vuex的值
  plugins: [createPersistedState({
    storage: window.sessionStorage,
    // 在此使用
    paths: PERSIST_PATHS
  })]
})

4.建立远程Git仓库完成初始提交

目标码云或者github上建立相应的远程仓库,并将代码分支提交

建立远程仓库

远程仓库建立只需要在网站上直接操作即可

本地项目提交

注意: 由于我们之前的项目是直接从 vue-element-admin *_克隆*而来,里面拥有原来的提交记录,为了避免冲突, 先将原来的_.git**文件夹删除掉

并且对项目进行git初始化

$ git init  #初始化项目
$  git add . #将修改添加到暂存
$  git commit -m '人资项目初始化' #将暂存提到本地仓库

查看版本日志

$ git log #查看版本日志

推送到远程仓库

推送到远程仓库一般先将**远程仓库地址**用本地仓库别名代替

$ git remote add origin <远程仓库地址>  #添加远程仓库地址

当我们不清楚自己的仓库对应的origin地址时, 我们可以通过命令查看当前的远程仓库地址

$ git remote -v #查看本地仓库的远程仓库地址映射

推送master分支到远程仓库

$ git push -u origin master #将master分支推送到origin所代表的远程仓库地址

5.API模块和请求封装模块介绍

目标 介绍API模块的单独请求和 request模块的封装

5.1Axios的拦截器介绍

该项目采用了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 requests  
	timeout: 5000 // 超时时间})

请求拦截器

请求拦截器主要处理 token的**统一注入问题**

// axios的请求拦截器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)  
})

响应拦截器

响应拦截器主要处理 返回的**数据异常** 和**数据结构**问题

// 响应拦截器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-login        
	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)  
})

这里为了后续更清楚的书写代码,我们将原有代码注释掉,换成如下代码

// 导出一个axios的实例  而且这个实例要有请求拦截器 响应拦截器
import axios from 'axios'const service = axios.create() // 创建一个axios的实例
service.interceptors.request.use() 
// 请求拦截器service.interceptors.response.use() 
// 响应拦截器export default service // 导出axios实例
5.2api模块的单独封装

我们习惯性的将所有的网络请求 放置在api目录下统一管理,按照模块进行划分

单独封装代码

import request from '@/utils/request'
export function login(data) {  
	return request({    
		url: '/vue-admin-template/user/login',    
		method: 'post',    
		data  
	})
}
export function getInfo(token) {  
	return request({    
		url: '/vue-admin-template/user/info',    
		method: 'get',    
		params: { token }  
	})
}
export function logout() {  
	return request({    
	url: '/vue-admin-template/user/logout',    
	method: 'post'  
	})
}

上面代码中,使用了封装的request工具,每个接口的请求都单独**导出**了一个方法,这样做的好处就是,任何位置需要请求的话,可以直接引用我们导出的请求方法

为了后续更好的开发,我们可以先将user.js代码的方法设置为空,后续在进行更正

// import request from '@/utils/request'
export function login(data) {}
export function getInfo(token) {}
export function logout() {}

6.自定义指令

开发中,经常遇到图片挂掉,导致无法显示问题,比如公司的图片服务器宕机等

这时候可以显示一张默认图片,可以通过自定义指令解决这个问题

自定义指令 — Vue.js (vuejs.org)

6.1自定义指令

全局注册自定义指令语法

Vue.directive('指令名称', {    
	// 会在当前指令作用的dom元素 插入之后执行    
	// options 里面是指令的表达式    
	inserted: function (dom,options) {            
	}
})

获取焦点指令

main.js 中加入如下代码,全局注册自定义指令 v-focus

Vue.directive('focus', {  
	inserted: function (el) {    
	console.log(el.children[0])    
	el.children[0].focus()  
	}
})

然后在登录组件中使用此指令

	 <el-input          
	 ref="mobile"          
	 v-model="loginForm.username"          
	 v-focus          
	 placeholder="手机号"          
	 name="mobile"          
	 type="text"          
	 tabindex="1"        
 />

注意:一定要在当前浏览器窗口激活的情况下,才可以看到效果,比如 vs code 获取焦点的情况下,是看不到效果的,也就是代码编辑完成后,要用鼠标点一下浏览器窗口,才可以看到效果

统一管理

自定义指令可以采用统一的文件来管理 src/directives/index.js,这个文件负责管理所有的自定义指令

首先定义第一个自定义指令 v-imagerror

export const imagerror = {  
	// 指令对象 会在当前的dom元素插入到节点之后执行  
	inserted(dom, options) {    
	// options是 指令中的变量的解释  
	其中有一个属性叫做 value    
	// dom 表示当前指令作用的dom对象    
	// dom认为此时就是图片    
	// 当图片有地址 但是地址没有加载成功的时候 会报错 会触发图片的一个事件 => onerror    
	dom.onerror = function() {      
	// 当图片出现异常的时候 会将指令配置的默认图片设置为该图片的内容      
	// dom可以注册error事件      
		dom.src = options.value // 这里不能写死    
	}  
}}
6.2 自定义指令全局注册

然后,在**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')    
	}  
},

7.路由页面整理

目标 删除基础模板中附带的多余页面

基础模板帮我们提前内置了一些页面,本章节我们进行一下整理

7.1页面设置

首先,我们需要知道类似这种大型中台项目的页面路由是如何设置的。

简单项目

image-20200827153753307

当前项目结构

在这里插入图片描述

为什么要拆成若干个路由模块呢?

因为复杂中台项目的页面众多,不可能把所有的业务都集中在一个文件上进行管理和维护,并且还有最重要的,前端的页面中主要分为两部分,一部分是所有人都可以访问的, 一部分是只有有权限的人才可以访问的,拆分多个模块便于更好的控制

静态路由和动态路由

image-20200716150421791

注意**这里的动态路由并不是 **路由传参的动态路由

7.2 路由和组件删除

了解完成路由设计之后,我们对当前的路由进行一下整理

删除多余的静态路由表 src/router/index.js

/** * 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',    
		children: [{      
		path: 'dashboard',      
		name: 'Dashboard',      
		component: () => import('@/views/dashboard/index'),      
		meta: { title: 'Dashboard', icon: 'dashboard' }    
	}]  
}, 
 // 404 page must be placed at the end !!!  { path: '*', redirect: '/404', hidden: true }]

原来的八个路由记录,我们只对上面几个进行保留

同时删除对应的无用组件

左侧导航菜单的最终样子

在这里插入图片描述

同样的在api目录下,存在多余的api-table.js 一并删除

8.业务模块页面的快速搭建

目标: 掌握vue-admin-tempate 基础框架下新模块的创建

8.1新建模块的页面

接下来,我们可以将小优电商后台关系系统需要做的模块快速搭建相应的页面和路由

├── dashboard           
# 首页├── login               
# 登录├── 404                 
# 404├── Users                 
# 用户├── Roles               
# 角色├── Rights              
# 权限├── Goods               
# 商品├── Category            
# 类别├── Report                
# 报表

根据上图中的结构,在views目录下,建立对应的目录,给每个模块新建一个**index.vue**,作为每个模块的主页

每个模块的内容,可以先按照标准的模板建立,如

用户

<template>  
	<div class="dashboard-container">    
		<div class="app-container">      
			<h2>        用户      </h2>    
		</div>  
	</div>
</template>
<script>
	export default {}
</script>
<style></style>
8.2新建路由

根据以上的标准建立好对应页面之后,接下来建立每个模块的路由规则

在 router 目录下新建目录 modules

在此目录中新建各个路由模块

路由模块目录结构

image-20210506111546287

8.3 设置模块的路由规则

每个模块导出的内容表示该模块下的路由规则

如用户 user.js

// 导出属于用户的路由规则
import Layout from '@/layout'
//  {  path: '', component: '' }// 每个子模块 其实 都是外层是layout  组件位于layout的二级路由里面
export default {  
	path: '/user', // 路径  
	name: '', // 给路由规则加一个name  
	component: Layout, // 组件  // 配置二级路的路由表  
	children: [{    path: '', // 这里当二级路由的path什么都不写的时候 表示该路由为当前二级路由的默认路由    
	name: 'user', // 给路由规则加一个name    
	component: () => import('@/views/Users'),    
	// 路由元信息  其实就是存储数据的对象 我们可以在这里放置一些信息    
	meta: {      
		title: '用户管理' // meta属性的里面的属性 随意定义 但是这里为什么要用title呢, 因为左侧导航会读取我们的路由里的meta里面的title作为显示菜单名称    
		}  
	}
]}// 当你的访问地址 是 /user的时候 layout组件会显示 此时 你的二级路由的默认组件  也会显示

上述代码中,我们用到了meta属性,该属性为一个对象,里面可放置自定义属性,主要用于读取一些配置和参数,并且值得**注意的是:我们的meta写了二级默认路由上面,而不是一级路由,因为当存在二级路由的时候,访问当前路由信息访问的就是二级默认路由**

根据上面的路由规则,自己编写其他几个路由模块的路由规则

8.4 静态路由和动态路由合并

目标: 将静态路由和动态路由的路由表进行临时合并

什么叫临时合并?

前面讲过,动态路由是需要权限进行访问的,但是权限的动态路由访问是很复杂的,我们可以先将 静态路由和动态路由进行合并,不考虑权限问题,后面再解决这个问题

路由主文件 src/router/index.js

// 引入多个模块的规则
import Layout from '@/layout'
import userRouter from './modules/user'
import roleRouter from './modules/role'
import rightsRouter from './modules/right'
import goodsRouter from './modules/goods'
import categoryRouter from './modules/category'
import reportsRouter from './modules/report'// 动态路由
export const asyncRoutes = [  userRouter, roleRouter, rightsRouter, goodsRouter, categoryRouter, reportsRouter]const createRouter = () => new Router({  
// mode: 'history', 
// require service support  scrollBehavior: () => ({ y: 0 }), 
// 管理滚动行为 如果出现滚动 切换就让 让页面回到顶部  
routes: [...constantRoutes, ...asyncRoutes] // 临时合并所有的路由
})

通过上面的操作,我们将静态路由和动态路由进行了合并

image-20200827170403463

当我们合并权限完成,我们惊奇的发现页面效果已经左侧的导航菜单 =》 路由页面

这是之前基础模板中对于左侧导航菜单的封装

在这里插入图片描述

提交代码

本节任务: 将静态路由和动态路由临时合并,形成左侧菜单

8.5 左侧菜单的显示逻辑

目标 解析左侧菜单的显示逻辑, 设置左侧导航菜单的图标内容

上小节中,我们集成了路由,菜单就显示内容了,这是为什么 ?

阅读左侧菜单代码

我们发现如图的逻辑
在这里插入图片描述

由于,该项目不需要二级菜单的显示,所以对代码进行一下处理,只保留一级菜单路由

src/layout/components/Sidebar/SidebarItem.vue

<template>  
	<div v-if="!item.hidden">    
		<template v-if="hasOneShowingChild(item.children,item) &&(!onlyOneChild.children||onlyOneChild.noShowingChildren)&&!item.alwaysShow">      
			<app-link v-if="onlyOneChild.meta" :to="resolvePath(onlyOneChild.path)">        
				<el-menu-item :index="resolvePath(onlyOneChild.path)" :class="{'submenu-title-noDropdown':!isNest}">          
					<item :icon="onlyOneChild.meta.icon||(item.meta&&item.meta.icon)" :title="onlyOneChild.meta.title" />        
				</el-menu-item>     
		 </app-link>    
	</template>        
		 <!-- <el-submenu v-else ref="subMenu" :index="resolvePath(item.path)" popper-append-to-body>      
		 <template slot="title">        
			 <item v-if="item.meta" :icon="item.meta && item.meta.icon" :title="item.meta.title" />      
		 </template>      
		 <sidebar-item        v-for="child in item.children"        :key="child.path"        :is-nest="true"        :item="child"        :base-path="resolvePath(child.path)"        class="nest-menu"      />    
		 </el-submenu> -->  
	</div>
</template>

本节注意**:通过代码发现,当路由中的属性hidden**为true时,表示该路由不显示在左侧菜单中

与此同时,我们发现左侧菜单并不协调,是因为缺少图标。在本项目中,我们的图标采用了SVG的组件

左侧菜单的图标实际上读取的是meta属性的icon,这个icon需要我们提前放置在**src/icons/svg**目录下

项目提供了一些 svg 图标,具体的icon名称可参考线上地址,如果没有找到合适的,可以到 iconfont 获取

模块对应icon

├── category           
# category├── goods                
# goods├── reports            
# reports├── user               
# account├── roles              
# roles├── rights             
# rights

9. 过滤器解决时间格式的处理

针对用户创建时间,我们可以采用过滤器进行处理

Vue.js 允许你自定义过滤器,可被用于一些常见的文本格式化。过滤器可以用在两个地方:双花括号插值和 v-bind 表达式 (后者从 2.1.0+ 开始支持)。过滤器应该被添加在 JavaScript 表达式的尾部,由“管道”符号指示:

 <el-table-column label="入职时间" sortable prop="timeOfEntry">            
	 	<template slot-scope="obj">              
	 		{{  obj.row.timeOfEntry | 过滤器  }}            
	 	</template>  
 </el-table-column>

问题来了,过滤器从哪里呢?

src 目录下新建 filters 目录,其中新建 index.js 文件

这里我们使用 moment 做时间的格式化,所以需要首先安装 moment

npm i moment

然后编写过滤器函数

import moment from 'moment'
export function formatTime(value) {  
	return moment(value * 1000).format('YYYY-MM-DD HH:mm:ss')
}

在**main.js**中全局注册过滤器

import * as filters from '@/filters'
Object.keys(filters).forEach(key => {  
	Vue.filter(key, filters[key]
)})

好了,现在可以愉快的用过滤器的方式使用工具类的方法了

<el-table-column label="创建时间" sortable="">    
	<template slot-scope="scope">
		{{ scope.row.create_time | formatTime }}   
	 </template>
 </el-table-column>

10.用户导入

10.1 模板和样式

image-20200726005308648

首先封装一个类似的组件,首先需要注意的是,类似功能,vue-element-admin已经提供了,我们只需要改造即可 代码地址

类似功能性的组件,我们只需要会使用和封装即可

excel导入功能需要使用npm包**xlsx,所以需要安装xlsx**插件

$ npm i xlsx

将vue-element-admin提供的导入功能新建一个组件,位置: src/components/UploadExcel

注册全局的导入excel组件

import CommonTools from './CommonTools'
import UploadExcel from './UploadExcel'
export default {  
	install(Vue) {    
		Vue.component('CommonTools', CommonTools) // 注册工具栏组件    
		Vue.component('UploadExcel', UploadExcel) // 注册导入excel组件  
	}
}

修改样式和布局

<template>
  <div>
    <el-card>
      <el-page-header
        style="padding: 0 !important; margin-top: 10px"
        @back="goBack"
        content="用户导入"
      >
      </el-page-header>
    </el-card>
    <upload-excel :on-success="success"></upload-excel>
  </div>
</template>
<script>
import UploadExcel from '@/components/UploadExcel'
import { importUser } from '@/api/user'
export default {
  data() {
    return {
      type: ''
    }
  },
  created() {
    this.type = this.$route.query.type
  },
  components: {
    'upload-excel': UploadExcel
  },
  methods: {
    async success(result) {
      if (this.type == 'user') {
        const userRelations = {
          '入职日期': 'create_time',
          '手机号': 'mobile',
          '用户名': 'username',
          '密码': 'password',
          '邮箱': 'email',
          '部门': 'department'
        }
        this.transLateData(userRelations, result)
      }
    },
    goBack() {
      this.$router.push('/user/index')
    },
    transLateData(target, result) {
      const arr = []
      result.results.forEach(item => {
        const userInfo = {}
        Object.keys(item).forEach(key => {
          // key是当前的中文名 找到对应的英文名
          if (target[key] === 'timeOfEntry' || target[key] === 'correctionTime' || target[key] === 'create_time') {
            userInfo[target[key]] = this.formatDate(item[key])// 只有这样, 才能入库
            return
          }
          userInfo[target[key]] = item[key]
        })
        arr.push(userInfo)
      })
      arr.forEach(async item => {
        let res = await importUser(item)
        const { meta: { msg, status } } = res.data
        if (status !== 200) return
        this.$message.success(msg)
      })

    },
    // 转换日期参数
    formatDate(numb, format) {
      return (numb - 70 * 365 - 19) * 86400 - 8 * 3600
    }
  }
}
</script>
<style scoped>
.el-page-header {
  padding: 40px !important;
}
</style>
10.2 创建路由和组件

建立公共导入的页面路由

新建一个公共的导入页面,挂载路由 src/router/index.js

{    
	path: '/import',    
	component: Layout,    
	hidden: true, // 隐藏在左侧菜单中    
	children: [{      
	path: '', // 二级路由path什么都不写 表示二级默认路由      
	component: () => import('@/views/import')    
	}]  
},

创建import路由组件 src/views/import/index.vue

<template>  <!-- 公共导入组件 -->  
 	<upload-excel :on-success="success" />
 </template>
10.3 实现导入

封装导入用户的api接口

/** * *  封装一个导入用户的接口 * * ***/
export function importUser(data) {  
	return request({    
		url: 'user/batch',    
		method: 'post',    
		data  
	})
}
10.4 实现excel导入

获取导入的excel数据, 导入excel接口

    async  success({ header, results }) {      
	    // 如果是导入用户        
	    const userRelations = {          
		    '入职日期': 'create_time',          
		    '手机号': 'mobile',          
		    '用户名': 'username',          
		    '密码': 'password',          
		    '邮箱': 'email',          
		    '部门':'部门'        
	    }        
    	const arr = []       
    	results.forEach(item => {          
    		const userInfo = {}          
    		Object.keys(item).forEach(key => {            
   				userInfo[userRelations[key]] = item[key]          
   			})         
    		arr.push(userInfo)         
    	})        
    	await importUser(arr) // 调用导入接口        
    	this.$router.back()    
    }

为了让这个页面可以服务更多的导入功能,我们可以在页面中用参数来判断,是否是导入用户

 data() {    
	 return {      
	 	type: this.$route.query.type    
	 }  
 },

当excel中有日期格式的时候,实际转化的值为一个数字,我们需要一个方法进行转化

    formatDate(numb, format) {      
	    const time = new Date((numb - 1) * 24 * 3600000 + 1)      
	    time.setYear(time.getFullYear() - 70)      
	    const year = time.getFullYear() + ''      
	    const month = time.getMonth() + 1 + ''      
	    const date = time.getDate() - 1 + ''      
	    if (format && format.length === 1) {        
	    	return year + format + month + format + date      
	    }      
    return year + (month < 10 ? '0' + month : month) + (date < 10 ? '0' + date : date)    }

需要注意,导入的手机号不能和之前的存在的手机号重复

逻辑判断

 async  success({ header, results }) {      
	 if (this.type === 'user') {        
		 const userRelations = {          
		 '入职日期': 'create_time',          
		 '手机号': 'mobile',          
		 '用户名': 'username',          
		 '密码': 'password',          
		 '邮箱': 'email',          
		 '部门':'部门'        
		 }        
	 const arr = []        
 // 遍历所有的数组        
 	results.forEach(item => {        
 		// 需要将每一个条数据里面的中文都换成英文          
 		const userInfo = {}          
 		Object.keys(item).forEach(key => {          
		 // key是当前的中文名 找到对应的英文名            
 		if (userRelations[key] === 'timeOfEntry' || userRelations[key] === 'correctionTime') {              
 		userInfo[userRelations[key]] = new Date(this.formatDate(item[key], '/')) // 只有这样, 才能入库              
 		return            
	 }            
 	userInfo[userRelations[key]] = item[key]          
 	})          
 // 最终userInfo变成了全是英文          
 	arr.push(userInfo)        
	 })        
	 await importUser(arr)        
	 this.$message.success('导入成功')      
	 }      
	 this.$router.back() // 回到上一页    
	 },    
	 formatDate(numb, format) {      
		 const time = new Date((numb - 1) * 24 * 3600000 + 1)      
		 time.setYear(time.getFullYear() - 70)      
		 const year = time.getFullYear() + ''      
		 const month = time.getMonth() + 1 + ''      
		 const date = time.getDate() - 1 + ''      
		 if (format && format.length === 1) {        
		 return year + format + month + format + date      
	}      
	 return year + (month < 10 ? '0' + month : month) + (date < 10 ? '0' + date : date)    
 }

用户页面跳转

<el-button type="warning" size="small" @click="$router.push('/import?type=user')">导入</el-button>

11.用户导出

日常业务中,我们经常遇到excel导出功能, 怎么使用呢

Excel 的导入导出都是依赖于js-xlsx来实现的。

js-xlsx的基础上又封装了Export2Excel.js来方便导出数据。

11.1 安装excel所需依赖和按需加载

由于 Export2Excel不仅依赖js-xlsx还依赖file-saverscript-loader

所以你先需要安装如下命令:

npm install xlsx file-saver -Snpm install script-loader -S -D

xlsx 在导入功能时已经安装,这里可以不安装

由于js-xlsx体积还是很大的,导出功能也不是一个非常常用的功能,所以使用的时候建议使用懒加载。使用方法如下:

import('@/vendor/Export2Excel').then(excel => {  
	excel.export_json_to_excel({    
		header: tHeader, //表头 必填    
		data, //具体数据 必填    
		filename: 'excel-list', //非必填    
		autoWidth: true, //非必填    
		bookType: 'xlsx' //非必填  
	})
})
11.2 excel导出参数的介绍

vue-element-admin提供了导出的功能模块,在课程资源/excel导出目录下,放置到src目录下

参数

参数

说明

类型

可选值

默认值

header

导出数据的表头

Array

/

[]

data

导出的具体数据

Array

/

[[]]

filename

导出文件名

String

/

excel-list

autoWidth

单元格是否要自适应宽度

Boolean

true / false

true

bookType

导出文件类型

String

xlsx, csv, txt, more

xlsx

11.3 excel导出基本的结构

下面代码会用到 Export2Excel.js 模块,所以首先在 src 目录下新建 vendor 目录,其中新建 Export2Excel.js ,输入如下代码

/* eslint-disable */
import {
  saveAs
} from 'file-saver'
import XLSX from 'xlsx'

function generateArray(table) {
  var out = [];
  var rows = table.querySelectorAll('tr');
  var ranges = [];
  for (var R = 0; R < rows.length; ++R) {
    var outRow = [];
    var row = rows[R];
    var columns = row.querySelectorAll('td');
    for (var C = 0; C < columns.length; ++C) {
      var cell = columns[C];
      var colspan = cell.getAttribute('colspan');
      var rowspan = cell.getAttribute('rowspan');
      var cellValue = cell.innerText;
      if (cellValue !== "" && cellValue == +cellValue) cellValue = +cellValue;

      //Skip ranges
      ranges.forEach(function (range) {
        if (R >= range.s.r && R <= range.e.r && outRow.length >= range.s.c && outRow.length <= range.e.c) {
          for (var i = 0; i <= range.e.c - range.s.c; ++i) outRow.push(null);
        }
      });

      //Handle Row Span
      if (rowspan || colspan) {
        rowspan = rowspan || 1;
        colspan = colspan || 1;
        ranges.push({
          s: {
            r: R,
            c: outRow.length
          },
          e: {
            r: R + rowspan - 1,
            c: outRow.length + colspan - 1
          }
        });
      };

      //Handle Value
      outRow.push(cellValue !== "" ? cellValue : null);

      //Handle Colspan
      if (colspan)
        for (var k = 0; k < colspan - 1; ++k) outRow.push(null);
    }
    out.push(outRow);
  }
  return [out, ranges];
};

function datenum(v, date1904) {
  if (date1904) v += 1462;
  var epoch = Date.parse(v);
  return (epoch - new Date(Date.UTC(1899, 11, 30))) / (24 * 60 * 60 * 1000);
}

function sheet_from_array_of_arrays(data, opts) {
  var ws = {};
  var range = {
    s: {
      c: 10000000,
      r: 10000000
    },
    e: {
      c: 0,
      r: 0
    }
  };
  for (var R = 0; R != data.length; ++R) {
    for (var C = 0; C != data[R].length; ++C) {
      if (range.s.r > R) range.s.r = R;
      if (range.s.c > C) range.s.c = C;
      if (range.e.r < R) range.e.r = R;
      if (range.e.c < C) range.e.c = C;
      var cell = {
        v: data[R][C]
      };
      if (cell.v == null) continue;
      var cell_ref = XLSX.utils.encode_cell({
        c: C,
        r: R
      });

      if (typeof cell.v === 'number') cell.t = 'n';
      else if (typeof cell.v === 'boolean') cell.t = 'b';
      else if (cell.v instanceof Date) {
        cell.t = 'n';
        cell.z = XLSX.SSF._table[14];
        cell.v = datenum(cell.v);
      } else cell.t = 's';

      ws[cell_ref] = cell;
    }
  }
  if (range.s.c < 10000000) ws['!ref'] = XLSX.utils.encode_range(range);
  return ws;
}

function Workbook() {
  if (!(this instanceof Workbook)) return new Workbook();
  this.SheetNames = [];
  this.Sheets = {};
}

function s2ab(s) {
  var buf = new ArrayBuffer(s.length);
  var view = new Uint8Array(buf);
  for (var i = 0; i != s.length; ++i) view[i] = s.charCodeAt(i) & 0xFF;
  return buf;
}

export function export_table_to_excel(id) {
  var theTable = document.getElementById(id);
  var oo = generateArray(theTable);
  var ranges = oo[1];

  /* original data */
  var data = oo[0];
  var ws_name = "SheetJS";

  var wb = new Workbook(),
    ws = sheet_from_array_of_arrays(data);

  /* add ranges to worksheet */
  // ws['!cols'] = ['apple', 'banan'];
  ws['!merges'] = ranges;

  /* add worksheet to workbook */
  wb.SheetNames.push(ws_name);
  wb.Sheets[ws_name] = ws;

  var wbout = XLSX.write(wb, {
    bookType: 'xlsx',
    bookSST: false,
    type: 'binary'
  });

  saveAs(new Blob([s2ab(wbout)], {
    type: "application/octet-stream"
  }), "test.xlsx")
}

export function export_json_to_excel({
  multiHeader = [],
  header,
  data,
  filename,
  merges = [],
  autoWidth = true,
  bookType = 'xlsx'
} = {}) {
  /* original data */
  filename = filename || 'excel-list'
  data = [...data]
  data.unshift(header);

  for (let i = multiHeader.length - 1; i > -1; i--) {
    data.unshift(multiHeader[i])
  }

  var ws_name = "SheetJS";
  var wb = new Workbook(),
    ws = sheet_from_array_of_arrays(data);

  if (merges.length > 0) {
    if (!ws['!merges']) ws['!merges'] = [];
    merges.forEach(item => {
      ws['!merges'].push(XLSX.utils.decode_range(item))
    })
  }

  if (autoWidth) {
    /*设置worksheet每列的最大宽度*/
    const colWidth = data.map(row => row.map(val => {
      /*先判断是否为null/undefined*/
      if (val == null) {
        return {
          'wch': 10
        };
      }
      /*再判断是否为中文*/
      else if (val.toString().charCodeAt(0) > 255) {
        return {
          'wch': val.toString().length * 2
        };
      } else {
        return {
          'wch': val.toString().length
        };
      }
    }))
    /*以第一行为初始值*/
    let result = colWidth[0];
    for (let i = 1; i < colWidth.length; i++) {
      for (let j = 0; j < colWidth[i].length; j++) {
        if (result[j]['wch'] < colWidth[i][j]['wch']) {
          result[j]['wch'] = colWidth[i][j]['wch'];
        }
      }
    }
    ws['!cols'] = result;
  }

  /* add worksheet to workbook */
  wb.SheetNames.push(ws_name);
  wb.Sheets[ws_name] = ws;

  var wbout = XLSX.write(wb, {
    bookType: bookType,
    bookSST: false,
    type: 'binary'
  });
  saveAs(new Blob([s2ab(wbout)], {
    type: "application/octet-stream"
  }), `${filename}.${bookType}`);
}

然后实现数据的导出

我们最重要的一件事,就是把表头和数据进行相应的对应

因为数据中的key是英文,想要导出的表头是中文的话,需要将中文和英文做对应

 const headers = {        
	 '入职日期': 'create_time',        
	 '手机号': 'mobile',        
	 '用户名': 'username',        
	 '角色': 'role_name',        
	 '邮箱': 'email',        
	 '部门': 'department_name'      
 }

然后,完成导出代码

import { formatDate } from '@/filters'// 导出数据    
exportData() {      
	const headers = {        
	'入职日期': 'create_time',        
	'手机号': 'mobile',        
	'用户名': 'username',        
	'角色': 'role_name',        
	'邮箱': 'email',        
	'部门': 'department_name'      
	}      
	import('@/vendor/Export2Excel').then(async excel => {        
	const res = await getUserList({ query: '', pagenum: 1, pagesize: this.page.total })        
	// console.log(res)        
	const data = this.formatJson(headers, res.users)        
		console.log(data)        
			excel.export_json_to_excel({          
			header: Object.keys(headers),          
			data,          
			filename: '用户信息表',          
			autoWidth: true,          
			bookType: 'xlsx'        
			})      
		})    
	}        
}
11.4 导出时间格式的处理
   // 该方法负责将数组转化成二维数组    
   formatJson(headers, rows) {      
   		return rows.map(item => {        
   		return Object.keys(headers).map(key => {          
   			if (headers[key] === 'create_time') {            
   				return formatDate(item[headers[key]]) // formatDate 函数是定义好的一个过滤器          
   			}          
   		return item[headers[key]]        
   		})      
   })

过滤器 formatDate

import moment from 'moment'
export function formatTime(value) {  
	return moment(value * 1000).format('YYYY-MM-DD HH:mm:ss')
}
export function formatDate(value) {  
	return moment(value * 1000).format('YYYY-MM-DD')
}

12.配置腾讯云Cos

目标: 配置一个腾讯云cos

由于上课的开发的特殊性,我们不希望把所有的图片都上传到我们自己的官方服务器上,这里我们可以采用一个腾讯云的图片方案

在这里插入图片描述

上边图的意思就是说,我们找一个可以免费上传图片的服务器,帮我们**代管图片,我们在自己的数据库里只保存一个地址就行, 这其实也是很多项目的处理方案,会有一个公共的文件服务器**

第一步,我们必须先拥有一个腾迅云的开发者账号(小心腾讯云的广告电话)

请按照腾讯云的注册方式,注册自己的账号

第二步,实名认证

选择个人账户

image-20200805201013078

填写个人身份信息

image-20200805201230001

下一步,扫描二维码授权

image-20200805201318977

手机端授权

image-20200805201422567

点击领取免费产品

image-20200805201513338

选择对象存储COS

image-20200805201559233

我们免费拥有**6个月的50G流量**的对象存储空间使用权限,足够我们上传用户头像的使用了

点击0元试用,开通服务

image-20200805201750662

到这一步,账号的部分就操作完毕,接下来,我们需要来创建一个存储图片的存储桶

登录 对象存储控制台创建存储桶。设置存储桶的权限为 公有读,私有写

image-20200806135409338

image-20200916231600661

设置cors规则

image-20200916231949174

AllowHeader 需配成*,如下图所示。

image-20200805211749522

因为我们本身没有域名,所以这里设置成*****,仅限于测试,正式环境的话,这里需要配置真实的域名地址

到这里,我们的腾讯云存储桶就设置好了。

12.1 封装上传图片组件
12.1.1 上传组件需求分析

初始化cos对象参数

名称

描述

SecretId

开发者拥有的项目身份识别 ID,用以身份认证,可在 API 密钥管理 页面获取

SecretKey

开发者拥有的项目身份密钥,可在 API 密钥管理 页面获取

注意,上述的参数我们在本次开发过程中,直接将参数放置在前端代码中存储,但是腾讯云本身是不建议这么做的,因为**敏感信息**放在前端很容易被捕获,由于我们本次是测试研发,所以这个过程可以忽略

正确的做法应该是,通过网站调用接口换取敏感信息

相关文档

实例化 上传sdk

var cos = new COS({    
	SecretId: 'COS_SECRETID', // 身份识别 ID    
	SecretKey: 'COS_SECRETKEY', // 身份密钥
});

到目前为止,我们上传图片准备的内容就已经OK,接下来,我们在**src/componets** 新建一个**ImageUpload** 组件

该组件需要满足什么要求呢?

  1. 可以显示传入的图片地址
  2. 可以删除传入的图片地址
  3. 可以上传图片到云服务器
  4. 上传到腾讯云之后,可以返回图片地址,显示
  5. 上传成功之后,可以回调成功函数

这个上传组件简单吗?

no ! ! !

看似需求很明确,但是它真正的实现很复杂,我们通过一个图来看一下

在这里插入图片描述

从上图中,我们可以看到,实际上是有两种场景的,本地场景和已经上传的场景

下个章节,针对这个场景我们进行开发

12.1.2 封装上传组件

**目标**实现上传组件的代码部分

JavaScript SDK 需浏览器支持基本的 HTML5 特性(支持 IE10 以上浏览器),以便支持 ajax 上传文件和计算文件 MD5 值。

12.1.3 新建文件上传组件

安装JavaScript SDK

$ npm i cos-js-sdk-v5 --save

新建上传图片组件 src/components/ImageUpload/index.vue

上传组件,我们可以沿用element的el-upload组件,并且采用照片墙的模式 list-type="picture-card"

放置el-upload组件

<template>  
	<el-upload list-type="picture-card">     
	<i class="el-icon-plus" />  
</el-upload></template>

全局注册组件

import PageTools from './PageTools'
import UploadExcel from './UploadExcel'
import ImageUpload from './ImageUpload'
export default {  
	install(Vue) {    
		Vue.component('PageTools', PageTools) // 注册工具栏组件    
		Vue.component('UploadExcel', UploadExcel) // 注册导入excel组件
		Vue.component('ImageUpload', ImageUpload) // 注册导入上传组件  
}}
12.1.4 点击图片进行预览

限定上传的图片数量和action

<template>  
	<el-upload list-type="picture-card" :limit="1" action="#">  </el-upload>
</template>

action为什么给#, 因为前面我们讲过了,我们要上传到腾讯云,需要自定义的上传方式,action给个#防止报错

预览

data() {    
	return {      
		fileList: [], // 图片地址设置为数组       
		showDialog: false, // 控制显示弹层      
		imgUrl: ''    
	}  
},     
preview(file) {      
	// 这里应该弹出一个层 层里是点击的图片地址      
	this.imgUrl = file.url      
	this.showDialog = true  
},  

预览弹层

  <el-dialog title="图片" :visible.sync="showDialog">      
  	<img :src="imgUrl" style="width:100%" alt="">   
  </el-dialog>
12.1.5 控制上传按钮

控制上传显示

 computed: {    
 // 设定一个计算属性 判断是否已经上传完了一张    
 	fileComputed() {      
 		return this.fileList.length === 1    
 	}  
 },     
 <el-upload  :on-preview="preview"  
 :on-remove="handleRemove" 
 :on-change="changeFile"          
 :file-list="fileList" 
 list-type="picture-card"  action="#" 
 :limit="1"  
 :class="{disabled: fileComputed }">
 
 <style>.disabled .el-upload--picture-card {  display: none}</style>
12.1.6 删除图片和添加图片

删除文件

     handleRemove(file) {      
	     // file是点击删除的文件    
	     //   将原来的文件给排除掉了 剩下的就是最新的数组了      
	     this.fileList = this.fileList.filter(item => item.uid !== file.uid)    
     },

添加文件

    // 修改文件时触发    
    // 此时可以用fileList 因为该方法会进来很多遍 不能每次都去push    
    // fileList因为fileList参数是当前传进来的最新参数 我们只需要将其转化成数组即可 需要转化成一个新的数组    
    // [] => [...fileList] [] => fileList.map()    
    // 上传成功之后 还会进来 需要实现上传代码的逻辑 这里才会成功    
    changeFile(file, fileList) {      
    	this.fileList = fileList.map(item => item)    
    }
12.1.7 上传之前检查

控制上传图片的类型和上传大小, 如果不满足条件 返回false上传就会停止

    beforeUpload(file) {      
    	// 要开始做文件上传的检查了      
    	// 文件类型 文件大小      
	    const types = ['image/jpeg', 'image/gif', 'image/bmp', 'image/png']      
	    if (!types.includes(file.type)) {        
	    this.$message.error('上传图片只能是 JPG、GIF、BMP、PNG 格式!')        
	    return false      
	    }      
    	//  检查大小      
    	const maxSize = 5 * 1024 * 1024      
    	if (maxSize < file.size) {        
    		this.$message.error('图片大小最大不能超过5M')        
    		return false      
    	}      
    	return true    
    }
12.1.8 调用上传腾讯云

上传动作为el-upload的http-request属性

 :http-request="upload"    
 // 自定义上传动作 有个参数 有个file对象,是我们需要上传到腾讯云服务器的内容    
 upload(params) {      
 	console.log(params.file)    
 }

我们需要在该方法中,调用腾讯云的上传方法

腾讯云文档地址

身份ID和密钥可以通过腾讯云平台获取

登录 访问管理控制台 ,获取您的项目 SecretId 和 SecretKey。

image-20200805211539544

实现代码

    // 进行上传操作    upload(params) {    
    //   console.log(params.file)      
    if (params.file) {        
    // 执行上传操作        
	    cos.putObject({          
	    	Bucket: 'shuiruohanyu-106-1302806742', // 存储桶          
	    	Region: 'ap-beijing', // 地域          
	    	Key: params.file.name, // 文件名          
	    	Body: params.file, // 要上传的文件对象          
	    	StorageClass: 'STANDARD' // 上传的模式类型 直接默认 标准模式即可          
	    	// 上传到腾讯云 =》 哪个存储桶 哪个地域的存储桶 文件  格式  名称 回调        
	    	}, function(err, data) {          
		    	// data返回数据之后 应该如何处理          
		    	console.log(err || data)        
	    	})      
    	}    
   }
12.1.9 处理返回数据

如何处理返回成功的返回数据

处理返回数据

    // 进行上传操作    
    upload(params) {    
	    //   console.log(params.file)      
	    if (params.file) {        
    		// 执行上传操作        
    		cos.putObject({          
    			Bucket: 'shuiruohanyu-106-1302806742', // 存储桶          
    			Region: 'ap-beijing', // 地域          
    			Key: params.file.name, // 文件名          
    			Body: params.file, // 要上传的文件对象          
    			StorageClass: 'STANDARD' // 上传的模式类型 直接默认 标准模式即可          
    			// 上传到腾讯云 =》 哪个存储桶 哪个地域的存储桶 文件  格式  名称 回调        
    			}, (err, data) => {          
    				// data返回数据之后 应该如何处理          
    				console.log(err || data)          
    				// data中有一个statusCode === 200 的时候说明上传成功          
    				if (!err && data.statusCode === 200) {            
    				//   此时说明文件上传成功  要获取成功的返回地址            
    				// fileList才能显示到上传组件上 此时我们要将fileList中的数据的url地址变成 现在上传成功的地址
    				// 目前虽然是一张图片 但是请注意 我们的fileList是一个数组            
    				// 需要知道当前上传成功的是哪一张图片            
    				this.fileList = this.fileList.map(item => {              
    				// 去找谁的uid等于刚刚记录下来的id              
    					if (item.uid === this.currentFileUid) {                
    					// 将成功的地址赋值给原来的url属性                
    						return { url: 'http://' + data.Location, upload: true }                
    						// upload 为true 表示这张图片已经上传完毕 这个属性要为我们后期应用的时候做标记
    						// 保存  => 图片有大有小 => 上传速度有快又慢 =>要根据有没有upload这个标记来决定是否去保存
                  		}              
                  		return item            
                  	})            
                  // 将上传成功的地址 回写到了fileList中 fileList变化  =》 upload组件 就会根据fileList的变化而去渲染视图          
             }        
        })      
 	 }    
}

我们在fileList中设置了属性为upload为true的属性,表示该图片已经上传成功了,如果fileList还有upload不为true的数据,那就表示该图片还没有上传完毕

12.1.10 上传的进度条显示

为了再上传图片过程中显示进度条,我们可以使用element-ui的进度条显示当前的上传进度

放置进度条

 <el-progress v-if="showPercent" style="width: 180px" :percentage="percent" />

通过腾讯云sdk监听上传进度

 cos.putObject({          
	 // 配置          
	 Bucket: 'laogao-1302806742', // 存储桶名称          
	 Region: 'ap-guangzhou', // 存储桶地域          
	 Key: params.file.name, // 文件名作为key          
	 StorageClass: 'STANDARD', // 此类写死          
	 Body: params.file, // 将本地的文件赋值给腾讯云配置          
	 // 进度条          
	 onProgress: (params) => {            
	 	this.percent = params.percent * 100          
	 }        
 }

完整代码

<template>  
	<div>    
	<!-- 放置一个上传组件 -->    
	<!-- action这里不写上传地址 因为我们是调用腾讯云cos 不是一个地址可以搞定的 要用自定义的上传 -->
		<el-upload      
		:on-preview="preview"      
		:on-remove="handleRemove"      
		:on-change="changeFile"      
		:before-upload="beforeUpload"      
		:file-list="fileList"      
		:http-request="upload"      
		list-type="picture-card"      
		action="#"      
		:limit="1"      
		:class="{disabled: fileComputed }">      
		<i class="el-icon-plus" />    
		</el-upload>    
			<!-- 进度条 -->    
			<el-progress v-if="showPercent" style="width: 180px" :percentage="percent" />
			<!-- 放置一个弹层 -->    
			<!-- sync修饰符自动将弹层关闭了 -->    
			<el-dialog title="图片" :visible.sync="showDialog">      
			<img :src="imgUrl" style="width:100%" alt="">    
		</el-dialog>  
	</div>
</template>
<script>
import COS from 'cos-js-sdk-v5' // 引入腾讯云的包
// 需要实例化
const cos = new COS({  
SecretId: 'AKID0mqfEWqlUzIbeSkGRL6c7ML6c0B93To9',  
SecretKey: 'JFwNZdeRF2iOp03FFsGNDm44vWFitmNF'}) 
// 实例化的包 已经具有了上传的能力 可以上传到该账号里面的存储桶了
export default {  
data() {    
	return {      
		fileList: [],      
		showDialog: false, // 控制图片的显示或者隐藏      
		imgUrl: '', // 存储点击的图片地址      
		currentFileUid: '', // 用一个变量 记住当前上传的图片id      
		percent: 0,      
		showPercent: false // 默认不显示进度条    
	}  
},  
computed: {    
// 设定一个计算属性 判断是否已经上传完了一张    
	fileComputed() {      
		return this.fileList.length === 1    
	}  
},  
methods: {    
	preview(file) {      
	// 这里应该弹出一个层 层里是点击的图片地址      
	this.imgUrl = file.url      
	this.showDialog = true    
	},    
	handleRemove(file) {      
	// file是点击删除的文件    
	//   将原来的文件给排除掉了 剩下的就是最新的数组了      
	this.fileList = this.fileList.filter(item => item.uid !== file.uid)    
	},    
	// 修改文件时触发    
	// 此时可以用fileList 因为该方法会进来很多遍 不能每次都去push    
	// fileList因为fileList参数是当前传进来的最新参数 我们只需要将其转化成数组即可 需要转化成一个新的数组    
	// [] => [...fileList] [] => fileList.map()    
	// 上传成功之后 还会进来 需要实现上传代码的逻辑 这里才会成功    
	changeFile(file, fileList) {      
		this.fileList = fileList.map(item => item)    
	},    
	beforeUpload(file) {      
		// 要开始做文件上传的检查了      
		// 文件类型 文件大小      
		const types = ['image/jpeg', 'image/gif', 'image/bmp', 'image/png']      
		if (!types.includes(file.type)) {        
			this.$message.error('上传图片只能是 JPG、GIF、BMP、PNG 格式!')        
			return false      
		}      
		//  检查大小      
		const maxSize = 5 * 1024 * 1024      
		if (maxSize < file.size) {        
			this.$message.error('图片大小最大不能超过5M')        
			return false      
		}      
		// file.uid      
		this.currentFileUid = file.uid // 记住当前的uid      
		this.showPercent = true      
		return true    
		},    
	// 自定义上传动作 有个参数 有个file对象,是我们需要上传到腾讯云服务器的内容    
	upload(params) {      
		if (params.file) {        
		//  上传文件到腾讯云        
		cos.putObject({          
		// 配置          
		Bucket: 'laogao-1302806742', // 存储桶名称          
		Region: 'ap-guangzhou', // 存储桶地域          
		Key: params.file.name, // 文件名作为key          
		StorageClass: 'STANDARD', // 此类写死          
		Body: params.file, // 将本地的文件赋值给腾讯云配置          
		// 进度条          
		onProgress: (params) => {            
			this.percent = params.percent * 100          
		}        
		}, (err, data) => {          
		// 需要判断错误与成功          
		if (!err && data.statusCode === 200) {            
			// 如果没有失败表示成功了            
			// 此时认为上传成功了            
			// this.currentFileUid            
			// 仍然有个小问题, 比如此时我们正在上传,但是调用了保存,保存在上传过程中进行,           
			// 此时上传还没有完成  此时可以这样做 : 给所有上传成功的图片 加一个属性 upload: true            this.fileList = this.fileList.map(item => {              
			if (item.uid === this.currentFileUid) {                
				//   upload为true表示 该图片已经成功上传到服务器,地址已经是腾讯云的地址了  就不可以执行保存了
	 			return { url: 'http://' + data.Location, upload: true } // 将本地的地址换成腾讯云地址              }              
	 			return item            
	 		})            
		 setTimeout(() => {              
			 this.showPercent = false // 隐藏进度条              
			 this.percent = 0 // 进度归0            
		 }, 2000)            
	 // 将腾讯云地址写入到fileList上 ,保存的时候 就可以从fileList中直接获取图片地址
	 // 此时注意,我们应该记住 当前上传的是哪个图片  上传成功之后,将图片的地址赋值回去          
	 		}        
		})     
	 }    
  }  
}}
	 </script>
	 <style>.disabled .el-upload--picture-card {  display: none}</style>

上传动作中,用到了上个小节中,我们注册的腾讯云cos的**存储桶名称地域名称**

通过上面的代码,我们会发现,我们把上传之后的图片信息都给了**fileList数据,那么在应用时,就可以直接获取该实例的fileList数据即可**

10.1.11 上传前裁剪

首先安装

vue-cropper

在上传组件中引入

import { VueCropper } from 'vue-cropper'

模板代码

上传组件模板中加入如下代码

  <!-- vueCropper 剪裁图片实现-->    
  <el-dialog title="图片剪裁" :visible.sync="cutDialogVisible" append-to-body>      
  	<div class="cropper-content">        
  		<div class="cropper" style="text-align: center">          
  			<vue-cropper            
			  ref="cropper"            
			  :img="option.img"            
			  :output-size="option.outputSize"            
			  :output-type="option.outputType"            
			  :info="option.info"            
			  :can-scale="option.canScale"            
			  :auto-crop="option.autoCrop"            
			  :auto-crop-width="option.autoCropWidth"            
			  :auto-crop-height="option.autoCropHeight"            
			  :fixed="option.fixed"            
			  :fixed-number="option.fixedNumber"            
			  :full="option.full"            
			  :fixed-box="option.fixedBox"            
			  :can-move="option.canMove"            
			  :can-move-box="option.canMoveBox"            
			  :original="option.original"            
			  :center-box="option.centerBox"            
			  :height="option.height"            
			  :info-true="option.infoTrue"            
			  :max-img-size="option.maxImgSize"            
			  :enlarge="option.enlarge"            
			  :mode="option.mode"/>        
  		</div>      
  	</div>      
  	<div slot="footer" class="dialog-footer">        
  		<el-button @click="cutDialogVisible  = false">取 消</el-button>        
  		<el-button type="primary" @click="finish">确认</el-button>      
  	</div>    
  </el-dialog>
  • 通过变量 cutDialogVisible 控制裁剪对话框的显示和隐藏,默认情况下隐藏,在上传控件的 on-change 事件中显示
  • 设置 el-upload 组件的 :auto-upload="false" 取消自动上传
  • 删除 el-upload 组件的 :http-request="upload" 否则选择的图片不会加入到 fileList 变量中

变量设置

data 中新增如下变量

cutDialogVisible: false,      
// 裁剪组件的基础配置option      
option: {        
	img: '', // 裁剪图片的地址        
	outputSize: 1, // 裁剪生成图片的质量(可选0.1 - 1)        
	outputType: 'jpeg', // 裁剪生成图片的格式(jpeg || png || webp)        
	info: true, // 图片大小信息        
	canScale: true, // 图片是否允许滚轮缩放        
	autoCrop: true, // 是否默认生成截图框        
	autoCropWidth: 230, // 默认生成截图框宽度        
	autoCropHeight: 150, // 默认生成截图框高度        
	fixed: true, // 是否开启截图框宽高固定比例        
	fixedNumber: [1.53, 1], // 截图框的宽高比例        
	full: false, // false按原比例裁切图片,不失真        
	fixedBox: false, // 固定截图框大小,不允许改变        
	canMove: false, // 上传图片是否可以移动        
	canMoveBox: true, // 截图框能否拖动        
	original: false, // 上传图片按照原始比例渲染        
	centerBox: false, // 截图框是否被限制在图片里面        
	height: true, // 是否按照设备的dpr 输出等比例图片        
	infoTrue: false, // true为展示真实输出图片宽高,false展示看到的截图框宽高        
	maxImgSize: 3000, // 限制图片最大宽度和高度        
	enlarge: 1, // 图片根据截图框输出比例倍数        
	mode: 'contain' // 图片默认渲染方式      
},      
	fileInfo: null // 存储待上传的文件信息,因为多个函数中都要用到
  • fileInfo:此变量用于存储选择上传的图片的信息,因为禁止 el-upload 组件自动上传后,并删除 http-request="upload'属性后,上传前不会自动执行 before-upload 事件,需要在上传之前进行调用,在此事件中也就没有能够获取文件信息的参数了,只能先存起来,然后在此事件中再获取
  • option 中都是关于裁剪控件的设置

js代码

 methods: {    
 	preview(file) {      
 		this.imgUrl = file.url      
 		this.showDialog = true    
 		},    
 		beforeUpload(file) {      
 			// 允许上传的文件类型      
 			const types = ['image/jpeg', 'image/gif', 'image/bmp', 'image/png']      
 			if (!types.includes(file.type)) {        
 				this.$message.error('上传图片只能是 JPG、GIF、BMP、PNG 格式!')        
 				return false // return false 会阻止图片的上传操作      
 			}      
 			const maxSize = 1024 * 1024      
 			if (file.size > maxSize) {        
 				this.$message.error('图片大小最大不能超过1M')        
 				return false      
 			}      
 			this.currentImageUid = this.fileInfo.uid      
 			this.showPercent = true      
 			return true    
 		},       
 		handleRemove(file, fileList) {      
 			this.fileList = this.fileList.filter(item => item.uid !== file.uid)
 			console.log(file)      
 			cos.deleteObject({        
 				Bucket: 'store-1302254460', /* 必须 */        
 				Region: 'ap-nanjing', /* 存储桶所在地域,必须字段 */        
 				Key: file.name /* 必须 */      
 			}, (err, data) => {        
 				console.log(err || data)      
 			})    
 		},    
 		change(file, fileList) {      
 			// 为裁剪组件设置要裁剪的图片      
 			this.option.img = file.url      
 			// 显示裁剪对话框      
 			this.cutDialogVisible = true      
 			// 存储待上传的文件信息,因为 finish 函数中需要用到      
 			this.fileInfo = file      
 			this.fileList = fileList    
 			},    
 		finish() {      
 		// 首先调用 beforeUpload 函数做上传前的验证      
 		// this.beforeUpload()      
 		this.$refs.cropper.getCropBlob(data => {        
 		// 检查文件 mime 类型        
 		this.beforeUpload({ type: data.type, size: data.size })        
 		// 执行上传操作        
 		cos.putObject({          
 			Bucket: 'store-1302254460', // 存储桶          
 			Region: 'ap-nanjing', // 地域          
 			Key: this.fileInfo.name, // 文件名          
 			Body: data, // 要上传的文件对象          
 			StorageClass: 'STANDARD', // 上传的模式类型 直接默认 标准模式即可          
 			onProgress: (progressData) => {            
 				this.percent = progressData.percent * 100          
 			}        
 		}, (err, data) => {          
 			console.log(data)          
 			// data返回数据之后 应该如何处理          
 			if (err) return          
 			this.fileList = this.fileList.map(item => {            
 			if (item.uid === this.currentImageUid) {              
 				return { url: 'http://' + data.Location, name: item.name }            
 			}            
 			return item          
 		})          
 			this.cutDialogVisible = false        
 			})      
 		})    
 	}  
 }
  • 删除了 upload 方法,增加了 finish 方法,点击 确定 按钮时调用
  • 何时进行裁剪?当然时上传之前。所以在用户选择文件后,也就是 change 事件中,为裁剪组件赋值并展示对话框
  • 当对图片进行了一系列的裁剪操作后,点击 确定 按钮,进入 finish 函数中,通过this.$refs.cropper.getCropBlob 方法的回调函数获取裁剪后的图片,然后调用 beforeUpload 函数进行验证,验证成功后再进行上传
  • 思考:为什么要在裁剪之后才进行检查,而不是裁剪之前呢?因为变量 fileInfo 中没有 type 属性(因为 fileInfo 变量是在 change 事件中赋值的,而不是原来在 beforeUpload 中的形参),裁剪之后的对象中具有此属性

完整代码

<template>  
	<div>    
		<el-upload      
		list-type="picture-card"      
		:limit="4"      
		action="#"      
		:auto-upload="false"      
		:file-list="fileList"      
		:on-preview="preview"      
		:on-change="change"      
		:before-upload="beforeUpload"      
		:accept="typeList"      
		:on-remove="handleRemove">      
		<i class="el-icon-plus" />    
		</el-upload>    
		<el-progress      
		v-if="showPercent"      
		style="width: 180px"      
		:percentage="percent"/>    
		<el-dialog title="图片" :visible.sync="showDialog">      
			<img :src="imgUrl" style="width: 100%" alt="" />    
		</el-dialog>    
		<!-- vueCropper 剪裁图片实现-->    
		<el-dialog title="图片剪裁" :visible.sync="cutDialogVisible" append-to-body>
		<div class="cropper-content">        
			<div class="cropper" style="text-align: center">          
				<vue-cropper            
				ref="cropper"            
				:img="option.img"            
				:output-size="option.outputSize"            
				:output-type="option.outputType"            
				:info="option.info"            
				:can-scale="option.canScale"            
				:auto-crop="option.autoCrop"            
				:auto-crop-width="option.autoCropWidth"            
				:auto-crop-height="option.autoCropHeight"
				:fixed="option.fixed"            
				:fixed-number="option.fixedNumber"            
				:full="option.full"            
				:fixed-box="option.fixedBox"            
				:can-move="option.canMove"            
				:can-move-box="option.canMoveBox"            
				:original="option.original"            
				:center-box="option.centerBox"            
				:height="option.height"            
				:info-true="option.infoTrue"            
				:max-img-size="option.maxImgSize"            
				:enlarge="option.enlarge"            
				:mode="option.mode"          
				/>        
			</div>      
		</div>      
			<div slot="footer" class="dialog-footer">        
				<el-button @click="cutDialogVisible = false">取 消</el-button>        
				<el-button type="primary" @click="finish">确认</el-button>      
			</div>    
		</el-dialog>  
	</div>
</template>
<script>
import { VueCropper } from 'vue-cropper'
import COS from 'cos-js-sdk-v5'
const cos = new COS({  SecretId: 'AKIDmOnxsXw5xZv0pOh9SWLuEJ9l7DMM6brr',  SecretKey: 'oLdhYAYieMq7CWlqkZN3Zu063kCRRBi1'}) 
// 实例化的包 已经具有了上传的能力 可以上传到该账号里面的存储桶了
export default {  
	components: {    VueCropper  },  
	data() {    
	return {      
		fileList: [], // 图片地址设置为数组      
		showDialog: false, // 控制显示弹层      
		imgUrl: '',      
		currentImageUid: null,      
		typeList: 'image/*',      
		showPercent: false, // 是否显示进度条      
		percent: 0, // 上传进度      
		cutDialogVisible: false,      
		// 裁剪组件的基础配置option      
		option: {        
			img: '', // 裁剪图片的地址        
			outputSize: 1, // 裁剪生成图片的质量(可选0.1 - 1)        
			outputType: 'jpeg', // 裁剪生成图片的格式(jpeg || png || webp)        
			info: true, // 图片大小信息        
			canScale: true, // 图片是否允许滚轮缩放        
			autoCrop: true, // 是否默认生成截图框        
			autoCropWidth: 230, // 默认生成截图框宽度        
			autoCropHeight: 150, // 默认生成截图框高度        
			fixed: true, // 是否开启截图框宽高固定比例        
			fixedNumber: [1.53, 1], // 截图框的宽高比例        
			full: false, // false按原比例裁切图片,不失真        
			fixedBox: false, // 固定截图框大小,不允许改变        
			canMove: false, // 上传图片是否可以移动        
			canMoveBox: true, // 截图框能否拖动        
			original: false, // 上传图片按照原始比例渲染        
			centerBox: false, // 截图框是否被限制在图片里面        
			height: true, // 是否按照设备的dpr 输出等比例图片        
			infoTrue: false, // true为展示真实输出图片宽高,false展示看到的截图框宽高
			maxImgSize: 3000, // 限制图片最大宽度和高度        
			enlarge: 1, // 图片根据截图框输出比例倍数        
			mode: 'contain' // 图片默认渲染方式      
		},      
		fileInfo: null // 存储待上传的文件信息,因为多个函数中都要用到    
		}  
	},  
	methods: {    
		preview(file) {      
			this.imgUrl = file.url      
			this.showDialog = true    
		},    
		beforeUpload(file) {      
			// 允许上传的文件类型      
			const types = ['image/jpeg', 'image/gif', 'image/bmp', 'image/png']      
			if (!types.includes(file.type)) {        
				this.$message.error('上传图片只能是 JPG、GIF、BMP、PNG 格式!')        
				return false 
				// return false 会阻止图片的上传操作      
			}      
			const maxSize = 1024 * 1024      
			if (file.size > maxSize) {        
				this.$message.error('图片大小最大不能超过1M')        
				return false      
			}      
			this.currentImageUid = this.fileInfo.uid      
			this.showPercent = true      
			return true    
		},    
		upload(params) {      
			console.log('upload')      
			if (params.file) {        
				// 执行上传操作        
				cos.putObject({          
					Bucket: 'store-1302254460', // 存储桶          
					Region: 'ap-nanjing', // 地域          
					Key: params.file.name, // 文件名          
					Body: params.file, // 要上传的文件对象          
					StorageClass: 'STANDARD', // 上传的模式类型 直接默认 标准模式即可
					onProgress: (progressData) => {            
						this.percent = progressData.percent * 100          
						}        
					}, (err, data) => {          
					// data返回数据之后 应该如何处理          
					if (err) return          
					this.fileList = this.fileList.map(item => {            
						if (item.uid === this.currentImageUid) {              
							return { url: 'http://' + data.Location, name: item.name }            
							}            
						return item          
						})          
					console.log(this.fileList)        
					})      
			}    
		},    
		handleRemove(file, fileList) {      
			this.fileList = this.fileList.filter(item => item.uid !== file.uid)      
			console.log(file)      
			cos.deleteObject({        
				Bucket: 'store-1302254460', /* 必须 */        
				Region: 'ap-nanjing', /* 存储桶所在地域,必须字段 */        
				Key: file.name /* 必须 */      
			}, (err, data) => {       
				console.log(err || data)      
			})    
		},    
		change(file, fileList) {      
			// 为裁剪组件设置要裁剪的图片      
			this.option.img = file.url      
			// 显示裁剪对话框      
			this.cutDialogVisible = true      
			// 存储待上传的文件信息,因为 finish 函数中需要用到      
			this.fileInfo = file      
			this.fileList = fileList    
		},    
		finish() {      
		// 首先调用 beforeUpload 函数做上传前的验证      
		// this.beforeUpload()      
		this.$refs.cropper.getCropBlob(data => {        
			// 检查文件 mime 类型        
			this.beforeUpload({ type: data.type, size: data.size })        
			// 执行上传操作        
			cos.putObject({          
				Bucket: 'store-1302254460', // 存储桶          
				Region: 'ap-nanjing', // 地域          
				Key: this.fileInfo.name, // 文件名          
				Body: data, // 要上传的文件对象          
				StorageClass: 'STANDARD', // 上传的模式类型 直接默认 标准模式即可          
				onProgress: (progressData) => {            
				this.percent = progressData.percent * 100          
			}        
		}, (err, data) => {          
			console.log(data)          
			// data返回数据之后 应该如何处理          
			if (err) return          
			this.fileList = this.fileList.map(item => {            
				if (item.uid === this.currentImageUid) {              
					return { url: 'http://' + data.Location, name: item.name }            
				}            
				return item          
			})          
			this.cutDialogVisible = false        
		})      
	})    
}  
}}
</script>
<style scoped>.cropper {  width: auto;  height: 300px;}</style>

图片的一些操作

  • 旋转和缩放

找到裁剪对话框中的 dialog-footer 元素,在其上键入如下控件

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ZyW4fWMP-1621564330806)(http://blog.daqitc.net/assets/image-20210514112509273.png)]

<template>
  <el-button
    size="mini"
    type="danger"
    plain
    icon="el-icon-zoom-in"
    @click="changeScale(1)"
    >放大</el-button
  >
  <el-button
    size="mini"
    type="danger"
    plain
    icon="el-icon-zoom-out"
    @click="changeScale(-1)"
    >缩小</el-button
  >
  <el-button size="mini" type="danger" plain @click="rotateLeft"
    >? 左旋转</el-button
  >
  <el-button size="mini" type="danger" plain @click="rotateRight"
    >? 右旋转</el-button
  >
</template>

加入如下几个方法

 // 图片缩放    
 changeScale(num) {      
 	num = num || 1      
 	this.$refs.cropper.changeScale(num)    
 },    
 // 向左旋转    
 rotateLeft() {      
 	this.$refs.cropper.rotateLeft()    
 },    
 // 向右旋转    
 rotateRight() {      
 	this.$refs.cropper.rotateRight()    
 },

13.图片地址生成二维码

目标 将图片地址生成二维码显示

我们想完成这样一个功能,当我们拥有头像地址时,将头像地址生成一个二维码,用手机扫码来访问

首先,需要安装生成二维码的插件

$ npm i qrcode

user/index.vue 中引入 qrcode

import QrCode from 'qrcode'

qrcode的用法是

QrCode.toCanvas(dom, info)

dom为一个canvas的dom对象, info为转化二维码的信息

我们尝试将canvas标签放到dialog的弹层中

   <el-dialog title="二维码" :visible.sync="showCodeDialog" @close="imgUrl = ''">
    	<el-row type="flex" justify="center"> <canvas ref="myCanvas" /> </el-row>
  </el-dialog>

在点击用户的图片时,显示弹层,并将图片地址转化成二维码

        showQrCode(url) {
      // url存在的情况下 才弹出层
      if (url) {
        this.showCodeDialog = true // 设置为 true 后,会在dom结构中新增一个 dialog-body,但这个过程是异步的,并不能马上拿到其中的 canvas,所以需要在下面 nextTick 方法的回调中使用        // 有一个方法可以在上一次数据更新完毕,页面渲染完毕之后 
        this.$nextTick(() => {
          // 此时可以确认已经有ref对象了
          QrCode.toCanvas(this.$refs.myCanvas, url) // 将地址转化成二维码
          // 如果转化的二维码后面信息 是一个地址的话 就会跳转到该地址 如果不是地址就会显示内容
        })
      } else {
        this.$message.warning('该用户还未上传头像')
      }
    }

在这里插入图片描述

14. 打印用户信息

目标 完成个人信息和工作信息的打印功能

14.1 新建打印页面及路由

在 views/user 目录下新建 print.vue

创建页面组件

<template>
  <div id="myPrint" class="dashboard-container">
    <div class="app-container">
      <el-card style="margin-bottom: 16px">
        <el-breadcrumb separator="/" class="titInfo">
          <el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
          <el-breadcrumb-item>
            <router-link :to="{ path: '/user/index' }">用户管理</router-link>
          </el-breadcrumb-item>
          <el-breadcrumb-item>
            <router-link :to="{ path: '/user/detail?id=' + userId }"
              >个人详情</router-link
            >
          </el-breadcrumb-item>
          <el-breadcrumb-item>打印</el-breadcrumb-item>
        </el-breadcrumb>
      </el-card>
      <el-card style="margin-bottom: 16px">
        <el-row type="flex" justify="end">
          <el-button v-print="printObj" size="small" type="primary"
            >打印</el-button
          >
        </el-row>
      </el-card>
      <el-card>
        <div>
          <h2 class="centInfo">用户信息表</h2>
          <table cellspacing="0" width="100%" class="tableList">
            <tr class="title">
              <td colspan="8" class="centInfo">基本信息</td>
            </tr>
            <tr>
              <th style="width: 10%">用户名</th>
              <td colspan="6" style="width: 80%">{{ formData.username }}</td>
              <td rowspan="5" style="width: 10%">
                <img :src="formData.avatar" />
              </td>
            </tr>
            <tr>
              <th>个人邮箱</th>
              <td>{{ formData.email }}</td>
            </tr>
            <tr>
              <th>联系电话</th>
              <td colspan="7">{{ formData.mobile }}</td>
            </tr>

            <tr>
              <th>部门</th>
              <td>{{ department }}</td>
            </tr>
          </table>
          <div class="foot">签字:___________日期:___________</div>
        </div>
      </el-card>
    </div>
  </div>
</template>

<script>

import { getDetail } from '@/api/user'
import { getDepartment } from '@/api/department'
export default {
  data() {
    return {
      formData: {},
      userId: this.$route.params.id,
      printObj: {
        id: 'myPrint'
      },
      department: ''
    }
  },
  // 创建完毕状态
  async created() {
    const userId = this.$route.params.id
    this.userId = userId
    const res = await getDetail(userId)
    this.formData = res.data.data
    let res_depart = await getDepartment()
    res_depart.data.data.forEach(item => {
      if (item.department_id == this.formData.department_id) {
        this.department = item.department_title
      }
    })
  },
  // 组件更新
  methods: {

  }
}
</script>

<style lang="scss">
.foot {
  padding: 30px 0;
  text-align: right;
}
</style>

该页面内容实际上就是读取用户信息并以利于打印的表格形式展示

新建打印页面路由

{    
	path: 'print/:id', // 二级默认路由    
	component: () => import('@/views/employees/print'), // 按需加载    
	hidden: true,    
	meta: {      
		title: '打印', // 标记当前路由规则的中文名称 后续在做左侧菜单时 使用      
		icon: 'people'    
	}  
}

完成详情到打印的跳转

detail.vue 中添加如下代码

 <el-row type="flex" justify="end">            
 	<el-tooltip content="打印个人基本信息">              
	 	<router-link :to="`/user/print/${this.userId}`">                
	 	<i class="el-icon-printer" />              
	 	</router-link>            
 	</el-tooltip>          
 </el-row>
14.2 vue-print-nb进行打印

首先,打印功能我们借助一个比较流行的插件

$ npm i vue-print-nb

它的用法是

首先注册该插件

import Print from 'vue-print-nb'Vue.use(Print);

使用v-print指令的方式进行打印

在 print.vue 中加入如下代码

  <el-row type="flex" justify="end">          
  	<el-button v-print="printObj" size="small" type="primary">打印</el-button>   
  </el-row>   
  printObj: {        
  	id: 'myPrint'   
  }

15. 全屏插件的引用

目标:实现页面的全屏功能

全屏功能可以借助一个插件来实现

第一步,安装全局插件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() {
      // e.target.className.animVal = 'fullscreen2'
      if (!ScreenFull.isEnabled) {
        // 此时全屏不可用
        this.$message.warning('此时全屏组件不可用')
        return
      }
      // document.documentElement.requestFullscreen()  原生js调用
      //   如果可用 就可以全屏
      ScreenFull.toggle()
    }
  }
}
</script>

第三步,全局注册该组件 src/components/index.js

import ScreenFull from './ScreenFull'Vue.component('ScreenFull', ScreenFull) // 注册全屏组件

第四步,放置于**layout/navbar.vue**中

<screen-full class="right-menu-item" />.right-menu-item {   vertical-align: middle;}

提交代码

本节任务: 实现页面的全屏功能

16. 动态主题的设置

目标: 实现动态主题的设置

我们想要实现在页面中实时的切换颜色,此时页面的主题可以跟着设置的颜色进行变化

简单说明一下它的原理: element-ui 2.0 版本之后所有的样式都是基于 SCSS 编写的,所有的颜色都是基于几个基础颜色变量来设置的,所以就不难实现动态换肤了,只要找到那几个颜色变量修改它就可以了。 首先我们需要拿到通过 package.json 拿到 element-ui 的版本号,根据该版本号去请求相应的样式。拿到样式之后将样色,通过正则匹配和替换,将颜色变量替换成你需要的,之后动态添加 style 标签来覆盖原有的 css 样式。

第一步, 封装颜色选择组件 ThemePicker 代码地址:@/components/ThemePicker

注意:本章节重点在于集成,内部的更换主题可以先不用关心。

实现代码

<template>
  <el-color-picker
    v-model="theme"
    :predefine="[
      '#409EFF',
      '#1890ff',
      '#304156',
      '#212121',
      '#11a983',
      '#13c2c2',
      '#6959CD',
      '#f5222d',
    ]"
    class="theme-picker"
    popper-class="theme-picker-dropdown"
  />
</template>

<script>
const version = require('element-ui/package.json').version // element-ui version from node_modules
const ORIGINAL_THEME = '#409EFF' // default color
export default {
  data() {
    return {
      chalk: '', // content of theme-chalk css
      theme: ''
    }
  },
  computed: {
    defaultTheme() {
      return this.$store.state.settings.theme
    }
  },
  watch: {
    defaultTheme: {
      handler: function (val, oldVal) {
        this.theme = val
      },
      immediate: true
    },
    async theme(val) {
      const oldVal = this.chalk ? this.theme : ORIGINAL_THEME
      if (typeof val !== 'string') return
      const themeCluster = this.getThemeCluster(val.replace('#', ''))
      const originalCluster = this.getThemeCluster(oldVal.replace('#', ''))
      console.log(themeCluster, originalCluster)
      const $message = this.$message({
        message: '  Compiling the theme',
        customClass: 'theme-message',
        type: 'success',
        duration: 0,
        iconClass: 'el-icon-loading'
      })
      const getHandler = (variable, id) => {
        return () => {
          const originalCluster = this.getThemeCluster(ORIGINAL_THEME.replace('#', ''))
          const newStyle = this.updateStyle(this[variable], originalCluster, themeCluster)
          let styleTag = document.getElementById(id)
          if (!styleTag) {
            styleTag = document.createElement('style')
            styleTag.setAttribute('id', id)
            document.head.appendChild(styleTag)
          }
          styleTag.innerText = newStyle
        }
      }
      if (!this.chalk) {
        const url = `https://unpkg.com/element-ui@${version}/lib/theme-chalk/index.css`
        await this.getCSSString(url, 'chalk')
      }
      const chalkHandler = getHandler('chalk', 'chalk-style')
      chalkHandler()
      const styles = [].slice.call(document.querySelectorAll('style'))
        .filter(style => {
          const text = style.innerText
          return new RegExp(oldVal, 'i').test(text) && !/Chalk Variables/.test(text)
        })
      styles.forEach(style => {
        const { innerText } = style
        if (typeof innerText !== 'string') return
        style.innerText = this.updateStyle(innerText, originalCluster, themeCluster)
      })
      this.$emit('change', val)
      $message.close()
    }
  },
  methods: {
    updateStyle(style, oldCluster, newCluster) {
      let newStyle = style
      oldCluster.forEach((color, index) => {
        newStyle = newStyle.replace(new RegExp(color, 'ig'), newCluster[index])
      })
      return newStyle
    },
    getCSSString(url, variable) {
      return new Promise(resolve => {
        const xhr = new XMLHttpRequest()
        xhr.onreadystatechange = () => {
          if (xhr.readyState === 4 && xhr.status === 200) {
            this[variable] = xhr.responseText.replace(/@font-face{[^}]+}/, '')
            resolve()
          }
        }
        xhr.open('GET', url)
        xhr.send()
      })
    },
    getThemeCluster(theme) {
      const tintColor = (color, tint) => {
        let red = parseInt(color.slice(0, 2), 16)
        let green = parseInt(color.slice(2, 4), 16)
        let blue = parseInt(color.slice(4, 6), 16)
        if (tint === 0) { // when primary color is in its rgb space
          return [red, green, blue].join(',')
        } else {
          red += Math.round(tint * (255 - red))
          green += Math.round(tint * (255 - green))
          blue += Math.round(tint * (255 - blue))
          red = red.toString(16)
          green = green.toString(16)
          blue = blue.toString(16)
          return `#${red}${green}${blue}`
        }
      }
      const shadeColor = (color, shade) => {
        let red = parseInt(color.slice(0, 2), 16)
        let green = parseInt(color.slice(2, 4), 16)
        let blue = parseInt(color.slice(4, 6), 16)
        red = Math.round((1 - shade) * red)
        green = Math.round((1 - shade) * green)
        blue = Math.round((1 - shade) * blue)
        red = red.toString(16)
        green = green.toString(16)
        blue = blue.toString(16)
        return `#${red}${green}${blue}`
      }
      const clusters = [theme]
      for (let i = 0; i <= 9; i++) {
        clusters.push(tintColor(theme, Number((i / 10).toFixed(2))))
      }
      clusters.push(shadeColor(theme, 0.1))
      return clusters
    }
  }
}
</script>

<style>
.theme-message,
.theme-picker-dropdown {
  z-index: 99999 !important;
}
.theme-picker .el-color-picker__trigger {
  height: 26px !important;
  width: 26px !important;
  padding: 2px;
}
.theme-picker-dropdown .el-color-dropdown__link-btn {
  display: none;
}
.el-color-picker {
  height: auto !important;
}
</style>

注册代码

import ThemePicker from './ThemePicker'Vue.component('ThemePicker', ThemePicker)

第二步, 放置于**layout/navbar.vue**中

   <!-- 放置全屏插件 -->    <theme-picker class="right-menu-item" />

17.多语言实现

**目标**实现国际化语言切换

17.1 初始化多语言包

本项目使用国际化 i18n 方案。通过 vue-i18n而实现。

第一步,我们需要首先国际化的包

 $ npm i vue-i18n

第二步,需要单独一个多语言的实例化文件 src/lang/index.js

import Vue from 'vue' // 引入Vue
import VueI18n from 'vue-i18n' // 引入国际化的包
import Cookie from 'js-cookie' // 引入cookie包
import customZH from './zh' // 引入自定义中文包
import customEN from './en' // 引入自定义英文包
import customJA from './ja'
import elementEN from 'element-ui/lib/locale/lang/en' // 引入饿了么的英文包
import elementZH from 'element-ui/lib/locale/lang/zh-CN' // 引入饿了么的中文包
import elementJA from 'element-ui/lib/locale/lang/ja' // 引入饿了么的中文包
Vue.use(VueI18n) // 全局注册国际化包
export default new VueI18n({
  locale: Cookie.get('language') || 'zh', // 从cookie中获取语言类型 获取不到就是中文
  messages: {
    en: {
      ...elementEN, // 将饿了么的英文语言包引入
      ...customEN
    },
    zh: {
      ...elementZH, // 将饿了么的中文语言包引入
      ...customZH
    },
    ja: {
      ...elementJA, // 将饿了么的日文语言包引入
      ...customJA
    }
  }
})

上面的代码的作用是将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)
})
17.2 引入自定义语言包

此时,element已经变成了zh,也就是中文,但是我们常规的内容怎么根据当前语言类型显示?

这里,针对英文和中文,我们可以提供两个不同的语言包 src/lang/zh.js , src/lang/en.js

该语言包,我们已经在资源中提供

第四步,在index.js中同样引入该语言包

import Vue from 'vue' // 引入Vue
import VueI18n from 'vue-i18n' // 引入国际化的包
import Cookie from 'js-cookie' // 引入cookie包
import customZH from './zh' // 引入自定义中文包
import customEN from './en' // 引入自定义英文包
import customJA from './ja'
import elementEN from 'element-ui/lib/locale/lang/en' // 引入饿了么的英文包
import elementZH from 'element-ui/lib/locale/lang/zh-CN' // 引入饿了么的中文包
import elementJA from 'element-ui/lib/locale/lang/ja' // 引入饿了么的中文包
Vue.use(VueI18n) // 全局注册国际化包
export default new VueI18n({
  locale: Cookie.get('language') || 'zh', // 从cookie中获取语言类型 获取不到就是中文
  messages: {
    en: {
      ...elementEN, // 将饿了么的英文语言包引入
      ...customEN
    },
    zh: {
      ...elementZH, // 将饿了么的中文语言包引入
      ...customZH
    },
    ja: {
      ...elementJA, // 将饿了么的日文语言包引入
      ...customJA
    }
  }
})
17.3 左侧菜单应用多语言包

自定义语言包的内容怎么使用

第五步,在左侧菜单应用

当我们全局注册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...)**的方式获取

现在,我们已经完成了多语言的接入,现在封装切换多语言的组件

17.4 封装多语言插件

第六步,封装多语言组件 src/components/lang/index.vue

<template>
  <el-dropdown
    trigger="click"
    @command="changeLanguage"
    title="切换语言"
    style="cursor: pointer; position: absolute; right: 340px; top: 18px"
  >
    <!-- 这里必须加一个div -->
    <div>
      <svg-icon style="color: #fff; font-size: 20px" icon-class="language" />
      <span
        class="el-dropdown-link"
        style="color: #fff; position: relative; top: -15px"
      >
        {{ $t("switchlanguage")
        }}<i class="el-icon-arrow-down el-icon--right"></i>
      </span>
    </div>
    <el-dropdown-menu slot="dropdown" style="margin: 0; position: relative">
      <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-item command="ja" :disabled="'ja' === $i18n.locale"
        >日本語</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>
<style scoped>
.h1 {
  cursor: pointer;
}
</style>

第七步,在Navbar组件中引入

 <!-- 放置切换多语言 -->      
 <lang class="right-menu-item" />      
 <!-- 放置主题 -->      
 <theme-picker class="right-menu-item" />      
 <!-- 放置全屏插件 -->      
 <screen-full class="right-menu-item" />

18.tab页的视图引入

目标: 实现tab页打开路由的功能

当前我们实现的打开页面,看到一个页面之后,另一个页面就会关闭,为了显示更加有效率,我们可以引入多页签组件

多页签的组件的代码过于繁杂,开发实际需要的是集成和调用能力,所以我们只是将开发好的组件集成到当前的功能项中即可。

在资源目录中,**多页签**目录下放置的是 组件和vuex模块

第一步,将组件TagsView目录放置到**src/components** , 并全局注册

import TagsView from './TagsView'Vue.component('TagsView', TagsView)

第二步,将Vuex模块**tagsView.js放置到 **src/store/modules

并在store中引入该模块

import tagsView from './modules/tagsView'
const store = new Vuex.Store({  
modules: {    
	app,    
	settings,    
	user,    
	permission,    
	tagsView  },  
	getters
})

第三步,在**src/layout/Index.vue**中引入该组件

<template>  
	<div :class="classObj" class="app-wrapper">    
		<div v-if="device==='mobile'&&sidebar.opened" class="drawer-bg" @click="handleClickOutside" />    
			<sidebar class="sidebar-container" />    
			<div class="main-container">      
				<div :class="{'fixed-header':fixedHeader}">        
					<navbar />        
					<!-- 放置tabsview -->        
					<tags-view />      
				</div>      
			<app-main />    
		</div>  
	</div>
</template>

19. 打包之前的路由模式

**目标**配置打包之前的路由模式

在SPA单页应用中,有两种路由模式

hash模式 : #后面是路由路径,特点是前端访问,#后面的变化不会经过服务器

history模式:正常的/访问模式,特点是后端访问,任意地址的变化都会访问服务器

开发到现在,我们一直都在用hash模式,打包我们尝试用history模式

改成history模式非常简单,只需要将路由的mode类型改成history即可

const createRouter = () => new Router({  
	mode: 'history', 
	// require service support  scrollBehavior: () => ({ 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 support  
	base: '/hr/', 
	// 配置项目的基础地址  
	scrollBehavior: () => ({ y: 0 }), // 管理滚动行为 如果出现滚动 切换就让 让页面回到顶部  
	routes: [...constantRoutes] // 改成只有静态路由
})

此时,我们会发现地址已经变成我们想要的样子了

image-20200804014626686

提交代码

19.1 性能分析和CDN的应用

目标: 对开发的应用进行性能分析和CDN的应用

19.1.1 性能分析

我们集成了 功能,写了很多组件,最终都会打包成一堆文件,那么真实运行的性能如何呢?

我们可以使用vue-cli本身提供的性能分析工具,对我们开发的所有功能进行打包分析

它的应用非常简单

$ npm run preview -- --report

这个命令会从我们的**入口main.js**进行依赖分析,分析出最大的包,方便我们进行观察和优化

执行完这个命令,我们会看到如下的页面

在这里插入图片描述

如图所以,方块越大,说明该文件占用的文件越大,文件越大,对于网络带宽和访问速度的要求就越高,这也就是我们优化的方向

像这种情况,我们怎么优化一下呢

19.1.2 webpack排除打包

CDN是一个比较好的方式

文件不是大吗?我们就不要把这些大的文件和那些小的文件打包到一起了,像这种xlsx,element这种功能性很全的插件,我们可以放到CDN服务器上,一来,减轻整体包的大小,二来CDN的加速服务可以加快我们对于插件的访问速度

使用方式

先找到 vue.config.js, 添加 externalswebpack 不打包 xlsxelement

vue.config.js

 // 排除 elementUI xlsx  和 vue   
 externals:{        
 	'vue': 'Vue',        
 	'element-ui': 'ELEMENT',        
 	'xlsx': 'XLSX'     
 }

再次运行,我们会发现包的大小已经大幅减小

19.2 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: []
}
// 通过环境变量 来区分是否使用cdn
const 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文件目录
  }
}
19.2.1 注入CDN到模板

之后通过 html-webpack-plugin注入到 index.html之中:

config.plugin('html').tap(args => {  args[0].cdn = cdn  return 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
Logo

前往低代码交流专区

更多推荐