可视化电影选座--升级版

357 阅读5分钟

一、简介

之前做过一版简易版的微信小程序的电影选座小组件,通过很朴素的方式实现选座场景,但是现在回顾一下,那时候的代码很稚嫩啊,使用起来并不是那么简便。最近闲来无事,于是我想重新通过vue3编写一个更加好用的组件版本,代码为本人理解,不喜勿喷。源码在此

good.gif

二、开始行动

要想组件写得好,封装组件少不了,想要直接把座位配置出来,我们先封装一个基础组件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。在用户选择时,用户点击隐藏区域是应该是没有效果的,所以目前我们需要满足的功能有:

  1. 最大个数校验;
  2. 隐藏区域不选择;
  3. 重复点击可取消选择;
  4. 已选择的回显。

我们先来编写选择的点击函数:

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,通过这个参数来判断需不需要校验座位的连续。要保持座位连续的话我们需要在座位信息变化的时候进行校验,那什么时候座位会变化呢,有以下几种情况:

  1. 用户选择座位时;
  2. 用户再次点击座位进行取消时;
  3. 用户点击删除已选择座位。

首先,我们来判断座位是否连续,编写areSeatsContinuous函数:

  1. 当座位数为1时,直接判定为连续;
  2. 这里通过标记的方式,将相邻座位(不包括斜相邻)添加标记,通过判断被标记个数知道用户选择座位是否连续。 代码如下:
// 判断座位是否连续
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)
}

我们已经实现了座位的选择和回显,以及额外的选择判断逻辑。打完收工!简单调整一下外层组件,我们来看看目前的效果:

image.png 嗯~!很完美,定睛一看,不对!第五排第二个不应该是五排二座吗,怎么变成四座了,原来把隐藏的座位也算上了,所以我们现在得到的座位并不是真正的位次。

image.png

真正的座位

看来革命尚未成功,同志还需努力。当我们隐藏了部分区域时,应该去掉横排前隐藏的座位的位次,那我们应该怎么做呢。

目前看来,最好的办法就是直接判断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
})

三、最后

再来看看效果:

可以看到现在的座位是实际的位次啦。源码在此

至此,完结撒花!有什么更好的建议,在评论区讨论吧!

感谢各位观众老爷的阅读!