likes
comments
collection
share

从零到一:搭建Vue3后台管理系统

作者站长头像
站长
· 阅读数 4

2022.3.13 更新 前端技术变化很快,当初写这篇文章时 ElementPlus 和 Pinia 还没成熟,但是时至今日,它们已经是后台管理系统组件库和状态管理库更好的选择,所以我又搭了一个新的开发框架,如果你想了解搭建开发框架的过程,本文依然是一个不错的选择,如果你需要一个开发框架,请来这里 vue3-element-admin2

因为之前的开发框架已经把所有需要用到的库都集成完成,所以在此基础上,我们需要做的就是把后台管理系统相关的主体框架及组件编写完成即可,《项目地址》 下面就让我们开始吧!

1. 路由权限控制

通常,如果用户未登录的情况下访问后台管理系统且目标页面不是登录页面,需要将用户路由指向登录页面进行登录,这一步可以通过vue-router的路由前置守卫实现,代码如下:

// 路由前置守卫
router.beforeEach((to) => {
  // 如果to需要鉴权
  if (!to.meta.notNeedAuth) {
    // 获取userInfo
    const userInfo = store.getters.userInfo;
    // 如果未登录
    if (!userInfo.name || !userInfo.roles.length) {
      return { name: "Login" };
    }
  }
})

这里我们用到了 store.getters.userInfo.userId 判断用户是否登录,实际项目中,大家可以根据实际项目需求进行该判断,比如判断 cookie,seesion 等。 并且在登录成功后,在 src/store/modules/permission.ts 中进行了动态路由的添加,模拟登录成功后,动态添加该用户的权限路由。

2. 封装页面主体框架

后台管理系统的整理内容框架一般如下图所示:

从零到一:搭建Vue3后台管理系统 左侧为 logo 及菜单区,右侧顶部为控制菜单展开收起的按钮,当前页面的面包屑导航,用户头像,点击下拉一般会有用户信息,修改密码,退出登录等快捷入口及操作,以及当前已打开页面的页签列表。

在系统内点击左侧菜单切换页面的时候,页面主体框架是不变的,只有中间内容区切换展示对应业务页面,很自然的想到这一部分可以抽离出一个组件进行维护,业务页面通过 router-view 标签嵌套在主体框架中。

在项目中,以上功能可以通过路由嵌套实现,调整后的首页路由如下:

{
    path: "/",
    name: "Root",
    component: Layout,
    redirect: "/home",
    children: [
      {
        path: "home",
        name: "Home",
        meta: {
          title: "首页",
          icon: "el-icon-s-home",
          needCache: true,
          fixed: true,
        },
        component: () => import("@/views/Home.vue"),
      },
    ],
  }

上面的 Layout 即为封装好的主体框架组件,Home.vue 代码如下:

<template>
  <div class="box t_center">
    <img src="@/assets/img/avatar.jpg" alt="" />
    <HelloWorld
      msg="Vite2 + Vue3 + Element3 + TS + SASS + jest + cypress buy wzy!"
    />
  </div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import HelloWorld from "@/components/HelloWorld.vue";
export default defineComponent({
  name: "Home",
  components: {
    HelloWorld,
  },
  setup() {
    return {};
  },
});
</script>
<style lang="scss" scoped>
.box {
  padding-top: 100px;
  > img {
    width: 100px;
    height: auto;
  }
}
</style>

HelloWorld.vue

<template>
  <h1 class="c_3477F2">{{ msg }}</h1>
  <p>{{ count }}</p>
  <el-button class="plus" type="primary" @click="count++"> plus </el-button>
</template>

<script lang="ts">
import { defineComponent, ref } from "vue";
export default defineComponent({
  name: "HelloWorld",
  props: {
    msg: {
      type: String,
      required: true,
    },
  },
  setup() {
    const count = ref(0);
    return { count };
  },
});
</script>
<style lang="scss" scoped>
p {
  padding: 20px 0;
  font-size: 20px;
  color: red;
}
</style>

