feat(MainLayout): 更新主布局菜单导航组件 - Navbar/CascaderMenu 增强级联菜单交互 - Sider 更新侧边栏,新增 SideMenuItem 组件 - 修复菜单激活状态逻辑笑笑 authored at 2026-05-05 22:32:58
diff --git a/core/layouts/MainLayout/index.vue b/core/layouts/MainLayout/index.vue
index e18a366..5d5fb9f 100644
--- a/core/layouts/MainLayout/index.vue
+++ b/core/layouts/MainLayout/index.vue
@@ -1,4 +1,4 @@
-<script setup lang="ts">
+<script setup lang="ts">
import { ref } from 'vue';
import Navbar from './Navbar/index.vue';
import Content from './Content/index.vue';
@@ -9,7 +9,7 @@ const userStore = useUserStore();
const collapsed = ref(false);
const navbarHeight = 56;
-const menuWidth = collapsed.value ? 48 : 120;
+const menuWidth = collapsed.value ? 64 : 240;
const paddingLeft = { paddingLeft: menuWidth + 'px' };
const paddingTop = { paddingTop: navbarHeight + 'px' };
@@ -19,7 +19,7 @@ const paddingStyle = { ...paddingLeft, ...paddingTop };
<template>
<div class="layout">
<div class="layoutNavbar">
- <Navbar :currentUser="userStore.userInfo" :logout="userStore.logout" :collapsed="false" />
+ <Navbar :currentUser="userStore.userInfo" :logout="userStore.logout" :collapsed="collapsed" />
</div>
<div class="layoutMain">
<div class="layoutSidebar" :style="{ ...paddingTop, width: menuWidth + 'px' }">
@@ -27,9 +27,6 @@ const paddingStyle = { ...paddingLeft, ...paddingTop };
</div>
<div class="layoutContent" :style="paddingStyle">
<div class="layoutContentWrapper">
- <!-- <div v-if="breadcrumb.length > 0" class="layoutBreadcrumb">
- <Breadcrumb></Breadcrumb>
- </div> -->
<Content>
<slot></slot>
</Content>
@@ -46,7 +43,11 @@ $layout-max-width: 1100px;
.layout {
width: 100%;
height: 100%;
- background-color: #e9f6fe;
+ min-height: 100vh;
+ background:
+ radial-gradient(ellipse 70% 50% at 15% 5%, rgba(219, 234, 254, 0.35) 0%, transparent 55%),
+ radial-gradient(ellipse 50% 40% at 85% 95%, rgba(239, 246, 255, 0.25) 0%, transparent 55%),
+ linear-gradient(180deg, #faf8f5 0%, #f5f0eb 50%, #faf8f5 100%);
}
.layoutNavbar {
@@ -57,10 +58,6 @@ $layout-max-width: 1100px;
left: 0;
height: $nav-size-height;
z-index: 100;
-
- &-hidden {
- height: 0;
- }
}
.layoutMain {
@@ -71,76 +68,31 @@ $layout-max-width: 1100px;
left: 0;
z-index: 99;
box-sizing: border-box;
-
- ::-webkit-scrollbar {
- width: 12px;
- height: 4px;
- }
-
- ::-webkit-scrollbar-thumb {
- border: 4px solid transparent;
- background-clip: padding-box;
- border-radius: 7px;
- background-color: var(--color-text-4);
- }
-
- ::-webkit-scrollbar-thumb:hover {
- background-color: var(--color-text-3);
- }
-
- &::after {
- content: '';
- display: block;
- position: absolute;
- top: 0;
- right: -1px;
- width: 1px;
- height: 100%;
- background-color: var(--color-border);
- }
-
- .collapseBtn {
- height: 24px;
- width: 24px;
- background-color: var(--color-fill-1);
- color: var(--color-text-3);
- border-radius: 2px;
- cursor: pointer;
- display: flex;
- justify-content: center;
- align-items: center;
- // 位置
- position: absolute;
- bottom: 12px;
- right: 12px;
-
- &:hover {
- background-color: var(--color-fill-3);
- }
- }
+ transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.layoutContent {
- background-color: #e9f6fe;
min-width: $layout-max-width;
min-height: 100vh;
height: 100vh;
- transition: padding-left 0.2s;
+ transition: padding-left 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-sizing: border-box;
- padding-right: 5px;
- padding-bottom: 5px;
+ padding-right: 12px;
+ padding-bottom: 12px;
}
.layoutContentWrapper {
height: 100%;
- background-color: #fff;
- border-radius: 14px;
- padding: 16px 6px 0 16px;
+ background: rgba(255, 255, 255, 0.78);
+ backdrop-filter: blur(12px);
+ -webkit-backdrop-filter: blur(12px);
+ border-radius: 16px;
+ padding: 24px 16px 0 24px;
box-sizing: border-box;
- }
-
- .layoutBreadcrumb {
- margin-bottom: 16px;
+ border: 1px solid rgba(255, 255, 255, 0.55);
+ box-shadow:
+ 0 4px 24px rgba(0, 0, 0, 0.03),
+ 0 1px 3px rgba(0, 0, 0, 0.02);
}
}
</style>
diff --git a/core/layouts/MainLayout/Navbar/CascaderMenu/components/SecondCascaderMenu.vue b/core/layouts/MainLayout/Navbar/CascaderMenu/components/SecondCascaderMenu.vue
index 0be2f4e..7ef0e8d 100644
--- a/core/layouts/MainLayout/Navbar/CascaderMenu/components/SecondCascaderMenu.vue
+++ b/core/layouts/MainLayout/Navbar/CascaderMenu/components/SecondCascaderMenu.vue
@@ -1,69 +1,64 @@
-<template>
- <div class="menu-cascader-second">
- <div :class="['menu-cascader-second-title']" :style="{ width }">
- {{ renderMenuTitle(menu) }}
- </div>
- <div class="menu-cascader-second-item-wrap" :style="{ width }">
- <SecondCascaderMenuItem
- v-for="leaf in menu.children || []"
- :key="leaf.id"
- :menu="leaf"
- :active-menu="activeMenu"
- :on-menu-click="onMenuClick || ((menu) => {})"
- />
- </div>
- </div>
-</template>
-
<script setup lang="ts">
import { type TreeMenuItem } from '@core/stores/menu';
import { renderMenuTitle } from '@core/utils/menuHelpers';
import SecondCascaderMenuItem from './SecondCascaderMenuItem.vue';
import { computed } from 'vue';
-/**
- * 二级层叠菜单组件Props接口定义
- */
interface SecondCascaderMenuProps {
- /** 菜单项 */
menu: TreeMenuItem;
- /** 宽度 */
width: string;
- /** 单行菜单项数量 */
menuItemColumns: number;
- /** 激活菜单项 */
activeMenu?: TreeMenuItem;
- /** 菜单点击回调函数 */
onMenuClick?: (menu: TreeMenuItem) => void;
}
const props = defineProps<SecondCascaderMenuProps>();
-
const menuItemColumns = computed(() => props.menuItemColumns);
</script>
+<template>
+ <div class="cascader-second">
+ <div class="cascader-second-title" :style="{ width }">
+ <span class="title-text">{{ renderMenuTitle(menu) }}</span>
+ </div>
+ <div class="cascader-second-items" :style="{ width }">
+ <SecondCascaderMenuItem
+ v-for="leaf in menu.children || []"
+ :key="leaf.id"
+ :menu="leaf"
+ :active-menu="activeMenu"
+ :on-menu-click="onMenuClick || ((menu) => {})"
+ />
+ </div>
+ </div>
+</template>
+
<style lang="scss" scoped>
-.menu-cascader-second {
- padding-bottom: 16px;
+.cascader-second {
+ padding-bottom: 20px;
- .menu-cascader-second-title {
- letter-spacing: 0;
- height: 40px;
- margin-left: 16px;
- padding-left: 20px;
- color: #333;
+ .cascader-second-title {
+ display: flex;
+ align-items: center;
+ height: 36px;
+ margin-left: 12px;
+ padding-left: 16px;
+ border-bottom: 1px solid #f0ece6;
+ overflow: hidden;
+ }
+
+ .title-text {
+ font-size: 14px;
font-weight: 700;
- border-bottom: 1px solid #f1f1f1;
- font-size: 16px;
- line-height: 40px;
+ color: #1e293b;
+ letter-spacing: -0.01em;
+ white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
- word-break: break-all;
- white-space: nowrap;
}
- .menu-cascader-second-item-wrap {
- margin-top: 8px;
+ .cascader-second-items {
+ margin-top: 6px;
display: grid;
grid-template-columns: repeat(v-bind(menuItemColumns), 1fr);
width: 100%;
diff --git a/core/layouts/MainLayout/Navbar/CascaderMenu/components/SecondCascaderMenuItem.vue b/core/layouts/MainLayout/Navbar/CascaderMenu/components/SecondCascaderMenuItem.vue
index 0aa77c2..a807b69 100644
--- a/core/layouts/MainLayout/Navbar/CascaderMenu/components/SecondCascaderMenuItem.vue
+++ b/core/layouts/MainLayout/Navbar/CascaderMenu/components/SecondCascaderMenuItem.vue
@@ -1,40 +1,16 @@
-<template>
- <div
- :class="[
- 'menu-cascader-second-item',
- {
- 'menu-cascader-second-item-active': isChildMenu(activeMenu, menu),
- },
- ]"
- @click="handleMenuClick"
- >
- <TextOverflow :text="renderMenuTitle(menu)" tooltip-placement="right" class="menu-title-text" />
- </div>
-</template>
-
<script setup lang="ts">
import { type TreeMenuItem } from '@core/stores/menu';
import { isChildMenu, renderMenuTitle } from '@core/utils/menuHelpers';
import TextOverflow from '@core/components/TextOverflow.vue';
-/**
- * 二级层叠菜单项组件Props接口定义
- */
interface SecondCascaderMenuItemProps {
- /** 菜单项 */
menu: TreeMenuItem;
- /** 激活菜单项 */
activeMenu?: TreeMenuItem;
- /** 菜单点击回调函数 */
onMenuClick: (menu: TreeMenuItem) => void;
}
const props = defineProps<SecondCascaderMenuItemProps>();
-/**
- * 处理菜单项点击
- * @param event 鼠标事件
- */
const handleMenuClick = (event: MouseEvent) => {
event.preventDefault();
event.stopPropagation();
@@ -42,31 +18,45 @@ const handleMenuClick = (event: MouseEvent) => {
};
</script>
+<template>
+ <div
+ :class="['cascader-item', { 'cascader-item--active': isChildMenu(activeMenu, menu) }]"
+ @click="handleMenuClick"
+ >
+ <TextOverflow :text="renderMenuTitle(menu)" tooltip-placement="right" class="menu-title-text" />
+ </div>
+</template>
+
<style lang="scss" scoped>
-.menu-cascader-second-item {
- font-size: 14px;
- letter-spacing: 0;
+.cascader-item {
+ font-size: 13px;
width: 100%;
- height: 36px;
- margin-top: 0;
- margin-bottom: 0;
- margin-left: 16px;
- padding-left: 20px;
- padding-right: 10px;
- color: #6a6a6a;
- line-height: 36px;
+ height: 34px;
+ margin-left: 12px;
+ padding-left: 16px;
+ padding-right: 8px;
+ color: #64748b;
+ line-height: 34px;
cursor: pointer;
+ border-radius: 8px;
+ box-sizing: border-box;
+ transition:
+ background-color 0.15s ease,
+ color 0.15s ease;
.menu-title-text {
width: 100%;
display: block;
}
- box-sizing: border-box;
- &-active,
+ &--active,
&:hover {
- color: var(--primary-color);
- background: #e9f6fe;
+ color: #1e40af;
+ background: #eff6ff;
+ }
+
+ &--active {
+ font-weight: 600;
}
}
</style>
diff --git a/core/layouts/MainLayout/Navbar/CascaderMenu/index.vue b/core/layouts/MainLayout/Navbar/CascaderMenu/index.vue
index a81b3fa..90dda88 100644
--- a/core/layouts/MainLayout/Navbar/CascaderMenu/index.vue
+++ b/core/layouts/MainLayout/Navbar/CascaderMenu/index.vue
@@ -4,57 +4,30 @@ import { type TreeMenuItem } from '@core/stores/menu';
import SecondCascaderMenu from './components/SecondCascaderMenu.vue';
import { isChildMenu, hasChildren } from '@core/utils/menuHelpers';
-/**
- * 层叠菜单组件Props接口定义
- */
interface CascaderMenuProps {
- /** 菜单项 */
menu: TreeMenuItem;
- /** 激活菜单项 */
activeMenu?: TreeMenuItem;
- /** 当前鼠标悬浮菜单项 */
currentMenu?: TreeMenuItem;
- /** 菜单点击回调函数 */
onMenuClick: (menu: TreeMenuItem) => void;
}
const props = defineProps<CascaderMenuProps>();
-
-/** 一行显示菜单项数量 */
const menuItemColumns = 3;
-/** 子菜单宽度 */
const subMenuWidth = 225;
-/** 层叠菜单宽度 */
const subWidth = `${subMenuWidth * menuItemColumns}px`;
-/** 导航栏宽度 */
-const navWidth = '200px';
-/** 是否折叠 */
+const navWidth = '220px';
const collapsed = false;
-/**
- * 计算处理后的子菜单列表
- * 将没有子菜单的项目归类到一起,有子菜单的单独列出
- */
const children = computed(() => {
- /** 无子菜单的菜单项 */
const noChildrenList = props.menu.children?.filter((item) => !hasChildren(item));
-
- /** 有子菜单的菜单项 */
const childrenList: TreeMenuItem[] =
noChildrenList && noChildrenList.length > 0
- ? [
- {
- ...props.menu,
- children: noChildrenList,
- },
- ]
+ ? [{ ...props.menu, children: noChildrenList }]
: [];
-
props.menu.children?.forEach((item) => {
if (noChildrenList?.includes(item)) return;
childrenList.push(item);
});
-
return childrenList;
});
</script>
@@ -63,13 +36,9 @@ const children = computed(() => {
<div
:class="[
'menu-cascader',
- {
- 'menu-cascader-current': currentMenu && isChildMenu(currentMenu, menu),
- },
+ { 'menu-cascader--visible': currentMenu && isChildMenu(currentMenu, menu) },
]"
- :style="{
- maxWidth: collapsed ? 'calc(100vw - 50px)' : `calc(100vw - ${navWidth})`,
- }"
+ :style="{ maxWidth: collapsed ? 'calc(100vw - 50px)' : `calc(100vw - ${navWidth})` }"
>
<SecondCascaderMenu
v-for="second in children"
@@ -90,17 +59,21 @@ const children = computed(() => {
display: none;
height: 100%;
width: 675px;
- max-width: calc(100vw - 200px);
+ max-width: calc(100vw - 220px);
overflow: hidden auto;
- padding: 32px 16px 0;
+ padding: 28px 20px 0;
z-index: 1100;
- background: #fff;
- border-left: 1px solid #f0f0f0;
+ background: rgba(255, 255, 255, 0.95);
+ backdrop-filter: blur(16px);
+ -webkit-backdrop-filter: blur(16px);
+ border-left: 1px solid rgba(226, 224, 220, 0.5);
+ border-radius: 0 14px 14px 0;
+ box-shadow:
+ 4px 0 24px rgba(0, 0, 0, 0.05),
+ 0 4px 24px rgba(0, 0, 0, 0.04);
- /* 完全隐藏滚动条,但保留滚动功能 */
scrollbar-width: none;
-ms-overflow-style: none;
-
&::-webkit-scrollbar {
width: 0;
height: 0;
@@ -108,7 +81,7 @@ const children = computed(() => {
}
}
-.menu-cascader-current {
+.menu-cascader--visible {
display: block;
}
</style>
diff --git a/core/layouts/MainLayout/Navbar/index.vue b/core/layouts/MainLayout/Navbar/index.vue
index 786f2c4..c452b91 100644
--- a/core/layouts/MainLayout/Navbar/index.vue
+++ b/core/layouts/MainLayout/Navbar/index.vue
@@ -1,9 +1,10 @@
-<script setup lang="ts">
+<script setup lang="ts">
import { storeToRefs } from 'pinia';
import Menu from './Menu/index.vue';
import { type UserInfo } from '@core/stores/user';
import { useMenuStore } from '@core/stores/menu';
import { getConfig } from '@core/configure';
+import { useTheme, THEMES } from '@core/composables/useTheme';
interface NavbarProps {
currentUser?: Partial<UserInfo>;
@@ -15,137 +16,256 @@ defineProps<NavbarProps>();
const menuStore = useMenuStore();
const baseConfig = getConfig().base;
-
-// 使用storeToRefs保持解构属性的响应性
const { treeMenus: mainMenus } = storeToRefs(menuStore);
-
const title = baseConfig.title;
+
+const { currentTheme, setTheme } = useTheme();
</script>
<template>
- <div class="navbar">
- <div class="left">
- <div class="logo">
- <!-- <Login /> -->
- <div class="logoName">{{ title }}</div>
+ <header class="navbar">
+ <!-- 左侧品牌 -->
+ <div class="navbar-left">
+ <div class="brand">
+ <div class="brand-icon">
+ <svg
+ width="20"
+ height="20"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="2.2"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ >
+ <path
+ d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"
+ />
+ <polyline points="3.27 6.96 12 12.01 20.73 6.96" />
+ <line x1="12" y1="22.08" x2="12" y2="12" />
+ </svg>
+ </div>
+ <span class="brand-name">{{ title }}</span>
</div>
</div>
- <div class="right">
- <!-- 顶部导航信息 -->
- <div class="menu-wrapper">
- <Menu :tabPanes="mainMenus" />
+ <!-- 中间菜单 -->
+ <div class="navbar-center">
+ <Menu :tabPanes="mainMenus" />
+ </div>
+
+ <!-- 右侧用户 -->
+ <div class="navbar-right">
+ <!-- 主题切换 -->
+ <div class="theme-switcher">
+ <button
+ v-for="theme in THEMES"
+ :key="theme.id"
+ class="theme-btn"
+ :class="{ 'theme-btn--active': currentTheme === theme.id }"
+ :title="theme.description"
+ @click="setTheme(theme.id)"
+ >
+ <span class="theme-btn-icon">{{ theme.icon }}</span>
+ <span class="theme-btn-label">{{ theme.label }}</span>
+ </button>
</div>
- <!-- 顶部导航操作栏 -->
- <div class="actions-wrapper">
- <!-- 头像 -->
- <div class="user-info">
- <span class="userName">
- {{ currentUser?.displayName || currentUser?.name || '' }}
- </span>
+ <div class="user-info">
+ <div class="user-avatar">
+ {{ (currentUser?.displayName || currentUser?.name || 'U').charAt(0).toUpperCase() }}
</div>
- <div class="actionItem" @click="logout">退出</div>
+ <span class="user-name">{{ currentUser?.displayName || currentUser?.name || '' }}</span>
</div>
+ <button class="logout-btn" @click="logout">
+ <svg
+ width="15"
+ height="15"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="2"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ >
+ <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
+ <polyline points="16 17 21 12 16 7" />
+ <line x1="21" y1="12" x2="9" y2="12" />
+ </svg>
+ <span>退出</span>
+ </button>
</div>
- </div>
+ </header>
</template>
<style lang="scss" scoped>
-$left-width: 200px;
+$brand-width: 220px;
.navbar {
display: flex;
- box-sizing: border-box;
- background-color: #e9f6fe;
+ align-items: center;
+ height: 56px;
+ background: rgba(255, 255, 255, 0.82);
+ backdrop-filter: blur(14px);
+ -webkit-backdrop-filter: blur(14px);
+ border-bottom: 1px solid rgba(226, 224, 220, 0.6);
+ box-shadow: 0 1px 6px rgba(0, 0, 0, 0.03);
+}
+
+// ---- 左侧品牌 ----
+.navbar-left {
+ width: $brand-width;
+ height: 56px;
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+}
+
+.brand {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding-left: 20px;
+}
+
+.brand-icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 34px;
+ height: 34px;
+ border-radius: 10px;
+ background: linear-gradient(135deg, #1e40af 0%, #2563eb 100%);
+ color: #ffffff;
+ flex-shrink: 0;
+ box-shadow: 0 2px 8px rgba(30, 64, 175, 0.2);
+}
+
+.brand-name {
+ font-weight: 700;
+ font-size: 18px;
+ color: #1e293b;
+ letter-spacing: -0.02em;
+ white-space: nowrap;
+}
+
+// ---- 中间菜单 ----
+.navbar-center {
+ flex: 1;
+ height: 56px;
+ overflow: hidden;
+ min-width: 0;
+}
+
+// ---- 右侧用户 ----
+.navbar-right {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding-right: 20px;
+ flex-shrink: 0;
height: 56px;
- /* 固定导航栏高度为56px */
- position: relative;
-
- .left {
- display: flex;
- align-items: center;
- width: 200px;
- height: 56px;
- /* 确保左侧内容也是56px高 */
-
- .logo {
- display: flex;
- align-items: center;
- width: $left-width;
- padding-left: 20px;
- box-sizing: border-box;
- }
-
- .logoName {
- font-weight: 500;
- font-size: 20px;
- margin-left: 10px;
- font-family:
- Alimama ShuHeiTi,
- Alimama ShuHeiTi;
- font-weight: bold;
- font-size: 20px;
- color: #1d2129;
- line-height: 28px;
- text-align: left;
- font-style: normal;
- text-transform: none;
- }
+}
+
+// ---- 主题切换 ----
+.theme-switcher {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+ background: rgba(0, 0, 0, 0.04);
+ border-radius: 8px;
+ padding: 3px;
+}
+
+.theme-btn {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ padding: 4px 10px;
+ border: none;
+ border-radius: 6px;
+ background: transparent;
+ cursor: pointer;
+ font-size: 12px;
+ font-family: 'Inter', sans-serif;
+ font-weight: 500;
+ color: var(--color-text-tertiary, #64748b);
+ transition:
+ background 0.15s,
+ color 0.15s;
+ white-space: nowrap;
+
+ &:hover {
+ background: rgba(0, 0, 0, 0.06);
+ color: var(--color-text-primary, #1e293b);
+ }
+
+ &--active {
+ background: #ffffff;
+ color: var(--color-text-primary, #1e293b);
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+ }
+}
+
+.theme-btn-icon {
+ font-size: 13px;
+ line-height: 1;
+}
+
+.theme-btn-label {
+ line-height: 1;
+}
+
+.user-info {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.user-avatar {
+ width: 32px;
+ height: 32px;
+ border-radius: 10px;
+ background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%);
+ color: #1e40af;
+ font-weight: 700;
+ font-size: 13px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+
+.user-name {
+ font-size: 14px;
+ font-weight: 500;
+ color: #475569;
+ white-space: nowrap;
+}
+
+.logout-btn {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 14px;
+ border: 1px solid #e2e0dc;
+ border-radius: 10px;
+ background: #ffffff;
+ color: #64748b;
+ font-size: 13px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.18s ease;
+ white-space: nowrap;
+
+ &:hover {
+ background: #fef2f2;
+ color: #dc2626;
+ border-color: #fecaca;
}
- .right {
- display: flex;
- justify-content: space-between;
- list-style: none;
- padding-right: 20px;
- width: calc(100% - #{$left-width});
- height: 56px;
- /* 确保右侧区域高度也是56px */
-
- .menu-wrapper {
- flex: 1;
- overflow: hidden;
- /* 确保菜单不会超出其容器 */
- margin-right: 20px;
- /* 与操作区域保持间距 */
- }
-
- .actions-wrapper {
- display: flex;
- align-items: center;
- justify-content: flex-end;
- white-space: nowrap;
- /* 防止文本换行 */
- min-width: 150px;
- /* 确保操作区域有最小宽度 */
- padding-left: 15px;
- /* 与菜单保持一定距离 */
- z-index: 15;
- /* 确保显示在菜单之上 */
- background-color: #e9f6fe;
- /* 与导航栏背景色一致 */
-
- .user-info {
- display: flex;
- align-items: center;
- }
-
- .userName {
- margin-left: 5px;
- font-weight: 500;
- }
-
- .actionItem {
- cursor: pointer;
- margin-left: 15px;
- color: #1890ff;
-
- &:hover {
- text-decoration: underline;
- }
- }
- }
+ &:active {
+ background: #fee2e2;
}
}
</style>
diff --git a/core/layouts/MainLayout/Navbar/Menu/index.vue b/core/layouts/MainLayout/Navbar/Menu/index.vue
index c9f7a8b..0ab7156 100644
--- a/core/layouts/MainLayout/Navbar/Menu/index.vue
+++ b/core/layouts/MainLayout/Navbar/Menu/index.vue
@@ -7,11 +7,7 @@ import { ElScrollbar } from 'element-plus';
import { hasChildren } from '@core/utils/menuHelpers';
import { openMenuTab } from '@core/utils/menuTab';
-/**
- * Menu组件Props接口定义
- */
interface MenuProps {
- /** 菜单项数组 */
tabPanes?: Array<TreeMenuItem>;
}
@@ -22,11 +18,8 @@ const menuCascaderHeight = ref<number>(500);
const router = useRouter();
const menuStore = useMenuStore();
const activeMenu = computed(() => menuStore.activeMenu);
-
-/** 当前鼠标悬浮位置菜单,层叠菜单组件需要 */
const currentMenu = ref<TreeMenuItem | undefined>();
-// 滚动相关引用和状态
const scrollbarRef = ref<InstanceType<typeof ElScrollbar> | null>(null);
const menuRef = ref<HTMLElement | null>(null);
const showLeftArrow = ref(false);
@@ -35,90 +28,58 @@ const scrollPosition = ref(0);
const scrollWidth = ref(0);
const containerWidth = ref(0);
-/**
- * 更新箭头显示状态
- */
const updateArrowVisibility = () => {
if (!menuRef.value || !scrollbarRef.value) return;
-
const wrapperElement = scrollbarRef.value.$el.querySelector('.el-scrollbar__wrap');
if (!wrapperElement) return;
-
scrollPosition.value = wrapperElement.scrollLeft;
scrollWidth.value = menuRef.value.scrollWidth;
containerWidth.value = wrapperElement.clientWidth;
-
- // 显示/隐藏左右箭头
showLeftArrow.value = scrollPosition.value > 0;
-
- // 添加2px的容差值,解决右侧边缘判断精度问题
- const scrollRightTolerance = 2; // 2像素的容差值
+ const tolerance = 2;
showRightArrow.value =
scrollWidth.value > containerWidth.value &&
- scrollPosition.value < scrollWidth.value - containerWidth.value - scrollRightTolerance;
+ scrollPosition.value < scrollWidth.value - containerWidth.value - tolerance;
};
-/**
- * 向左滚动
- */
const scrollLeft = () => {
if (!scrollbarRef.value?.$el) return;
- const wrapperElement = scrollbarRef.value.$el.querySelector('.el-scrollbar__wrap');
- if (!wrapperElement) return;
-
- wrapperElement.scrollLeft -= 200; // 滚动200px
+ const w = scrollbarRef.value.$el.querySelector('.el-scrollbar__wrap');
+ if (!w) return;
+ w.scrollLeft -= 200;
updateArrowVisibility();
};
-/**
- * 向右滚动
- */
const scrollRight = () => {
if (!scrollbarRef.value?.$el) return;
- const wrapperElement = scrollbarRef.value.$el.querySelector('.el-scrollbar__wrap');
- if (!wrapperElement) return;
-
- wrapperElement.scrollLeft += 200; // 滚动200px
+ const w = scrollbarRef.value.$el.querySelector('.el-scrollbar__wrap');
+ if (!w) return;
+ w.scrollLeft += 200;
updateArrowVisibility();
};
-/**
- * 处理鼠标滚轮事件
- */
const handleWheel = (e: WheelEvent) => {
if (!scrollbarRef.value?.$el) return;
- const wrapperElement = scrollbarRef.value.$el.querySelector('.el-scrollbar__wrap');
- if (!wrapperElement) return;
-
- // 阻止默认的垂直滚动
+ const w = scrollbarRef.value.$el.querySelector('.el-scrollbar__wrap');
+ if (!w) return;
e.preventDefault();
-
- // 水平滚动距离
- const delta = e.deltaY || e.deltaX;
- wrapperElement.scrollLeft += delta;
+ w.scrollLeft += e.deltaY || e.deltaX;
updateArrowVisibility();
};
-// 组件挂载后初始化
onMounted(async () => {
await nextTick();
-
- // 初始化箭头状态
updateArrowVisibility();
-
- // 添加滚动事件监听
window.addEventListener('resize', updateArrowVisibility);
if (scrollbarRef.value?.$el) {
- const wrapperElement = scrollbarRef.value.$el.querySelector('.el-scrollbar__wrap');
- if (wrapperElement) {
- wrapperElement.addEventListener('scroll', updateArrowVisibility);
- // 添加鼠标滚轮事件
+ const w = scrollbarRef.value.$el.querySelector('.el-scrollbar__wrap');
+ if (w) {
+ w.addEventListener('scroll', updateArrowVisibility);
scrollbarRef.value.$el.addEventListener('wheel', handleWheel, { passive: false });
}
}
});
-// 监听菜单项变化,更新箭头态
watch(
() => props.tabPanes,
async () => {
@@ -128,113 +89,51 @@ watch(
{ deep: true },
);
-/**
- * 计算是否显示层叠菜单
- */
const showCascaderMenu = computed(() => {
- // 如果是顶级菜单,但是没有子菜单,不显示
- if (currentMenu.value && !currentMenu.value.parentMenu) {
- return hasChildren(currentMenu.value);
- }
-
- // 子菜单,显示
+ if (currentMenu.value && !currentMenu.value.parentMenu) return hasChildren(currentMenu.value);
return currentMenu.value !== undefined;
});
-/**
- * 检查菜单项是否应该高亮显示
- * @param menu 要检查的菜单项
- * @returns 是否应该高亮显示
- */
const isMenuActive = (menu: TreeMenuItem) => {
- // 直接判断顶级活动菜单路径匹配
- if (menuStore.topLevelActiveMenu?.path === menu.path) {
- return true;
- }
-
- // 通过ID匹配顶级活动菜单
- if (menuStore.topLevelActiveMenu?.id === menu.id) {
- return true;
- }
-
- // 判断当前激活菜单是否是该菜单的子菜单
- if (activeMenu.value && isChildOf(activeMenu.value, menu)) {
- return true;
- }
-
- // 当前路由路径匹配菜单路径
- if (router.currentRoute.value.path === menu.path) {
- return true;
- }
-
+ if (menuStore.topLevelActiveMenu?.path === menu.path) return true;
+ if (menuStore.topLevelActiveMenu?.id === menu.id) return true;
+ if (activeMenu.value && isChildOf(activeMenu.value, menu)) return true;
+ if (router.currentRoute.value.path === menu.path) return true;
return false;
};
-/**
- * 检查一个菜单是否是另一个菜单的子菜单(包括多级子菜单)
- * @param child 可能的子菜单
- * @param parent 可能的父菜单
- * @returns 是否是子菜单关系
- */
const isChildOf = (child: TreeMenuItem, parent: TreeMenuItem) => {
if (!child || !parent) return false;
if (child.id === parent.id) return true;
-
- // 向上遍历查找父菜单
- let currentParent = child.parentMenu;
- while (currentParent) {
- if (currentParent.id === parent.id) return true;
- currentParent = currentParent.parentMenu;
+ let p = child.parentMenu;
+ while (p) {
+ if (p.id === parent.id) return true;
+ p = p.parentMenu;
}
-
return false;
};
-/**
- * 鼠标悬浮在菜单处处理
- * @param event 鼠标事件
- * @param menuItem 菜单项
- */
const handleMouseEnter = (event: MouseEvent, menuItem: TreeMenuItem) => {
event.preventDefault();
event.stopPropagation();
-
const target = event.target as HTMLElement;
-
- /** 层叠菜单展示的左边距 */
let x = event.clientX - (target?.clientWidth || 0);
-
- // 菜单宽度大于屏幕宽度时,计算偏移量
- if (x + cascaderMenuWidth > window.innerWidth) {
- x = window.innerWidth - cascaderMenuWidth;
- }
-
+ if (x + cascaderMenuWidth > window.innerWidth) x = window.innerWidth - cascaderMenuWidth;
clientX.value = x;
currentMenu.value = menuItem;
};
-/**
- * 鼠标离开菜单项处理
- */
const handleMouseLeave = (event: MouseEvent) => {
event.preventDefault();
event.stopPropagation();
-
currentMenu.value = undefined;
};
-/**
- * 遮罩触发处理
- */
const handleMaskTrigger = (e: MouseEvent) => {
- // 只有当鼠标移动到遮罩区域,而不是导航栏或级联菜单时才隐藏
const target = e.target as HTMLElement;
const relatedTarget = e.relatedTarget as HTMLElement;
-
- // 如果鼠标从菜单区域移出到遮罩区域,才隐藏菜单
if (
- target &&
- target.classList.contains('side-mask') &&
+ target?.classList.contains('side-mask') &&
!relatedTarget?.closest('.menu-cascader-parent') &&
!relatedTarget?.closest('.menu-container')
) {
@@ -242,27 +141,29 @@ const handleMaskTrigger = (e: MouseEvent) => {
}
};
-/**
- * 菜单点击处理
- * @param menu 菜单项
- */
const onMenuClick = (menu: TreeMenuItem) => {
- openMenuTab({
- url: menu.path,
- title: menu.name,
- });
-
+ openMenuTab({ url: menu.path, title: menu.name });
menuStore.setActiveMenu(menu);
-
currentMenu.value = undefined;
};
</script>
<template>
<div class="menu-container" @mouseleave="handleMouseLeave">
- <!-- 左箭头按钮 -->
- <div class="scroll-arrow left-arrow" @click="scrollLeft" v-show="showLeftArrow">
- <i class="el-icon-arrow-left"><</i>
+ <!-- 左箭头 -->
+ <div class="scroll-arrow scroll-arrow--left" @click="scrollLeft" v-show="showLeftArrow">
+ <svg
+ width="14"
+ height="14"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="2.5"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ >
+ <polyline points="15 18 9 12 15 6" />
+ </svg>
</div>
<ElScrollbar class="menu-scrollbar" ref="scrollbarRef">
@@ -270,7 +171,7 @@ const onMenuClick = (menu: TreeMenuItem) => {
<li v-for="(tabPane, index) in props.tabPanes" :key="index">
<div
class="menu-item"
- :class="{ 'menu-item-active': isMenuActive(tabPane) }"
+ :class="{ 'menu-item--active': isMenuActive(tabPane) }"
@mouseenter="(e) => handleMouseEnter(e, tabPane)"
>
{{ tabPane.title || tabPane.name }}
@@ -279,11 +180,23 @@ const onMenuClick = (menu: TreeMenuItem) => {
</div>
</ElScrollbar>
- <!-- 右箭头按钮 -->
- <div class="scroll-arrow right-arrow" @click="scrollRight" v-show="showRightArrow">
- <i class="el-icon-arrow-right">></i>
+ <!-- 右箭头 -->
+ <div class="scroll-arrow scroll-arrow--right" @click="scrollRight" v-show="showRightArrow">
+ <svg
+ width="14"
+ height="14"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="2.5"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ >
+ <polyline points="9 18 15 12 9 6" />
+ </svg>
</div>
- <!-- 只渲染当前鼠标悬停的一级菜单的级联菜单 -->
+
+ <!-- 层叠菜单 -->
<div
v-if="currentMenu"
:style="{ left: `${clientX}px`, height: `${menuCascaderHeight}px` }"
@@ -297,6 +210,8 @@ const onMenuClick = (menu: TreeMenuItem) => {
:onMenuClick="onMenuClick"
/>
</div>
+
+ <!-- 遮罩 -->
<div
v-if="showCascaderMenu"
class="side-mask"
@@ -313,7 +228,7 @@ const onMenuClick = (menu: TreeMenuItem) => {
height: 56px;
display: flex;
align-items: center;
- overflow: hidden; /* 确保内容不会溢出 */
+ overflow: hidden;
}
.scroll-arrow {
@@ -321,50 +236,50 @@ const onMenuClick = (menu: TreeMenuItem) => {
display: flex;
align-items: center;
justify-content: center;
- width: 24px;
- height: 56px;
- background-color: rgba(233, 246, 254, 0.9);
- color: #1890ff;
+ width: 28px;
+ height: 36px;
+ border-radius: 10px;
+ background: rgba(255, 255, 255, 0.9);
+ color: #64748b;
cursor: pointer;
- z-index: 20; /* 提高层级确保显示在其他元素之上 */
+ z-index: 20;
+ backdrop-filter: blur(6px);
+ border: 1px solid #e2e0dc;
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
+ transition: all 0.18s ease;
&:hover {
- background-color: rgba(233, 246, 254, 1);
+ background: #ffffff;
+ color: #1e40af;
+ border-color: #bfdbfe;
+ box-shadow: 0 2px 8px rgba(30, 64, 175, 0.1);
}
- &.left-arrow {
- left: 0;
- box-shadow: 2px 0 5px rgba(0, 0, 0, 0.1);
+ &--left {
+ left: 4px;
}
-
- &.right-arrow {
- right: 0;
- box-shadow: -2px 0 5px rgba(0, 0, 0, 0.1);
+ &--right {
+ right: 4px;
}
}
.menu-scrollbar {
- flex: 1; /* 使用flex布局占据所有可用空间 */
+ flex: 1;
width: 100%;
height: 63px;
- overflow: hidden; /* 确保不会出现额外的滚动条 */
+ overflow: hidden;
:deep(.el-scrollbar__wrap) {
overflow-x: auto;
overflow-y: hidden;
-
- /* 完全隐藏滚动条,但保留滚动功能 */
scrollbar-width: none;
-ms-overflow-style: none;
-
&::-webkit-scrollbar {
width: 0;
height: 0;
display: none;
}
}
-
- /* 覆盖Element Plus默认滚动条样式 */
:deep(.el-scrollbar__bar) {
display: none !important;
}
@@ -376,40 +291,45 @@ const onMenuClick = (menu: TreeMenuItem) => {
margin: 0;
padding: 0 10px;
list-style: none;
- white-space: nowrap; /* 防止菜单项换行 */
- min-width: max-content; /* 确保内容不会被压缩 */
- box-sizing: border-box;
- width: fit-content; /* 适应内容宽度 */
+ white-space: nowrap;
+ min-width: max-content;
+ width: fit-content;
.menu-item {
display: flex;
align-items: center;
height: 100%;
- margin: auto 32px auto 5px;
+ margin: auto 28px auto 4px;
cursor: pointer;
font-weight: 600;
- color: #6d6f72;
- padding: 0 5px;
+ font-size: 14px;
+ color: #64748b;
+ padding: 0 6px;
+ position: relative;
transition: color 0.2s ease;
- &-active {
- color: #1890ff;
- font-weight: 600;
- position: relative;
+ &::after {
+ content: '';
+ position: absolute;
+ bottom: 0;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 0;
+ height: 2.5px;
+ background: linear-gradient(90deg, #1e40af, #2563eb);
+ border-radius: 2px 2px 0 0;
+ transition: width 0.25s cubic-bezier(0.4, 0, 0.2, 1);
+ }
+ &--active {
+ color: #1e40af;
&::after {
- content: '';
- position: absolute;
- bottom: 0;
- left: 0;
width: 100%;
- height: 2px;
- background-color: #1890ff;
}
}
&:hover {
- color: #1890ff;
+ color: #1e40af;
}
}
}
@@ -427,8 +347,8 @@ const onMenuClick = (menu: TreeMenuItem) => {
position: fixed;
inset: 56px 0 0;
z-index: 1080;
- background-color: #000;
- cursor: pointer;
- opacity: 0.1;
+ background: #0f172a;
+ opacity: 0.12;
+ backdrop-filter: blur(1px);
}
</style>
diff --git a/core/layouts/MainLayout/Sider/index.vue b/core/layouts/MainLayout/Sider/index.vue
index 2903c43..77761ed 100644
--- a/core/layouts/MainLayout/Sider/index.vue
+++ b/core/layouts/MainLayout/Sider/index.vue
@@ -1,124 +1,188 @@
<script setup lang="ts">
import { computed } from 'vue';
-import { openMenuTab } from '@core/utils/menuTab';
-import { useMenuStore, type TreeMenuItem } from '@core/stores/menu';
-import { renderMenuTitle } from '@core/utils/menuHelpers';
-import TextOverflow from '@core/components/TextOverflow.vue';
+import { useMenuStore } from '@core/stores/menu';
+import SideMenuItem from './SideMenuItem.vue';
const menuStore = useMenuStore();
-const parentMenu = computed(() => {
- return menuStore.activeMenu?.parentMenu;
-});
-
-/** 点击菜单处理 */
-const handleMenuClick = (menu: TreeMenuItem) => {
- openMenuTab({
- url: menu.path,
- title: menu.name,
- });
-
- menuStore.setActiveMenu(menu);
-};
-
-function isMenuActive(menu: TreeMenuItem) {
- return menu.path === menuStore.activeMenu?.path;
-}
+const parentMenu = computed(() => menuStore.activeMenu?.parentMenu);
+const secondLevelMenus = computed(() => parentMenu.value?.children || []);
</script>
<template>
- <div class="sider">
- <!-- 菜单容器 -->
- <div class="menuContainer">
- <!-- 一级菜单名,带图标 -->
- <div class="title">
- <!-- <Icon v-if="parentMenu?.icon" :type="parentMenu.icon" /> -->
- <span>{{ parentMenu?.title }}</span>
+ <aside class="sider">
+ <!-- 顶部标题区 -->
+ <div class="sider-header">
+ <div class="sider-header-icon">
+ <svg
+ width="18"
+ height="18"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="2"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ >
+ <rect x="3" y="3" width="7" height="7" rx="1.2" />
+ <rect x="14" y="3" width="7" height="7" rx="1.2" />
+ <rect x="3" y="14" width="7" height="7" rx="1.2" />
+ <rect x="14" y="14" width="7" height="7" rx="1.2" />
+ </svg>
</div>
- <!-- 二级菜单名 -->
- <div
- v-for="item in parentMenu?.children"
- :key="item.name"
- :class="['menuName', isMenuActive(item) && 'active']"
- @click="() => handleMenuClick(item)"
- >
- <TextOverflow
- :text="renderMenuTitle(item)"
- tooltip-placement="right"
- class="menu-title-text"
+ <span class="sider-header-title">
+ {{ parentMenu?.title || parentMenu?.name || '导航菜单' }}
+ </span>
+ </div>
+
+ <!-- 分割线 -->
+ <div class="sider-divider" />
+
+ <!-- 菜单树 -->
+ <nav class="sider-nav">
+ <div class="sider-nav-inner">
+ <SideMenuItem
+ v-for="menu in secondLevelMenus"
+ :key="menu.id"
+ :menu="menu"
+ :depth="0"
+ :activeMenu="menuStore.activeMenu"
/>
</div>
+ </nav>
+
+ <!-- 底部 -->
+ <div class="sider-footer">
+ <button class="sider-collapse-btn" title="折叠菜单">
+ <svg
+ width="16"
+ height="16"
+ viewBox="0 0 16 16"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="1.5"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ >
+ <path d="M10 3L5 8L10 13" />
+ </svg>
+ </button>
</div>
- </div>
+ </aside>
</template>
<style lang="scss" scoped>
.sider {
+ display: flex;
+ flex-direction: column;
width: 100%;
height: 100%;
- background-color: #e9f6fe;
+ background: rgba(255, 255, 255, 0.78);
+ backdrop-filter: blur(14px);
+ -webkit-backdrop-filter: blur(14px);
+ border-right: 1px solid rgba(226, 224, 220, 0.5);
+ box-shadow: 1px 0 12px rgba(0, 0, 0, 0.02);
+}
- .menuContainer {
- height: 100%;
- overflow-y: auto;
+// ---- 顶部标题 ----
+.sider-header {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 20px 16px 12px;
+ flex-shrink: 0;
+}
- /* 完全隐藏滚动条 */
- scrollbar-width: none;
- -ms-overflow-style: none;
+.sider-header-icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ border-radius: 10px;
+ background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
+ color: #1e40af;
+ flex-shrink: 0;
+}
- &::-webkit-scrollbar {
- width: 0;
- height: 0;
- display: none;
- }
+.sider-header-title {
+ font-size: 15px;
+ font-weight: 700;
+ color: #1e293b;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ letter-spacing: -0.01em;
+}
+
+// ---- 分割线 ----
+.sider-divider {
+ height: 1px;
+ margin: 0 16px;
+ background: linear-gradient(to right, transparent 0%, #e2e0dc 20%, #e2e0dc 80%, transparent 100%);
+ flex-shrink: 0;
+}
+
+// ---- 菜单导航 ----
+.sider-nav {
+ flex: 1;
+ overflow-y: auto;
+ overflow-x: hidden;
+ padding: 8px 0;
- /* 悬浮时可使用滚动,但保持滚动条隐藏 */
+ scrollbar-width: thin;
+ scrollbar-color: #d5cec4 transparent;
+
+ &::-webkit-scrollbar {
+ width: 4px;
+ }
+ &::-webkit-scrollbar-track {
+ background: transparent;
+ }
+ &::-webkit-scrollbar-thumb {
+ background: #d5cec4;
+ border-radius: 4px;
&:hover {
- overflow-y: auto;
- }
- .title {
- display: flex;
- justify-content: center;
- align-items: center;
- font-weight: 600;
- font-size: 14px;
- color: #86909c;
- margin-bottom: 16px;
-
- span {
- margin-left: 4px;
- }
+ background: #b8b3bf;
}
+ }
+}
- .menuName {
- height: 32px;
- font-weight: 600;
- font-size: 14px;
- // color: #1D2129;
- color: #6d6f72;
- line-height: 20px;
- margin: 0 8px 4px 8px;
- padding: 6px 0 6px 20px;
- text-align: left;
- user-select: none;
- cursor: pointer;
-
- .menu-title-text {
- /* 继承菜单项样式 */
- width: 100%;
- }
-
- &:hover {
- color: #007fff;
- }
- }
+.sider-nav-inner {
+ padding-bottom: 8px;
+}
- .active {
- background: rgba(255, 255, 255, 0.7);
- border-radius: 8px;
- color: #007fff;
- box-shadow: 0px 2px 8px 0px rgba(92, 173, 255, 0.1);
- }
+// ---- 底部 ----
+.sider-footer {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 8px 16px 12px;
+ flex-shrink: 0;
+ border-top: 1px solid #f0ece6;
+}
+
+.sider-collapse-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ border: 1px solid #e2e0dc;
+ border-radius: 10px;
+ background: #ffffff;
+ color: #94a3b8;
+ cursor: pointer;
+ transition: all 0.18s ease;
+
+ &:hover {
+ background: #f8fafc;
+ color: #64748b;
+ border-color: #cbd5e1;
+ }
+
+ &:active {
+ background: #f1f5f9;
}
}
</style>
diff --git a/core/layouts/MainLayout/Sider/SideMenuItem.vue b/core/layouts/MainLayout/Sider/SideMenuItem.vue
new file mode 100644
index 0000000..606624a
--- /dev/null
+++ b/core/layouts/MainLayout/Sider/SideMenuItem.vue
@@ -0,0 +1,266 @@
+<script setup lang="ts">
+import { ref, computed, watch } from 'vue';
+import { type TreeMenuItem } from '@core/stores/menu';
+import { isChildMenu, renderMenuTitle, hasChildren } from '@core/utils/menuHelpers';
+import { openMenuTab } from '@core/utils/menuTab';
+import { useMenuStore } from '@core/stores/menu';
+
+const props = withDefaults(
+ defineProps<{
+ menu: TreeMenuItem;
+ depth?: number;
+ activeMenu?: TreeMenuItem;
+ }>(),
+ { depth: 0 },
+);
+
+const menuStore = useMenuStore();
+const isExpanded = ref(false);
+
+const hasChildrenMenu = computed(() => hasChildren(props.menu));
+
+const isActive = computed(
+ () => !hasChildrenMenu.value && isChildMenu(props.activeMenu, props.menu),
+);
+
+const isAncestorOfActive = computed(() => {
+ if (!props.activeMenu || !hasChildrenMenu.value) return false;
+ let current: TreeMenuItem | undefined = props.activeMenu.parentMenu;
+ while (current) {
+ if (current.id === props.menu.id) return true;
+ current = current.parentMenu;
+ }
+ return false;
+});
+
+// 自动展开 activeMenu 的祖先链路
+watch(
+ () => props.activeMenu,
+ (newActive) => {
+ if (newActive && hasChildrenMenu.value) {
+ let current: TreeMenuItem | undefined = newActive.parentMenu;
+ while (current) {
+ if (current.id === props.menu.id) {
+ isExpanded.value = true;
+ return;
+ }
+ current = current.parentMenu;
+ }
+ }
+ },
+ { immediate: true },
+);
+
+const handleClick = () => {
+ if (hasChildrenMenu.value) {
+ isExpanded.value = !isExpanded.value;
+ } else {
+ openMenuTab({ url: props.menu.path, title: props.menu.name });
+ menuStore.setActiveMenu(props.menu);
+ }
+};
+</script>
+
+<template>
+ <div class="side-menu-item">
+ <!-- 菜单行 -->
+ <div
+ :class="[
+ 'menu-row',
+ {
+ 'menu-row--active': isActive,
+ 'menu-row--ancestor': isAncestorOfActive && isExpanded,
+ },
+ ]"
+ :style="{ paddingLeft: `${depth * 16 + 16}px` }"
+ @click="handleClick"
+ >
+ <!-- 展开箭头 -->
+ <span
+ v-if="hasChildrenMenu"
+ class="menu-row-arrow"
+ :class="{ 'menu-row-arrow--expanded': isExpanded }"
+ >
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none" class="arrow-svg">
+ <path
+ d="M4.5 2.5L8 6L4.5 9.5"
+ stroke="currentColor"
+ stroke-width="1.5"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ />
+ </svg>
+ </span>
+ <span v-else class="menu-row-arrow-placeholder" />
+
+ <!-- 图标 -->
+ <span v-if="menu.icon" class="menu-row-icon">
+ <i :class="menu.icon"></i>
+ </span>
+
+ <!-- 标题 -->
+ <span class="menu-row-title">{{ renderMenuTitle(menu) }}</span>
+
+ <!-- 子菜单计数 -->
+ <span v-if="hasChildrenMenu" class="menu-row-badge">
+ {{ menu.children?.length }}
+ </span>
+ </div>
+
+ <!-- 子菜单容器 -->
+ <div
+ v-if="hasChildrenMenu"
+ class="menu-children"
+ :class="{ 'menu-children--expanded': isExpanded }"
+ >
+ <div class="menu-children-inner">
+ <SideMenuItem
+ v-for="child in menu.children"
+ :key="child.id"
+ :menu="child"
+ :depth="depth + 1"
+ :activeMenu="activeMenu"
+ />
+ </div>
+ </div>
+ </div>
+</template>
+
+<style lang="scss" scoped>
+.menu-row {
+ display: flex;
+ align-items: center;
+ height: 40px;
+ padding-right: 12px;
+ cursor: pointer;
+ user-select: none;
+ position: relative;
+ transition:
+ background-color 0.15s ease,
+ color 0.15s ease;
+ border-radius: 0 10px 10px 0;
+ margin: 1px 8px 1px 0;
+
+ // 左侧激活指示条
+ &::before {
+ content: '';
+ position: absolute;
+ left: 0;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 3px;
+ height: 0;
+ background: linear-gradient(180deg, #1e40af 0%, #2563eb 100%);
+ border-radius: 0 3px 3px 0;
+ transition: height 0.22s cubic-bezier(0.4, 0, 0.2, 1);
+ }
+
+ &:hover {
+ background: #f3f0ed;
+ }
+
+ // 激活态(叶子节点)
+ &--active {
+ background: linear-gradient(90deg, #eff6ff 0%, rgba(239, 246, 255, 0.4) 100%);
+ color: #1e40af;
+ font-weight: 600;
+
+ &::before {
+ height: 24px;
+ }
+
+ .menu-row-title {
+ color: #1e40af;
+ }
+ .menu-row-icon {
+ color: #1e40af;
+ }
+ }
+
+ // 展开的祖先节点
+ &--ancestor {
+ .menu-row-title {
+ color: #1e3a8a;
+ font-weight: 600;
+ }
+ }
+}
+
+.menu-row-arrow {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 20px;
+ height: 20px;
+ margin-right: 2px;
+ color: #94a3b8;
+ transition: transform 0.22s cubic-bezier(0.4, 0, 0.2, 1);
+ flex-shrink: 0;
+
+ &--expanded {
+ transform: rotate(90deg);
+ color: #64748b;
+ }
+
+ .arrow-svg {
+ display: block;
+ }
+}
+
+.menu-row-arrow-placeholder {
+ width: 20px;
+ height: 20px;
+ margin-right: 2px;
+ flex-shrink: 0;
+}
+
+.menu-row-icon {
+ display: flex;
+ align-items: center;
+ margin-right: 8px;
+ font-size: 16px;
+ color: #94a3b8;
+ flex-shrink: 0;
+ transition: color 0.15s ease;
+}
+
+.menu-row-title {
+ font-size: 14px;
+ color: #475569;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ line-height: 1.4;
+ flex: 1;
+ transition: color 0.15s ease;
+}
+
+.menu-row-badge {
+ font-size: 11px;
+ color: #64748b;
+ background: #f1f5f9;
+ padding: 1px 7px;
+ border-radius: 10px;
+ margin-left: 8px;
+ flex-shrink: 0;
+ font-weight: 500;
+ transition:
+ background 0.15s ease,
+ color 0.15s ease;
+}
+
+// 子菜单展开动画
+.menu-children {
+ display: grid;
+ grid-template-rows: 0fr;
+ transition: grid-template-rows 0.28s cubic-bezier(0.4, 0, 0.2, 1);
+
+ &--expanded {
+ grid-template-rows: 1fr;
+ }
+}
+
+.menu-children-inner {
+ overflow: hidden;
+}
+</style>