阅读 878

Vue+ElementUI 后台博客管理

首页

前言

此 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 一起运行才行的,不然接口请求会失败。

项目地址:

前台展示:https://gitee.com/sdj_work/blog-page(Vue/Nuxt/uni-app)

管理后台:https://gitee.com/sdj_work/blog-admin(Vue/React)

后端Node:https://gitee.com/sdj_work/blog-node(Express/Koa)

博客地址:https://sdjBlog.cn/

项目系列文章:

Vue+Nuxt 博客展示

Vue+uniapp 博客展示

Vue+ElementUI 后台博客管理

node + koa + mongodb 博客接口开发

node + express + mongodb 博客接口开发

文章分类
前端
文章标签