feat(projects): 支持同一路由根据不同query和hash同时显示不同Tab

ISSUES CLOSED: #64
This commit is contained in:
Soybean 2022-06-07 00:56:25 +08:00
parent 434ab1c560
commit 4122685803
20 changed files with 364 additions and 89 deletions

View File

@ -302,6 +302,53 @@ export const routeModel: Record<Auth.RoleType, AuthRoute.Route[]> = {
order: 5
}
},
{
name: 'function',
path: '/function',
component: 'basic',
children: [
{
name: 'function_tab',
path: '/function/tab',
component: 'self',
meta: {
title: 'Tab',
requiresAuth: true,
icon: 'ic:round-tab'
}
},
{
name: 'function_tab-detail',
path: '/function/tab-detail',
component: 'self',
meta: {
title: 'Tab Detail',
requiresAuth: true,
hide: true,
activeMenu: 'function_tab',
icon: 'ic:round-tab'
}
},
{
name: 'function_tab-multi-detail',
path: '/function/tab-multi-detail',
component: 'self',
meta: {
title: 'Tab Multi Detail',
requiresAuth: true,
hide: true,
multiTab: true,
activeMenu: 'function_tab',
icon: 'ic:round-tab'
}
}
],
meta: {
title: '功能',
icon: 'ri:function-line',
order: 6
}
},
{
name: 'exception',
path: '/exception',
@ -341,7 +388,7 @@ export const routeModel: Record<Auth.RoleType, AuthRoute.Route[]> = {
meta: {
title: '异常页',
icon: 'ant-design:exception-outlined',
order: 6
order: 7
}
},
{
@ -395,7 +442,7 @@ export const routeModel: Record<Auth.RoleType, AuthRoute.Route[]> = {
meta: {
title: '多级菜单',
icon: 'carbon:menu',
order: 7
order: 8
}
},
{
@ -407,7 +454,7 @@ export const routeModel: Record<Auth.RoleType, AuthRoute.Route[]> = {
requiresAuth: true,
singleLayout: 'basic',
icon: 'fluent:book-information-24-regular',
order: 8
order: 9
}
}
],
@ -704,6 +751,53 @@ export const routeModel: Record<Auth.RoleType, AuthRoute.Route[]> = {
order: 5
}
},
{
name: 'function',
path: '/function',
component: 'basic',
children: [
{
name: 'function_tab',
path: '/function/tab',
component: 'self',
meta: {
title: 'Tab',
requiresAuth: true,
icon: 'ic:round-tab'
}
},
{
name: 'function_tab-detail',
path: '/function/tab-detail',
component: 'self',
meta: {
title: 'Tab Detail',
requiresAuth: true,
hide: true,
activeMenu: 'function_tab',
icon: 'ic:round-tab'
}
},
{
name: 'function_tab-multi-detail',
path: '/function/tab-multi-detail',
component: 'self',
meta: {
title: 'Tab Multi Detail',
requiresAuth: true,
hide: true,
multiTab: true,
activeMenu: 'function_tab',
icon: 'ic:round-tab'
}
}
],
meta: {
title: '功能',
icon: 'ri:function-line',
order: 6
}
},
{
name: 'exception',
path: '/exception',
@ -743,7 +837,7 @@ export const routeModel: Record<Auth.RoleType, AuthRoute.Route[]> = {
meta: {
title: '异常页',
icon: 'ant-design:exception-outlined',
order: 6
order: 7
}
},
{
@ -797,7 +891,7 @@ export const routeModel: Record<Auth.RoleType, AuthRoute.Route[]> = {
meta: {
title: '多级菜单',
icon: 'carbon:menu',
order: 7
order: 8
}
},
{
@ -809,7 +903,7 @@ export const routeModel: Record<Auth.RoleType, AuthRoute.Route[]> = {
requiresAuth: true,
singleLayout: 'basic',
icon: 'fluent:book-information-24-regular',
order: 8
order: 9
}
}
],

View File

@ -16,7 +16,7 @@ export enum EnumStorageKey {
/** 用户信息 */
'user-info' = '__USER_INFO__',
/** 多页签路由信息 */
'tab-routes' = '__TAB_ROUTES__'
'multi-tab-routes' = '__MULTI_TAB_ROUTES__'
}
/** 数据类型 */

View File

@ -12,7 +12,7 @@
@after-enter="handleAfterEnter"
>
<keep-alive :include="routeStore.cacheRoutes">
<component :is="Component" v-if="app.reloadFlag" :key="route.path" />
<component :is="Component" v-if="app.reloadFlag" :key="route.fullPath" />
</keep-alive>
</transition>
</router-view>

View File

@ -69,7 +69,7 @@ const options = computed<Option[]>(() => [
{
label: '关闭',
key: 'close-current',
disabled: props.currentPath === tab.homeTab.path,
disabled: props.currentPath === tab.homeTab.fullPath,
icon: iconifyRender('ant-design:close-outlined')
},
{

View File

@ -3,15 +3,15 @@
<component
:is="activeComponent"
v-for="(item, index) in tab.tabs"
:key="item.path"
:is-active="tab.activeTab === item.path"
:key="item.fullPath"
:is-active="tab.activeTab === item.fullPath"
:primary-color="theme.themeColor"
:closable="item.path !== tab.homeTab.path"
:closable="item.name !== tab.homeTab.name"
:dark-mode="theme.darkMode"
:class="{ '!mr-0': isChromeMode && index === tab.tabs.length - 1, 'mr-10px': !isChromeMode }"
@click="tab.handleClickTab(item.path)"
@close="tab.removeTab(item.path)"
@contextmenu="handleContextMenu($event, item.path)"
@click="tab.handleClickTab(item.fullPath)"
@close="tab.removeTab(item.fullPath)"
@contextmenu="handleContextMenu($event, item.fullPath)"
>
<Icon v-if="item.meta.icon" :icon="item.meta.icon" class="inline-block align-text-bottom mr-4px text-16px" />
{{ item.meta.title }}
@ -77,11 +77,11 @@ function setDropdown(x: number, y: number, currentPath: string) {
}
/** 点击右键菜单 */
async function handleContextMenu(e: MouseEvent, path: string) {
async function handleContextMenu(e: MouseEvent, fullPath: string) {
e.preventDefault();
const { clientX, clientY } = e;
hideDropdown();
setDropdown(clientX, clientY, path);
setDropdown(clientX, clientY, fullPath);
await nextTick();
showDropdown();
}

View File

@ -45,10 +45,10 @@ function init() {
}
watch(
() => route.path,
() => route.fullPath,
() => {
tab.addTab(route);
tab.setActiveTab(route.path);
tab.setActiveTab(route.fullPath);
}
);

View File

@ -31,15 +31,10 @@ export async function createDynamicRouteGuard(
if (to.name === routeName('not-found-page')) {
// 动态路由没有加载导致被not-found-page路由捕获等待权限路由加载好了回到之前的路由
// 若路由是从根路由重定向过来的,重新回到根路由
const ROOT_ROUTE_NAME: AuthRoute.RouteKey = 'root';
if (to.redirectedFrom?.name === ROOT_ROUTE_NAME) {
next({ path: '/', replace: true, query: to.query });
return false;
}
next({ path: to.fullPath, replace: true, query: to.query });
const path = to.redirectedFrom?.name === ROOT_ROUTE_NAME ? '/' : to.fullPath;
next({ path, replace: true, query: to.query, hash: to.hash });
return false;
}
}

View File

@ -6,10 +6,13 @@ export const scrollBehavior: RouterScrollBehavior = (to, from) => {
const tab = useTabStore();
if (to.hash) {
resolve({
el: to.hash,
behavior: 'smooth'
});
const el = document.querySelector(to.hash);
if (el) {
resolve({
el,
behavior: 'smooth'
});
}
}
const { left, top } = tab.getTabScrollPosition(to.path);

View File

@ -8,7 +8,7 @@ const about: AuthRoute.Route = {
singleLayout: 'basic',
permissions: ['super', 'admin', 'user'],
icon: 'fluent:book-information-24-regular',
order: 8
order: 9
}
};

View File

@ -37,7 +37,7 @@ const exception: AuthRoute.Route = {
meta: {
title: '异常页',
icon: 'ant-design:exception-outlined',
order: 6
order: 7
}
};

View File

@ -0,0 +1,49 @@
const functionRoute: AuthRoute.Route = {
name: 'function',
path: '/function',
component: 'basic',
children: [
{
name: 'function_tab',
path: '/function/tab',
component: 'self',
meta: {
title: 'Tab',
requiresAuth: true,
icon: 'ic:round-tab'
}
},
{
name: 'function_tab-detail',
path: '/function/tab-detail',
component: 'self',
meta: {
title: 'Tab Detail',
requiresAuth: true,
hide: true,
activeMenu: 'function_tab',
icon: 'ic:round-tab'
}
},
{
name: 'function_tab-multi-detail',
path: '/function/tab-multi-detail',
component: 'self',
meta: {
title: 'Tab Multi Detail',
requiresAuth: true,
hide: true,
multiTab: true,
activeMenu: 'function_tab',
icon: 'ic:round-tab'
}
}
],
meta: {
title: '功能',
icon: 'ri:function-line',
order: 6
}
};
export default functionRoute;

View File

@ -49,7 +49,7 @@ const multiMenu: AuthRoute.Route = {
meta: {
title: '多级菜单',
icon: 'carbon:menu',
order: 6
order: 8
}
};

View File

@ -1,13 +1,15 @@
import type { RouteRecordNormalized, RouteLocationNormalizedLoaded } from 'vue-router';
/**
* vue路由获取tab路由
* vue路由获取tab路由
* @param route
*/
export function getTabRouteByVueRoute(route: RouteRecordNormalized | RouteLocationNormalizedLoaded) {
const fullPath = hasFullPath(route) ? route.fullPath : route.path;
const tabRoute: GlobalTabRoute = {
name: route.name,
path: route.path,
fullPath,
meta: route.meta,
scrollPosition: {
left: 0,
@ -20,17 +22,36 @@ export function getTabRouteByVueRoute(route: RouteRecordNormalized | RouteLocati
/**
*
* @param tabs -
* @param path -
* @param fullPath -
*/
export function getIndexInTabRoutes(tabs: GlobalTabRoute[], path: string) {
return tabs.findIndex(tab => tab.path === path);
export function getIndexInTabRoutes(tabs: GlobalTabRoute[], fullPath: string) {
return tabs.findIndex(tab => tab.fullPath === fullPath);
}
/**
*
* @param tabs -
* @param path -
* @param fullPath -
*/
export function isInTabRoutes(tabs: GlobalTabRoute[], path: string) {
return getIndexInTabRoutes(tabs, path) > -1;
export function isInTabRoutes(tabs: GlobalTabRoute[], fullPath: string) {
return getIndexInTabRoutes(tabs, fullPath) > -1;
}
/**
*
* @param tabs -
* @param routeName -
*/
export function getIndexInTabRoutesByRouteName(tabs: GlobalTabRoute[], routeName: string) {
return tabs.findIndex(tab => tab.name === routeName);
}
/**
* fullPath属性
* @param route
*/
function hasFullPath(
route: RouteRecordNormalized | RouteLocationNormalizedLoaded
): route is RouteLocationNormalizedLoaded {
return Boolean((route as RouteLocationNormalizedLoaded).fullPath);
}

View File

@ -3,14 +3,14 @@ import { defineStore } from 'pinia';
import { useRouterPush } from '@/composables';
import { getTabRoutes, clearTabRoutes } from '@/utils';
import { useThemeStore } from '../theme';
import { getTabRouteByVueRoute, isInTabRoutes, getIndexInTabRoutes } from './helpers';
import { getTabRouteByVueRoute, isInTabRoutes, getIndexInTabRoutes, getIndexInTabRoutesByRouteName } from './helpers';
interface TabState {
/** 多页签数据 */
tabs: GlobalTabRoute[];
/** 多页签首页 */
homeTab: GlobalTabRoute;
/** 当前激活状态的页签(路由path) */
/** 当前激活状态的页签(路由fullPath) */
activeTab: string;
}
@ -19,7 +19,7 @@ export const useTabStore = defineStore('tab-store', {
tabs: [],
homeTab: {
name: 'root',
path: '/',
fullPath: '/',
meta: {
title: 'Root'
},
@ -34,7 +34,7 @@ export const useTabStore = defineStore('tab-store', {
/** 当前激活状态的页签索引 */
activeTabIndex(state) {
const { tabs, activeTab } = state;
return tabs.findIndex(tab => tab.path === activeTab);
return tabs.findIndex(tab => tab.fullPath === activeTab);
}
},
actions: {
@ -45,10 +45,10 @@ export const useTabStore = defineStore('tab-store', {
},
/**
*
* @param path - path
* @param fullPath - fullPath
*/
setActiveTab(path: string) {
this.activeTab = path;
setActiveTab(fullPath: string) {
this.activeTab = fullPath;
},
/**
*
@ -68,23 +68,39 @@ export const useTabStore = defineStore('tab-store', {
* @param route -
*/
addTab(route: RouteLocationNormalizedLoaded) {
if (!isInTabRoutes(this.tabs, route.path)) {
const tab = getTabRouteByVueRoute(route);
this.tabs.push(tab);
const tab = getTabRouteByVueRoute(route);
if (isInTabRoutes(this.tabs, tab.fullPath)) {
return;
}
const index = getIndexInTabRoutesByRouteName(this.tabs, route.name as string);
if (index === -1) {
this.tabs.push(tab);
return;
}
const { multiTab = false } = route.meta;
if (!multiTab) {
this.tabs.splice(index, 1, tab);
return;
}
this.tabs.push(tab);
},
/**
*
* @param path - path
* @param fullPath - fullPath
*/
removeTab(path: string) {
removeTab(fullPath: string) {
const { routerPush } = useRouterPush(false);
const isActive = this.activeTab === path;
const updateTabs = this.tabs.filter(tab => tab.path !== path);
const isActive = this.activeTab === fullPath;
const updateTabs = this.tabs.filter(tab => tab.fullPath !== fullPath);
this.tabs = updateTabs;
if (isActive && updateTabs.length) {
const activePath = updateTabs[updateTabs.length - 1].path;
const activePath = updateTabs[updateTabs.length - 1].fullPath;
this.setActiveTab(activePath);
routerPush(activePath);
}
@ -96,73 +112,73 @@ export const useTabStore = defineStore('tab-store', {
clearTab(excludes: string[] = []) {
const { routerPush } = useRouterPush(false);
const homePath = this.homeTab.path;
const homePath = this.homeTab.fullPath;
const remain = [homePath, ...excludes];
const hasActive = remain.includes(this.activeTab);
const updateTabs = this.tabs.filter(tab => remain.includes(tab.path));
const updateTabs = this.tabs.filter(tab => remain.includes(tab.fullPath));
this.tabs = updateTabs;
if (!hasActive && updateTabs.length) {
const activePath = updateTabs[updateTabs.length - 1].path;
const activePath = updateTabs[updateTabs.length - 1].fullPath;
this.setActiveTab(activePath);
routerPush(activePath);
}
},
/**
*
* @param path - path
* @param fullPath - fullPath
*/
clearLeftTab(path: string) {
const index = getIndexInTabRoutes(this.tabs, path);
clearLeftTab(fullPath: string) {
const index = getIndexInTabRoutes(this.tabs, fullPath);
if (index > -1) {
const excludes = this.tabs.slice(index).map(item => item.path);
const excludes = this.tabs.slice(index).map(item => item.fullPath);
this.clearTab(excludes);
}
},
/**
*
* @param path - path
* @param fullPath - fullPath
*/
clearRightTab(path: string) {
const index = getIndexInTabRoutes(this.tabs, path);
clearRightTab(fullPath: string) {
const index = getIndexInTabRoutes(this.tabs, fullPath);
if (index > -1) {
const excludes = this.tabs.slice(0, index + 1).map(item => item.path);
const excludes = this.tabs.slice(0, index + 1).map(item => item.fullPath);
this.clearTab(excludes);
}
},
/**
* tab
* @param path - path
* @param fullPath - fullPath
*/
handleClickTab(path: string) {
handleClickTab(fullPath: string) {
const { routerPush } = useRouterPush(false);
const isActive = this.activeTab === path;
const isActive = this.activeTab === fullPath;
if (!isActive) {
this.setActiveTab(path);
routerPush(path);
this.setActiveTab(fullPath);
routerPush(fullPath);
}
},
/**
* tab滚动位置
* @param path - path
* @param fullPath - fullPath
* @param position - tab当前页的滚动位置
*/
recordTabScrollPosition(path: string, position: { left: number; top: number }) {
const index = getIndexInTabRoutes(this.tabs, path);
recordTabScrollPosition(fullPath: string, position: { left: number; top: number }) {
const index = getIndexInTabRoutes(this.tabs, fullPath);
if (index > -1) {
this.tabs[index].scrollPosition = position;
}
},
/**
* tab滚动位置
* @param path - path
* @param fullPath - fullPath
*/
getTabScrollPosition(path: string) {
getTabScrollPosition(fullPath: string) {
const position = {
left: 0,
top: 0
};
const index = getIndexInTabRoutes(this.tabs, path);
const index = getIndexInTabRoutes(this.tabs, fullPath);
if (index > -1) {
Object.assign(position, this.tabs[index].scrollPosition);
}
@ -174,20 +190,27 @@ export const useTabStore = defineStore('tab-store', {
const tabs: GlobalTabRoute[] = theme.tab.isCache ? getTabRoutes() : [];
const hasHome = isInTabRoutes(tabs, this.homeTab.path);
const hasHome = getIndexInTabRoutesByRouteName(tabs, this.homeTab.name as string) > -1;
if (!hasHome && this.homeTab.name !== 'root') {
tabs.unshift(this.homeTab);
}
const isHome = currentRoute.path === this.homeTab.path;
const hasCurrent = isInTabRoutes(tabs, currentRoute.path);
if (!isHome && !hasCurrent) {
const isHome = currentRoute.fullPath === this.homeTab.fullPath;
const index = getIndexInTabRoutesByRouteName(tabs, currentRoute.name as string);
if (!isHome) {
const currentTab = getTabRouteByVueRoute(currentRoute);
tabs.push(currentTab);
if (!currentRoute.meta.multiTab) {
tabs.splice(index, 1, currentTab);
} else {
const hasCurrent = isInTabRoutes(tabs, currentRoute.fullPath);
if (!hasCurrent) {
tabs.push(currentTab);
}
}
}
this.tabs = tabs;
this.setActiveTab(currentRoute.path);
this.setActiveTab(currentRoute.fullPath);
}
}
});

View File

@ -44,6 +44,10 @@ declare namespace AuthRoute {
| 'auth-demo'
| 'auth-demo_permission'
| 'auth-demo_super'
| 'function'
| 'function_tab'
| 'function_tab-detail'
| 'function_tab-multi-detail'
| 'exception'
| 'exception_403'
| 'exception_404'
@ -94,12 +98,14 @@ declare namespace AuthRoute {
hide?: boolean;
/** 外链链接 */
href?: string;
/** 是否支持多个tab页签(默认一个即相同name的路由会被替换) */
multiTab?: boolean;
/** 路由顺序,可用于菜单的排序 */
order?: number;
/** 表示是否是多级路由的中间级路由(用于转换路由数据时筛选多级路由的标识,定义路由时不用填写) */
multi?: boolean;
/** 当前路由需要选中的菜单项(用于跳转至不在左侧菜单显示的路由且需要高亮某个菜单的情况) */
activeMenu?: RouteKey;
/** 表示是否是多级路由的中间级路由(用于转换路由数据时筛选多级路由的标识,定义路由时不用填写) */
multi?: boolean;
};
/** 单个路由的类型结构(动态路由模式:后端返回此类型结构的路由) */

View File

@ -276,7 +276,8 @@ type GlobalBreadcrumb = import('naive-ui').DropdownOption & {
};
/** 多页签Tab的路由 */
interface GlobalTabRoute extends Pick<import('vue-router').RouteLocationNormalizedLoaded, 'name' | 'path' | 'meta'> {
interface GlobalTabRoute
extends Pick<import('vue-router').RouteLocationNormalizedLoaded, 'name' | 'fullPath' | 'meta'> {
/** 滚动的位置 */
scrollPosition: {
left: number;

View File

@ -3,13 +3,13 @@ import { setLocal, getLocal } from '../storage';
/** 缓存多页签数据 */
export function setTabRoutes(data: GlobalTabRoute[]) {
setLocal(EnumStorageKey['tab-routes'], data);
setLocal(EnumStorageKey['multi-tab-routes'], data);
}
/** 获取缓存的多页签数据 */
export function getTabRoutes() {
const routes: GlobalTabRoute[] = [];
const data = getLocal<GlobalTabRoute[]>(EnumStorageKey['tab-routes']);
const data = getLocal<GlobalTabRoute[]>(EnumStorageKey['multi-tab-routes']);
if (data) {
const defaultTabRoutes = data.map(item => ({
...item,

View File

@ -0,0 +1,27 @@
<template>
<n-space :vertical="true" :size="16">
<n-card title="Tab Detail" :bordered="false" size="small" class="shadow-sm rounded-16px">
<n-space :vertical="true" :size="12">
<div>当前路由的描述数据(meta)</div>
<div>{{ route.meta }}</div>
<n-button @click="handleToTab">返回Tab</n-button>
</n-space>
</n-card>
</n-space>
</template>
<script setup lang="ts">
import { useRoute } from 'vue-router';
import { routeName } from '@/router';
import { useRouterPush } from '@/composables';
const { routerPush } = useRouterPush();
const route = useRoute();
function handleToTab() {
routerPush({ name: routeName('function_tab') });
}
</script>
<style scoped></style>

View File

@ -0,0 +1,28 @@
<template>
<n-space :vertical="true" :size="16">
<n-card title="Tab Detail" :bordered="false" size="small" class="shadow-sm rounded-16px">
<n-space :vertical="true" :size="12">
<div>当前路由的描述数据(meta)</div>
<div>{{ route.meta }}</div>
<div>当前路由的查询数据(query)</div>
<div>{{ route.query }}</div>
<n-button @click="handleToTab">返回Tab</n-button>
</n-space>
</n-card>
</n-space>
</template>
<script setup lang="ts">
import { useRoute } from 'vue-router';
import { routeName } from '@/router';
import { useRouterPush } from '@/composables';
const route = useRoute();
const { routerPush } = useRouterPush();
function handleToTab() {
routerPush({ name: routeName('function_tab') });
}
</script>
<style scoped></style>

View File

@ -0,0 +1,28 @@
<template>
<n-space :vertical="true" :size="16">
<n-card title="Tab Home" :bordered="false" size="small" class="shadow-sm rounded-16px">
<n-space :vertical="true" :size="12">
<n-button @click="handleToTabDetail">跳转Tab Detail</n-button>
<n-button @click="handleToTabMultiDetail(1)">跳转Tab Multi Detail 1</n-button>
<n-button @click="handleToTabMultiDetail(2)">跳转Tab Multi Detail 2</n-button>
</n-space>
</n-card>
</n-space>
</template>
<script setup lang="ts">
import { routeName } from '@/router';
import { useRouterPush } from '@/composables';
const { routerPush } = useRouterPush();
function handleToTabDetail() {
routerPush({ name: routeName('function_tab-detail'), query: { name: 'abc' }, hash: '#DEMO_HASH' });
}
function handleToTabMultiDetail(num: number) {
routerPush({ name: routeName('function_tab-multi-detail'), query: { name: 'abc', num }, hash: '#DEMO_HASH' });
}
</script>
<style scoped></style>