Vue3边学边做系列(5)–布局切换&菜单事件&标签页
前端开发系列(1)-开发环境安装
前端开发系列(2)-项目框架搭建
Vue3边学边做系列(3)-路由缓存接口封装
vue3边学边做系列(4)-工具条&系统设置&动态菜单
本期效果图
本期实现的功能:
- 布局的优化
- 菜单的动态生成
- 菜单的事件处理
- 标签页的设置
1. 布局的优化
顶部菜单的布局:
左侧菜单布局1:
左侧菜单布局2(折叠版):
混合布局:
每种布局通过动态组件生成.
2. 动态菜单生成
菜单组件进一步优化.
(1) 作为递归调用的菜单组件:
src/layouts/menus/MenuItem.vue
<template>
<!-- 没有子菜单 -->
<template v-if="!hasChildren">
<el-menu-item :index="menu.name" :route="menu.path" class="drip-menu-item">
<template v-if="showIcon && menu.icon">
<el-icon>
<component :is="menu.icon" />
</el-icon>
</template>
<span>{{ menu.title }}</span>
</el-menu-item>
</template>
<!-- 有子菜单 -->
<el-sub-menu v-else :index="menu.name" :route="menu.path">
<template #title>
<template v-if="showIcon && menu.icon">
<el-icon>
<component :is="menu.icon" />
</el-icon>
</template>
<span>{{ menu.title }}</span>
</template>
<MenuItem
v-for="child in menu.children"
:key="child.id"
:menu="child"
:show-icon="showIcon"
/>
</el-sub-menu>
</template>
<script setup lang="ts">
import type { BackendMenu } from "@/stores/route";
import MenuItem from "./MenuItem.vue";
import { computed } from "vue";
const props = withDefaults(
defineProps<{
menu: BackendMenu;
showIcon?: boolean;
}>(),
{
showIcon: true,
}
);
const hasChildren = computed(
() => props.menu.children && props.menu.children?.length > 0
);
</script>
<style scoped lang="scss">
.drip-menu-item {
height: 40px;
line-height: 40px;
}
</style>
然后, 就是被各个布局管理器调用的菜单, 提供了几种参数设置可以作为外部传入:
interface Props {
menus: BackendMenu[]; //菜单数据
mode?: 'horizontal' | 'vertical'; //方向
collapse?: boolean; //是否折叠
showIcon?: boolean; //是否显示菜单图标
defaultActive?: string; //默认激活菜单
menuTrigger?: 'click' | 'hover'; //触发方式
}
withDefaults(defineProps<Props>(),{
mode: 'horizontal',
collapse: false,
showIcon: true,
defaultActive: '',
menuTrigger: 'click',
});
通过这样几种参数,可以满足基本的布局需求
- defaultActive
默认激活的名称, 这个暴露出去,非常方便在混合模式下, 分别设置父级和子级的激活状态.
在下面的菜单列表中引入上面的菜单来实现树的遍历, 完整的参考如下:
src/layouts/menus/MenuList.vue
<template>
<el-menu
class="menu-list"
:unique-opened="true"
:collapse="collapse"
:showIcon="showIcon"
:mode="mode"
:ellipsis="false"
:trigger="menuTrigger || 'click'"
:default-active="defaultActive"
:collapse-transition="false"
@select="handleSelect"
>
<MenuItem
v-for="menu in menus"
:key="menu.id"
:menu="menu"
:show-icon="showIcon"
/>
</el-menu>
</template>
<script setup lang="ts">
import type { BackendMenu } from "@/stores/route";
import MenuItem from "./MenuItem.vue";
interface Props {
menus: BackendMenu[];
mode?: 'horizontal' | 'vertical';
collapse?: boolean;
showIcon?: boolean;
defaultActive?: string;
menuTrigger?: 'click' | 'hover';
}
withDefaults(defineProps<Props>(),{
mode: 'horizontal',
collapse: false,
showIcon: true,
defaultActive: '',
menuTrigger: 'click',
});
const emit = defineEmits<{
select:[name:string]
}>();
const handleSelect = (name: string) => {
emit('select',name);
};
</script>
<style scoped lang="scss">
.el-menu--horizontal.el-menu {
border: none;
}
.menu-list {
height: 100%;
}
</style>
在lay-mix混合布局中, 继续引入menulist来实现菜单的展示.
由于混合布局时, 存在顶部菜单和侧边菜单, 所以需要分别在顶部和侧边引入menulist, 然后分别传入菜单数据.
<div class="drip-layout">
<div class="drip-header">
<div class="drip-header-left">
<Logo
v-show="currentLayoutConfig.showLogo"
class="layout-header-logo"
/>
<menu-list
:menus="menuStore.topMenus"
mode="horizontal"
@select="menuStore.toSelect"
:defaultActive="menuStore.activeTopName"
:isTopMenu="true"
/>
</div>
<header-toolbar class="drip-header-right" />
</div>
<div class="drip-main">
<div class="drip-side">
<menu-list
class="drip-side-menu"
:mode="'vertical'"
:menus="menuStore.currentSideMenus"
:defaultActive="menuStore.activeCurrName"
@select="menuStore.toSelect"
/>
</div>
<div class="drip-content">
<div>
<tabs class="drip-tabs" />
<router-view class="drip-content-view" />
</div>
<div class="drip-footer" v-if="currentLayoutConfig.showFooter">
<Footer />
</div>
</div>
</div>
</div>
将状态中缓存的menuStore.activeTopName 和menuStore.activeCurrName分别赋值给了顶级菜单和左侧子级菜单, 这样方便后续菜单切换时, 只需要更新缓存中这两个值就可以了.
2. 菜单的缓存
菜单缓存定义的一些路由跳转使用的方法:
菜单缓存特别重要, 几乎所有的菜单状态的激活, 以及路由的跳转,都是通过这里的toSelect
方法来控制的, 这样做是为了统一入口.
在上面的混合布局中, @select事件直接调用了menuStore.toSelect方法.
//跳转并添加到标签页
function toSelect(name: string) {
const r = router.getRoutes().find((r) => r.name === name);
if (r) {
const { meta, name, path } = r;
if (meta.type === RouteType.Page) {
const tab = {
title: (meta.title as string) || "",
path,
name: name as string,
keepAlive: true,
};
throttledAddTab(tab);
router.push({ name: name as string });
}
}
//更新激活的路由菜单
updateActiveMenu(name);
}
当点击菜单时,获取当前路由名称, 根据路由name在总的路由中查找, 找到点击的菜单对应的路由信息.
如果是页面类型, 则将其插入到标签页中, 然后通过router.push
完成跳转.
最后激活对应的菜单
//更新激活的菜单
function updateActiveMenu(currentRouteName: string) {
activeTopName.value = findTopMenuName(currentRouteName);
activeCurrName.value = currentRouteName;
}
更新激活的菜单中,包含2个缓存值, activeTopName
是用来使顶部菜单高亮的, activeCurrName
是使访问的页面的菜单高亮显示的.
当我们单击菜单子项时, 在路由信息中并不包含顶级菜单的信息, 所有需要通过反向查找 findTopMenuName
方法来查找对应的顶级菜单的信息
这样就完整实现了菜单的点击 -> 标签页面的添加 -> 路由的跳转 -> 点击的菜单以及顶级菜单的高亮显示
.
3. 标签页的设置
标签页使用的是el-tabs来实现的, 由于需要对页面刷新后,保留标签的状态信息, 所以需要对标签页进行缓存.
3.1 标签页事件
通用的标签页包含下面几个事件:
-
单击事件
-
关闭事件
-
右键事件
所以除了@tab-remove和@tab-click之外, 还需要支持右键菜单事件, 右键事件放到标签模版中.
右键菜单包含: 关闭当前, 关闭左侧, 关闭右侧, 关闭其他, 关闭所有.
<template>
<div class="drip-container">
<el-tabs
v-model="activeName"
closable
class="drip-tabs"
type="border-card"
@tab-remove="tabRemove"
@tab-click="tabClick"
>
<!-- 动态标签页 -->
<el-tab-pane
v-for="item in tabList"
:key="item.path"
:name="item.name"
:lazy="true"
>
<template #label>
<el-dropdown trigger="contextmenu" @command="tabRightClick">
<div class="tab-label">{{ item.title }}</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
:command="{ event: RightEvent.closeCurrent, targetTab: item }"
:disabled="onlyHomeTab"
>关闭当前</el-dropdown-item
>
<el-dropdown-item
:command="{ event: RightEvent.closeLeft, targetTab: item }"
:disabled="!hasLeftTabs(item.name)"
>关闭左侧</el-dropdown-item
>
<el-dropdown-item
:command="{ event: RightEvent.closeRight, targetTab: item }"
:disabled="!hasRightTabs(item.name)"
>关闭右侧</el-dropdown-item
>
<el-dropdown-item
:command="{ event: RightEvent.closeOthers, targetTab: item }"
:disabled="onlyHomeTab"
>关闭其他</el-dropdown-item
>
<el-dropdown-item
:command="{ event: RightEvent.closeAll, targetTab: item }"
:disabled="onlyHomeTab"
>关闭所有</el-dropdown-item
>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<!-- 动态组件 -->
<!-- 使用keep-alive和条件渲染优化组件性能 -->
<keep-alive :include="cachedTabNames">
<component :is="item.name" />
</keep-alive>
</el-tab-pane>
</el-tabs>
</div>
</template>
-
单击事件
需要激活对应的标签, 并且标签和菜单需要联动起来, 所以这里使用了菜单中的
toSelect
方法来改变路由信息.const tabClick = (pane: TabsPaneContext, event: Event) => { menuStore.toSelect(pane.paneName as string); };
-
标签页的监听
watch(activeTab, (newTab) => { menuStore.toSelect(newTab.name); });
通过设置监听
activeTab
的变化,来自动路由对应的页面
3.2 标签页缓存
状态信息:
//标签页
const tabList = ref<TabType[]>([]);
const activeTab = ref<TabType>({ ...homeTab });
每点击一个菜单, 添加一个标签信息, 所以标签页的缓存使用数组tabList
来保存, 并且使用activeTab
来标记当前标签是否被激活.
初始化时,会自动将首页设置为激活状态, 并且首页不可关闭的.
右键事件的处理:
重点是处理右键中各个事件, 通过switch 方式处理右键菜单中不同的事件:
// 处理右键菜单命令
const tabRightClick = (params: {
event: RightEvent;
targetTab: TabType;
}) => {
const event = params.event;
const targetTab = params.targetTab;
const index = tabList.value.findIndex(
(tab) => tab.name === targetTab.name
);
switch (event) {
case RightEvent.closeCurrent:
// 保护性检查:如果目标就是Home页,则不执行关闭
if (targetTab.name === homeTab.name) return;
tabRemove(targetTab.name);
break;
case RightEvent.closeLeft:
// 从索引1开始删除,确保跳过Home页
if (index > 1) {
// 只有左侧有可关闭的标签时才执行
tabList.value.splice(1, index - 1); // 从索引1开始,删除 (index-1) 个元素
// 检查当前激活页是否被删除
if (
tabList.value.findIndex(
(tab) => tab.name === activeTab.value.name
) === -1
) {
activeTab.value = targetTab;
}
}
break;
case RightEvent.closeRight:
// 计算结束索引,确保不会删到Home页
const homeIndex = tabList.value.findIndex(
(tab) => tab.name === homeTab.name
);
const endIndex = index < homeIndex ? homeIndex : tabList.value.length;
const deleteStartIndex = index + 1;
const deleteCount =
(endIndex > index ? endIndex : tabList.value.length) -
deleteStartIndex;
if (deleteCount > 0) {
tabList.value.splice(deleteStartIndex, deleteCount);
if (
tabList.value.findIndex(
(tab) => tab.name === activeTab.value.name
) === -1
) {
activeTab.value = targetTab;
}
}
break;
case RightEvent.closeOthers:
// 保留目标页和Home页
tabList.value = tabList.value.filter(
(tab) => tab.name === targetTab.name || tab.name === homeTab.name
);
activeTab.value = targetTab;
break;
case RightEvent.closeAll:
tabList.value = [{ ...homeTab }]; // 假设homeTab是Home页的完整对象
activeTab.value = { ...homeTab };
break;
}
};
这样就实现了标签页和菜单的所有联动信息.
到此, 页面布局, 菜单和标签基本所大功告成了.