此时打开首页,即可看到如上图的首页内容。

接下来,我们一步步编写 Layout 组件

2-1. 左侧 logo

从零到一:搭建Vue3后台管理系统

这里很简单,不需要多说,对应文件 src/layout/components/SliderBar/Logo.vue

2-2. 左侧菜单

从零到一:搭建Vue3后台管理系统

这一部分相对复杂一些,src/layout/components/SliderBar/index.vue 为主体文件,使用 el-menu 组件,内部遍历 routes,根据是否有子路由,选择使用 el-submenu 或者 el-munu-item 组件生成菜单,需要注意的是这里的 Item 组件需要给定 name,以便 Item 组件递归自调用时使用。 src/layout/components/SliderBar/index.vue

<template>
  <el-aside :class="{ collapse: isCollapse }">
    <Logo />
    <el-menu :default-active="activeMenu" :collapse="isCollapse">
      <template v-for="item in routes" :key="item.path">
        <SlideBarItem :item="item" />
      </template>
    </el-menu>
  </el-aside>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import { useStore } from "vuex";
import { useRoute } from "vue-router";
import Logo from "./Logo.vue";
import SlideBarItem from "./Item.vue";
export default defineComponent({
  name: "",
  components: { Logo, SlideBarItem },
  setup() {
    const store = useStore();
    const routes = store.getters.routes;
    return {
      routes,
    };
  },
  computed: {
    activeMenu() {
      return useRoute().name;
    },
    isCollapse() {
      return useStore().getters.isCollapse;
    },
  },
});
</script>

src/layout/components/SliderBar/Item.vue

<template>
  <template v-if="!item.hidden">
    <template v-if="item.children">
      <template v-if="isOnlyChild(item)">
        <SliderBarItem
          :item="
            Object.assign(
              {},
              {
                ...item.children[0],
                meta: { ...item.meta, ...item.children[0].meta },
              }
            )
          "
        />
      </template>
      <el-submenu v-else :index="item.name">
        <template #title>
          <i v-if="item.meta && item.meta.icon" :class="item.meta.icon"></i>
          <span>{{ (item.meta && item.meta.title) || item.name }}</span>
        </template>
        <template v-for="child in item.children" :key="child.name">
          <SliderBarItem :item="child" />
        </template>
      </el-submenu>
    </template>
    <el-menu-item v-else :index="item.name">
      <router-link :to="item">
        <i v-if="item.meta && item.meta.icon" :class="item.meta.icon"></i>
        <span>{{ (item.meta && item.meta.title) || item.name }}</span>
      </router-link>
    </el-menu-item>
  </template>
</template>
<script>
import { defineComponent } from "vue";
export default defineComponent({
  name: "SliderBarItem",
  components: {},
  props: {
    item: {
      type: Object,
      required: true,
    },
  },
  setup() {
    const isOnlyChild = (item) => {
      return item.children && item.children.length === 1;
    };
    return {
      isOnlyChild,
    };
  }
})
</script>

这里要说一下 el-menu 组件有一个 collapse 属性,控制菜单的展开收起,我们在 store 的setting.js 模块中维护了这个状态。

2-3. 右侧 Collapse 组件

该组件控制左侧菜单展开收起 src/layout/components/NavBar/Collapse.vue

<template>
  <div class="collapse_box pr_20 pointer" @click="changeCollapse">
    <span v-if="isCollapse" class="fontsize_20 el-icon-s-unfold"></span>
    <span v-else class="fontsize_20 el-icon-s-fold"></span>
  </div>
</template>
<script>
import { defineComponent } from "vue";
import { useStore } from "vuex";
export default defineComponent({
  name: "",
  setup() {
    const store = useStore();
    const changeCollapse = () => {
      store.commit("setting/SET_COLLAPSE", !store.getters.isCollapse);
    };
    return {
      changeCollapse,
    };
  },
  computed: {
    isCollapse() {
      return useStore().getters.isCollapse;
    },
  }
})
</script>

