chore: init project

This commit is contained in:
vben
2024-05-19 21:20:42 +08:00
commit 399334ac57
630 changed files with 45623 additions and 0 deletions

View File

@@ -0,0 +1,54 @@
{
"name": "@vben-core/layout-ui",
"version": "1.0.0",
"type": "module",
"license": "MIT",
"homepage": "https://github.com/vbenjs/vue-vben-admin",
"repository": {
"type": "git",
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
"directory": "packages/@vben-core/uikit/layout-ui"
},
"bugs": {
"url": "https://github.com/vbenjs/vue-vben-admin/issues"
},
"scripts": {
"build": "pnpm vite build",
"prepublishOnly": "npm run build"
},
"files": [
"dist"
],
"sideEffects": [
"**/*.css"
],
"main": "./dist/index.mjs",
"module": "./dist/index.mjs",
"imports": {
"#*": "./src/*"
},
"exports": {
".": {
"development": "./src/index.ts",
"types": "./src/index.ts",
"default": "./dist/index.mjs"
}
},
"publishConfig": {
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.mjs"
}
}
},
"dependencies": {
"@vben-core/design": "workspace:*",
"@vben-core/iconify": "workspace:*",
"@vben-core/shadcn-ui": "workspace:*",
"@vben-core/toolkit": "workspace:*",
"@vben-core/typings": "workspace:*",
"@vueuse/core": "^10.9.0",
"vue": "^3.4.27"
}
}

View File

@@ -0,0 +1 @@
export { default } from '@vben/tailwind-config/postcss';

View File

@@ -0,0 +1,5 @@
export { default as LayoutContent } from './layout-content.vue';
export { default as LayoutFooter } from './layout-footer.vue';
export { default as LayoutHeader } from './layout-header.vue';
export { default as LayoutSide } from './layout-side.vue';
export { default as LayoutTabs } from './layout-tabs.vue';

View File

@@ -0,0 +1,86 @@
<script setup lang="ts">
import type { ContentCompactType } from '@vben-core/typings';
import type { CSSProperties } from 'vue';
import { computed } from 'vue';
interface Props {
/**
* 内容区域定宽
* @default 'wide'
*/
contentCompact?: ContentCompactType;
/**
* 定宽布局宽度
* @default 1200
*/
contentCompactWidth?: number;
/**
* padding
* @default 16
*/
padding?: number;
/**
* paddingBottom
* @default 16
*/
paddingBottom?: number;
/**
* paddingLeft
* @default 16
*/
paddingLeft?: number;
/**
* paddingRight
* @default 16
*/
paddingRight?: number;
/**
* paddingTop
* @default 16
*/
paddingTop?: number;
}
defineOptions({ name: 'LayoutContent' });
const props = withDefaults(defineProps<Props>(), {
contentCompact: 'wide',
contentCompactWidth: 1200,
padding: 16,
paddingBottom: 16,
paddingLeft: 16,
paddingRight: 16,
paddingTop: 16,
});
const style = computed((): CSSProperties => {
const {
contentCompact,
padding,
paddingBottom,
paddingLeft,
paddingRight,
paddingTop,
} = props;
const compactStyle: CSSProperties =
contentCompact === 'compact' ? { margin: '0 auto', width: `1200px` } : {};
return {
...compactStyle,
flex: 1,
padding: `${padding}px`,
paddingBottom: `${paddingBottom}px`,
paddingLeft: `${paddingLeft}px`,
paddingRight: `${paddingRight}px`,
paddingTop: `${paddingTop}px`,
};
});
</script>
<template>
<main :style="style">
<slot></slot>
</main>
</template>

View File

@@ -0,0 +1,74 @@
<script setup lang="ts">
import { useNamespace } from '@vben-core/toolkit';
import type { CSSProperties } from 'vue';
import { computed } from 'vue';
interface Props {
/**
* 背景颜色
*/
backgroundColor?: string;
/**
* 是否固定在顶部
* @default true
*/
fixed?: boolean;
/**
* 高度
* @default 32
*/
height?: number;
/**
* 是否显示
* @default true
*/
show?: boolean;
/**
* 高度
* @default 100%
*/
width?: string;
/**
* zIndex
* @default 0
*/
zIndex?: number;
}
defineOptions({ name: 'LayoutFooter' });
const props = withDefaults(defineProps<Props>(), {
backgroundColor: 'hsl(var(--color-background))',
fixed: true,
height: 32,
show: true,
width: '100%',
zIndex: 0,
});
const { b } = useNamespace('footer');
const style = computed((): CSSProperties => {
const { backgroundColor, fixed, height, show, width, zIndex } = props;
return {
backgroundColor,
height: `${height}px`,
marginBottom: show ? '0' : `-${height}px`,
position: fixed ? 'fixed' : 'static',
width,
zIndex,
};
});
</script>
<template>
<footer
:class="b()"
class="bottom-0 w-full transition-all duration-200"
:style="style"
>
<slot></slot>
</footer>
</template>

View File

