Vue3 通用容器布局设计器实现|可直接集成使用

在各类管理系统开发中,容器内元素的可视化布局设计是高频需求,本文分享一个基于Vue3 + TypeScript + Ant Design Vue开发的通用容器布局设计器,移除所有业务敏感数据,支持横向/纵向排列、自定义间隔、单元格拖拽等核心功能,代码可直接集成到项目中使用。

核心功能

该布局设计器为通用化组件,无业务耦合,核心能力全覆盖:
✅ 横向(行优先)/纵向(列优先)两种布局模式自由切换
✅ 支持空格/换行两种间隔规则,可自定义间隔数量
✅ 单元格拖拽交换元素位置,支持手动微调布局
✅ 布局越界智能检测,自动降级调整间隔规则,避免超出容器范围
✅ 单元格选中/悬停/有内容多状态样式反馈
✅ 布局数据一键保存,与后端无缝交互
✅ 支持动态适配不同容器的行列数配置

技术栈

  • 框架:Vue3 + TypeScript
  • UI组件库:Ant Design Vue
  • 样式:Scoped CSS + Less
  • 核心特性:Vue3组合式API、原生拖拽API、数据深拷贝、状态标记控制

组件核心结构

组件采用模块化设计,分为标题操作区、布局配置区、布局展示区三部分,结构清晰,易于维护:

  1. 标题操作区:展示容器名称,提供布局保存按钮
  2. 布局配置区:实现横向/纵向切换、间隔规则(模式+数量)配置
  3. 布局展示区:可视化渲染容器单元格,实现元素展示、拖拽、选中等交互

快速集成使用

1. 环境依赖

确保项目中已引入以下依赖,且Ant Design Vue组件已全局/按需注册:

  • Vue3 + TypeScript
  • Ant Design Vue(需引入a-modal、a-card、a-button、a-switch、a-select、a-tooltip等组件)
  • 开启Scoped CSS(避免样式污染)

2. 代码获取

直接复制以下完整Vue3单文件组件代码,保存为ContainerDesigner.vue:

<template>
  <a-modal
    v-model:visible="visible"
    :destroy-on-close="true"
    :mask-closable="false"
    :keyboard="false"
    :footer="false"
    centered
    style="width: 1080px; max-height: calc(100vh - 20px)"
  >
    <!-- 标题操作区 -->
    <template #title>
      <div class="title-wrap">
        <span>设计({{ containerInfo.name }})</span>
        <div class="design-btns">
          <a-button type="primary" @click="submit">保存</a-button>
        </div>
      </div>
    </template>

    <!-- 布局配置区 -->
    <a-card>
      <div class="desc">
        <span>说明:</span>
        <span class="text-gray-400">
          1. 预先配置容器内元素的存储位置;2. 下图示例容器的元素布局,若不符合要求,请调整排列方式或手动拖拽;3. 当前方案下所有容器统一应用此布局规则。
        </span>
      </div>
      <div class="sort-box">
        <div class="flex-item">
          <span class="flex">
            排列
            <a-tooltip :overlayStyle="{ 'max-width': '420px' }">
              <i class="cursor-pointer wentishuoming icpIconfont hover-primary"></i>
              <template #title>
                排列说明<br />
                横向:指所有元素每行从左往右排列 <br />
                纵向:指所有元素每行从上往下排列<br />
                间隔:指每个分组之间按设置要求另起行或者留出空格
              </template>
            </a-tooltip>
            :
          </span>
          <a-switch
            v-model:checked="horizontalLayout"
            @change="dealData()"
            checked-children="横向"
            un-checked-children="纵向"
          />
        </div>
        <div class="flex-item">
          <span>间隔:</span>
          <a-input-group compact>
            <a-select v-model:value="breakModal" style="width: 66px" @change="changeModal" size="small">
              <a-select-option :value="1">空格</a-select-option>
              <a-select-option :value="2">换行</a-select-option>
            </a-select>
            <a-select v-model:value="breakRow" style="width: 60px" size="small">
              <template v-if="breakModal === 1">
                <a-select-option :value="0">0</a-select-option>
                <a-select-option v-for="item in 10" :key="`gap_${item}`" :value="item">{{ item }}</a-select-option>
              </template>
              <template v-if="breakModal === 2">
                <a-select-option v-for="item in 5" :key="`line_${item}`" :value="item">{{ item }}</a-select-option>
              </template>
            </a-select>
          </a-input-group>
          <a-button type="primary" @click="dealData()" size="small"> 确定</a-button>
        </div>
      </div>
    </a-card>

    <!-- 布局展示区 -->
    <a-card style="margin-top: 12px">
      <div class="item-container">
        <div v-for="row in options.row" :key="`row_${row}`" class="item-row">
          <div class="item-col item-index index-col">{{ row }}</div>
          <div
            v-for="col in options.col"
            :key="`cell_${row}_${col}`"
            class="item-col"
            style="cursor: pointer"
            :class="{ br: col === 1, brt: row === 1, selected: isSelected(row, col), hovered: isHovered(row, col), filled: hasContent(row, col) }"
            draggable
            @dragstart="onDragStart($event, row, col)"
            @dragover.prevent="onDragOver($event, row, col)"
            @dragleave="onDragLeave"
            @drop="onDrop($event, row, col)"
            @click="selectCell(row, col)"
          >
            <template v-if="dataArr[`${row}_${col}`]">
              {{ dataArr[`${row}_${col}`].shortCode }}
              <br />
              {{ dataArr[`${row}_${col}`].name }}
            </template>
          </div>
        </div>
        <div class="item-row index-bottom">
          <div class="item-col item-index item-corner"></div>
          <div v-for="col in options.col" :key="`col_index_${col}`" class="item-col item-index index-row">
            {{ col }}
          </div>
        </div>
      </div>
    </a-card>
  </a-modal>
