[项目总结] Vue商铺后台管理系统(待完善)

215 阅读6分钟

这是一个基于Vue2 + Element-ui + Echarts的PC端小项目,功能模块有:登录可视化数据展示分页展示修改用户信息订单管理规格管理等。

整个项目功能不多,但是我也从中学习到很多,比如封装复用组件、使用Echarts可视化图表、更加熟练使用axios发请求和Element-ui组件库等等。

温故而知新,想对完成的项目做一个简单的总结和回顾,目前只写到了商品管理模块,后面还会补充更新的。

项目地址

在线演示:chyeonl.github.io/vue-system/

在线演示只能展示页面布局和所有数据,无法使用功能

因为后端是我自己在本地搭建的node服务器,为了可以在线展示我mock了数据但是没办法进行增删改查的操作

github:github.com/chyeonL/vue…

可以下载仓库中的master分支在本地运行

项目准备

reset.css 引入公共的初始化样式

3种引入方式:

  1. main.js 入口文件 import
  2. index.html 标签引入
  3. 在组件的标签中引入

iconfont引入

引入方式:(每次有新的图标都要重新生成在线代码/下载、重新引入)

  1. 在线代码
    • 放在reset.css里一起引入吧,比较好(就只用引入一次)
    • main.js 入口文件 import
  2. 下载到本地

scss 预处理器使用

// 安装依赖
npm i sass  
npm i sass-loader@10

    
<style scoped lang="scss">
// 样式
</style>

路由分析、配置(兄弟路由,子路由,多级路由)

  1. 先分析有什么页面,然后注册路由组件

  2. 写了结构,再写功能和样式

  • 一级路由:登录login 和 布局layout(兄弟路由) layout就是首页界面,里面还有

  • 二级路由:layout的侧边栏(子路由)

    layout分为左边和右边,都封装为单独的组件

    myNav组件左边导航侧边栏

    myContent组件右边内容区(又分为上下)

  • 三级路由:侧边栏导航的子菜单

    注意路由注册的时候,子路由的写法,前面连 斜杠 都不要加

img

  1. 其他技术点

    路由搭建:

import Vue from 'vue'
import VueRouter from 'vue-router'
import routes from './routes'
Vue.use(VueRouter)

const router = new VueRouter({
    // 路由模块化 注册  数组
    routes, 
    // 路由配置模式,默认是hash
    mode:history 
    // 路由的其他可配置项,如:滚动行为
})

路由懒加载 ()=>import('@/views/Home')

路由重定向 redirect

路由元信息 meta:{ }

路由模块化 routes = [{},{},......]

在main.js中引入,并注册 (Don't forget!)

页面布局Layout(左固定 右自适应)

左侧和右侧顶部固定,右侧内容区滑动

image-20221025182646080.png

右边顶部固定 下边内容区显示不同路由 实现:

  • Layout盒子 flex布局 100vw 100vh
  • 左边固定具体宽度 高度100%
  • 右边flex:1 高度100% overflow:auto(超出高度的时候显示滚动条,不然不设置滚动时左边没办法占到底)

img

  • 右边flex+相对定位

    • 顶部绝对定位,宽度100%高度固定,top 0 left 0

    • 内容区flex:1, overflow:auto,margin-top顶部的高度

img

axios二次封装

import Axios from "axios";
import Nprogress from 'nprogress'
import 'nprogress/nprogress.css'    // Progress 添加进度条样式

// 默认配置
Axios.defaults.baseURL = "/api";
Axios.defaults.headers.post["Content-Type"] =  "application/x-www-form-urlencoded";
Axios.defaults.timeout = 5000;

// 请求拦截器
Axios.interceptors.request.use(
  (config) => {
    Nprogress.start()   // 进度条开始
    // 携带token
    if(JSON.parse(localStorage.getItem('userInfo'))){
      let token = JSON.parse(localStorage.getItem('userInfo')).token
      Axios.defaults.headers.common['Authorization'] = token
    }
    return config; //很重要,没有这句直接gg
  },
  (error) => {}
);

// 响应拦截器
Axios.interceptors.response.use(
  (response) => {
    // console.log(response);
    Nprogress.done()
    if (response.status == 200) return response.data;
  },
  (error) => {}
);

export default Axios;

跨域问题

配置代理服务器

Vue.config.js -- devserver -- proxy

proxy写成对象形式,可以设置多个代理(以前缀区分)

img

统一管理接口

新建api.js文件,引入刚封装的axios实例,分别向外暴露每一个接口

// 统一封装接口 并 分别暴露
import request from "./request";

// 举例接口
// 登录
export const login = (data)=>request.post('/login',data)
export const modifyPwd = (data)=>request.post('/modifyPwd',data)

在需要发请求的地方引入使用

// 可以引入整个api文件,也可以解构赋值引用其中的接口方法
import { login,modifyPwd } from "@/api";

还可以在main.js引入,然后挂在组件身上。

img

注意:但是只有在组件里才能使用,其他文件比如vuex就不行(因为vuex中的this并不是指向Vue实例,而是Vuex.store)

Vuex

Vuex:是Vue专门用于集中式状态管理的一个插件,适用于任何组件间的通信。

  • 新建store文件夹,index.js文件
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)

