feat: 初始化提交
笑笑 authored at 2025-05-13 21:25:06
10.58 KiB
cube-front
<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">&lt;</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">&gt;</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>