123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561 |
- <script setup lang="ts">
- import type { VbenLayoutProps } from './vben-layout';
- import type { CSSProperties } from 'vue';
- import { computed, ref, watch } from 'vue';
- import { Menu } from '@vben-core/icons';
- import { VbenIconButton } from '@vben-core/shadcn-ui';
- import { useMouse, useScroll, useThrottleFn } from '@vueuse/core';
- import {
- LayoutContent,
- LayoutFooter,
- LayoutHeader,
- LayoutSidebar,
- LayoutTabbar,
- } from './components';
- import { useLayout } from './hooks/use-layout';
- interface Props extends VbenLayoutProps {}
- defineOptions({
- name: 'VbenLayout',
- });
- const props = withDefaults(defineProps<Props>(), {
- contentCompact: 'wide',
- contentCompactWidth: 1200,
- contentPadding: 0,
- contentPaddingBottom: 0,
- contentPaddingLeft: 0,
- contentPaddingRight: 0,
- contentPaddingTop: 0,
- footerEnable: false,
- footerFixed: true,
- footerHeight: 32,
- headerHeight: 50,
- headerHidden: false,
- headerMode: 'fixed',
- headerToggleSidebarButton: true,
- headerVisible: true,
- isMobile: false,
- layout: 'sidebar-nav',
- sidebarCollapseShowTitle: false,
- sidebarExtraCollapsedWidth: 60,
- sidebarHidden: false,
- sidebarMixedWidth: 80,
- sidebarTheme: 'dark',
- sidebarWidth: 180,
- sideCollapseWidth: 60,
- tabbarEnable: true,
- tabbarHeight: 40,
- zIndex: 200,
- });
- const emit = defineEmits<{ sideMouseLeave: []; toggleSidebar: [] }>();
- const sidebarCollapse = defineModel<boolean>('sidebarCollapse');
- const sidebarExtraVisible = defineModel<boolean>('sidebarExtraVisible');
- const sidebarExtraCollapse = defineModel<boolean>('sidebarExtraCollapse');
- const sidebarExpandOnHover = defineModel<boolean>('sidebarExpandOnHover');
- const sidebarEnable = defineModel<boolean>('sidebarEnable', { default: true });
- // side是否处于hover状态展开菜单中
- const sidebarExpandOnHovering = ref(false);
- const headerIsHidden = ref(false);
- const contentRef = ref();
- const {
- arrivedState,
- directions,
- isScrolling,
- y: scrollY,
- } = useScroll(document);
- const { y: mouseY } = useMouse({ target: contentRef, type: 'client' });
- const {
- currentLayout,
- isFullContent,
- isHeaderNav,
- isMixedNav,
- isSidebarMixedNav,
- } = useLayout(props);
- /**
- * 顶栏是否自动隐藏
- */
- const isHeaderAutoMode = computed(() => props.headerMode === 'auto');
- const headerWrapperHeight = computed(() => {
- let height = 0;
- if (props.headerVisible && !props.headerHidden) {
- height += props.headerHeight;
- }
- if (props.tabbarEnable) {
- height += props.tabbarHeight;
- }
- return height;
- });
- const getSideCollapseWidth = computed(() => {
- const { sidebarCollapseShowTitle, sidebarMixedWidth, sideCollapseWidth } =
- props;
- return sidebarCollapseShowTitle || isSidebarMixedNav.value
- ? sidebarMixedWidth
- : sideCollapseWidth;
- });
- /**
- * 动态获取侧边区域是否可见
- */
- const sidebarEnableState = computed(() => {
- return !isHeaderNav.value && sidebarEnable.value;
- });
- /**
- * 侧边区域离顶部高度
- */
- const sidebarMarginTop = computed(() => {
- const { headerHeight, isMobile } = props;
- return isMixedNav.value && !isMobile ? headerHeight : 0;
- });
- /**
- * 动态获取侧边宽度
- */
- const getSidebarWidth = computed(() => {
- const { isMobile, sidebarHidden, sidebarMixedWidth, sidebarWidth } = props;
- let width = 0;
- if (sidebarHidden) {
- return width;
- }
- if (
- !sidebarEnableState.value ||
- (sidebarHidden && !isSidebarMixedNav.value && !isMixedNav.value)
- ) {
- return width;
- }
- if (isSidebarMixedNav.value && !isMobile) {
- width = sidebarMixedWidth;
- } else if (sidebarCollapse.value) {
- width = isMobile ? 0 : getSideCollapseWidth.value;
- } else {
- width = sidebarWidth;
- }
- return width;
- });
- /**
- * 获取扩展区域宽度
- */
- const sidebarExtraWidth = computed(() => {
- const { sidebarExtraCollapsedWidth, sidebarWidth } = props;
- return sidebarExtraCollapse.value ? sidebarExtraCollapsedWidth : sidebarWidth;
- });
- /**
- * 是否侧边栏模式,包含混合侧边
- */
- const isSideMode = computed(
- () =>
- currentLayout.value === 'mixed-nav' ||
- currentLayout.value === 'sidebar-mixed-nav' ||
- currentLayout.value === 'sidebar-nav',
- );
- /**
- * header fixed值
- */
- const headerFixed = computed(() => {
- const { headerMode } = props;
- return (
- isMixedNav.value ||
- headerMode === 'fixed' ||
- headerMode === 'auto-scroll' ||
- headerMode === 'auto'
- );
- });
- const showSidebar = computed(() => {
- return isSideMode.value && sidebarEnable.value;
- });
- /**
- * 遮罩可见性
- */
- const maskVisible = computed(() => !sidebarCollapse.value && props.isMobile);
- const mainStyle = computed(() => {
- let width = '100%';
- let sidebarAndExtraWidth = 'unset';
- if (
- headerFixed.value &&
- currentLayout.value !== 'header-nav' &&
- currentLayout.value !== 'mixed-nav' &&
- showSidebar.value &&
- !props.isMobile
- ) {
- // fixed模式下生效
- const isSideNavEffective =
- isSidebarMixedNav.value &&
- sidebarExpandOnHover.value &&
- sidebarExtraVisible.value;
- if (isSideNavEffective) {
- const sideCollapseWidth = sidebarCollapse.value
- ? getSideCollapseWidth.value
- : props.sidebarMixedWidth;
- const sideWidth = sidebarExtraCollapse.value
- ? props.sidebarExtraCollapsedWidth
- : props.sidebarWidth;
- // 100% - 侧边菜单混合宽度 - 菜单宽度
- sidebarAndExtraWidth = `${sideCollapseWidth + sideWidth}px`;
- width = `calc(100% - ${sidebarAndExtraWidth})`;
- } else {
- sidebarAndExtraWidth =
- sidebarExpandOnHovering.value && !sidebarExpandOnHover.value
- ? `${getSideCollapseWidth.value}px`
- : `${getSidebarWidth.value}px`;
- width = `calc(100% - ${sidebarAndExtraWidth})`;
- }
- }
- return {
- sidebarAndExtraWidth,
- width,
- };
- });
- // 计算 tabbar 的样式
- const tabbarStyle = computed((): CSSProperties => {
- let width = '';
- let marginLeft = 0;
- // 如果不是混合导航,tabbar 的宽度为 100%
- if (!isMixedNav.value || props.sidebarHidden) {
- width = '100%';
- } else if (sidebarEnable.value) {
- // 鼠标在侧边栏上时,且侧边栏展开时的宽度
- const onHoveringWidth = sidebarExpandOnHover.value
- ? props.sidebarWidth
- : getSideCollapseWidth.value;
- // 设置 marginLeft,根据侧边栏是否折叠来决定
- marginLeft = sidebarCollapse.value
- ? getSideCollapseWidth.value
- : onHoveringWidth;
- // 设置 tabbar 的宽度,计算方式为 100% 减去侧边栏的宽度
- width = `calc(100% - ${sidebarCollapse.value ? getSidebarWidth.value : onHoveringWidth}px)`;
- } else {
- // 默认情况下,tabbar 的宽度为 100%
- width = '100%';
- }
- return {
- marginLeft: `${marginLeft}px`,
- width,
- };
- });
- const contentStyle = computed((): CSSProperties => {
- const fixed = headerFixed.value;
- const { footerEnable, footerFixed, footerHeight } = props;
- return {
- marginTop:
- fixed &&
- !isFullContent.value &&
- !headerIsHidden.value &&
- (!isHeaderAutoMode.value || scrollY.value < headerWrapperHeight.value)
- ? `${headerWrapperHeight.value}px`
- : 0,
- paddingBottom: `${footerEnable && footerFixed ? footerHeight : 0}px`,
- };
- });
- const headerZIndex = computed(() => {
- const { zIndex } = props;
- const offset = isMixedNav.value ? 1 : 0;
- return zIndex + offset;
- });
- const headerWrapperStyle = computed((): CSSProperties => {
- const fixed = headerFixed.value;
- return {
- height: isFullContent.value ? '0' : `${headerWrapperHeight.value}px`,
- left: isMixedNav.value ? 0 : mainStyle.value.sidebarAndExtraWidth,
- position: fixed ? 'fixed' : 'static',
- top:
- headerIsHidden.value || isFullContent.value
- ? `-${headerWrapperHeight.value}px`
- : 0,
- width: mainStyle.value.width,
- 'z-index': headerZIndex.value,
- };
- });
- /**
- * 侧边栏z-index
- */
- const sidebarZIndex = computed(() => {
- const { isMobile, zIndex } = props;
- let offset = isMobile || isSideMode.value ? 1 : -1;
- if (isMixedNav.value) {
- offset += 1;
- }
- return zIndex + offset;
- });
- const footerWidth = computed(() => {
- if (!props.footerFixed) {
- return '100%';
- }
- return mainStyle.value.width;
- });
- const maskStyle = computed((): CSSProperties => {
- return { zIndex: props.zIndex };
- });
- const showHeaderToggleButton = computed(() => {
- return (
- props.isMobile ||
- (props.headerToggleSidebarButton &&
- isSideMode.value &&
- !isSidebarMixedNav.value &&
- !isMixedNav.value &&
- !props.isMobile)
- );
- });
- const showHeaderLogo = computed(() => {
- return !isSideMode.value || isMixedNav.value || props.isMobile;
- });
- watch(
- () => props.isMobile,
- (val) => {
- if (val) {
- sidebarCollapse.value = true;
- }
- },
- {
- immediate: true,
- },
- );
- {
- const mouseMove = () => {
- mouseY.value > headerWrapperHeight.value
- ? (headerIsHidden.value = true)
- : (headerIsHidden.value = false);
- };
- watch(
- [() => props.headerMode, () => mouseY.value],
- () => {
- if (!isHeaderAutoMode.value || isMixedNav.value || isFullContent.value) {
- if (props.headerMode !== 'auto-scroll') {
- headerIsHidden.value = false;
- }
- return;
- }
- headerIsHidden.value = true;
- mouseMove();
- },
- {
- immediate: true,
- },
- );
- }
- {
- const checkHeaderIsHidden = useThrottleFn((top, bottom, topArrived) => {
- if (scrollY.value < headerWrapperHeight.value) {
- headerIsHidden.value = false;
- return;
- }
- if (topArrived) {
- headerIsHidden.value = false;
- return;
- }
- if (top) {
- headerIsHidden.value = false;
- } else if (bottom) {
- headerIsHidden.value = true;
- }
- }, 300);
- watch(
- () => scrollY.value,
- () => {
- if (
- props.headerMode !== 'auto-scroll' ||
- isMixedNav.value ||
- isFullContent.value
- ) {
- return;
- }
- if (isScrolling.value) {
- checkHeaderIsHidden(
- directions.top,
- directions.bottom,
- arrivedState.top,
- );
- }
- },
- );
- }
- function handleClickMask() {
- sidebarCollapse.value = true;
- }
- function handleHeaderToggle() {
- if (props.isMobile) {
- sidebarCollapse.value = false;
- } else {
- emit('toggleSidebar');
- }
- }
- </script>
- <template>
- <div class="relative flex min-h-full w-full">
- <LayoutSidebar
- v-if="sidebarEnableState"
- v-model:collapse="sidebarCollapse"
- v-model:expand-on-hover="sidebarExpandOnHover"
- v-model:expand-on-hovering="sidebarExpandOnHovering"
- v-model:extra-collapse="sidebarExtraCollapse"
- v-model:extra-visible="sidebarExtraVisible"
- :collapse-width="getSideCollapseWidth"
- :dom-visible="!isMobile"
- :extra-width="sidebarExtraWidth"
- :fixed-extra="sidebarExpandOnHover"
- :header-height="isMixedNav ? 0 : headerHeight"
- :is-sidebar-mixed="isSidebarMixedNav"
- :margin-top="sidebarMarginTop"
- :mixed-width="sidebarMixedWidth"
- :show="showSidebar"
- :theme="sidebarTheme"
- :width="getSidebarWidth"
- :z-index="sidebarZIndex"
- @leave="() => emit('sideMouseLeave')"
- >
- <template v-if="isSideMode && !isMixedNav" #logo>
- <slot name="logo"></slot>
- </template>
- <template v-if="isSidebarMixedNav">
- <slot name="mixed-menu"></slot>
- </template>
- <template v-else>
- <slot name="menu"></slot>
- </template>
- <template #extra>
- <slot name="side-extra"></slot>
- </template>
- <template #extra-title>
- <slot name="side-extra-title"></slot>
- </template>
- </LayoutSidebar>
- <div
- ref="contentRef"
- class="flex flex-1 flex-col overflow-hidden transition-all duration-300 ease-in"
- >
- <div
- :class="{
- 'shadow-[0_16px_24px_hsl(var(--background))]': scrollY > 20,
- }"
- :style="headerWrapperStyle"
- class="overflow-hidden transition-all duration-200"
- >
- <LayoutHeader
- v-if="headerVisible"
- :full-width="!isSideMode"
- :height="headerHeight"
- :is-mobile="isMobile"
- :show="!isFullContent && !headerHidden"
- :sidebar-width="sidebarWidth"
- :theme="headerTheme"
- :width="mainStyle.width"
- :z-index="headerZIndex"
- >
- <template v-if="showHeaderLogo" #logo>
- <slot name="logo"></slot>
- </template>
- <template #toggle-button>
- <VbenIconButton
- v-if="showHeaderToggleButton"
- class="my-0 ml-2 mr-1 rounded-md"
- @click="handleHeaderToggle"
- >
- <Menu class="size-4" />
- </VbenIconButton>
- </template>
- <slot name="header"></slot>
- </LayoutHeader>
- <LayoutTabbar
- v-if="tabbarEnable"
- :height="tabbarHeight"
- :style="tabbarStyle"
- >
- <slot name="tabbar"></slot>
- </LayoutTabbar>
- </div>
- <!-- </div> -->
- <LayoutContent
- :content-compact="contentCompact"
- :content-compact-width="contentCompactWidth"
- :padding="contentPadding"
- :padding-bottom="contentPaddingBottom"
- :padding-left="contentPaddingLeft"
- :padding-right="contentPaddingRight"
- :padding-top="contentPaddingTop"
- :style="contentStyle"
- class="transition-[margin-top] duration-200"
- >
- <slot name="content"></slot>
- <template #overlay>
- <slot name="content-overlay"></slot>
- </template>
- </LayoutContent>
- <LayoutFooter
- v-if="footerEnable"
- :fixed="footerFixed"
- :height="footerHeight"
- :show="!isFullContent"
- :width="footerWidth"
- :z-index="zIndex"
- >
- <slot name="footer"></slot>
- </LayoutFooter>
- </div>
- <slot name="extra"></slot>
- <div
- v-if="maskVisible"
- :style="maskStyle"
- class="bg-overlay fixed left-0 top-0 h-full w-full transition-[background-color] duration-200"
- @click="handleClickMask"
- ></div>
- </div>
- </template>
|