mirror of
https://github.com/soybeanjs/soybean-admin.git
synced 2025-12-06 00:06:01 +08:00
feat(projects): hybrid layout mode auto select first deepest child menu
This commit is contained in:
@@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}`">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -157,7 +157,9 @@ const local: App.I18n.Schema = {
|
|||||||
collapsedWidth: '侧边栏折叠宽度',
|
collapsedWidth: '侧边栏折叠宽度',
|
||||||
mixWidth: '混合布局侧边栏宽度',
|
mixWidth: '混合布局侧边栏宽度',
|
||||||
mixCollapsedWidth: '混合布局侧边栏折叠宽度',
|
mixCollapsedWidth: '混合布局侧边栏折叠宽度',
|
||||||
mixChildMenuWidth: '混合布局子菜单宽度'
|
mixChildMenuWidth: '混合布局子菜单宽度',
|
||||||
|
autoSelectFirstMenu: '自动选择第一个子菜单',
|
||||||
|
autoSelectFirstMenuTip: '点击一级菜单时,自动选择并导航到第一个子菜单的最深层级'
|
||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
title: '底部设置',
|
title: '底部设置',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
4
src/typings/app.d.ts
vendored
4
src/typings/app.d.ts
vendored
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user