@@ -0,0 +1,156 @@
<script setup lang="ts">
import { IcRoundMenu } from '@vben-core/iconify';
import { VbenIconButton } from '@vben-core/shadcn-ui';
import { useNamespace } from '@vben-core/toolkit';
import type { CSSProperties } from 'vue';
import { computed, useSlots } from 'vue';
interface Props {
/**
* 背景颜色
*/
backgroundColor?: string;
/**
* 横屏
* @default false
*/
fullWidth?: boolean;
/**
* 高度
* @default 60
*/
height?: number;
/**
* 是否混合导航
* @default false
*/
isMixedNav?: boolean;
/**
* 是否移动端
* @default false
*/
isMobile?: boolean;
/**
* 是否显示
* @default true
*/
show?: boolean;
/**
* 是否显示关闭菜单按钮
* @default true
*/
showToggleBtn?: boolean;
/**
* 侧边是否显示
*/
sideHidden?: boolean;
/**
* 侧边菜单宽度
* @default 0
*/
sideWidth?: number;
/**
* 宽度
* @default 100%
*/
width?: string;
/**
* zIndex
* @default 0
*/
zIndex?: number;
}
defineOptions({ name: 'LayoutHeader' });
const props = withDefaults(defineProps<Props>(), {
backgroundColor: 'hsl(var(--color-background))',
// fixed: true,
height: 60,
isMixedNav: false,
show: true,
showToggleBtn: false,
sideWidth: 0,
width: '100%',
zIndex: 0,
});
const emit = defineEmits<{ openMenu: []; toggleMenu: [] }>();
const slots = useSlots();
const { b, e } = useNamespace('header');
const style = computed((): CSSProperties => {
const { backgroundColor, fullWidth, height, show } = props;
const right = !show || !fullWidth ? undefined : 0;
return {
// ...(props.isMixedNav ? { left: 0, position: `fixed` } : {}),
backgroundColor,
height: `${height}px`,
marginTop: show ? 0 : `-${height}px`,
right,
};
});
const logoStyle = computed((): CSSProperties => {
return {
minWidth: `${props.isMobile ? 40 : props.sideWidth}px`,
};
});
function handleToggleMenu() {
emit('toggleMenu');
}
function handleOpenMenu() {
emit('openMenu');
}
</script>
<template>
<header :class="b()" :style="style">
<div v-if="slots.logo" :style="logoStyle">
<slot name="logo"></slot>
</div>
<VbenIconButton
v-if="showToggleBtn"
:class="e('toggle-btn')"
@click="handleToggleMenu"
>
<IcRoundMenu class="size-5" />
</VbenIconButton>
<VbenIconButton
v-if="isMobile"
:class="e('toggle-btn')"
@click="handleOpenMenu"
>
<IcRoundMenu class="size-5" />
</VbenIconButton>
<slot></slot>
</header>
</template>
<style scoped lang="scss">
@import '@vben-core/design/global';
@include b('header') {
top: 0;
display: flex;
flex: 0 0 auto;
align-items: center;
width: 100%;
border-bottom: 1px solid hsl(var(--color-border));
@include e('toggle-btn') {
margin: 0 4px 0 8px;
border-radius: 4px;
}
}
</style>

View File

