vben-layout.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604
  1. <script setup lang="ts">
  2. import { useNamespace } from '@vben-core/toolkit';
  3. import type { CSSProperties } from 'vue';
  4. import { useMouse, useScroll, useThrottleFn } from '@vueuse/core';
  5. import { computed, ref, watch } from 'vue';
  6. import {
  7. LayoutContent,
  8. LayoutFooter,
  9. LayoutHeader,
  10. LayoutSide,
  11. LayoutTabs,
  12. } from './components';
  13. import { VbenLayoutProps } from './vben-layout';
  14. interface Props extends VbenLayoutProps {}
  15. defineOptions({
  16. name: 'VbenLayout',
  17. });
  18. const props = withDefaults(defineProps<Props>(), {
  19. contentCompact: 'wide',
  20. contentPadding: 0,
  21. contentPaddingBottom: 0,
  22. contentPaddingLeft: 0,
  23. contentPaddingRight: 0,
  24. contentPaddingTop: 0,
  25. // footerBackgroundColor: '#fff',
  26. footerFixed: true,
  27. footerHeight: 32,
  28. footerVisible: false,
  29. // headerBackgroundColor: 'hsl(var(--color-background))',
  30. headerHeight: 50,
  31. headerHeightOffset: 10,
  32. headerMode: 'fixed',
  33. headerVisible: true,
  34. isMobile: false,
  35. layout: 'side-nav',
  36. sideCollapseShowTitle: false,
  37. // sideCollapse: false,
  38. sideCollapseWidth: 60,
  39. sideMixedWidth: 80,
  40. sideSemiDark: true,
  41. sideTheme: 'dark',
  42. sideWidth: 180,
  43. // tabsBackgroundColor: 'hsl(var(--color-background))',
  44. tabsHeight: 38,
  45. tabsVisible: true,
  46. zIndex: 200,
  47. });
  48. const emit = defineEmits<{ sideMouseLeave: [] }>();
  49. const sideCollapse = defineModel<boolean>('sideCollapse');
  50. const sideExtraVisible = defineModel<boolean>('sideExtraVisible');
  51. const sideExtraCollapse = defineModel<boolean>('sideExtraCollapse');
  52. const sideExpandOnHover = defineModel<boolean>('sideExpandOnHover');
  53. const sideVisible = defineModel<boolean>('sideVisible', { default: true });
  54. const { b, e, is } = useNamespace('layout');
  55. const {
  56. arrivedState,
  57. directions,
  58. isScrolling,
  59. y: scrollY,
  60. } = useScroll(document);
  61. const { y: mouseY } = useMouse({ type: 'client' });
  62. // side是否处于hover状态展开菜单中
  63. const sideExpandOnHovering = ref(false);
  64. const sideHidden = ref(false);
  65. const headerIsHidden = ref(false);
  66. const realLayout = computed(() => {
  67. return props.isMobile ? 'side-nav' : props.layout;
  68. });
  69. /**
  70. * 是否全屏显示content,不需要侧边、底部、顶部、tab区域
  71. */
  72. const fullContent = computed(() => realLayout.value === 'full-content');
  73. /**
  74. * 是否侧边混合模式
  75. */
  76. const isSideMixedNav = computed(() => realLayout.value === 'side-mixed-nav');
  77. /**
  78. * 是否为头部导航模式
  79. */
  80. const isHeaderNav = computed(() => realLayout.value === 'header-nav');
  81. /**
  82. * 是否为混合导航模式
  83. */
  84. const isMixedNav = computed(() => realLayout.value === 'mixed-nav');
  85. /**
  86. * 顶栏是否自动隐藏
  87. */
  88. const isHeaderAuto = computed(() => props.headerMode === 'auto');
  89. /**
  90. * header区域高度
  91. */
  92. const getHeaderHeight = computed(() => {
  93. const { headerHeight, headerHeightOffset, headerVisible } = props;
  94. if (!headerVisible) {
  95. return 0;
  96. }
  97. // 顶部存在导航时,增加10
  98. const offset = isMixedNav.value || isHeaderNav.value ? headerHeightOffset : 0;
  99. return headerHeight + offset;
  100. });
  101. const headerWrapperHeight = computed(() => {
  102. let height = 0;
  103. if (props.headerVisible) {
  104. height += getHeaderHeight.value;
  105. }
  106. if (props.tabsVisible) {
  107. height += props.tabsHeight;
  108. }
  109. return height;
  110. });
  111. const getSideCollapseWidth = computed(() => {
  112. const { sideCollapseShowTitle, sideCollapseWidth, sideMixedWidth } = props;
  113. return sideCollapseShowTitle || isSideMixedNav
  114. ? sideMixedWidth
  115. : sideCollapseWidth;
  116. });
  117. /**
  118. * 动态获取侧边区域是否可见
  119. */
  120. const sideVisibleState = computed(() => {
  121. return !isHeaderNav.value && sideVisible.value;
  122. });
  123. /**
  124. * 侧边区域离顶部高度
  125. */
  126. const sidePaddingTop = computed(() => {
  127. const { isMobile } = props;
  128. return isMixedNav.value && !isMobile ? getHeaderHeight.value : 0;
  129. });
  130. /**
  131. * 动态获取侧边宽度
  132. */
  133. const getSideWidth = computed(() => {
  134. const { isMobile, sideMixedWidth, sideWidth } = props;
  135. let width = 0;
  136. if (
  137. !sideVisibleState.value ||
  138. (sideHidden.value && !isSideMixedNav.value && !isMixedNav.value)
  139. ) {
  140. return width;
  141. }
  142. if (isSideMixedNav.value && !isMobile) {
  143. width = sideMixedWidth;
  144. } else if (sideCollapse.value) {
  145. width = isMobile ? 0 : getSideCollapseWidth.value;
  146. } else {
  147. width = sideWidth;
  148. }
  149. return width;
  150. });
  151. /**
  152. * 获取扩展区域宽度
  153. */
  154. const getExtraWidth = computed(() => {
  155. const { sideWidth } = props;
  156. return sideExtraCollapse.value ? getSideCollapseWidth.value : sideWidth;
  157. });
  158. /**
  159. * 是否侧边栏模式,包含混合侧边
  160. */
  161. const isSideMode = computed(() =>
  162. ['mixed-nav', 'side-mixed-nav', 'side-nav'].includes(realLayout.value),
  163. );
  164. const showSide = computed(() => {
  165. return isSideMode.value && sideVisible.value;
  166. });
  167. const sideFace = computed(() => {
  168. const { sideSemiDark, sideTheme } = props;
  169. const isDark = sideTheme === 'dark' || sideSemiDark;
  170. let backgroundColor = '';
  171. let extraBackgroundColor = '';
  172. if (isDark) {
  173. backgroundColor = isSideMixedNav.value
  174. ? 'hsl(var(--color-menu-dark-darken))'
  175. : 'hsl(var(--color-menu-dark))';
  176. } else {
  177. backgroundColor = isSideMixedNav.value
  178. ? 'hsl(var(--color-menu-darken))'
  179. : 'hsl(var(--color-menu))';
  180. }
  181. extraBackgroundColor = isDark
  182. ? 'hsl(var(--color-menu-dark))'
  183. : 'hsl(var(--color-menu))';
  184. return {
  185. backgroundColor,
  186. extraBackgroundColor,
  187. theme: isDark ? 'dark' : 'light',
  188. };
  189. });
  190. /**
  191. * 遮罩可见性
  192. */
  193. const maskVisible = computed(() => !sideCollapse.value && props.isMobile);
  194. /**
  195. * header fixed值
  196. */
  197. const headerFixed = computed(() => {
  198. return (
  199. isMixedNav.value ||
  200. ['auto', 'auto-scroll', 'fixed'].includes(props.headerMode)
  201. );
  202. });
  203. const mainStyle = computed(() => {
  204. let width = '100%';
  205. let sidebarWidth = 'unset';
  206. if (
  207. headerFixed.value &&
  208. !['header-nav', 'mixed-nav'].includes(realLayout.value) &&
  209. showSide.value &&
  210. !props.isMobile
  211. ) {
  212. // pin模式下生效
  213. const isSideNavEffective =
  214. isSideMixedNav.value && sideExpandOnHover.value && sideExtraVisible.value;
  215. if (isSideNavEffective) {
  216. const sideCollapseWidth = sideCollapse.value
  217. ? getSideCollapseWidth.value
  218. : props.sideMixedWidth;
  219. const sideWidth = sideExtraCollapse.value
  220. ? getSideCollapseWidth.value
  221. : props.sideWidth;
  222. // 100% - 侧边菜单混合宽度 - 菜单宽度
  223. sidebarWidth = `${sideCollapseWidth + sideWidth}px`;
  224. width = `calc(100% - ${sidebarWidth})`;
  225. } else {
  226. sidebarWidth =
  227. sideExpandOnHovering.value && !sideExpandOnHover.value
  228. ? `${getSideCollapseWidth.value}px`
  229. : `${getSideWidth.value}px`;
  230. width = `calc(100% - ${sidebarWidth})`;
  231. }
  232. }
  233. return {
  234. sidebarWidth,
  235. width,
  236. };
  237. });
  238. const tabsStyle = computed((): CSSProperties => {
  239. let width = '';
  240. let marginLeft = 0;
  241. if (!isMixedNav.value) {
  242. width = '100%';
  243. } else if (sideVisible.value) {
  244. marginLeft = sideCollapse.value
  245. ? getSideCollapseWidth.value
  246. : props.sideWidth;
  247. width = `calc(100% - ${getSideWidth.value}px)`;
  248. } else {
  249. width = '100%';
  250. }
  251. return {
  252. marginLeft: `${marginLeft}px`,
  253. width,
  254. };
  255. });
  256. const footerWidth = computed(() => {
  257. if (!props.footerFixed) {
  258. return '100%';
  259. }
  260. return mainStyle.value.width;
  261. });
  262. const contentStyle = computed((): CSSProperties => {
  263. const fixed = headerFixed.value;
  264. return {
  265. marginTop:
  266. fixed &&
  267. !fullContent.value &&
  268. !headerIsHidden.value &&
  269. (!isHeaderAuto.value || scrollY.value < headerWrapperHeight.value)
  270. ? `${headerWrapperHeight.value}px`
  271. : 0,
  272. paddingBottom: `${props.footerVisible ? props.footerHeight : 0}px`,
  273. };
  274. });
  275. const headerZIndex = computed(() => {
  276. const { zIndex } = props;
  277. const offset = isMixedNav.value ? 1 : 0;
  278. return zIndex + offset;
  279. });
  280. const headerWrapperStyle = computed((): CSSProperties => {
  281. const fixed = headerFixed.value;
  282. return {
  283. height: fullContent.value ? '0' : `${headerWrapperHeight.value}px`,
  284. left: isMixedNav.value ? 0 : mainStyle.value.sidebarWidth,
  285. position: fixed ? 'fixed' : 'static',
  286. top:
  287. headerIsHidden.value || fullContent.value
  288. ? `-${headerWrapperHeight.value}px`
  289. : 0,
  290. width: mainStyle.value.width,
  291. 'z-index': headerZIndex.value,
  292. };
  293. });
  294. /**
  295. * 侧边栏z-index
  296. */
  297. const sideZIndex = computed(() => {
  298. const { isMobile, zIndex } = props;
  299. const offset = isMobile || isSideMode.value ? 1 : -1;
  300. return zIndex + offset;
  301. });
  302. const maskStyle = computed((): CSSProperties => {
  303. return {
  304. zIndex: props.zIndex,
  305. };
  306. });
  307. const showHeaderToggleButton = computed(() => {
  308. return (
  309. isSideMode.value &&
  310. !isSideMixedNav.value &&
  311. !isMixedNav.value &&
  312. !props.isMobile
  313. );
  314. // return false;
  315. });
  316. const showHeaderLogo = computed(() => {
  317. return !isSideMode.value || isMixedNav.value || props.isMobile;
  318. });
  319. watch(
  320. () => props.isMobile,
  321. (val) => {
  322. sideCollapse.value = val;
  323. },
  324. );
  325. {
  326. const mouseMove = () => {
  327. mouseY.value > headerWrapperHeight.value
  328. ? (headerIsHidden.value = true)
  329. : (headerIsHidden.value = false);
  330. };
  331. watch(
  332. [() => props.headerMode, () => mouseY.value],
  333. () => {
  334. if (!isHeaderAuto.value || isMixedNav.value || fullContent.value) {
  335. return;
  336. }
  337. headerIsHidden.value = true;
  338. mouseMove();
  339. },
  340. {
  341. immediate: true,
  342. },
  343. );
  344. }
  345. {
  346. const checkHeaderIsHidden = useThrottleFn((top, bottom, topArrived) => {
  347. if (scrollY.value < headerWrapperHeight.value) {
  348. headerIsHidden.value = false;
  349. return;
  350. }
  351. if (topArrived) {
  352. headerIsHidden.value = false;
  353. return;
  354. }
  355. if (top) {
  356. headerIsHidden.value = false;
  357. } else if (bottom) {
  358. headerIsHidden.value = true;
  359. }
  360. }, 300);
  361. watch(
  362. () => scrollY.value,
  363. () => {
  364. if (
  365. props.headerMode !== 'auto-scroll' ||
  366. isMixedNav.value ||
  367. fullContent.value
  368. ) {
  369. return;
  370. }
  371. if (isScrolling.value) {
  372. checkHeaderIsHidden(
  373. directions.top,
  374. directions.bottom,
  375. arrivedState.top,
  376. );
  377. }
  378. },
  379. );
  380. }
  381. function handleClickMask() {
  382. sideCollapse.value = true;
  383. }
  384. function handleToggleMenu() {
  385. // sideVisible.value = !sideVisible.value;
  386. sideHidden.value = !sideHidden.value;
  387. }
  388. function handleOpenMenu() {
  389. sideCollapse.value = false;
  390. }
  391. </script>
  392. <template>
  393. <div :class="[b(), is(realLayout, true)]">
  394. <slot name="preference"></slot>
  395. <slot name="back-top"></slot>
  396. <LayoutSide
  397. v-if="sideVisibleState"
  398. v-model:collapse="sideCollapse"
  399. v-model:extra-collapse="sideExtraCollapse"
  400. v-model:expand-on-hovering="sideExpandOnHovering"
  401. v-model:expand-on-hover="sideExpandOnHover"
  402. v-model:extra-visible="sideExtraVisible"
  403. :dom-visible="!isMobile"
  404. :fixed-extra="sideExpandOnHover"
  405. :mixed-width="sideMixedWidth"
  406. :header-height="isMixedNav ? 0 : getHeaderHeight"
  407. :collapse-width="getSideCollapseWidth"
  408. :is-side-mixed="isSideMixedNav"
  409. :padding-top="sidePaddingTop"
  410. :show="showSide"
  411. :extra-width="getExtraWidth"
  412. :width="getSideWidth"
  413. :z-index="sideZIndex"
  414. v-bind="sideFace"
  415. @leave="() => emit('sideMouseLeave')"
  416. >
  417. <template v-if="isSideMode && !isMixedNav" #logo>
  418. <slot name="logo"></slot>
  419. </template>
  420. <template v-if="isSideMixedNav">
  421. <slot name="mixed-menu"></slot>
  422. </template>
  423. <template v-else>
  424. <slot name="menu"></slot>
  425. </template>
  426. <template #extra>
  427. <slot name="side-extra"></slot>
  428. </template>
  429. <template #extra-title>
  430. <slot name="side-extra-title"></slot>
  431. </template>
  432. </LayoutSide>
  433. <div :class="e('main')">
  434. <div :style="headerWrapperStyle" :class="e('header-wrapper')">
  435. <LayoutHeader
  436. v-if="headerVisible"
  437. :full-width="!isSideMode"
  438. :height="getHeaderHeight"
  439. :show="!fullContent"
  440. :side-hidden="sideHidden"
  441. :show-toggle-btn="showHeaderToggleButton"
  442. :width="mainStyle.width"
  443. :is-mixed-nav="isMixedNav"
  444. :is-mobile="isMobile"
  445. :z-index="headerZIndex"
  446. :side-width="sideWidth"
  447. @toggle-menu="handleToggleMenu"
  448. @open-menu="handleOpenMenu"
  449. >
  450. <template v-if="showHeaderLogo" #logo>
  451. <slot name="logo"></slot>
  452. </template>
  453. <slot name="header"></slot>
  454. </LayoutHeader>
  455. <LayoutTabs v-if="tabsVisible" :height="tabsHeight" :style="tabsStyle">
  456. <slot name="tabs"></slot>
  457. <template #toolbar>
  458. <slot name="tabs-toolbar"></slot>
  459. </template>
  460. </LayoutTabs>
  461. </div>
  462. <!-- </div> -->
  463. <LayoutContent
  464. :class="e('content')"
  465. :style="contentStyle"
  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. >
  474. <slot name="content"></slot>
  475. </LayoutContent>
  476. <LayoutFooter
  477. v-if="footerVisible"
  478. :fixed="footerFixed"
  479. :height="footerHeight"
  480. :show="!fullContent"
  481. :width="footerWidth"
  482. :z-index="zIndex"
  483. >
  484. <slot name="footer"></slot>
  485. </LayoutFooter>
  486. </div>
  487. <div
  488. v-if="maskVisible"
  489. :class="e('mask')"
  490. :style="maskStyle"
  491. @click="handleClickMask"
  492. ></div>
  493. </div>
  494. </template>
  495. <style scoped lang="scss">
  496. @import '@vben-core/design/global';
  497. @include b('layout') {
  498. position: relative;
  499. display: flex;
  500. width: 100%;
  501. min-height: 100%;
  502. @include e('main') {
  503. display: flex;
  504. flex: auto;
  505. flex: 1;
  506. flex-direction: column;
  507. overflow-x: hidden;
  508. background-color: hsl(var(--color-body));
  509. border-left: 1px solid hsl(var(--color-border));
  510. transition: all 0.3s ease;
  511. }
  512. @include e('content') {
  513. transition: margin-top 0.3s ease;
  514. }
  515. @include e('header-wrapper') {
  516. overflow: hidden;
  517. transition: all 0.25s ease-in-out;
  518. }
  519. @include e('mask') {
  520. position: fixed;
  521. top: 0;
  522. left: 0;
  523. width: 100%;
  524. height: 100%;
  525. background-color: rgb(0 0 0 / 40%);
  526. transition: background-color 0.2s;
  527. }
  528. }
  529. </style>