chenxiaochun / blog

🖋️ChenXiaoChun's blog
179 stars 15 forks source link

买火车票选择座位功能实现 #33

Open chenxiaochun opened 6 years ago

chenxiaochun commented 6 years ago

需求描述

最近开发京东旅行的一个买票选择座位功能,当你订票时选择的是送票上门,可以在线选择座位位置,因为每个订单最多只能预订5位乘客(包括儿童)的票,因此每种座位类型的选择逻辑如下:

一排显示三个座位,乘客人数大于3时,需要显示两排座位

一排显示五个座位,可以任意选择

只允许乘客选择靠窗位置的座位数

只允许乘客选择下铺的数目,并且乘客数大于等于2时,不能保证会在同一包厢(不是车厢)

这几种座位之间的共同点是:

  1. 对于高铁票,点击可以选择一个座位,再次点击就是取消已选择的座位
  2. 选择座位时,如果已选择的座位数超过了乘客数,则最早选择的那个座位会被自动取消掉,依次类推
  3. 删除乘客时,也是优先取消最早选择的座位
  4. 当座位显示了两排,然后删除乘客时,如果一排的座位数可以满足当前的乘客数,则自动取消掉最早选择的那一排座位
  5. 卧铺的逻辑很简单,只要已选择的铺位数不会大于乘客数即可

功能实现

需求已经写清楚了,我先模拟一个高铁在线选座的界面出来:

座位类型:
<select id="ticketType">
    <option value="P">商务座/一等座</option>
    <option value="M">二等座</option>
    <option value="1">硬座</option>
    <option value="2">硬卧/软卧</option>
</select>

乘客数:
<input type="number" step=0 min=0 max=5 step=1 value=0 id="passenger">

选择数:
<span id="ticketInfo"></span>