</template>

<script lang="ts" setup>
import { ref } from 'vue'
import { message } from 'ant-design-vue'
// 请替换为项目实际接口路径
import { getContainerData, batchUpdateLayout } from '@/api/container/layout'

// 基础配置
const breakModal = ref<number>(1)  // 间隔模式:1-空格 2-换行
const breakRow = ref<number>(1)    // 间隔数量:空格数/换行数
const visible = ref<boolean>(false)
const options = ref({ row: 9, col: 9, cw: 90, ch: 70 })

// 布局数据与交互状态
const dataArr = ref<Record<string, any>>({})
const selectedCell = ref<string>('')
const draggedCell = ref<string>('')
const hoveredCell = ref<string>('')
const horizontalLayout = ref<boolean>(true)
const containerInfo = ref<Record<string, any>>({})

// 原始数据与分组数据
const originList = ref<any[]>([])
const groupedList = ref<any[]>([])

// 越界控制标记
const isOutOfBounds = ref<boolean>(false)
const isAutoAdjusting = ref<boolean>(false)

// 间隔模式切换参数修正
const changeModal = () => {
  if (breakModal.value === 2 && breakRow.value === 0) breakRow.value = 1
  if (breakModal.value === 2 && breakRow.value > 5) breakRow.value = 5
}

// 越界自动调整间隔规则
const autoAdjustGapAndDeal = () => {
  if (isAutoAdjusting.value) return
  isAutoAdjusting.value = true
  const originalModal = breakModal.value
  const originalGap = breakRow.value
  let adjustMsg = ''
  try {
    if (originalModal === 1) {
      breakRow.value = 0
      adjustMsg = `空格间隔(${originalGap})超出容器容量,已自动恢复为(0)`
      resetAndDealData()
    } else if (originalModal === 2) {
      breakRow.value = 1
      adjustMsg = `换行间隔(${originalGap})超出容器容量,已自动恢复为(1)`
      resetAndDealData()
      if (isOutOfBounds.value) {
        breakModal.value = 1
        breakRow.value = 0
        adjustMsg = `换行间隔(1)仍超出容器容量,已自动切换为空格模式(0)`
        resetAndDealData()
      }
    }
    if (isOutOfBounds.value) message.error('即使取消所有间隔仍超出容器限制,请检查元素数量或容器大小')
    else if (adjustMsg) message.warning(adjustMsg)
  } catch (error) {
    console.error('自动调整间隔失败:', error)
    message.error('自动调整间隔失败,请手动调整')
  } finally {
    isAutoAdjusting.value = false
  }
}

// 重置布局并重新处理
const resetAndDealData = () => {
  dataArr.value = {}
  isOutOfBounds.value = false
  const list = JSON.parse(JSON.stringify(originList.value))
  const grouped = JSON.parse(JSON.stringify(groupedList.value))
  if (list.length === 0) return
  horizontalLayout.value ? handleHorizontalLayout(list, grouped) : handleVerticalLayout(list, grouped)
}

