PageLayout布局组件封装技巧

在后台管理系统、工作台类应用中,常常需要 左侧导航 + 右侧内容区 的页面布局,同时右侧又可能包含 顶部区域(搜索栏 / 工具栏)+ 主体内容区。 为了避免重复编写布局逻辑,这里封装了一个通用的 GiPageLayout 组件,提供灵活的插槽与属性配置。

功能与特性

  • 左右分栏布局: 左侧(可折叠/隐藏) 右侧(包含 header 区域 + body 区域)

  • 插槽灵活: left:放置侧边栏内容(如菜单、筛选条件) header:放置工具栏、搜索栏 default:页面主体内容

  • 自适应移动端: 在移动端自动收起左侧栏 通过 SplitButton 控制展开/折叠

  • 交互增强: 左侧可拖拽调整宽度(基于 Arco a-split) 支持折叠动画,体验更自然

  • 样式定制: leftStyle、headerStyle、bodyStyle 三个 props 允许外部控制布局样式 是否显示边框、内边距、margin 由 props 控制

组件 Props 说明

属性 类型 默认值 说明
size string '270px' 左侧区域宽度
margin boolean false 是否增加外边距
inner boolean false 是否内部布局(去除额外 padding)
headerBordered boolean true 是否显示 header 底部边框
leftStyle CSSProperties {} 左侧区域样式
headerStyle CSSProperties {} 头部区域样式
bodyStyle CSSProperties {} 主体区域样式
collapsed boolean false 是否默认折叠左侧

插槽 Slots

插槽名 说明
left 左侧栏内容
header 顶部工具栏
default 主体内容

交互逻辑

  • 折叠/展开: 通过 SplitButton 控制 size,大于 20px 时认为展开,否则为收起状态。

  • 动画处理: 使用 collapseing 状态在折叠/展开时添加过渡效果。

  • 移动端优化: 在移动端浏览器环境下(xe-utils 判断),默认收起左侧栏。

样式设计

整体:flex 布局,高度 100% 占满容器

header 与 body:header 固定在顶部,body 填充剩余空间

分割线 trigger:定制化 trigger 样式,鼠标 hover / active 时加亮

折叠过渡:通过 transition: flex-basis 0.3s 平滑动画

演示

布局1

布局2

布局3

布局4

布局5

总结

GiPageLayout 组件封装了常见的后台布局模式:左侧导航 + 顶部工具栏 + 主体内容。 相比直接写布局,它具备:

  • 可扩展性好:插槽式设计,内容与布局解耦

  • 体验优化:支持拖拽、折叠动画、移动端适配

  • 可定制:支持样式覆盖与 props 配置

非常适合在 中后台系统 中作为统一的页面容器使用。

源码

index.vue

vue 复制代码
<template>
  <a-split v-model:size="size" class="gi-page-layout" :class="getClass" min="1px" max="600px" :disabled="!slots.left">
    <template #first>
      <div v-if="slots.left" class="gi-page-layout__left" :style="props.leftStyle">
        <slot name="left"></slot>
      </div>
    </template>
    <template #second>
      <div v-if="slots.header" class="gi-page-layout__header" :style="props.headerStyle">
        <slot name="header"></slot>
      </div>
      <div class="gi-page-layout__body" :style="props.bodyStyle">
        <slot></slot>
      </div>
    </template>
    <template v-if="props.collapsed || isMobile" #resize-trigger-icon>
      <SplitButton :collapsed="parseInt(size) < 20" @click="handleClick"></SplitButton>
    </template>
  </a-split>
</template>

<script setup lang='ts'>
import type { CSSProperties } from 'vue'
import { browse } from 'xe-utils'
import SplitButton from './SplitButton.vue'

defineOptions({ name: 'GiPageLayout' })

const props = withDefaults(defineProps<Props>(), {
  size: '270px',
  margin: false,
  inner: false,
  headerBordered: true,
  leftStyle: () => ({}),
  headerStyle: () => ({}),
  bodyStyle: () => ({}),
  collapsed: false
})

/** 组件插槽定义 */
defineSlots<{
  header: () => void
  left: () => void
  default: () => void
}>()

const slots = useSlots()
const isMobile = browse().isMobile ?? false
const size = ref(isMobile ? '0px' : props.size)
const collapseing = ref(false)

const getClass = computed(() => {
  return {
    'gi-page-layout--disabled': !slots.left,
    'gi-page-layout--margin': props.margin,
    'gi-page-layout--inner': props.inner,
    'gi-page-layout--has-header': slots.header,
    'gi-page-layout--header-bordered': props.headerBordered,
    'gi-page-layout--collapseing': collapseing.value
  }
})

/** 组件属性定义 */
interface Props {
  size?: string
  margin?: boolean
  inner?: boolean
  headerBordered?: boolean
  leftStyle?: CSSProperties
  headerStyle?: CSSProperties
  bodyStyle?: CSSProperties
  collapsed?: boolean
}

function handleClick() {
  collapseing.value = true
  setTimeout(() => {
    collapseing.value = false
  }, 300)
  size.value = Number.parseInt(size.value) > 20 ? '0px' : props.size
}
</script>

<style lang='scss' scoped>
:deep(.arco-split-pane) {
  height: 100%;
  overflow: hidden;
  display: flex;
  flex-direction: column;
}

:deep(.arco-split-trigger) {
  position: relative;
}

