后台管理系统开发(导航栏 前端篇)

241 阅读4分钟

实现效果

  • 当点击 内容顶部任意导航标签 时, 路由至相对应的 菜单
  • 当点击 侧边任意菜单 时, 路由至相对应的 菜单
  • 当移除 内容顶部任意导航标签 时, 导航标签移动至最后一个(路由跳转)。当不存在导航标签时跳转至首页
  • 内容顶部任意导航标签 根据需求判定是否进行 页面缓存

PixPin_2024-02-04_22-10-59.png

image.png

实现关键

  • 通过 定义全局路由守卫 获取跳转路由信息 存储至 store
  • 页面缓存 通过 vue-routerkeep-alive标签include属性维护缓存的 视图组件名列

实现code

定义 useTabStore.ts

  • composables/useTabStore.ts
import type { RouteLocationNormalizedLoaded } from 'vue-router';
import { MenuType } from '~/types/enums';
import { SYS_ENABLE_CACHE_VALUE } from '~/key/dict';
import { MenuTreeUtil } from '~/utils/MenuTreeUtil';

/**
 * 页面缓存 store
 *
 * @author tuuuuuun
 * @version v1.0
 * @since 2024/2/2 5:01
 */
interface NavTabs {
  id: string;
  name: string;
  path: string;
  isCache: string;
  parent?: Partial<NavTabs>;
  icon: string;
  component: string;
  parentId: string;
}

export const useTabStore = defineStore(
  'system-keep-alive',
  () => {
    const router = useRouter();

    // 所有菜单标签
    const tabList = ref<NavTabs[]>([]);

    // 选择菜单标签
    const selectList = ref<NavTabs[]>([]);

    // 激活标签
    const active = ref<NavTabs>();

    const getTab = (id: string) => {
      return selectList.value.find((item) => item.id === id) || ({} as NavTabs);
    };

    /**
     * 激活标签, 并根据 `isCache` 判定是否需要缓存
     *
     * @param route
     */
    const toActive = (
      route: RouteLocationNormalizedLoaded
    ): Partial<NavTabs> => {
      const bar = tabList.value.find((item) => {
        return item.path === route.path;
      });
      if (bar) {
        // 判断是否已经存在, 不存在添加
        const isNotExist =
          selectList.value.findIndex((item) => {
            return item.path === route.path;
          }) === -1;
        if (isNotExist) {
          selectList.value.push(bar);
        }

        // 更新 激活标签
        active.value = bar;
        return bar;
      }

      return {
        name: '未知标签',
      };
    };

    /**
     * 获取所有菜单栏[type {{@link MenuType.Menu }}]
     *
     * @param tree
     */
    const loadMenu = (tree: MenuEntity[]) => {
      const routes = router.getRoutes();
      tabList.value = [];

      const menuEntities = MenuTreeUtil.treeToArray(tree);
      tabList.value = <NavTabs[]>menuEntities
        .filter((en) => en.menuType.code === MenuType.Menu)
        .map((item) => {
          const route = routes.find((route) => route.path === item.component);
          // 获取该菜单的父目录
          const parent =
            menuEntities.find((en) => en.menuId === item.parentId) ||
            ({} as MenuEntity);
          return {
            parent: {
              id: parent.parentId,
              name: parent.menuName,
              path: parent.component,
              isCache: parent.cache?.code,
              icon: parent.menuIcon,
            },
            id: item.menuId,
            name: item.menuName,
            path: item.component,
            isCache: item.cache?.code,
            icon: item.menuIcon,
            component: route?.name as string,
          };
        });

      console.log(tabList);
    };

    /**
     * 获取缓存的组件名
     */
    const keepAliveNames = computed(() => {
      const names =
        selectList.value
          .filter((en) => en.isCache === SYS_ENABLE_CACHE_VALUE)
          .map((entity) => entity.component) || [];
      return names.join(',');
    });

    /**
     * 获取当前激活的菜单id
     */
    const activeKey = computed(() => {
      return active.value?.id || '';
    });

    /**
     * 获取当前激活的菜单 父级id
     */
    const activeParentId = computed(() => {
      return active.value?.parentId || '';
    });

    /**
     * 关闭所有标签
     */
    const closeAllTabs = async () => {
      selectList.value = [];
      await autoMoveLast();
    };

    /**
     * 关闭其他标签
     */
    const closeOtherTabs = async (key: string) => {
      selectList.value = selectList.value.filter((en) => {
        return en.id === key;
      });

      await autoMoveLast();
    };

    /**
     * 关闭左侧标签
     */
    const closeLeftTabs = async (key: string) => {
      const findIndex = selectList.value.findIndex((tab) => tab.id === key);
      if (findIndex !== -1) {
        selectList.value.splice(0, findIndex);
      }

      await autoMoveLast();
    };

    /**
     * 关闭右侧标签
     */
    const closeRightTabs = async (key: string) => {
      const findIndex = selectList.value.findIndex((tab) => tab.id === key);
      if (findIndex !== -1) {
        selectList.value.splice(findIndex + 1, selectList.value.length - 1);
      }

      await autoMoveLast();
    };

    /**
     * 自动移动到最后一个标签
     */
    const autoMoveLast = async () => {
      const selectListValue = selectList.value;

      /**
       * 当缓存列表为空时, 跳转至首页
       */
      if (selectListValue.length === 0) {
        await router.push({
          name: 'index',
        });
        return;
      }

      const lastTab = selectListValue[selectListValue.length - 1];
      if (lastTab) {
        await router.push({
          name: lastTab.component,
        });
      }
    };

    /**
     * 关闭当前标签
     *
     * @param key 标签id
     */
    const closeCurrentTab = async (key: string) => {
      const findIndex = selectList.value.findIndex((name) => {
        return name.id === key;
      });

      if (findIndex !== -1) {
        // 移除导航标签
        selectList.value.splice(findIndex, 1);
      }

      // 移动到最后一个导航标签[跳转路由]
      await autoMoveLast();
    };

    return {
      closeRightTabs,
      closeLeftTabs,
      closeOtherTabs,
      closeAllTabs,
      loadMenu,
      tabList,
      active,
      closeCurrentTab,
      toActive,
      activeKey,
      keepAliveNames,
      activeParentId,
      selectList,
      getTab,
    };
  },

  {
    persist: process.client && {
      storage: localStorage,
    },
  }
);

