<script setup lang="tsx">
import { computed, ref, onMounted, nextTick, watch } from 'vue';
import { useRouter } from 'vue-router';
import { type TreeMenuItem, useMenuStore } from 'cube-front/core/stores/menu';
import CascaderMenu from '../CascaderMenu/index.vue';
import { ElScrollbar } from 'element-plus';
import { hasChildren } from 'cube-front/core/utils/menuHelpers';
import { openMenuTab } from 'cube-front/core/utils/menuTab';
/**
* Menu组件Props接口定义
*/
interface MenuProps {
/** 菜单项数组 */
tabPanes?: Array<TreeMenuItem>;
}
const props = defineProps<MenuProps>();
const cascaderMenuWidth = 724;
const clientX = ref<number>(0);
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);
const showRightArrow = ref(false);
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像素的容差值
showRightArrow.value =
scrollWidth.value > containerWidth.value &&
scrollPosition.value < scrollWidth.value - containerWidth.value - scrollRightTolerance;
};
/**
* 向左滚动
*/
const scrollLeft = () => {
if (!scrollbarRef.value?.$el) return;
const wrapperElement = scrollbarRef.value.$el.querySelector('.el-scrollbar__wrap');
if (!wrapperElement) return;
wrapperElement.scrollLeft -= 200; // 滚动200px
updateArrowVisibility();
};
/**
* 向右滚动
*/
const scrollRight = () => {
if (!scrollbarRef.value?.$el) return;
const wrapperElement = scrollbarRef.value.$el.querySelector('.el-scrollbar__wrap');
if (!wrapperElement) return;
wrapperElement.scrollLeft += 200; // 滚动200px
updateArrowVisibility();
};
/**
* 处理鼠标滚轮事件
*/
const handleWheel = (e: WheelEvent) => {
if (!scrollbarRef.value?.$el) return;
const wrapperElement = scrollbarRef.value.$el.querySelector('.el-scrollbar__wrap');
if (!wrapperElement) return;
// 阻止默认的垂直滚动
e.preventDefault();
// 水平滚动距离
const delta = e.deltaY || e.deltaX;
wrapperElement.scrollLeft += delta;
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);
// 添加鼠标滚轮事件
scrollbarRef.value.$el.addEventListener('wheel', handleWheel, { passive: false });
}
}
});
// 监听菜单项变化,更新箭头态
watch(
() => props.tabPanes,
async () => {
await nextTick();
updateArrowVisibility();
},
{ deep: true },
);
/**
* 计算是否显示层叠菜单
*/
const showCascaderMenu = computed(() => {
// 如果是顶级菜单,但是没有子菜单,不显示
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;
}
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;
}
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;
}
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') &&
!relatedTarget?.closest('.menu-cascader-parent') &&
!relatedTarget?.closest('.menu-container')
) {
currentMenu.value = undefined;
}
};
/**
* 菜单点击处理
* @param menu 菜单项
*/
const onMenuClick = (menu: TreeMenuItem) => {
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>
<ElScrollbar class="menu-scrollbar" ref="scrollbarRef">
<div class="menu" ref="menuRef">
<li v-for="(tabPane, index) in props.tabPanes" :key="index">
<div
class="menu-item"
:class="{ 'menu-item-active': isMenuActive(tabPane) }"
@mouseenter="(e) => handleMouseEnter(e, tabPane)"
>
{{ tabPane.title || tabPane.name }}
</div>
</li>
</div>
</ElScrollbar>
<!-- 右箭头按钮 -->
<div class="scroll-arrow right-arrow" @click="scrollRight" v-show="showRightArrow">
<i class="el-icon-arrow-right">></i>
</div>
<!-- 只渲染当前鼠标悬停的一级菜单的级联菜单 -->
<div
v-if="currentMenu"
:style="{ left: `${clientX}px`, height: `${menuCascaderHeight}px` }"
class="menu-cascader-parent"
>
<CascaderMenu
v-show="showCascaderMenu"
:menu="currentMenu"
:currentMenu="currentMenu"
:activeMenu="activeMenu"
:onMenuClick="onMenuClick"
/>
</div>
<div
v-if="showCascaderMenu"
class="side-mask"
@mouseenter="(e) => handleMaskTrigger(e)"
@mousemove="(e) => handleMaskTrigger(e)"
></div>
</div>
</template>
<style lang="scss" scoped>
.menu-container {
position: relative;
width: 100%;
height: 56px;
display: flex;
align-items: center;
overflow: hidden; /* 确保内容不会溢出 */
}
.scroll-arrow {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 56px;
background-color: rgba(233, 246, 254, 0.9);
color: #1890ff;
cursor: pointer;
z-index: 20; /* 提高层级确保显示在其他元素之上 */
&:hover {
background-color: rgba(233, 246, 254, 1);
}
&.left-arrow {
left: 0;
box-shadow: 2px 0 5px rgba(0, 0, 0, 0.1);
}
&.right-arrow {
right: 0;
box-shadow: -2px 0 5px rgba(0, 0, 0, 0.1);
}
}
.menu-scrollbar {
flex: 1; /* 使用flex布局占据所有可用空间 */
width: 100%;
height: 63px;
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;
}
}
.menu {
display: flex;
height: 56px;
margin: 0;
padding: 0 10px;
list-style: none;
white-space: nowrap; /* 防止菜单项换行 */
min-width: max-content; /* 确保内容不会被压缩 */
box-sizing: border-box;
width: fit-content; /* 适应内容宽度 */
.menu-item {
display: flex;
align-items: center;
height: 100%;
margin: auto 32px auto 5px;
cursor: pointer;
font-weight: 600;
color: #6d6f72;
padding: 0 5px;
transition: color 0.2s ease;
&-active {
color: #1890ff;
font-weight: 600;
position: relative;
&::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 2px;
background-color: #1890ff;
}
}
&:hover {
color: #1890ff;
}
}
}
.menu-cascader-parent {
position: fixed;
min-height: 60vh;
max-height: 80vh;
overflow: auto;
top: 56px;
z-index: 1100;
}
.side-mask {
position: fixed;
inset: 56px 0 0;
z-index: 1080;
background-color: #000;
cursor: pointer;
opacity: 0.1;
}
</style>
|