一、简介
之前做过一版简易版的微信小程序的电影选座小组件,通过很朴素的方式实现选座场景,但是现在回顾一下,那时候的代码很稚嫩啊,使用起来并不是那么简便。最近闲来无事,于是我想重新通过vue3
编写一个更加好用的组件版本,代码为本人理解,不喜勿喷。源码在此
二、开始行动
要想组件写得好,封装组件少不了,想要直接把座位配置出来,我们先封装一个基础组件SeatBase.vue
,通过传参的方式显示座位。行row
和列col
,通过两个循环,把座位遍历一下。
基础显示
// seatBase.vue
<template>
<div class="seat-base">
<div class="seat-area">
<div class="y-row" v-for="(, y) in row">
<div class="x-col" v-for="(, x) in col">
<div class="'seat-item'">
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
const props = defineProps({
seatConfig: {
type: Object,
default: () => {}
}
})
const { row, col } = props.seatConfig
</script>
然后添加亿点点样式,就可以展示一下10x10的座位表了:
座位坐标
为了更好地了解座位点的信息,添加一个配置showIndex
来显示座位的点坐标,以及isShowRowNumber
来判断是否显示座位的排数。
例如:
// seatSelection.vue
const seatConfig = ref({
row: 10,
col: 10,
hideArea: {
1: [[0, 4], [1, 9]],
2: [[8, 4], [9, 9]],
},
showIndex: true,
isShowRowNumber: true
})
// seatBase.vue
<div class="seat-base">
<div class="seat-area">
<div class="y-row" v-for="(, y) in row">
<div class="x-col" v-for="(, x) in col">
<div class="seat-item">
{{ isShowIndex && `${x},${y}` }}
</div>
<span
v-if="isShowRowNumber && col == x + 1"
style="margin-left: 20px;">
第{{ y + 1 }}排
</span>
</div>
</div>
</div>
</div>
效果图:
座位不同图形显示
为了适应不同的应用场景,还要支持隐藏部分座位区域,隐藏后可以变成不同的形状,通过传参hideArea
来表示隐藏的座位区域,通过左上角和右下角的两个点来表示。以下传参的意思是生成一个10x10
格式的座位,从(0,4)
到(1,9)
区域、(8,4)
到(9,9)
区域隐藏,给标签添加一个动态类"['seat-item', inCertainArea({x, y}, hideArea) && 'hide-item']
。
{
row: 10,
col: 10,
hideArea: {
1: [[0, 4], [1, 9]],
2: [[8, 4], [9, 9]],
}
}
接下来就是实现这个inCertainArea
函数来判断座位的坐标点是不是在指定范围内。
// 判断座位是否在指定区域
function inCertainArea(seat, areaList) {
const { x, y } = seat
for(let key of Object.keys(areaList)) {
const startPoint = areaList[key][0]
const endPoint = areaList[key][1]
if (x >= startPoint[0] && x <= endPoint[0] &&
y >= startPoint[1] && y <= endPoint[1]) {
return true
}
}
return false
}
然后再添加一点样式:
推荐位置高亮
有一些影院有推荐的区域,推荐区域需要不同的样式,同理,我们也是通过隐藏类似的逻辑来判断是否在指定区域,再添加一点点样式。
// seatSelection.vue
const seatConfig = ref({
row: 10,
col: 10,
hideArea: {
1: [[0, 4], [1, 9]],
2: [[8, 4], [9, 9]],
},
highlightArea: {
1: [[3, 4], [6, 6]],
},
isShowIndex: true
})
// seatBase.vue
<template>
<div class="seat-base">
<div class="seat-area">
<div class="y-row" v-for="(, y) in row">
<div class="x-col" v-for="(, x) in col">
<div :class="[
'seat-item',
inCertainArea({x, y}, highlightArea) && 'highlight-item',
inCertainArea({x, y}, hideArea) && 'hide-item',
]">
{{ isShowIndex && `${x},${y}` }}
</div>
<span
v-if="isShowRowNumber && col == x + 1"
style="margin-left: 20px;">
第{{ y + 1 }}排
</span>
</div>
</div>
</div>
</div>
</template>
用户选择
一般的影院都会限制用户的最大选择个数,所以我们添加配置maxNum
。在用户选择时,用户点击隐藏区域是应该是没有效果的,所以目前我们需要满足的功能有:
- 最大个数校验;
- 隐藏区域不选择;
- 重复点击可取消选择;
- 已选择的回显。
我们先来编写选择的点击函数:
function handleSelect(x, y) {
// 隐藏区域不能选择
if(inCertainArea({x, y}, hideArea)) {
return
}
// 是否已选择,如选择,则取消选择
if(isSelected(x, y)) {
const index = props.selectedList.findIndex(seat => seat.x == x && seat.y == y)
index !== -1 && props.selectedList.splice(index, 1)
return
}
// 是否超出选择
if(props.selectedList.length >= maxNum) {
ElMessage.error(`每人最多选择${maxNum}个座位`)
return
}
props.selectedList.push({ x, y })
}
座位选中回显,再添加一个选中的类名,添加一点选中样式:
<template>
<div class="seat-base">
<div class="seat-area">
<div class="y-row" v-for="(, y) in row">
<div class="x-col" v-for="(, x) in col">
<div :class="[
'seat-item',
inCertainArea({x, y}, highlightArea) && 'highlight-item',
inCertainArea({x, y}, hideArea) && 'hide-item',
isSelected(x, y) && 'selected-item'
]"
@click="handleSelect(x, y)"
>
{{ isShowIndex && `${x},${y}` }}
</div>
<span
v-if="isShowRowNumber && col == x + 1"
style="margin-left: 20px;">
第{{ y + 1 }}排
</span>
</div>
</div>
</div>
</div>
</template>
<script>
// 判断是否被选中
function isSelected(x, y) {
return props.selectedList?.findIndex(point => point.x === x && point.y === y) != -1
}
</script>
<style>
.hide-item {
border: none !important;
cursor: default;
}
.highlight-item {
border: 1px solid #D29A3E;
}
.selected-item {
border: 1px solid #0CC88F;
background-color: #0CC88F;
}
</style>
至此,座位的基本功能已经实现了。
座位连续
一般电影院还会限制用户选择座位需要连续,不连续的话会给提示,所以我们再添加一个字段isContinuation
,通过这个参数来判断需不需要校验座位的连续。要保持座位连续的话我们需要在座位信息变化的时候进行校验,那什么时候座位会变化呢,有以下几种情况:
- 用户选择座位时;
- 用户再次点击座位进行取消时;
- 用户点击删除已选择座位。
首先,我们来判断座位是否连续,编写areSeatsContinuous
函数:
- 当座位数为1时,直接判定为连续;
- 这里通过标记的方式,将相邻座位(不包括斜相邻)添加标记,通过判断被标记个数知道用户选择座位是否连续。 代码如下:
// 判断座位是否连续
function areSeatsContinuous(seats) {
seats = cloneDeep(seats)
// 座位数为1直接返回
if(seats.length <= 1) {
return true
}
// 对第一个进行标记
seats[0].flag = true
markSeat(seats, 0)
return seats.every(seat => seat?.flag)
}
// 对连续的座位进行标记
function markSeat(seats, cur) {
const init = seats[cur]
for(let i = 1; i < seats.length; i++) {
if(!seats[i].flag) {
if(isAdjacent(seats[i], init)) {
seats[i].flag = true
markSeat(seats, i)
}
}
}
return true
}
// 是否相邻
function isAdjacent(seat1, seat2) {
if(Math.abs(seat1.x - seat2.x) + Math.abs(seat1.y - seat2.y) == 1) {
return true
}
return false
}
在用户选择触发的方法中添加座位是否连续的判断逻辑:
function handleSelect(x, y) {
if(inCertainArea({x, y}, hideArea)) {
return
}
// 是否已选择,如选择,则取消选择
if(isSelected(x, y)) {
// 取消时保持座位连续
if(isContinuation && !areSeatsContinuous(props.selectedList.filter(seat => !(seat.x == x && seat.y == y)))) {
ElMessage.error('请保持座位连续')
return
}
const index = props.selectedList.findIndex(seat => seat.x == x && seat.y == y)
index !== -1 && props.selectedList.splice(index, 1)
return
}
// 是否超出选择
if(props.selectedList.length >= maxNum) {
ElMessage.error(`每人最多选择${maxNum}个座位`)
return
}
props.selectedList.push({ x, y })
// 判断用户选择座位是否连续
if(isContinuation && !areSeatsContinuous(props.selectedList)) {
props.selectedList.pop()
ElMessage.error('请保持座位连续')
}
}
为了让外层组件可以在增减后自行判断座位是否连续,和是否在指定区域,将相关函数抛出。
defineExpose({
areSeatsContinuous,
inCertainArea
})
组件自定义
1、样式自定义。有时候,座位与座位之间并不是紧密相连的,可能有个走廊啥的,所有我们再添加一个配置,让外层组件添加行样式。
// seatBase.vue
<div class="y-row" v-for="(, y) in row" :style="{...rowStyle?.[y + 1]}">
// seatSelection.vue
rowStyle: {
2: {
marginBottom: '40px',
}
}
2、判断逻辑自定义。为了让外层组件在用户选择时的判断逻辑更加灵活,需要支持外层组件自行判断用户的选择逻辑,例如:用户必须选择第一排第一座,只需要添加判断函数。
const seatConfig = ref({
row: 10,
col: 10,
hideArea: {
1: [[0, 4], [1, 9]],
2: [[8, 4], [9, 9]],
},
highlightArea: {
1: [[3, 4], [6, 6]],
},
isShowIndex: true,
maxNum: 6,
isContinuation: true,
customRule() {
if(selectedList.value[0].x !== 0 || selectedList.value[0].y !== 0) {
ElMessage.error('请选择第一排第一座')
selectedList.value.pop()
}
}
})
// seatBase.vue
function handleSelect(x, y) {
// 其他逻辑
customRule?.(props.selectedList)
}
我们已经实现了座位的选择和回显,以及额外的选择判断逻辑。打完收工!简单调整一下外层组件,我们来看看目前的效果:
嗯~!很完美,定睛一看,不对!第五排第二个不应该是五排二座吗,怎么变成四座了,原来把隐藏的座位也算上了,所以我们现在得到的座位并不是真正的位次。
真正的座位
看来革命尚未成功,同志还需努力。当我们隐藏了部分区域时,应该去掉横排前隐藏的座位的位次,那我们应该怎么做呢。
目前看来,最好的办法就是直接判断DOM元素的类名了,说干就干。先给最外层添加一个ref
。
<div class="seat-area" ref="seatAreaRef">
获取所有行,再通过传参的y
值,得到当前座位的所在行,然后通过座位的x
值,获取当前的座位节点,拿到该节点后,将节点的前面所有兄弟节点放到数组内,筛选掉包含hide-item
的类名的节点,最后返回真实的位次!
// 获取真实的座位信息
const seatAreaRef = ref(null)
function getRealSeat(seat) {
// 获取所有行
const rows = seatAreaRef.value.querySelectorAll('.y-row')
const row = rows[seat.y]
// 当前座位node节点
let seatEle = row.querySelectorAll('.x-col')?.[seat.x] || null
let previousElements = []
// 获取所有前面的兄弟节点
while(true) {
seatEle = seatEle.previousElementSibling;
if (seatEle) previousElements.push(seatEle);
else break
}
previousElements = previousElements.filter(item => !item.childNodes[0].className?.includes('hide-item'))
return { x: previousElements.length, y: seat.y }
}
// 将函数抛出
defineExpose({
areSeatsContinuous,
inCertainArea,
getRealSeat
})
三、最后
再来看看效果:
可以看到现在的座位是实际的位次啦。源码在此
至此,完结撒花!有什么更好的建议,在评论区讨论吧!
感谢各位观众老爷的阅读!