页面实现

layouts/components/DefaultPageHeader.vue

<!--
 * @FileDescription: 默认布局-主内容头部
 * @Author: tuuuuuun
 * @Date: 2024/1/20 15:24
-->
<script lang="ts" setup>
  const tabStore = useTabStore();
  const { active, activeKey } = storeToRefs(tabStore);

  const router = useRouter();

  const visibleTab = ref<boolean>();
  const currentTab = ref<string>();

  /**
   * 关闭当前标签
   *
   * @param key
   */
  const onCloseCurrentTab = (key: string | number) => {
    tabStore.closeCurrentTab(key + '');
  };

  /**
   * 点击 tab 时,跳转到对应路由
   *
   * @param key 菜单 id
   */
  const onTab = (key: string | number) => {
    visibleTab.value = false;

    const { path } = tabStore.getTab(key + '');
    router.push({
      path,
    });
  };

  /**
   * 关闭右侧标签
   *
   * @param key 菜单 id
   */
  const onCloseRightTabs = (key: string) => {
    tabStore.closeRightTabs(key);
    visibleTab.value = false;
  };

  /**
   * 关闭左侧标签
   *
   * @param key 菜单 id
   */
  const onCloseLeftTabs = (key: string) => {
    tabStore.closeLeftTabs(key);
    visibleTab.value = false;
  };

  /**
   * 关闭其他标签
   *
   * @param key 菜单 id
   */
  const onCloseOtherTabs = (key: string) => {
    tabStore.closeOtherTabs(key);
    visibleTab.value = false;
  };

  /**
   * 右键标签
   *
   * @param key
   */
  const onContextmenuTab = (key: string) => {
    visibleTab.value = true;
    currentTab.value = key;
  };

  /**
   * 右键标签时,显示弹窗
   *
   * @param visible
   */
  const onVisibleChange = (visible: boolean) => {
    visibleTab.value = visible;
  };
</script>

