**vue-element-admin 框架结构粗解**
vue-element-admin 是一个后台前端解决方案,它基于 vue 和 element-ui实现。它使用了最新的前端技术栈,内置了 i18 国际化解决方案,动态路由,权限验证,提炼了典型的业务模型,提供了丰富的功能组件,它可以帮助你快速搭建企业级中后台产品原型。
安装cnpm来解决依赖安装失败问题,安装完之后,可以使用cnpm install安装已有或新增加的依赖.
├── 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
我们在做项目时 其中最关注的就是src目录, 里面是所有的源代码和资源, 至于其他目录, 都是对项目的环境和工具的配置。
开发阶段在config/index.js配置proxyTable,目前使用/api代理后端的IP+端口;
在生产环境,同样要配置/api代理后端的IP+端口(在Nginx配置)
代码如下:
location /api/ {
proxy_pass http://139.196.59.97:8090/;
}
登录阿里巴巴图标库(http://iconfont.cn/home/index?spm=a313x.7781069.1998910419.2),创建项目。
把找到的icon放入购物车
把找到的icon放入个人项目
引入icon
单个icon可以跳过上面步骤,直接下载svg文件,放进src/icons/svg下,就可以使用了(不需要引入js或css)
在项目中也可以直接import导入素材,并将url绑定到data上,用方式使用
注意:如果项目需要兼容ie,不要使用svg图片,删除package.json中svg-sprite-loader(因为svg-sprite-loader插件不兼容ie浏览器)
目标
: 了解当前模板的基本运行机制和基础架构
├── 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 # 配置文件
1.VUE 实例化
2.挂载路由
3.挂载vuex-store
4.注册ElementUI
5.根组件
import Vue from 'vue'
import 'normalize.css/normalize.css' // A modern alternative to CSS resets
import ElementUI from 'element-ui'
import 'element-ui/lib/theme-chalk/index.css'
// import locale from 'element-ui/lib/locale/lang/en' // lang i18n
import '@/styles/index.scss' // global css
import App from './App'
import store from './store'
import router from './router'
import Components from '@/components'
import * as directives from '@/directives'
import * as filters from '@/filters'
import i18n from '@/lang'
import CheckPermission from '@/mixin/checkPermission'
import '@/icons' // icon
import '@/permission' // permission control
// set ElementUI lang to EN
// Vue.use(ElementUI, { locale })
// 如果想要中文版 element-ui,按如下方式声明
Vue.use(ElementUI, {
// element本身支持i18n的处理
// 此时 i18n就会根据当前的locale属性去寻找对应的显示内容
i18n: (key, value) => i18n.t(key) // t方法 会去对应的语言包里寻找对应的内容
// 改变locale的值 就可以改变对应的当前语言
})
// for in
Object.keys(directives).forEach(key => {
Vue.directive(key, directives[key]) // 注册自定义指令
})
Object.keys(filters).forEach(key => {
Vue.filter(key, filters[key]) // 注册自定义过滤器
})
// 注册自定义组件
Vue.use(Components)
// 全局混入检查对象
Vue.mixin(CheckPermission) // 表示所有的组件都拥有了检查的方法
Vue.config.productionTip = false
new Vue({
el: '#app',
router,
store,
i18n,
render: h => h(App)
})
请注释掉**mock数据
的部分,删除src下的mock
**文件夹,我们开发的时候用不到模拟数据,如图
同时,请注释掉**vue.config.js
**中的 before: require(‘./mock/mock-server.js’)
<template>
<div id="app">
<router-view /> //一级路由器
</div>
</template>
<script>
export default {
name: 'App'
}
</script>
\src\layout\index.vue
src下,除了main.js还有两个文件,
permission.js
和**settings.js
**
permission.js
是控制页面登录权限的文件, 此处的代码没有经历构建过程会很难理解, 所以先将此处的代码进行注释,等我们构建权限功能时,再从0到1进行构建。
注释代码
settings.js
则是对于一些项目信息的配置,里面有三个属性title
(项目名称),fixedHeader
(固定头部),sidebarLogo
(显示左侧菜单logo)
**settings.js
**中的文件在其他的位置会引用到,所以这里暂时不去对该文件进行变动
module.exports = {
// 程序标题
title: 'Vue Admin 管理后台',
// 是否在右侧显示设置入口
showSettings: false,
// 是否显示标签多文档视图
tagsView: true,
// 是否固定标题头
fixedHeader: false,
// 是否显示侧边栏的LOGO
sidebarLogo: false,
/**
* @type {string | array} 'production' | ['production', 'development']
* @description Need show err logs component.
* The default is only used in the production env
* If you want to also use it in dev, you can pass ['production', 'development']
*/
errorLog: 'production'
}
当前的Vuex结构采用了模块形式进行管理共享状态,其架构如下
import Vue from 'vue'
import Vuex from 'vuex'
import getters from './getters'
Vue.use(Vuex)
// https://webpack.js.org/guides/dependency-management/#requirecontext
const modulesFiles = require.context('./modules', true, /\.js$/)
// you do not need `import app from './modules/app'`
// it will auto require all vuex module from modules file
const modules = modulesFiles.keys().reduce((modules, modulePath) => {
// set './app.js' => 'app'
const moduleName = modulePath.replace(/^\.\/(.*)\.\w+$/, '$1')
const value = modulesFiles(modulePath)
modules[moduleName] = value.default
return modules
}, {})
const store = new Vuex.Store({
modules,
getters
})
export default
store
其中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
import Vue from 'vue'
import Router from 'vue-router'
// 引入多个模块的规则
import approvalsRouter from './modules/approvals'
import departmentsRouter from './modules/departments'
import employeesRouter from './modules/employees'
import permissionRouter from './modules/permission'
import attendancesRouter from './modules/attendances'
import salarysRouter from './modules/salarys'
import settingRouter from './modules/setting'
import socialRouter from './modules/social'
import userRouter from './modules/user'
Vue.use(Router)
/* Layout */
import Layout from '@/layout'
/**
* Note: sub-menu only appear when route children.length >= 1
* Detail see: https://panjiachen.github.io/vue-element-admin-site/guide/essentials/router-and-nav.html
*
* hidden: true if set true, item will not show in the sidebar(default is false)
* alwaysShow: true if set true, will always show the root menu
* if not set alwaysShow, when item has more than one children route,
* it will becomes nested mode, otherwise not show the root menu
* redirect: noRedirect if set noRedirect will no redirect in the breadcrumb
* name:'router-name' the name is used by (must set!!!)
* meta : {
roles: ['admin','editor'] control the page roles (you can set multiple roles)
title: 'title' the name show in sidebar and breadcrumb (recommend set)
icon: 'svg-name'/'el-icon-x' the icon show in the sidebar
breadcrumb: false if set false, the item will hidden in breadcrumb(default is true)
activeMenu: '/example/list' if set path, the sidebar will highlight the path you set
}
*/
/**
* constantRoutes
* a base page that does not have permission requirements
* all roles can be accessed
*/
export const constantRoutes = [
{
path: '/login',
component: () => import('@/views/login/index'),
hidden: true
},
{
path: '/404',
component: () => import('@/views/404'),
hidden: true
},
{
path: '/',
component: Layout,
redirect: '/dashboard',
children: [{
path: 'dashboard',
name: 'dashboard',
component: () => import('@/views/dashboard/index'),
meta: { title: '首页', icon: 'dashboard' }
}]
},
{
path: '/import',
component: Layout,
hidden: true, // 不显示在左侧菜单中
children: [{
path: '', // 什么都不写表示默认的二级路由
component: () => import('@/views/import')
}]
},
userRouter // 放置一个都可以访问的路由
// 404 page must be placed at the end !!!
]
// 定义一个动态路由变量
// 这里导出这个变量 后面做权限的时候会用到
export const asyncRoutes = [
approvalsRouter,
departmentsRouter,
employeesRouter,
permissionRouter,
attendancesRouter,
salarysRouter,
settingRouter,
socialRouter
]
const createRouter = () => new Router({
mode: 'history', // require service support
base: 'hr/',
scrollBehavior: () => ({ y: 0 }),
routes: [...constantRoutes] // 静态路由和动态路由的临时合并
})
const router = createRouter() // 实例化一个路由
// Detail see: https://github.com/vuejs/vue-router/issues/1234#issuecomment-357941465
export function resetRouter() {
const newRouter = createRouter()
router.matcher = newRouter.matcher // reset router
}
export default router
该项目还使用了scss作为css的扩展语言,在**
styles
**目录下,我们可以发现scss的相关文件,相关用法 我们下一小节 进行讲解[
@import './variables.scss';
@import './mixin.scss';
@import './transition.scss';
@import './element-ui.scss';
@import './sidebar.scss';
@import './btn.scss';
body {
height: 100%;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
font-family: Helvetica Neue, Helvetica, PingFang SC, Hiragino Sans GB, Microsoft YaHei, Arial, sans-serif;
}
label {
font-weight: 700;
}
html {
height: 100%;
box-sizing: border-box;
}
#app {
height: 100%;
}
以上就是vue-element-admin的基础和介绍,希望大家在这过程中体会 一个基础的模板运行机制
本节任务
: 大家根据目录结构和设计图,对以上的内容进行了解
目标
: 了解和学习Scss处理器的规范和用法
官方文档
首先注意,这里的sass和我们的scss是什么关系
sass和scss其实是**一样的
**css预处理语言,SCSS 是 Sass 3 引入新的语法,其后缀名是分别为 .sass和.scss两种。
SASS版本3.0之前的后缀名为.sass,而版本3.0之后的后缀名.scss。
两者是有不同的,继sass之后scss的编写规范基本和css一致,sass时代是有严格的缩进规范并且没有‘{}’和‘;’。
而scss则和css的规范是一致的。
目标
将一些公共的图片和样式资源放入到 规定目录中
我们已经将整体的基础模块进行了简单的介绍,接下来,我们需要将该项目所用到的图片和样式进行统一的处理
图片资源
图片资源在课程资料的图片文件中,我们只需要将**
common
**文件夹拷贝放置到 **assets
**目录即可
样式
样式资源在 资源/样式目录下
修改**variables.scss
**
新增**common.scss
**
我们在**variables.scss
**添加了一些基础的变量值
我们提供了 一份公共的**common.scss
**样式,里面内置了一部分内容的样式,在开发期间可以帮助我们快速的实现页面样式和布局
将两个文件放置到styles目录下,然后在**index.scss
**中引入该样式
@import './common.scss'; //引入common.scss样式表
提交代码
本节注意
:注意在scss文件中,通过**@import** 引入其他样式文件,需要注意最后加分号,否则会报错
本节任务
将公共资源的图片和样式放置到规定位置
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 => {
if (store.getters.token) {
config.headers['X-Token'] = getToken()
}
return config
},
error => {
return Promise.reject(error)
}
)
响应拦截器主要处理 返回的**数据异常
** 和**数据结构
**问题
service.interceptors.response.use(
response => {
const res = response.data
if (res.code !== 20000) {
Message({
message: res.message || 'Error',
type: 'error',
duration: 5 * 1000
})
if (res.code === 50008 || res.code === 50012 || res.code === 50014) {
MessageBox.confirm('You have been logged out, you can cancel to stay on this page, or log in again', 'Confirm logout', {
confirmButtonText: 'Re-Login',
cancelButtonText: 'Cancel',
type: 'warning'
}).then(() => {
store.dispatch('user/resetToken').then(() => {
location.reload()
})
})
}
return Promise.reject(new Error(res.message || 'Error'))
} else {
return res
}
},
error => {
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实例
我们习惯性的将所有的网络请求 放置在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() {
}
提交代码
本节任务
: 将request和用户模块的代码进行清理,理解request和模块封装
import request from '@/utils/request'
export function login(data) {
return request({
url: '/vue-admin-template/user/login',
method: 'post',
data
})
}
export function logout() {
return request({
url: '/vue-admin-template/user/logout',
method: 'post'
})
}
如果是作为管理平台使用时,侧边栏Sidebar用作菜单栏显示,菜单中遇到问题如下:
二级菜单中如果面包屑一级菜单不显示:设置children中第一个path=""来解决;
router.js中定义静态路由时,name不可以相同,否则会出现TagView组件关闭功能异常(子路由的name相同会导致刷新页面 路由变化)
router.js中定义动态路由时,父路由的name和children中子路由的name不可以相同而且父路由的name不可以缺失,否则this.$router.push()打开第一个子路由时,会打开另一个画面
此外目前动态菜单的实现逻辑是在router/index.js定义所有前端页面的路由表,在登录成功后getInfo方法,拿到后台菜单code的集合,与前端的路由表的menu属性进行比较,如果存在则显示在侧边栏,否则不显示(相关代码位于store/modules/permission.js);也可以改为纯动态菜单实现,前端不再维护一个路由表,完全通过后台的多级菜单关系数据来动态生成前端的路由表。
如果只是在项目中增加静态页面,在router/index.js中按照正常router写法即可。
/**
* constantRoutes
* a base page that does not have permission requirements
* all roles can be accessed
*/
export const constantRouterMap = [
{
path: '/',
redirect: '/nextShow',
name: '首页',
hidden: true
},
{
path: '/nextShow',
hidden:false,
component: _import('product/nextShow')
},
{
path: '/next',
hidden:false,
component: _import('product/next')
}
]
export default new Router({
scrollBehavior: () => ({ y: 0 }),
routes: constantRouterMap
})
本地服务端口:在 vue.config.js
中进行设置
vue.config.js
就是vue项目相关的编译,配置,打包,启动服务相关的配置文件,它的核心在于webpack,但是又不同于webpack,相当于改良版的webpack.
// If your port is set to 80,
// use administrator privileges to execute the command line.
// For example, Mac: sudo npm run
// You can change the port by the following methods:
// port = 9528 npm run dev OR npm run dev --port = 9528
const port = process.env.port || process.env.npm_config_port || 9528 // dev port
在项目下, 我们发现了 .env.development 和 .env.production 两个文件
development => 开发环境
production => 生产环境
当我们运行npm run dev进行开发调试的时候,此时会加载执行 .env.development 文件内容
当我们运行npm run build:prod进行生产环境打包的时候,会加载执行 .env.production 文件内容
网站名称实际在configureWebpack选项中的name选项,通过阅读代码,我们会发现name实际上来源于src目录下的 settings.js
文件
module.exports = {
// 程序标题
title: 'Vue ERP 管理后台',
// 是否在右侧显示设置入口
showSettings: false,
// 是否显示标签多文档视图
tagsView: true,
// 是否固定标题头
fixedHeader: false,
// 是否显示侧边栏的LOGO
sidebarLogo: false,
/**
* @type {string | array} 'production' | ['production', 'development']
* @description Need show err logs component.
* The default is only used in the production env
* If you want to also use it in dev, you can pass ['production', 'development']
*/
errorLog: 'production'
}
1 设置头部背景
"title-container">
"title">
"@/assets/common/login-logo.png" alt="">
2 设置背景图片
.login-container {
background-image: url("https://img0.baidu.com/it/u=3612597965,1770541226&fm=26&fmt=auto&gp=0.jpg");
background-position: center;
background-size: 100% 100%;
}
1 用户名和密码的校验
loginRules: {
username: [{ required: true, trigger: 'blur', validator: validateUsername }],
password: [
{ required: true, trigger: 'blur' },
{ min: 6, max: 12, trigger: 'blur', message: '密码长度应该在6-12位之间' }
]
},
2 关于修饰符
@keyup.enter属于按键修饰符,如果我们想监听在按回车键的时候触发,可以如下编写
v-on:keyup.enter="submit">
1 为什么会出现跨域?
当下,最流行的就是前后分离项目,也就是前端项目和后端接口并不在一个域名之下,那么前端项目访问后端接口必然存在跨域的行为.
2 解决开发环境的跨域问题
开发环境的跨域,也就是在vue-cli脚手架环境下开发启动服务时,我们访问接口所遇到的跨域问题,vue-cli为我们在本地开启了一个服务,可以通过这个服务帮我们代理请求,解决跨域问题
proxy: {
'/api/private/v1/': {
target: 'http://127.0.0.1:8888', // 跨域请求的地址
changeOrigin: true // 只有这个值为true的情况下 才表示开启跨域
}
}
export function login(data) {
return request({
url: 'login',
method: 'post',
data
})
}
// 状态
const state = {}
// 修改状态
const mutations = {}
// 执行异步
const actions = {}
export default {
namespaced: true,
state,
mutations,
actions
}
const state = {
token: null
}
在 utils/auth.js 中,基础模板已经为我们提供了获取 token ,设置 token ,删除 token 的方法,可以直接使用
const TokenKey = 'xiaoyou'
export function getToken() {
return localStorage.getItem(TokenKey)
}
export function setToken(token) {
return localStorage.setItem(TokenKey, token)
}
export function removeToken() {
return localStorage.removeItem(TokenKey)
}
import Cookies from 'js-cookie'
const TokenKey = 'vue_admin_template_token'
const UserIdKey = 'vue_admin_template_userid'
export function getToken() {
return Cookies.get(TokenKey)
}
export function setToken(token) {
return Cookies.set(TokenKey, token)
}
export function removeToken() {
return Cookies.remove(TokenKey)
}
// 由于store/modules/user.js里面getDefaultState需要获得用户ID
// 否则刷新获取不到userid信息导致无法获得用户信息,因此和token一样处理userid
export function getUserId() {
return Cookies.get(UserIdKey)
}
export function setUserId(userid) {
return Cookies.set(UserIdKey, userid)
}
import { getToken, setToken, removeToken } from '@/utils/auth'
const state = {
token: getToken() // 设置token初始状态 token持久化 => 放到缓存中
}
const mutations = {
setToken(state, token) {
state.token = token // 设置token 只是修改state的数据 123 =》 1234
setToken(token) // vuex和 缓存数据的同步
},
removeToken(state) {
state.token = null // 删除vuex的token
removeToken() // 先清除 vuex 再清除缓存 vuex和 缓存数据的同步
}
}
登录action要做的事情,调用登录接口,成功后设置token到vuex,失败则返回失败
const actions = {
async login(context, data) {
const result = await login(data) // 实际上就是一个promise result就是执行的结果
if (result.data.success) {
context.commit('setToken', result.data.data)
}
}
}
可以在 .env.development 和 .env.production 定义变量,变量自动就为当前环境的值
基础模板在以上文件定义了变量VUE_APP_BASE_API,该变量可以作为axios请求的baseURL 在模板中,两个值分别为/dev-api 和 /prod-api
service.interceptors.response.use(response => {
const { success, message, data } = response.data
if (success) {
return data
} else {
Message.error(message) // 提示错误消息
return Promise.reject(new Error(message))
}
}, error => {
Message.error(error.message) // 提示错误信息
return Promise.reject(error) // 返回执行错误 让当前的执行链跳出成功 直接进入 catch
})
登录逻辑:当用户填写完账号密码后向服务端验证是否正确,验证通过后,服务端会返回一个token,拿到token后(可将token存到cookie中,保证页面刷新后可以记住用户登录状态),前端会根据token拉取一个 user_info接口来获取用户详情信息(如用户权限,用户名等)。
权限验证:通过token获取用户对应的 role,动态根据用户的 role 算出其对应有权限的路由,通过 router.addRoutes
动态挂载这些路由。
首先看一下登录的具体流程:
出现this.$store,看到这个东西就去store目录下面找东西
src\views\login\index2.vue
handleLogin() { // 处理密码登录事件
this.$refs.loginForm.validate(valid => {
if (valid) {
this.loading = true
this.$store
.dispatch('user/login', this.loginForm)
.then(() => {
this.$router.push({ path: this.redirect || '/' })
this.loading = false
})
.catch(() => {
this.loading = false
})
} else {
console.log('error submit!!')
return false
}
})
},
src\store\modules\user.js
根据给的地址前面的一段来找,user,就找user
根据后面的找到对应的函数名
说到这里应该对项目结构应该比较清晰了,如果还觉得绕的话,只能说明你对项目的结构理解还不够彻底;
const actions = {
// user login //处理登录业务
login({ commit }, userInfo) { // 常规登陆方式
//userinfo是表单传过来的对象
const { username, password } = userInfo
return new Promise((resolve, reject) => {
//发送网络请求,进行登录操作
user.login({ username: username.trim(), password: password }).then(response => {
const { result } = response // 获取返回对象的 result
// console.log(result)// 记录数据
var token = result.accessToken // 用户令牌
var userId = result.userId // 用户id
// 修改State对象,记录令牌和用户Id
commit('SET_TOKEN', token)
commit('SET_USERID', userId)
// 存储cookie
setToken(token)
setUserId(userId)
resolve()
}).catch(error => {
reject(error)
})
})
},
那么现在又看到一个login函数,从哪里来的呢?
import user from ‘@/api/system/user’ import tokenauth from ‘@/api/system/tokenauth’
import { getToken, setToken, removeToken, getUserId, setUserId } from ‘@/utils/auth’
import router, { resetRouter } from ‘@/router’
可以看到,所有的接口都来自api ,根据花裤衩大佬的模式,直接api目录下面找user就行
import request from '@/utils/request'
import BaseApi from '@/api/base-api'
// 业务类自定义接口实现, 通用的接口已经在BaseApi中定义
class Api extends BaseApi {
login(data) {
return request({
url: '/abp/TokenAuth/Authenticate',
method: 'post',
data: {
UsernameOrEmailAddress: data.username,
password: data.password
}
})
}
getInfo(id) {
return request({
url: '/abp/services/app/User/Get',
method: 'get',
params: { id }
})
}
logout() {
// return request({
// url: '/api/user/logout',
// method: 'post'
// })
var p = new Promise(function(resolve, reject) {
// 做一些异步操作
setTimeout(function() {
resolve({
code: 20000,
data: 'success'
});
}, 200);
});
return p;
}
就这样没了? No.No.No…
直接return request({xxxxx}),request是干嘛的?从哪来的呢?
继续根据花裤衩大佬的模式,直接去utils目录下面找request
此处是api中的login方法,上面import引入了utils下的request,本质上是它调用了request.js,request.js是对axios进行了封装(统一请求拦截和响应拦截),用来请求后台接口的,如果这个接口请求成功,则回到login.vue页面中的.then()方法路由跳转到登录页。
import axios from 'axios'
import { Message } from 'element-ui'
import store from '@/store'
import { getToken } from '@/utils/auth'
// create an axios instance
const service = axios.create({
// baseURL: process.env.VUE_APP_BASE_API, // url = base url + request url
// withCredentials: true, // send cookies when cross-domain requests
timeout: 5000 // request timeout
})
// request 请求拦截
service.interceptors.request.use(
config => {
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()
config.headers['Authorization'] = 'Bearer ' + getToken()
}
return config
},
error => {
console.log(error) // for debug
return Promise.reject(error)
}
)
// response 拦截器
service.interceptors.response.use(
response => {
const res = response.data
return res;
},
error => {
if (error.response) {
var custErr = error.response.data.error
if (custErr) {
console.log('error:' + custErr.details)// for debug
Message({
message: custErr.message,
type: 'error',
duration: 10 * 1000,
showClose: true
})
} else {
console.log('err:' + error) // for debug
Message({
message: error.message,
type: 'error',
duration: 5 * 1000,
showClose: true
})
}
}
return Promise.reject(error)
}
)
export default service
规定的是后台返回的值里面是需要有一个code码的,花裤衩大佬这里规定的是20000是正常的,你只需要将这里改成你自己定义的正常的就行,比如我的是0 (实际以后端为准)
权限验证:通过token获取用户对应的 role,动态根据用户的 role 算出其对应有权限的路由,通过
router.addRoutes
动态挂载这些路由。入口代码位于permission.js
下,使用router的拦截器实现权限拦截;
permission主要负责全局路由守卫和登录判断,可以理解为一个拦截器,下面来看一下具体的逻辑:
从代码上来看,主要分两种情况:有token和无token
import router from './router'
import store from './store'
import { Message } from 'element-ui'
import NProgress from 'nprogress' // progress bar
import 'nprogress/nprogress.css' // progress bar style
import { getToken } from '@/utils/auth' // get token from cookie
import getPageTitle from '@/utils/get-page-title'
NProgress.configure({ showSpinner: false }) // NProgress Configuration
const whiteList = ['/login'] // 白名单,在白名单当中的路由可以免登录,直接进入。
//比较常见的使用场景是进入登陆界面或者是进入扫码下载界面
router.beforeEach(async(to, from, next) => { //全局前置守卫,当有路由进行跳转时就会进入这个守卫,
//这个守卫方法接收三个参数:
//to: Route: 即将要进入的目标 路由对象
//from: Route: 当前导航正要离开的路由
//next: Function: 一定要调用该方法来 resolve 这个钩子。执行效果依赖 next 方法的调用参数。
NProgress.start() // 开始加载进度条
document.title = getPageTitle(to.meta.title)//设置页面标题
const hasToken = getToken() //判断用户是否登录,也就是是否能获得token值,
//在实际开发当中有token值就意味着已经登录了
if (hasToken) { //如果有token值(用户登录了!)
if (to.path === '/login') { //如果路由要跳转到登录页面
next({ path: '/' }) //界面会重定向到首页,
//这种场景不是应用于退出登录的,一般是用于因为有人在路径当中直接输入/login来进行路由跳转,
//然后就会重定向回首页
NProgress.done() //进度条结束
} else { // 否则路由要跳转到其他界面,比如首页
const hasGetUserInfo = store.getters.name // 去vuex仓库拿取用户名字
if (hasGetUserInfo) { // 如果拿取到了用户的名字信息就直接让它跳转到下一个路由
next() // 跳转到下一个路由
} else { //否则
try {
await store.dispatch('user/getInfo') // 触发vux仓库的获取用户信息的事件,获取用户信息
next() // 成功获取到用户信息,跳转到下一个路由
} catch (error) { // 获取用户信息失败,进入以下一级
await store.dispatch('user/resetToken') // 获取用户信息失败后,就删除token
Message.error(error || 'Has Error') //提示相应的错误
next(`/login?redirect=${to.path}`) //并跳转回登录界面,重新登录
NProgress.done() // 进度条结束
}
}
}
} else { // 进入这一级就意味着没有获得token,也就是没有登录
if (whiteList.indexOf(to.path) !== -1) { //进行遍历如果要去往的路由在白名单内
next() // 就允许直接跳转
} else { //否则,说明要去往的路由不在白名单内而且用户也没登录
next(`/login?redirect=${to.path}`) // 那么,哦里给,滚回登录页去吧,或者只能留在登录页
NProgress.done() //结束了,bor
}
}
})
router.afterEach(() => { //全局后置钩子
NProgress.done()
})
刷新页面导致vuex中会话状态丢失的问题解决办法有两个:
通过App.vue中的监听器来解决,思路是在刷新之前把数据存储到浏览器的内存中,刷新完页面好,把之前的数据再次加载到内存
在getToken()判断之后,如果存在hasLogin, 再次检查有没有token数据,没有的话,调接口得到数据后,保存到vuex即可
首先说一下element-admin这个框架实现方式:他是通过获取当前用户的权限去比对路由表,生成当前用户具的权限可访问的路由表,通过
router.addRoutes
动态挂载到router
上。在前端控制权限。
但其实很多公司的业务逻辑可能不是这样的,举一个例子来说,很多公司的需求是每个页面的权限是动态配置的,不像本项目中是写死预设的。但其实原理是相同的。如:你可以在后台通过一个 tree 控件或者其它展现形式给每一个页面动态配置权限,之后将这份路由表存储到后端。当用户登录后得到
roles
,前端根据roles 去向后端请求可访问的路由表,从而动态生成可访问页面,之后就是router.addRoutes
动态挂载到 router 上,你会发现原来是相同的,万变不离其宗。
只是多了一步将后端返回路由表和本地的组件映射到一起。
const map={
login:require('login/index').default // 同步的方式
login:()=>import('login/index') // 异步的方式
}
//你存在服务端的map类似于
const serviceMap=[
{ path: '/login', component: 'login', hidden: true }
]
//之后遍历这个map,动态生成asyncRoutes
并将 component 替换为 map[component]
注意这个routes中分两块路由配置
一块是固定的,无权限的路由配置,
第二块是,带权限的路由配置,根据用户权限来显示侧边栏。
上面的componentsMap其实就是一个动态需要根据权限加载的路由表。
首先要了解一下,侧边栏是如何渲染出来的,看看layout/components/slibar/index.vue有这样一段代码:
"route in permission_routes" :key="route.path" :item="route" :base-path="route.path" />
src\store\getters.js
const getters = {
sidebar: state => state.app.sidebar,
language: state => state.app.language,
size: state => state.app.size,
device: state => state.app.device,
visitedViews: state => state.tagsView.visitedViews,
cachedViews: state => state.tagsView.cachedViews,
token: state => state.user.token,
avatar: state => state.user.avatar,
name: state => state.user.name,
userid: state => state.user.userid,
introduction: state => state.user.introduction,
roles: state => state.user.roles,
roleNames: state => state.user.roleNames,
permits: state => state.user.permits,
permission_routes: state => state.permission.routes,
errorLogs: state => state.errorLog.logs
}
export default getters
注意:
mapGetters 辅助函数仅仅是将 store 中的 getter 映射到局部计算属性
这个permission_routes其实就是路由的元信息,是一个数组
我们在获取用户信息的时候,会得到这个用户所拥有的角色,根据角色来筛选出符合的动态添加的路由,也就是上面permission.js中
routes其实就是上面的两个权限组成的数组,然后传入了GenerateRoutes方法内,我们看下store/permission/generateRoutes中的代码:
首先从@/router/index引入了constantRoutes, componentsMap
可以看出,GenerateRoutes方法中,把从后台获取的菜单list转为tree的格式(其实这块可以在后台根据parentId递归查询即可),由于转为的tree为不可直接用的路由表,必须在把tree数据结构转换为可用的路由格式,存到vuex中。这里我是先给前台组件写在一个map里,key就是路由名称,遍历生成路由的时候,按key取出来组件,后台返回来路由名称后,去这个map里匹配,比如routeMap[‘user’]。
总结:这里就是把原有的获取路由组件逻辑改了一下下,从原来route/index改成异步从后台拿,从后台拿过来之后,在组合成组件。
然后调用了这两句
router.addRoutes(store.getters.addRouters); // 动态添加可访问路由表
router.options.routes=store.getters.routers;
router.addRoutes()方法是,动态添加路由配置,参数是符合路由配置的数组,然后将路由元信息,变成合并后的路由元信息,因为渲染侧边栏需要用到!
左侧导航组件的样式文件 styles/siderbar.scss
.scrollbar-wrapper {
background: url('~@/assets/common/leftnavBg.png') no-repeat 0 100%;
}
module.exports = {
title: '小优电商后台管理系统',
fixedHeader: false,
sidebarLogo: true // 显示logo
}
src/layout/components/Sidebar/Logo.vue
"sidebar-logo-container" :class="{'collapse':collapse}">
"sidebarLogoFade">
"collapse" class="sidebar-logo-link" to="/">
"@/assets/common/logo.png" class="sidebar-logo ">
需要把页面设置成如图样式
"app-breadcrumb">
北京小优智慧城市科技有限公司
"breadBtn">V1.0
"right-menu">
"avatar-container" trigger="click">
"avatar-wrapper">
"@/assets/common/bigUserHeader.png" class="user-avatar">
"name">管理员
"el-icon-caret-bottom" style="color:#fff" />
"dropdown" class="user-dropdown">
"/">
首页
"_blank" href="https://github.com/PanJiaChen/vue-admin-template/">
项目地址
@click.native="logout">
display:block;">退出登录
state: {
token: getToken(),
userInfo: {} // 用于存储用户对象信息
},
// 设置用户信息
setUserInfo(state, userInfo) {
state.userInfo = userInfo
},
// 删除用户信息
removeUserInfo(state) {
state.userInfo = {}
}
// 设置用户信息
setUserInfo(state, userInfo) {
state.userInfo = userInfo
},
// 删除用户信息
removeUserInfo(state) {
state.userInfo = {}
}
{{ username }}
"el-icon-caret-bottom" style="color: #fff" />
全局注册自定义指令语法 - 获取焦点指令
Vue.directive('focus', {
inserted: function (el) {
console.log(el.children[0])
el.children[0].focus()
}
})
在登录组件中使用此指令
"mobile"
v-model="loginForm.username"
v-focus
placeholder="手机号"
name="mobile"
type="text"
tabindex="1"
/>
logout(context) {
// 删除token
context.commit('removeToken') // 不仅仅删除了vuex中的 还删除了缓存中的
// 删除用户资料
context.commit('removeUserInfo') // 删除用户信息
}
removeToken(state) {
state.token = null // 将vuex的数据置空
removeToken() // 同步到缓存
},
removeUseInfo(state) {
state.userInfo = {}
}
async logout() {
await this.$store.dispatch('user/logout') // 这里不论写不写 await 登出方法都是同步的
this.$router.push(`/login`) // 跳到登录
}
请求封装
代码位于src/utils/request/Util.js,这里需要注意的是根据后台接口协议,设置content-type以及Accept(即response-type)属性,目前用到的content-type有表单或者application/json两种;
此外需要注意的是对于文件下载等接口,只有通过表单提交来打开新窗口,接口返回字节流实现下载,所以无法使用该共通来调接口,所以在代码中使用process.env.BASE_URL拿到接口的IP+端口,硬编码来调用下载接口;
request拦截器负责在http请求头上加上token的key/value键值对;
另外在后端接口中不要直接返回404,403等状态码,会导致返回数据在error回调函数中,不方便response拦截器统一处理异常;
所有的后端接口(除了下载文件)必须在src/api声明接口url,import到业务中使用(方便维护,以及和业务解耦)
src/utils/request.js
const timeKey = 'hrsaas-timestamp-key' // 设置一个独一无二的key
// 获取时间戳
export function getTimeStamp() {
return Cookies.get(timeKey)
}
// 设置时间戳
export function setTimeStamp() {
Cookies.set(timeKey, Date.now())
}
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-api
timeout: 5000 // 设置超时时间
})
// 请求拦截器
service.interceptors.request.use(config => {
// config 是请求的配置信息
// 注入token
if (store.getters.token) {
// 只有在有token的情况下 才有必要去检查时间戳是否超时
if (IsCheckTimeOut()) {
// 如果它为true表示 过期了
// token没用了 因为超时了
store.dispatch('user/logout') // 登出操作
// 跳转到登录页
router.push('/login')
return Promise.reject(new Error('token超时了'))
}
config.headers['Authorization'] = store.getters.token
}
return config // 必须要返回的
}, error => {
return Promise.reject(error)
})
// 响应拦截器
service.interceptors.response.use(response => {
// axios默认加了一层data
const { success, message, data } = response.data
// 要根据success的成功与否决定下面的操作
if (success) {
return data
} else {
// 业务已经错误了 还能进then ? 不能 ! 应该进catch
Message.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
async login(context, data) {
const result = await login(data) // 实际上就是一个promise result就是执行的结果
context.commit('setToken', result)
setTimeStamp() // 将当前的最新时间写入缓存
}
因为复杂中台项目的页面众多,不可能把所有的业务都集中在一个文件上进行管理和维护,并且还有最重要的,前端的页面中主要分为两部分,一部分是所有人都可以访问的, 一部分是只有有权限的人才可以访问的,拆分多个模块便于更好的控制。
在 router 目录下新建目录 modules,在此目录中新建各个路由模块
路由模块目录结构
// 导出属于用户的路由规则
import Layout from '@/layout'
export default {
path: '/user', // 路径
name: '', // 给路由规则加一个name
component: Layout, // 组件
// 配置二级路的路由表
children: [{
path: '', // 这里当二级路由的path什么都不写的时候 表示该路由为当前二级路由的默认路由
name: 'user', // 给路由规则加一个name
component: () => import('@/views/Users'),
// 路由元信息 其实就是存储数据的对象 我们可以在这里放置一些信息
meta: {
title: '用户管理' // meta属性的里面的属性 随意定义
}
}]
}
什么叫临时合并?
动态路由是需要权限进行访问的,但是权限的动态路由访问是很复杂的,我们可以先将 静态路由和动态路由进行合并,不考虑权限问题,后面再解决这个问题
路由主文件 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({
scrollBehavior: () => ({ y: 0 }), // 管理滚动行为 如果出现滚动 切换就让 让页面回到顶部
routes: [...constantRoutes, ...asyncRoutes]
})
本系统
// 合并组件模块的路由到一起
export const asyncRoutes = Object.assign(routes, componentsRouter)
Vue.js 允许你自定义过滤器,可被用于一些常见的文本格式化。过滤器可以用在两个地方:双花括号插值和 v-bind 表达式 (后者从 2.1.0+ 开始支持)。过滤器应该被添加在 JavaScript 表达式的尾部,由“管道”符号指示:
{{obj.row.timeOfEntry | 过滤器}}
安装 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])
})
在 views/user 目录下新建一个弹层组件 src/views/user/components/add-user.vue
"新增用户" :visible.sync="dialogVisible" width="50%">
"form" :rules="rules" :model="userForm" label-width="80px">
"用户名" prop="username">
"userForm.username" />
"密码" prop="password">
"userForm.password" />
"手机号" prop="mobile">
"userForm.mobile" />
"邮箱" prop="email">
"userForm.email" />
"部门" prop="department_title">
"userForm.department_title" @focus="getAllDepartment"/>
"showTree" v-loading="loading" :data="treeData" :props="{ label: 'department_title' }" @node-click="handleNodeClick"/>
"footer" class="dialog-footer">
@click="btnCancel">取 消
"primary" @click="saveUser">确 定
父组件中引用,弹出层
import AddUser from './components/add-user'
"adduser" />
点击 新增用户 按钮,弹出弹出层
"small" type="primary" @click="adduser">新增用户
点击按钮展示弹出层的关键,就是设置组件中 el-dialog 组件中的如下属性的值
:visible.sync="dialogVisible"
按钮在父组件,变量 dialogVisible在子组件,如何改变?
可以在子组件中的 props 中新建属性
dialogVisible
然后父组件中为其赋值
最后在父组件的 data 中定义变量
addDialogVisible:false
但是上面的解决方案有一个问题:点击对话框右上角的 X ,或者“取消”按钮,或者点击其他区域关闭对话框时,会抛出如下错误
**错误原因:**进行上面的几个操作时,会导致自动修改 props 中的 dialogVisible 变量的值,但这是不允许的
**解决方案:**参考上面的实现,直接在父组件中操作子组件中的 data 变量的值
$ npm i xlsx
位置:src/components/UploadExcel
import CommonTools from './CommonTools'
import UploadExcel from './UploadExcel'
export default {
install(Vue) {
Vue.component('CommonTools', CommonTools) // 注册工具栏组件
Vue.component('UploadExcel', UploadExcel) // 注册导入excel组件
}
}
{
path: '/import',
component: Layout,
hidden: true, // 隐藏在左侧菜单中
children: [{
path: '', // 二级路由path什么都不写 表示二级默认路由
component: () => import('@/views/import')
}]
},
<upload-excel :on-success="success" />
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()
}
第一步,安装全局插件screenfull
$ npm i screenfull
第二步,封装全屏显示的插件src/components/ScreenFull/index.vue
"fullscreen" style="color:#fff; width: 20px; height: 20px" @click="changeScreen" />
第三步,全局注册该组件 src/components/index.js
import ScreenFull from './ScreenFull'
Vue.component('ScreenFull', ScreenFull) // 注册全屏组件
第四步,放置于layout/navbar.vue中
.right-menu-item {
vertical-align: middle;
}
第一步, 封装颜色选择组件 ThemePicker 代码地址:@/components/ThemePicker
import ThemePicker from './ThemePicker'
Vue.component('ThemePicker', ThemePicker)
第二步, 放置于layout/navbar.vue中
第一步,我们需要首先国际化的包
$ npm i vue-i18n
第二步,需要单独一个多语言的实例化文件 src/lang/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
}
}
})
第三步,在main.js中对挂载 i18n的插件,并设置element为当前的语言
Vue.use(ElementUI, {
i18n: (key, value) => i18n.t(key, value)
})
new Vue({
el: '#app',
router,
store,
i18n,
render: h => h(App)
})
第四步,在左侧菜单应用
<item :icon="onlyOneChild.meta.icon||(item.meta&&item.meta.icon)" :title="$t('route.'+onlyOneChild.name)" />
第五步,封装多语言组件 src/components/lang/index.vue
"click" @command="changeLanguage">
color:#fff;font-size:20px" icon-class="language" />
中文
en
第六步,在Navbar组件中引入
"right-menu-item" />
hash模式 : #后面是路由路径,特点是前端访问,#后面的变化不会经过服务器
history模式:正常的/访问模式,特点是后端访问,任意地址的变化都会访问服务器
改成history模式非常简单,只需要将路由的mode类型改成history即可
改成history模式非常简单,只需要将路由的mode类型改成history即可
const createRouter = () => new Router({
mode: 'history', // require service support
scrollBehavior: () => ({ y: 0 }), // 管理滚动行为 如果出现滚动 切换就让 让页面回到顶部
routes: [...constantRoutes] // 改成只有静态路由
})
先找到 vue.config.js, 添加 externals 让 webpack 不打包 xlsx 和 element
externals:
{
'vue': 'Vue',
'element-ui': 'ELEMENT',
'xlsx': 'XLSX'
}
const cdn = {
css: [
// element-ui css
'https://unpkg.com/element-ui/lib/theme-chalk/index.css' // 样式表
],
js: [
'https://unpkg.com/vue/dist/vue.js',
'https://unpkg.com/element-ui/lib/index.js',
'https://cdn.jsdelivr.net/npm/[email protected]/dist/jszip.min.js',
'https://cdn.jsdelivr.net/npm/[email protected]/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/[email protected]/dist/xlsx.full.min.js', // xlsx 相关
'https://cdn.jsdelivr.net/npm/[email protected]/dist/jszip.min.js' // xlsx 相关
] // 放置js文件目录
}
}
之后通过 html-webpack-plugin注入到 index.html之中:
config.plugin('html').tap(args => {
args[0].cdn = cdn
return args
})
找到 public/index.html。通过你配置的CDN Config 依次注入 css 和 js。
<% for(var css of htmlWebpackPlugin.options.cdn.css) { %>
"stylesheet" href="<%=css%>">
<% } %>
<% for(var js of htmlWebpackPlugin.options.cdn.js) { %>
<% } %>
$ npm run build:prod