<div class="seat-box"></div>
#passenger{
    width: 80px;
}
.seat-row{
    padding: 10px;
}
.seat{
    display: inline-block;
    width: 42px;
    height: 36px;
    line-height: 33px;
    text-align: center;
    color: #666;
    background-image: url(//img20.360buyimg.com/uba/jfs/t7282/74/1658256725/1195/7e270fe2/599e6afbN59d51b35.png);
    background-repeat: no-repeat;
    margin-left: 10px;
    cursor: pointer;
}
.seat-chosen{
    background-image: url(//img30.360buyimg.com/uba/jfs/t7588/281/1633648098/1268/b1d85178/599e6bbcNfb1b597a.png)
}

image

初始化代码结构

我首先创建了一个类ChooseSeatinit方法用来担任路由的角色,根据传入的seatType调用相应的功能方法,将计算完之后的座位模版插入到页面中;bind方法用来给页面中的所有的dom操作绑定事件;setTicketInfo方法用来更新页面中展示的已选座位数与乘客数。

class ChooseSeat{
    constructor(seatType, totalNum){
        this.seatType = seatType //座位类型
        this.totalNum = totalNum //乘客人数
        this.seatBox = document.querySelector('.seat-box')

        this.initSeat()
        this.bind()
        this.setTicketInfo()
    }

    initSeat(){        
        var tpl = ''
        switch(this.seatType){
            case 'P':
                tpl = this.shangWuZuo()
                break
            case 'M':
                tpl = this.erDengZuo()
                break
            case '1':
                tpl = this.yingZuo()
                break
            case '2':
                tpl = this.woPu()
                break
        }

        this.seatBox.innerHTML = tpl
    }

    bind(){

    }

    setTicketInfo(){

    }

    shangWuZuo(){

    }

    erDengZuo(){

    }

    yingZuo(){

    }

    woPu(){

    }
}

new ChooseSeat('M', 0)

从共同点入手

通过需求描述可以发现,不管你预订的是高铁几等座,共同点都是:点击座位预订,再次点击就是取消。所以,我决定先从这个点入手。

class ChooseSeat{
    constructor(seatType, totalNum){
        this.hasChosen = []
        this.seatBox = document.querySelector('.seat-box')
    }

    bind(){
        var self = this
        self.seatBox.onclick = (event) => {
            var target = event.target

            if(target.classList.contains('seat')){
                let dataSeat = target.getAttribute('data-seat')
                //如果当前选择的座位不在数组中,而且乘客数(非零)小于等于已选择座位数,则将最早选择的座位删掉,然后再把当前选择的座位push到数组中
                if(!self.hasChosen.includes(dataSeat)){
                    if(self.totalNum <= self.hasChosen.length){
                        self.hasChosen.splice(0, 1)
                    }

                    if(self.totalNum != 0){
                        self.hasChosen.push(dataSeat)
                    }
                }
                //如果当前选择的座位存在于数组中,则找出来,将其删掉
                else{
                    let index = self.hasChosen.findIndex(value => {
                        return value == dataSeat
                    })
                    self.hasChosen.splice(index, 1)
                }
                self.initSeat()
                self.setTicketInfo()
            }
        }
    }
}

new ChooseSeat('M', 0)

后选择的座位会顶替掉最早选择的座位;删除时,也是优先删除最早选择的座位。这明显是一个先进先出的队列操作,聪明的你一定想到了可以用数组来模拟这个数据结构。所以我在constructor中定义一个hasChosen数组,用来存放当前所有已经选择的座位。

利用事件委托在seat-box上监听onclick事件,保证每次座位类型改变之后,座位依然可以进行点击。每次触发click事件,我都使用classList对象的contains方法来确定点击的是否为seat,再进行下一步处理。

选择一个座位无非两种情况:

  1. 如果当前选择的座位不在hasChosen中,而且乘客数(非零)小于等于已选择座位数,则将最早选择的座位删掉,然后再把当前选择的座位push到数组中。

  2. 如果当前选择的座位存在于hasChosen中,使用findIndex方法定位其索引,再使用splice方法将其删除。

贯穿整个代码结构的思想逻辑是:每次根据用户的选择情况,处理完hasChosen中的数据之后,再去重新调用initSeat方法渲染页面上的模版。以达到数据处理与dom操作分离的目的。

高铁二等座位

第二个比较容易实现的功能是选择高铁二等座位,因为它只有一排,模版每次只需要根据hasChosen中的数据进行渲染即可。

row数组代表座位编号,每次erDengZuo方法被调用时,都会遍历此数组,判断每一个座位编号是否存在于hasChosen中。如果存在,就给其加上seat-chosen样式,否则,就按默认样式渲染。

class ChooseSeat{
    constructor(seatType, totalNum){
        this.hasChosen = []
        this.seatBox = document.querySelector('.seat-box')
    }

    bind(){
        self.seatBox.onclick = (event) => {
            var target = event.target

            if(target.classList.contains('seat')){
                let dataSeat = target.getAttribute('data-seat')
                //如果当前选择的座位不在数组中,而且乘客数(非零)小于等于已选择座位数,则将最早选择的座位删掉,然后再把当前选择的座位push到数组中
                if(!self.hasChosen.includes(dataSeat)){
                    if(self.totalNum <= self.hasChosen.length){
                        self.hasChosen.splice(0, 1)
                    }

                    if(self.totalNum != 0){
                        self.hasChosen.push(dataSeat)
                    }
                }
                //如果当前选择的座位存在于数组中,则找出来,将其删掉
                else{
                    let index = self.hasChosen.findIndex(value => {
                        return value == dataSeat
                    })
                    self.hasChosen.splice(index, 1)
                }
                self.initSeat()
                self.setTicketInfo()
            }
        }
    }

    erDengZuo(){
        var tpl = ''
        var row = ['A', 'B', 'C', 'D', 'E']

        for(let r of row){
            if(this.hasChosen.includes(r)){
                tpl += `<span class="seat seat-chosen" data-seat="${r}">${r}</span>`
            }else{
                tpl += `<span class="seat" data-seat="${r}">${r}</span>`
            }
        }
        return `<div class="seat-row">${tpl}</div>`
    }
}

new ChooseSeat('M', 0)

9 -08-2017 10-57-59

高铁商务座位/一等座位

SWRow1表示商务座位/一等座位的第一排,SWRow2表示商务座位/一等座位的第二排。ticketType表示选择的座位类型,passenger表示乘客数目。

ticketType的切换说明用户改变了座位的类型,需要重新订票选择,所以我需要把已经选择的座位清空、重新初始化座位模版、更新显示的乘客与已选择座位数目:

self.hasChosen = []
self.initSeat()
self.setTicketInfo()

passenger的切换说明用户改变了需要订票的乘客数。那首先应该处理的是,如果当前乘客数小于已选择座位数的时候,需要将最早选择的座位删掉。

这里需要解释一下为什么是“小于”,比如场景如下:

接下来处理棘手的显示两排座位的场景,这又分两种情况:

我定义了一个totalNum变量用来存放上一次的乘客数,this.value存放的是当前乘客数,所以当totalNum=4,并且this.value=3的时候,用户肯定是减少了乘客数目。(ps:4和3是乘客数目的一个临界点,只有这个时候才会有一排和两排切换的问题)。

取出hasChosen[0]的值,也就是最早选择的那个座位和C进行比较,小于等于C的肯定是A, B, C,否则就是D, E, F。这样就能知道是需要删除哪一排了,这也是为什么上面会把两排座位分开存放。

class ChooseSeat{
    constructor(seatType, totalNum){
        this.seatType = seatType
        this.totalNum = totalNum
        this.hasChosen = []
        this.SWRow1 = ['A', 'B', 'C'] //商务座位/一等座位的第一排
        this.SWRow2 = ['D', 'E', 'F'] //商务座位/一等座位的第二排
        this.seatBox = document.querySelector('.seat-box')
        this.ticketInfo = document.getElementById('ticketInfo')

        this.initSeat()
        this.bind()
        this.setTickeInfo()
    }

    initSeat(){        
        ...
    }

    bind(){
        var self = this
        var seats = document.querySelectorAll('.seat')
        var ticketType = document.getElementById('ticketType')
        var passenger = document.getElementById('passenger')

        ticketType.onchange = function(){
            self.seatType = this.value
            self.hasChosen = []
            self.initSeat()
            self.setTickeInfo()
        }

        passenger.onchange = function(){
            var seatEl = ''
            //当前乘客数小于已选择座位数的时候,将最早选择的座位删掉
            if(this.value < self.hasChosen.length){
                seatEl = self.hasChosen.splice(0, 1)
            }

            /**
            * 因为只有商务座/一等座可能会显示两排座位,所以需要特殊处理
            * 当前乘客数小于等于3时,并且座位显示了两排时,必须删除一排
            */            
            if(this.value == 3 && self.totalNum == 4 && self.seatType == 'P'){
                if(!seatEl){
                    seatEl = self.hasChosen[0]
                }
                if(seatEl <= 'C'){
                    removeSeat(self.SWRow1)
                }else{
                    removeSeat(self.SWRow2)
                }
            }

            function removeSeat(seats){
                for(let s of seats){
                    let index = self.hasChosen.findIndex(value => {
                        return value == s
                    })
                    if(index != -1){
                        self.hasChosen.splice(index, 1)
                    }
                }
            }

            self.totalNum = this.value
            self.initSeat()
            self.setTicketInfo()

        }
    }

    setTicketInfo(){
        this.ticketInfo.innerHTML = `${this.hasChosen.length}/${this.totalNum}`
    }
}

new ChooseSeat('P', 0)

处理完数据,接下来就是使用数据来渲染模版了。

  1. hasChosen.length = 0,也就是用户没有选择座位的时候。如果乘客数目小于等于3,直接显示第一排;否则,就是把两排都显示出来。
  2. hasChosen.length != 0,也就是用户选择了若干座位的时候。如果乘客数目小于等3,那肯定就是显示一排座位,但是,显示哪一排取决于hasChosen[0]取出来的值属于哪一排。否则,也是直接将两排都显示出来。
class ChooseSeat{
    shangWuZuo(){
        var self = this
        var tpl = ''

        function seatGen(row){
            var seats = ''
            for(let r of row){
                if(self.hasChosen.includes(r)){
                    seats += `<span class="seat seat-chosen" data-seat="${r}">${r}</span>`
                }else{
                    seats += `<span class="seat" data-seat="${r}">${r}</span>`
                }
            }

            return `<div class="seat-row">${seats}</div>`
        }

        if(this.hasChosen.length == 0){
            if(this.totalNum <= 3){
                tpl = seatGen(this.SWRow1)
            }
            else{
                tpl = seatGen(this.SWRow1)
                tpl += seatGen(this.SWRow2)
            }
        }
        else{
            if(this.totalNum <= 3){
                let seatEl = this.hasChosen[0]
                if(this.SWRow1.includes(seatEl)){
                    tpl = seatGen(this.SWRow1)
                }
                else{
                    tpl = seatGen(this.SWRow2)
                }
            }
            else{
                tpl = seatGen(this.SWRow1)
                tpl += seatGen(this.SWRow2)
            }
        }
        return tpl
    }
}

new ChooseSeat('P', 0)

9 -08-2017 14-17-52

硬座/卧铺

对于这两种座位类型,只能是选择一个数量:上限是不能大于乘客数目,也不能大于5;下限是不能小于0即可。所以,它的操作界面是这样的:

2017-09-08 9 39 59

class ChooseSeat{
    constructor(seatType, totalNum){
        ...
    }

    initSeat(){        
        ...
    }

    bind(){
        var self = this
        var seats = document.querySelectorAll('.seat')
        var ticketType = document.getElementById('ticketType')
        var passenger = document.getElementById('passenger')

        self.seatBox.onclick = (event) => {
            var target = event.target

            ...

            //处理硬座、硬卧、软卧
            if(this.seatType == '1' || this.seatType == '2'){
                let ticketNum = document.getElementById('ticketNum')
                let n = parseInt(ticketNum.value)

                if(target.classList.contains('increase')){
                    if(n < 5 && n < self.totalNum){
                        ticketNum.value = ++n
                    }
                }
                if(target.classList.contains('reduce')){
                    if(n > 0){
                        ticketNum.value = --n
                    }
                }
            }
        }
        ...
    }

    yingZuo(){
        var tpl = `
            <button class="reduce">-</button> <input id="ticketNum" type="number" value=0 min=0 max=5 step=1 /> <button class="increase">+</button>
        `
        return tpl
    }
}

new ChooseSeat('1', 0)

总结

好了,终于写完了。这几种座位类型的选择功能,最容易实现的是硬座/卧铺,其次是高铁二等座,最痛苦的就是高铁商务座/一等座。因为它涉及到了两排座位的各种增删,曾经思考这个逻辑感觉都要精神分裂了。

不过,任何复杂的逻辑都是由多个简单的小逻辑组成。所以,学会能够清晰的把各个小逻辑拆分出来、实现它,并组合到一起才是最重要的。

完整示例

https://codepen.io/sjzcxc/pen/MvXNrG