// 核心布局处理
const dealData = () => {
  try {
    dataArr.value = {}
    isOutOfBounds.value = false
    const list = JSON.parse(JSON.stringify(originList.value))
    const grouped = JSON.parse(JSON.stringify(groupedList.value))
    if (list.length === 0) return
    horizontalLayout.value ? handleHorizontalLayout(list, grouped) : handleVerticalLayout(list, grouped)
    if (isOutOfBounds.value && !isAutoAdjusting.value) autoAdjustGapAndDeal()
  } catch (error) {
    console.error('布局处理失败:', error)
    message.error('布局处理失败,请重试')
    isOutOfBounds.value = false
    isAutoAdjusting.value = false
  }
}
/**
 * 处理横向布局(行优先)
 * @param list 原始数据列表
 * @param grouped 分组数据列表
 */
 function handleHorizontalLayout(list: any[], grouped: any[]) {
  let currentRow = 1
  let currentCol = 1
  // 根据间隔模式处理
  if (breakModal.value === 1) {
    // 空格模式:每个分组后空N格
    for (const [groupIndex, group] of grouped.entries()) {
      if (isOutOfBounds.value) break
      // 处理当前分组的每个元素
      for (const item of group) {
        if (checkOutOfBounds(currentRow, currentCol)) break
        const key = `${currentRow}_${currentCol}`
        dataArr.value[key] = { ...item }
        currentCol++
        // 列越界则换行
        if (currentCol > options.value.col) {
          currentRow++
          currentCol = 1
        }
      }
      if (isOutOfBounds.value) break
      // 不是最后一个分组,才添加间隔
      if (groupIndex < grouped.length - 1 && breakRow.value > 0) {
        let remainingGap = breakRow.value
        while (remainingGap > 0 && !isOutOfBounds.value) {
          currentCol++
          remainingGap--
          if (currentCol > options.value.col) {
            currentRow++
            currentCol = 1
            if (checkOutOfBounds(currentRow, currentCol)) {
              remainingGap = 0
              break
            }
          }
        }
      }
    }
  } else if (breakModal.value === 2) {
    // 换行模式:每组数据后换N行
    for (const [groupIndex, group] of grouped.entries()) {
      if (isOutOfBounds.value) break
      for (const item of group) {
        if (checkOutOfBounds(currentRow, currentCol)) break
        const key = `${currentRow}_${currentCol}`
        dataArr.value[key] = { ...item }
        currentCol++
        if (currentCol > options.value.col) {
          currentRow++
          currentCol = 1
          if (checkOutOfBounds(currentRow, currentCol)) {
            break
          }
        }
      }
      
      if (isOutOfBounds.value) break
      if (groupIndex < grouped.length - 1 && breakRow.value > 0) {
        const targetRow = currentRow + breakRow.value
        if (targetRow > options.value.row) {
          isOutOfBounds.value = true
          break
        }
        currentRow = targetRow
        currentCol = 1
      }
    }
  }
}

/**
 * 处理纵向布局(列优先)
 * @param list 原始数据列表
 * @param grouped 分组数据列表
 */
 function handleVerticalLayout(list: any[], grouped: any[]) {
  let currentRow = 1
  let currentCol = 1
  if (breakModal.value === 1) {
    // 空格模式:每个分组后空N行
    for (const [groupIndex, group] of grouped.entries()) {
      if (isOutOfBounds.value) break
      for (const item of group) {
        if (checkOutOfBounds(currentRow, currentCol)) break
        const key = `${currentRow}_${currentCol}`
        dataArr.value[key] = { ...item }
        currentRow++
        if (currentRow > options.value.row) {
          currentCol++
          currentRow = 1
        }
      }
      if (isOutOfBounds.value) break
      if (groupIndex < grouped.length - 1 && breakRow.value > 0) {
        let remainingGap = breakRow.value
        while (remainingGap > 0 && !isOutOfBounds.value) {
          currentRow++
          remainingGap--
          if (currentRow > options.value.row) {
            currentCol++
            currentRow = 1
            if (checkOutOfBounds(currentRow, currentCol)) {
              remainingGap = 0
              break
            }
          }
        }
      }
    }
  } else if (breakModal.value === 2) {
    // 换行模式:每组数据后换N列
    for (const [groupIndex, group] of grouped.entries()) {
      if (isOutOfBounds.value) break
      for (const item of group) {
        if (checkOutOfBounds(currentRow, currentCol)) break
        const key = `${currentRow}_${currentCol}`
        dataArr.value[key] = { ...item }
        currentRow++
        if (currentRow > options.value.row) {
          currentCol++
          currentRow = 1
          if (checkOutOfBounds(currentRow, currentCol)) {
            break
          }
        }
      }
      if (isOutOfBounds.value) break
      if (groupIndex < grouped.length - 1 && breakRow.value > 0) {
        const targetCol = currentCol + breakRow.value
        if (targetCol > options.value.col) {
          isOutOfBounds.value = true
          break
        }
        currentCol = targetCol
        currentRow = 1
      }
    }
  }
}

