Vue3 通用容器布局设计器实现
Vue3 通用容器布局设计器实现|可直接集成使用
在各类管理系统开发中,容器内元素的可视化布局设计是高频需求,本文分享一个基于Vue3 + TypeScript + Ant Design Vue开发的通用容器布局设计器,移除所有业务敏感数据,支持横向/纵向排列、自定义间隔、单元格拖拽等核心功能,代码可直接集成到项目中使用。
核心功能
该布局设计器为通用化组件,无业务耦合,核心能力全覆盖:
✅ 横向(行优先)/纵向(列优先)两种布局模式自由切换
✅ 支持空格/换行两种间隔规则,可自定义间隔数量
✅ 单元格拖拽交换元素位置,支持手动微调布局
✅ 布局越界智能检测,自动降级调整间隔规则,避免超出容器范围
✅ 单元格选中/悬停/有内容多状态样式反馈
✅ 布局数据一键保存,与后端无缝交互
✅ 支持动态适配不同容器的行列数配置
技术栈
- 框架:Vue3 + TypeScript
- UI组件库:Ant Design Vue
- 样式:Scoped CSS + Less
- 核心特性:Vue3组合式API、原生拖拽API、数据深拷贝、状态标记控制
组件核心结构
组件采用模块化设计,分为标题操作区、布局配置区、布局展示区三部分,结构清晰,易于维护:
- 标题操作区:展示容器名称,提供布局保存按钮
- 布局配置区:实现横向/纵向切换、间隔规则(模式+数量)配置
- 布局展示区:可视化渲染容器单元格,实现元素展示、拖拽、选中等交互
快速集成使用
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避免样式污染,易于自定义修改
自定义扩展指南
- 调整容器默认配置:修改
options中的row、col可调整默认行列数,修改cw、ch可调整单元格宽高 - 修改间隔最大值:调整
a-select中循环的10(空格最大)和5(换行最大)即可修改间隔数量上限 - 自定义样式:直接修改样式部分的颜色、尺寸、间距,可快速适配项目UI规范
- 扩展交互功能:可基于现有代码增加「批量删除」「一键重置」「布局复制」等功能
- 适配不同容器类型:支持传入不同的
rowNum和colNum,实现单排、双排、多行列等不同容器的布局适配
总结
该Vue3通用容器布局设计器实现了可视化布局的核心需求,代码可直接集成使用,无需二次开发核心逻辑。组件采用模块化、通用化设计,兼顾了自动化布局渲染和手动微调,同时具备智能越界处理、流畅交互体验等特性,可快速适配各类管理系统中的容器布局场景,大幅提升开发效率。
更多推荐

所有评论(0)