@@ -0,0 +1,388 @@
<script setup lang="ts">
import { ScrollArea } from '@vben-core/shadcn-ui';
import { useNamespace } from '@vben-core/toolkit';
import type { CSSProperties } from 'vue';
// import { onClickOutside } from '@vueuse/core';
import { computed, ref, shallowRef, useSlots, watchEffect } from 'vue';
import { SideCollapseButton, SidePinButton } from './widgets';
interface Props {
/**
* 背景颜色
*/
backgroundColor: string;
/**
* 折叠区域高度
* @default 32
*/
collapseHeight?: number;
/**
* 折叠宽度
* @default 48
*/
collapseWidth?: number;
/**
* 隐藏的dom是否可见
* @default true
*/
domVisible?: boolean;
/**
* 扩展区域背景颜色
*/
extraBackgroundColor: string;
/**
* 扩展区域宽度
* @default 180
*/
extraWidth?: number;
/**
* 固定扩展区域
* @default false
*/
fixedExtra?: boolean;
/**
* 头部高度
*/
headerHeight: number;
/**
* 是否侧边混合模式
* @default false
*/
isSideMixed?: boolean;
/**
* 混合菜单宽度
* @default 80
*/
mixedWidth?: number;
/**
* 顶部padding
* @default 60
*/
paddingTop?: number;
/**
* 是否显示
* @default true
*/
show?: boolean;
/**
* 显示折叠按钮
* @default false
*/
showCollapseButton?: boolean;
/**
* 主题
*/
theme?: string;
/**
* 宽度
* @default 180
*/
width?: number;
/**
* zIndex
* @default 0
*/
zIndex?: number;
}
defineOptions({ name: 'LayoutSide' });
const props = withDefaults(defineProps<Props>(), {
collapseHeight: 42,
collapseWidth: 48,
domVisible: true,
extraWidth: 180,
fixedExtra: false,
isSideMixed: false,
mixedWidth: 80,
paddingTop: 60,
show: true,
showCollapseButton: true,
theme: 'dark',
width: 180,
zIndex: 0,
});
const emit = defineEmits<{ leave: [] }>();
const collapse = defineModel<boolean>('collapse');
const extraCollapse = defineModel<boolean>('extraCollapse');
const expandOnHovering = defineModel<boolean>('expandOnHovering');
const expandOnHover = defineModel<boolean>('expandOnHover');
const extraVisible = defineModel<boolean>('extraVisible');
const { b, e, is } = useNamespace('side');
const slots = useSlots();
const asideRef = shallowRef<HTMLDivElement | null>();
const scrolled = ref(false);
const hiddenSideStyle = computed((): CSSProperties => {
return calcMenuWidthStyle(true);
});
const style = computed((): CSSProperties => {
const { isSideMixed, paddingTop, zIndex } = props;
return {
...calcMenuWidthStyle(false),
paddingTop: `${paddingTop}px`,
zIndex,
...(isSideMixed && extraVisible.value ? { transition: 'none' } : {}),
};
});
const extraStyle = computed((): CSSProperties => {
const { extraBackgroundColor, extraWidth, width, zIndex } = props;
return {
backgroundColor: extraBackgroundColor,
left: `${width}px`,
width: extraVisible.value ? `${extraWidth}px` : 0,
zIndex,
};
});
const extraTitleStyle = computed((): CSSProperties => {
const { headerHeight } = props;
return {
height: `${headerHeight - 1}px`,
};
});
const contentWidthStyle = computed((): CSSProperties => {
const { collapseWidth, fixedExtra, isSideMixed, mixedWidth } = props;
if (isSideMixed && fixedExtra) {
// if (!extraVisible.value) {
// return {};
// }
return { width: `${collapse.value ? collapseWidth : mixedWidth}px` };
}
return {};
});
const contentStyle = computed((): CSSProperties => {
const { collapseHeight, headerHeight } = props;
return {
height: `calc(100% - ${headerHeight + collapseHeight}px)`,
paddingTop: '8px',
...contentWidthStyle.value,
};
});
const headerStyle = computed((): CSSProperties => {
const { headerHeight, isSideMixed } = props;
return {
...(isSideMixed ? { display: 'flex', justifyContent: 'center' } : {}),
height: `${headerHeight}px`,
...contentWidthStyle.value,
};
});
const extraContentStyle = computed((): CSSProperties => {
const { collapseHeight, headerHeight } = props;
return {
color: 'red',
height: `calc(100% - ${headerHeight + collapseHeight}px)`,
};
});
const collapseStyle = computed((): CSSProperties => {
const { collapseHeight } = props;
return {
height: `${collapseHeight}px`,
};
});
watchEffect(() => {
extraVisible.value = props.fixedExtra ? true : extraVisible.value;
});
// onClickOutside(asideRef, (event) => {
// const { fixedExtra, width } = props;
// // 防止点击 aside 区域关闭
// if (!fixedExtra && event.clientX >= width && extraVisible.value) {
// extraVisible.value = false;
// }
// });
function calcMenuWidthStyle(isHiddenDom: boolean): CSSProperties {
const { backgroundColor, extraWidth, fixedExtra, isSideMixed, show, width } =
props;
let widthValue = `${width + (isSideMixed && fixedExtra && extraVisible.value ? extraWidth : 0)}px`;
const { collapseWidth } = props;
if (isHiddenDom && expandOnHovering.value && !expandOnHover.value) {
widthValue = `${collapseWidth}px`;
}
return {
...(widthValue === '0px' ? { overflow: 'hidden' } : {}),
backgroundColor,
flex: `0 0 ${widthValue}`,
marginLeft: show ? 0 : `-${widthValue}`,
maxWidth: widthValue,
minWidth: widthValue,
width: widthValue,
};
}
function handleMouseenter() {
// 未开启和未折叠状态不生效
if (expandOnHover.value) {
return;
}
if (!expandOnHovering.value) {
collapse.value = false;
}
expandOnHovering.value = true;
}
function handleMouseleave() {
emit('leave');
if (expandOnHover.value) {
return;
}
expandOnHovering.value = false;
collapse.value = true;
extraVisible.value = false;
}
function handleScroll(event: Event) {
const target = event.target as HTMLElement;
scrolled.value = (target?.scrollTop ?? 0) > 0;
}
</script>
<template>
<div v-if="domVisible" :class="e('hide')" :style="hiddenSideStyle"></div>
<aside
:class="[b(), is(theme, true)]"
:style="style"
@mouseenter="handleMouseenter"
@mouseleave="handleMouseleave"
>
<SidePinButton
v-if="!collapse && !isSideMixed"
v-model:expand-on-hover="expandOnHover"
:theme="theme"
/>
<div v-if="slots.logo" :style="headerStyle">
<slot name="logo"></slot>
</div>
<ScrollArea :style="contentStyle" :on-scroll="handleScroll">
<div :class="[e('shadow'), { scrolled }]"></div>
<slot></slot>
</ScrollArea>
<div :style="collapseStyle"></div>
<SideCollapseButton
v-if="showCollapseButton && !isSideMixed"
v-model:collapse="collapse"
:theme="theme"
/>
<div
v-if="isSideMixed"
ref="asideRef"
:class="e('extra')"
:style="extraStyle"
>
<SideCollapseButton
v-if="isSideMixed && expandOnHover"
v-model:collapse="extraCollapse"
:theme="theme"
/>
<SidePinButton
v-if="!extraCollapse"
v-model:expand-on-hover="expandOnHover"
:theme="theme"
/>
<div v-if="!extraCollapse" :style="extraTitleStyle">
<slot name="extra-title"></slot>
</div>
<ScrollArea
:style="extraContentStyle"
:class="e('extra-content')"
:on-scroll="handleScroll"
>
<div :class="[e('shadow'), { scrolled }]"></div>
<slot name="extra"></slot>
</ScrollArea>
</div>
</aside>
</template>
<style scoped lang="scss">
@import '@vben-core/design/global';
@include b('side') {
--color-surface: var(--color-menu);
position: fixed;
top: 0;
left: 0;
height: 100%;
transition: all 0.2s ease 0s;
@include is('dark') {
--color-surface: var(--color-menu-dark);
}
@include e('shadow') {
position: absolute;
top: 0;
z-index: 1;
inline-size: 100%;
block-size: 40px;
height: 50px;
pointer-events: none;
background: linear-gradient(
to bottom,
hsl(var(--color-surface)),
transparent
);
opacity: 0;
transition: opacity 0.15s ease-in-out;
will-change: opacity;
&.scrolled {
opacity: 1;
}
}
@include is('dark') {
.#{$namespace}-side__extra {
&-content {
border-color: hsl(var(--color-dark-border)) !important;
}
}
}
@include e('hide') {
height: 100%;
transition: all 0.2s ease 0s;
}
@include e('extra') {
position: fixed;
top: 0;
height: 100%;
overflow: hidden;
transition: all 0.2s ease 0s;
&-content {
padding: 4px 0;
}
}
}
</style>