/**
 * 检查行列是否越界
 * @param row 当前行
 * @param col 当前列
 * @returns 是否越界
 */
function checkOutOfBounds(row: number, col: number): boolean {
  if (isOutOfBounds.value) return true
  // 只有行/列超过容器最大行列数时才算越界,等于的情况不算
  if (row > options.value.row || col > options.value.col) {
    isOutOfBounds.value = true
    return true
  }
  return false
}

// 打开设计器
const show = (_item: any, _pack: any) => {
  horizontalLayout.value = true; breakRow.value = 0; breakModal.value = 1
  containerInfo.value = _item; dataArr.value = {}
  isOutOfBounds.value = false; isAutoAdjusting.value = false
  options.value = { ...options.value, col: _item.colNum, row: _item.rowNum }
  visible.value = true
  getContainerDetail(_item.id, _pack.packageId)
}

// 获取容器数据
const getContainerDetail = (_id: string, _packageId: string) => {
  getContainerData({ containerId: _id, packageId: _packageId }).then((res) => {
    originList.value = res || []
    groupedList.value = (originList.value || []).reduce((acc: any[], item) => {
      const group = acc.find((g) => g[0]?.groupIndex === item.groupIndex)
      group ? group.push(item) : acc.push([item])
      return acc
    }, [])
    originList.value.forEach((cell) => {
      dataArr.value[`${cell.rowNum}_${cell.colNum}`] = cell
    })
  })
}

// 单元格选择
const selectCell = (row: number, col: number) => { selectedCell.value = `${row}_${col}` }
const isSelected = (row: number, col: number): boolean => { return selectedCell.value === `${row}_${col}` }

// 单元格内容检查
const hasContent = (row: number, col: number): boolean => { return !!dataArr.value[`${row}_${col}`] }

// 拖拽相关方法
const onDragStart = (event: DragEvent, row: number, col: number) => { draggedCell.value = `${row}_${col}` }
const onDragOver = (event: DragEvent, row: number, col: number) => { hoveredCell.value = `${row}_${col}` }
const onDragLeave = () => { hoveredCell.value = '' }
const isHovered = (row: number, col: number): boolean => { return hoveredCell.value === `${row}_${col}` }
const onDrop = (event: DragEvent, row: number, col: number) => {
  const targetCell = `${row}_${col}`
  if (draggedCell.value && draggedCell.value !== targetCell) {
    const temp = dataArr.value[targetCell]
    dataArr.value[targetCell] = dataArr.value[draggedCell.value]
    dataArr.value[draggedCell.value] = temp
  }
  hoveredCell.value = ''
}

// 保存布局
const submit = () => {
  const list: any[] = []
  for (const key in dataArr.value) {
    const [row, col] = key.split('_').map(Number)
    const cellObj = dataArr.value[key]
    if (cellObj) list.push({ ...cellObj, rowNum: row, colNum: col })
  }
  batchUpdateLayout(list)
    .then(() => { message.success('布局保存成功'); visible.value = false })
    .catch(() => { message.error('布局保存失败,请重试') })
}

// 暴露方法供父组件调用
defineExpose({ show })
</script>

