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


实现关键
- 通过 定义
全局路由守卫 获取跳转路由信息 存储至 store
- 页面缓存 通过
vue-router的 keep-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';
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);
};
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: '未知标签',
};
};
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
<script lang="ts" setup>
const tabStore = useTabStore();
const { active, activeKey } = storeToRefs(tabStore);
const router = useRouter();
const visibleTab = ref<boolean>();
const currentTab = ref<string>();
const onCloseCurrentTab = (key: string | number) => {
tabStore.closeCurrentTab(key + '');
};
const onTab = (key: string | number) => {
visibleTab.value = false;
const { path } = tabStore.getTab(key + '');
router.push({
path,
});
};
const onCloseRightTabs = (key: string) => {
tabStore.closeRightTabs(key);
visibleTab.value = false;
};
const onCloseLeftTabs = (key: string) => {
tabStore.closeLeftTabs(key);
visibleTab.value = false;
};
const onCloseOtherTabs = (key: string) => {
tabStore.closeOtherTabs(key);
visibleTab.value = false;
};
const onContextmenuTab = (key: string) => {
visibleTab.value = true;
currentTab.value = key;
};
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
<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);
},
});
const onClick = (path: string) => {
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>