NewLife/NewLife.Cube

feat(cube-vue): 新增 SideMenu 整树 EP Menu 侧边栏组件

将 SmartMES 自研的基于 Element Plus el-menu 的整树侧边栏沉淀为 Cube 框架组件,供各业务系统复用。
- 整树渲染:el-menu/el-sub-menu/el-menu-item、图标解析、可见性过滤、搜索、折叠 rail+飞出面板、openMenuTab 跳转
- 品牌区参数化为 #brand 插槽;根作用域自带 --cube-layout-* 变量回退,脱离业务系统 variables.css 也能渲染
- 纯新增,不改动现有 MainLayout/MenuItem/SideMenuItem 等默认实现

关联:SmartMES 菜单栏上移 Cube(MenuUpstream)
笑笑 authored at 2026-09-20 19:46:20
57627ef
Tree
1 Parent(s) 0b61102
Summary: 1 changed files with 658 additions and 0 deletions.
Added +658 -0
Added +658 -0
diff --git a/NewLife.Cube.Vue/web/core/components/SideMenu.vue b/NewLife.Cube.Vue/web/core/components/SideMenu.vue
new file mode 100644
index 0000000..d89ca67
--- /dev/null
+++ b/NewLife.Cube.Vue/web/core/components/SideMenu.vue
@@ -0,0 +1,658 @@
+<template>
+  <aside class="cube-side-menu" :class="{ 'cube-side-menu--collapsed': collapsed }">
+    <!-- 品牌区:由消费方通过 #brand 插槽注入(如各业务系统的 Logo + 名称) -->
+    <div class="cube-side-menu-logo" @click="handleLogoClick">
+      <slot name="brand" />
+    </div>
+
+    <!-- 搜索框 -->
+    <div v-show="!collapsed" class="cube-side-menu-search">
+      <el-input
+        v-model="searchText"
+        placeholder="搜索菜单..."
+        :prefix-icon="Search"
+        size="small"
+        clearable
+      />
+    </div>
+
+    <!-- 展开态:导航菜单 — 使用 Element Plus el-menu 组件渲染整棵菜单树 -->
+    <div v-if="!collapsed" class="cube-side-menu-menu-wrapper">
+      <el-menu
+        :default-active="activeMenuId"
+        :unique-opened="true"
+        :router="false"
+        @select="handleMenuSelect"
+      >
+        <!-- 菜单组:遍历 menuStore 的 treeMenus 渲染 -->
+        <template v-for="group in filteredGroups" :key="group.id">
+          <!-- 有子菜单的组 → el-sub-menu -->
+          <el-sub-menu
+            v-if="hasVisibleChildren(group)"
+            :index="String(group.id)"
+          >
+            <template #title>
+              <el-icon :size="18">
+                <component :is="resolveGroupIcon(group.icon)" />
+              </el-icon>
+              <span>{{ group.title || group.name }}</span>
+            </template>
+            <!-- 子菜单项 -->
+            <template v-for="child in group.children" :key="child.id">
+              <!-- 孙子菜单:二级分组(极少使用,但保留支持) -->
+              <el-sub-menu
+                v-if="hasVisibleChildren(child)"
+                :index="String(child.id)"
+              >
+                <template #title>
+                  <span>{{ child.title || child.name }}</span>
+                </template>
+                <el-menu-item
+                  v-for="leaf in child.children"
+                  :key="leaf.id"
+                  :index="String(leaf.id)"
+                >
+                  <el-icon :size="16">
+                    <component :is="resolveLeafIcon(leaf.icon)" />
+                  </el-icon>
+                  <span>{{ leaf.title || leaf.name }}</span>
+                </el-menu-item>
+              </el-sub-menu>
+              <!-- 叶子菜单项 -->
+              <el-menu-item v-else :index="String(child.id)">
+                <el-icon :size="16">
+                  <component :is="resolveLeafIcon(child.icon)" />
+                </el-icon>
+                <span>{{ child.title || child.name }}</span>
+              </el-menu-item>
+            </template>
+          </el-sub-menu>
+
+          <!-- 顶层叶子菜单 → el-menu-item -->
+          <el-menu-item v-else :index="String(group.id)">
+            <el-icon :size="18">
+              <component :is="resolveGroupIcon(group.icon)" />
+            </el-icon>
+            <span>{{ group.title || group.name }}</span>
+          </el-menu-item>
+        </template>
+      </el-menu>
+    </div>
+
+    <!-- 折叠态:只显示大类图标(悬停显示名称,点击右侧弹出子菜单) -->
+    <div v-else class="cube-side-menu-rail">
+      <div
+        v-for="group in menuGroups"
+        :key="group.id"
+        class="cube-side-menu-rail-item"
+      >
+        <el-tooltip
+          :content="group.title || group.name"
+          placement="right"
+          :show-after="150"
+        >
+          <div
+            class="cube-side-menu-rail-btn"
+            :class="{ 'is-active': isGroupActive(group) }"
+            @click="handleRailClick(group, $event)"
+          >
+            <el-icon :size="20">
+              <component :is="resolveGroupIcon(group.icon)" />
+            </el-icon>
+          </div>
+        </el-tooltip>
+      </div>
+    </div>
+
+    <!-- 底部:折叠 / 展开按钮 -->
+    <div class="cube-side-menu-footer">
+      <button
+        class="cube-side-menu-collapse-btn"
+        :title="collapsed ? '展开菜单' : '收起菜单'"
+        @click="$emit('toggle')"
+      >
+        <el-icon :size="16">
+          <component :is="collapsed ? Expand : Fold" />
+        </el-icon>
+      </button>
+    </div>
+  </aside>
+
+  <!-- 折叠态:点击大类图标弹出的子菜单面板(Teleport 到 body,避免被侧边栏裁剪) -->
+  <Teleport to="body">
+    <template v-if="collapsed && flyoutGroup">
+      <!-- 点击遮罩关闭面板 -->
+      <div class="cube-side-menu-flyout-mask" @click="closeFlyout"></div>
+      <!-- 子菜单面板:垂直对齐被点击的大类图标 -->
+      <div class="cube-side-menu-flyout" :style="{ top: flyoutTop + 'px' }">
+        <div class="cube-side-menu-flyout-title">
+          {{ flyoutGroup.title || flyoutGroup.name }}
+        </div>
+        <div class="cube-side-menu-flyout-items">
+          <template v-for="child in flyoutGroup.children" :key="child.id">
+            <!-- 二级分组 -->
+            <div
+              v-if="hasVisibleChildren(child)"
+              class="cube-side-menu-flyout-subgroup"
+            >
+              <div class="cube-side-menu-flyout-subgroup-title">
+                {{ child.title || child.name }}
+              </div>
+              <div
+                v-for="leaf in child.children"
+                :key="leaf.id"
+                class="cube-side-menu-flyout-item"
+                :class="{ 'is-active': isLeafActive(leaf) }"
+                @click="handleFlyoutSelect(leaf)"
+              >
+                <el-icon :size="16">
+                  <component :is="resolveLeafIcon(leaf.icon)" />
+                </el-icon>
+                <span>{{ leaf.title || leaf.name }}</span>
+              </div>
+            </div>
+            <!-- 叶子子菜单项 -->
+            <div
+              v-else
+              class="cube-side-menu-flyout-item"
+              :class="{ 'is-active': isLeafActive(child) }"
+              @click="handleFlyoutSelect(child)"
+            >
+              <el-icon :size="16">
+                <component :is="resolveLeafIcon(child.icon)" />
+              </el-icon>
+              <span>{{ child.title || child.name }}</span>
+            </div>
+          </template>
+        </div>
+      </div>
+    </template>
+  </Teleport>
+</template>
+
+<script setup lang="ts">
+import { computed, ref, watch } from "vue";
+import { type Component } from "vue";
+import {
+  useMenuStore,
+  type TreeMenuItem,
+} from "@newlifex/cube-vue/core/stores/menu";
+import { storeToRefs } from "pinia";
+import { openMenuTab } from "@newlifex/cube-vue/core/utils/menuTab";
+import {
+  renderMenuTitle,
+  hasChildren,
+  isChildMenu,
+} from "@newlifex/cube-vue/core/utils/menuHelpers";
+import * as ElementPlusIconsVue from "@element-plus/icons-vue";
+import { Search, Expand, Fold } from "@element-plus/icons-vue";
+
+const props = defineProps<{ collapsed?: boolean }>();
+defineEmits<{ (e: "toggle"): void }>();
+
+const menuStore = useMenuStore();
+const { treeMenus, activeMenu, loading, hasMenus } = storeToRefs(menuStore);
+const searchText = ref("");
+
+// ======== 图标解析(复用 MenuItem.vue 的 resolveEpIcon 思路)========
+
+/** 将图标名解析为 Element Plus 图标组件(兼容各种命名格式) */
+function resolveIcon(iconName?: string): Component | null {
+  if (!iconName) return null;
+  // 直接匹配
+  const icons = ElementPlusIconsVue as Record<string, Component>;
+  if (icons[iconName]) return icons[iconName];
+  // PascalCase 匹配
+  const pascal = iconName
+    .replace(/[-_]/g, " ")
+    .replace(/\b\w/g, (c) => c.toUpperCase())
+    .replace(/\s/g, "");
+  if (icons[pascal]) return icons[pascal];
+  return null;
+}
+
+/** 组图标(带 fallback: Folder) */
+function resolveGroupIcon(icon?: string): Component {
+  return (
+    resolveIcon(icon) ||
+    resolveIcon("Folder") ||
+    ElementPlusIconsVue.FolderOpened
+  );
+}
+
+/** 叶子菜单图标(带 fallback: Menu) */
+function resolveLeafIcon(icon?: string): Component {
+  return (
+    resolveIcon(icon) || resolveIcon("Menu") || ElementPlusIconsVue.Document
+  );
+}
+
+// ======== 菜单数据处理 ========
+
+/** 过滤隐藏菜单 */
+const filterVisibleMenus = (menus: TreeMenuItem[]): TreeMenuItem[] => {
+  return menus
+    .filter((m) => (m as { visible?: boolean }).visible !== false)
+    .map((m) => ({
+      ...m,
+      children: m.children ? filterVisibleMenus(m.children) : undefined,
+    }));
+};
+
+const menuGroups = computed(() => filterVisibleMenus(treeMenus.value ?? []));
+
+/** 是否有可见子菜单 */
+function hasVisibleChildren(menu: TreeMenuItem): boolean {
+  if (!menu.children || menu.children.length === 0) return false;
+  return menu.children.some(
+    (c) => (c as { visible?: boolean }).visible !== false,
+  );
+}
+
+/** 当前激活的菜单 ID(用于 el-menu default-active) */
+const activeMenuId = computed(() => String(activeMenu.value?.id ?? ""));
+
+/** 搜索过滤后的菜单 */
+const filteredGroups = computed(() => {
+  const q = searchText.value.trim().toLowerCase();
+  if (!q) return menuGroups.value;
+  return menuGroups.value
+    .map((g) => {
+      const filteredChildren = g.children?.filter((c) =>
+        (c.title || c.name || "").toLowerCase().includes(q),
+      );
+      const groupMatch = (g.title || g.name || "").toLowerCase().includes(q);
+      if (groupMatch) return g;
+      if (filteredChildren && filteredChildren.length > 0) {
+        return { ...g, children: filteredChildren };
+      }
+      return null;
+    })
+    .filter(Boolean) as TreeMenuItem[];
+});
+
+// ======== 菜单点击处理(复用 MenuItem.vue 的 handleClick 核心逻辑)========
+
+/** 根据菜单 ID 在树中查找菜单项 */
+function findMenuById(
+  menus: TreeMenuItem[],
+  id: string,
+): TreeMenuItem | undefined {
+  for (const m of menus) {
+    if (String(m.id) === id) return m;
+    if (m.children) {
+      const found = findMenuById(m.children, id);
+      if (found) return found;
+    }
+  }
+  return undefined;
+}
+
+/** el-menu select 回调处理 */
+function handleMenuSelect(index: string) {
+  const menu = findMenuById(menuGroups.value, index);
+  if (!menu) return;
+
+  if (hasChildren(menu)) {
+    // 有子菜单的组 — 由 el-sub-menu 自行处理展开/折叠,不做路由跳转
+    return;
+  }
+
+  // 叶子菜单 — 复用 MenuItem.vue 的核心逻辑
+  openMenuTab({ url: menu.path, title: renderMenuTitle(menu) });
+  menuStore.setActiveMenu(menu);
+}
+
+/** Logo 点击:跳转到首页 / 工作台 */
+function handleLogoClick() {
+  // 如果有 工作台 / dashboard 菜单则跳转,否则跳根路径
+  const dashboard = menuGroups.value.find(
+    (m) => m.path === "/" || (m.title || m.name) === "工作台",
+  );
+  if (dashboard) {
+    openMenuTab({ url: dashboard.path, title: renderMenuTitle(dashboard) });
+    menuStore.setActiveMenu(dashboard);
+  }
+}
+
+// ======== 折叠态:大类图标栏 + 右侧弹出子菜单面板 ========
+
+/** 当前弹出子菜单的大类(null 表示未弹出) */
+const flyoutGroup = ref<TreeMenuItem | null>(null);
+
+/** 弹出面板的 top 坐标(与点击的大类图标垂直对齐) */
+const flyoutTop = ref(0);
+
+/** 展开侧边栏时关闭弹出面板 */
+watch(() => props.collapsed, (val) => {
+  if (!val) closeFlyout();
+});
+
+/** 打开大类子菜单面板:与图标垂直对齐,防止超出视口底部 */
+function openFlyout(group: TreeMenuItem, trigger: HTMLElement) {
+  const rect = trigger.getBoundingClientRect();
+  const top = Math.min(Math.max(8, rect.top), window.innerHeight - 360);
+  flyoutTop.value = top;
+  flyoutGroup.value = group;
+}
+
+/** 关闭大类子菜单面板 */
+function closeFlyout() {
+  flyoutGroup.value = null;
+}
+
+/** 跳转到叶子菜单并记录激活态 */
+function handleLeafSelect(menu: TreeMenuItem) {
+  openMenuTab({ url: menu.path, title: renderMenuTitle(menu) });
+  menuStore.setActiveMenu(menu);
+}
+
+/** 折叠态点击大类图标:有子菜单 → 右侧弹出子菜单;叶子 → 直接跳转 */
+function handleRailClick(group: TreeMenuItem, e: MouseEvent) {
+  if (hasVisibleChildren(group)) {
+    openFlyout(group, e.currentTarget as HTMLElement);
+  } else {
+    handleLeafSelect(group);
+  }
+}
+
+/** 点击弹出面板中的子菜单项:跳转并关闭面板 */
+function handleFlyoutSelect(menu: TreeMenuItem) {
+  handleLeafSelect(menu);
+  closeFlyout();
+}
+
+/** 大类是否包含当前激活菜单(用于图标高亮) */
+function isGroupActive(group: TreeMenuItem): boolean {
+  const current = activeMenu.value;
+  if (!current) return false;
+  return isChildMenu(current, group);
+}
+
+/** 叶子菜单是否为当前激活菜单 */
+function isLeafActive(leaf: TreeMenuItem): boolean {
+  return activeMenu.value?.id === leaf.id;
+}
+</script>
+
+<style scoped lang="scss">
+.cube-side-menu {
+  // 自带布局变量回退:脱离业务系统的 variables.css 也能正常渲染
+  --cube-layout-sidebar-width: 220px;
+  --cube-layout-sidebar-collapsed-width: 64px;
+  --cube-layout-sidebar-bg: var(--el-bg-color);
+  --cube-layout-sidebar-border-color: var(--el-border-color-light);
+  --cube-layout-z-sidebar: 100;
+  --cube-layout-transition-duration: 0.25s;
+  --cube-layout-menu-item-hover-bg: var(--el-fill-color-light);
+  --cube-layout-menu-item-active-bg: var(--el-color-primary-light-9);
+  --cube-layout-menu-item-active-color: var(--el-color-primary);
+  --cube-layout-menu-item-active-border-color: var(--el-color-primary);
+  --cube-layout-menu-icon-active-color: var(--el-color-primary);
+
+  position: fixed;
+  left: 0;
+  top: 0;
+  width: var(--cube-layout-sidebar-width, 220px);
+  min-width: var(--cube-layout-sidebar-width, 220px);
+  height: 100vh;
+  background: var(--cube-layout-sidebar-bg);
+  border-right: 1px solid var(--cube-layout-sidebar-border-color);
+  z-index: var(--cube-layout-z-sidebar, 100);
+  display: flex;
+  flex-direction: column;
+  transition: width var(--cube-layout-transition-duration, 0.25s) ease;
+  overflow: hidden;
+
+  &--collapsed {
+    width: var(--cube-layout-sidebar-collapsed-width, 64px);
+    min-width: var(--cube-layout-sidebar-collapsed-width, 64px);
+
+    .cube-side-menu-logo {
+      padding: 16px 0;
+      justify-content: center;
+    }
+  }
+}
+
+// ---- 品牌区 ----
+.cube-side-menu-logo {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  padding: 16px 20px;
+  border-bottom: 1px solid var(--cube-layout-sidebar-border-color);
+  cursor: pointer;
+  user-select: none;
+}
+
+// ---- 搜索框 ----
+.cube-side-menu-search {
+  padding: 8px 12px;
+  border-bottom: 1px solid var(--el-border-color-light);
+
+  :deep(.el-input) {
+    --el-input-bg-color: var(--el-bg-color-page);
+  }
+}
+
+// ---- 菜单区域 ----
+.cube-side-menu-menu-wrapper {
+  flex: 1;
+  overflow-y: auto;
+  overflow-x: hidden;
+  padding: 8px 0;
+
+  /* 自定义 el-menu 样式 */
+  :deep(.el-menu) {
+    border-right: none;
+    background: transparent;
+
+    /* 子菜单标题 */
+    .el-sub-menu__title {
+      display: flex;
+      align-items: center;
+      gap: 8px;
+      height: 40px;
+      line-height: 40px;
+      padding: 0 16px !important;
+      font-size: 13px;
+      color: var(--el-text-color-regular);
+      border-left: 3px solid transparent;
+      transition: all 0.2s ease;
+
+      &:hover {
+        background: var(--cube-layout-menu-item-hover-bg);
+        color: var(--el-text-color-primary);
+      }
+
+      .el-icon {
+        color: var(--el-text-color-secondary);
+        flex-shrink: 0;
+      }
+    }
+
+    /* 菜单项 */
+    .el-menu-item {
+      display: flex;
+      align-items: center;
+      gap: 8px;
+      height: 36px;
+      line-height: 36px;
+      padding: 0 16px 0 28px !important;
+      font-size: 13px;
+      color: var(--el-text-color-regular);
+      border-left: 3px solid transparent;
+      transition: all 0.2s ease;
+
+      &:hover {
+        background: var(--cube-layout-menu-item-hover-bg);
+        color: var(--el-text-color-primary);
+      }
+
+      &.is-active {
+        background: var(--cube-layout-menu-item-active-bg);
+        color: var(--cube-layout-menu-item-active-color);
+        border-left-color: var(--cube-layout-menu-item-active-border-color);
+        font-weight: 500;
+
+        .el-icon {
+          color: var(--cube-layout-menu-icon-active-color);
+        }
+      }
+
+      .el-icon {
+        color: var(--el-text-color-secondary);
+        flex-shrink: 0;
+      }
+    }
+
+    /* 展开箭头覆写 */
+    .el-sub-menu__icon-arrow {
+      font-size: 12px;
+      transition: transform 0.2s;
+    }
+  }
+}
+
+// ---- 底部折叠 / 展开按钮 ----
+.cube-side-menu-footer {
+  flex-shrink: 0;
+  padding: 8px 0;
+  border-top: 1px solid var(--cube-layout-sidebar-border-color);
+  display: flex;
+  justify-content: center;
+}
+
+.cube-side-menu-collapse-btn {
+  width: 32px;
+  height: 32px;
+  border: none;
+  border-radius: 8px;
+  background: transparent;
+  color: var(--el-text-color-secondary);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  cursor: pointer;
+  transition: all 0.2s;
+
+  &:hover {
+    background: var(--cube-layout-menu-item-hover-bg);
+    color: var(--el-color-primary);
+  }
+}
+
+// ---- 折叠态:大类图标栏 ----
+.cube-side-menu-rail {
+  flex: 1;
+  overflow-y: auto;
+  overflow-x: hidden;
+  padding: 12px 0;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 4px;
+}
+
+.cube-side-menu-rail-item {
+  flex-shrink: 0;
+}
+
+.cube-side-menu-rail-btn {
+  width: 44px;
+  height: 44px;
+  border-radius: 10px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: var(--el-text-color-secondary);
+  cursor: pointer;
+  transition: all 0.2s;
+
+  &:hover {
+    background: var(--cube-layout-menu-item-hover-bg);
+    color: var(--el-color-primary);
+  }
+
+  &.is-active {
+    background: var(--cube-layout-menu-item-active-bg);
+    color: var(--cube-layout-menu-icon-active-color);
+  }
+}
+
+// ---- 折叠态:弹出面板(Teleport 到 body,点击遮罩关闭) ----
+.cube-side-menu-flyout-mask {
+  position: fixed;
+  inset: 0;
+  z-index: calc(var(--cube-layout-z-sidebar, 100) + 1);
+  background: var(--el-mask-color-extra-light);
+}
+
+.cube-side-menu-flyout {
+  position: fixed;
+  left: calc(var(--cube-layout-sidebar-collapsed-width, 64px) + 8px);
+  min-width: 180px;
+  max-width: 240px;
+  max-height: 380px;
+  overflow-y: auto;
+  z-index: calc(var(--cube-layout-z-sidebar, 100) + 2);
+  background: var(--el-bg-color-overlay);
+  border: 1px solid var(--cube-layout-sidebar-border-color);
+  border-radius: 10px;
+  box-shadow: var(--el-box-shadow-light);
+  padding: 6px;
+
+  .cube-side-menu-flyout-title {
+    padding: 8px 12px;
+    font-size: 13px;
+    font-weight: 600;
+    color: var(--el-text-color-primary);
+    border-bottom: 1px solid var(--el-border-color-light);
+  }
+
+  .cube-side-menu-flyout-items {
+    padding: 4px 0;
+  }
+
+  .cube-side-menu-flyout-item {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    height: 36px;
+    line-height: 36px;
+    padding: 0 12px;
+    font-size: 13px;
+    color: var(--el-text-color-regular);
+    border-radius: 6px;
+    cursor: pointer;
+    transition: all 0.2s;
+
+    &:hover {
+      background: var(--cube-layout-menu-item-hover-bg);
+      color: var(--el-text-color-primary);
+    }
+
+    &.is-active {
+      background: var(--cube-layout-menu-item-active-bg);
+      color: var(--cube-layout-menu-item-active-color);
+      font-weight: 500;
+    }
+
+    .el-icon {
+      color: var(--el-text-color-secondary);
+      flex-shrink: 0;
+    }
+  }
+
+  .cube-side-menu-flyout-subgroup {
+    .cube-side-menu-flyout-subgroup-title {
+      padding: 8px 12px 4px;
+      font-size: 11px;
+      color: var(--el-text-color-secondary);
+      letter-spacing: 1px;
+      text-transform: uppercase;
+    }
+  }
+}
+</style>