// 模块化
import user from './user'
import params from './params'

export default new Vuex.Store({
    modules:{
        user,params
    }
})

在main.js中引入,并注册 (Don't forget!)

系统初始化

初次进入系统,默认进入是“home首页”

加载App组件时,在mounted生命周期函数中进行初始化——判断vuex仓库中 isLogin 的状态(isLogin默认读取本地存储),若无登录,会跳转Login登陆页面

image.png

image.png

image.png

登录拦截(全局路由守卫)

beforeEach() 判断当前使用的功能是否需要登录,若需要,则再次判断登录状态

import {Message} from 'element-ui'
import store from '@/store'
    
router.beforeEach((to,from,next)=>{
    // 只要有一个匹配到的路由有requireAuth,就返回true
    let requireAuth = to.matched.some(item=>item.meta.requireAuth)
    // 看看登录没
    let isLogin = store.state.user.isLogin 
    // console.log(isLogin);
    // 需要登录
    if(requireAuth){        
        // 已经登陆
        if(isLogin) next()
        else{
            Message({
                message: '需要先进行登录',
                type: 'warning'
              });
            next({
                path:'/login',
                query:{
                    redirect:to.fullPath
                }
            })
        }
    }else{
        next()
    }
})

注:所有功能都需要登录后才能使用

添加路由元信息meta:{ },因为所有功能模块都是Layout路由的子路由,所以只要给父路由加就好了,所有的子路由都会进行拦截

{
    path:'',
    name:'Layout',
    component:Layout,
    redirect:'/home',	// 路由重定向
    meta:{
    	requireAuth:true	// 需要登陆权限
    },
    children:[{...},{...},...]
 }

登录的逻辑

前端验证(表单验证)

用了 Element-ui 的表单样式和验证规则,也可以自定义规则

后端验证(发请求)

这里我没有直接发请求,而是dispatch请求,然后在vuex处理数据

验证结果

成功:

  • 保存返回的用户信息(vuex+本地存储)
  • 弹窗提醒
  • 路由跳转

保存之前要去的功能页到路由query参数的redirect,登陆成功后判断有无query参数,有则跳转

 // 表单结构HTML
 <el-form
        :model="ruleForm"
        :rules="rules"
        ref="ruleForm"
        label-width="50px"
        class="demo-ruleForm"
      >
        <h2 style="font-weight: normal; font-size: 18px">登录</h2>
        <el-form-item label="账号" prop="username">
          <el-input
            type="text"
            v-model="ruleForm.username"
            @keyup.enter.native="submitForm()"
          ></el-input>
        </el-form-item>
        <el-form-item label="密码" prop="password">
          <el-input
            type="password"
            v-model="ruleForm.password"
            @keyup.enter.native="submitForm()"
          ></el-input>
        </el-form-item>
        <el-form-item class="buttons">
          <el-button @click="resetForm()">重置</el-button>
          <el-button type="primary" @click="submitForm()" class="goLogin">
            登录
          </el-button>
        </el-form-item>
 </el-form>
 
 // 表单项 以及 验证规则
 data() {
    var validateUsername = (rule, value, callback) => {
      if (value === "") {
        callback(new Error("请输入帐户"));
      } else {
        callback();
      }
    };
    var validatePassword = (rule, value, callback) => {
      if (value === "") {
        callback(new Error("请输入密码"));
      } else {
        callback();
      }
    };
    return {
      // 用户对象
      ruleForm: {
        username: "administrator",
        password: "password",
      },
      // 验证方法
      rules: {
        username: [{ validator: validateUsername, trigger: "blur" }],
        password: [{ validator: validatePassword, trigger: "blur" }],
      },
    };
 },

// 登录的事件回调
submitForm() {
      this.$refs["ruleForm"].validate((valid) => {
       // 表单验证
        if (valid) {
          // console.log(this.ruleForm);          
          // 后台验证  发请求
          this.$store.dispatch("goLogin", this.ruleForm).then((res) => {
            // 弹窗提示
            if (res) {
              this.$message({
                message: "登陆成功",
                type: "success",
              });
              // 跳转到登录前要去的页面,如果有的话
              if (this.$route.query.redirect) {
                this.$router.replace(`/${this.$route.query.redirect}`);
              } else this.$router.replace("/");
              // 刷新页面
              this.$router.go(0);
            }
          });
        } 
        // 前端验证失败
        else {
          this.$message.error("请完整输入");
          return false;
        }
      });
 },
 
 // 重置
    resetForm() {
      this.ruleForm = {
        username: "",
        password: "",
      };
 }

失败: 弹窗提示

注:

  • 自动填充密码和账号
  • 给组件原生绑定回调事件需要加上 .native 修饰符

侧边栏的折叠与展开

layout父组件 折叠与展开改变的是myNav的宽度,动态类名控制样式,默认展开(collapse:false)

image.png

image.png

  1. 自定义事件(改变collapse)传给myContent 点击图标触发

    // 定义自定义事件
    changeIsCollapse() {
          this.isCollapse = !this.isCollapse;
    },
    
    
    // 触发自定义事件
    change() {
    	this.$emit("changeIsCollapse");
    }
    
  2. collapse值传给myNav 导航栏组件有内置属性值

image.png

深度选择器 ::v-deep

当 标签有 scoped 属性时,它的 CSS 只作用于当前组件中的元素,父组件的样式将不会渗透到子组件。 如果你希望 scoped 样式中的一个选择器能够作用得“更深”,例如影响子组件,你可以使用深度选择器。

例:Element-ui的badge组件

image.png image.png

同时还有牵涉到css选择器的问题,像这样多个类名之间没有空格隔开写在一起,是代表并集选择器。如果用空格分开,则代表后代选择器。

这里不用考虑优先级的问题,所以只写一个类名就ok了。

很容易混淆,要注意!

image.png

Echarts可视化(封装复用组件)

官网使用手册:echarts.apache.org/handbook/zh…

// 安装依赖
npm i echart -S

// main.js引入
import * as echarts from 'echarts';
  • 初始化时,挂载在一个DOM元素上,配置对象option{ }

    option一些重要配置:

    1. grid:{ }控制图标在DOM元素中的位置

    2. legend:{} 图例设置

    3. series:{ } 设置 type图表类型,name图例,data数据

  • 查看示例选择适合的图表样式进行修改

img

  • Home组件props数据给Chart组件,包括图表数据和配置

分页展示商品

页面展示:flex布局

image.png

  • 进入所有商品组件,created生命周期中发请求获取数据(默认页码第1页,页面大小4条数据),处理返回数据,props传给分页器。(没有用vuex)

image.png

image.png

  • 与分页器通信:

    • 传过去 总页数、当前页码、页面大小
    • 自定义事件:修改页码、修改页面大小
  • 用Element-ui的表格组件展示数据

image.png

分页展示搜索结果

在搜索框输入关键字,敲下回车或点击搜索就会发请求获取搜索结果,并分页展示(共用一个分页器)

image.png

注:发请求将一次性返回所有搜索结果,所以修改页码或修改页面大小并没有发请求,而是对之前的结果进行切割展示

  • 定义一个type变量用于区分不同功能:all--展示所有商品,search--搜索商品

  • 自定义事件里,加一层if-else逻辑,判断当前type值是否all,是就调用获取商品数据接口,反之做搜索处理

image.png

添加商品

有两种方式添加商品:

  1. 页面添加:点击跳转路由(没有真正实现页面添加的功能)
  2. 弹窗添加:点击显示弹窗,可进行添加

弹窗添加(封装弹窗组件)

点击按钮“弹窗添加”,触发事件:显示弹窗可见(全局事件总线),控制弹窗可见的变量定义在弹窗组件中

  • 使用Element-ui的dialog对话框和form表单
  <el-dialog
    :title="type == 'add' ? '商品添加' : '商品编辑'"
    :visible.sync="dialogFormVisible"
    center
    width="70%"
  >
    <el-form
      :model="goodsForm"
      :rules="rules"
      label-width="80px"
      ref="goodsForm"
    >
      <el-form-item label="类目选择" prop="category">
        <el-button type="primary" @click="innerVisible = true"
          >选择类别</el-button
        >
        {{ goodsForm.category }}
      </el-form-item>

      <el-form-item label="商品名称" prop="name">
        <el-input v-model="goodsForm.name"></el-input>
      </el-form-item>

      <el-form-item label="商品价格" prop="price">
        <el-input v-model="goodsForm.price"></el-input>
      </el-form-item>

      <el-form-item label="商品数量" prop="num">
        <el-input v-model="goodsForm.num"></el-input>
      </el-form-item>

      <el-form-item label="发布时间">
        <el-col :span="11">
          <el-date-picker
            type="date"
            placeholder="选择日期"
            v-model="goodsForm.date1"
            style="width: 100%"
            prop="date1"
          ></el-date-picker>
        </el-col>
        <el-col class="line" :span="2" style="text-align: center">-</el-col>
        <el-col :span="11">
          <el-time-picker
            placeholder="选择时间"
            v-model="goodsForm.date2"
            style="width: 100%"
            prop="date2"
          ></el-time-picker>
        </el-col>
      </el-form-item>

      <el-form-item label="商品卖点" prop="sellPoint">
        <el-input v-model="goodsForm.sellPoint"></el-input>
      </el-form-item>

      <el-form-item label="商品图片" prop="imgUrl" class="imgItem">
        <el-button type="primary" @click="innerIMGVisible = true">
          上传图片
          <i class="el-icon-upload el-icon--right"></i>
        </el-button>
        <img :src="goodsForm.imgUrl" class="imgUpload" />
      </el-form-item>

      <el-form-item label="商品描述" prop="desc">
        <!-- 富文本编辑器 -->
        <WangEditor ref="desc" :desc='goodsForm.desc'/>
      </el-form-item>
    </el-form>

    <div slot="footer" class="dialog-footer">
      <el-button @click="resetForm()">重置 </el-button>
      <el-button type="primary" @click="submitForm()">确 定</el-button>
    </div>

    <!-- 内层嵌套的dialog  类目选择 -->
    <el-dialog
      width="30%"
      title="类目选择"
      :visible.sync="innerVisible"
      append-to-body
    >
      <Tree />

      <div slot="footer" class="dialog-footer">
        <el-button @click="innerVisible = false">取消 </el-button>
        <el-button type="primary" @click="chooseCategory()">确 定</el-button>
      </div>
    </el-dialog>

    <!-- 内层嵌套的dialog  上传图片 -->
    <el-dialog
      width="30%"
      title="上传图片"
      :visible.sync="innerIMGVisible"
      append-to-body
    >
      <Upload />

      <div slot="footer" class="dialog-footer">
        <el-button @click="innerIMGVisible = false">取消</el-button>
        <el-button type="primary" @click="uploadIMG()">确 定</el-button>
      </div>
    </el-dialog>
  </el-dialog>
  
  
  // 全局事件总线 绑定开关弹窗事件
   this.$bus.$on("openDialog", (type, goods = {}) => {
      this.dialogFormVisible = !this.dialogFormVisible;
      // 关闭弹窗的方法有两个:1、改变这个变量  2、确定添加商品
      // 所以弹窗右上角的关闭 和 取消 都是 绑定了这个变量
      // 之所以没有将这个变量定义在父组件:
      // 1、props过来的参数,不允许直接改变,
      // (所以还要另外定义一个变量接收props,然后改变这个变量)
      // (或者再弄自定义事件,父组件绑定,子组件触发) both 麻烦
      // 2、这是一个独立封装的组件了,当然还是自己收着参数更好啦
      this.type = type;
      if (this.type == "edit") this.goodsForm = goods;
    });
  • 点击按钮“确定”,收集表单数据发请求添加商品

  • 点击右上角的按钮“x”或弹窗外任意地方,可关闭弹窗

  • 点击按钮“重置”,可清空所有表单数据

image.png

嵌套弹窗

用于类目选择和上传图片

嵌套弹窗直接写在了Dialog组件里,没有封装

image.png

  • 类目选择,还使用了树形控件Tree

    • 点击按钮“选择类别”时,显示弹窗

    • 封装为Tree组件,组件加载时发请求获取所有类目

    • 懒加载,点击类别再加载下一层

image.png

  • 上传图片,还用了上传组件upload

image.png

// 选择类目
chooseCategory() {
    this.innerVisible = false;
    this.goodsForm.category = this.TreeData.name;
    this.goodsForm.cid = this.TreeData.cid;
},

// 上传图片
uploadIMG() {
    this.innerIMGVisible = false;
    this.goodsForm.imgUrl = this.img;
},
  • 内嵌弹窗中,点击确定,触发自定义事件,父组件获取Tree组件中选择的类目(Upload组件中上传的图片url),并渲染到页面上

    // 获取类目
    getCategory(data, node, ele) {
          this.$bus.$emit("getCategory", data);
    },
    
    // 图片
    getCategory(data, node, ele) {
          this.$bus.$emit("getCategory", data);
    }
    

写在最后

会有小伙伴看到这里吗?如果有,谢谢你的耐心,欢迎指正!
暂时先写到这里啦,未完待续。