[Vue3]构建通用后台系统

[Vue3]构建通用后台系统_第1张图片

前言

基于Vue3 + Vite + Element-plus 来构建一个常见的后台,做这个的原因还是在于理清技术细节,虽然这玩意到处都是,但对于前端经验不算多的我而言,还是有必要自己捣鼓一次的,此外本次使用JavaScript来开发,而不适用TypeScript,核心原因是,遇到一些问题时,请教周围的前端朋友发现他们都不太熟悉TypeScript,所以一些小问题因为其类型推断系统搞了很久,后面和解了,个人项目用JavaScript也没啥问题,下面我们开始吧。

本文会提供完整的代码,请放心食用。

使用yarn构建vue3项目:

yarn create vite DashboardFrameWork --template vue
yarn && yarn dev

然后安装相应的依赖:

yarn add element-plus --save
yarn add @element-plus/icons-vue --save
yarn add axios --save
yarn add sass --save
yarn add vue-router@next --save
yarn add vuex@next --save

我将依赖都安装到dependencies(--save)中。

就我个人理解,开发时使用的工具向的东西,比如vite、webpack、mockjs等,不需要在上线时依赖,那么就将其安装到devDependencies(--dev)中,反之则安装到dependencies中。

上述安装的依赖中,element-plus是UI框架,element-plus/icons-vue是UI框架的图标相关的支撑库,axios是HTTP请求库、sass是用于编写样式的CSS超集语言、vue-router用于实现单页面路由,vuex用于实现状态存储,很常见的Vue3全家桶。

封装axios

为了方便开发和代码模块化,多数项目在开始前会进行一些基本的封装并构建出项目的骨架。

首先,我们对axios库进行封装,如果单纯的使用axios库,其用法已经足够简单了,但结合后台业务情况,还是有必要对它进行二次封装,实现请求拦截、响应拦截。

创建src/utils/request.js,用于实现封装JavaScript的逻辑,先创建axios对象:

// 创建axios实例对象,添加全局配置
const service = axios.create({
  baseURL: config.baseApi,
  timeout: 8000,
});

接着实现请求拦截,实现每次请求前身份的校验:

// 请求拦截
service.interceptors.request.use((req) => {
  const headers = req.headers;
  const { token } = storage.getItem("userInfo") || {};
  if (token) {
    if (!headers.Authorization) headers.Authorization = "Bearer " + token;
  }
  return req;
});

上述代码中,从storage中获取userInfo的数据,我们可以通过chrome的开发者工具Application查看到storage中存储的数据:

[Vue3]构建通用后台系统_第2张图片

我们在login时,将用户基础信息写入其中,每次请求前都会通过请求拦截做一次登录校验。

以类似的方式,我们可以实现响应拦截:

// 响应拦截
service.interceptors.response.use((res) => {
  const { code, data, msg } = res.data;
  if (code === 200) {
    return data;
  } else if (code === 500001) {
    ElMessage.error(TOKEN_INVALID);
    // 让错误信息展示一下,再跳转
    setTimeout(() => {
      router.push("/login");
    }, 1500);
    // 抛出异常
    return Promise.reject(TOKEN_INVALID);
  } else {
    ElMessage.error(msg || NETWORK_ERROR);
    return Promise.reject(msg || NETWORK_ERROR);
  }
});

如果响应的code是200,则将数据正常返回,如果code不为200,则通过ELMessage给用户展示相关的错误信息,并通过路由方法跳转到login页面,一个小技巧是,失败时,不要立刻跳转到login页面,因为我们希望用户看到相关的报错信息,最后返回Promise.reject对象。

拦截相关的方法实现好后,再封装一下请求方法就好了:

function request(options) {
  options.method = options.method || "get";
  if (options.method.toLowerCase() === "get") {
    options.params = options.data;
  }
  let isMock = config.mock;
  //  兼容局部Mock的用法
  if (typeof options.mock != "undefined") {
    isMock = options.mock;
  }
  service.defaults.baseURL = isMock ? config.mockApi : config.baseApi;

  return service(options);
}

上述代码中封装了request方法,这里的核心在于,请求的URL是Mock地址还是真实的后台地址。

通过Mock,前端可以在不需要后台提供出完整的API的情况下进行开发,很多前端开源项目会使用Mockjs来构建一个单独的serve来为前端项目提供数据,而这里我直接使用了在线的Mock服务(后文介绍)。

至此,我们可以通过如下方式来实现http请求:

request({
  url: "/users/login",
  method: "post",
  data: {
    username: "ayuliao",
    pwd: "123"
  }
}).then((res) => {
  console.log(res);
});

request.js github位置:https://github.com/ayuLiao/DashboardFrameWork/blob/master/src/utils/request.js

配置文件

多数项目中,都会使用配置文件来管理相关的配置,我们也不例外。