<template>
  <div class="pb-2 lg:px-4">
    <a-page-header
      :subtitle="active?.parent?.name"
      :title="active?.name"
      class="p-2!"
      @back="() => router.back()"
    />

    <a-tabs
      :active-key="activeKey"
      :type="'rounded'"
      animation
      class="flex-1"
      closable
      editable
      @delete="onCloseCurrentTab"
      @tab-click="onTab"
    >
      <a-tab-pane v-for="tab in tabStore.selectList" :key="tab.id">
        <template #title>
          <a-trigger
            :popup-offset="16"
            :popup-visible="tab.id === currentTab && visibleTab"
            :trigger="'contextMenu'"
            show-arrow
            @contextmenu="onContextmenuTab(tab.id)"
            @popup-visible-change="onVisibleChange"
          >
            <a-space v-if="tab.icon">
              <Iconify :name="tab.icon" />
              <span>{{ tab.name }}</span>
            </a-space>

            <span v-else>{{ tab.name }}</span>
            <template #content>
              <a-card
                :body-style="{
                  padding: '0.25rem',
                }"
                :bordered="false"
                class="shadow-lg! rounded!"
              >
                <a-button-group ref="tabRef" :type="'text'" class="p-1">
                  <a-space :direction="'vertical'" :size="'mini'">
                    <a-button @click="tabStore.closeCurrentTab(tab.id)">
                      关闭当前标签页
                      <template #icon>
                        <i class="i-gg:close" />
                      </template>
                    </a-button>

                    <a-divider class="m-0!" />

                    <a-button @click="onCloseOtherTabs(tab.id)">
                      关闭其他标签页
                      <template #icon>
                        <i class="i-gg:dock-left" />
                      </template>
                    </a-button>

                    <a-button @click="tabStore.closeAllTabs">
                      关闭所有标签页
                      <template #icon>
                        <i class="i-gg:display-fullwidth" />
                      </template>
                    </a-button>

                    <a-divider class="m-0!" />

                    <a-button @click="onCloseLeftTabs(tab.id)">
                      关闭左侧标签页
                      <template #icon>
                        <i class="i-gg:push-chevron-left" />
                      </template>
                    </a-button>

                    <a-button @click="onCloseRightTabs(tab.id)">
                      关闭右侧标签页
                      <template #icon>
                        <i class="i-gg:push-chevron-left rotate-180" />
                      </template>
                    </a-button>
                  </a-space>
                </a-button-group>
              </a-card>
            </template>
          </a-trigger>
        </template>
      </a-tab-pane>
    </a-tabs>
  </div>
</template>

<style lang="scss" scoped>
  :deep(.arco-tabs-content) {
    display: none;
  }

  :deep(.arco-page-header-wrapper) {
    padding: 0;
  }

  :deep(.arco-tabs-tab) {
    @apply rounded;
  }

  :deep(.arco-tabs-nav-type-rounded .arco-tabs-tab) {
    @apply px-3;
  }

  :deep(.arco-btn-size-medium) {
    padding: 0 0.5rem;
  }

  :deep(.arco-page-header-title) {
    font-size: 1.2em;
  }
</style>

layouts/components/DefaultSide.vue

<!--
 * @FileDescription: 默认布局-侧边栏
 * @Author: tuuuuuun
 * @Date: 2024/1/26 15:24
-->
<script lang="ts" setup>
  import { useRequest } from '#imports';
  import { getMenuTreeApi } from '~/api/menu';

  defineProps<{
    collapsed: boolean;
  }>();

  const keepAliveStore = useTabStore();
  const { activeKey, activeParentId, active } = storeToRefs(keepAliveStore);

  const globalState = useGlobalState();
  const { sideDarkTheme, sideCollapsed } = storeToRefs(globalState);

  const openKeys = ref<string[]>([]);

  /**
   * 当标签切换时,更新打开的目录
   */
  whenever(
    activeParentId,
    (pid) => {
      openKeys.value[0] = pid;
    },
    {
      immediate: true,
    }
  );

  const { data: homeMenuData } = useRequest(getMenuTreeApi, {
    manual: false,
    defaultParams: [
      {
        size: 1000,
      },
    ],
    onSuccess(data) {
      keepAliveStore.loadMenu(data);
    },
  });

  /**
   * 点击菜单 进行跳转
   *
   * @param path 跳转路径
   */
  const onClick = (path: string) => {
    // console.info('点击菜单', path);
    navigateTo({
      path,
    });
  };
</script>

<template>
  <div class="h-screen flex flex-col overflow-hidden">
    <div class="flex-1 overflow-y-auto">
      <a-menu
        v-model:open-keys="openKeys"
        :collapsed="collapsed"
        :selected-keys="[activeKey]"
        :theme="sideDarkTheme ? 'dark' : 'light'"
        auto-open-selected
        style="height: 100%"
      >
        <a-menu-item
          :style="{ padding: 0, margin: '0 auto' }"
          disabled
          @click="sideCollapsed = !sideCollapsed"
        >
          <Logo :collapsed="collapsed" />
        </a-menu-item>

        <a-sub-menu v-for="menu in homeMenuData" :key="menu.menuId">
          <template #icon>
            <Iconify :name="menu.menuIcon" />
          </template>
          <template #title>{{ menu.menuName }}</template>

          <a-menu-item
            v-for="subMenu in menu.children"
            :key="subMenu.menuId"
            @click="onClick(subMenu.component)"
          >
            {{ subMenu.menuName }}
            <template #icon>
              <Iconify :name="subMenu.menuIcon" />
            </template>
          </a-menu-item>
        </a-sub-menu>
      </a-menu>
    </div>
  </div>
</template>

<style lang="scss" scoped>
  @import '../styles/menu';
</style>