针对页面 resize 展开收起的逻辑写在了 src/layout/mixin/resize.js 中,在 src/layout/index.vue 引入并进行混入。

2-4. 右侧面包屑导航

src/layout/components/Navbar/Breadcrumb.vue 该组件动态展示当前页面路由,且当前页面的祖先路由可以点击进行跳转。

2-5. 右侧头像下拉

src/layout/components/Navbar/AvatarDropDown.vue 该组件展示登录用户的头像,点击展开下拉,显示用户信息,修改密码,退出登录等功能。

2-6. 右侧已访问路由列表

从零到一:搭建Vue3后台管理系统 src/layout/components/Navbar/VisitedViews.vue 改组件展示用户已经访问的路由,并且右键显示刷新,关闭,关闭其他,关闭所有菜单,功能相对复杂一些,对应数据状态存和修改数据方法放在 src/store/modules/tagsView.ts 中进行维护,当路由跳转时,通过路由后置守卫,将访问过的路由添加到 store/tagsView 中。

至此,lauyout 相关组件封装完成,为了便于以后扩展,我们在 src/layout/components 下新建 AppMain.vue 对所有组件做引入及 业务组件切换的动画封装

<template>
  <section class="app_main flex">
    <SliderBar />
    <el-container>
      <el-header height="80px">
        <NavBar />
      </el-header>
      <el-main>
        <router-view v-slot="{ Component }">
          <template v-if="Component">
            <transition name="fade-transform" mode="out-in">
              <keep-alive :include="cachedViews">
                <component :is="Component" :key="key" />
              </keep-alive>
            </transition>
          </template>
        </router-view>
      </el-main>
    </el-container>
  </section>
</template>
<script lang="ts">
import { defineComponent, ref, watchEffect } from "vue";
import { useStore } from "vuex";
import { useRoute } from "vue-router";
import SliderBar from "./SliderBar/index.vue";
import NavBar from "./NavBar/index.vue";
export default defineComponent({
  components: { SliderBar, NavBar },
  setup() {
    const store = useStore();
    const cachedViews = store.getters.cachedViews;
    const route = useRoute();
    const key = ref(route.fullPath);
    watchEffect(() => (key.value = route.fullPath));
    return {
      cachedViews,
      key,
    };
  },
});
</script>

最后 src/layout/index.vue 如下:

<template>
  <AppMain />
</template>
<script lang="ts">
import { defineComponent } from "vue";
import AppMain from "./components/AppMain.vue";
import ResizeMixin from "./mixin/resize";
export default defineComponent({
  components: { AppMain },
  mixins: [ResizeMixin],
});
</script>

3. 解决页面刷新丢失 store 数据问题

这是一个 Vue 项目老生常谈的问题了,因为我们搭建的是一个 Vue 单页应用,共享状态 通过 Vuex 放在了 store 中进行维护,刷新页面后 store 中的数据进行了初始化,之前保存的状态就丢失了,解决这个问题,我们需要在 store 的引入过程中封装一下。 新建文件 src/utils/cacheStore.ts

import store from "@/store";
//在页面加载时读取sessionStorage里的状态信息
const sessionStore = sessionStorage.getItem("store");
if (sessionStore) {
  store.replaceState(Object.assign({}, store.state, JSON.parse(sessionStore)));
  store.dispatch("permission/handleRoutes", null, { root: true });
}

//在页面刷新时将vuex里的信息保存到sessionStorage里
window.addEventListener("beforeunload", () => {
  sessionStorage.setItem("store", JSON.stringify(store.state));
});
export default store;

main.tsstore 引入同时做调整

import store from "@/utils/cacheStore";

End

至此,本文就结束了,项目中其他的细节还是需要在项目代码中具体查看,本文只是把主体思路及节点讲述一遍,如有任何问题或建议,欢迎留言讨论!