创建src/config/config/index.js,代码如下:

/**
 * 环境配置封装
 */

// import.meta.env.MODE 当前项目环境
const env = import.meta.env.MODE
const EnvConfig = {
    development:{
        baseApi:'/api',
        mockApi:'https://www.fastmock.site/mock/xxx/api'
    },
    production:{
        baseApi:'//xxx.com/api',
        mockApi:'https://www.fastmock.site/mock/xxx/api'
    }
}
export default {
    env,
    // 是否开启Mock
    mock:true, 
    namespace:'manager',
    ...EnvConfig[env]
}

通过env.MODE来判断当前的环境,在Vue3中,默认情况下,开发服务器 (dev 命令) 运行在 development (开发) 模式,而 build 命令则运行在 production (生产) 模式(更多可看:https://cn.vitejs.dev/guide/env-and-mode.html#intellisense)。

配置中,提供mockApi来请求在线Mock,这里使用fastmock这个在线mock服务,当然我们可以通过mock.js来构建本地的Mock服务,这里图方便,就使用了fastmock。

很常规的使用方式,注册账号,然后创建相应的接口:[Vue3]构建通用后台系统_第3张图片

返回的值,给一个JSON则可,通常直接使用后端给的API对接文档中的内容则可:

[Vue3]构建通用后台系统_第4张图片

封装状态管理

我们使用vuex来进行状态管理,但只要我们一刷新浏览器,vuex中的数据便会丢失,为了避免这种情况,我们可以配合着浏览器的localStorage来存储数据,实现数据的持久化。

创建 src/utils/storage.js,对localStorage的增删改查进行封装,思考一个问题:通过window.localStorage对象已经可以实现增删改查,为啥还要封装一层?

主要是因为localStorage不能直接存储Object对象,只能存储字符串,所以常规的做法就是将通过JSON字符串的形式来存储数据,存入将对象转成JSON字符串,取出则从JSON字符串解码成对象,代码如下:

/**
 * Storage二次封装
 */
 import config from '@/config'
 export default {
     setItem(key,val){
         let storage = this.getStroage();
         storage[key] = val;
         window.localStorage.setItem(config.namespace,JSON.stringify(storage));
     },
     getItem(key){
         return this.getStroage()[key]
     },
     getStroage(){
         return JSON.parse(window.localStorage.getItem(config.namespace) || "{}");
     },
     clearItem(key){
         let storage = this.getStroage()
         delete storage[key]
         window.localStorage.setItem(config.namespace,JSON.stringify(storage));
     },
     clearAll(){
         window.localStorage.clear()
     }
 }

创建src/store/目录,在其中创建index.js和mutations.js,其中index.js中写vuex中state相关的逻辑,而mutations.js自然实现mutations相关逻辑,先看index.js,代码如下:

/**
 * Vuex状态管理
 */
 import { createStore } from 'vuex'
 import mutations from './mutations'
 import storage from './../utils/storage'
 
 const state = {
    //  Vuex配合storage使用,Vuex强刷的话,数据会丢失,所以配合storage使用
     userInfo: "" || storage.getItem("userInfo") // 获取用户信息
 }
 export default createStore({
     state,
     mutations
 })

mutation是vuex中的概念,是修改Vuex store中状态的唯一方法,简单理解就是定义方法,通过这些方法才能修改存储在vuex中的事件,项目通常会将state和mutations分开来实现:

/**
 * Mutations业务层数据提交
 * 
 */
 import storage from './../utils/storage'

 export default {
     saveUserInfo(state,userInfo){
         state.userInfo = userInfo;
         storage.setItem('userInfo',userInfo)
     }
 }

基础路由

使用vue-router来实现路由,创建src/router/index.js。创建路由的逻辑是很机械化的,代码如下:

import { createRouter, createWebHashHistory } from "vue-router";
import Home from "@/components/Home.vue";

const routes = [
  {
    name: "home",
    path: "/",
    meta: {
      title: "首页",
    },
    component: Home,
    redirect: "/welcome",
    children: [
      {
        name: "welcome",
        path: "/welcome",
        meta: {
          title: "Welcome use Dashboard Framework",
        },
        component: () => import("@/views/Welcome.vue"),
      },
    ],
  },
  {
    name: "login",
    path: "/login",
    meta: {
      title: "登录",
    },
    component: () => import("@/views/Login.vue"),
  }
];

const router = createRouter({
  history: createWebHashHistory(),
  routes,
});

export default router;

路由需要配合router-view来使用,在App.vue中,直接使用router-view则可,router-view会渲染出一级路由的内容,对上述路由而言,便是home和login的内容。









@import "./assets/style/reset.css";
@import "./assets/style/index.scss";

home的子路由有welcome,要渲染welcome,就需要在home中使用router-view,router-view的嵌套模式与路由中一致。

实现首页骨架

创建components/Home.vue,其template如下:

整个骨架我们使用div一块块搭建处理,接着来写CSS,我们使用scss来实现:


.basic-layout {

  // 侧边栏
  .nav-side {
    position: fixed;
    width: 200px;
    // 浏览器可见窗口的百分比
    height: 100vh;
    background-color: #001529;
    color: #fff;
    // 超出y轴部分
    overflow-y: auto;
    // 宽度变化时,带动画效果
    transition: width 0.5s;

    .logo {
      display: flex;
      align-items: center;
      font-size: 18px;
      height: 50px;
      img {
        margin: 0 16px;
        width: 32px;
        height: 32px;
      }
    }

    .nav-menu {
      height: calc(100vh - 50px);
      border-right: none;
    }
  }

  .content-right {
    // 因为父元素position:relative,所以直接移200px
    margin-left: 200px;

    .nav-top {
      height: 50px;
      line-height: 50px;
      display: flex;
      // 利用flex,将元素排到两端
      justify-content: space-between;
      border-bottom: 1px solid #ddd;
      padding: 0 20px;

      .nav-left {
        display: flex;
        align-items: center;
        .menu-fold {
          margin-right: 15px;
          font-size: 18px;
        }
      }

      .user-info {
        .notice {
          line-height: 30px;
          margin-right: 15px;
        }

        .user-link {
          // 在鼠标指针悬停在元素上时显示相应样式
          cursor: pointer;
          color: #409eff;
        }
      }

      
    }
    .wrapper {
        background: #eef0f3;
        padding: 20px;
        height: calc(100vh - 50px);
      }
  }
}

在使用scss编写css时,为了避免命名冲突,通常会通过一个div将组件或模块包裹起来,这里便是basic-layout,不同组件根div的class不同,再利用scss的语法来写css,就不会出现css命名冲突的问题了。

position定位

上述css中,使用了fixed定位,借此记录一下编写css时,我们常用的relative定位与fixed定位。

relative定位会相对于默认位置(static定位)进行偏移:

[Vue3]构建通用后台系统_第5张图片

在浏览器中,每个元素默认通过static的形式来定(position的默认值),static定位下,元素会按HTML源码的顺序来排列,每个块级元素占据自己的位置,元素与元素之间不会重叠。

当我们使用relative时,它会相对于static进行偏移,static即它原本的正常位置,改成relative后,配合top、bottom、left、right这四个属性来实现偏移:

[Vue3]构建通用后台系统_第6张图片

接着聊fixed定位,我们后台的首页布局中使用了fixed定位来固定侧边栏和顶部栏,fixed会基于浏览器窗口定义,其效果就是元素不会随着页面滚动而变化,如同固定在页面上一样。

[Vue3]构建通用后台系统_第7张图片

display布局

天下苦布局久已,自从display出来后,一切便简单起来了,这里记录几种display中最常用的布局。

先说居中布局:

.box {
  display: flex;
  // 水平居中
  justify-content: center;
  // 垂直居中
  align-items: center;
}

上述CSS会让box中的元素水平、垂直都居中,如果以骰子为例,效果为:

[Vue3]构建通用后台系统_第8张图片

然后再说一下两端对其:

.box {
  display: flex;
  justify-content: space-between;
}

效果为:

[Vue3]构建通用后台系统_第9张图片

我们可以借助在线布局演示网站(https://xluos.github.io/demo/flexbox/)来体验flex布局的效果,从而理解flex其他样式:

[Vue3]构建通用后台系统_第10张图片

首页路由

首页中,有如下一段HTML,用于渲染子路由的页面,根据router/index.js配置的路由,这里会渲染Welcome.vue


  

首页侧边栏

在常见的后台中,侧边栏是按登录者的权限来展示的,不同的用户登录时,侧边中的内容有所不同,具体而言,权限控制由后端权限管理相关逻辑实现,而前端只需要通过后端返回的内容,动态渲染出侧边栏则可。

我们将侧边栏相关的逻辑放在class为nav-side的div中:


  
    
    DashBoard
  
                

首先,我们使用element-plus中的el-menu来包裹出侧边栏,el-menu元素可使用的属性可自行读一下文档,而侧边栏真正的实现逻辑是TreeMenu子组件,其传入参数为menuList,TreeMenu.vue代码如下:


// https://v3.cn.vuejs.org/api/sfc-script-setup.html#defineprops-%E5%92%8C-defineemits
const props = defineProps({
  menuList: Array,
});



      
    
    
    {{ menu.menuName }}
  

这个项目中,我使用了最新的setup语法,TreeMenu子组件在接收父组件传参时,需要通过defineProps方法来实现参数的接收。

TreeMenu子组件核心逻辑在template中,因为侧边栏有嵌套的情况,比如下图这种情况:

[Vue3]构建通用后台系统_第11张图片

对于嵌套情况,可以通过递归的方式来,通过v-if判断是否当前元素是否嵌套有子结构,有的话就再通过TreeMenu来构建出新的新的节点。

TreeMenu代码中有一个小技巧,就是多个template节点的嵌套使用,template节点本身不会被渲染出相应的DOM,利用template节点来放置v-for、v-if等操作是很合适的做法。

另外一个技巧是,element-plus展示图标的方式有所改变,我们需要通过动态组件的方式来放置合适的图标:

当然,要在项目中随意使用element-plus图标,需要在入口文件main.js中全局挂载一下:

app.use(router).use(store).use(ElementPlus)

// 全局挂载icon,方便icon在项目各处使用
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
  app.component(key, component)
}

此外,在构建目录时,使用了Vue中的插槽:



  
  
  {{ menu.menuName }}

我们希望目录中父级目录可以显示图标和名字,而子级目录,不需要显示图标了,但Element-plus提供的el-menu并不直接支持,此时我们就需要通过Vue插槽动态的将template中的代码替换到el-menu中,el-menu组件提供了title插槽。

顶部栏实现

常见的后台其顶部栏会展示面包屑和用户相关信息:

[Vue3]构建通用后台系统_第12张图片相关的HTML如下:


  
    
      
    
  
                                                  {{ userInfo.userName }}                                           邮箱:{{ userInfo.userEmail }}           退出                        

整体结构通过div构建,先看面包屑,它是一个独立的组件,通过BreadCrumb子组件实现。

这里面包屑的主要效果是暂时当前用户访问页面其访问路径,vue-router中的属性可以让我们轻松实现效果效果:


import { computed } from '@vue/reactivity';
import router from '../router';
import { ArrowRight } from '@element-plus/icons-vue'

const breadList = computed(() => router.currentRoute.value.matched)



这里通过computed获得路由路径,computed会构建一层缓存,当对象发生改变时,缓存会更新。

通过router.currentRoute.value.matched可以获得当前路由以及访问当前路由的完整路由路由,这是vue-router提供的功能。

要找到这种功能,最好的方式是使用debug大法,参考element-plus-admin源码剖析一文,debug起来,效果如下:

[Vue3]构建通用后台系统_第13张图片

对router对象,一层层看里面的属性,便可以找到需要的内容了,随后便通过el-breadcrumb标签展示出来则可。

面包屑完成了,将注意力移动到顶部栏右侧的下拉按钮:


    
    
      
    
    
    
      
        {{ userInfo.userName }}
        
      
      
        
          邮箱:{{ userInfo.userEmail }}
          退出
        
      
    
  

核心的下拉后展示相关内容的逻辑,主要通过el-dropdown的dropdown插槽实现。

登录页面

登录是个常见的功能,通过Element-plus提供的表单组件,来构建登录页的基础骨架:

阅读el-form文档可知,表单数据会通过model绑定到user对象中,表单数据的前端验证会通过rules绑定到rules对象中,而el-form标签本身,我们通过ref将其绑定了userForm变量中,方便我们直接通过userForm变量来调用校验方法,相关JS如下:

import { reactive, ref, getCurrentInstance  } from 'vue';
import api from '../api';
import router from '../router';
import store from '../store';

// 表单提交数据
const user = reactive({
  userName: "",
  userPwd: ""
})

// 表单对象
const userForm = ref();

// 校验规则
const rules = {
    userName: [
      {
        required: true,
        message: "请输入用户名",
        trigger: "blur",
      },
    ],
    userPwd: [
      {
        required: true,
        message: "请输入密码",
        trigger: "blur",
      },
    ],
}

// 登录方法
function login() {
  // 通过userForm表单对象调用validate方法,实现前端校验
  userForm.value.validate((valid) => {
    if (valid) {
      api.login(user).then((res) => {
        store.commit("saveUserInfo", res);
        router.push("/welcome");
      });
    } else {
      return false;
    }
  });
}

el-form标签对象与userForm变量关联,当用户点击登录时,会调用login方法,login方法首先会通过userForm变量调用其中的validate方法(el-form提供的校验方法),基于校验规则(rules变量)对前端内容进行校验,校验通过后,再请求后端登录api,如果登录成功,则将数据记录下来并访问welcome页面。

这里,还有个细节,使用el-form-item时,要让element-plus帮我们验证,需要通过prop关联一下user对象中的属性:userName和userPwd。


          
        
        
          
        

这样就可以实现实时校验的效果了。[Vue3]构建通用后台系统_第14张图片

结尾

虽然是一个很常见的后台,但对于我这种前端比较薄弱的后端同学,还是踩了一些坑的。

项目代码:https://github.com/ayuliao/DashboardFrameWork

你可能感兴趣的:(定位,vue,js,python,html)