github: https://github.com/heyongsheng/hevue3-admin 码云: https://gitee.com/ihope_top/hevue3-admin 线上体验地址 https://ihope_top.gitee.io/hevue3-admin
本章知识点:
我们先来看一下主页面长什么样子。
页面比较简单,主要分为左侧的菜单栏,顶部的导航栏(折叠左侧菜单,切换暗黑模式,员工账号名,退出登录),再下面的标签栏,之后就是主页面显示区域。
我们在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)
}
关于滚动,还有一个小细节,就是当标签比较多了之后,我们通过侧边栏或者其他方式跳转到已经访问过的页面,如果该页面的标签被超出屏幕被隐藏了,我们需要把标签栏滚动到该标签的位置。
// 滚动到当前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
来实现。
固定标签没有关闭按钮
刚才说标签的时候提到了缓存页面,不过没有说怎么写,这里和过渡效果一起说。
我们需要切换过渡效果的地方其实就是主界面显示区域那一块,文件是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>
这里还需要注意两个点:
out-in
模式,否则可能会导致页面空白或者过渡效果不生效的问题下面附上过渡效果的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;
}
这样我们的页面切换过渡效果就做好了。
可以看到,我们是通过keep-alive的include参数,把我们刚才的缓存页面name列表告诉它哪些页面需要缓存的,我们这里设置角色管理为缓存页面测试一下效果
可以看到角色管理页面被成功的缓存了。
本章就到这里,下一章讲一下前端字典项,还有一些前面遗漏的地方。