<style lang="less" scoped>
// 通用样式
.flex { display: flex; align-items: center; gap: 8px; }
.text-gray-400 { color: #999; }

// 标题栏
.title-wrap { width: 100%; display: flex; justify-content: space-between; align-items: center; }
.design-btns { margin-right: 12px; display: flex; gap: 8px; }

// 配置区
.desc { font-size: 13px; line-height: 1.5; }
.sort-box { display: flex; gap: 60px; font-size: 12px; margin-top: 12px; align-items: center; white-space: nowrap; }
.flex-item { display: flex; align-items: center; gap: 8px; }

// 布局容器核心样式
.item-container { overflow: auto; }
.item-row { display: flex; }
.index-bottom { height: 30px; }
.item-col {
  width: 90px; height: 70px; border: 1px solid #e8e8e8;
  display: flex; align-items: center; justify-content: center;
  text-align: center; font-size: 12px; line-height: 1.4;
}
// 首列首行加粗边框
.item-col.br { border-left: 2px solid #000; }
.item-col.brt { border-top: 2px solid #000; }
// 索引单元格
.item-index { background: #fafafa; font-weight: bold; font-size: 14px; }
.index-col { width: 40px; }
.index-row { height: 30px; }
.item-corner { width: 40px; height: 30px; border: none; }

// 单元格状态
.filled { background-color: #f0f0f0; }
.selected { background-color: rgba(24, 144, 255, 0.2); border-color: #1890ff; }
.hovered { background-color: rgba(24, 144, 255, 0.5); border: 2px dashed #1890ff; }

// 图标样式
.icpIconfont { font-size: 20px; line-height: normal; }
.hover-primary { transition: color 0.2s; &:hover { color: #1890ff; } }
</style>

4. 父组件调用

在需要使用的页面引入组件,通过ref调用show方法即可打开设计器,示例代码:

<template>
  <a-button type="primary" @click="openDesigner">打开布局设计器</a-button>
  <!-- 布局设计器组件 -->
  <ContainerDesigner ref="designerRef" />
</template>

<script lang="ts" setup>
import { ref } from 'vue'
import ContainerDesigner from './ContainerDesigner.vue'

// 定义组件ref
const designerRef = ref()

// 打开设计器方法
const openDesigner = () => {
  // 容器信息(接口获取)
  const containerInfo = {
    id: 'container_001',
    name: '通用测试容器',
    rowNum: 9,
    colNum: 9
  }
  // 调用组件的show方法
  designerRef.value?.show(containerInfo)
}
</script>

核心亮点

1. 通用化设计,无业务耦合

移除所有行业/业务相关数据和命名,采用shortCode、name、groupIndex等通用字段,可适配仓储管理、设备布局、表单设计等各类容器布局场景。

2. 智能越界处理,无需手动干预

内置布局越界检测机制,当元素数量超出容器容量时,会自动按规则降级调整间隔:

  • 空格模式越界 → 自动恢复为0空格
  • 换行模式越界 → 先恢复为1行,仍越界则切换为空格模式0空格
  • 增加防死循环标记,避免调整逻辑重复执行

3. 流畅的交互体验

  • 原生拖拽API实现单元格拖拽,操作丝滑无卡顿
  • 单元格选中、悬停、有内容三种状态差异化样式,反馈清晰
  • 布局配置后点击「确定」实时渲染,支持即时预览
  • 保存成功后自动关闭弹窗,操作流程闭环

4. 代码简洁易维护

  • 采用Vue3组合式API,逻辑按功能模块化拆分,便于阅读和修改
  • 移除所有冗余注释和代码,保留核心逻辑,组件体积小
  • 样式与结构分离,Scoped CSS避免样式污染,易于自定义修改

自定义扩展指南

  1. 调整容器默认配置:修改options中的row、col可调整默认行列数,修改cw、ch可调整单元格宽高
  2. 修改间隔最大值:调整a-select中循环的10(空格最大)和5(换行最大)即可修改间隔数量上限
  3. 自定义样式:直接修改样式部分的颜色、尺寸、间距,可快速适配项目UI规范
  4. 扩展交互功能:可基于现有代码增加「批量删除」「一键重置」「布局复制」等功能
  5. 适配不同容器类型:支持传入不同的rowNum和colNum,实现单排、双排、多行列等不同容器的布局适配

总结

该Vue3通用容器布局设计器实现了可视化布局的核心需求,代码可直接集成使用,无需二次开发核心逻辑。组件采用模块化、通用化设计,兼顾了自动化布局渲染和手动微调,同时具备智能越界处理、流畅交互体验等特性,可快速适配各类管理系统中的容器布局场景,大幅提升开发效率。

更多推荐