#菜单导航设置加入企业最常用的 左侧双列布局

This commit is contained in:
duc 2026-07-14 00:23:44 +08:00
parent 5f837700e9
commit 82e7eec628
7 changed files with 609 additions and 7 deletions

View File

@ -13,6 +13,7 @@
@use './layout/sidebar/collapsed';
@use './layout/sidebar/responsive';
@use './layout/sidebar/popper' as sidebar-popper;
@use './layout/sidebar/dual' as sidebar-dual;
@use './components/legacy-utilities';
@use './components/misc';
@use './components/page-shell';

View File

@ -0,0 +1,101 @@
/* 双列侧栏:第一列折叠一级菜单图标 + 第二列展开子菜单;覆盖 sidebar-container 宽度与 main-container 左边距。 */
@use '../../tokens/sass-vars' as *;
// 双列总宽度 = 折叠列(58px) + 间距(8px) + 展开列(200px)
$dual-sidebar-width: 58px + 8px + $base-sidebar-width;
// 仅第一列宽度
$dual-first-width: 58px;
#app {
// 双列模式始终覆盖 sidebar-shell padding防止全局 .hideSidebar 样式干扰
.dualSidebar .sidebar-container .sidebar-shell {
padding: 0 !important;
}
// 双列展开状态两列都显示
.dualSidebar:not(.hideSidebar) {
.sidebar-container {
width: $dual-sidebar-width !important;
}
.main-container {
margin-left: calc(#{$dual-sidebar-width} + 12px) !important;
}
// 固定头部宽度适配双列侧栏
.fixed-header {
width: calc(100% - #{$dual-sidebar-width} - 12px) !important;
}
}
// 双列展开状态但第二列无子菜单仅显示第一列宽度
.dualSidebar:not(.hideSidebar) .sidebar-container.dual-no-second {
width: $dual-first-width !important;
// 隐藏第二列
.dual-sidebar-second {
display: none !important;
}
// 第一列无右边框
.dual-sidebar-first {
border-right: none;
}
}
// 第二列隐藏时main-container 左边距收窄
.dualSidebar:not(.hideSidebar) .sidebar-container.dual-no-second ~ .main-container {
margin-left: calc(#{$dual-first-width} + 12px) !important;
}
// 第二列隐藏时fixed-header 宽度调整
.dualSidebar:not(.hideSidebar) .sidebar-container.dual-no-second ~ .main-container .fixed-header {
width: calc(100% - #{$dual-first-width} - 12px) !important;
}
// 双列折叠状态汉堡按钮折叠仅显示第一列图标列
.dualSidebar.hideSidebar:not(.mobile) {
.sidebar-container {
width: $dual-first-width !important;
}
.main-container {
margin-left: 70px !important;
}
.fixed-header {
width: calc(100% - 70px) !important;
}
// 隐藏第二列
.dual-sidebar-second {
display: none !important;
}
// 折叠时第一列无右边框
.dual-sidebar-first {
border-right: none;
}
// 折叠时隐藏 Logo 文字仅显示图标
.dual-sidebar-logo {
.sidebar-title {
display: none !important;
}
}
}
// 移动端双列模式
.mobile.dualSidebar {
.sidebar-container {
width: $dual-sidebar-width !important;
}
&.hideSidebar .sidebar-container {
transition: transform 0.28s;
pointer-events: none;
transform: translate3d(calc(-#{$dual-sidebar-width} - 12px), 0, 0);
}
}
}

View File

@ -13,5 +13,9 @@ export enum NavTypeEnum {
/**
*
*/
MIX = 'mix'
MIX = 'mix',
/**
*
*/
DUAL = 'dual'
}

View File

@ -1,7 +1,7 @@
<template>
<div class="navbar" :class="'nav' + navType">
<div class="navbar-left">
<div v-if="navType !== NavTypeEnum.TOP" class="hamburger-shell">
<div v-if="navType !== NavTypeEnum.TOP && navType !== NavTypeEnum.DUAL" class="hamburger-shell">
<hamburger
id="hamburger-container"
:is-active="appStore.sidebar.opened"
@ -9,12 +9,12 @@
@toggle-click="toggleSideBar"
/>
</div>
<router-link v-else-if="showLogo" to="/" class="navtop-logo-shell">
<router-link v-else-if="showLogo && navType !== NavTypeEnum.DUAL" to="/" class="navtop-logo-shell">
<img :src="appLogo" class="navtop-logo-icon" alt="logo" />
</router-link>
<div class="nav-context">
<breadcrumb v-if="navType == NavTypeEnum.LEFT" id="breadcrumb-container" class="breadcrumb-container" />
<breadcrumb v-if="navType == NavTypeEnum.LEFT || navType == NavTypeEnum.DUAL" id="breadcrumb-container" class="breadcrumb-container" />
<top-nav v-if="navType == NavTypeEnum.MIX" id="topmenu-container" class="topmenu-container" />
<template v-if="navType == NavTypeEnum.TOP">

View File

@ -43,6 +43,17 @@
<b></b>
</div>
</el-tooltip>
<el-tooltip content="双列菜单" placement="bottom">
<div
class="item dual"
@click="handleNavType(NavTypeEnum.DUAL)"
:style="{ '--theme': theme }"
:class="{ activeItem: navType == NavTypeEnum.DUAL }"
>
<b></b>
<b></b>
</div>
</el-tooltip>
</div>
<h3 class="drawer-title">主题风格设置</h3>
@ -218,6 +229,12 @@ watch(
permissionStore.setSidebarRouters(permissionStore.defaultRoutes as any);
} else if (val === NavTypeEnum.MIX) {
appStore.toggleSideBarHide(false);
} else if (val === NavTypeEnum.DUAL) {
appStore.toggleSideBarHide(false);
//
if (!appStore.sidebar.opened) {
appStore.toggleSideBar(false);
}
}
},
{ immediate: true }
@ -447,5 +464,26 @@ defineExpose({
display: none;
}
}
.dual {
b:first-child {
width: 25%;
background: #1b2a47;
position: absolute;
height: 100%;
top: 0;
border-radius: 4px 0 0 4px;
}
b:last-child {
width: 40%;
background: #1b2a47;
position: absolute;
height: 100%;
top: 0;
left: 30%;
border-radius: 0 4px 4px 0;
}
}
}
</style>

View File

@ -0,0 +1,455 @@
<template>
<div
class="sidebar-shell dual-sidebar-shell"
:class="{ 'dual-no-second': childMenus.length === 0 }"
:style="menuStyle"
>
<!-- Logo横跨两列放在最上方 -->
<div class="dual-sidebar-logo" v-if="showLogo">
<logo :collapse="false" />
</div>
<!-- 主体第一列 + 第二列 并排 -->
<div class="dual-sidebar-body">
<!-- 第一列折叠的一级菜单仅图标 -->
<div class="dual-sidebar-first">
<el-scrollbar :class="sideTheme" wrap-class="scrollbar-wrapper">
<el-menu
:default-active="selectedTopPath"
:collapse="true"
:collapse-transition="false"
:unique-opened="true"
mode="vertical"
@select="handleTopSelect"
>
<template v-for="(r, index) in topMenus" :key="r.path + index">
<el-menu-item v-if="!r.hidden" :index="r.path">
<svg-icon :icon-class="getMenuIcon(r.meta?.icon)" />
<template #title>
<span>{{ r.meta?.title }}</span>
</template>
</el-menu-item>
</template>
</el-menu>
</el-scrollbar>
</div>
<!-- 第二列展开的子菜单动态显示/隐藏 -->
<div class="dual-sidebar-second" v-if="childMenus.length > 0">
<el-scrollbar :class="sideTheme" wrap-class="scrollbar-wrapper">
<el-menu
:default-active="activeMenu"
:collapse="false"
:unique-opened="true"
:collapse-transition="false"
:popper-offset="12"
mode="vertical"
>
<sidebar-item
v-for="(r, index) in childMenus"
:key="r.path + index"
:item="r"
:base-path="resolveChildBasePath(r.path)"
/>
</el-menu>
</el-scrollbar>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { RouteRecordRaw } from 'vue-router';
import { getNormalPath } from '@/utils/ruoyi';
import { usePermissionStore } from '@/store/modules/permission';
import { useSettingsStore } from '@/store/modules/settings';
import { isHttp } from '@/utils/validate';
import Logo from './Logo.vue';
import SidebarItem from './SidebarItem.vue';
const route = useRoute();
const router = useRouter();
const settingsStore = useSettingsStore();
const permissionStore = usePermissionStore();
const showLogo = computed(() => settingsStore.sidebarLogo);
const sideTheme = computed(() => settingsStore.sideTheme);
const theme = computed(() => settingsStore.theme);
// +
const allRoutes = computed<RouteRecordRaw[]>(() => permissionStore.getDefaultRoutes());
//
const topMenus = computed<RouteRecordRaw[]>(() => {
const menus: RouteRecordRaw[] = [];
allRoutes.value.forEach(menu => {
if (menu.hidden !== true) {
// path '' '/'
if ((menu.path === '/' || menu.path === '') && menu.children && menu.children.length > 0) {
const child = { ...menu.children[0] };
//
if (child.path && !child.path.startsWith('/')) {
child.path = '/' + child.path;
}
menus.push(child);
} else {
menus.push(menu);
}
}
});
return menus;
});
//
const activeTopPath = computed(() => {
const currentPath = route.meta.activeMenu || route.path;
for (const menu of topMenus.value) {
if (!menu.path) continue;
if (currentPath === menu.path || currentPath.startsWith(menu.path + '/')) {
return menu.path;
}
}
//
return topMenus.value[0]?.path || '';
});
//
const selectedTopPath = ref('');
watch(
activeTopPath,
val => {
selectedTopPath.value = val;
},
{ immediate: true }
);
//
const childMenus = computed<RouteRecordRaw[]>(() => {
const menu = topMenus.value.find(m => m.path === selectedTopPath.value);
if (!menu || !menu.children) return [];
return menu.children.filter(c => !c.hidden);
});
//
const activeMenu = computed(() => {
const { meta, path } = route;
if (meta.activeMenu) {
return meta.activeMenu;
}
return path;
});
//
const handleTopSelect = (key: string) => {
const menu = topMenus.value.find(m => m.path === key);
// selectedTopPath
selectedTopPath.value = key;
if (isHttp(key)) {
window.open(key, '_blank');
return;
}
if (!menu || !menu.children || menu.children.length === 0) {
// childMenus
router.push({ path: key });
return;
}
// selectedTopPath
};
// base-pathselectedTopPath + '/' + childPath
// SidebarItem
const resolveChildBasePath = (childPath: string): string => {
return getNormalPath(selectedTopPath.value + '/' + childPath);
};
//
const getMenuIcon = (icon?: string): string => {
if (!icon || icon === '#' || icon === '') {
return 'component';
}
return icon;
};
//
const bgColor = computed(() => (sideTheme.value === 'theme-dark' ? '#111827' : '#ffffff'));
const textColor = computed(() => (sideTheme.value === 'theme-dark' ? '#e5edf8' : '#1f2937'));
const menuStyle = computed(() => ({
backgroundColor: bgColor.value,
'--el-menu-bg-color': bgColor.value,
'--el-menu-text-color': textColor.value,
'--el-menu-active-color': theme.value
}));
</script>
<style lang="scss" scoped>
.dual-sidebar-shell {
height: 100%;
display: flex;
flex-direction: column;
gap: 0;
padding: 0 !important;
border: 1px solid var(--app-sidebar-border);
border-radius: var(--app-radius-base);
box-shadow: var(--app-shadow-sm);
background: v-bind(bgColor) !important;
overflow: hidden;
}
/* Logo 区域:横跨两列 */
.dual-sidebar-logo {
flex-shrink: 0;
padding: 10px 8px 0;
box-sizing: border-box;
:deep(.sidebar-logo-container) {
margin-top: 0;
}
}
/* 主体:两列并排 */
.dual-sidebar-body {
flex: 1;
display: flex;
flex-direction: row;
min-height: 0;
}
/* 第一列:折叠侧栏 */
.dual-sidebar-first {
width: 58px;
flex-shrink: 0;
display: flex;
flex-direction: column;
padding: 0 4px 12px;
border-right: 1px solid var(--app-sidebar-border);
box-sizing: border-box;
:deep(.el-scrollbar) {
flex: 1;
min-height: 0;
height: auto !important;
}
:deep(.el-scrollbar__view) {
min-height: 0;
padding-bottom: 12px;
/* 距顶部 12px确保不被滚动容器吞掉 */
margin-top: 12px !important;
}
:deep(.el-scrollbar__wrap) {
height: 100%;
overflow-x: hidden;
}
:deep(.el-menu) {
border: none;
background: transparent !important;
width: 100% !important;
}
/* el-menu-item 折叠模式:固定 40x40 居中,间距 12px */
:deep(.el-menu-item) {
width: 40px !important;
min-width: 40px !important;
height: 40px !important;
/* 6px 上下边距 = 12px 间距 */
margin: 6px auto !important;
padding: 0 !important;
border-radius: var(--app-radius-md);
display: flex !important;
align-items: center !important;
justify-content: center !important;
box-sizing: border-box;
background: transparent !important;
color: var(--side-menu-text) !important;
line-height: 1 !important;
text-align: center !important;
&:not(.is-active):hover {
background-color: var(--side-menu-hover-bg) !important;
color: var(--side-menu-hover-text) !important;
}
&.is-active {
background: var(--side-menu-active-bg) !important;
color: var(--side-menu-active-text) !important;
box-shadow: inset 3px 0 0 var(--side-menu-active-line);
}
}
/* tooltip 触发器:撑满父容器并居中 */
:deep(.el-menu-item .el-menu-tooltip__trigger) {
width: 100% !important;
height: 100% !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
line-height: 1 !important;
padding: 0 !important;
}
/* svg 图标:去除全局 margin-right确保居中 */
:deep(.el-menu-item .svg-icon) {
margin: 0 !important;
margin-right: 0 !important;
width: 16px !important;
height: 16px !important;
flex-shrink: 0;
vertical-align: middle !important;
margin-top: 12px !important;
}
/* 折叠菜单中隐藏文字和箭头 */
:deep(.el-menu--collapse) {
.el-menu-item > span {
height: 0 !important;
width: 0 !important;
overflow: hidden !important;
visibility: hidden !important;
display: inline-block !important;
}
.el-menu-item .el-sub-menu__icon-arrow {
display: none !important;
}
}
}
/* 第二列:展开侧栏 */
.dual-sidebar-second {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
padding: 10px 8px 12px;
box-sizing: border-box;
:deep(.el-scrollbar) {
flex: 1;
min-height: 0;
height: auto !important;
}
:deep(.el-scrollbar__view) {
min-height: 0;
padding-bottom: 12px;
}
:deep(.el-scrollbar__wrap) {
height: 100%;
overflow-x: hidden;
}
:deep(.el-menu) {
border: none;
height: 100%;
width: 100% !important;
background: transparent !important;
}
:deep(.el-menu--inline) {
background: transparent !important;
}
:deep(.el-menu-item),
:deep(.menu-title) {
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: nowrap !important;
}
:deep(.el-menu-item),
:deep(.el-sub-menu__title),
:deep(.el-menu-item .svg-icon),
:deep(.el-sub-menu__title .svg-icon),
:deep(.el-sub-menu__icon-arrow) {
color: var(--side-menu-text) !important;
fill: currentColor;
}
:deep(.el-menu-item),
:deep(.el-sub-menu__title) {
position: relative;
font-weight: 500;
}
/* 菜单项样式 */
:deep(.submenu-title-noDropdown),
:deep(.el-sub-menu__title) {
border-radius: var(--app-radius-md);
margin: 3px 8px;
height: 40px;
color: var(--side-menu-text) !important;
&:hover {
background-color: var(--side-menu-hover-bg) !important;
color: var(--side-menu-hover-text) !important;
}
}
:deep(.nest-menu .el-sub-menu > .el-sub-menu__title),
:deep(.el-sub-menu .el-menu-item) {
min-width: calc(100% - 16px) !important;
border-radius: var(--app-radius-md);
height: 40px;
margin: 3px 8px;
background: transparent !important;
&.is-active {
background: var(--side-menu-active-bg) !important;
color: var(--side-menu-active-text) !important;
box-shadow:
inset 3px 0 0 var(--side-menu-active-line),
inset 0 0 0 1px var(--side-menu-active-border);
}
&:not(.is-active):hover {
background-color: var(--side-menu-hover-bg) !important;
color: var(--side-menu-hover-text) !important;
}
}
:deep(.el-menu-item) {
border-radius: var(--app-radius-md);
height: 40px;
margin: 3px 8px;
background: transparent !important;
&.is-active {
background: var(--side-menu-active-bg) !important;
color: var(--side-menu-active-text) !important;
box-shadow:
inset 3px 0 0 var(--side-menu-active-line),
inset 0 0 0 1px var(--side-menu-active-border);
}
&:not(.is-active):hover {
background-color: var(--side-menu-hover-bg) !important;
color: var(--side-menu-hover-text) !important;
}
}
}
/* 侧边栏主题色变量 */
:deep(.el-scrollbar.theme-light) {
--side-menu-text: #1f2937;
--side-menu-hover-bg: rgba(64, 158, 255, 0.08);
--side-menu-hover-text: #111827;
--side-menu-active-bg: rgba(64, 158, 255, 0.12);
--side-menu-active-text: #409eff;
--side-menu-active-border: rgba(64, 158, 255, 0.16);
--side-menu-active-line: #409eff;
}
:deep(.el-scrollbar.theme-dark) {
--side-menu-text: #e5edf8;
--side-menu-hover-bg: rgba(255, 255, 255, 0.08);
--side-menu-hover-text: #ffffff;
--side-menu-active-bg: rgba(64, 158, 255, 0.22);
--side-menu-active-text: #ffffff;
--side-menu-active-border: rgba(96, 165, 250, 0.24);
--side-menu-active-line: #60a5fa;
}
</style>

View File

@ -1,7 +1,8 @@
<template>
<div :class="classObj" class="app-wrapper" :style="{ '--current-color': theme }">
<div v-if="device === 'mobile' && sidebar.opened" class="drawer-bg" @click="handleClickOutside" />
<side-bar v-if="showSidebar" class="sidebar-container" />
<dual-side-bar v-if="showSidebar && layout === NavTypeEnum.DUAL" class="sidebar-container" />
<side-bar v-else-if="showSidebar" class="sidebar-container" />
<div :class="{ hasTagsView: needTagsView, sidebarHide: sidebar.hide }" class="main-container">
<div :class="{ 'fixed-header': fixedHeader }" class="layout-header">
<navbar @set-layout="setLayout" />
@ -19,6 +20,7 @@ import { useAppStore } from '@/store/modules/app';
import { useSettingsStore } from '@/store/modules/settings';
import { initMessageBox, initPush } from '@/utils/push';
import { AppMain, Navbar, Settings, TagsView } from './components';
import DualSideBar from './components/Sidebar/DualSidebar.vue';
import SideBar from './components/Sidebar/index.vue';
const settingsStore = useSettingsStore();
@ -32,14 +34,15 @@ const layout = computed(() => settingsStore.navType);
//
const showSidebar = computed(() => {
if (sidebar.value.hide) return false;
return layout.value === NavTypeEnum.LEFT || layout.value === NavTypeEnum.MIX;
return layout.value === NavTypeEnum.LEFT || layout.value === NavTypeEnum.MIX || layout.value === NavTypeEnum.DUAL;
});
const classObj = computed(() => ({
hideSidebar: !sidebar.value.opened,
openSidebar: sidebar.value.opened,
withoutAnimation: sidebar.value.withoutAnimation,
mobile: device.value === 'mobile'
mobile: device.value === 'mobile',
dualSidebar: layout.value === NavTypeEnum.DUAL
}));
const { width } = useWindowSize();