View File

@@ -0,0 +1,70 @@
<script setup lang="ts">
import { useNamespace } from '@vben-core/toolkit';
import type { CSSProperties } from 'vue';
import { computed } from 'vue';
interface Props {
/**
* 背景颜色
*/
backgroundColor?: string;
/**
* 高度
* @default 30
*/
height?: number;
}
defineOptions({ name: 'LayoutTabs' });
const props = withDefaults(defineProps<Props>(), {
backgroundColor: 'hsl(var(--color-background))',
fixed: true,
height: 30,
});
const { b, e } = useNamespace('tabs');
const hiddenStyle = computed((): CSSProperties => {
const { height } = props;
return {
height: `${height}px`,
};
});
const style = computed((): CSSProperties => {
const { backgroundColor } = props;
return {
...hiddenStyle.value,
backgroundColor,
display: 'flex',
};
});
</script>
<template>
<section :class="b()" :style="style">
<slot></slot>
<div :class="e('toolbar')">
<slot name="toolbar"></slot>
</div>
</section>
</template>
<style scoped lang="scss">
@import '@vben-core/design/global';
@include b('tabs') {
display: flex;
width: 100%;
border-bottom: 1px solid hsl(var(--color-border));
// transition: all 0.2s;
@include e('toolbar') {
display: flex;
align-items: center;
}
}
</style>

View File

@@ -0,0 +1,2 @@
export { default as SideCollapseButton } from './side-collapse-button.vue';
export { default as SidePinButton } from './side-pin-button.vue';

View File

@@ -0,0 +1,63 @@
<script setup lang="ts">
import { MdiMenuClose, MdiMenuOpen } from '@vben-core/iconify';
import { useNamespace } from '@vben-core/toolkit';
interface Props {
theme: string;
}
defineOptions({ name: 'SideCollapseButton' });
withDefaults(defineProps<Props>(), {});
const collapse = defineModel<boolean>('collapse');
const { b, is } = useNamespace('side-collapse');
function handleCollapse() {
collapse.value = !collapse.value;
}
</script>
<template>
<div :class="[b(), is(theme, true)]" @click.stop="handleCollapse">
<MdiMenuClose v-if="collapse" />
<MdiMenuOpen v-else />
</div>
</template>
<style scoped lang="scss">
@import '@vben-core/design/global';
@include b('side-collapse') {
position: absolute;
bottom: 6px;
left: 10px;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 5px;
color: hsl(var(--color-foreground) / 60%);
cursor: pointer;
background: hsl(var(--color-accent)) !important;
border-radius: 4px;
opacity: 1;
transition: all 0.3s ease;
@include is('dark') {
color: hsl(var(--color-dark-foreground) / 60%) !important;
background: hsl(var(--color-dark-accent)) !important;
&:hover {
color: hsl(var(--color-dark-foreground)) !important;
background: hsl(var(--color-dark-accent-hover)) !important;
}
}
&:hover {
color: hsl(var(--color-foreground));
background: hsl(var(--color-accent-hover));
}
}
</style>

