最近在做一个在线考试系统的项目时,遇到了一个很有意思的需求:需要实现一个考场座位布局编辑器。这个编辑器要能让学生座位按照矩阵排列,可以标记座位状态,还能拖拽调整座位顺序。做完之后发现,这套逻辑完全可以复用在电影选座、会议室预订等场景。
矩阵式布局的核心在于用坐标系统来管理每个元素的位置。就像下棋时每个棋子都有对应的坐标一样,我们把每个座位看作棋盘上的一个点。这种设计最大的好处是:
我在实现过程中发现,很多开发者遇到类似需求时都会重复造轮子。其实只要抽象出核心逻辑,完全可以做成一个通用的Vue3组件。下面我就分享下这个组件的设计思路和实现细节。
首先需要定义座位的数据结构。这里我用TypeScript定义了一个接口:
typescript复制interface SeatModel {
x: number; // 横坐标
y: number; // 纵坐标
seatNo: number; // 座位编号
seatId: string | number; // 后端生成的唯一ID
status?: number; // 座位状态(可选)
}
这个模型包含了座位的基本信息:
x,y确定座位在矩阵中的位置seatNo是用户可见的编号seatId用于和后端数据关联status可以扩展各种状态(如可用/禁用等)生成矩阵的核心是双层循环。比如要生成8×8的矩阵:
typescript复制const generateMatrix = (rows: number, cols: number) => {
const seats: SeatModel[] = [];
let seatNo = 1;
for (let x = 0; x < rows; x++) {
for (let y = 0; y < cols; y++) {
seats.push({
x,
y,
seatNo: seatNo++,
seatId: ''
});
}
}
return seats;
};
这里有个性能优化点:为了快速通过坐标查找座位,我们可以额外维护一个Map:
typescript复制const seatMap = new Map<string, SeatModel>();
seats.forEach(seat => {
seatMap.set(`${seat.x},${seat.y}`, seat);
});
这样后续通过坐标查找座位时,时间复杂度可以从O(n)降到O(1)。
实现座位选择的核心是点击事件处理:
typescript复制const handleSeatClick = (x: number, y: number) => {
const key = `${x},${y}`;
const seat = seatMap.get(key);
if (seat) {
// 已有座位,执行删除
seats.value = seats.value.filter(s => s.x !== x || s.y !== y);
seatMap.delete(key);
} else {
// 无座位,添加新座位
const newSeat = {
x,
y,
seatNo: 0, // 临时编号,后续会重新排序
seatId: ''
};
seats.value.push(newSeat);
seatMap.set(key, newSeat);
}
};
在前端渲染时,我们可以根据seatMap判断某个坐标是否有座位:
html复制<div
v-for="x in rows"
:key="x"
class="seat-row"
>
<div
v-for="y in cols"
:key="y"
class="seat"
:class="{ active: seatMap.get(`${x},${y}`) }"
@click="handleSeatClick(x, y)"
></div>
</div>
拖拽功能需要处理三个关键事件:
dragstart - 记录拖拽起始位置dragover - 允许放置drop - 处理放置逻辑首先给座位元素添加拖拽属性:
html复制<div
draggable
@dragstart="onDragStart($event, x, y)"
@dragover.prevent
@drop="onDrop($event, x, y)"
></div>
然后实现事件处理函数:
typescript复制const dragStartSeat = ref<{x: number; y: number}>();
const onDragStart = (event: DragEvent, x: number, y: number) => {
dragStartSeat.value = { x, y };
};
const onDrop = (event: DragEvent, x: number, y: number) => {
if (!dragStartSeat.value) return;
const startSeat = seatMap.get(`${dragStartSeat.value.x},${dragStartSeat.value.y}`);
const endSeat = seatMap.get(`${x},${y}`);
if (startSeat && endSeat) {
// 交换座位编号
const temp = startSeat.seatNo;
startSeat.seatNo = endSeat.seatNo;
endSeat.seatNo = temp;
}
};
横向和纵向布局的区别在于座位编号的顺序。我们可以通过排序来实现:
typescript复制const isHorizontal = ref(false);
const updateLayout = () => {
if (isHorizontal.value) {
// 横向布局:先排y轴,再排x轴
seats.value.sort((a, b) => {
if (a.y !== b.y) return a.y - b.y;
return a.x - b.x;
});
} else {
// 纵向布局:先排x轴,再排y轴
seats.value.sort((a, b) => {
if (a.x !== b.x) return a.x - b.x;
return a.y - b.y;
});
}
// 重新编号
seats.value.forEach((seat, index) => {
seat.seatNo = index + 1;
});
};
为了让组件更通用,我设计了这些props:
typescript复制interface Props {
rows: number; // 行数
cols: number; // 列数
seats: SeatModel[]; // 初始座位数据
editable?: boolean; // 是否可编辑
draggable?: boolean; // 是否可拖拽
layout?: 'horizontal' | 'vertical'; // 布局方向
}
组件需要抛出这些事件以便父组件处理:
typescript复制interface Emits {
(e: 'update:seats', seats: SeatModel[]): void;
(e: 'seat-click', seat: SeatModel): void;
(e: 'seat-swap', payload: { from: SeatModel; to: SeatModel }): void;
}
在电影选座场景中的使用方式:
html复制<SeatMatrix
:rows="10"
:cols="15"
:seats="seats"
editable
draggable
layout="horizontal"
@update:seats="handleSeatsUpdate"
/>
在考场排座场景中的使用方式:
html复制<SeatMatrix
:rows="8"
:cols="8"
:seats="seats"
:editable="isAdmin"
layout="vertical"
@seat-click="handleSeatSelect"
/>
在实际使用中,我发现当矩阵较大时(如20×20),渲染性能会下降。经过测试,这些优化措施效果明显:
实现虚拟滚动的核心代码:
html复制<div class="seat-container" @scroll="handleScroll">
<div
class="seat-viewport"
:style="{ height: `${totalRows * seatHeight}px` }"
>
<div
v-for="row in visibleRows"
:key="row"
class="seat-row"
:style="{ top: `${row * seatHeight}px` }"
>
<!-- 座位渲染 -->
</div>
</div>
</div>
为了让组件更纯粹,应该将业务逻辑移到组件外部。比如:
这些都可以通过插槽(slot)来实现:
html复制<template #seat="{ seat }">
<div
class="custom-seat"
:class="getSeatClass(seat)"
>
{{ getSeatLabel(seat) }}
</div>
</template>
父组件可以完全控制座位的渲染方式和交互逻辑,而矩阵组件只负责维护座位的位置关系。
在项目实战中,我遇到了几个典型问题:
坐标系统混淆:有开发者误将CSS的grid布局坐标与业务坐标混用。解决方案是始终保持业务坐标(x,y)独立于UI实现。
拖拽体验不佳:原生拖拽API在移动端支持不好。后来我换用了拖拽库(dragula)来获得更好的体验。
大数据量性能:当座位数量超过500个时,响应式更新变慢。最终方案是使用非响应式数据配合手动更新。
后端数据同步:座位ID由后端生成,前端需要维护临时ID到正式ID的映射。我设计了一个ID映射表来解决这个问题。
这套矩阵布局组件还可以进一步扩展:
比如实现动态障碍物只需要在座位模型中增加一个字段:
typescript复制interface SeatModel {
// ...其他字段
isFixed?: boolean; // 是否固定不可修改
}
然后在交互逻辑中检查这个标记:
typescript复制const handleSeatClick = (x: number, y: number) => {
const seat = seatMap.get(`${x},${y}`);
if (seat?.isFixed) return;
// ...原有逻辑
};
这个组件从最初的考场排座需求出发,经过不断抽象和优化,现在已经应用在了公司的三个不同业务场景中。最大的体会是:好的组件设计应该在满足当前需求的同时,为未来的扩展留出空间。