vben-layout.vue 14 KB

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