View File

@@ -0,0 +1,64 @@
<script setup lang="ts">
import { MdiPin, MdiPinOff } from '@vben-core/iconify';
import { useNamespace } from '@vben-core/toolkit';
interface Props {
theme: string;
}
defineOptions({ name: 'SidePinButton' });
withDefaults(defineProps<Props>(), {});
const expandOnHover = defineModel<boolean>('expandOnHover');
const { b, is } = useNamespace('side-pin');
function togglePined() {
expandOnHover.value = !expandOnHover.value;
}
</script>
<template>
<div :class="[b(), is(theme, true)]" @click="togglePined">
<MdiPinOff v-if="!expandOnHover" />
<MdiPin v-else />
</div>
</template>
<style scoped lang="scss">
@import '@vben-core/design/global';
@include b('side-pin') {
position: absolute;
right: 10px;
bottom: 6px;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 5px;
color: hsl(var(--color-foreground) / 60%);
cursor: pointer;
background: hsl(var(--color-accent)) !important;
border-radius: 4px;
opacity: 1;
transition: all 0.3s ease;
@include is('dark') {
color: hsl(var(--color-dark-foreground) / 60%) !important;
background: unset;
background: hsl(var(--color-dark-accent)) !important;
&:hover {
color: hsl(var(--color-dark-foreground)) !important;
background: hsl(var(--color-dark-accent-hover)) !important;
}
}
&:hover {
color: hsl(var(--color-foreground));
background: hsl(var(--color-accent-hover));
}
}
</style>

View File

@@ -0,0 +1,2 @@
export type * from './vben-layout';
export { default as VbenAdminLayout } from './vben-layout.vue';

View File

@@ -0,0 +1,171 @@
import type {
ContentCompactType,
LayoutHeaderMode,
LayoutType,
ThemeType,
} from '@vben-core/typings';
interface VbenLayoutProps {
/**
* 内容区域定宽
* @default 'wide'
*/
contentCompact?: ContentCompactType;
/**
* 定宽布局宽度
* @default 1200
*/
contentCompactWidth?: number;
/**
* padding
* @default 16
*/
contentPadding?: number;
/**
* paddingBottom
* @default 16
*/
contentPaddingBottom?: number;
/**
* paddingLeft
* @default 16
*/
contentPaddingLeft?: number;
/**
* paddingRight
* @default 16
*/
contentPaddingRight?: number;
/**
* paddingTop
* @default 16
*/
contentPaddingTop?: number;
/**
* footer背景颜色
* @default #fff
*/
footerBackgroundColor?: string;
/**
* footer 是否固定
* @default true
*/
footerFixed?: boolean;
/**
* footer 高度
* @default 32
*/
footerHeight?: number;
/**
* footer 是否可见
* @default false
*/
footerVisible?: boolean;
/**
* 背景颜色
* @default #fff
*/
headerBackgroundColor?: string;
/**
* header高度
* @default 48
*/
headerHeight?: number;
/**
* header高度增加高度
* 在顶部存在导航时额外加高header高度
* @default 10
*/
headerHeightOffset?: number;
/**
* header 显示模式
* @default 'fixed'
*/
headerMode?: LayoutHeaderMode;
/**
* header是否显示
* @default true
*/
headerVisible?: boolean;
/**
* 是否移动端显示
* @default false
*/
isMobile?: boolean;
/**
* 布局方式
* side-nav 侧边菜单布局
* header-nav 顶部菜单布局
* mixed-nav 侧边&顶部菜单布局
* side-mixed-nav 侧边混合菜单布局
* full-content 全屏内容布局
* @default side-nav
*/
layout?: LayoutType;
/**
* 侧边菜单折叠状态
* @default false
*/
sideCollapse?: boolean;
/**
* 侧边菜单是否折叠时是否显示title
* @default true
*/
sideCollapseShowTitle?: boolean;
/**
* 侧边菜单折叠宽度
* @default 48
*/
sideCollapseWidth?: number;
/**
* 混合侧边扩展区域是否可见
* @default false
*/
sideMixedExtraVisible?: boolean;
/**
* 混合侧边栏宽度
* @default 80
*/
sideMixedWidth?: number;
/**
* 侧边栏是否半深色
* @default false
*/
sideSemiDark?: boolean;
/**
* 侧边栏
* @default dark
*/
sideTheme?: ThemeType;
/**
* 侧边栏是否可见
* @default true
*/
sideVisible?: boolean;
/**
* 侧边栏宽度
* @default 210
*/
sideWidth?: number;
/**
* footer背景颜色
* @default #fff
*/
tabsBackgroundColor?: string;
/**
* tab高度
* @default 30
*/
tabsHeight?: number;
/**
* tab是否可见
* @default true
*/
tabsVisible?: boolean;
/**
* zIndex
* @default 100
*/
zIndex?: number;
}
export type { VbenLayoutProps };

View File