:deep(.arco-split-trigger-icon-wrapper) {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 16px;
  height: 100%;
  background-color: transparent;

  .arco-split-trigger-icon {
    display: none;
  }

  &:hover,
  &:active {
    &::before {
      width: 2px;
      background-color: var(--color-primary-light-2);
    }
  }

  &::before {
    content: '';
    width: 1px;
    height: 100%;
    position: absolute;
    top: 0;
    left: 50%;
    transform: translateX(-50%);
    background-color: var(--color-border-2);
  }
}

.gi-page-layout {
  flex: 1;
  width: auto;
  height: 100%;
  display: flex;
  overflow: hidden;
  box-sizing: border-box;
  background-color: var(--color-bg-1);
}

.gi-page-layout--margin {
  margin: $margin;
}

.gi-page-layout--has-header {
  .gi-page-layout__body {
    padding-top: 8px;
  }
}

.gi-page-layout--inner {
  .gi-page-layout__header {
    padding: 0;
  }

  .gi-page-layout__body {
    padding-left: 0;
    padding-right: 0;
    padding-bottom: 0;
  }
}

.gi-page-layout--header-bordered {
  .gi-page-layout__header {
    border-bottom: 1px solid var(--color-border);
  }
}

.gi-page-layout--disabled {
  :deep(> .arco-split-pane-first) {
    display: none;
  }
}

.gi-page-layout--collapseing {
  :deep(> .arco-split-pane-first) {
    transition: flex-basis 0.3s;
  }
}

.gi-page-layout__left {
  height: 100%;
  padding: $padding;
  display: flex;
  flex-direction: column;
  overflow: hidden;
  box-sizing: border-box;
}

.gi-page-layout__header {
  box-sizing: border-box;
  padding: $padding;
  padding-bottom: 0;
}

.gi-page-layout__body {
  padding: $padding;
  flex: 1;
  display: flex;
  flex-direction: column;
  overflow: hidden;
  box-sizing: border-box;
}
</style>

SplitButton.vue

vue 复制代码
<!--
  @file GiSplitButton 组件 - 分割面板切换按钮
  @description 提供可折叠面板的切换按钮,支持多种样式和交互效果
-->
<template>
  <div class="gi-split-button" :class="buttonClass" @click="handleClick">
    <icon-right v-if="collapsed" :size="iconSize" />
    <icon-left v-else :size="iconSize" />
  </div>
</template>

<script setup lang="ts">
import { computed } from 'vue'

/** 按钮类型 */
type ButtonType = 'default' | 'circle'

/** 组件属性定义 */
interface Props {
  /** 是否折叠状态 */
  collapsed?: boolean
  /** 按钮类型 */
  type?: ButtonType
  /** 图标大小 */
  iconSize?: number
  /** 是否禁用 */
  disabled?: boolean
}

/** 组件事件定义 */
interface Emits {
  (e: 'click'): void
  (e: 'update:collapsed', value: boolean): void
}

const props = withDefaults(defineProps<Props>(), {
  collapsed: false,
  type: 'circle',
  iconSize: 14,
  disabled: false
})

const emit = defineEmits<Emits>()

/** 计算按钮类名 */
const buttonClass = computed(() => [
  `gi-split-button--${props.type}`,
  {
    'is-collapsed': props.collapsed,
    'is-disabled': props.disabled
  }
])

/** 处理点击事件 */
const handleClick = () => {
  if (props.disabled) return
  emit('click')
  emit('update:collapsed', !props.collapsed)
}
</script>

<style lang="scss" scoped>
.gi-split-button {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  display: flex;
  justify-content: center;
  align-items: center;
  z-index: 2;
  border: 1px solid var(--color-border-2);
  background-color: var(--color-bg-1);
  box-sizing: border-box;
  cursor: pointer;
  transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
  will-change: transform, background-color, border-color;

  &.is-disabled {
    cursor: not-allowed;
    opacity: 0.6;
    pointer-events: none;
  }

  &--default {
    width: 18px;
    height: 40px;
    left: 0;
    box-shadow: 2px 0 6px rgba(0, 0, 0, 0.1);
  }

  &--circle {
    width: 24px;
    height: 24px;
    border-radius: 50%;
    left: -4px;
    overflow: hidden;
    box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
  }
}
</style>
相关推荐
程序员小续2 分钟前
React 官方严令禁止:Hook 不能写在 if/else,真相竟然是…
前端·javascript·程序员
懒得不想起名字2 分钟前
flutter_riverpod: ^2.6.1 应用笔记
前端
CrabXin2 分钟前
让网页在 PC 缩放时“纹丝不动”的 4 个技巧
前端·react.js
Juchecar12 分钟前
Naive UI 学习指南 - Vue3 初学者完全教程
前端·vue.js
用户81686947472514 分钟前
从0到1教你开发一个Mini-ESLint
前端·开源
coding随想14 分钟前
JavaScript中的DOM事件对象Event全解析
前端
专研狂14 分钟前
React 的闭包陷阱 + 状态异步更新机制
前端
zabr19 分钟前
AI黑箱解密:开发者必须了解的AI内部机制真相,原来我们一直被忽悠了
前端·aigc·ai编程
Sokach38631 分钟前
vue3引入tailwindcss 4.1
前端·css
小奋斗40 分钟前
深入浅出:JavaScript中 三大异步编程方案以及应用
javascript·面试