vben-layout.vue 14 KB

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