feat(projects): hybrid layout mode auto select first deepest child menu

This commit is contained in:
paynezhuang
2025-12-03 23:12:38 +08:00
committed by Soybean
parent b8a767d704
commit 9401925f41
9 changed files with 132 additions and 17 deletions

View File

@@ -3,6 +3,7 @@ import { useRoute } from 'vue-router';
import { useContext } from '@sa/hooks'; import { useContext } from '@sa/hooks';
import type { RouteKey } from '@elegant-router/types'; import type { RouteKey } from '@elegant-router/types';
import { useRouteStore } from '@/store/modules/route'; import { useRouteStore } from '@/store/modules/route';
import { useThemeStore } from '@/store/modules/theme';
import { useRouterPush } from '@/hooks/common/router'; import { useRouterPush } from '@/hooks/common/router';
export const [provideMixMenuContext, useMixMenuContext] = useContext('MixMenu', useMixMenu); export const [provideMixMenuContext, useMixMenuContext] = useContext('MixMenu', useMixMenu);
@@ -10,6 +11,7 @@ export const [provideMixMenuContext, useMixMenuContext] = useContext('MixMenu',
function useMixMenu() { function useMixMenu() {
const route = useRoute(); const route = useRoute();
const routeStore = useRouteStore(); const routeStore = useRouteStore();
const themeStore = useThemeStore();
const { selectedKey } = useMenu(); const { selectedKey } = useMenu();
const { routerPushByKeyWithMetaQuery } = useRouterPush(); const { routerPushByKeyWithMetaQuery } = useRouterPush();
@@ -100,10 +102,46 @@ function useMixMenu() {
() => secondLevelMenus.value.find(menu => menu.key === activeSecondLevelMenuKey.value)?.children || [] () => secondLevelMenus.value.find(menu => menu.key === activeSecondLevelMenuKey.value)?.children || []
); );
const hasChildLevelMenus = computed(() => childLevelMenus.value.length > 0);
function getDeepestLevelMenuKey(): RouteKey | null {
if (!secondLevelMenus.value.length || !themeStore.sider.autoSelectFirstMenu) {
return null;
}
const secondLevelFirstMenu = secondLevelMenus.value[0];
if (!secondLevelFirstMenu) {
return null;
}
function findDeepest(menu: App.Global.Menu): RouteKey {
if (!menu.children?.length) {
return menu.routeKey;
}
return findDeepest(menu.children[0]);
}
return findDeepest(secondLevelFirstMenu);
}
function activeDeepestLevelMenuKey() {
const deepestLevelMenuKey = getDeepestLevelMenuKey();
if (!deepestLevelMenuKey) return;
// select the deepest second level menu
handleSelectSecondLevelMenu(deepestLevelMenuKey);
}
watch( watch(
() => route.name, () => route.name,
() => { () => {
getActiveFirstLevelMenuKey(); getActiveFirstLevelMenuKey();
// if there are child level menus, get the active second level menu key
if (hasChildLevelMenus.value) {
getActiveSecondLevelMenuKey();
}
}, },
{ immediate: true } { immediate: true }
); );
@@ -121,7 +159,10 @@ function useMixMenu() {
isActiveSecondLevelMenuHasChildren, isActiveSecondLevelMenuHasChildren,
handleSelectSecondLevelMenu, handleSelectSecondLevelMenu,
getActiveSecondLevelMenuKey, getActiveSecondLevelMenuKey,
childLevelMenus childLevelMenus,
hasChildLevelMenus,
getDeepestLevelMenuKey,
activeDeepestLevelMenuKey
}; };
} }

View File

