【vite+vue3+Ts+element-plus】肩并肩带你写后台管理之主页面开发(侧边栏菜单生成、标签栏开发)

4,363 阅读4分钟

github: github.com/heyongsheng…

码云: https://gitee.com/ihope_top/hevue3-admin

线上体验地址 ihope_top.gitee.io/hevue3-admi…

本章知识点:

  • layout页面开发
  • 侧边栏菜单开发
  • 标签栏开发
  • 页面切换过渡效果及页面缓存

layout页面开发

我们先来看一下主页面长什么样子。

image.png

页面比较简单,主要分为左侧的菜单栏,顶部的导航栏(折叠左侧菜单,切换暗黑模式,员工账号名,退出登录),再下面的标签栏,之后就是主页面显示区域。

我们在layout目录下创建一个index.vue来作为我们的入口文件

<template>
  <div class="app-wrapper">
    <!-- 左侧menu -->
    <sidebar
      id="guide-sidebar"
      class="sidebar"
      :class="{ 'sidebar-container': !isCollapse }"
    ></sidebar>
    <div class="main-container">
      <div class="fixed-header">
        <!-- 顶部 navbar -->
        <navbar>
          <template #collapse>
            <div class="collapse-btn" :class="{ row: isCollapse }">
              <svg-icon
                name="gengduo-heng"
                @click="isCollapse = !isCollapse"
              ></svg-icon>
            </div>
          </template>
        </navbar>
        <!-- 标签 -->
        <tags-view></tags-view>
      </div>
      <!-- 内容区 -->
      <app-main></app-main>
    </div>
  </div>
</template>

<script setup lang="ts">
import Navbar from './components/Navbar.vue'
import Sidebar from './components/Sidebar/index.vue'
import TagsView from './components/TagsView/index.vue'
import AppMain from './components/AppMain.vue'

const isCollapse = ref(false)
provide('isCollapse', isCollapse)
</script>
<style lang="scss">
.app-wrapper {
  @include globalScss.clearfix;
  position: fixed;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
  display: flex;
}
.sidebar {
  position: relative;
  z-index: 2;
  background: var(--color-menu-bg);
}
.sidebar-container {
  min-width: globalScss.$sideBarWidth;
}
.main-container {
  overflow: hidden;
  flex: 1;
  display: flex;
  flex-direction: column;
}
.collapse-btn {
  cursor: pointer;
  margin-left: 16px;
  transition: all 0.3s;
  &.row {
    transform: rotate(90deg);
  }
}
</style>

这里控制侧边栏折叠的按钮我是通过slot的方式传入的顶部导航栏,因为左侧的菜单组件也需要接收这个属性,并且层级较深,所以这里我们使用provide发送一下,在菜单组件那里使用inject进行接收。

这里需要讲的内容主要就是左侧的菜单和标签栏,我们先来讲一下左侧的菜单开发。

侧边菜单栏开发

我们之前讲权限的地方已经给大家看过了返回的菜单数据,并封装成了树形结构,所以我们这里菜单就根据保存的菜单数据渲染菜单就可以了。

我们在按照以下层级创建侧边栏需要用到的组件

layout -> components -> Sidebar -> index.vue , SidebarItem.vue, SidebarMenu.vue

// index.vue
<template>
  <div>
    <el-scrollbar>
      <sidebar-menu></sidebar-menu>
    </el-scrollbar>
  </div>
</template>

<script setup lang="ts">
import SidebarMenu from './SidebarMenu.vue'
</script>
<style scoped></style>

index.vue比较简单,我们这里就是引用了一下element-plus的滚动条组件,然后再引入SidebarMenu

// SidebarMenu
<template>
  <el-menu
    :default-openeds="defaultOpeneds"
    :default-active="$route.fullPath"
    class="el-menu-vertical-demo"
    :unique-opened="true"
    :active-text-color="themeColor"
    router
    :collapse="isCollapse"
  >
    <template v-for="item in menus" :key="item._id">
      <sidebar-item :item-data="item" v-if="!item?.meta?.hidden"></sidebar-item>
    </template>
  </el-menu>
</template>

<script setup lang="ts">
import { usePermissionStore } from '@/stores/permission'
import SidebarItem from './SidebarItem.vue'
import { reactive, inject } from 'vue'
import { compareVersion } from '@/utils/util'

const permissionStore = usePermissionStore()
const menus = permissionStore.routes
const router = useRouter()
const isCollapse: boolean | undefined = inject('isCollapse')

const themeColor = ref('')
onMounted(() => {
  themeColor.value =
    document.documentElement.style.getPropertyValue('--color-primary')
})

// 默认展开
const defaultOpeneds: any[] = reactive([])
const findActive = (menus: any) => {
  menus.forEach((item: any) => {
    if (item.children && item.children[0]) {
      findActive(item.children)
    } else {
      if (item.path === router.currentRoute.value.path) {
        defaultOpeneds.push(item.parentId)
      }
    }
  })
}
findActive(menus)

