前言
此 blog-admin 后台管理项目是基于 vue 全家桶 + Element UI 开发
效果图
完整效果请看:sdjBlog.cn:8080/
功能描述
已经实现功能
- 登录注册
- 个人资料
- 主题切换
- 数据统计
- 文章列表
- 评论列表
- 标签列表
- 项目列表
- 友情链接
- 留言列表
- 菜单功能
- 用户角色
前端技术
- vue
- vuex
- vue-route
- axios
- scss
- element-ui
- moment
- highlight.js
- echarts
- wangeditor
- mavon-editor
- xlsx
主要项目结构
- api axios封装以及api接口
- assets 图片和css字体资源
- components 组件封装
- TagsView 路由标签导航
- ChartCard 卡片
- EmptyShow 数据为空提示
- MyEcharts echarts图表封装
- MyForm 表单封装
- MyTable 表格封装
- TreeSelect 下拉树型结构
- UploadFile 文件上传
- WangEnduit WangEnduit 富文本编辑器
- router 路由封装
- store vuex 的状态管理
- utils 封装的常用的方法,如表单验证,excel导出
- views
- article 文章列表、文章评论以及文章标签
- errorPage 错误页面,如404
- home 数据统计(访客、用户、文章和留言统计)
- layout 头部导航以及侧边导航
- link 友情链接列表
- login 登录注册
- menu 菜单功能
- message 留言列表
- project 项目列表
- user 用户角色(角色包括导入权限以及批量导入导出用户)
- redirect 路由重定向
- app.vue 根组件
- main.js 入口文件,实例化Vue、插件初始化
- permission.js 路由权限拦截,通过后台返回权限加载对应路由
复制代码
说明
- 登录是通过用户名或邮箱加密码登录,测试账号:用户名:test 密码:123456
- 从后台注册页面注册用户为博主管理员,可以发布自己文章和添加普通用户等权限
- 该系统实现了菜单功能权限以及数据权限,根据用户角色拥有的权限加载菜单路由以及按钮操作,后台通过用户角色和权限进行api请求拦截以及请求数据获取该用户下的数据列表
功能实现
代码封装
通用的部分代码进行封装,从而方便统一管理和维护
图表封装
通过配置option图表参数(同echarts options配置)、宽高来初始化图表,并监听option参数变化以及窗口变化,图表自适应
<template>
<div class="echarts"
:id="id"
:style="style">
</div>
</template>
<script>
export default {
props: {
width: {
type: String,
default: "100%"
},
height: {
type: String
},
option: {
type: Object
}
},
data() {
return {
id: '',
MyEcharts: "" //echarts实例
};
},
computed: {
style() {
return {
height: this.height,
width: this.width
};
}
},
watch: {
//要监听的对象 option
//由于echarts采用的数据驱动,所以需要监听数据的变化来重绘图表
option: {
handler(newVal, oldVal) {
if (this.MyEcharts) {
if (newVal) {
this.MyEcharts.setOption(newVal, true);
} else {
this.MyEcharts.setOption(oldVal, true);
}
} else {
this.InitCharts();
}
},
deep: true //对象内部属性的监听,关键。
}
},
created(){
// this.id = Number(Math.random().toString().substr(3,length) + Date.now()).toString(36);
this.id = this.uuid();
},
mounted() {
this.InitCharts();
},
methods: {
// 生成唯一标识
uuid(){
return 'xxxxxx4xxxyxxxxxx'.replace(/[xy]/g, function (c) {
let r = Math.random() * 16 | 0,
v = c == 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
},
//所设计的图表公共组件,不论第一种方法还是第二种方法都按照官网的格式来写数据,这样方便维护
InitCharts() {
this.MyEcharts = this.$echarts.init(document.getElementById(this.id));
/**
* 此方法适用于所有项目的图表,但是每个配置都需要在父组件传进来,相当于每个图表的配置都需要写一遍,不是特别的省代码,主要是灵活度高
* echarts的配置项,你可以直接在外边配置好,直接扔进来一个this.option
*/
this.MyEcharts.clear(); //适用于大数据量的切换时图表绘制错误,先清空在重绘
this.MyEcharts.setOption(this.option, true); //设置为true可以是图表切换数据时重新渲染
//以下这种方法,当一个页面有多个图表时,会有一个bug那就是只有一个图表会随着窗口大小变化而变化。
// window.onresize = () => {
// this.MyEcharts.resize();
// };
//以下为上边的bug的解决方案。以后用这种方案,放弃上一种。
window.addEventListener("resize", ()=> {
this.MyEcharts.resize();
});
}
}
};
</script>
复制代码
表单和表格封装
表单和表格是后台系统使用比较频繁的组件,通过对Element UI进行二次封装,达到使用通用的JSON数据格式传递。表单子组件通过将prop传递属性定义为对象(基本类型无法直接修改),来达到通过v-model绑定数据并更新,初始化开启部分默认操作(清空按钮、字数限制等),并使用大量插槽让组件更灵活。表格通过render模式来渲染,增加数据展示灵活性。
//表单传递格式,ref来操作表单清空等操作
articleForm: {
ref: 'articleRef',
labelWidth: '80px',
marginBottom: '30px',
requiredAsterisk: true,
formItemList: [
{
type: "text",
prop: "title",
width: '400px',
label: '文章标题',
placeholder: "请输入文章标题"
},
{
type: "text",
prop: "description",
width: '400px',
label: '文章描述',
placeholder: "请输入文章描述"
},
{
type: "select",
prop: "tags",
multiple: true,
width: '400px',
label: '文章标签',
placeholder: "请选择文章标签",
arrList: []
},
{
label: "文章封面",
slot: 'upload'
},
{
type: "radio",
prop: "contentType",
label: '文章类型',
arrList: [
{
label: '富文本编辑',
value: '0'
},
{
label: 'markdown编辑',
value: '1'
}
]
}
],
formModel: {
title: '',
description: '',
tags: [],
contentType: '0'
},
rules: {
title: [
{ required: true, validator: Format.FormValidate.Form('文章标题').NoEmpty, trigger: 'blur' }
],
description: [
{ required: true, validator: Format.FormValidate.Form('文章描述').NoEmpty, trigger: 'blur' }
],
tags: [
{ required: true, validator: Format.FormValidate.Form('文章标签').TypeSelect, trigger: 'change' }
]
}
}
复制代码
文件上传封装
在beforeUpload中验证文件大小、格式,并进行文件上传请求和文件上传进度条显示
<template>
<div class='slot-upload'>
<div class="upload-square" v-if='type === "square"'>
<el-upload class='upload-file' enctype='multipart/form-data' :accept="accept" :limit='limit' :multiple="multiple"
:list-type="listType" :file-list="fileList" :before-upload='beforeUpload' :before-remove='beforeRemove'
:on-exceed='handleExceed' action="">
<i class="el-icon-plus avatar-uploader-icon"></i>
</el-upload>
<div class="file-progress" v-if='progressObj.show'>
<el-progress :percentage="progressObj.percentage" :status="progressObj.percentage == 100?'success':'exception'">
</el-progress>
</div>
</div>
<div class="upload-avatar" v-else-if='type === "avatar"'>
<el-upload class='avatar-slot' enctype='multipart/form-data' :accept="accept" :limit='limit' :multiple="multiple"
:list-type="listType" :file-list="fileList" :before-upload='beforeUpload' :before-remove='beforeRemove'
:on-exceed='handleExceed' action="">
<slot :name="avatarSlot" v-if='avatarSlot' />
</el-upload>
<div class="file-progress" v-if='progressObj.show' :style='{width: progress.width,margin: progress.margin}'>
<el-progress :percentage="progressObj.percentage" :status="progressObj.percentage == 100?'success':'exception'">
</el-progress>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'UploadFile',
props: {
//展示类型
type: {
type: String,
default: "square"
},
//接收文件类型
accept: {
type: String,
default: "image/*"
},
//是否可选多个
multiple: {
type: Boolean,
default: false
},
//文件展示类型
listType: {
type: String,
default: "text"
},
//限制个数
limit: {
type: Number,
default: 1
},
//文件展示列表
fileList: {
type: Array,
default() {
return [];
}
},
//文件限制大小
fileSize: {
type: Number,
default: 1048576
},
//进度条对象
progress: {
type: Object,
default() {
return {};
}
},
//自定义内容
avatarSlot: {
type: String,
default: ""
}
},
data() {
return {
progressObj: {
show: false,
percentage: 0
}
}
},
methods: {
handleExceed(files, fileList) {
this.$message.warning(`当前限制选择 ${this.limit} 个文件,本次选择了 ${files.length} 个文件,共选择了 ${files.length + fileList.length} 个文件,请删除后在添加`);
},
beforeUpload(file){
if(file.size > this.fileSize){
let sizeLimit = this.fileSize/1024/1024
this.$Message.warning(`大小限制在${sizeLimit}Mb以内`)
return
}
this.progressObj.percentage = 0;
this.progressObj.show = true;
let fd = new FormData()
fd.append('file', file)
this.$api.upload.uploadFile(fd,(upload)=>{
let complete = (upload.loaded / upload.total * 100 | 0)
this.progressObj.percentage = complete;
if(this.progressObj.percentage == 100){
setTimeout(()=>{
this.progressObj = {
show: false,
percentage: 0
}
},1000)
}
}).then((res) => {
let code = res.code
if(code === this.$constant.reqSuccess){
let fileData = res.data
this.$emit('uploadEvent',fileData)
}else{
this.progressObj = {
show: false,
percentage: 0
}
this.$message.warning('文件上传失败');
}
})
return false
},
beforeRemove(file, fileList){
if(file.status === 'ready'){
return true
}else{
this.$api.upload.fileDel(file.sourceId).then((res)=>{
let code = res.code
if(code === this.$constant.reqSuccess){
this.$emit('removeEvent',file)
}else{
this.$message.warning('文件删除失败');
return false
}
})
}
}
}
}
</script>
<style lang="scss" scoped>
.slot-upload {
width: 100%;
.upload-file {
.el-upload {
border: 1px dashed $color-G70;
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
&:hover {
border-color: #409eff;
}
}
/deep/ .el-upload--picture-card, /deep/ .el-upload-list__item{
width: 120px;
height: 120px;
line-height: 120px;
}
.avatar-uploader-icon {
font-size: 20px;
color: #8c939d;
width: 48px;
height: 48px;
line-height: 48px;
text-align: center;
}
}
.upload-avatar{
.avatar-slot{
display: flex;
align-items: center;
justify-content: center;
}
}
.file-progress {
width: 400px;
margin-top: 10px;
}
}
</style>
复制代码
api封装
请求和响应进行拦截,配置token数据,请求接口统一在一个文件里面处理
//数据请求
const project = {
projectList (params) {
return axios.get('/project/list',{params})
},
projectAdd (params) {
return axios.post('/project/add',params)
},
projectUpdate (params) {
return axios.put('/project/update',params)
},
projectDel(id){
return axios.delete('/project/del/'+id)
}
}
export default {
project
}
复制代码
路由拦截
通过后台返回的角色菜单id权限和路由菜单id对比,动态添加路由
import router from './router'
import store from './store'
import { Message } from 'element-ui'
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
NProgress.configure({ showSpinner: false })
const whiteList = ['/login'] // 没有重定向白名单
router.beforeEach(async(to, from, next) => {
NProgress.start()
// 设置页面标题
if (to.meta.title) {
document.title = to.meta.title
}
if (sessionStorage.getItem('token')) {
if (to.path === '/login') {
// 如果已登录,则重定向到主页,添加query解决在/刷新到login时重回主页
next({ path: '/' ,query: {
t: +new Date()
}})
NProgress.done()
} else {
// 确定用户是否获取了用户权限信息
const hasAuths = store.getters.getAuthList && store.getters.getAuthList.length > 0;
if (hasAuths) {
next()
} else {
try {
// 获得用户信息
let auths = await store.dispatch('actAuthList')
// 生成可访问的路由表
let accessRoutes = await store.dispatch('generateRoutes', auths)
if(accessRoutes.length > 0){
let pathTo = accessRoutes[0].path
// 动态添加可访问路由表
router.addRoutes(accessRoutes)
// hack方法 确保addRoutes已完成
let routeList = []
accessRoutes.forEach((item)=>{
if(item.children){
for(let i = 0; i < item.children.length; i++){
routeList.push(item.children[i].path)
}
}
})
if(routeList.includes(to.path)){
next({ ...to, replace: true })
}else{
next({path: pathTo, replace: true })
}
}else{
// 删除token,进入登录页面重新登录
sessionStorage.removeItem('token')
Message.error('暂无权限查看')
next(`/login?redirect=${to.path}`)
NProgress.done()
}
} catch (error) {
// 删除token,进入登录页面重新登录
sessionStorage.removeItem('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()
}
}
})
router.afterEach((to) => {
NProgress.done();
window.scrollTo(0, 0);
})
复制代码
主题切换
通过localStorage存储所选主题,动态给body添加属性data-theme,使用scss @mixin来配置不同主题
//body 添加属性
getThemeColor(){
let propTheme = localStorage.getItem('propTheme');
if(propTheme){
document.body.setAttribute('data-theme',propTheme);
}else{
document.body.setAttribute('data-theme','custom-light');
localStorage.setItem('propTheme','custom-light')
}
}
//scss根据属性配置不同主题
@mixin bg-color($lightColor: transparent, $darkColor: transparent){
background-color: $lightColor;
[data-theme='custom-light'] & {
background-color: $lightColor;
}
[data-theme='custom-dark'] & {
background-color: $darkColor;
}
}
.product-item{
@include bg-color($color-W20,$color-D20);
}
复制代码
常用工具函数封装
表单校验、excel导出、时间格式封装
// 与当前时间相差天数
export function diffDay(time) {
let currentTime = moment();
let endTime = moment(time);
let day = endTime.diff(currentTime, 'day')
return day
}
// 当前时间格式化
export function currentDay(type = 'time') {
if(type === 'day'){
return moment().format('YYYY-MM-DD')
}else{
return moment().format('YYYY-MM-DD HH:mm:ss')
}
}
// 判断深层次对象属性是否存在
export let objProp = (data, path) => {
if (!data || !path) {
return null
}
let tempArr = path.split('.');
for (let i = 0; i < tempArr.length; i++) {
let key = tempArr[i]
if (data[key]) {
data = data[key]
} else {
return null
}
}
return data
}
复制代码
Build Setup ( 建立安装 )
# install dependencies
npm install
# serve with hot reload at localhost: 8090
npm run dev
# build for production with minification
npm run build
复制代码
如果要看完整的效果,是要和后台项目 blog-node 一起运行才行的,不然接口请求会失败。
项目地址:
项目系列文章: