权限管理系统项目文档——Vue前端
《Spring Boot+Spring Cloud+Vue+Element项目实战》权限管理系统前端部分
文章目录
项目源码及markdown笔记与其对应pdf见后端篇
第二篇 前端实现篇
1. 搭建开发环境
1.1 技术基础
前端项目将会使用以下几项主要技术和框架。
- Vue.js官网:https://cn.vuejs.org/
- Vue.js教程:http://www.runoob.com/vue2/vue-tutorial.html
- Vue-router教程:https://router.vuejs.org/zh/
- Vuex教程:https://vuex.vuejs.org/zh/guide/
- Element教程:http://element-cn.eleme.io/#/zh-CN
1.2 开发环境
-
VS Code
-
Node.js
Node.js教程:
-
安装webpack
安装好npm后,就可以通过npm命令来下载各种工具了。安装打包工具webpack, -g表示全局安装。
npm install webpack -g
webpack教程:
-
安装vue-cli
安装vue脚手架项目初始化工具vue-cli,-g表示全局安装:
npm install vue-cli -g
-
淘宝镜像
因为NPM使用的是国外中央仓库,有时候下载速度"感人",就像Maven有国内镜像一样,NPM在国内也有镜像可用。建议使用淘宝镜像。
-
安装Yarn
Yarn是 Facebook 发布的node.js包管理器,比npm更快、更高效,可以使用Yarn替代npm。
安装了Node,同时也就安装了NPM,可以使用下面的命令安装:
npm i -g verbose
NPM官方源访问速度实在不敢恭维,建议使用之前切换为淘宝镜像,在Yarn安装完毕之后执行如下指令:
yarn config get registry https://registry.npm.taobao.org
到此为止我们就可以在项目中像使用NPM一样使用Yarn了。使用Yarn和NPM差别不大,具体命令关系如下:
npm install => yarn install npm install --save [package] => yarn add [package] npm install --save--dev [package] => yarn add [package] --dev npm install --global [package] => yarn global add [package] npm unistall --save [package] => yarn remove [package] npm unistall --save-dev [package] => yarn remove [package]
1.3 创建项目
- 生成项目
vue init webpack mango-ui
- 安装依赖
cd mango-ui
yarn install
- 启动运行
npm run dev
2. 前端项目案例
2.1 安装Element
- 安装依赖
yarn add element-ui
-
导入项目
import ElementUI from 'element-ui' import 'element-ui/lib/theme-chalk/index.css' Vue.use(ElementUI)
2.2 页面路由
-
添加页面
我们把components改名为views,并在views目录下添加页面。
-
配置路由
打开router/index.js,添加路由,分别对应不同页面。
在浏览器访问不同路径,路由器会根据路径路由到相应页面。
2.3 安装SCSS
- 安装依赖
yarn add sass-loader node-sass -dev
-
添加配置
在build文件夹下的webpack.base.conf.js的rules标签下添加如下配置:
{ test: /\.scss$/, loaders: ['style', 'css', 'sass'] }
-
如何使用
在页面代码style标签中把lang设置为scss即可。
<style lang="scss"> </style>
2.4 安装axios
axios是一个基于Promise用于浏览器和Node.js的HTTP客户端,我们后续需要用来发送HTTP请求。
- 安装依赖
yarn add axios
-
编写代码
安装完成后修改Home.vue,进行简单的安装测试。
<template> <div class="page"> <h2>Home Page</h2> <el-button type="primary" @click="testAxios()">测试Axios调用</el-button> </div> </template> <script> import axios from 'axios' export default { name: 'Home', methods: { testAxios() { axios.get('http://localhost:8080').then(res => { alert(res.data) }) } } } </script>
-
页面测试
打开主页,单击测试按钮触发HTTP请求,并弹出窗显示返回页面的HTML数据,如下:
2.5 安装Mock.js
为了模拟后台接口提供页面需要的数据,引入Mock.js为我们提供模拟数据,而不用依赖后台接口的完成。
-
安装依赖
执行如下命令,安装依赖包:
yarn add mockjs -dev
-
编写代码
安装完成之后,我们可以写个例子测试一下。
在src目录下新建一个mock目录,创建mock.js,在里面模拟两个接口,分别拦截用户和菜单的请求,并返回相应数据。
import Mock from 'mockjs' Mock.mock('http://localhost:8080/user', { 'name': '@name', // 随机生成姓名 'name': '@email', // 随机生成邮箱 'age|1-10': 5, // 年龄1-10之间 }) Mock.mock('http://localhost:8080/menu', { 'id': '@increment', // id自增 'name': 'menu', // 名称为menu 'order|1-20': 5, // 排序1-20之间 })
修改Home.vue,在页面添加两个按钮,分别触发用户和菜单的处理请求,成功后弹出获取结果。
注意需要在页面通过import mock from "@/mock/mock.js"语句引入mock模块。
<template> <div class="page"> <h2>Home Page</h2> <el-button type="primary" @click="testAxios()">测试Axios调用</el-button> <el-button type="primary" @click="getUser()">获取用户信息</el-button> <el-button type="primary" @click="getMenu()">获取菜单信息</el-button> </div> </template> <script> import axios from "axios"; import mock from "@/mock/mock.js"; export default { name: "Home", methods: { testAxios() { axios.get("http://localhost:8080").then(res => { alert(res.data); }); }, getUser() { axios.get("http://localhost:8080/user").then(res => { alert(JSON.stringify(res.data)); }); }, getMenu() { axios.get("http://localhost:8080/menu").then(res => { alert(JSON.stringify(res.data)); }); } } }; </script>
-
页面测试
在浏览器访问http://localhost:8080/#/,分别单击两个按钮,mock会根据请求url拦截对应请求并返回模拟数据。
-
获取用户信息
-
获取菜单信息
测试成功,这样mock就成功集成进来了。
-
3. 工具模块封装
3.1 封装axios模块
-
封装背景
使用axios发起一个请求是比较简单的事,但是axios没有进行封装复用,项目越来越大,会引起越来越多的代码冗余,让代码变得越来越难维护,所以先对axios进行二次封装,使项目中各个组件能够复用请求,让代码变得更容易维护。
-
封装要点
- 统一url配置。
- 统一api请求。
- request(请求)拦截器。例如,带上token等,设置请求头。
- response(响应)拦截器。例如,统一错误处理,页面重定向等。
- 根据需要,结合vuex做全局的loading动画或者错误处理。
- 将axios封装成Vue插件使用。
-
文件结构
在src目录下,新建一个http文件夹,用来存放http交互api代码,交互结构如下:
- config.js:axios默认配置,包含基础路径等信息。
- axios.js:二次封装axios模块,包含拦截器等信息。
- api.js:请求接口汇总模块,聚合所有模块API。
- index.js:将axios封装成插件,按插件方式引入。
- modules:用户管理、菜单管理等子模块API。
http模块与modules目录文件结构图如下:
-
代码说明
-
config.js
AXIOS相关配置,每个配置项都有带说明。
import { baseUrl } from '@/utils/global' export default { method: 'get', // 基础url前缀 baseUrl: baseUrl, // 请求头信息 headers: { 'Content-Type': 'application/json;charset=UTF-8' }, // 参数 data: {}, // 设置超时时间 timeout: 10000, // 携带凭证 withCredentials: true, // 返回数据类型 responseType: 'json' }
-
axios.js
axios拦截器,可以进行请求拦截和响应拦截,在发送请求和响应请求时执行一些操作。
这里导入类配置文件的信息(如baseURL、headers、withCredentials等设置)到axios对象。
发送请求的时候获取token,如果token不存在,说明未登录,就重定向到系统登录界面,否则携带token继续发送请求。
如果有需要,可以在这里通过response响应拦截器对返回结果进行统一处理后再返回。
import axios from 'axios'; import config from './config'; import Cookies from "js-cookie"; import router from '@/router' export default function $axios(options) { return new Promise((resolve, reject) => { const instance = axios.create({ baseURL: config.baseUrl, headers: config.headers, timeout: config.timeout, withCredentials: config.withCredentials }) // request 请求拦截器 instance.interceptors.request.use( config => { let token = Cookies.get('token') // 发送请求时携带token if (token) { config.headers.token = token } else { // 重定向到登录页面 router.push('/login') } return config }, error => { // 请求发生错误时 console.log('request:', error) // 判断请求超时 if (error.code === 'ECONNABORTED' && error.message.indexOf('timeout') !== -1) { console.log('timeout请求超时') } // 需要重定向到错误页面 const errorInfo = error.response console.log(errorInfo) if (errorInfo) { error = errorInfo.data // 页面那边catch的时候就能拿到详细的错误信息,看最下边的Promise.reject const errorStatus = errorInfo.status; // 404 403 500 ... router.push({ path: `/error/${errorStatus}` }) } return Promise.reject(error) // 在调用的那边可以拿到(catch)你想返回的错误信息 } ) // response 响应拦截器 instance.interceptors.response.use( response => { return response.data }, err => { if (err && err.response) { switch (err.response.status) { case 400: err.message = '请求错误' break case 401: err.message = '未授权,请登录' break case 403: err.message = '拒绝访问' break case 404: err.message = `请求地址出错: ${err.response.config.url}` break case 408: err.message = '请求超时' break case 500: err.message = '服务器内部错误' break case 501: err.message = '服务未实现' break case 502: err.message = '网关错误' break case 503: err.message = '服务不可用' break case 504: err.message = '网关超时' break case 505: err.message = 'HTTP版本不受支持' break default: } } console.error(err) return Promise.reject(err) // 返回接口返回的错误信息 } ) // 请求处理 instance(options).then(res => { resolve(res) return false }).catch(error => { reject(error) }) }) }
-
index.js
这里把axios注册为Vue插件使用,并将api模块挂载到Vue原型的 a p i 对 象 上 。 这 样 在 获 取 t h i s 引 用 的 地 方 就 可 以 通 过 " t h i s . api对象上。这样在获取this引用的地方就可以通过"this. api对象上。这样在获取this引用的地方就可以通过"this.api.子模块.方法"的方式调用api了。
// 导入所有接口 import api from './api' const install = Vue => { if (install.installed) return; install.installed = true; Object.defineProperties(Vue.prototype, { // 注意,此处挂载在 Vue 原型的 $api 对象上 $api: { get() { return api } } }) } export default install
-
api.js
此模块是一个聚合模块,汇合modules目录下的所有子模块API。
/* * 接口统一集成模块 */ import * as login from './modules/login' import * as user from './modules/user' import * as dept from './modules/dept' import * as role from './modules/role' import * as menu from './modules/menu' import * as dict from './modules/dict' import * as config from './modules/config' import * as log from './modules/log' import * as loginlog from './modules/loginlog' // 默认全部导出 export default { login, user, dept, role, menu, dict, config, log, loginlog }
-
user.js
modules目录下的子模块太多。不方便全贴,这里以用户管理模块为例。
import axios from '../axios' /* * 用户管理模块 */ // 保存 export const save = (data) => { return axios({ url: '/user/save', method: 'post', data }) } // 删除 export const batchDelete = (data) => { return axios({ url: '/user/delete', method: 'post', data }) } // 分页查询 export const findPage = (data) => { return axios({ url: '/user/findPage', method: 'post', data }) } // 查找用户的菜单权限标识集合 export const findPermissions = (params) => { return axios({ url: '/user/findPermissions', method: 'get', params }) }
-
global.js
上面的配置文件中引用了global.js,我们把一些全局的配置、常量和方法防止在此文件中。
/** * 全局常量、方法封装模块 * 通过原型挂载到Vue属性 * 通过 this.Global 调用 */ // 后台管理系统服务器地址 // export const baseUrl = 'http://139.196.87.48:8001' export const baseUrl = 'http://localhost:8001' // 系统数据备份还原服务器地址 // export const backupBaseUrl = 'http://139.196.87.48:8002' export const backupBaseUrl = 'http://localhost:8002' export default { baseUrl, backupBaseUrl }
-
main.js
修改main.js导入API模块,并通过Vue.use(api)语句进行使用注册,这样就可以通过"this.$api.子模块.方法"的方式调用后台接口了。
引入global模块,并通过Vue.prototype.global = global语句进行挂载,这样就可以通过this.global.xx来获取全局配置了。
import Vue from 'vue' import App from './App' import router from './router' import api from './http' import global from '@/utils/global' import ElementUI from 'element-ui' import 'element-ui/lib/theme-chalk/index.css' Vue.use(ElementUI) // 引入Element Vue.use(api) // 引入API模块 Vue.prototype.global = global // 挂载全局配置模块 new Vue({ el: '#app', router, render: h => h(App) })
-
-
安装js-cookie
在上面的axios.js中,会用到Cookie获取token,所以需要把相关依赖安装一下。执行以下命令,安装依赖包:
yarn add js-cookie
-
测试案例
-
登录页面
在登录页面Login.vue中添加一个登录按钮,单击处理函数通过axios调用login接口返回数据。成功返回之后弹出框显示token信息,然后将token放入Cookie并跳转到主页。
<template> <div class="page"> <h2>Login Page</h2> <el-button type="primary" @click="login()">登录</el-button> </div> </template> <script> import mock from '@/mock/index.js' import Cookies from "js-cookie" import router from '@/router' export default { name: 'Login', methods: { login() { this.$api.login.login().then(function(res) { alert(res.token) Cookies.set('token', res.token) // 放置token到Cookie router.push('/') // 登录成功,跳转到主页 }).catch(function(res) { alert(res); }); } } } </script>
-
Mock接口
在mock.js中添加login接口进行拦截,返回一个token。
-
页面测试
在浏览器访问登录界面单击登录弹出框显示返回的token信息。单击跳转按钮后页面跳转到主页,说明我们的axios模块已经成功封装并使用了。
-
3.2 封装mock模块
为了可以统一管理和集中控制数据模拟接口,我们对mock模块进行了封装,可以方便定制模拟接口的统一开关和个体开关。
-
文件结构
在mock目录下新建一个index.js,创建modules目录并在里面创建子模块的*.js文件,如下:
-
index.js
index.js是聚合模块,统一导入所有的子模块并通过调用mock进行数据模拟。
import Mock from 'mockjs' import { baseUrl } from '@/utils/global' import * as login from './modules/login' import * as user from './modules/user' import * as role from './modules/role' import * as dept from './modules/dept' import * as menu from './modules/menu' import * as dict from './modules/dict' import * as config from './modules/config' import * as log from './modules/log' import * as loginlog from './modules/loginlog' // 1. 开启/关闭[所有模块]拦截, 通过调[openMock参数]设置. // 2. 开启/关闭[业务模块]拦截, 通过调用fnCreate方法[isOpen参数]设置. // 3. 开启/关闭[业务模块中某个请求]拦截, 通过函数返回对象中的[isOpen属性]设置. let openMock = true // let openMock = false fnCreate(login, openMock) fnCreate(user, openMock) fnCreate(role, openMock) fnCreate(dept, openMock) fnCreate(menu, openMock) fnCreate(dict, openMock) fnCreate(config, openMock) fnCreate(log, openMock) fnCreate(loginlog, openMock) /** * 创建mock模拟数据 * @param {*} mod 模块 * @param {*} isOpen 是否开启? */ function fnCreate (mod, isOpen = true) { if (isOpen) { for (var key in mod) { ((res) => { if (res.isOpen !== false) { let url = baseUrl if(!url.endsWith("/")) { url = url + "/" } url = url + res.url Mock.mock(new RegExp(url), res.type, (opts) => { opts['data'] = opts.body ? JSON.parse(opts.body) : null delete opts.body console.log('\n') console.log('%cmock拦截, 请求: ', 'color:blue', opts) console.log('%cmock拦截, 响应: ', 'color:blue', res.data) return res.data }) } })(mod[key]() || {}) } } }
-
user.js
子模块modules下的代码太多,不方便贴出来,以用户管理为例,格式和后台接口保持一致。
/* * 用户管理模块 */ // 保存 export function save() { return { url: 'user/save', type: 'post', data: { "code": 200, "msg": null, "data": 1 } } } // 批量删除 export function batchDelete() { return { url: 'user/delete', type: 'post', data: { "code": 200, "msg": null, "data": 1 } } } // 分页查询 export function findPage(params) { let findPageData = { "code": 200, "msg": null, "data": {} } let pageNum = 1 let pageSize = 8 if(params !== null) { // pageNum = params.pageNum } if(params !== null) { // pageSize = params.pageSize } let content = this.getContent(pageNum, pageSize) findPageData.data.pageNum = pageNum findPageData.data.pageSize = pageSize findPageData.data.totalSize = 50 findPageData.data.content = content return { url: 'user/findPage', type: 'post', data: findPageData } } export function getContent(pageNum, pageSize) { let content = [] for(let i=0; i<pageSize; i++) { let obj = {} let index = ((pageNum - 1) * pageSize) + i + 1 obj.id = index obj.name = 'mango' + index obj.password = '9ec9750e709431dad22365cabc5c625482e574c74adaebba7dd02f1129e4ce1d' obj.salt = 'YzcmCZNvbXocrsz9dm8e' obj.email = 'mango' + index +'@qq.com' obj.mobile = '18688982323' obj.status = 1 obj.deptId = 12 obj.deptName = '技术部' obj.status = 1 if(i % 2 === 0) { obj.deptId = 13 obj.deptName = '市场部' } obj.createBy= 'admin' obj.createTime= '2018-08-14 11:11:11' obj.createBy= 'admin' obj.createTime= '2018-09-14 12:12:12' content.push(obj) } return content } // 查找用户的菜单权限标识集合 export function findPermissions() { let permsData = { "code": 200, "msg": null, "data": [ null, "sys:user:view", "sys:menu:delete", "sys:dept:edit", "sys:dict:edit", "sys:dict:delete", "sys:menu:add", "sys:user:add", "sys:log:view", "sys:dept:delete", "sys:role:edit", "sys:role:view", "sys:dict:view", "sys:user:edit", "sys:user:delete", "sys:dept:view", "sys:dept:add", "sys:role:delete", "sys:menu:view", "sys:menu:edit", "sys:dict:add", "sys:role:add" ] } return { url: 'user/findPermissions', type: 'get', data: permsData } }
-
登录界面
修改登录界面,包括导入语句和返回数据格式的获取:
<template> <div class="page"> <h2>Login Page</h2> <el-button type="primary" @click="login()">登录</el-button> </div> </template> <script> import mock from "@/mock/index.js"; import Cookies from "js-cookie"; import router from "@/router"; export default { name: "Login", methods: { login() { this.$api.login .login() .then(function(res) { alert(res.data.token); Cookies.set("token", res.data.token); // 放置token到Cookie router.push("/"); // 登录成功,跳转到主页 }) .catch(function(res) { alert(res); }); } } }; </script>
-
主页界面
修改主页界面,替换导入mock文件的语句,如下:
<template> <div class="page"> <h2>Home Page</h2> <el-button type="primary" @click="testAxios()">测试Axios调用</el-button> <el-button type="primary" @click="getUser()">获取用户信息</el-button> <el-button type="primary" @click="getMenu()">获取菜单信息</el-button> </div> </template> <script> import axios from 'axios' import mock from '@/mock/index.js' export default { name: 'Home', methods: { testAxios() { axios.get('http://localhost:8080').then(res => { alert(res.data) }) }, getUser() { axios.get('http://localhost:8080/user').then(res => { alert(JSON.stringify(res.data)) }) }, getMenu() { axios.get('http://localhost:8080/menu').then(res => { alert(JSON.stringify(res.data)) }) } } } </script>
-
页面测试
在浏览器访问http://localhost:8080/#/login。单击"登录"按钮,在弹出框中返回token信息,如下:
登录成功之后重定向到主页面,如下:
到此,axios模块和mock模块都封装好了,后续使用只要参考此处即可。
4. 第三方图标库
4.1 使用第三方图标库
用过Element的人都知道,Element UI提供的字体图符少之又少,实在不够用,幸好现在有不少丰富的第三方图标库可以使用。
4.2 Font Awesome
Font Awesome提供了675个可缩放的矢量图标,可以使用CSS所提供的所有特性对它们进行更改,包括大小、颜色、阴影或者其他任何支持的效果。
Font Awesome 5 跟之前的版本使用方式差别很大,功能是强大了,图标也更丰富了,但是使用也更加复杂了。
若是需求没那么复杂,只使用简单的图标就可。
官方网址:http://fontawesome.dashgame.com/.
- 安装依赖
yarn add font-awesome
-
项目引入
在项目main.js中引入css依赖。
import 'font-awesome/css/font-awesome.min.css'
-
页面使用
引入之后就可以直接在页面中使用了,修改Home.vue,加入一个图标:
<li class="fa fa-home fa-lg"></li>
-
页面测试
启动应用,访问http://localhost:8080/#/,效果如下:
5. 多语言国际化
国际化多语言支持是现在系统通常都要具备的功能,Vue对国际化提供了很好的支持。
5.1 安装依赖
首先需要安装国际化组件,执行
yarn add vue-i18n
命令,安装i18n依赖。
5.2 添加配置
在src下新建i18n目录,并创建一个index.js。
import Vue from 'vue'
import VueI18n from 'vue-i18n'
Vue.use(VueI18n)
// 注册i18n实例并引入语言文件,文件格式等下解析
const i18n = new VueI18n({
locale: 'zh_cn',
messages: {
'zh_cn': require('@/assets/languages/zh_cn.json'),
'en_us': require('@/assets/languages/en_us.json')
}
})
export default i18n
然后在assets目录下面创建两个多语言文件。
zh_cn.json:
{
"common": {
"home": "首页",
"login": "登录",
"logout": "退出登录",
"doc": "文档",
"blog": "博客",
"projectRepo": "项目",
"myMsg": "我的消息",
"config": "系统配置",
"backup": "备份",
"restore": "还原",
"backupRestore": "备份还原",
"versionName": "版本名称",
"exit": "退出"
},
"action": {
"operation": "操作",
"add": "新增",
"edit": "编辑",
"delete": "删除",
"batchDelete": "批量删除",
"search": "查询",
"loading": "拼命加载中",
"submit": "提交",
"comfirm": "确定",
"cancel": "取消",
"reset": "重置"
}
}
en_us:.json:
{
"common": {
"home": "Home",
"login": "Login",
"logout": "Logout",
"doc": "Document",
"blog": "Blog",
"projectRepo": "Project",
"myMsg": "My Message",
"config": "Config",
"backup": "Backup",
"restore": "Restore",
"backupRestore": "Backup Restore",
"versionName": "Version",
"exit": "Exit"
},
"action": {
"operation": "Operation",
"add": "Add",
"edit": "Edit",
"delete": "Delete",
"batchDelete": "Batch Delete",
"search": "Search",
"loading": "loading",
"submit": "Submit",
"comfirm": "Comfirm",
"cancel": "Cancel",
"reset": "Reset"
}
}
在mian.js中引入i18n并注入vue对象中。
import Vue from 'vue'
import App from './App'
import router from './router'
import api from './http'
import i18n from './i18n'
import global from '@/utils/global'
import ElementUI from 'element-ui'
import 'element-ui/lib/theme-chalk/index.css'
import 'font-awesome/css/font-awesome.min.css'
Vue.use(ElementUI) // 注册使用Element
Vue.use(api) // 注册使用API模块
Vue.prototype.global = global // 挂载全局配置模块
new Vue({
el: '#app',
i18n,
router,
render: h => h(App)
})
5.3 字符引用
在原本使用字符的地方加入国际化字符串。
打开Home.vue,在模板下面添加一个国际化字符串和两个按钮做中英文切换。
<h3>{{$t('common.doc')}}</h3>
<el-button type="success" @click="changeLanguage('zh_cn')">简体中文</el-button>
<el-button type="success" @click="changeLanguage('en_us')">English</el-button>
在方法声明区域添加以下方法,设置国际化语言。
// 语言切换
changeLanguage(lang) {
lang === '' ? 'zh_cn' : lang
this.$i18n.locale = lang
this.langVisible = false
},
5.4 页面测试
启动应用,访问http://localhost:8080/#/,进入主页,如下所示。
单击"English"按钮,国际化字符变成英文,如下:
单击"简体中文"按钮,国际化字符又变成中文。
通过this.$i18n.locale = xx方式就可以全局切换语言,Vue框架会根据local的值读取对应的国际化多语言文件并进行适时更新。
6. 登录流程完善
6.1 登录界面
登录方法主要逻辑是调用后台登录接口,并在登录成功之后保存token到Cookie,保存用户到本地存储,然后跳转到主页面。
login() {
this.loading = true
let userInfo = { account:this.loginForm.account, password:this.loginForm.password,
captcha:this.loginForm.captcha }
this.$api.login.login(userInfo).then((res) => { // 调用登录接口
if(res.msg != null) {
this.$message({ message: res.msg, type: 'error' })
} else {
Cookies.set('token', res.data.token) // 放置token到Cookie
sessionStorage.setItem('user', userInfo.account) // 保存用户到本地会话
this.$router.push('/') // 登录成功,跳转到主页
}
this.loading = false
}).catch((res) => {
this.$message({ message: res.message, type: 'error' })
})
}
6.2 主页面
在views下面另外添加几个页面文件,分别在头部、左侧导航和主内容区域。
Home.vue:
主页由导航菜单、头部区域和主内容区域组成。
<template>
<div class="container">
<!-- 导航菜单栏 -->
<nav-bar></nav-bar>
<!-- 头部区域 -->
<head-bar></head-bar>
<!-- 主内容区域 -->
<main-content></main-content>
</div>
</template>
<script>
import HeadBar from "./HeadBar"
import NavBar from "./NavBar"
import MainContent from "./MainContent"
export default {
components:{
HeadBar,
NavBar,
MainContent
}
};
</script>
<style scoped lang="scss">
.container {
position:absolute;
top: 0px;
left: 0px;
right: 0px;
bottom: 0px;
// background: rgba(224, 234, 235, 0.1);
}
</style>
HeaderBar.vue:
头部导航主要是设置样式,并在右侧添加用户名和头像显示。
<template>
<div class="headbar" style="background:#14889A" :class="'position-left'">
<!-- 工具栏 -->
<span class="toolbar">
<el-menu class="el-menu-demo" background-color="#14889A" text-color="#14889A" active-text-color="#14889A" mode="horizontal">
<el-menu-item index="1">
<!-- 用户信息 -->
<span class="user-info"><img :src="user.avatar" />{{user.name}}</span>
</el-menu-item>
</el-menu>
</span>
</div>
</template>
<script>
import mock from "@/mock/index"
export default {
data() {
return {
user: {
name: "Louis",
avatar: "",
role: "超级管理员",
registeInfo: "注册时间:2018-12-20 "
},
activeIndex: '1',
langVisible: false
}
},
methods: {
selectNavBar(key, keyPath) {
console.log(key, keyPath)
}
},
mounted() {
var user = sessionStorage.getItem("user")
if (user) {
this.user.name = user
this.user.avatar = require("@/assets/user.png")
}
}
}
</script>
<style scoped lang="scss">
.headbar {
position: fixed;
top: 0;
right: 0;
z-index: 1030;
height: 60px;
line-height: 60px;
border-color: rgba(180, 190, 190, 0.8);
border-left-width: 1px;
border-left-style: solid;
}
.navbar {
float: left;
}
.toolbar {
float: right;
}
.user-info {
font-size: 20px;
color: #fff;
cursor: pointer;
img {
width: 40px;
height: 40px;
border-radius: 10px;
margin: 10px 0px 10px 10px;
float: right;
}
}
.position-left {
left: 200px;
}
</style>
NavBar.vue:
左侧导航包含上方Logo区域和下方导航菜单区域。
<template>
<div class="menu-bar-container">
<!-- logo -->
<div class="logo" style="background:#14889A" :class="'menu-bar-width'"
@click="$router.push('/')">
<img src="@/assets/logo.png"/> <div>Mango</div>
</div>
</div>
</template>
<script>
export default {
methods: {
}
}
</script>
<style scoped lang="scss">
.menu-bar-container {
position: fixed;
top: 0px;
left: 0;
bottom: 0;
z-index: 1020;
.logo {
position:absolute;
top: 0px;
height: 60px;
line-height: 60px;
background: #545c64;
cursor:pointer;
img {
width: 40px;
height: 40px;
border-radius: 0px;
margin: 10px 10px 10px 10px;
float: left;
}
div {
font-size: 25px;
color: white;
text-align: left;
padding-left: 20px;
}
}
.menu-bar-width {
width: 200px;
}
}
</style>
MainContent.vue:
主内容区域包含标签页导航和主内容区域,在主内容中放置route-view用于路由信息。
<template>
<div id="main-container" class="main-container" :class="'position-left'">
<!-- 标签页 -->
<div class="tab-container"></div>
<!-- 主内容区域 -->
<div class="main-content">
<keep-alive>
<transition name="fade" mode="out-in">
<router-view></router-view>
</transition>
</keep-alive>
</div>
</div>
</template>
<script>
export default {
data () {
return {
}
},
methods: {
}
}
</script>
<style scoped lang="scss">
.main-container {
padding: 0 5px 5px;
position: absolute;
top: 60px;
left: 1px;
right: 1px;
bottom: 0px;
background: rgba(67, 69, 70, 0.1);
.main-content {
position: absolute;
top: 45px;
left: 5px;
right: 5px;
bottom: 5px;
padding: 5px;
}
}
.position-left {
left: 200px;
}
</style>
6.3 页面测试
启动应用,访问http://localhost:8080/#/login,进入登录界面,如下所示:
单击"登录"按钮,登录之后跳转到主页面:
7. 管理应用状态
在很多应用场景下,我们需要在组件之间共享状态,比如我们的左侧导航栏需要收缩和展开的功能,收缩时宽度很小,只显示菜单图标,因为导航菜单栏收缩之后宽度变了,所以右侧的主内容区域要占用导航栏收缩的空间,主内容区域宽度也要根据导航栏的收缩状态做变更,而导航栏和主内容区域是两个不同的组件,而非父子组件之间不支持状态传递,所以组件之间的状态共享问题产生了。vuex是一个专门为vue.js应用程序开发的状态管理模式。它采用集中式存储管理应用的所有组件的状态,并以相应的规则保证状态以一种可预测的方式发生变化。
vuex资料可参考:https://vuex.vuejs.org/zh/
7.1 安装依赖
执行
yarn add vuex
命令安装vuex依赖。
7.2 添加store
在src下新建一个store目录,专门管理应用状态:
index.js:
在index.js中引入vuex并统一组织导入和管理子模块。
import Vue from 'vue'
import vuex from 'vuex'
Vue.use(vuex);
// 引入子模块
import app from './modules/app'
const store = new vuex.Store({
modules: {
app: app
}
})
export default store
app.js:
app.js是属于应用内的全局性配置,比如主题色、导航栏收缩状态等。
export default {
state: {
appName: "Mango Platform", // 应用名称
themeColor: "#14889A", // 主题颜色
oldThemeColor: "#14889A", // 上一次主题颜色
collapse:false, // 导航栏收缩状态
menuRouteLoaded:false // 菜单和路由是否已经加载
},
getters: {
collapse(state){// 对应着上面state
return state.collapse
}
},
mutations: {
onCollapse(state){ // 改变收缩状态
state.collapse = !state.collapse
},
setThemeColor(state, themeColor){ // 改变主题颜色
state.oldThemeColor = state.themeColor
state.themeColor = themeColor
},
menuRouteLoaded(state, menuRouteLoaded){ // 改变菜单和路由的加载状态
state.menuRouteLoaded = menuRouteLoaded;
}
},
actions: {
}
}
7.3 引入store
在main.js中引入store。
import Vue from 'vue'
import App from './App'
import router from './router'
import api from './http'
import i18n from './i18n'
import store from './store'
import global from '@/utils/global'
import ElementUI from 'element-ui'
import 'element-ui/lib/theme-chalk/index.css'
import 'font-awesome/css/font-awesome.min.css'
Vue.use(ElementUI) // 注册使用Element
Vue.use(api) // 注册使用API模块
Vue.prototype.global = global // 挂载全局配置模块
new Vue({
el: '#app',
i18n,
router,
store,
render: h => h(App)
})
7.4 使用store
这里以头部页面HeadBar.vue的状态使用为例,其他页面同理。
首先通过computed计算属性引入store属性,这样就可以直接在页面中通过collapse引用状态值了,当然如果不嫌长也可以不使用计算属性,直接在页面中通过$store.state.app.collapse引用。
然后在页面中通过collapse的状态值来绑定不同的宽度样式。
7.5 收缩组件
在src下新建components目录,并在其下创建导航栏收缩展开组件Hamburger。
组件是使用SVG绘制,绘制根据isActive状态决定是否旋转、显示收缩和展开状态不同的图形。在头部区域HeadBar中引入hamburger并将自身isActive状态跟收缩状态collapse绑定。
单击导航栏收缩组件区域的响应函数,设置导航收缩状态到Store。
7.6 页面测试
启动应用,访问http://localhost:8080/#/login,单击登录进入主页面。如下:
单击导航栏收缩展开组件,导航菜单栏收缩起来,效果如下:
8. 头部组件功能
本章介绍头部区域一些常用功能的实现方案,比如动态主题切换器、国际化语言切换器、用户信息弹出面板等。
8.1 主题切换组件
8.1.1 编写组件
在components目录下新建一个主题切换器组件ThemePicker。
ThemePicker实现思路是使用一个颜色选取组件el-color-picker获取一个主题色,然后通过动态替换覆盖Element默认CSS样式的方式替换框架的主题色primary color,并在主题色切换成功之后提供回调函数,通过此回调函数同步更新需要更换主题色的页面或组件。
组件使用el-color-picker获取主题色,并绑定theme属性和size属性。
<template>
<el-color-picker class="theme-picker" popper-class="theme-picker-dropdown"
v-model="theme" :size="size">
</el-color-picker>
</template>
通过watch监听theme属性即主题色的更新动态替换CSS样式,并在替换CSS后通过**this.$emit(‘onThemeChange’, val)**语句提供一个回调函数onThemeChange并将更新后的主题色的值val作为参数传入,使得外部组件可以通过此回调函数同步更新外部组件颜色为主题。
watch: {
theme(val, oldVal) {
if (typeof val !== 'string') return
// 替换CSS样式,修改主题色
const themeCluster = this.getThemeCluster(val.replace('#', ''))
const originalCluster = this.getThemeCluster(oldVal.replace('#', ''))
console.log(themeCluster, originalCluster)
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
}
}
const chalkHandler = getHandler('chalk', 'chalk-style')
if (!this.chalk) {
const url = `https://unpkg.com/element-ui@${version}/lib/theme-chalk/index.css`
this.getCSSString(url, chalkHandler, 'chalk')
} else {
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('onThemeChange', val)
if(this.showSuccess) {
this.$message({ message: '换肤成功', type: 'success' })
} else {
this.showSuccess = true
}
}
},
在HeadBar.vue中引入ThemePicker组件并在页面中添加一个皮肤主题色切换器菜单。
在方法区定义主题切换的回调函数,同步设置store中的themeColor:
// 切换主题
onThemeChange: function(themeColor) {
this.$store.commit('setThemeColor', themeColor)
},
将各个页面中需要同步为主题色的页面或组件绑定store主题色中的主题色属性themeColor,这样每次通过切换器切换主题色的时候都会把主题色通过store传递到页面组件,如下所示:
8.1.2 页面测试
启动应用,访问http://localhost:8080/#/,单击切换按钮选择主题色:
8.2 语言切换组件
8.2.1 编写组件
在HeadBar.vue工具栏主题切换器右边放置一个语言切换组件,可以选择中英文两种语言。
语言切换响应函数,设置local值并让弹出下拉面板消失:
// 语言切换
changeLanguage(lang) {
lang === '' ? 'zh_cn' : lang
this.$i18n.locale = lang
this.langVisible = false
}
在头部工具栏左侧放置一些菜单项,使用国际化字符串,以测试多语言切换效果:
<!-- 导航菜单 -->
<span class="navbar">
<el-menu :default-active="activeIndex" class="el-menu-demo"
:background-color="themeColor" text-color="#fff" active-text-color="#ffd04b" mode="horizontal" @select="selectNavBar()">
<el-menu-item index="1" @click="$router.push('/')">{{$t("common.home")}}</el-menu-item>
<el-menu-item index="2" @click="openWindow('https://gitee.com/liuge1988/kitty/wikis/Home')">{{$t("common.doc")}}</el-menu-item>
<el-menu-item index="3" @click="openWindow('https://www.cnblogs.com/xifengxiaoma/')">{{$t("common.blog")}}</el-menu-item>
</el-menu>
</span>
8.2.2 页面测试
效果如下:
8.3 用户信息面板
8.3.1 编写组件
在views目录下新建一个core目录并在其下新建一个用户信息面板PersonalPanel.vue,单击用户头像时弹出用户信息面板,面板显示用户信息和一些功能操作。
用户信息面板页面内容如下:
<template>
<div class="personal-panel">
<div class="personal-desc" :style="{'background':this.$store.state.app.themeColor}">
<div class="avatar-container">
<img class="avatar" :src="require('@/assets/user.png')" />
</div>
<div class="name-role">
<span class="sender">{{ user.name }} - {{ user.role }}</span>
</div>
<div class="registe-info">
<span class="registe-info">
<li class="fa fa-clock-o"></li>
{{ user.registeInfo }}
</span>
</div>
</div>
<div class="personal-relation">
<span class="relation-item">followers</span>
<span class="relation-item">watches</span>
<span class="relation-item">friends</span>
</div>
<div class="main-operation">
<span class="main-operation-item">
<el-button size="small" icon="fa fa-male"> 个人中心</el-button>
</span>
<span class="main-operation-item">
<el-button size="small" icon="fa fa-key"> 修改密码</el-button>
</span>
</div>
<div class="other-operation">
<div class="other-operation-item">
<li class="fa fa-eraser"></li>
清除缓存
</div>
<div class="other-operation-item">
<li class="fa fa-user"></li>
在线人数
</div>
<div class="other-operation-item">
<li class="fa fa-bell"></li>
访问次数
</div>
<div class="other-operation-item">
<li class="fa fa-undo"></li>
{{$t("common.backupRestore")}}
</div>
</div>
<div class="personal-footer" @click="logout">
<li class="fa fa-sign-out"></li>
{{$t("common.logout")}}
</div>
</div>
</template>
<script>
export default {
name: 'PersonalPanel',
components:{
},
props: {
user: {
type: Object,
default: {
name: "admin",
avatar: "@/assets/user.png",
role: "超级管理员",
registeInfo: "注册时间:2018-12-25 "
}
}
},
data() {
return {
}
},
methods: {
// 退出登录
logout: function() {
this.$confirm("确认退出吗?", "提示", {
type: "warning"
})
.then(() => {
sessionStorage.removeItem("user")
this.$router.push("/login")
this.$api.login.logout().then((res) => {
}).catch(function(res) {
})
})
.catch(() => {})
}
},
mounted() {
}
}
</script>
<style scoped>
.personal-panel {
font-size: 14px;
width: 280px;
text-align: center;
border-color: rgba(180, 190, 190, 0.2);
border-width: 1px;
border-style: solid;
background: rgba(182, 172, 172, 0.1);
margin: -14px;
}
.personal-desc {
padding: 15px;
color: #fff;
}
.avatar {
width: 80px;
height: 80px;
border-radius: 90px;
}
.name-role {
font-size: 16px;
padding: 5px;
}
.personal-relation {
font-size: 16px;
padding: 12px;
margin-right: 1px;
background: rgba(200, 209, 204, 0.3);
}
.relation-item {
padding: 12px;
}
.relation-item:hover {
cursor: pointer;
color: rgb(19, 138, 156);
}
.main-operation {
padding: 8px;
margin-right: 1px;
/* background: rgba(175, 182, 179, 0.3); */
border-color: rgba(201, 206, 206, 0.2);
border-top-width: 1px;
border-top-style: solid;
}
.main-operation-item {
margin: 15px;
}
.other-operation {
padding: 15px;
margin-right: 1px;
text-align: left;
border-color: rgba(180, 190, 190, 0.2);
border-top-width: 1px;
border-top-style: solid;
}
.other-operation-item {
padding: 12px;
}
.other-operation-item:hover {
cursor: pointer;
background: #9e94941e;
color: rgb(19, 138, 156);
}
.personal-footer {
margin-right: 1px;
font-size: 14px;
text-align: center;
padding-top: 10px;
padding-bottom: 10px;
border-color: rgba(180, 190, 190, 0.2);
border-top-width: 1px;
border-top-style: solid;
}
.personal-footer:hover {
cursor: pointer;
color: rgb(19, 138, 156);
background: #b1a6a61e;
}
</style>
退出登录处理函数。确认退出后清空本地存储,返回登录页面并调用后台退出登录接口:
// 退出登录
logout: function() {
this.$confirm("确认退出吗?", "提示", {
type: "warning"
})
.then(() => {
sessionStorage.removeItem("user")
this.$router.push("/login")
this.$api.login.logout().then((res) => {
}).catch(function(res) {
})
})
.catch(() => {})
}
在头部区域引入组件,在用户头像信息组件下通过popover组件关联用户信息面板:
<el-menu-item index="5" v-popover:popover-personal>
<!-- 用户信息 -->
<span class="user-info"
><img :src="user.avatar" />{{ user.name }}</span
>
<el-popover
ref="popover-personal"
placement="bottom-end"
trigger="click"
:visible-arrow="false"
>
<personal-panel :user="user"></personal-panel>
</el-popover>
</el-menu-item>
8.3.2 页面测试
8.3 系统通知面板
8.3.1 编写组件
在view/core目录下新建一个系统通知面板NoticePanel.vue,在头部工具栏中添加系统通知组件,单击弹出系统通知信息面板。
系统通知面板页面如下,主要通过遍历通知信息列表展示:
<template>
<div class="notice-panel">
<div class="header">您有 {{data.length}} 条通知</div>
<div class="notice-content">
<div v-for="item in data" :key="item.key" class="notice-item">
<span class="notice-icon">
<li :class="item.icon"></li>
</span>
<span class="notice-cotent">
{{ item.content }}
</span>
</div>
</div>
<div class="notice-footer">查看所有通知</div>
</div>
</template>
在头部区域HeadBar.vue中引入组件并通过popover组件关联通知面板:
<el-menu-item index="4" v-popover:popover-notice>
<!-- 系统通知 -->
<el-badge :value="4" :max="99" class="badge" type="error">
<li style="color:#fff;" class="fa fa-bell-o fa-lg"></li>
</el-badge>
<el-popover
ref="popover-notice"
placement="bottom-end"
trigger="click"
>
<notice-panel></notice-panel>
</el-popover>
</el-menu-item>
8.3.2 页面测试
8.4 用户私信面板
8.4.1 编写组件
在view/core目录下新建一个用户私信面板MessagePanel.vue,在头部工具栏添加用户私信组件,单击弹出用户私信信息面板。
用户私信面板页面内容如下,主要通过遍历私信信息列表展示:
<template>
<div class="message-panel">
<div class="message-header">您有 {{data.length}} 条消息</div>
<div class="message-content">
<div v-for="item in data" :key="item.key" class="message-item">
<div class="message-avatar">
<img class="avatar" :src="require('@/assets/user.png')" />
</div>
<span class="sender">
{{ item.sender }}
</span>
<span class="time">
<li class="fa fa-clock-o"></li> {{ item.time }}
</span>
<div class="message-cotent">
{{ item.content }}
</div>
</div>
</div>
<div class="message-footer">查看所有消息</div>
</div>
</template>
在头部区域引入组件并通过popover组件关联私信面板:
<el-menu-item index="3" v-popover:popover-message>
<!-- 我的私信 -->
<el-badge :value="5" :max="99" class="badge" type="error">
<li style="color:#fff;" class="fa fa-envelope-o fa-lg"></li>
</el-badge>
<el-popover
ref="popover-message"
placement="bottom-end"
trigger="click"
>
<message-panel></message-panel>
</el-popover>
</el-menu-item>
8.4.2 页面测试
9. 动态加载菜单
本章我们将介绍如何动态加载数据库的菜单数据并显示到导航栏。
9.1 添加store
我们先添加几个store状态,后续需要用来共享使用。
首先在store/modules下的app.js中添加一个menuRouteLoaded状态,判断路由是否加载过:
然后在store/modules下新建一个menu.js,在index.js中引入,里面保存着加载后的导航菜单树数据。
export default {
state: {
navTree: [], // 导航菜单树
},
getters: {
},
mutations: {
setNavTree(state, navTree){ // 设置导航菜单树
state.navTree = navTree;
}
},
actions: {
}
}
在store/modules下新建一个user.js并在index.js中引入,里面保存加载后的用户权限数据。
export default {
state: {
perms: [], // 用户权限标识集合
},
getters: {
},
mutations: {
setPerms(state, perms){ // 用户权限标识集合
state.perms = perms;
}
},
actions: {
}
}
9.2 登录页面
打开登录页面Login.vue,在登录接口设置菜单加载状态,要求重新登录之后重新加载菜单:
login() {
this.loading = true
let userInfo = { account:this.loginForm.account, password:this.loginForm.password,
captcha:this.loginForm.captcha }
this.$api.login.login(userInfo).then((res) => { // 调用登录接口
if(res.msg != null) {
this.$message({ message: res.msg, type: 'error' })
} else {
Cookies.set('token', res.data.token) // 放置token到Cookie
sessionStorage.setItem('user', userInfo.account) // 保存用户到本地会话
this.$store.commit('menuRouteLoaded', false) // 要求重新加载导航菜单
this.$router.push('/') // 登录成功,跳转到主页
}
this.loading = false
}).catch((res) => {
this.$message({ message: res.message, type: 'error' })
})
},
9.3 导航守卫
路由对象router为我们提供了beforeEach方法,可以在每次路由之前进行一些相关处理,也叫导航守卫,我们这里就是通过导航守卫实现动态菜单的加载。
修改router/index.js文件,添加导航守卫,在每次路由时判断用户会话是否过期。如果登录有效且跳转的是登录页面,就直接路由到主页;如果是非登录页面且会话过期,就会跳到登录页面要求登录;否则加载动态菜单并路由到目标界面。
router.beforeEach((to, from, next) => {
// 登录界面登录成功之后,会把用户信息保存在会话
// 存在时间为会话生命周期,页面关闭即失效。
let userName = sessionStorage.getItem('user')
if (to.path === '/login') {
// 如果是访问登录界面,如果用户会话信息存在,代表已登录过,跳转到主页
if(userName) {
next({ path: '/' })
} else {
next()
}
} else {
if (!userName) {
// 如果访问非登录界面,且户会话信息不存在,代表未登录,则跳转到登录界面
next({ path: '/login' })
} else {
// 加载动态菜单和路由
addDynamicMenuAndRoutes(userName, to, from)
next()
}
}
})
加载动态路由的方法内容如下。首先判断动态菜单是否已经存在,如果存在就不再重复加载损耗性能,否则调用后台接口加载数据库存储菜单数据,加载成功后通过router.addRoutes方法将菜单数据动态添加到路由器并同时保持菜单数据及加载状态以备后用。导航菜单加载成功之后,调用后台接口查找用户权限数据并保存起来,供权限判断时读取。
/**
* 加载动态菜单和路由
*/
function addDynamicMenuAndRoutes(userName, to, from) {
if(store.state.app.menuRouteLoaded) {
console.log('动态菜单和路由已经存在.')
return
}
api.menu.findNavTree({'userName':userName})
.then(res => {
// 添加动态路由
let dynamicRoutes = addDynamicRoutes(res.data)
router.options.routes[0].children = router.options.routes[0].children.concat(dynamicRoutes)
router.addRoutes(router.options.routes)
// 保存加载状态
store.commit('menuRouteLoaded', true)
// 保存菜单树
store.commit('setNavTree', res.data)
}).then(res => {
api.user.findPermissions({'name':userName}).then(res => {
// 保存用户权限标识集合
store.commit('setPerms', res.data)
})
})
.catch(function(res) {
})
}
下面是遍历菜单数据实际创建路由对象的逻辑。
/**
* 添加动态(菜单)路由
* @param {*} menuList 菜单列表
* @param {*} routes 递归创建的动态(菜单)路由
*/
function addDynamicRoutes (menuList = [], routes = []) {
var temp = []
for (var i = 0; i < menuList.length; i++) {
if (menuList[i].children && menuList[i].children.length >= 1) {
temp = temp.concat(menuList[i].children)
} else if (menuList[i].url && /\S/.test(menuList[i].url)) {
menuList[i].url = menuList[i].url.replace(/^\//, '')
// 创建路由配置
var route = {
path: menuList[i].url,
component: null,
name: menuList[i].name,
meta: {
icon: menuList[i].icon,
index: menuList[i].id
}
}
try {
// 根据菜单URL动态加载vue组件,这里要求vue组件须按照url路径存储
// 如url="sys/user",则组件路径应是"@/views/sys/user.vue",否则组件加载不到
let array = menuList[i].url.split('/')
let url = ''
for(let i=0; i<array.length; i++) {
url += array[i].substring(0,1).toUpperCase() + array[i].substring(1) + '/'
}
url = url.substring(0, url.length - 1)
route['component'] = resolve => require([`@/views/${url}`], resolve)
} catch (e) {}
routes.push(route)
}
}
if (temp.length >= 1) {
addDynamicRoutes(temp, routes)
} else {
console.log('动态路由加载...')
console.log(routes)
console.log('动态路由加载完成.')
}
return routes
}
9.4 导航树组件
在components目录下新建一个导航树组件MenuTree,文件结构如下:
MenuTree.vue的页面内容大致如下,主要是遍历菜单数据创建导航菜单。
<template>
<el-submenu v-if="menu.children && menu.children.length >= 1" :index="'' + menu.id">
<template slot="title">
<i :class="menu.icon" ></i>
<span slot="title">{{menu.name}}</span>
</template>
<MenuTree v-for="item in menu.children" :key="item.id" :menu="item"></MenuTree>
</el-submenu>
<el-menu-item v-else :index="'' + menu.id" @click="handleRoute(menu)">
<i :class="menu.icon"></i>
<span slot="title">{{menu.name}}</span>
</el-menu-item>
</template>
上面的导航菜单都绑定了handleRoute函数,在单击菜单项的时候路由到指定路径。路由业务功能页面目前没有实现,后面实现业务功能页面时会讲到。
在NavBar.vue中引入导航菜单树组件,并编写导航菜单区域内容,其中的菜单数据navTree是在导航守卫加载并存储到store的:
<!-- 导航菜单 -->
<el-menu ref="navmenu" default-active="1" :class="collapse?'menu-bar-collapse-width':'menu-bar-width'"
:collapse="collapse" :collapse-transition="false" :unique-opened="true "
@open="handleopen" @close="handleclose" @select="handleselect">
<!-- 导航菜单树组件,动态加载菜单 -->
<menu-tree v-for="item in navTree" :key="item.id" :menu="item"></menu-tree>
</el-menu>
9.5 页面测试
10. 页面权限控制
10.1 权限控制方案
既然是后台权限管理系统,当然少不了权限控制。至于权限控制,前端方面就是指对页面资源的访问与操作控制。前端资源权限主要分两个部分,即导航菜单的查看权限和页面增删改查操作按钮的操作权限。
我们的设计把页面导航菜单和页面操作按钮统一存储在菜单数据库表中,菜单表中包含以下权限关注点。
10.1.1 菜单类型
菜单类型代表页面资源的类型。
10.1.2 权限标识
权限标识是对页面资源进行权限控制的唯一标识,主要是增删改查的权限控制。权限标识主要包含四种,以用户管理为例,权限标识包括sys:user:add、sys:user:edit、sys:user:delete、sys:user:view。
(后台接口根据用户权限加载用户菜单数据返回给前端,前端导航菜单显示用户菜单数据,并在管理界面根据用户操作权限标识设置页面操作按钮的可见性或可用状态。我们这里采用无操作权限则页面按钮为不可用状态的方式)
10.2 导航菜单实现思路
10.2.1 用户登录系统
用户登录成功之后跳转首页
10.2.2 根据用户加载导航菜单
在路由导航守卫路由时加载用户导航菜单并存储到store。加载过程如下,返回结果排除按钮类型:user→user_role→role_menu→menu。
10.2.3 导航栏读取菜单树
导航栏页面到store读取导航菜单树并进行展示
10.3 页面按钮实现思路
10.3.1 用户登录系统
用户登录之后跳转到首页
10.3.2 加载权限标识
在路由导航守卫路由时加载用户权限标识集合并保存到store备用。加载过程如下,返回结果是用户权限标识的集合:user→user_role→role_menu→menu。
10.3.3 页面按钮控制
页面操作按钮提供perms属性绑定权限标识,使用disable属性绑定权限判断方法的返回值,权限判断方法hasPerms(perms)通过查找上一步保存的用户权限标识集合是否包含perms来判断用户是否拥有此相关权限,否则设置当前操作按钮为不可用状态。
10.4 权限控制实现
10.4.1 导航菜单权限
-
加载导航菜单
在导航守卫路由时加载导航菜单并保存状态:
router/index.js:
-
页面组件引用
导航栏页面从共享状态中读取导航菜单树并展示:
修改NavBar.vue文件:
<template>
<div class="menu-bar-container">
<!-- logo -->
<div
class="logo"
:style="{ 'background-color': themeColor }"
:class="collapse ? 'menu-bar-collapse-width' : 'menu-bar-width'"
@click="$router.push('/')"
>
<img v-if="collapse" src="@/assets/logo.png" />
<div>{{ collapse ? "" : appName }}</div>
</div>
<!-- 导航菜单 -->
<el-menu
ref="navmenu"
default-active="1"
:class="collapse ? 'menu-bar-collapse-width' : 'menu-bar-width'"
:collapse="collapse"
:collapse-transition="false"
:unique-opened="true"
@open="handleopen"
@close="handleclose"
@select="handleselect"
>
<!-- 导航菜单树组件,动态加载菜单 -->
<menu-tree
v-for="item in navTree"
:key="item.id"
:menu="item"
></menu-tree>
</el-menu>
</div>
</template>
10.4.2 页面按钮权限
-
加载权限标识
打开router/index.js,在导航守卫路由时加载权限标识并保存状态。
-
权限按钮判断
封装了权限操作按钮组件,在组件中根据外部传入的权限标识进行权限判断。
views/Core/KtButton.vue:
<template> <el-button :size="size" :type="type" :icon="icon" :loading="loading" :disabled="!hasPerms(perms)" @click="handleClick"> {{label}} </el-button> </template> <script> import { hasPermission } from '@/permission/index.js' export default { name: 'KtButton', props: { label: { // 按钮显示文本 type: String, default: 'Button' }, icon: { // 按钮显示图标 type: String, default: '' }, size: { // 按钮尺寸 type: String, default: 'mini' }, type: { // 按钮类型 type: String, default: null }, loading: { // 按钮加载标识 type: Boolean, default: false }, disabled: { // 按钮是否禁用 type: Boolean, default: false }, perms: { // 按钮权限标识,外部使用者传入 type: String, default: null } }, data() { return { } }, methods: { handleClick: function () { // 按钮操作处理函数 this.$emit('click', {}) }, hasPerms: function (perms) { // 根据权限标识和外部指示状态进行权限判断 return hasPermission(perms) & !this.disabled } }, mounted() { } } </script> <style scoped> </style>
-
权限判断逻辑
新建权限文件src/permission/index.js,提供权限判断方法hasPermission,传入当前组件绑定的权限标识perms,判断权限标识是否存在于store中保存的用户权限标识集合中。
src/permission/index.js:
import store from '@/store' /** * 判断用户是否拥有操作权限 * 根据传入的权限标识,查看是否存在用户权限标识集合 * @param perms */ export function hasPermission (perms) { let hasPermission = false let permissions = store.state.user.perms for(let i=0, len=permissions.length; i<len; i++) { if(permissions[i] === perms) { hasPermission = true; break } } return hasPermission }
-
权限按钮引用
在文件views/Sys/User.vue中配置相关内容,新建一个用户管理页面。在页面中引入权限按钮组件并绑定权限标识。
views/Sys/User.vue:
<el-form :inline="true" :model="filters" :size="size"> <el-form-item> <el-input v-model="filters.name" placeholder="用户名"></el-input> </el-form-item> <el-form-item> <kt-button icon="fa fa-search" :label="$t('action.search')" perms="sys:role:view" type="primary" @click="findPage(null)"/> </el-form-item> <el-form-item> <kt-button icon="fa fa-plus" :label="$t('action.add')" perms="sys:user:add2" type="primary" @click="handleAdd" /> </el-form-item> </el-form>
在文件views/Core/KtTable.vue中配置相关内容,添加一个表格封装组件,在组件中引入权限按钮组件,并将表格设计的编辑、删除、批量删除按钮绑定对应的权限标识:
views/Core/KtTable.vue:
<template> <div> <!--表格栏--> <el-table :data="data.content" :highlight-current-row="highlightCurrentRow" @selection-change="selectionChange" @current-change="handleCurrentChange" v-loading="loading" :element-loading-text="$t('action.loading')" :border="border" :stripe="stripe" :show-overflow-tooltip="showOverflowTooltip" :max-height="maxHeight" :size="size" :align="align" style="width:100%;" > <el-table-column type="selection" width="40" v-if="showBatchDelete & showOperation"></el-table-column> <el-table-column v-for="column in columns" header-align="center" align="center" :prop="column.prop" :label="column.label" :width="column.width" :min-width="column.minWidth" :fixed="column.fixed" :key="column.prop" :type="column.type" :formatter="column.formatter" :sortable="column.sortable==null?true:column.sortable"> </el-table-column> <el-table-column :label="$t('action.operation')" width="185" fixed="right" v-if="showOperation" header-align="center" align="center"> <template slot-scope="scope"> <kt-button icon="fa fa-edit" :label="$t('action.edit')" :perms="permsEdit" :size="size" @click="handleEdit(scope.$index, scope.row)" /> <kt-button icon="fa fa-trash" :label="$t('action.delete')" :perms="permsDelete" :size="size" type="danger" @click="handleDelete(scope.$index, scope.row)" /> </template> </el-table-column> </el-table> <!--分页栏--> <div class="toolbar" style="padding:10px;"> <kt-button :label="$t('action.batchDelete')" :perms="permsDelete" :size="size" type="danger" @click="handleBatchDelete()" :disabled="this.selections.length===0" style="float:left;" v-if="showBatchDelete & showOperation"/> <el-pagination layout="total, prev, pager, next, jumper" @current-change="refreshPageRequest" :current-page="pageRequest.pageNum" :page-size="pageRequest.pageSize" :total="data.totalSize" style="float:right;"> </el-pagination> </div> </div> </template>
10.5 标签页功能
新建store/modules/tab.js文件,用于存储标签页和当前选中标签选项:
export default {
state: {
// 主入口标签页
mainTabs: [],
// 当前标签页名
mainTabsActiveName: ''
},
mutations: {
updateMainTabs (state, tabs) {
state.mainTabs = tabs
},
updateMainTabsActiveName (state, name) {
state.mainTabsActiveName = name
}
}
}
修改views/MainContent.vue文件,在主内容区域上面添加标签页组件,通过Element的选项卡el-tabs组件实现,能够通过切换标签页查看不同的路由页面,并需要支持导航菜单和标签页同步:
<!-- 标签页 -->
<div class="tab-container">
<el-tabs
class="tabs"
:class="
$store.state.app.collapse ? 'position-collapse-left' : 'position-left'
"
v-model="mainTabsActiveName"
:closable="true"
type="card"
@tab-click="selectedTabHandle"
@tab-remove="removeTabHandle"
>
<el-dropdown class="tabs-tools" :show-timeout="0" trigger="hover">
<div style="font-size:20px;width:50px;">
<i class="el-icon-arrow-down"></i>
</div>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item @click.native="tabsCloseCurrentHandle"
>关闭当前标签</el-dropdown-item
>
<el-dropdown-item @click.native="tabsCloseOtherHandle"
>关闭其它标签</el-dropdown-item
>
<el-dropdown-item @click.native="tabsCloseAllHandle"
>关闭全部标签</el-dropdown-item
>
<el-dropdown-item @click.native="tabsRefreshCurrentHandle"
>刷新当前标签</el-dropdown-item
>
</el-dropdown-menu>
</el-dropdown>
<el-tab-pane
v-for="item in mainTabs"
:key="item.name"
:label="item.title"
:name="item.name"
>
<span slot="label"><i :class="item.icon"></i> {{ item.title }} </span>
</el-tab-pane>
</el-tabs>
</div>
通过watch实现对路由对象$route的监听,监听到路由变化的时候调用handleRoute方法进行处理,处理逻辑主要分三项:1.判断标签页是否存在,不存在则创建;2.如果是新创建了标签页,就把标签页加入当前标签页集合;3.同步标签页路由到导航菜单,设置选中状态。
watch: {
$route: "handleRoute"
},
methods: {
// 路由操作处理
handleRoute(route) {
// tab标签页选中, 如果不存在则先添加
var tab = this.mainTabs.filter(item => item.name === route.name)[0];
if (!tab) {
tab = {
name: route.name,
title: route.name,
icon: route.meta.icon
};
this.mainTabs = this.mainTabs.concat(tab);
}
this.mainTabsActiveName = tab.name;
// 切换标签页时同步更新高亮菜单
if (this.$refs.navmenu != null) {
this.$refs.navmenu.activeIndex = "" + route.meta.index;
this.$refs.navmenu.initOpenedMenu();
}
}
}
上一步将标签页存储到了store,这里mainTabs从store中获取标签页数据提供给页面:
computed:{
mainTabs: {
get() {
return this.$store.state.tab.mainTabs;
},
set(val) {
this.$store.commit("updateMainTabs", val);
}
},
mainTabsActiveName: {
get() {
return this.$store.state.tab.mainTabsActiveName;
},
set(val) {
this.$store.commit("updateMainTabsActiveName", val);
}
}
}
标签页对应的操作函数包括标签的选中、关闭等系列操作。
methods: {
// tabs, 选中tab
selectedTabHandle(tab) {
tab = this.mainTabs.filter(item => item.name === tab.name);
if (tab.length >= 1) {
this.$router.push({ name: tab[0].name });
}
},
// tabs, 删除tab
removeTabHandle(tabName) {
this.mainTabs = this.mainTabs.filter(item => item.name !== tabName);
if (this.mainTabs.length >= 1) {
// 当前选中tab被删除
if (tabName === this.mainTabsActiveName) {
this.$router.push(
{ name: this.mainTabs[this.mainTabs.length - 1].name },
() => {
this.mainTabsActiveName = this.$route.name;
}
);
}
} else {
this.$router.push("/");
}
},
// tabs, 关闭当前
tabsCloseCurrentHandle() {
this.removeTabHandle(this.mainTabsActiveName);
},
// tabs, 关闭其它
tabsCloseOtherHandle() {
this.mainTabs = this.mainTabs.filter(
item => item.name === this.mainTabsActiveName
);
},
// tabs, 关闭全部
tabsCloseAllHandle() {
this.mainTabs = [];
this.$router.push("/");
},
// tabs, 刷新当前
tabsRefreshCurrentHandle() {
var tempTabName = this.mainTabsActiveName;
this.removeTabHandle(tempTabName);
this.$nextTick(() => {
this.$router.push({ name: tempTabName });
});
}
}
10.6 系统介绍页
为方便标签页效果的测试,这里添加一个系统介绍页,作为系统的首页。新建views/Intro/Intro.vue文件结构,添加系统介绍页面,附上介绍信息:
<template>
<div class="page-container" style="width:99%;margin-top:15px;">
<el-carousel :interval="3000" type="card" height="450px" class="carousel">
<el-carousel-item class="carousel-item-intro">
<h2>项目介绍</h2>
<ul>
<li>基于 Spring Boot、Spring Cloud、Vue、Element 的 Java EE 快速开发平台</li>
<li>旨在提供一套简洁易用的解决方案,帮助用户有效降低项目开发难度和成本</li>
<li>博客提供项目开发过程同步系列教程文章,手把手的教你如何开发同类系统</li>
</ul>
<div><img src="@/assets/logo.png" style="width:120px;height:120px;padding-top:15px;" /></div>
</el-carousel-item>
<el-carousel-item class="carousel-item-func">
<h2>功能计划</h2>
<ul>
<li>✔ 系统登录:系统用户登录,系统登录认证(token方式)</li>
<li>✔ 用户管理:新建用户,修改用户,删除用户,查询用户</li>
<li>✔ 机构管理:新建机构,修改机构,删除机构,查询机构</li>
<li>✔ 角色管理:新建角色,修改角色,删除角色,查询角色</li>
<li>✔ 菜单管理:新建菜单,修改菜单,删除菜单,查询菜单</li>
<li>✔ 系统日志:记录用户操作日志,查看系统执行日志记录</li>
<li>✔ 数据监控:定制Druid信息,提供简洁有效的SQL监控</li>
<li>✔ 聚合文档:定制在线文档,提供简洁美观的API文档</li>
<li>✔ 备份还原:系统备份还原,一键恢复系统初始化数据</li>
<li>✔ 主题切换:支持主题切换,自定主题颜色,一键换肤</li>
<li>✔ 服务治理:集成Spring Cloud,实现全方位服务治理</li>
<li>✔ 服务监控:集成Spring Boot Admin,实现微服务监控</li>
<li>...</li>
</ul>
</el-carousel-item>
<el-carousel-item class="carousel-item-env">
<h2>开发环境</h2>
<ul>
<li>IDE : eclipse 4.6.x。 JDK : JDK 1.8.x。</li>
<li>Maven : Maven 3.5.x。 MySQL: MySQL 5.7.x。</li>
<li>IDE : VS Code 1.27。 Webpack:webpack 3.2.x。</li>
<li>NODE: Node 8.9.x。 NPM : NPM 6.4.x。</li>
</ul>
<h2>技术选型</h2>
<ul>
<li>核心框架:Spring Boot 2.x。 服务治理:Spring Cloud。</li>
<li>视图框架:Spring MVC 5.x。 持久层框架:MyBatis 3.x。</li>
<li>数据库连接池:Druid 1.x。 安全框架:Shiro 1.4.x。</li>
<li>前端框架:Vue.js 2.x。 页面组件:Element 2.x。</li>
<li>状态管理:Vuex.js 2.x。 后台交互:axios 0.18.x。</li>
<li>...</li>
</ul>
</el-carousel-item>
</el-carousel>
</div>
</template>
<script>
export default {
components:{
},
methods :{
}
}
</script>
<style>
.carousel {
padding-left: 20px;
padding-right: 20px;
margin-right: 20px;
}
.carousel h2 {
color: #475669;
font-size: 22px;
opacity: 1.75;
line-height: 100px;
margin: 0;
}
.carousel ul {
color: #475669;
font-size: 15px;
opacity: 1.75;
line-height: 40px;
margin: 0;
}
.carousel-item-intro h2 {
color: #ffffff;
font-size: 22px;
opacity: 1.75;
line-height: 80px;
margin: 0;
}
.carousel-item-intro ul {
color: #ffffff;
font-size: 15px;
opacity: 1.75;
line-height: 65px;
padding: 5px;
margin: 0;
}
.carousel-item-func h2 {
color: #3f393b;
font-size: 22px;
opacity: 1.75;
line-height: 50px;
margin: 0;
}
.carousel-item-func ul {
color: #3f393b;
font-size: 15px;
opacity: 1.75;
line-height: 30px;
text-align: left;
padding-left: 90px;
margin: 0;
}
.carousel-item-env h2 {
color: #475669;
font-size: 22px;
opacity: 1.75;
line-height: 50px;
margin: 0;
}
.carousel-item-env ul {
color: #475669;
font-size: 15px;
opacity: 1.75;
line-height: 35px;
text-align: left;
padding-left: 110px;
margin: 0;
}
.carousel-item-intro {
background-color: #19aaaf73;
-webkit-border-radius: 25px;
border-radius: 25px;
-moz-border-radius: 15px;
background-clip: padding-box;
box-shadow: 0 0 25px #a3b3b965;
}
.carousel-item-func {
background-color: #19aaaf73;
-webkit-border-radius: 25px;
border-radius: 25px;
-moz-border-radius: 15px;
background-clip: padding-box;
box-shadow: 0 0 25px #a3b3b965;
}
.carousel-item-env {
background-color: #19aaaf73;
-webkit-border-radius: 25px;
border-radius: 25px;
-moz-border-radius: 15px;
background-clip: padding-box;
box-shadow: 0 0 25px #a3b3b965;
}
.carousel-item-intro {
background-color: #b95e5e;
}
.carousel-item-func {
background-color: #52c578;
}
.carousel-item-env {
background-color: #41a7b9;
}
</style>
在router/index.js中添加系统介绍的路由。
{
path: '/',
name: '首页',
component: Home,
children: [
{
path: '',
name: '系统介绍',
component: Intro,
meta: {
icon: 'fa fa-home fa-lg',
index: 0
}
}
]
},
10.7 页面测试
效果如下:
11. 功能管理模块
就目前来看,功能管理页面大都相似,如用户管理、功能管理模块的字典管理、系统配置、登录日志和操作日志等都是以表格管理数据为主,机构管理和菜单管理则以表格树的数据为主,所以这里在每个类型中挑选一个作为讲解案例。
11.1 字典管理
11.1.1 关键代码
参照用户管理界面,在views/Sys下添加字段管理页面组件,逐步添加页面内容。
-
添加工具栏内容。
views/Sys/Dict.vue <div class="page-container"> <!--工具栏--> <div class="toolbar" style="float:left;padding-top:10px;padding-left:15px;"> <el-form :inline="true" :model="filters" :size="size"> <el-form-item> <el-input v-model="filters.label" placeholder="名称"></el-input> </el-form-item> <el-form-item> <kt-button icon="fa fa-search" :label="$t('action.search')" perms="sys:dict:view" type="primary" @click="findPage(null)"/> </el-form-item> <el-form-item> <kt-button icon="fa fa-plus" :label="$t('action.add')" perms="sys:dict:add" type="primary" @click="handleAdd" /> </el-form-item> </el-form> </div>
-
添加展示表格kt-table组件。
views/Sys/Dict.vue <!--表格内容栏--> <kt-table permsEdit="sys:dict:edit" permsDelete="sys:dict:delete" :data="pageResult" :columns="columns" @findPage="findPage" @handleEdit="handleEdit" @handleDelete="handleDelete"> </kt-table>
-
编辑对话框。
views/Sys/Dict.vue <!--新增编辑界面--> <el-dialog :title="operation?'新增':'编辑'" width="40%" :visible.sync="editDialogVisible" :close-on-click-modal="false"> <el-form :model="dataForm" label-width="80px" :rules="dataFormRules" ref="dataForm" :size="size"> <el-form-item label="ID" prop="id" v-if="false"> <el-input v-model="dataForm.id" :disabled="true" auto-complete="off"></el-input> </el-form-item> <el-form-item label="名称" prop="label"> <el-input v-model="dataForm.label" auto-complete="off"></el-input> </el-form-item> <el-form-item label="值" prop="value"> <el-input v-model="dataForm.value" auto-complete="off"></el-input> </el-form-item> <el-form-item label="类型" prop="type"> <el-input v-model="dataForm.type" auto-complete="off"></el-input> </el-form-item> <el-form-item label="排序" prop="sort"> <el-input v-model="dataForm.sort" auto-complete="off"></el-input> </el-form-item> <el-form-item label="描述 " prop="description"> <el-input v-model="dataForm.description" auto-complete="off" type="textarea"></el-input> </el-form-item> <el-form-item label="备注" prop="remarks"> <el-input v-model="dataForm.remarks" auto-complete="off" type="textarea"></el-input> </el-form-item> </el-form> <div slot="footer" class="dialog-footer"> <el-button :size="size" @click.native="editDialogVisible = false">{{$t('action.cancel')}}</el-button> <el-button :size="size" type="primary" @click.native="submitForm" :loading="editLoading">{{$t('action.submit')}}</el-button> </div> </el-dialog>
-
编写分页查询方法。
views/Sys/Dict.vue // 获取分页数据 findPage: function (data) { if(data !== null) { this.pageRequest = data.pageRequest } this.pageRequest.params = [{name:'label', value:this.filters.label}] this.$api.dict.findPage(this.pageRequest).then((res) => { this.pageResult = res.data }).then(data!=null?data.callback:'') },
-
编写批量删除方法。
views/Sys/Dict.vue // 批量删除 handleDelete: function (data) { this.$api.dict.batchDelete(data.params).then(data!=null?data.callback:'') },
-
编辑提交方法。
views/Sys/Dict.vue // 编辑 submitForm: function () { this.$refs.dataForm.validate((valid) => { if (valid) { this.$confirm('确认提交吗?', '提示', {}).then(() => { this.editLoading = true let params = Object.assign({}, this.dataForm) this.$api.dict.save(params).then((res) => { if(res.code == 200) { this.$message({ message: '操作成功', type: 'success' }) } else { this.$message({message: '操作失败, ' + res.msg, type: 'error'}) } this.editLoading = false this.$refs['dataForm'].resetFields() this.editDialogVisible = false this.findPage(null) }) }) } }) },
11.1.2 页面截图
11.2 角色管理
角色管理页面除了表格展示之外,还可以给角色赋予角色菜单。
11.2.1 关键代码
首先在views/Sys下新建角色管理页面,然后模仿用户管理加入表格组件,再在角色表格下面添加一个角色菜单树,为用户分配角色菜单。
<!--角色菜单,表格树内容栏-->
<div class="menu-container" :v-if="true">
<div class="menu-header">
<span><B>角色菜单授权</B></span>
</div>
<el-tree :data="menuData" size="mini" show-checkbox node-key="id" :props="defaultProps"
style="width: 100%;pading-top:20px;" ref="menuTree" :render-content="renderContent"
v-loading="menuLoading" element-loading-text="拼命加载中" :check-strictly="true"
@check-change="handleMenuCheckChange">
</el-tree>
<div style="float:left;padding-left:24px;padding-top:12px;padding-bottom:4px;">
<el-checkbox v-model="checkAll" @change="handleCheckAll" :disabled="this.selectRole.id == null"><b>全选</b></el-checkbox>
</div>
<div style="float:right;padding-right:15px;padding-top:4px;padding-bottom:4px;">
<kt-button :label="$t('action.reset')" perms="sys:role:edit" type="primary" @click="resetSelection"
:disabled="this.selectRole.id == null"/>
<kt-button :label="$t('action.submit')" perms="sys:role:edit" type="primary" @click="submitAuthForm"
:disabled="this.selectRole.id == null" :loading="authLoading"/>
</div>
</div>
然后针对选中的角色同步更新菜单树的勾选项以及进行菜单树本身节点选中时对应父子关系的同步选中状态变更。
// 角色选择改变监听
handleRoleSelectChange(val) {
if(val == null || val.val == null) {
return
}
this.selectRole = val.val
this.$api.role.findRoleMenus({'roleId':val.val.id}).then((res) => {
this.currentRoleMenus = res.data
this.$refs.menuTree.setCheckedNodes(res.data)
})
},
// 树节点选择监听
handleMenuCheckChange(data, check, subCheck) {
if(check) {
// 节点选中时同步选中父节点
let parentId = data.parentId
this.$refs.menuTree.setChecked(parentId, true, false)
} else {
// 节点取消选中时同步取消选中子节点
if(data.children != null) {
data.children.forEach(element => {
this.$refs.menuTree.setChecked(element.id, false, false)
});
}
}
},
11.2.2 页面截图
11.3 菜单管理
菜单管理主要是放置一个表格树组件,可以支持表格的新增、编辑和删除。机构管理页面与此类似。
在Core目录下添加一个TableTreeColumn封装组件,作为表格中可展开的列。
11.3.1 表格列组件
views/Core/TableTreeColumn.vue
<template>
<el-table-column :prop="prop" v-bind="$attrs">
<template slot-scope="scope">
<span @click.prevent="toggleHandle(scope.$index, scope.row)" :style="childStyles(scope.row)">
<i :class="iconClasses(scope.row)" :style="iconStyles(scope.row)"></i>
{{ scope.row[prop] }}
</span>
</template>
</el-table-column>
</template>
<script>
import isArray from 'lodash/isArray'
export default {
name: 'table-tree-column',
props: {
prop: {
type: String
},
treeKey: {
type: String,
default: 'id'
},
parentKey: {
type: String,
default: 'parentId'
},
levelKey: {
type: String,
default: 'level'
},
childKey: {
type: String,
default: 'children'
}
},
methods: {
childStyles (row) {
return { 'padding-left': (row[this.levelKey] * 25) + 'px' }
},
iconClasses (row) {
return [ !row._expanded ? 'el-icon-caret-right' : 'el-icon-caret-bottom' ]
},
iconStyles (row) {
return { 'visibility': this.hasChild(row) ? 'visible' : 'hidden' }
},
hasChild (row) {
return (isArray(row[this.childKey]) && row[this.childKey].length >= 1) || false
},
// 切换处理
toggleHandle (index, row) {
if (this.hasChild(row)) {
var data = this.$parent.store.states.data.slice(0)
data[index]._expanded = !data[index]._expanded
if (data[index]._expanded) {
data = data.splice(0, index + 1).concat(row[this.childKey]).concat(data)
} else {
data = this.removeChildNode(data, row[this.treeKey])
}
this.$parent.store.commit('setData', data)
this.$nextTick(() => {
this.$parent.doLayout()
})
}
},
// 移除子节点
removeChildNode (data, parentId) {
var parentIds = isArray(parentId) ? parentId : [parentId]
if (parentId.length <= 0) {
return data
}
var ids = []
for (var i = 0; i < data.length; i++) {
if (parentIds.indexOf(data[i][this.parentKey]) !== -1 && parentIds.indexOf(data[i][this.treeKey]) === -1) {
ids.push(data.splice(i, 1)[0][this.treeKey])
i--
}
}
return this.removeChildNode(data, ids)
}
}
}
</script>
11.3.2 创建表格树
在Sys目录下新建一个菜单管理界面,加入工具栏、编辑对话框与其他功能类型,主要是在页面放置表格树组件,放置一个el-table,针对name列使用我们封装的TableTreeColumn组件显示子树的展示。
views/Sys/Menu.vue
<!--表格树内容栏-->
<el-table :data="tableTreeDdata" stripe size="mini" style="width: 100%;"
v-loading="loading" rowKey="id" element-loading-text="$t('action.loading')">
<el-table-column
prop="id" header-align="center" align="center" width="80" label="ID">
</el-table-column>
<table-tree-column
prop="name" header-align="center" treeKey="id" width="150" label="名称">
</table-tree-column>
<el-table-column header-align="center" align="center" label="图标">
<template slot-scope="scope">
<i :class="scope.row.icon || ''"></i>
</template>
</el-table-column>
<el-table-column prop="type" header-align="center" align="center" label="类型">
<template slot-scope="scope">
<el-tag v-if="scope.row.type === 0" size="small">目录</el-tag>
<el-tag v-else-if="scope.row.type === 1" size="small" type="success">菜单</el-tag>
<el-tag v-else-if="scope.row.type === 2" size="small" type="info">按钮</el-tag>
</template>
</el-table-column>
<el-table-column
prop="parentName" header-align="center" align="center" width="120" label="上级菜单">
</el-table-column>
<el-table-column
prop="url" header-align="center" align="center" width="150"
:show-overflow-tooltip="true" label="菜单URL">
</el-table-column>
<el-table-column
prop="perms" header-align="center" align="center" width="150"
:show-overflow-tooltip="true" label="授权标识">
</el-table-column>
<el-table-column
prop="orderNum" header-align="center" align="center" label="排序">
</el-table-column>
<el-table-column
fixed="right" header-align="center" align="center" width="185" :label="$t('action.operation')">
<template slot-scope="scope">
<kt-button icon="fa fa-edit" :label="$t('action.edit')" perms="sys:menu:edit" @click="handleEdit(scope.row)"/>
<kt-button icon="fa fa-trash" :label="$t('action.delete')" perms="sys:menu:delete" type="danger" @click="handleDelete(scope.row)"/>
</template>
</el-table-column>
</el-table>
11.3.3 页面截图
12. 嵌套外部网页
12.1 需求背景
有些时候,我们需要内嵌外部网页,可以通过单击导航菜单,然后将我们的主内容栏加载外部网页的内容进行显示,如查看服务端提供的SQL监控页面、接口文档页面等。
这是就要求我们的导航菜单能够解析外部嵌套网页的URL,并根据URL路由到相应的嵌套组件。
12.2 实现原理
- 给菜单URL添加外部嵌套网页格式,除内部渲染的页面URL外,外部网页统一直接以http[s]完整路径开头。
- 路由导航守卫在动态加载路由时,检测到如果是外部嵌套网页,就绑定到IFrame嵌套组件,最后使用IFrame组件来渲染嵌套页面。
- 菜单单击跳转时,根据路由类型生成不同的路由路径,载入特定的页面内容渲染到步骤2绑定的特定组件中。
12.3 代码实现
12.3.1 确定菜单URL
服务监控页面,其实显示的就是服务监控提供的现有页面。访问地址是http://localhost:8001/druid/login.html,即完整的HTTP地址格式。输入完整的账号密码就可以看了(均为admin)。
我们将SQL监控的菜单URL配置为http://127.0.0.1:8001/druid/login.html,localhost跟127.0.0.1是一个意思,代表本机,届时路由解析时检测到以http开头的就是外部网页,然后绑定到IFrame嵌套页面组件上进行渲染。
12.3.2 创建嵌套组件
在views下创建IFrame目录并在其下创建一个IFrame.vue嵌套组件。
IFrame组件在渲染时,读取store的iframeUrl以加载要渲染的内容(通过设置src)。
views/IFrame/IFrame.vue
<template>
<div class="iframe-container">
<iframe :src="src" scrolling="auto" frameborder="0" class="frame" :onload="onloaded()">
</iframe>
</div>
</template>
<script>
export default {
data() {
return {
src: "",
loading: null
}
},
methods: {
// 获取路径
resetSrc: function(url) {
this.src = url
this.load()
},
load: function() {
this.loading = this.$loading({
lock: true,
text: "loading...",
spinner: "el-icon-loading",
background: "rgba(0, 0, 0, 0.5)",
// fullscreen: false,
target: document.querySelector("#main-container ")
})
},
onloaded: function() {
if(this.loading) {
this.loading.close()
}
}
},
mounted() {
this.resetSrc(this.$store.state.iframe.iframeUrl);
},
watch: {
$route: {
handler: function(val, oldVal) {
// 如果是跳转到嵌套页面,切换iframe的url
this.resetSrc(this.$store.state.iframe.iframeUrl);
}
}
}
}
</script>
<style lang="scss">
.iframe-container {
position: absolute;
top: 0px;
left: 0px;
right: 0px;;
bottom: 0px;
.frame {
width: 100%;
height: 100%;
}
}
</style>
12.3.3 绑定嵌套组件
在导航守卫动态加载路由时,解析URL,如果是嵌套页面,就绑定到IFrame组件。
router/index.js相关内容如下:
在每次路由时,把路由路径保存到store,如果是IFrame嵌套页面,IFrame就会在渲染的时候到store读取iframeUrl确定渲染的内容。
/**
* 处理IFrame嵌套页面
*/
function handleIFrameUrl(path) {
// 嵌套页面,保存iframeUrl到store,供IFrame组件读取展示
let url = path
let length = store.state.iframe.iframeUrls.length
for(let i=0; i<length; i++) {
let iframe = store.state.iframe.iframeUrls[i]
if(path != null && path.endsWith(iframe.path)) {
url = iframe.url
store.commit('setIFrameUrl', url)
break
}
}
}
在store/modules下新建iframe.js文件,存储IFrame状态。
store/modules/iframe.js
export default {
state: {
iframeUrl: [], // 当前嵌套页面路由路径
iframeUrls: [] // 所有嵌套页面路由路径访问URL
},
getters: {
},
mutations: {
setIFrameUrl(state, iframeUrl){ // 设置iframeUrl
state.iframeUrl = iframeUrl
},
addIFrameUrl(state, iframeUrl){ // iframeUrls
state.iframeUrls.push(iframeUrl)
}
},
actions: {
}
}
iframe.js是一个工具类,主要对嵌套URL进行处理:
utils/iframe.js
/**
* 嵌套页面IFrame模块
*/
import { baseUrl } from '@/utils/global'
/**
* 嵌套页面URL地址
* @param {*} url
*/
export function getIFramePath (url) {
let iframeUrl = ''
if(/^iframe:.*/.test(url)) {
iframeUrl = url.replace('iframe:', '')
} else if(/^http[s]?:\/\/.*/.test(url)) {
iframeUrl = url.replace('http://', '')
if(iframeUrl.indexOf(":") != -1) {
iframeUrl = iframeUrl.substring(iframeUrl.lastIndexOf(":") + 1)
}
}
return iframeUrl
}
/**
* 嵌套页面路由路径
* @param {*} url
*/
export function getIFrameUrl (url) {
let iframeUrl = ''
if(/^iframe:.*/.test(url)) {
iframeUrl = baseUrl + url.replace('iframe:', '')
} else if(/^http[s]?:\/\/.*/.test(url)) {
iframeUrl = url
}
return iframeUrl
}
12.3.4 菜单路由跳转
在菜单路由跳转的时候,判断是否是IFrame路由,如果是就处理成IFrame需要的路由URL进行跳转。
components/MenuTree/index.vue
handleRoute (menu) {
// 如果是嵌套页面,转换成iframe的path
let path = getIFramePath(menu.url)
if(!path) {
path = menu.url
}
// 通过菜单URL跳转至指定路由
this.$router.push("/" + path)
}
12.4 页面测试
启动注册中心、监控服务、后台服务,访问http://localhost:8080,登录主页。
(数据监控如果页面显示拒绝加载,是因为Spring Security默认不允许页面被嵌套,所以X-Frame-Options默认设置为DENY,这个可以在后台WebSecurityConfig配置类通过http.headers().frameOptions().disable();禁用X-Frame-Options设置就可以正常显示了)
13. 数据备份还原
13.1 需求背景
在很多时候,我们需要对系统数据进行备份还原。当然,生产线上专业的备份会由专门的工作人员如DBA在数据库服务端直接进行备份还原。我们这里主要通过界面进行少量的数据备份和还原,比如做一个在线演示系统,为了方便恢复被演示用户删除或修改过的演示数据,我们这里就实现了这么一个系统数据备份和还原的功能。通过代码调用MySQL的备份还原命令实现系统备份还原的功能,具体逻辑都是由后台代码实现的。
13.2 后台接口
在前期我们已经准备好了系统备份还原的后台接口,具体可以参考备份还原后台篇。
备份还原的接口主要有以下几个:
- backup
- delete
- findRecord
- restore
13.3 备份页面
在views目录下新建Backup目录和页面,页面内容主要是一个可以进行备份和还原的对话框组件。
-
模板内容
views/Backup/Backup.vue <template> <!--备份还原界面--> <el-dialog :title="$t('common.backupRestore')" width="40%" :visible.sync="backupVisible" :close-on-click-modal="false" :modal=false> <el-table :data="tableData" style="width: 100%;font-size:16px;" height="330px" :show-header="showHeader" size="mini" v-loading="tableLoading" :element-tableLoading-text="$t('action.loading')"> <el-table-column prop="title" :label="$t('common.versionName')" header-align="center" align="center"> </el-table-column> <el-table-column fixed="right" :label="$t('action.operation')" width="180"> <template slot-scope="scope"> <el-button @click="handleRestore(scope.row)" type="primary" size="mini">{{$t('common.restore')}}</el-button> <el-button @click="handleDelete(scope.row)" type="danger" :disabled="scope.row.name=='backup'?true:false" size="mini">{{$t('action.delete')}}</el-button> </template> </el-table-column> </el-table> <span slot="footer" class="dialog-footer"> <el-button size="small" @click="backupVisible = false">{{$t('action.cancel')}}</el-button> <el-button size="small" type="primary" @click="handleBackup">{{$t('common.backup')}}</el-button> </span> </el-dialog> </template>
针对备份查询、备份创建、备份还原和备份删除的方法如下,因为备份还原服务比较简单,这里就没有对axios进行封装了,直接调用axios方法即可。
-
备份查询
// 查询备份记录 findRecords: function () { this.tableLoading = true axios.get(this.baseUrl + '/backup/findRecords').then((res) => { res = res.data if(res.code == 200) { this.tableData = res.data } else { this.$message({message: '操作失败, ' + res.msg, type: 'error'}) } this.tableLoading = false }) },
-
备份创建
// 数据备份 handleBackup: function () { this.tableLoading = true axios.get(this.baseUrl + '/backup/backup').then((res) => { res = res.data if(res.code == 200) { this.$message({ message: '操作成功', type: 'success' }) } else { this.$message({message: '操作失败, ' + res.msg, type: 'error'}) } this.tableLoading = false this.findRecords() }) },
-
备份还原
// 数据还原 handleRestore: function (data) { this.tableLoading = true axios.get(this.baseUrl + '/backup/restore', {params : {name : data.name }}).then((res) => { res = res.data if(res.code == 200) { this.$message({ message: '操作成功', type: 'success' }) this.$emit('afterRestore', {}) } else { this.$message({message: '操作失败, ' + res.msg, type: 'error'}) } this.tableLoading = false }) },
-
备份删除
// 删除备份 handleDelete: function (data) { this.tableLoading = true axios.get(this.baseUrl + '/backup/delete', {params : {name : data.name }}).then((res) => { res = res.data if(res.code == 200) { this.$message({ message: '操作成功', type: 'success' }) } else { this.$message({message: '操作失败, ' + res.msg, type: 'error'}) } this.findRecords() this.tableLoading = false }) }
13.4 页面引用
之前在用户面板预留了备份还原的操作入口,在用户面板中引入备份还原组件。
views/Core/PersonalPanel.vue相关内容如下:
在备份还原操作中添加响应函数,打开备份还原界面。views/Core/PersonalPanel.vue的相关内容如下:
<div class="other-operation-item" @click="showBackupDialog">
<li class="fa fa-undo"></li>
{{$t("common.backupRestore")}}
</div>
响应函数,打开备份还原界面:
// 打开备份还原界面
showBackupDialog: function() {
this.$refs.backupDialog.setBackupVisible(true)
},
还原操作响应函数,还原成功之后清除用户信息并返回到登录页面:
// 成功还原之后,重新登录
afterRestore: function() {
this.$refs.backupDialog.setBackupVisible(false)
sessionStorage.removeItem("user")
this.$router.push("/login")
this.$api.login.logout().then((res) => {
}).catch(function(res) {
})
}
13.5 页面测试
启动注册中心、监控服务、后台服务、备份服务,访问http://localhost:8080/#/,登录应用进入主页,单击"用户面板→备份还原",弹出备份还原界面。
-
备份查询
可以看到初始状态系统会为我们创建一个初始备份,即系统默认备份,且系统默认备份不可删除,防止所有备份被删除时无备份可用。
-
备份创建
点击"备份"按钮,进行数据备份,系统创建了一个新的备份。备份名称格式为"backup_+时间戳",对应SQL文件存储目录名称。
-
备份删除
单击"删除"按钮,备份成功删除。
-
备份还原
接下来我们测试接下来我们测试一下数据还原功能,选择"系统管理→机构管理",删除轻尘集团和牧尘集团的两棵机构树,只剩下三国集团。
单击"还原"按钮,进行数据还原:
还原成功之后会回到登录页面,重新登录,查看机构管理,发现数据已经恢复。
更多推荐
所有评论(0)