Vue3边学边做系列(5)--布局切换&菜单事件&标签页

#编程达人挑战赛·第2期#

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;
      }
    };

这样就实现了标签页和菜单的所有联动信息.

到此, 页面布局, 菜单和标签基本所大功告成了.

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

硅谷工具人

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值