fix: 菜单切页先亮标签再渲染,减轻顶栏卡顿

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-08-30 18:09:08 +08:00
parent cf4d2c4a3f
commit df35a90321
5 changed files with 147 additions and 22 deletions

View File

@ -117,6 +117,7 @@
watch( watch(
() => route.fullPath, () => route.fullPath,
async () => { async () => {
tabBarStore.clearPreview();
await nextTick(); await nextTick();
scrollActiveTagIntoView(); scrollActiveTagIntoView();
}, },
@ -240,7 +241,7 @@
overflow-y: hidden; overflow-y: hidden;
display: flex; display: flex;
align-items: center; align-items: center;
scroll-behavior: smooth; scroll-behavior: auto;
&::-webkit-scrollbar { &::-webkit-scrollbar {
height: 0; height: 0;

View File

@ -108,6 +108,21 @@
} }
const isActive = computed(() => { const isActive = computed(() => {
const previewPath = tabBarStore.previewingPath;
if (previewPath || tabBarStore.previewingName) {
if (
previewPath &&
(props.itemData.fullPath === previewPath ||
stripEphemeralQuery(props.itemData.fullPath) ===
stripEphemeralQuery(previewPath))
) {
return true;
}
return (
!!tabBarStore.previewingName &&
props.itemData.name === tabBarStore.previewingName
);
}
if (route.fullPath === props.itemData.fullPath) return true; if (route.fullPath === props.itemData.fullPath) return true;
return ( return (
stripEphemeralQuery(route.fullPath) === stripEphemeralQuery(route.fullPath) ===
@ -117,6 +132,7 @@
const goto = (tag: TagProps) => { const goto = (tag: TagProps) => {
if (isActive.value) return; if (isActive.value) return;
tabBarStore.previewNavigate(tag);
// 只带路由必要字段,避免把 title 等无关字段推进 history // 只带路由必要字段,避免把 title 等无关字段推进 history
if (tag.name) { if (tag.name) {
router.push({ router.push({

View File

@ -25,9 +25,10 @@
trigger="hover" trigger="hover"
position="bl" position="bl"
:popup-offset="0" :popup-offset="0"
:mouse-enter-delay="100" :popup-visible="openedSubmenuKey === menuKey(item)"
:mouse-leave-delay="180" :mouse-enter-delay="80"
@popup-visible-change="onSubmenuPopupVisibleChange" :mouse-leave-delay="navPaintLock ? 0 : 180"
@popup-visible-change="(v: boolean) => onSubmenuPopupVisibleChange(menuKey(item), v)"
> >
<span <span
:ref="(el) => setMenuItemRef(menuKey(item), el)" :ref="(el) => setMenuItemRef(menuKey(item), el)"
@ -55,7 +56,9 @@
:key="childKey(menuItem, childItem)" :key="childKey(menuItem, childItem)"
class="child-menu-item" class="child-menu-item"
:class="{ active: isSubMenuActive(menuItem) }" :class="{ active: isSubMenuActive(menuItem) }"
@pointerenter="prefetchMenuRoute(menuItem)"
@pointerdown.prevent="gotoFromSubmenu(menuItem)" @pointerdown.prevent="gotoFromSubmenu(menuItem)"
@click.prevent="gotoFromSubmenu(menuItem)"
> >
<span class="child-menu-item__icon"> <span class="child-menu-item__icon">
<component <component
@ -88,9 +91,10 @@
trigger="hover" trigger="hover"
position="br" position="br"
:popup-offset="0" :popup-offset="0"
:mouse-enter-delay="100" :popup-visible="openedSubmenuKey === MORE_MENU_KEY"
:mouse-leave-delay="180" :mouse-enter-delay="80"
@popup-visible-change="onSubmenuPopupVisibleChange" :mouse-leave-delay="navPaintLock ? 0 : 180"
@popup-visible-change="(v: boolean) => onSubmenuPopupVisibleChange(MORE_MENU_KEY, v)"
> >
<span <span
:ref="(el) => setMenuItemRef(MORE_MENU_KEY, el)" :ref="(el) => setMenuItemRef(MORE_MENU_KEY, el)"
@ -119,7 +123,9 @@
:key="childKey(child, childIdx)" :key="childKey(child, childIdx)"
class="nav-more-item" class="nav-more-item"
:class="{ active: isSubMenuActive(child) }" :class="{ active: isSubMenuActive(child) }"
@pointerenter="prefetchMenuRoute(child)"
@pointerdown.prevent="gotoFromSubmenu(child)" @pointerdown.prevent="gotoFromSubmenu(child)"
@click.prevent="gotoFromSubmenu(child)"
> >
<span class="nav-more-item__icon"> <span class="nav-more-item__icon">
<component :is="getSubMenuIcon(child, item, menuNameToLocaleKeyMap)" /> <component :is="getSubMenuIcon(child, item, menuNameToLocaleKeyMap)" />
@ -321,11 +327,10 @@ async function onTopMenuClick(e: MouseEvent, item: any) {
e.preventDefault(); e.preventDefault();
const children = filterPage(item.children); const children = filterPage(item.children);
if (children.length > 0) { if (children.length > 0) {
// 有二级:点击一级直接进第一个可见子页(比空点更快) paintNavThenGo(children[0]);
goto(children[0]);
return; return;
} }
goto(item); paintNavThenGo(item);
} }
async function onTogglePrintConnection(value: string | number | boolean) { async function onTogglePrintConnection(value: string | number | boolean) {
@ -655,14 +660,23 @@ function isSubMenuActive(item: any) {
} }
/** 弹出二级/更多菜单时,把当前页对应项滚入可视区 */ /** 弹出二级/更多菜单时,把当前页对应项滚入可视区 */
function onSubmenuPopupVisibleChange(visible: boolean) { const openedSubmenuKey = ref('');
const navPaintLock = ref(false);
let navPaintUnlockTimer = 0;
function onSubmenuPopupVisibleChange(key: string, visible: boolean) {
if (navPaintLock.value) {
openedSubmenuKey.value = '';
return;
}
openedSubmenuKey.value = visible ? key : '';
if (!visible) return; if (!visible) return;
nextTick(() => { nextTick(() => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
const el = document.querySelector( const el = document.querySelector(
'.nav-submenu-panel .child-menu-item.active, .nav-more-panel .nav-more-item.active', '.nav-submenu-panel .child-menu-item.active, .nav-more-panel .nav-more-item.active',
) as HTMLElement | null; ) as HTMLElement | null;
el?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); el?.scrollIntoView({ block: 'nearest', behavior: 'auto' });
}); });
}); });
} }
@ -871,8 +885,14 @@ const goto = (item: any) => {
return; return;
} }
if (router.currentRoute.value.path === targetPath) {
tabBarStore.clearPreview();
return;
}
// 使用路由 path 跳转 // 使用路由 path 跳转
router.push(targetPath).catch((error) => { router.push(targetPath).catch((error) => {
tabBarStore.clearPreview();
console.error(`路由跳转失败: path=${targetPath}`, error); console.error(`路由跳转失败: path=${targetPath}`, error);
// 尝试使用路由名称跳转(从映射中获取,而不是使用 item.name,因为 item.name 是中文显示名称) // 尝试使用路由名称跳转(从映射中获取,而不是使用 item.name,因为 item.name 是中文显示名称)
const routeName = pathToRouteNameMap[targetPath]; const routeName = pathToRouteNameMap[targetPath];
@ -893,9 +913,72 @@ const goto = (item: any) => {
}); });
}; };
/** 子菜单点击后跳转(路由变化会关闭弹层) */ function prefetchMenuRoute(item: any) {
const gotoFromSubmenu = (item: any) => { const rawPath = item?.path || item?.route;
if (!rawPath || regexUrl.test(rawPath)) return;
const targetPath = rawPath.startsWith('/') ? rawPath : `/${rawPath}`;
try {
const resolved = router.resolve(targetPath);
const rec = resolved.matched[resolved.matched.length - 1];
const loader = rec?.components?.default as unknown;
if (typeof loader === 'function') {
const result = (loader as () => unknown)();
if (result && typeof (result as Promise<unknown>).then === 'function') {
(result as Promise<unknown>).catch(() => undefined);
}
}
} catch {
// 预取失败不影响点击跳转
}
}
function previewMenuNavigation(item: any) {
const rawPath = item?.path || item?.route;
if (!rawPath || regexUrl.test(rawPath)) return;
const targetPath = rawPath.startsWith('/') ? rawPath : `/${rawPath}`;
if (resolvePortalDrawerPanel(targetPath)) return;
try {
const resolved = router.resolve(targetPath);
const name = String(resolved.name || '');
if (!name) return;
tabBarStore.previewNavigate({
title: formatMenuTitle(item) || name,
name,
fullPath: resolved.fullPath || targetPath,
query: resolved.query,
});
} catch {
// 解析失败则等路由完成后再亮标签
}
}
/** 先关弹层、亮标签,下一帧再走路由,避免页面重渲染把顶栏一起卡住 */
let navGoingTo = '';
function paintNavThenGo(item: any) {
const rawPath = String(item?.path || item?.route || '');
if (navGoingTo && navGoingTo === rawPath) return;
navGoingTo = rawPath;
openedSubmenuKey.value = '';
navPaintLock.value = true;
if (navPaintUnlockTimer) {
window.clearTimeout(navPaintUnlockTimer);
}
previewMenuNavigation(item);
nextTick(() => {
requestAnimationFrame(() => {
goto(item); goto(item);
navPaintUnlockTimer = window.setTimeout(() => {
navPaintLock.value = false;
navPaintUnlockTimer = 0;
navGoingTo = '';
}, 280);
});
});
}
/** 子菜单点击后跳转(先关弹层,路由变化不再等页面) */
const gotoFromSubmenu = (item: any) => {
paintNavThenGo(item);
}; };
const showBalanceNav = computed(() => rbacUserStore.isCustomerPortal()); const showBalanceNav = computed(() => rbacUserStore.isCustomerPortal());
@ -966,14 +1049,6 @@ watch(
{ flush: 'post' }, { flush: 'post' },
); );
watch(
() => route.fullPath,
async () => {
await nextTick();
await recalcMenuOverflow();
},
);
function onWindowResize() { function onWindowResize() {
void recalcMenuOverflow(); void recalcMenuOverflow();
} }
@ -990,6 +1065,10 @@ onMounted(async () => {
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener(CUSTOMER_BALANCE_CHANGED_EVENT, handleCustomerBalanceChanged); window.removeEventListener(CUSTOMER_BALANCE_CHANGED_EVENT, handleCustomerBalanceChanged);
window.removeEventListener('resize', onWindowResize); window.removeEventListener('resize', onWindowResize);
if (navPaintUnlockTimer) {
window.clearTimeout(navPaintUnlockTimer);
navPaintUnlockTimer = 0;
}
}); });
</script> </script>

View File

@ -62,6 +62,8 @@ const useTabBarStore = defineStore('tabBar', {
state: (): TabBarState => ({ state: (): TabBarState => ({
cacheTabList: new Set([DEFAULT_ROUTE_NAME]), cacheTabList: new Set([DEFAULT_ROUTE_NAME]),
tagList: [DEFAULT_ROUTE], tagList: [DEFAULT_ROUTE],
previewingPath: '',
previewingName: '',
}), }),
getters: { getters: {
@ -107,6 +109,28 @@ const useTabBarStore = defineStore('tabBar', {
this.touchCache(String(route.name)); this.touchCache(String(route.name));
} }
}, },
/** 菜单/标签点击立刻高亮,不等路由 afterEach */
previewNavigate(tag: TagProps) {
if (!tag?.fullPath && !tag?.name) return;
this.previewingPath = tag.fullPath || '';
this.previewingName = tag.name || '';
const exists = this.tagList.some(
(t) =>
(tag.fullPath && t.fullPath === tag.fullPath) ||
(tag.name && t.name === tag.name),
);
if (!exists && tag.name) {
this.tagList.push(tag);
if (!tag.ignoreCache) {
this.touchCache(tag.name);
}
}
},
clearPreview() {
if (!this.previewingPath && !this.previewingName) return;
this.previewingPath = '';
this.previewingName = '';
},
deleteTag(idx: number, tag: TagProps) { deleteTag(idx: number, tag: TagProps) {
this.tagList.splice(idx, 1); this.tagList.splice(idx, 1);
this.cacheTabList.delete(tag.name); this.cacheTabList.delete(tag.name);
@ -137,6 +161,8 @@ const useTabBarStore = defineStore('tabBar', {
this.tagList = [DEFAULT_ROUTE]; this.tagList = [DEFAULT_ROUTE];
this.cacheTabList.clear(); this.cacheTabList.clear();
this.cacheTabList.add(DEFAULT_ROUTE_NAME); this.cacheTabList.add(DEFAULT_ROUTE_NAME);
this.previewingPath = '';
this.previewingName = '';
}, },
delVisitedRoute(path: string) { delVisitedRoute(path: string) {
const idx = this.tagList.findIndex(tag => tag.fullPath === path); const idx = this.tagList.findIndex(tag => tag.fullPath === path);

View File

@ -9,4 +9,7 @@ export interface TagProps {
export interface TabBarState { export interface TabBarState {
tagList: TagProps[]; tagList: TagProps[];
cacheTabList: Set<string>; cacheTabList: Set<string>;
/** 菜单点击后、路由完成前的预亮标签,避免顶栏等页面渲染完才动 */
previewingPath: string;
previewingName: string;
} }