浏览代码

feat: header mixed layout (#5263)

* feat: new layout header-mixed

* fix: header-mixed layout update

* feat: layout preference update

* fix: extra menus follow layout setting
Netfan 2 月之前
父节点
当前提交
ff8d5ca351

+ 1 - 0
packages/@core/base/typings/src/app.d.ts

@@ -1,5 +1,6 @@
 type LayoutType =
   | 'full-content'
+  | 'header-mixed-nav'
   | 'header-nav'
   | 'mixed-nav'
   | 'sidebar-mixed-nav'

+ 11 - 1
packages/@core/preferences/src/use-preferences.ts

@@ -82,6 +82,10 @@ function usePreferences() {
     () => appPreferences.value.layout === 'header-nav',
   );
 
+  const isHeaderMixedNav = computed(
+    () => appPreferences.value.layout === 'header-mixed-nav',
+  );
+
   /**
    * @zh_CN 是否为混合导航模式
    */
@@ -93,7 +97,12 @@ function usePreferences() {
    * @zh_CN 是否包含侧边导航模式
    */
   const isSideMode = computed(() => {
-    return isMixedNav.value || isSideMixedNav.value || isSideNav.value;
+    return (
+      isMixedNav.value ||
+      isSideMixedNav.value ||
+      isSideNav.value ||
+      isHeaderMixedNav.value
+    );
   });
 
   const sidebarCollapsed = computed(() => {
@@ -214,6 +223,7 @@ function usePreferences() {
     globalSearchShortcutKey,
     isDark,
     isFullContent,
+    isHeaderMixedNav,
     isHeaderNav,
     isMixedNav,
     isMobile,

+ 8 - 0
packages/@core/ui-kit/layout-ui/src/hooks/use-layout.ts

@@ -31,9 +31,17 @@ export function useLayout(props: VbenLayoutProps) {
    */
   const isMixedNav = computed(() => currentLayout.value === 'mixed-nav');
 
+  /**
+   * 是否为头部混合模式
+   */
+  const isHeaderMixedNav = computed(
+    () => currentLayout.value === 'header-mixed-nav',
+  );
+
   return {
     currentLayout,
     isFullContent,
+    isHeaderMixedNav,
     isHeaderNav,
     isMixedNav,
     isSidebarMixedNav,

+ 14 - 7
packages/@core/ui-kit/layout-ui/src/vben-layout.vue

@@ -87,6 +87,7 @@ const { y: mouseY } = useMouse({ target: contentRef, type: 'client' });
 const {
   currentLayout,
   isFullContent,
+  isHeaderMixedNav,
   isHeaderNav,
   isMixedNav,
   isSidebarMixedNav,
@@ -112,7 +113,9 @@ const getSideCollapseWidth = computed(() => {
   const { sidebarCollapseShowTitle, sidebarMixedWidth, sideCollapseWidth } =
     props;
 
-  return sidebarCollapseShowTitle || isSidebarMixedNav.value
+  return sidebarCollapseShowTitle ||
+    isSidebarMixedNav.value ||
+    isHeaderMixedNav.value
     ? sidebarMixedWidth
     : sideCollapseWidth;
 });
@@ -145,12 +148,15 @@ const getSidebarWidth = computed(() => {
 
   if (
     !sidebarEnableState.value ||
-    (sidebarHidden && !isSidebarMixedNav.value && !isMixedNav.value)
+    (sidebarHidden &&
+      !isSidebarMixedNav.value &&
+      !isMixedNav.value &&
+      !isHeaderMixedNav.value)
   ) {
     return width;
   }
 
-  if (isSidebarMixedNav.value && !isMobile) {
+  if ((isHeaderMixedNav.value || isSidebarMixedNav.value) && !isMobile) {
     width = sidebarMixedWidth;
   } else if (sidebarCollapse.value) {
     width = isMobile ? 0 : getSideCollapseWidth.value;
@@ -176,7 +182,8 @@ const isSideMode = computed(
   () =>
     currentLayout.value === 'mixed-nav' ||
     currentLayout.value === 'sidebar-mixed-nav' ||
-    currentLayout.value === 'sidebar-nav',
+    currentLayout.value === 'sidebar-nav' ||
+    currentLayout.value === 'header-mixed-nav',
 );
 
 /**
@@ -213,7 +220,7 @@ const mainStyle = computed(() => {
   ) {
     // fixed模式下生效
     const isSideNavEffective =
-      isSidebarMixedNav.value &&
+      (isSidebarMixedNav.value || isHeaderMixedNav.value) &&
       sidebarExpandOnHover.value &&
       sidebarExtraVisible.value;
 
@@ -476,7 +483,7 @@ const idMainContent = ELEMENT_ID_MAIN_CONTENT;
       :extra-width="sidebarExtraWidth"
       :fixed-extra="sidebarExpandOnHover"
       :header-height="isMixedNav ? 0 : headerHeight"
-      :is-sidebar-mixed="isSidebarMixedNav"
+      :is-sidebar-mixed="isSidebarMixedNav || isHeaderMixedNav"
       :margin-top="sidebarMarginTop"
       :mixed-width="sidebarMixedWidth"
       :show="showSidebar"
@@ -489,7 +496,7 @@ const idMainContent = ELEMENT_ID_MAIN_CONTENT;
         <slot name="logo"></slot>
       </template>
 
-      <template v-if="isSidebarMixedNav">
+      <template v-if="isSidebarMixedNav || isHeaderMixedNav">
         <slot name="mixed-menu"></slot>
       </template>
       <template v-else>

+ 14 - 6
packages/effects/layouts/src/basic/layout.vue

@@ -1,7 +1,7 @@
 <script lang="ts" setup>
 import type { MenuRecordRaw } from '@vben/types';
 
-import { computed, useSlots, watch } from 'vue';
+import { computed, type SetupContext, useSlots, watch } from 'vue';
 
 import { useRefresh } from '@vben/hooks';
 import { $t } from '@vben/locales';
@@ -39,6 +39,7 @@ const {
   isMixedNav,
   isMobile,
   isSideMixedNav,
+  isHeaderMixedNav,
   layout,
   preferencesButtonPosition,
   sidebarCollapsed,
@@ -83,11 +84,16 @@ const logoCollapsed = computed(() => {
   if (isHeaderNav.value || isMixedNav.value) {
     return false;
   }
-  return sidebarCollapsed.value || isSideMixedNav.value;
+  return (
+    sidebarCollapsed.value || isSideMixedNav.value || isHeaderMixedNav.value
+  );
 });
 
 const showHeaderNav = computed(() => {
-  return !isMobile.value && (isHeaderNav.value || isMixedNav.value);
+  return (
+    !isMobile.value &&
+    (isHeaderNav.value || isMixedNav.value || isHeaderMixedNav.value)
+  );
 });
 
 // 侧边多列菜单
@@ -108,6 +114,8 @@ const {
   headerMenus,
   sidebarActive,
   sidebarMenus,
+  mixedSidebarActive,
+  mixHeaderMenus,
   sidebarVisible,
 } = useMixedMenu();
 
@@ -154,7 +162,7 @@ watch(
 // 语言更新后,刷新页面
 watch(() => preferences.app.locale, refresh, { flush: 'post' });
 
-const slots = useSlots();
+const slots: SetupContext['slots'] = useSlots();
 const headerSlots = computed(() => {
   return Object.keys(slots).filter((key) => key.startsWith('header-'));
 });
@@ -267,8 +275,8 @@ const headerSlots = computed(() => {
     </template>
     <template #mixed-menu>
       <LayoutMixedMenu
-        :active-path="extraActiveMenu"
-        :menus="wrapperMenus(headerMenus, false)"
+        :active-path="isHeaderMixedNav ? mixedSidebarActive : extraActiveMenu"
+        :menus="wrapperMenus(mixHeaderMenus, false)"
         :rounded="isMenuRounded"
         :theme="sidebarTheme"
         @default-select="handleDefaultSelect"

+ 37 - 20
packages/effects/layouts/src/basic/menu/use-extra-menu.ts

@@ -1,6 +1,6 @@
 import type { MenuRecordRaw } from '@vben/types';
 
-import { computed, ref, watch } from 'vue';
+import { computed, nextTick, ref, watch } from 'vue';
 import { useRoute } from 'vue-router';
 
 import { preferences } from '@vben/preferences';
@@ -17,7 +17,7 @@ function useExtraMenu() {
 
   /** 记录当前顶级菜单下哪个子菜单最后激活 */
   const defaultSubMap = new Map<string, string>();
-
+  const extraRootMenus = ref<MenuRecordRaw[]>([]);
   const route = useRoute();
   const extraMenus = ref<MenuRecordRaw[]>([]);
   const sidebarExtraVisible = ref<boolean>(false);
@@ -49,11 +49,13 @@ function useExtraMenu() {
    * @param menu
    * @param rootMenu
    */
-  const handleDefaultSelect = (
+  const handleDefaultSelect = async (
     menu: MenuRecordRaw,
     rootMenu?: MenuRecordRaw,
   ) => {
-    extraMenus.value = rootMenu?.children ?? [];
+    await nextTick();
+
+    extraMenus.value = rootMenu?.children ?? extraRootMenus.value ?? [];
     extraActiveMenu.value = menu.parents?.[0] ?? menu.path;
 
     if (preferences.sidebar.expandOnHover) {
@@ -65,17 +67,16 @@ function useExtraMenu() {
    * 侧边菜单鼠标移出事件
    */
   const handleSideMouseLeave = () => {
+    // const { findMenu, rootMenu, rootMenuPath } = findRootMenuByPath(
+    //   menus.value,
+    //   route.path,
+    // );
+    calcExtraMenus(route.path);
     if (preferences.sidebar.expandOnHover) {
+      sidebarExtraVisible.value = extraMenus.value.length > 0;
       return;
     }
     sidebarExtraVisible.value = false;
-
-    const { findMenu, rootMenu, rootMenuPath } = findRootMenuByPath(
-      menus.value,
-      route.path,
-    );
-    extraActiveMenu.value = rootMenuPath ?? findMenu?.path ?? '';
-    extraMenus.value = rootMenu?.children ?? [];
   };
 
   const handleMenuMouseEnter = (menu: MenuRecordRaw) => {
@@ -87,20 +88,36 @@ function useExtraMenu() {
     }
   };
 
-  watch(
-    () => route.path,
-    (path) => {
-      const currentPath = route.meta?.activePath || path;
-      // if (preferences.sidebar.expandOnHover) {
-      //   return;
-      // }
-      const { findMenu, rootMenu, rootMenuPath } = findRootMenuByPath(
-        menus.value,
+  function calcExtraMenus(path: string) {
+    const currentPath = route.meta?.activePath || path;
+    const { findMenu, rootMenu, rootMenuPath } = findRootMenuByPath(
+      menus.value,
+      currentPath,
+    );
+    if (preferences.app.layout === 'header-mixed-nav') {
+      const subExtra = findRootMenuByPath(
+        rootMenu?.children ?? [],
         currentPath,
+        1,
       );
+      extraRootMenus.value = subExtra.rootMenu?.children ?? [];
+      extraActiveMenu.value = subExtra.rootMenuPath ?? '';
+      extraMenus.value = subExtra.rootMenu?.children ?? [];
+    } else {
+      extraRootMenus.value = rootMenu?.children ?? [];
       if (rootMenuPath) defaultSubMap.set(rootMenuPath, currentPath);
       extraActiveMenu.value = rootMenuPath ?? findMenu?.path ?? '';
       extraMenus.value = rootMenu?.children ?? [];
+    }
+    if (preferences.sidebar.expandOnHover) {
+      sidebarExtraVisible.value = extraMenus.value.length > 0;
+    }
+  }
+
+  watch(
+    () => [route.path, preferences.app.layout],
+    ([path]) => {
+      calcExtraMenus(path || '');
     },
     { immediate: true },
   );

+ 20 - 2
packages/effects/layouts/src/basic/menu/use-mixed-menu.ts

@@ -15,12 +15,16 @@ function useMixedMenu() {
   const route = useRoute();
   const splitSideMenus = ref<MenuRecordRaw[]>([]);
   const rootMenuPath = ref<string>('');
+  const mixedRootMenuPath = ref<string>('');
+  const mixExtraMenus = ref<MenuRecordRaw[]>([]);
   /** 记录当前顶级菜单下哪个子菜单最后激活 */
   const defaultSubMap = new Map<string, string>();
-  const { isMixedNav } = usePreferences();
+  const { isMixedNav, isHeaderMixedNav } = usePreferences();
 
   const needSplit = computed(
-    () => preferences.navigation.split && isMixedNav.value,
+    () =>
+      (preferences.navigation.split && isMixedNav.value) ||
+      isHeaderMixedNav.value,
   );
 
   const sidebarVisible = computed(() => {
@@ -54,6 +58,10 @@ function useMixedMenu() {
     return needSplit.value ? splitSideMenus.value : menus.value;
   });
 
+  const mixHeaderMenus = computed(() => {
+    return isHeaderMixedNav.value ? sidebarMenus.value : headerMenus.value;
+  });
+
   /**
    * 侧边菜单激活路径
    */
@@ -61,6 +69,10 @@ function useMixedMenu() {
     return (route?.meta?.activePath as string) ?? route.path;
   });
 
+  const mixedSidebarActive = computed(() => {
+    return mixedRootMenuPath.value || sidebarActive.value;
+  });
+
   /**
    * 头部菜单激活路径
    */
@@ -118,6 +130,9 @@ function useMixedMenu() {
     if (!rootMenu) {
       rootMenu = menus.value.find((item) => item.path === path);
     }
+    const result = findRootMenuByPath(rootMenu?.children || [], path, 1);
+    mixedRootMenuPath.value = result.rootMenuPath ?? '';
+    mixExtraMenus.value = result.rootMenu?.children ?? [];
     rootMenuPath.value = rootMenu?.path ?? '';
     splitSideMenus.value = rootMenu?.children ?? [];
   }
@@ -145,6 +160,9 @@ function useMixedMenu() {
     headerMenus,
     sidebarActive,
     sidebarMenus,
+    mixedSidebarActive,
+    mixHeaderMenus,
+    mixExtraMenus,
     sidebarVisible,
   };
 }

+ 7 - 0
packages/effects/layouts/src/widgets/preferences/blocks/layout/layout.vue

@@ -9,6 +9,7 @@ import { VbenTooltip } from '@vben-core/shadcn-ui';
 
 import {
   FullContent,
+  HeaderMixedNav,
   HeaderNav,
   MixedNav,
   SidebarMixedNav,
@@ -33,6 +34,7 @@ const components: Record<LayoutType, Component> = {
   'mixed-nav': MixedNav,
   'sidebar-mixed-nav': SidebarMixedNav,
   'sidebar-nav': SidebarNav,
+  'header-mixed-nav': HeaderMixedNav,
 };
 
 const PRESET = computed((): PresetItem[] => [
@@ -56,6 +58,11 @@ const PRESET = computed((): PresetItem[] => [
     tip: $t('preferences.mixedMenuTip'),
     type: 'mixed-nav',
   },
+  {
+    name: $t('preferences.headerTwoColumn'),
+    tip: $t('preferences.headerTwoColumnTip'),
+    type: 'header-mixed-nav',
+  },
   {
     name: $t('preferences.fullContent'),
     tip: $t('preferences.fullContentTip'),

+ 1 - 1
packages/effects/layouts/src/widgets/preferences/blocks/layout/sidebar.vue

@@ -44,7 +44,7 @@ const sidebarExpandOnHover = defineModel<boolean>('sidebarExpandOnHover');
     v-model="sidebarAutoActivateChild"
     :disabled="
       !sidebarEnable ||
-      !['sidebar-mixed-nav', 'mixed-nav', 'sidebar-nav'].includes(
+      !['sidebar-mixed-nav', 'mixed-nav', 'header-mixed-nav'].includes(
         currentLayout as string,
       ) ||
       disabled

+ 202 - 0
packages/effects/layouts/src/widgets/preferences/icons/header-mixed-nav.vue

@@ -0,0 +1,202 @@
+<template>
+  <svg
+    class="custom-radio-image"
+    fill="none"
+    height="66"
+    width="104"
+    xmlns="http://www.w3.org/2000/svg"
+  >
+    <g>
+      <rect
+        id="svg_1"
+        fill="currentColor"
+        fill-opacity="0.02"
+        height="66"
+        rx="4"
+        stroke="null"
+        width="104"
+        x="0.13514"
+        y="0.13514"
+      />
+      <path
+        id="svg_2"
+        d="m-3.37838,3.7543a1.93401,4.02457 0 0 1 1.93401,-4.02457l11.3488,0l0,66.40541l-11.3488,0a1.93401,4.02457 0 0 1 -1.93401,-4.02457l0,-58.35627z"
+        fill="hsl(var(--primary))"
+        stroke="null"
+      />
+      <rect
+        id="svg_3"
+        fill="#e5e5e5"
+        height="2.789"
+        rx="1.395"
+        stroke="null"
+        width="5.47439"
+        x="1.64059"
+        y="15.46086"
+      />
+      <rect
+        id="svg_4"
+        fill="#ffffff"
+        height="7.67897"
+        rx="2"
+        stroke="null"
+        width="8.18938"
+        x="0.58676"
+        y="1.42154"
+      />
+      <rect
+        id="svg_8"
+        fill="hsl(var(--primary))"
+        height="9.07027"
+        rx="2"
+        stroke="null"
+        width="75.91967"
+        x="25.38277"
+        y="1.42876"
+      />
+      <rect
+        id="svg_9"
+        fill="#b2b2b2"
+        height="4.4"
+        rx="1"
+        stroke="null"
+        width="3.925"
+        x="27.91529"
+        y="3.69284"
+      />
+      <rect
+        id="svg_10"
+        fill="#b2b2b2"
+        height="4.4"
+        rx="1"
+        stroke="null"
+        width="3.925"
+        x="80.75054"
+        y="3.62876"
+      />
+      <rect
+        id="svg_11"
+        fill="#b2b2b2"
+        height="4.4"
+        rx="1"
+        stroke="null"
+        width="3.925"
+        x="87.78868"
+        y="3.69981"
+      />
+      <rect
+        id="svg_12"
+        fill="#b2b2b2"
+        height="4.4"
+        rx="1"
+        stroke="null"
+        width="3.925"
+        x="94.6847"
+        y="3.62876"
+      />
+      <rect
+        id="svg_13"
+        fill="currentColor"
+        fill-opacity="0.08"
+        height="21.51892"
+        rx="2"
+        stroke="null"
+        width="42.9287"
+        x="58.75427"
+        y="14.613"
+      />
+      <rect
+        id="svg_14"
+        fill="currentColor"
+        fill-opacity="0.08"
+        height="20.97838"
+        rx="2"
+        stroke="null"
+        width="28.36894"
+        x="26.14342"
+        y="14.613"
+      />
+      <rect
+        id="svg_15"
+        fill="currentColor"
+        fill-opacity="0.08"
+        height="21.65405"
+        rx="2"
+        stroke="null"
+        width="75.09493"
+        x="26.34264"
+        y="39.68822"
+      />
+      <rect
+        id="svg_5"
+        fill="#e5e5e5"
+        height="2.789"
+        rx="1.395"
+        stroke="null"
+        width="5.47439"
+        x="1.79832"
+        y="28.39462"
+      />
+      <rect
+        id="svg_6"
+        fill="#e5e5e5"
+        height="2.789"
+        rx="1.395"
+        stroke="null"
+        width="5.47439"
+        x="1.64059"
+        y="41.80156"
+      />
+      <rect
+        id="svg_7"
+        fill="#e5e5e5"
+        height="2.789"
+        rx="1.395"
+        stroke="null"
+        width="5.47439"
+        x="1.64059"
+        y="55.36623"
+      />
+      <rect
+        id="svg_16"
+        fill="currentColor"
+        fill-opacity="0.08"
+        height="65.72065"
+        stroke="null"
+        width="12.49265"
+        x="9.85477"
+        y="-0.02618"
+      />
+      <rect
+        id="svg_21"
+        fill="#e5e5e5"
+        height="2.789"
+        rx="1.395"
+        stroke="null"
+        width="7.52486"
+        x="35.14924"
+        y="4.07319"
+      />
+      <rect
+        id="svg_22"
+        fill="#e5e5e5"
+        height="2.789"
+        rx="1.395"
+        stroke="null"
+        width="7.52486"
+        x="47.25735"
+        y="4.20832"
+      />
+      <rect
+        id="svg_23"
+        fill="#e5e5e5"
+        height="2.789"
+        rx="1.395"
+        stroke="null"
+        width="7.52486"
+        x="59.23033"
+        y="4.07319"
+      />
+    </g>
+  </svg>
+</template>

+ 1 - 0
packages/effects/layouts/src/widgets/preferences/icons/index.ts

@@ -2,6 +2,7 @@ import HeaderNav from './header-nav.vue';
 
 export { default as ContentCompact } from './content-compact.vue';
 export { default as FullContent } from './full-content.vue';
+export { default as HeaderMixedNav } from './header-mixed-nav.vue';
 export { default as MixedNav } from './mixed-nav.vue';
 export { default as SidebarMixedNav } from './sidebar-mixed-nav.vue';
 export { default as SidebarNav } from './sidebar-nav.vue';

+ 3 - 1
packages/locales/src/langs/zh-CN/preferences.json

@@ -17,7 +17,9 @@
   "horizontalTip": "水平菜单模式,菜单全部显示在顶部",
   "twoColumn": "双列菜单",
   "twoColumnTip": "垂直双列菜单模式",
-  "mixedMenu": "混合菜单",
+  "headerTwoColumn": "混合双列",
+  "headerTwoColumnTip": "双列、水平菜单共存模式",
+  "mixedMenu": "混合垂直",
   "mixedMenuTip": "垂直水平菜单共存",
   "fullContent": "内容全屏",
   "fullContentTip": "不显示任何菜单,只显示内容主体",

+ 2 - 2
packages/utils/src/helpers/find-menu-by-path.ts

@@ -21,9 +21,9 @@ function findMenuByPath(
  * @param menus
  * @param path
  */
-function findRootMenuByPath(menus: MenuRecordRaw[], path?: string) {
+function findRootMenuByPath(menus: MenuRecordRaw[], path?: string, level = 0) {
   const findMenu = findMenuByPath(menus, path);
-  const rootMenuPath = findMenu?.parents?.[0];
+  const rootMenuPath = findMenu?.parents?.[level];
   const rootMenu = rootMenuPath
     ? menus.find((item) => item.path === rootMenuPath)
     : undefined;