vben-layout.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612
  1. <script setup lang="ts">
  2. import type { CSSProperties } from 'vue';
  3. import type { VbenLayoutProps } from './vben-layout';
  4. import { computed, ref, watch } from 'vue';
  5. import {
  6. SCROLL_FIXED_CLASS,
  7. useLayoutFooterStyle,
  8. useLayoutHeaderStyle,
  9. } from '@vben-core/composables';
  10. import { Menu } from '@vben-core/icons';
  11. import { VbenIconButton } from '@vben-core/shadcn-ui';
  12. import { ELEMENT_ID_MAIN_CONTENT } from '@vben-core/shared/constants';
  13. import { useMouse, useScroll, useThrottleFn } from '@vueuse/core';
  14. import {
  15. LayoutContent,
  16. LayoutFooter,
  17. LayoutHeader,
  18. LayoutSidebar,
  19. LayoutTabbar,
  20. } from './components';
  21. import { useLayout } from './hooks/use-layout';
  22. interface Props extends VbenLayoutProps {}
  23. defineOptions({
  24. name: 'VbenLayout',
  25. });
  26. const props = withDefaults(defineProps<Props>(), {
  27. contentCompact: 'wide',
  28. contentCompactWidth: 1200,
  29. contentPadding: 0,
  30. contentPaddingBottom: 0,
  31. contentPaddingLeft: 0,
  32. contentPaddingRight: 0,
  33. contentPaddingTop: 0,
  34. footerEnable: false,
  35. footerFixed: true,
  36. footerHeight: 32,
  37. headerHeight: 50,
  38. headerHidden: false,
  39. headerMode: 'fixed',
  40. headerToggleSidebarButton: true,
  41. headerVisible: true,
  42. isMobile: false,
  43. layout: 'sidebar-nav',
  44. sidebarCollapseShowTitle: false,
  45. sidebarExtraCollapsedWidth: 60,
  46. sidebarHidden: false,
  47. sidebarMixedWidth: 80,
  48. sidebarTheme: 'dark',
  49. sidebarWidth: 180,
  50. sideCollapseWidth: 60,
  51. tabbarEnable: true,
  52. tabbarHeight: 40,
  53. zIndex: 200,
  54. });
  55. const emit = defineEmits<{ sideMouseLeave: []; toggleSidebar: [] }>();
  56. const sidebarCollapse = defineModel<boolean>('sidebarCollapse', {
  57. default: false,
  58. });
  59. const sidebarExtraVisible = defineModel<boolean>('sidebarExtraVisible');
  60. const sidebarExtraCollapse = defineModel<boolean>('sidebarExtraCollapse', {
  61. default: false,
  62. });
  63. const sidebarExpandOnHover = defineModel<boolean>('sidebarExpandOnHover', {
  64. default: false,
  65. });
  66. const sidebarEnable = defineModel<boolean>('sidebarEnable', { default: true });
  67. // side是否处于hover状态展开菜单中
  68. const sidebarExpandOnHovering = ref(false);
  69. const headerIsHidden = ref(false);
  70. const contentRef = ref();
  71. const {
  72. arrivedState,
  73. directions,
  74. isScrolling,
  75. y: scrollY,
  76. } = useScroll(document);
  77. const { setLayoutHeaderHeight } = useLayoutHeaderStyle();
  78. const { setLayoutFooterHeight } = useLayoutFooterStyle();
  79. const { y: mouseY } = useMouse({ target: contentRef, type: 'client' });
  80. const {
  81. currentLayout,
  82. isFullContent,
  83. isHeaderMixedNav,
  84. isHeaderNav,
  85. isMixedNav,
  86. isSidebarMixedNav,
  87. } = useLayout(props);
  88. /**
  89. * 顶栏是否自动隐藏
  90. */
  91. const isHeaderAutoMode = computed(() => props.headerMode === 'auto');
  92. const headerWrapperHeight = computed(() => {
  93. let height = 0;
  94. if (props.headerVisible && !props.headerHidden) {
  95. height += props.headerHeight;
  96. }
  97. if (props.tabbarEnable) {
  98. height += props.tabbarHeight;
  99. }
  100. return height;
  101. });
  102. const getSideCollapseWidth = computed(() => {
  103. const { sidebarCollapseShowTitle, sidebarMixedWidth, sideCollapseWidth } =
  104. props;
  105. return sidebarCollapseShowTitle ||
  106. isSidebarMixedNav.value ||
  107. isHeaderMixedNav.value
  108. ? sidebarMixedWidth
  109. : sideCollapseWidth;
  110. });
  111. /**
  112. * 动态获取侧边区域是否可见
  113. */
  114. const sidebarEnableState = computed(() => {
  115. return !isHeaderNav.value && sidebarEnable.value;
  116. });
  117. /**
  118. * 侧边区域离顶部高度
  119. */
  120. const sidebarMarginTop = computed(() => {
  121. const { headerHeight, isMobile } = props;
  122. return isMixedNav.value && !isMobile ? headerHeight : 0;
  123. });
  124. /**
  125. * 动态获取侧边宽度
  126. */
  127. const getSidebarWidth = computed(() => {
  128. const { isMobile, sidebarHidden, sidebarMixedWidth, sidebarWidth } = props;
  129. let width = 0;
  130. if (sidebarHidden) {
  131. return width;
  132. }
  133. if (
  134. !sidebarEnableState.value ||
  135. (sidebarHidden &&
  136. !isSidebarMixedNav.value &&
  137. !isMixedNav.value &&
  138. !isHeaderMixedNav.value)
  139. ) {
  140. return width;
  141. }
  142. if ((isHeaderMixedNav.value || isSidebarMixedNav.value) && !isMobile) {
  143. width = sidebarMixedWidth;
  144. } else if (sidebarCollapse.value) {
  145. width = isMobile ? 0 : getSideCollapseWidth.value;
  146. } else {
  147. width = sidebarWidth;
  148. }
  149. return width;
  150. });
  151. /**
  152. * 获取扩展区域宽度
  153. */
  154. const sidebarExtraWidth = computed(() => {
  155. const { sidebarExtraCollapsedWidth, sidebarWidth } = props;
  156. return sidebarExtraCollapse.value ? sidebarExtraCollapsedWidth : sidebarWidth;
  157. });
  158. /**
  159. * 是否侧边栏模式,包含混合侧边
  160. */
  161. const isSideMode = computed(
  162. () =>
  163. currentLayout.value === 'mixed-nav' ||
  164. currentLayout.value === 'sidebar-mixed-nav' ||
  165. currentLayout.value === 'sidebar-nav' ||
  166. currentLayout.value === 'header-mixed-nav' ||
  167. currentLayout.value === 'header-sidebar-nav',
  168. );
  169. /**
  170. * header fixed值
  171. */
  172. const headerFixed = computed(() => {
  173. const { headerMode } = props;
  174. return (
  175. isMixedNav.value ||
  176. headerMode === 'fixed' ||
  177. headerMode === 'auto-scroll' ||
  178. headerMode === 'auto'
  179. );
  180. });
  181. const showSidebar = computed(() => {
  182. return isSideMode.value && sidebarEnable.value && !props.sidebarHidden;
  183. });
  184. /**
  185. * 遮罩可见性
  186. */
  187. const maskVisible = computed(() => !sidebarCollapse.value && props.isMobile);
  188. const mainStyle = computed(() => {
  189. let width = '100%';
  190. let sidebarAndExtraWidth = 'unset';
  191. if (
  192. headerFixed.value &&
  193. currentLayout.value !== 'header-nav' &&
  194. currentLayout.value !== 'mixed-nav' &&
  195. currentLayout.value !== 'header-sidebar-nav' &&
  196. showSidebar.value &&
  197. !props.isMobile
  198. ) {
  199. // fixed模式下生效
  200. const isSideNavEffective =
  201. (isSidebarMixedNav.value || isHeaderMixedNav.value) &&
  202. sidebarExpandOnHover.value &&
  203. sidebarExtraVisible.value;
  204. if (isSideNavEffective) {
  205. const sideCollapseWidth = sidebarCollapse.value
  206. ? getSideCollapseWidth.value
  207. : props.sidebarMixedWidth;
  208. const sideWidth = sidebarExtraCollapse.value
  209. ? props.sidebarExtraCollapsedWidth
  210. : props.sidebarWidth;
  211. // 100% - 侧边菜单混合宽度 - 菜单宽度
  212. sidebarAndExtraWidth = `${sideCollapseWidth + sideWidth}px`;
  213. width = `calc(100% - ${sidebarAndExtraWidth})`;
  214. } else {
  215. sidebarAndExtraWidth =
  216. sidebarExpandOnHovering.value && !sidebarExpandOnHover.value
  217. ? `${getSideCollapseWidth.value}px`
  218. : `${getSidebarWidth.value}px`;
  219. width = `calc(100% - ${sidebarAndExtraWidth})`;
  220. }
  221. }
  222. return {
  223. sidebarAndExtraWidth,
  224. width,
  225. };
  226. });
  227. // 计算 tabbar 的样式
  228. const tabbarStyle = computed((): CSSProperties => {
  229. let width = '';
  230. let marginLeft = 0;
  231. // 如果不是混合导航,tabbar 的宽度为 100%
  232. if (!isMixedNav.value || props.sidebarHidden) {
  233. width = '100%';
  234. } else if (sidebarEnable.value) {
  235. // 鼠标在侧边栏上时,且侧边栏展开时的宽度
  236. const onHoveringWidth = sidebarExpandOnHover.value
  237. ? props.sidebarWidth
  238. : getSideCollapseWidth.value;
  239. // 设置 marginLeft,根据侧边栏是否折叠来决定
  240. marginLeft = sidebarCollapse.value
  241. ? getSideCollapseWidth.value
  242. : onHoveringWidth;
  243. // 设置 tabbar 的宽度,计算方式为 100% 减去侧边栏的宽度
  244. width = `calc(100% - ${sidebarCollapse.value ? getSidebarWidth.value : onHoveringWidth}px)`;
  245. } else {
  246. // 默认情况下,tabbar 的宽度为 100%
  247. width = '100%';
  248. }
  249. return {
  250. marginLeft: `${marginLeft}px`,
  251. width,
  252. };
  253. });
  254. const contentStyle = computed((): CSSProperties => {
  255. const fixed = headerFixed.value;
  256. const { footerEnable, footerFixed, footerHeight } = props;
  257. return {
  258. marginTop:
  259. fixed &&
  260. !isFullContent.value &&
  261. !headerIsHidden.value &&
  262. (!isHeaderAutoMode.value || scrollY.value < headerWrapperHeight.value)
  263. ? `${headerWrapperHeight.value}px`
  264. : 0,
  265. paddingBottom: `${footerEnable && footerFixed ? footerHeight : 0}px`,
  266. };
  267. });
  268. const headerZIndex = computed(() => {
  269. const { zIndex } = props;
  270. const offset = isMixedNav.value ? 1 : 0;
  271. return zIndex + offset;
  272. });
  273. const headerWrapperStyle = computed((): CSSProperties => {
  274. const fixed = headerFixed.value;
  275. return {
  276. height: isFullContent.value ? '0' : `${headerWrapperHeight.value}px`,
  277. left: isMixedNav.value ? 0 : mainStyle.value.sidebarAndExtraWidth,
  278. position: fixed ? 'fixed' : 'static',
  279. top:
  280. headerIsHidden.value || isFullContent.value
  281. ? `-${headerWrapperHeight.value}px`
  282. : 0,
  283. width: mainStyle.value.width,
  284. 'z-index': headerZIndex.value,
  285. };
  286. });
  287. /**
  288. * 侧边栏z-index
  289. */
  290. const sidebarZIndex = computed(() => {
  291. const { isMobile, zIndex } = props;
  292. let offset = isMobile || isSideMode.value ? 1 : -1;
  293. if (isMixedNav.value) {
  294. offset += 1;
  295. }
  296. return zIndex + offset;
  297. });
  298. const footerWidth = computed(() => {
  299. if (!props.footerFixed) {
  300. return '100%';
  301. }
  302. return mainStyle.value.width;
  303. });
  304. const maskStyle = computed((): CSSProperties => {
  305. return { zIndex: props.zIndex };
  306. });
  307. const showHeaderToggleButton = computed(() => {
  308. return (
  309. props.isMobile ||
  310. (props.headerToggleSidebarButton &&
  311. isSideMode.value &&
  312. !isSidebarMixedNav.value &&
  313. !isMixedNav.value &&
  314. !props.isMobile)
  315. );
  316. });
  317. const showHeaderLogo = computed(() => {
  318. return !isSideMode.value || isMixedNav.value || props.isMobile;
  319. });
  320. watch(
  321. () => props.isMobile,
  322. (val) => {
  323. if (val) {
  324. sidebarCollapse.value = true;
  325. }
  326. },
  327. {
  328. immediate: true,
  329. },
  330. );
  331. watch(
  332. [() => headerWrapperHeight.value, () => isFullContent.value],
  333. ([height]) => {
  334. setLayoutHeaderHeight(isFullContent.value ? 0 : height);
  335. },
  336. {
  337. immediate: true,
  338. },
  339. );
  340. watch(
  341. () => props.footerHeight,
  342. (height: number) => {
  343. setLayoutFooterHeight(height);
  344. },
  345. {
  346. immediate: true,
  347. },
  348. );
  349. {
  350. const mouseMove = () => {
  351. mouseY.value > headerWrapperHeight.value
  352. ? (headerIsHidden.value = true)
  353. : (headerIsHidden.value = false);
  354. };
  355. watch(
  356. [() => props.headerMode, () => mouseY.value],
  357. () => {
  358. if (!isHeaderAutoMode.value || isMixedNav.value || isFullContent.value) {
  359. if (props.headerMode !== 'auto-scroll') {
  360. headerIsHidden.value = false;
  361. }
  362. return;
  363. }
  364. headerIsHidden.value = true;
  365. mouseMove();
  366. },
  367. {
  368. immediate: true,
  369. },
  370. );
  371. }
  372. {
  373. const checkHeaderIsHidden = useThrottleFn((top, bottom, topArrived) => {
  374. if (scrollY.value < headerWrapperHeight.value) {
  375. headerIsHidden.value = false;
  376. return;
  377. }
  378. if (topArrived) {
  379. headerIsHidden.value = false;
  380. return;
  381. }
  382. if (top) {
  383. headerIsHidden.value = false;
  384. } else if (bottom) {
  385. headerIsHidden.value = true;
  386. }
  387. }, 300);
  388. watch(
  389. () => scrollY.value,
  390. () => {
  391. if (
  392. props.headerMode !== 'auto-scroll' ||
  393. isMixedNav.value ||
  394. isFullContent.value
  395. ) {
  396. return;
  397. }
  398. if (isScrolling.value) {
  399. checkHeaderIsHidden(
  400. directions.top,
  401. directions.bottom,
  402. arrivedState.top,
  403. );
  404. }
  405. },
  406. );
  407. }
  408. function handleClickMask() {
  409. sidebarCollapse.value = true;
  410. }
  411. function handleHeaderToggle() {
  412. if (props.isMobile) {
  413. sidebarCollapse.value = false;
  414. } else {
  415. emit('toggleSidebar');
  416. }
  417. }
  418. const idMainContent = ELEMENT_ID_MAIN_CONTENT;
  419. </script>
  420. <template>
  421. <div class="relative flex min-h-full w-full">
  422. <LayoutSidebar
  423. v-if="sidebarEnableState"
  424. v-model:collapse="sidebarCollapse"
  425. v-model:expand-on-hover="sidebarExpandOnHover"
  426. v-model:expand-on-hovering="sidebarExpandOnHovering"
  427. v-model:extra-collapse="sidebarExtraCollapse"
  428. v-model:extra-visible="sidebarExtraVisible"
  429. :collapse-width="getSideCollapseWidth"
  430. :dom-visible="!isMobile"
  431. :extra-width="sidebarExtraWidth"
  432. :fixed-extra="sidebarExpandOnHover"
  433. :header-height="isMixedNav ? 0 : headerHeight"
  434. :is-sidebar-mixed="isSidebarMixedNav || isHeaderMixedNav"
  435. :margin-top="sidebarMarginTop"
  436. :mixed-width="sidebarMixedWidth"
  437. :show="showSidebar"
  438. :theme="sidebarTheme"
  439. :width="getSidebarWidth"
  440. :z-index="sidebarZIndex"
  441. @leave="() => emit('sideMouseLeave')"
  442. >
  443. <template v-if="isSideMode && !isMixedNav" #logo>
  444. <slot name="logo"></slot>
  445. </template>
  446. <template v-if="isSidebarMixedNav || isHeaderMixedNav">
  447. <slot name="mixed-menu"></slot>
  448. </template>
  449. <template v-else>
  450. <slot name="menu"></slot>
  451. </template>
  452. <template #extra>
  453. <slot name="side-extra"></slot>
  454. </template>
  455. <template #extra-title>
  456. <slot name="side-extra-title"></slot>
  457. </template>
  458. </LayoutSidebar>
  459. <div
  460. ref="contentRef"
  461. class="flex flex-1 flex-col overflow-hidden transition-all duration-300 ease-in"
  462. >
  463. <div
  464. :class="[
  465. {
  466. 'shadow-[0_16px_24px_hsl(var(--background))]': scrollY > 20,
  467. },
  468. SCROLL_FIXED_CLASS,
  469. ]"
  470. :style="headerWrapperStyle"
  471. class="overflow-hidden transition-all duration-200"
  472. >
  473. <LayoutHeader
  474. v-if="headerVisible"
  475. :full-width="!isSideMode"
  476. :height="headerHeight"
  477. :is-mobile="isMobile"
  478. :show="!isFullContent && !headerHidden"
  479. :sidebar-width="sidebarWidth"
  480. :theme="headerTheme"
  481. :width="mainStyle.width"
  482. :z-index="headerZIndex"
  483. >
  484. <template v-if="showHeaderLogo" #logo>
  485. <slot name="logo"></slot>
  486. </template>
  487. <template #toggle-button>
  488. <VbenIconButton
  489. v-if="showHeaderToggleButton"
  490. class="my-0 mr-1 rounded-md"
  491. @click="handleHeaderToggle"
  492. >
  493. <Menu class="size-4" />
  494. </VbenIconButton>
  495. </template>
  496. <slot name="header"></slot>
  497. </LayoutHeader>
  498. <LayoutTabbar
  499. v-if="tabbarEnable"
  500. :height="tabbarHeight"
  501. :style="tabbarStyle"
  502. >
  503. <slot name="tabbar"></slot>
  504. </LayoutTabbar>
  505. </div>
  506. <!-- </div> -->
  507. <LayoutContent
  508. :id="idMainContent"
  509. :content-compact="contentCompact"
  510. :content-compact-width="contentCompactWidth"
  511. :padding="contentPadding"
  512. :padding-bottom="contentPaddingBottom"
  513. :padding-left="contentPaddingLeft"
  514. :padding-right="contentPaddingRight"
  515. :padding-top="contentPaddingTop"
  516. :style="contentStyle"
  517. class="transition-[margin-top] duration-200"
  518. >
  519. <slot name="content"></slot>
  520. <template #overlay>
  521. <slot name="content-overlay"></slot>
  522. </template>
  523. </LayoutContent>
  524. <LayoutFooter
  525. v-if="footerEnable"
  526. :fixed="footerFixed"
  527. :height="footerHeight"
  528. :show="!isFullContent"
  529. :width="footerWidth"
  530. :z-index="zIndex"
  531. >
  532. <slot name="footer"></slot>
  533. </LayoutFooter>
  534. </div>
  535. <slot name="extra"></slot>
  536. <div
  537. v-if="maskVisible"
  538. :style="maskStyle"
  539. class="bg-overlay fixed left-0 top-0 h-full w-full transition-[background-color] duration-200"
  540. @click="handleClickMask"
  541. ></div>
  542. </div>
  543. </template>