// 菜单排序
const sortMenus = (menus: any) => {
  menus.sort((a: any, b: any) =>
    compareVersion(b.meta.sort || '0', a.meta.sort || '0')
  )
  menus.forEach((item: any) => {
    if (item.children) {
      sortMenus(item.children)
    }
  })
}
sortMenus(menus)
</script>
<style scoped></style>

这个页面主要就是几个操作

第一个就是从pinia中获取一下菜单数据,并传递给子组件进行遍历渲染。

第二个就是设置了默认展开项

第三个对菜单进行了排序,这里的排序我用的事版本号对比的方式,这里贴一下代码

// 版本号大小对比
export function compareVersion(v1: string = '0', v2: string = '0'): number {
  let v1Arr = v1.split('.')
  let v2Arr = v2.split('.')
  const len = Math.max(v1.length, v2.length)

  while (v1Arr.length < len) {
    v1Arr.push('0')
  }
  while (v2Arr.length < len) {
    v2Arr.push('0')
  }

  for (let i = 0; i < len; i++) {
    const num1 = parseInt(v1Arr[i])
    const num2 = parseInt(v2Arr[i])

    if (num1 > num2) {
      return 1
    } else if (num1 < num2) {
      return -1
    }
  }

  return 0
}

什么是版本号对比方式呢?就是 1.11 是比 1.2大的,因为我这里除了菜单要排序,标签栏那里也要排序,采用版本号对比的方式会方便一点。

之后再来看看SidebarItem.vue

<template>
  <!-- 此处注意,不要多嵌套层级,否则可能导致菜单样式错乱,建议直接在父级组件v-for时直接判断 -->
  <!-- <div v-if="!itemData?.meta?.hidden"> -->
  <el-sub-menu
    v-if="
      itemData?.children &&
      (itemData.meta.alwaysShow || itemData?.children?.length > 1)
    "
    :index="itemData._id"
  >
    <template #title>
      <!-- 此处不嵌套el-icon也可正常显示,嵌套了之后可以使用el-menu预设的样式,且在折叠的时候不会闪动 -->
      <el-icon
        ><svg-icon class="menu-icon" :name="itemData.meta.icon"></svg-icon
      ></el-icon>
      <span>{{ itemData.meta.title }}</span>
    </template>
    <!-- <el-menu-item-group> -->
    <sidebar-item
      v-for="item in itemData.children"
      :key="item._id"
      :item-data="item"
    ></sidebar-item>
    <!-- </el-menu-item-group> -->
  </el-sub-menu>
  <sidebar-item
    v-else-if="itemData?.children"
    :item-data="itemData?.children[0]"
  ></sidebar-item>
  <el-menu-item v-else :index="itemData.path">
    <el-icon
      ><svg-icon class="menu-icon" :name="itemData.meta.icon"></svg-icon
    ></el-icon>
    <span>{{ itemData.meta.title }}</span>
  </el-menu-item>
  <!-- </div> -->
</template>

<script setup lang="ts">
defineProps(['itemData'])
</script>
<style scoped lang="scss">
.menu-icon {
  font-size: 16px;
}
</style>

这里首先会判断该菜单是否要在菜单栏隐藏,之后会判断这是个菜单(一级菜单)还是个页面(二级菜单),同时也支持一些只有一个二级菜单的一级菜单直接显示二级菜单,这个是否直接显示根据我们在编辑菜单时配置的alwaysShow决定,后面也会简单的说一下菜单管理的配置项。

菜单栏其实就这么多东西,这里写的比较粗糙,如果有问题欢迎评论区指出。

标签栏开发

现在我们来开发标签栏,这里也参考了花裤衩大佬的标签方案,首先创建文件layout/components/TagsView/index.vue

这里就不放全篇的代码了,只讲一下注意的点吧。

首先说一下标签的数据从哪里来,我这里是监听的route,在route变化时,将新的路由信息添加到标签列表。

const route = useRoute()

watch(
  () => route.path,
  () => {
    addTags()
  }
)

const addTags = () => {
  const { name } = route
  if (name) {
    tagsViewStore.addView(route)
  }
}

这里我们把添加工作放到pinia里,需要注意的点我都在代码里备注上了

// stores/tagView.ts

  state: () => ({
    visitedViews: new Array<any>(), // 标签列表,每一项存储的路由信息
    cachedViews: new Array<string>(), // 缓存列表,每一项存储的路由name
  }),
  
  actions: {
    addView(view: any) {
      this.addVisitedView(view)
      this.addCachedView(view)
    },
    addVisitedView(view: any) {
      // 判断是否已添加
      if (this.visitedViews.some((v) => v.path === view.path)) return
      this.visitedViews.push(
        Object.assign({}, view, {
          title: view.meta.title || 'no-name',
        })
      )
    },
    addCachedView(view: any) {
      // 判断是否已添加
      if (this.cachedViews.includes(view.name)) return
      // 判断该页面是否需要缓存
      if (view.meta.cache) {
        this.cachedViews.push(view.name)
      }
    },
  }