@@ -2,6 +2,7 @@
import { ref, watch } from 'vue'; import { ref, watch } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { SimpleScrollbar } from '@sa/materials'; import { SimpleScrollbar } from '@sa/materials';
import type { RouteKey } from '@elegant-router/types';
import { GLOBAL_HEADER_MENU_ID, GLOBAL_SIDER_MENU_ID } from '@/constants/app'; import { GLOBAL_HEADER_MENU_ID, GLOBAL_SIDER_MENU_ID } from '@/constants/app';
import { useAppStore } from '@/store/modules/app'; import { useAppStore } from '@/store/modules/app';
import { useThemeStore } from '@/store/modules/theme'; import { useThemeStore } from '@/store/modules/theme';
@@ -18,12 +19,28 @@ const appStore = useAppStore();
const themeStore = useThemeStore(); const themeStore = useThemeStore();
const routeStore = useRouteStore(); const routeStore = useRouteStore();
const { routerPushByKeyWithMetaQuery } = useRouterPush(); const { routerPushByKeyWithMetaQuery } = useRouterPush();
const { firstLevelMenus, secondLevelMenus, activeFirstLevelMenuKey, handleSelectFirstLevelMenu } = const {
useMixMenuContext('TopHybridHeaderFirst'); firstLevelMenus,
secondLevelMenus,
activeFirstLevelMenuKey,
handleSelectFirstLevelMenu,
activeDeepestLevelMenuKey
} = useMixMenuContext('TopHybridHeaderFirst');
const { selectedKey } = useMenu(); const { selectedKey } = useMenu();
const expandedKeys = ref<string[]>([]); const expandedKeys = ref<string[]>([]);
/**
* Handle first level menu select
* @param key RouteKey
*/
function handleSelectMenu(key: RouteKey) {
handleSelectFirstLevelMenu(key);
// if there are second level menus, select the deepest one by default
activeDeepestLevelMenuKey();
}
function updateExpandedKeys() { function updateExpandedKeys() {
if (appStore.siderCollapse || !selectedKey.value) { if (appStore.siderCollapse || !selectedKey.value) {
expandedKeys.value = []; expandedKeys.value = [];
@@ -49,7 +66,7 @@ watch(
:options="firstLevelMenus" :options="firstLevelMenus"
:indent="18" :indent="18"
responsive responsive
@update:value="handleSelectFirstLevelMenu" @update:value="handleSelectMenu"
/> />
</Teleport> </Teleport>
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`"> <Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">

View File

@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { RouteKey } from '@elegant-router/types';
import { GLOBAL_HEADER_MENU_ID, GLOBAL_SIDER_MENU_ID } from '@/constants/app'; import { GLOBAL_HEADER_MENU_ID, GLOBAL_SIDER_MENU_ID } from '@/constants/app';
import { useAppStore } from '@/store/modules/app'; import { useAppStore } from '@/store/modules/app';
import { useThemeStore } from '@/store/modules/theme'; import { useThemeStore } from '@/store/modules/theme';
@@ -13,9 +14,25 @@ defineOptions({
const appStore = useAppStore(); const appStore = useAppStore();
const themeStore = useThemeStore(); const themeStore = useThemeStore();
const { routerPushByKeyWithMetaQuery } = useRouterPush(); const { routerPushByKeyWithMetaQuery } = useRouterPush();
const { firstLevelMenus, secondLevelMenus, activeFirstLevelMenuKey, handleSelectFirstLevelMenu } = const {
useMixMenuContext('TopHybridSidebarFirst'); firstLevelMenus,
secondLevelMenus,
activeFirstLevelMenuKey,
handleSelectFirstLevelMenu,
activeDeepestLevelMenuKey
} = useMixMenuContext('TopHybridSidebarFirst');
const { selectedKey } = useMenu(); const { selectedKey } = useMenu();
/**
* Handle first level menu select
* @param key RouteKey
*/
function handleSelectMenu(key: RouteKey) {
handleSelectFirstLevelMenu(key);
// if there are second level menus, select the deepest one by default
activeDeepestLevelMenuKey();
}
</script> </script>
<template> <template>
@@ -37,7 +54,7 @@ const { selectedKey } = useMenu();
:sider-collapse="appStore.siderCollapse" :sider-collapse="appStore.siderCollapse"
:dark-mode="themeStore.darkMode" :dark-mode="themeStore.darkMode"
:theme-color="themeStore.themeColor" :theme-color="themeStore.themeColor"
@select="handleSelectFirstLevelMenu" @select="handleSelectMenu"
@toggle-sider-collapse="appStore.toggleSiderCollapse" @toggle-sider-collapse="appStore.toggleSiderCollapse"
/> />
</div> </div>

View File

@@ -33,15 +33,15 @@ const {
isActiveSecondLevelMenuHasChildren, isActiveSecondLevelMenuHasChildren,
handleSelectSecondLevelMenu, handleSelectSecondLevelMenu,
getActiveSecondLevelMenuKey, getActiveSecondLevelMenuKey,
childLevelMenus childLevelMenus,
hasChildLevelMenus,
activeDeepestLevelMenuKey
} = useMixMenuContext('VerticalHybridHeaderFirst'); } = useMixMenuContext('VerticalHybridHeaderFirst');
const { selectedKey } = useMenu(); const { selectedKey } = useMenu();
const inverted = computed(() => !themeStore.darkMode && themeStore.sider.inverted); const inverted = computed(() => !themeStore.darkMode && themeStore.sider.inverted);
const hasChildMenus = computed(() => childLevelMenus.value.length > 0); const showDrawer = computed(() => hasChildLevelMenus.value && (drawerVisible.value || appStore.mixSiderFixed));
const showDrawer = computed(() => hasChildMenus.value && (drawerVisible.value || appStore.mixSiderFixed));
function handleSelectMixMenu(key: RouteKey) { function handleSelectMixMenu(key: RouteKey) {
handleSelectSecondLevelMenu(key); handleSelectSecondLevelMenu(key);
@@ -51,12 +51,33 @@ function handleSelectMixMenu(key: RouteKey) {
} }
} }
/**
* Handle second level menu selection based on autoSelectFirstMenu setting:
* - When disabled: Activate first second-level menu for display only, expand third-level menu if exists
* - When enabled: Navigate to the deepest menu automatically
*/
function handleSelectMenu(key: RouteKey) { function handleSelectMenu(key: RouteKey) {
handleSelectFirstLevelMenu(key); handleSelectFirstLevelMenu(key);
if (secondLevelMenus.value.length > 0) { if (secondLevelMenus.value.length === 0) return;
handleSelectMixMenu(secondLevelMenus.value[0].routeKey);
const secondFirstMenuKey = secondLevelMenus.value[0].routeKey;
// Case 1: autoSelectFirstMenu disabled - only activate menu for display
if (!themeStore.sider.autoSelectFirstMenu) {
// Check if there are third-level menus
const hasChildren = secondLevelMenus.value.find(menu => menu.key === secondFirstMenuKey)?.children?.length;
// If there are third-level menus, expand them
if (hasChildren) {
handleSelectMixMenu(secondFirstMenuKey);
}
return;
} }
// Case 2: autoSelectFirstMenu enabled - navigate to deepest menu
activeDeepestLevelMenuKey();
setDrawerVisible(false);
} }
function handleResetActiveMenu() { function handleResetActiveMenu() {
@@ -114,7 +135,9 @@ watch(
</FirstLevelMenu> </FirstLevelMenu>
<div <div
class="relative h-full transition-width-300" class="relative h-full transition-width-300"
:style="{ width: appStore.mixSiderFixed && hasChildMenus ? themeStore.sider.mixChildMenuWidth + 'px' : '0px' }" :style="{
width: appStore.mixSiderFixed && hasChildLevelMenus ? themeStore.sider.mixChildMenuWidth + 'px' : '0px'
}"
> >
<DarkModeContainer <DarkModeContainer
class="absolute-lt h-full flex-col-stretch nowrap-hidden shadow-sm transition-all-300" class="absolute-lt h-full flex-col-stretch nowrap-hidden shadow-sm transition-all-300"

View File

@@ -12,6 +12,7 @@ const themeStore = useThemeStore();
const layoutMode = computed(() => themeStore.layout.mode); const layoutMode = computed(() => themeStore.layout.mode);
const isMixLayoutMode = computed(() => layoutMode.value.includes('mix') || layoutMode.value.includes('hybrid')); const isMixLayoutMode = computed(() => layoutMode.value.includes('mix') || layoutMode.value.includes('hybrid'));
const isHybridLayoutMode = computed(() => layoutMode.value.includes('hybrid'));
</script> </script>
<template> <template>
@@ -32,6 +33,12 @@ const isMixLayoutMode = computed(() => layoutMode.value.includes('mix') || layou
<SettingItem v-if="layoutMode === 'vertical-mix'" key="5" :label="$t('theme.layout.sider.mixChildMenuWidth')"> <SettingItem v-if="layoutMode === 'vertical-mix'" key="5" :label="$t('theme.layout.sider.mixChildMenuWidth')">
<NInputNumber v-model:value="themeStore.sider.mixChildMenuWidth" size="small" :step="1" class="w-120px" /> <NInputNumber v-model:value="themeStore.sider.mixChildMenuWidth" size="small" :step="1" class="w-120px" />
</SettingItem> </SettingItem>
<SettingItem v-if="isHybridLayoutMode" key="6" :label="$t('theme.layout.sider.autoSelectFirstMenu')">
<template #suffix>
<IconTooltip :desc="$t('theme.layout.sider.autoSelectFirstMenuTip')" />
</template>
<NSwitch v-model:value="themeStore.sider.autoSelectFirstMenu" />
</SettingItem>
</TransitionGroup> </TransitionGroup>
</template> </template>

View File

@@ -160,7 +160,10 @@ const local: App.I18n.Schema = {
collapsedWidth: 'Sider Collapsed Width', collapsedWidth: 'Sider Collapsed Width',
mixWidth: 'Mix Sider Width', mixWidth: 'Mix Sider Width',
mixCollapsedWidth: 'Mix Sider Collapse Width', mixCollapsedWidth: 'Mix Sider Collapse Width',
mixChildMenuWidth: 'Mix Child Menu Width' mixChildMenuWidth: 'Mix Child Menu Width',
autoSelectFirstMenu: 'Auto Select First Submenu',
autoSelectFirstMenuTip:
'When a first-level menu is clicked, the first submenu is automatically selected and navigated to the deepest level'
}, },
footer: { footer: {
title: 'Footer Settings', title: 'Footer Settings',

View File

@@ -157,7 +157,9 @@ const local: App.I18n.Schema = {
collapsedWidth: '侧边栏折叠宽度', collapsedWidth: '侧边栏折叠宽度',
mixWidth: '混合布局侧边栏宽度', mixWidth: '混合布局侧边栏宽度',
mixCollapsedWidth: '混合布局侧边栏折叠宽度', mixCollapsedWidth: '混合布局侧边栏折叠宽度',
mixChildMenuWidth: '混合布局子菜单宽度' mixChildMenuWidth: '混合布局子菜单宽度',
autoSelectFirstMenu: '自动选择第一个子菜单',
autoSelectFirstMenuTip: '点击一级菜单时,自动选择并导航到第一个子菜单的最深层级'
}, },
footer: { footer: {
title: '底部设置', title: '底部设置',

View File

@@ -48,7 +48,8 @@ export const themeSettings: App.Theme.ThemeSetting = {
collapsedWidth: 64, collapsedWidth: 64,
mixWidth: 90, mixWidth: 90,
mixCollapsedWidth: 64, mixCollapsedWidth: 64,
mixChildMenuWidth: 200 mixChildMenuWidth: 200,
autoSelectFirstMenu: false
}, },
footer: { footer: {
visible: true, visible: true,

View File

@@ -96,6 +96,8 @@ declare namespace App {
mixCollapsedWidth: number; mixCollapsedWidth: number;
/** Child menu width when the layout is 'vertical-mix', 'top-hybrid-sidebar-first', or 'top-hybrid-header-first' */ /** Child menu width when the layout is 'vertical-mix', 'top-hybrid-sidebar-first', or 'top-hybrid-header-first' */
mixChildMenuWidth: number; mixChildMenuWidth: number;
/** Whether to auto select the first submenu */
autoSelectFirstMenu: boolean;
}; };
/** Footer */ /** Footer */
footer: { footer: {
@@ -429,6 +431,8 @@ declare namespace App {
mixWidth: string; mixWidth: string;
mixCollapsedWidth: string; mixCollapsedWidth: string;
mixChildMenuWidth: string; mixChildMenuWidth: string;
autoSelectFirstMenu: string;
autoSelectFirstMenuTip: string;
}; };
footer: { footer: {
title: string; title: string;