vben-layout.vue 14 KB

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