@@ -0,0 +1,603 @@
<script setup lang="ts">
import { useNamespace } from '@vben-core/toolkit';
import type { CSSProperties } from 'vue';
import { useMouse, useScroll, useThrottleFn } from '@vueuse/core';
import { computed, ref, watch } from 'vue';
import {
LayoutContent,
LayoutFooter,
LayoutHeader,
LayoutSide,
LayoutTabs,
} from './components';
import { VbenLayoutProps } from './vben-layout';
interface Props extends VbenLayoutProps {}
defineOptions({
name: 'VbenLayout',
});
const props = withDefaults(defineProps<Props>(), {
contentCompact: 'wide',
contentPadding: 0,
contentPaddingBottom: 0,
contentPaddingLeft: 0,
contentPaddingRight: 0,
contentPaddingTop: 0,
// footerBackgroundColor: '#fff',
footerFixed: true,
footerHeight: 32,
footerVisible: false,
// headerBackgroundColor: 'hsl(var(--color-background))',
headerHeight: 50,
headerHeightOffset: 10,
headerMode: 'fixed',
headerVisible: true,
isMobile: false,
layout: 'side-nav',
sideCollapseShowTitle: false,
// sideCollapse: false,
sideCollapseWidth: 60,
sideMixedWidth: 80,
sideSemiDark: true,
sideTheme: 'dark',
sideWidth: 180,
// tabsBackgroundColor: 'hsl(var(--color-background))',
tabsHeight: 38,
tabsVisible: true,
zIndex: 200,
});
const emit = defineEmits<{ sideMouseLeave: [] }>();
const sideCollapse = defineModel<boolean>('sideCollapse');
const sideExtraVisible = defineModel<boolean>('sideExtraVisible');
const sideExtraCollapse = defineModel<boolean>('sideExtraCollapse');
const sideExpandOnHover = defineModel<boolean>('sideExpandOnHover');
const sideVisible = defineModel<boolean>('sideVisible', { default: true });
const { b, e, is } = useNamespace('layout');
const {
arrivedState,
directions,
isScrolling,
y: scrollY,
} = useScroll(document);
const { y: mouseY } = useMouse({ type: 'client' });
// side是否处于hover状态展开菜单中
const sideExpandOnHovering = ref(false);
const sideHidden = ref(false);
const headerIsHidden = ref(false);
const realLayout = computed(() => {
return props.isMobile ? 'side-nav' : props.layout;
});
/**
* 是否全屏显示content不需要侧边、底部、顶部、tab区域
*/
const fullContent = computed(() => realLayout.value === 'full-content');
/**
* 是否侧边混合模式
*/
const isSideMixedNav = computed(() => realLayout.value === 'side-mixed-nav');
/**
* 是否为头部导航模式
*/
const isHeaderNav = computed(() => realLayout.value === 'header-nav');
/**
* 是否为混合导航模式
*/
const isMixedNav = computed(() => realLayout.value === 'mixed-nav');
/**
* 顶栏是否自动隐藏
*/
const isHeaderAuto = computed(() => props.headerMode === 'auto');
/**
* header区域高度
*/
const getHeaderHeight = computed(() => {
const { headerHeight, headerHeightOffset, headerVisible } = props;
if (!headerVisible) {
return 0;
}
// 顶部存在导航时增加10
const offset = isMixedNav.value || isHeaderNav.value ? headerHeightOffset : 0;
return headerHeight + offset;
});
const headerWrapperHeight = computed(() => {
let height = 0;
if (props.headerVisible) {
height += getHeaderHeight.value;
}
if (props.tabsVisible) {
height += props.tabsHeight;
}
return height;
});
const getSideCollapseWidth = computed(() => {
const { sideCollapseShowTitle, sideCollapseWidth, sideMixedWidth } = props;
return sideCollapseShowTitle || isSideMixedNav
? sideMixedWidth
: sideCollapseWidth;
});
/**
* 动态获取侧边区域是否可见
*/
const sideVisibleState = computed(() => {
return !isHeaderNav.value && sideVisible.value;
});
/**
* 侧边区域离顶部高度
*/
const sidePaddingTop = computed(() => {
const { isMobile } = props;
return isMixedNav.value && !isMobile ? getHeaderHeight.value : 0;
});
/**
* 动态获取侧边宽度
*/
const getSideWidth = computed(() => {
const { isMobile, sideMixedWidth, sideWidth } = props;
let width = 0;
if (
!sideVisibleState.value ||
(sideHidden.value && !isSideMixedNav.value && !isMixedNav.value)
) {
return width;
}
if (isSideMixedNav.value && !isMobile) {
width = sideMixedWidth;
} else if (sideCollapse.value) {
width = isMobile ? 0 : getSideCollapseWidth.value;
} else {
width = sideWidth;
}
return width;
});
/**
* 获取扩展区域宽度
*/
const getExtraWidth = computed(() => {
const { sideWidth } = props;
return sideExtraCollapse.value ? getSideCollapseWidth.value : sideWidth;
});
/**
* 是否侧边栏模式,包含混合侧边
*/
const isSideMode = computed(() =>
['mixed-nav', 'side-mixed-nav', 'side-nav'].includes(realLayout.value),
);
const showSide = computed(() => {
return isSideMode.value && sideVisible.value;
});
const sideFace = computed(() => {
const { sideSemiDark, sideTheme } = props;
const isDark = sideTheme === 'dark' || sideSemiDark;
let backgroundColor = '';
let extraBackgroundColor = '';
if (isDark) {
backgroundColor = isSideMixedNav.value
? 'hsl(var(--color-menu-dark-darken))'
: 'hsl(var(--color-menu-dark))';
} else {
backgroundColor = isSideMixedNav.value
? 'hsl(var(--color-menu-darken))'
: 'hsl(var(--color-menu))';
}
extraBackgroundColor = isDark
? 'hsl(var(--color-menu-dark))'
: 'hsl(var(--color-menu))';
return {
backgroundColor,
extraBackgroundColor,
theme: isDark ? 'dark' : 'light',
};
});
/**
* 遮罩可见性
*/
const maskVisible = computed(() => !sideCollapse.value && props.isMobile);
/**
* header fixed值
*/
const headerFixed = computed(() => {
return (
isMixedNav.value ||
['auto', 'auto-scroll', 'fixed'].includes(props.headerMode)
);
});
const mainStyle = computed(() => {
let width = '100%';
let sidebarWidth = 'unset';
if (
headerFixed.value &&
!['header-nav', 'mixed-nav'].includes(realLayout.value) &&
showSide.value &&
!props.isMobile
) {
// pin模式下生效
const isSideNavEffective =
isSideMixedNav.value && sideExpandOnHover.value && sideExtraVisible.value;
if (isSideNavEffective) {
const sideCollapseWidth = sideCollapse.value
? getSideCollapseWidth.value
: props.sideMixedWidth;
const sideWidth = sideExtraCollapse.value
? getSideCollapseWidth.value
: props.sideWidth;
// 100% - 侧边菜单混合宽度 - 菜单宽度
sidebarWidth = `${sideCollapseWidth + sideWidth}px`;
width = `calc(100% - ${sidebarWidth})`;
} else {
sidebarWidth =
sideExpandOnHovering.value && !sideExpandOnHover.value
? `${getSideCollapseWidth.value}px`
: `${getSideWidth.value}px`;
width = `calc(100% - ${sidebarWidth})`;
}
}
return {
sidebarWidth,
width,
};
});
const tabsStyle = computed((): CSSProperties => {
let width = '';
let marginLeft = 0;
if (!isMixedNav.value) {
width = '100%';
} else if (sideVisible.value) {
marginLeft = sideCollapse.value
? getSideCollapseWidth.value
: props.sideWidth;
width = `calc(100% - ${getSideWidth.value}px)`;
} else {
width = '100%';
}
return {
marginLeft: `${marginLeft}px`,
width,
};
});
const footerWidth = computed(() => {
if (!props.footerFixed) {
return '100%';
}
return mainStyle.value.width;
});
const contentStyle = computed((): CSSProperties => {
const fixed = headerFixed.value;
return {
marginTop:
fixed &&
!fullContent.value &&
!headerIsHidden.value &&
(!isHeaderAuto.value || scrollY.value < headerWrapperHeight.value)
? `${headerWrapperHeight.value}px`
: 0,
};
});
const headerZIndex = computed(() => {
const { zIndex } = props;
const offset = isMixedNav.value ? 1 : 0;
return zIndex + offset;
});
const headerWrapperStyle = computed((): CSSProperties => {
const fixed = headerFixed.value;
return {
height: fullContent.value ? '0' : `${headerWrapperHeight.value}px`,
left: isMixedNav.value ? 0 : mainStyle.value.sidebarWidth,
position: fixed ? 'fixed' : 'static',
top:
headerIsHidden.value || fullContent.value
? `-${headerWrapperHeight.value}px`
: 0,
width: mainStyle.value.width,
'z-index': headerZIndex.value,
};
});
/**
* 侧边栏z-index
*/
const sideZIndex = computed(() => {
const { isMobile, zIndex } = props;
const offset = isMobile || isSideMode.value ? 1 : -1;
return zIndex + offset;
});
const maskStyle = computed((): CSSProperties => {
return {
zIndex: props.zIndex,
};
});
const showHeaderToggleButton = computed(() => {
return (
isSideMode.value &&
!isSideMixedNav.value &&
!isMixedNav.value &&
!props.isMobile
);
// return false;
});
const showHeaderLogo = computed(() => {
return !isSideMode.value || isMixedNav.value || props.isMobile;
});
watch(
() => props.isMobile,
(val) => {
sideCollapse.value = val;
},
);
{
const mouseMove = () => {
mouseY.value > headerWrapperHeight.value
? (headerIsHidden.value = true)
: (headerIsHidden.value = false);
};
watch(
[() => props.headerMode, () => mouseY.value],
() => {
if (!isHeaderAuto.value || isMixedNav.value || fullContent.value) {
return;
}
headerIsHidden.value = true;
mouseMove();
},
{
immediate: true,
},
);
}
{
const checkHeaderIsHidden = useThrottleFn((top, bottom, topArrived) => {
if (scrollY.value < headerWrapperHeight.value) {
headerIsHidden.value = false;
return;
}
if (topArrived) {
headerIsHidden.value = false;
return;
}
if (top) {
headerIsHidden.value = false;
} else if (bottom) {
headerIsHidden.value = true;
}
}, 300);
watch(
() => scrollY.value,
() => {
if (
props.headerMode !== 'auto-scroll' ||
isMixedNav.value ||
fullContent.value
) {
return;
}
if (isScrolling.value) {
checkHeaderIsHidden(
directions.top,
directions.bottom,
arrivedState.top,
);
}
},
);
}
function handleClickMask() {
sideCollapse.value = true;
}
function handleToggleMenu() {
// sideVisible.value = !sideVisible.value;
sideHidden.value = !sideHidden.value;
}
function handleOpenMenu() {
sideCollapse.value = false;
}
</script>
<template>
<div :class="[b(), is(realLayout, true)]">
<slot name="preference"></slot>
<slot name="back-top"></slot>
<LayoutSide
v-if="sideVisibleState"
v-model:collapse="sideCollapse"
v-model:extra-collapse="sideExtraCollapse"
v-model:expand-on-hovering="sideExpandOnHovering"
v-model:expand-on-hover="sideExpandOnHover"
v-model:extra-visible="sideExtraVisible"
:dom-visible="!isMobile"
:fixed-extra="sideExpandOnHover"
:mixed-width="sideMixedWidth"
:header-height="isMixedNav ? 0 : getHeaderHeight"
:collapse-width="getSideCollapseWidth"
:is-side-mixed="isSideMixedNav"
:padding-top="sidePaddingTop"
:show="showSide"
:extra-width="getExtraWidth"
:width="getSideWidth"
:z-index="sideZIndex"
v-bind="sideFace"
@leave="() => emit('sideMouseLeave')"
>
<template v-if="isSideMode && !isMixedNav" #logo>
<slot name="logo"></slot>
</template>
<template v-if="isSideMixedNav">
<slot name="mixed-menu"></slot>
</template>
<template v-else>
<slot name="menu"></slot>
</template>
<template #extra>
<slot name="side-extra"></slot>
</template>
<template #extra-title>
<slot name="side-extra-title"></slot>
</template>
</LayoutSide>
<div :class="e('main')">
<div :style="headerWrapperStyle" :class="e('header-wrapper')">
<LayoutHeader
v-if="headerVisible"
:full-width="!isSideMode"
:height="getHeaderHeight"
:show="!fullContent"
:side-hidden="sideHidden"
:show-toggle-btn="showHeaderToggleButton"
:width="mainStyle.width"
:is-mixed-nav="isMixedNav"
:is-mobile="isMobile"
:z-index="headerZIndex"
:side-width="sideWidth"
@toggle-menu="handleToggleMenu"
@open-menu="handleOpenMenu"
>
<template v-if="showHeaderLogo" #logo>
<slot name="logo"></slot>
</template>
<slot name="header"></slot>
</LayoutHeader>
<LayoutTabs v-if="tabsVisible" :height="tabsHeight" :style="tabsStyle">
<slot name="tabs"></slot>
<template #toolbar>
<slot name="tabs-toolbar"></slot>
</template>
</LayoutTabs>
</div>
<!-- </div> -->
<LayoutContent
:class="e('content')"
:style="contentStyle"
:content-compact="contentCompact"
:content-compact-width="contentCompactWidth"
:padding="contentPadding"
:padding-bottom="contentPaddingBottom"
:padding-left="contentPaddingLeft"
:padding-right="contentPaddingRight"
:padding-top="contentPaddingTop"
>
<slot name="content"></slot>
</LayoutContent>
<LayoutFooter
v-if="footerVisible"
:fixed="footerFixed"
:height="footerHeight"
:show="!fullContent"
:width="footerWidth"
:z-index="zIndex"
>
<slot name="footer"></slot>
</LayoutFooter>
</div>
<div
v-if="maskVisible"
:class="e('mask')"
:style="maskStyle"
@click="handleClickMask"
></div>
</div>
</template>
<style scoped lang="scss">
@import '@vben-core/design/global';
@include b('layout') {
position: relative;
display: flex;
width: 100%;
min-height: 100%;
@include e('main') {
display: flex;
flex: auto;
flex: 1;
flex-direction: column;
overflow-x: hidden;
background-color: hsl(var(--color-body));
border-left: 1px solid hsl(var(--color-border));
transition: all 0.3s ease;
}
@include e('content') {
transition: margin-top 0.3s ease;
}
@include e('header-wrapper') {
overflow: hidden;
transition: all 0.25s ease-in-out;
}
@include e('mask') {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgb(0 0 0 / 40%);
transition: background-color 0.2s;
}
}
</style>

View File

@@ -0,0 +1 @@
export { default } from '@vben/tailwind-config';

View File

@@ -0,0 +1,5 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@vben/tsconfig/web.json",
"include": ["src"]
}

View File

@@ -0,0 +1,3 @@
import { defineConfig } from '@vben/vite-config';
export default defineConfig();