feat: 支持双列布局次级菜单折叠及三档响应式断点

This commit is contained in:
Ray.Hao
2026-07-02 20:25:51 +08:00
parent c966764314
commit cdf43ea71a
9 changed files with 130 additions and 42 deletions

View File

@@ -1,7 +1,7 @@
{ {
"name": "vue3-element-admin", "name": "vue3-element-admin",
"description": "Vue3 + Vite + TypeScript + Element-Plus 的后台管理模板vue-element-admin 的 Vue3 版本", "description": "Vue3 + Vite + TypeScript + Element-Plus 的后台管理模板vue-element-admin 的 Vue3 版本",
"version": "4.7.2", "version": "4.7.3",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -1,6 +1,10 @@
<template> <template>
<div class="layout-root" :class="layoutClass"> <div class="layout-root" :class="layoutClass">
<div v-if="isMobile && isSidebarOpen" class="layout-root__overlay" @click="closeSidebar" /> <div
v-if="showOverlay && isMobile && isSidebarOpen"
class="layout-root__overlay"
@click="closeSidebar"
/>
<slot /> <slot />
</div> </div>
@@ -9,6 +13,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { useLayout } from "./composables/useLayout"; import { useLayout } from "./composables/useLayout";
withDefaults(
defineProps<{
/** 移动端展开侧边栏时是否显示遮罩层LeftLayout 需要MixLayout 不需要) */
showOverlay?: boolean;
}>(),
{
showOverlay: true,
}
);
const { layoutClass, isSidebarOpen, isMobile, closeSidebar } = useLayout(); const { layoutClass, isSidebarOpen, isMobile, closeSidebar } = useLayout();
</script> </script>

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class="layout-navbar"> <div class="layout-navbar">
<div class="flex-y-center"> <div class="flex-y-center">
<Hamburger :is-active="isSidebarOpened" @toggle-click="toggleSideBar" /> <Hamburger :is-active="sidebarState.opened" @toggle-click="sidebarState.toggle" />
<Breadcrumb /> <Breadcrumb />
</div> </div>
@@ -16,13 +16,25 @@ import { useAppStore } from "@/stores";
import Hamburger from "@/components/Hamburger/index.vue"; import Hamburger from "@/components/Hamburger/index.vue";
import Breadcrumb from "@/components/Breadcrumb/index.vue"; import Breadcrumb from "@/components/Breadcrumb/index.vue";
const props = withDefaults(
defineProps<{
toggleTarget?: "primary" | "secondary";
}>(),
{
toggleTarget: "primary",
}
);
const appStore = useAppStore(); const appStore = useAppStore();
const isSidebarOpened = computed(() => appStore.sidebar.opened); const sidebarState = computed(() =>
props.toggleTarget === "secondary"
function toggleSideBar() { ? {
appStore.toggleSidebar(); opened: appStore.secondarySidebar?.opened ?? true,
} toggle: () => appStore.toggleSecondarySidebar(),
}
: { opened: appStore.sidebar.opened, toggle: () => appStore.toggleSidebar() }
);
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@@ -2,7 +2,7 @@
<el-menu <el-menu
ref="menuRef" ref="menuRef"
:default-active="activeMenuPath" :default-active="activeMenuPath"
:collapse="props.alwaysExpand ? false : !appStore.sidebar.opened" :collapse="props.collapseOverride ?? (props.alwaysExpand ? false : !appStore.sidebar.opened)"
:background-color="menuThemeProps.backgroundColor" :background-color="menuThemeProps.backgroundColor"
:text-color="menuThemeProps.textColor" :text-color="menuThemeProps.textColor"
:active-text-color="menuThemeProps.activeTextColor" :active-text-color="menuThemeProps.activeTextColor"
@@ -52,6 +52,11 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
/** 显式覆盖 collapse 状态,优先级高于 alwaysExpand 和全局 sidebar.opened */
collapseOverride: {
type: Boolean,
default: null,
},
}); });
const menuRef = ref<MenuInstance>(); const menuRef = ref<MenuInstance>();

View File