之后我们用router-link渲染一下数据

    <el-scrollbar
      ref="scrollPane"
      class="scroll-pane"
      @wheel.native.prevent="handleScroll"
    >
      <div class="tag-list">
        <router-link
          :to="item.path"
          class="tag-item"
          :class="{ checked: isCheck(item) }"
          v-for="item in tagsViewStore.visitedViews"
          ref="tagItem"
        >
          {{ item.meta.title }}
          <i-ep-close
            class="close-icon"
            v-if="!item.meta.affix"
            @click.prevent="closeTag(item)"
          />
        </router-link>
      </div>
    </el-scrollbar>

这里我们使用了el-scrollbar来进行横向滚动,由于el-scrollbar也不支持鼠标滚动的时候横向滚动,所以我们只能监听鼠标滚动事件,自己写一个横向滚动的方法。

// 标签栏横向滚动
const handleScroll = (e: any) => {
  const eventDelta = e.wheelDelta || -e.deltaY * 40
  let scrollLeft = scrollPane.value?.wrapRef.scrollLeft
  scrollLeft += eventDelta / 8
  scrollPane.value.setScrollLeft(scrollLeft)
}

2023-04-19 23.10.12.gif

关于滚动,还有一个小细节,就是当标签比较多了之后,我们通过侧边栏或者其他方式跳转到已经访问过的页面,如果该页面的标签被超出屏幕被隐藏了,我们需要把标签栏滚动到该标签的位置。

// 滚动到当前tag
const tagItem = ref<any[]>()
const moveToCurrentTag = async () => {
  tagItem.value?.forEach((item: any) => {
    if (item.to === route.path) {
      // 判断当前元素是否超出屏幕
      const isOut =
        item.$el.offsetLeft + item.$el.offsetWidth >
          scrollPane.value?.wrapRef.offsetWidth +
            scrollPane.value?.wrapRef.scrollLeft ||
        item.$el.offsetLeft < scrollPane.value?.wrapRef.scrollLeft + 20

      if (isOut) {
        scrollPane.value?.scrollTo(item.$el.offsetLeft - 20, 0)
      }
      // when query is different then update
      if (item.to.fullPath !== route.fullPath) {
        tagsViewStore.updateVisitedView(route)
      }
    }
  })
}

有时候我们会需要某些标签一直固定在标签栏,比如首页,固定的标签栏不可关闭,这里是通过在菜单管理时候配置的是否固定标签栏,固定标签的排序顺序跟菜单排序顺序一样。如果是公共路由,我们也可以给路由的meta配置affix: true来实现。

image.png

固定标签没有关闭按钮

image.png

页面切换过渡效果

刚才说标签的时候提到了缓存页面,不过没有说怎么写,这里和过渡效果一起说。

我们需要切换过渡效果的地方其实就是主界面显示区域那一块,文件是layout/components/AppMain.vue,这里需要注意的是,在vue3中router-view嵌套使用的时候写法发生了改变

<template>
  <div class="main-wrap">
    <router-view v-slot="{ Component, route }">
      <transition name="fade-transform" appea mode="default">
        <keep-alive :include="tagsViewStore.cachedViews">
          <component :is="Component" :key="route.path" class="app-main" />
        </keep-alive>
      </transition>
    </router-view>
  </div>
</template>

这里还需要注意两个点:

  • transition和keep-alive嵌套使用时,transition的mode不能为out-in模式,否则可能会导致页面空白或者过渡效果不生效的问题
  • 虽然vue3不再显示单个的页面根节点,但是transition和keep-alive都要求必须接受一个根节点,所以如果我们要使用这两个,建议vue页面还是乖乖的写单个根标签的好。

下面附上过渡效果的css

.fade-transform-enter-from {
  opacity: 0;
  transform: translateX(60px);
}
.fade-transform-leave-to {
  opacity: 0;
  transform: translateX(-60px);
}
.fade-transform-enter-active,
.fade-transform-leave-active {
  transition: all 0.5s;
}

这样我们的页面切换过渡效果就做好了。

2023-04-19 23.47.25.gif

可以看到,我们是通过keep-alive的include参数,把我们刚才的缓存页面name列表告诉它哪些页面需要缓存的,我们这里设置角色管理为缓存页面测试一下效果

2023-04-20 00.08.47.gif

可以看到角色管理页面被成功的缓存了。

本章就到这里,下一章讲一下前端字典项,还有一些前面遗漏的地方。