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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
<template>
<BaseLayout>
<BaseLayout :show-overlay="false">
<div v-show="!appStore.contentFullscreen" class="layout-header">
<div class="layout-header__content">
<div v-if="showLogo" class="layout-header__logo">
@@ -240,10 +240,20 @@ const useMenuColors = computed(
&.is-collapsed {
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) {
@include sidebar-scroll-height-with-toggle;
.el-scrollbar__wrap {
overflow-x: hidden !important;
}
.el-scrollbar__bar.is-horizontal {
display: none;
}
}
:deep(.el-menu) {
@@ -286,26 +296,6 @@ const useMenuColors = computed(
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.sidebar-color-blue) {
.layout-header__menu {

View File

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