@@ -2,23 +2,30 @@ import { useWindowSize } from "@vueuse/core";
import { useAppStore } from "@/stores"; import { useAppStore } from "@/stores";
import { DeviceEnum } from "@/enums/settings"; import { DeviceEnum } from "@/enums/settings";
const DESKTOP_BREAKPOINT = 992; const WIDE_BREAKPOINT = 992;
const MOBILE_BREAKPOINT = 768;
/** /**
* 根据窗口宽度同步设备类型和侧边栏展开状态 * 根据窗口宽度同步设备类型和侧边栏展开状态
*
* 三档断点:
* - ≥992px桌面宽屏侧边栏展开完整菜单
* - 768~992px桌面窄屏侧边栏收缩仅图标
* - <768px移动端侧边栏完全隐藏
*/ */
export function useLayoutDevice() { export function useLayoutDevice() {
const appStore = useAppStore(); const appStore = useAppStore();
const { width } = useWindowSize(); const { width } = useWindowSize();
const isDesktop = computed(() => width.value >= DESKTOP_BREAKPOINT); const isDesktop = computed(() => width.value >= MOBILE_BREAKPOINT);
const isWideDesktop = computed(() => width.value >= WIDE_BREAKPOINT);
watchEffect(() => { watchEffect(() => {
const device = isDesktop.value ? DeviceEnum.DESKTOP : DeviceEnum.MOBILE; const device = isDesktop.value ? DeviceEnum.DESKTOP : DeviceEnum.MOBILE;
appStore.toggleDevice(device); appStore.toggleDevice(device);
if (isDesktop.value) { if (isWideDesktop.value) {
appStore.openSidebar(); appStore.openSidebar();
} else { } else {
appStore.closeSidebar(); appStore.closeSidebar();

View File

@@ -3,7 +3,10 @@
<aside <aside
v-show="!appStore.contentFullscreen" v-show="!appStore.contentFullscreen"
class="layout-double" class="layout-double"
:class="{ 'is-collapsed': !isSidebarOpen }" :class="{
'is-collapsed': !isSidebarOpen,
'is-secondary-collapsed': !secondaryExpanded,
}"
> >
<div class="layout-double__primary"> <div class="layout-double__primary">
<LayoutLogo v-if="showLogo" :collapse="true" /> <LayoutLogo v-if="showLogo" :collapse="true" />
@@ -33,7 +36,7 @@
</button> </button>
</div> </div>
<div class="layout-double__secondary"> <div class="layout-double__secondary" :class="{ 'is-collapsed': !secondaryExpanded }">
<div v-if="showLogo" class="layout-double__title"> <div v-if="showLogo" class="layout-double__title">
{{ appConfig.title }} {{ appConfig.title }}
</div> </div>
@@ -42,7 +45,7 @@
<LayoutSidebar <LayoutSidebar
:data="sideMenuRoutes" :data="sideMenuRoutes"
:base-path="activeTopMenuPath" :base-path="activeTopMenuPath"
:always-expand="true" :collapse-override="!secondaryExpanded"
/> />
</el-scrollbar> </el-scrollbar>
</div> </div>
@@ -52,10 +55,11 @@
class="layout-main" class="layout-main"
:class="{ :class="{
'is-collapsed': !isSidebarOpen, 'is-collapsed': !isSidebarOpen,
'is-secondary-collapsed': !secondaryExpanded,
'is-fullscreen': appStore.contentFullscreen, 'is-fullscreen': appStore.contentFullscreen,
}" }"
> >
<LayoutNavbar v-show="!appStore.contentFullscreen" /> <LayoutNavbar v-show="!appStore.contentFullscreen" toggle-target="secondary" />
<LayoutTagsView v-if="showTagsView" /> <LayoutTagsView v-if="showTagsView" />
<LayoutMain /> <LayoutMain />
</main> </main>
@@ -79,6 +83,8 @@ import LayoutMenuIcon from "../components/LayoutMenuIcon.vue";
const appStore = useAppStore(); const appStore = useAppStore();
const { showTagsView, showLogo, isSidebarOpen, toggleSidebar } = useLayout(); const { showTagsView, showLogo, isSidebarOpen, toggleSidebar } = useLayout();
const secondaryExpanded = computed(() => appStore.secondarySidebar?.opened ?? true);
const { topMenuItems, activeTopMenuPath, sideMenuRoutes, handleTopMenuSelect } = useMixMenu(); const { topMenuItems, activeTopMenuPath, sideMenuRoutes, handleTopMenuSelect } = useMixMenu();
</script> </script>
@@ -115,13 +121,24 @@ $double-sidebar-width: $sidebar-primary-width + $sidebar-secondary-width;
display: none; display: none;
} }
} }
}
&.is-secondary-collapsed {
width: $sidebar-primary-width + $sidebar-width-collapsed;
.layout-double__secondary { .layout-double__secondary {
flex: 0 0 $sidebar-secondary-width; flex: 0 0 $sidebar-width-collapsed;
width: $sidebar-secondary-width; width: $sidebar-width-collapsed;
min-width: $sidebar-secondary-width; min-width: $sidebar-width-collapsed;
border-right: 1px solid var(--menu-border);
} }
.layout-double__title {
display: none;
}
}
&.is-collapsed.is-secondary-collapsed {
width: $sidebar-width-collapsed * 2;
} }
&__primary { &__primary {
@@ -263,6 +280,21 @@ $double-sidebar-width: $sidebar-primary-width + $sidebar-secondary-width;
:deep(.el-sub-menu__icon-arrow) { :deep(.el-sub-menu__icon-arrow) {
display: inline-flex !important; display: inline-flex !important;
} }
&.is-collapsed {
width: $sidebar-width-collapsed;
:deep(.el-menu--collapse) {
.el-menu-item span,
.el-sub-menu__title span {
display: none !important;
}
.el-sub-menu__icon-arrow {
display: none !important;
}
}
}
} }
&__secondary-scroll { &__secondary-scroll {
@@ -294,6 +326,14 @@ $double-sidebar-width: $sidebar-primary-width + $sidebar-secondary-width;
margin-left: $sidebar-width-collapsed + $sidebar-secondary-width; margin-left: $sidebar-width-collapsed + $sidebar-secondary-width;
} }
&.is-secondary-collapsed {
margin-left: $sidebar-primary-width + $sidebar-width-collapsed;
&.is-collapsed {
margin-left: $sidebar-width-collapsed + $sidebar-width-collapsed;
}
}
&.is-fullscreen { &.is-fullscreen {
margin-left: 0 !important; margin-left: 0 !important;
} }

View File

@@ -1,5 +1,5 @@
<template> <template>
<BaseLayout> <BaseLayout :show-overlay="false">
<div v-show="!appStore.contentFullscreen" class="layout-header"> <div v-show="!appStore.contentFullscreen" class="layout-header">
<div class="layout-header__content"> <div class="layout-header__content">
<div v-if="showLogo" class="layout-header__logo"> <div v-if="showLogo" class="layout-header__logo">
@@ -240,10 +240,20 @@ const useMenuColors = computed(
&.is-collapsed { &.is-collapsed {
width: $sidebar-width-collapsed !important; width: $sidebar-width-collapsed !important;
min-width: 0; // 覆盖 flex 子元素默认 min-width: auto防止 .el-menu--collapse 64px 推挤容器
overflow: hidden; // 裁剪 .el-menu--collapse 溢出,防止其可见溢出推挤 .layout-container 宽度
} }
:deep(.el-scrollbar) { :deep(.el-scrollbar) {
@include sidebar-scroll-height-with-toggle; @include sidebar-scroll-height-with-toggle;
.el-scrollbar__wrap {
overflow-x: hidden !important;
}
.el-scrollbar__bar.is-horizontal {
display: none;
}
} }
:deep(.el-menu) { :deep(.el-menu) {
@@ -286,26 +296,6 @@ const useMenuColors = computed(
overflow-y: auto; overflow-y: auto;
} }
:deep(.is-mobile) {
.layout-container {
.layout-sidebar {
position: fixed;
top: $navbar-height;
bottom: 0;
left: 0;
z-index: 1000;
transition: transform 0.28s;
}
}
&.is-sidebar-collapsed {
.layout-sidebar {
width: $sidebar-width !important;
transform: translateX(-$sidebar-width);
}
}
}
:global(html.dark), :global(html.dark),
:global(html.sidebar-color-blue) { :global(html.sidebar-color-blue) {
.layout-header__menu { .layout-header__menu {

View File

@@ -34,6 +34,13 @@ export const useAppStore = defineStore("app", () => {
withoutAnimation: false, withoutAnimation: false,
}); });
/**
* 双列布局第二列(次级菜单)展开状态
*/
const secondarySidebar = reactive({
opened: true,
});
/** /**
* 当前激活的顶部菜单路径 * 当前激活的顶部菜单路径
*/ */
@@ -73,6 +80,13 @@ export const useAppStore = defineStore("app", () => {
sidebarStatus.value = SidebarStatus.OPENED; sidebarStatus.value = SidebarStatus.OPENED;
} }
/**
* 切换第二列(次级菜单)展开状态
*/
function toggleSecondarySidebar() {
secondarySidebar.opened = !secondarySidebar.opened;
}
/** /**
* 切换设备类型 * 切换设备类型
*/ */
@@ -111,6 +125,7 @@ export const useAppStore = defineStore("app", () => {
return { return {
device, device,
sidebar, sidebar,
secondarySidebar,
language, language,
locale, locale,
size, size,
@@ -121,6 +136,7 @@ export const useAppStore = defineStore("app", () => {
toggleSidebar, toggleSidebar,
closeSidebar, closeSidebar,
openSidebar, openSidebar,
toggleSecondarySidebar,
setActiveTopMenuPath, setActiveTopMenuPath,
toggleContentFullscreen, toggleContentFullscreen,
activeTopMenuPath, activeTopMenuPath,

View File

@@ -124,7 +124,11 @@
} }
} }
html.dark .layout-root, /* 隐藏侧边栏 el-scrollbar 的水平滚动条el-scrollbar 的 wrap 宽度因滚动条 gutter 比内容少 1px导致 .el-menu--collapse 始终溢出) */
.layout-sidebar .el-scrollbar__bar.is-horizontal {
display: none !important;
}
html.sidebar-color-blue .layout-root, html.sidebar-color-blue .layout-root,
html.dark .el-menu--popup, html.dark .el-menu--popup,
html.sidebar-color-blue .el-menu--popup { html.sidebar-color-blue .el-menu--popup {