Bootstrap

(完结)Java项目实战笔记--基于SpringBoot3.0开发仿12306高并发售票系统--(三)项目优化

本文参考自

Springboot3+微服务实战12306高性能售票系统 - 慕课网 (imooc.com)

本文是仿12306项目实战第(三)章——项目优化,本篇将讲解该项目最后的优化部分以及一些压测知识点

一、压力测试-高并发优化前后的性能对比

1.压力测试相关概念讲解

在这里插入图片描述

我们项目测试的就是下单购票这一个接口,所以tps=qps,然后tps和吞吐量又是一个意思,所以目前三者相等

2.JMeter压测

  • 先将令牌数设置充足

    异步处理后的代码,测试下单购票接口的吞吐量,其实只是和前半部分有关,而前半部分如果令牌数不够,就直接快速失败了,所以防止这种情况导致测试结果不准确,我们直接把令牌数调大。

在这里插入图片描述

  • 开始压测

    这里我们设置500线程永远循环,通过聚合报告看结果

在这里插入图片描述

可以看到结果是900多

在这里插入图片描述

  • 恢复代码到初版

在这里插入图片描述

测试前将座位调多一些然后生成多一些车票,因为是同步的,整个过程会去查询余票数了,没票会快速失败

在这里插入图片描述

在这里插入图片描述

由于如果还是500个线程的话,出现异常太多了,测试结果可能不太准确,我这里就只设置了50个线程来测试

结果:

可以看到吞吐量明显降低,经过我们上一章节的各种优化后(主要是异步),吞吐量提升了大概25倍多

在这里插入图片描述

3.将mq去除,改用成springboot自带的异步

实际项目中看情况增加中间件,并不是中间件越多越好,像这里我们用springboot的异步,也能达到同样的效果,吞吐量也擦不多

  • 注释掉所有和mq相关的代码、依赖、配置

  • 换成springboot自带的异步

    • BusinessApplication.java

      @EnableAsync
      public class BusinessApplication {
      
    • BeforeConfirmOrderService

      package com.neilxu.train.business.service;
      
      import cn.hutool.core.date.DateTime;
      import com.alibaba.csp.sentinel.annotation.SentinelResource;
      import com.alibaba.csp.sentinel.slots.block.BlockException;
      import com.alibaba.fastjson.JSON;
      import com.neilxu.train.business.domain.ConfirmOrder;
      import com.neilxu.train.business.dto.ConfirmOrderMQDto;
      import com.neilxu.train.business.enums.ConfirmOrderStatusEnum;
      import com.neilxu.train.business.mapper.ConfirmOrderMapper;
      import com.neilxu.train.business.req.ConfirmOrderDoReq;
      import com.neilxu.train.business.req.ConfirmOrderTicketReq;
      import com.neilxu.train.common.context.LoginMemberContext;
      import com.neilxu.train.common.exception.BusinessException;
      import com.neilxu.train.common.exception.BusinessExceptionEnum;
      import com.neilxu.train.common.util.SnowUtil;
      import jakarta.annotation.Resource;
      import org.slf4j.Logger;
      import org.slf4j.LoggerFactory;
      import org.slf4j.MDC;
      import org.springframework.beans.factory.annotation.Autowired;
      import org.springframework.stereotype.Service;
      
      import java.util.Date;
      import java.util.List;
      
      @Service
      public class BeforeConfirmOrderService {
      
          private static final Logger LOG = LoggerFactory.getLogger(BeforeConfirmOrderService.class);
      
          @Resource
          private ConfirmOrderMapper confirmOrderMapper;
      
          @Autowired
          private SkTokenService skTokenService;
      
      //    @Resource
      //    public RocketMQTemplate rocketMQTemplate;
          @Resource
          private ConfirmOrderService confirmOrderService;
      
          @SentinelResource(value = "beforeDoConfirm", blockHandler = "beforeDoConfirmBlock")
          public Long beforeDoConfirm(ConfirmOrderDoReq req) {
              req.setMemberId(LoginMemberContext.getId());
              // 校验令牌余量
              boolean validSkToken = skTokenService.validSkToken(req.getDate(), req.getTrainCode(), LoginMemberContext.getId());
              if (validSkToken) {
                  LOG.info("令牌校验通过");
              } else {
                  LOG.info("令牌校验不通过");
                  throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_SK_TOKEN_FAIL);
              }
      
              Date date = req.getDate();
              String trainCode = req.getTrainCode();
              String start = req.getStart();
              String end = req.getEnd();
              List<ConfirmOrderTicketReq> tickets = req.getTickets();
      
              // 保存确认订单表,状态初始
              DateTime now = DateTime.now();
              ConfirmOrder confirmOrder = new ConfirmOrder();
              confirmOrder.setId(SnowUtil.getSnowflakeNextId());
              confirmOrder.setCreateTime(now);
              confirmOrder.setUpdateTime(now);
              confirmOrder.setMemberId(req.getMemberId());
              confirmOrder.setDate(date);
              confirmOrder.setTrainCode(trainCode);
              confirmOrder.setStart(start);
              confirmOrder.setEnd(end);
              confirmOrder.setDailyTrainTicketId(req.getDailyTrainTicketId());
              confirmOrder.setStatus(ConfirmOrderStatusEnum.INIT.getCode());
              confirmOrder.setTickets(JSON.toJSONString(tickets));
              confirmOrderMapper.insert(confirmOrder);
      
              // 发送MQ排队购票
              ConfirmOrderMQDto confirmOrderMQDto = new ConfirmOrderMQDto();
              confirmOrderMQDto.setDate(req.getDate());
              confirmOrderMQDto.setTrainCode(req.getTrainCode());
              confirmOrderMQDto.setLogId(MDC.get("LOG_ID"));
              String reqJson = JSON.toJSONString(confirmOrderMQDto);
      //        LOG.info("排队购票,发送mq开始,消息:{}", reqJson);
      //        rocketMQTemplate.convertAndSend(RocketMQTopicEnum.CONFIRM_ORDER.getCode(), reqJson);
      //        LOG.info("排队购票,发送mq结束");
      
              confirmOrderService.doConfirm(confirmOrderMQDto);
      
              return confirmOrder.getId();
      
          }
      
          /**
           * 降级方法,需包含限流方法的所有参数和BlockException参数
           * @param req
           * @param e
           */
          public void beforeDoConfirmBlock(ConfirmOrderDoReq req, BlockException e) {
              LOG.info("购票请求被限流:{}", req);
              throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_FLOW_EXCEPTION);
          }
      }
      
    • ConfirmOrderService.java

      @Async
      @SentinelResource(value = "doConfirm", blockHandler = "doConfirmBlock")
      public void doConfirm(ConfirmOrderMQDto dto) {
          MDC.put("LOG_ID", dto.getLogId());
          LOG.info("异步出票开始:{}", dto);
      
  • 测试吞吐量

    结果和mq的相差不大

在这里插入图片描述

二、项目功能优化

在这里插入图片描述

1.购票页面增加取消排队的功能

逻辑就是主动将订单状态改为 取消

  • ConfirmOrderService.java

    /**
     * 取消排队,只有I状态才能取消排队,所以按状态更新
     * @param id
     */
    public Integer cancel(Long id) {
        ConfirmOrderExample confirmOrderExample = new ConfirmOrderExample();
        ConfirmOrderExample.Criteria criteria = confirmOrderExample.createCriteria();
        criteria.andIdEqualTo(id).andStatusEqualTo(ConfirmOrderStatusEnum.INIT.getCode());
        ConfirmOrder confirmOrder = new ConfirmOrder();
        confirmOrder.setStatus(ConfirmOrderStatusEnum.CANCEL.getCode());
        return confirmOrderMapper.updateByExampleSelective(confirmOrder, confirmOrderExample);
    }
    
  • ConfirmOrderController.java

    @GetMapping("/cancel/{id}")
    public CommonResp<Integer> cancel(@PathVariable Long id) {
        Integer count = confirmOrderService.cancel(id);
        return new CommonResp<>(count);
    }
    
  • order.vue

    <template>
      <div class="order-train">
        <span class="order-train-main">{{dailyTrainTicket.date}}</span>&nbsp;
        <span class="order-train-main">{{dailyTrainTicket.trainCode}}</span>次&nbsp;
        <span class="order-train-main">{{dailyTrainTicket.start}}</span>站
        <span class="order-train-main">({{dailyTrainTicket.startTime}})</span>&nbsp;
        <span class="order-train-main">——</span>&nbsp;
        <span class="order-train-main">{{dailyTrainTicket.end}}</span>站
        <span class="order-train-main">({{dailyTrainTicket.endTime}})</span>&nbsp;
    
        <div class="order-train-ticket">
          <span v-for="item in seatTypes" :key="item.type">
            <span>{{item.desc}}</span>:
            <span class="order-train-ticket-main">{{item.price}}¥</span>&nbsp;
            <span class="order-train-ticket-main">{{item.count}}</span>&nbsp;张票&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
          </span>
        </div>
      </div>
      <a-divider></a-divider>
      <b>勾选要购票的乘客:</b>&nbsp;
      <a-checkbox-group v-model:value="passengerChecks" :options="passengerOptions" />
    
      <div class="order-tickets">
        <a-row class="order-tickets-header" v-if="tickets.length > 0">
          <a-col :span="2">乘客</a-col>
          <a-col :span="6">身份证</a-col>
          <a-col :span="4">票种</a-col>
          <a-col :span="4">座位类型</a-col>
        </a-row>
        <a-row class="order-tickets-row" v-for="ticket in tickets" :key="ticket.passengerId">
          <a-col :span="2">{{ticket.passengerName}}</a-col>
          <a-col :span="6">{{ticket.passengerIdCard}}</a-col>
          <a-col :span="4">
            <a-select v-model:value="ticket.passengerType" style="width: 100%">
              <a-select-option v-for="item in PASSENGER_TYPE_ARRAY" :key="item.code" :value="item.code">
                {{item.desc}}
              </a-select-option>
            </a-select>
          </a-col>
          <a-col :span="4">
            <a-select v-model:value="ticket.seatTypeCode" style="width: 100%">
              <a-select-option v-for="item in seatTypes" :key="item.code" :value="item.code">
                {{item.desc}}
              </a-select-option>
            </a-select>
          </a-col>
        </a-row>
      </div>
      <div v-if="tickets.length > 0">
        <a-button type="primary" size="large" @click="finishCheckPassenger">提交订单</a-button>
      </div>
    
      <a-modal v-model:visible="visible" title="请核对以下信息"
               style="top: 50px; width: 800px"
               ok-text="确认" cancel-text="取消"
               @ok="showFirstImageCodeModal">
        <div class="order-tickets">
          <a-row class="order-tickets-header" v-if="tickets.length > 0">
            <a-col :span="3">乘客</a-col>
            <a-col :span="15">身份证</a-col>
            <a-col :span="3">票种</a-col>
            <a-col :span="3">座位类型</a-col>
          </a-row>
          <a-row class="order-tickets-row" v-for="ticket in tickets" :key="ticket.passengerId">
            <a-col :span="3">{{ticket.passengerName}}</a-col>
            <a-col :span="15">{{ticket.passengerIdCard}}</a-col>
            <a-col :span="3">
              <span v-for="item in PASSENGER_TYPE_ARRAY" :key="item.code">
                <span v-if="item.code === ticket.passengerType">
                  {{item.desc}}
                </span>
              </span>
            </a-col>
            <a-col :span="3">
              <span v-for="item in seatTypes" :key="item.code">
                <span v-if="item.code === ticket.seatTypeCode">
                  {{item.desc}}
                </span>
              </span>
            </a-col>
          </a-row>
          <br/>
          <div v-if="chooseSeatType === 0" style="color: red;">
            您购买的车票不支持选座
            <div>12306规则:只有全部是一等座或全部是二等座才支持选座</div>
            <div>12306规则:余票小于一定数量时,不允许选座(本项目以20为例)</div>
          </div>
          <div v-else style="text-align: center">
            <a-switch class="choose-seat-item" v-for="item in SEAT_COL_ARRAY" :key="item.code"
                      v-model:checked="chooseSeatObj[item.code + '1']" :checked-children="item.desc" :un-checked-children="item.desc" />
            <div v-if="tickets.length > 1">
              <a-switch class="choose-seat-item" v-for="item in SEAT_COL_ARRAY" :key="item.code"
                        v-model:checked="chooseSeatObj[item.code + '2']" :checked-children="item.desc" :un-checked-children="item.desc" />
            </div>
            <div style="color: #999999">提示:您可以选择{{tickets.length}}个座位</div>
          </div>
          <!--<br/>-->
          <!--最终购票:{{tickets}}-->
          <!--最终选座:{{chooseSeatObj}}-->
        </div>
      </a-modal>
    
      <!-- 第二层验证码 后端 -->
      <a-modal v-model:visible="imageCodeModalVisible" :title="null" :footer="null" :closable="false"
               style="top: 50px; width: 400px">
        <p style="text-align: center; font-weight: bold; font-size: 18px">
          使用服务端验证码削弱瞬时高峰<br/>
          防止机器人刷票
        </p>
        <p>
          <a-input v-model:value="imageCode" placeholder="图片验证码">
            <template #suffix>
              <img v-show="!!imageCodeSrc" :src="imageCodeSrc" alt="验证码" v-on:click="loadImageCode()"/>
            </template>
          </a-input>
        </p>
        <a-button type="danger" block @click="handleOk">输入验证码后开始购票</a-button>
      </a-modal>
    
      <!-- 第一层验证码 纯前端 -->
      <a-modal v-model:visible="firstImageCodeModalVisible" :title="null" :footer="null" :closable="false"
               style="top: 50px; width: 400px">
        <p style="text-align: center; font-weight: bold; font-size: 18px">
          使用纯前端验证码削弱瞬时高峰<br/>
          减小后端验证码接口的压力
        </p>
        <p>
          <a-input v-model:value="firstImageCodeTarget" placeholder="验证码">
            <template #suffix>
              {{firstImageCodeSourceA}} + {{firstImageCodeSourceB}}
            </template>
          </a-input>
        </p>
        <a-button type="danger" block @click="validFirstImageCode">提交验证码</a-button>
      </a-modal>
    
      <a-modal v-model:visible="lineModalVisible" title="排队购票" :footer="null" :maskClosable="false" :closable="false"
               style="top: 50px; width: 400px">
        <div class="book-line">
          <div v-show="confirmOrderLineCount < 0">
            <loading-outlined /> 系统正在处理中...
          </div>
          <div v-show="confirmOrderLineCount >= 0">
            <loading-outlined /> 您前面还有{{confirmOrderLineCount}}位用户在购票,排队中,请稍候
          </div>
        </div>
        <br/>
        <a-button type="danger" @click="onCancelOrder">取消购票</a-button>
      </a-modal>
    </template>
    
    <script>
    
    import {defineComponent, ref, onMounted, watch, computed} from 'vue';
    import axios from "axios";
    import {notification} from "ant-design-vue";
    
    export default defineComponent({
      name: "order-view",
      setup() {
        const passengers = ref([]);
        const passengerOptions = ref([]);
        const passengerChecks = ref([]);
        const dailyTrainTicket = SessionStorage.get(SESSION_ORDER) || {};
        console.log("下单的车次信息", dailyTrainTicket);
    
        const SEAT_TYPE = window.SEAT_TYPE;
        console.log(SEAT_TYPE)
        // 本车次提供的座位类型seatTypes,含票价,余票等信息,例:
        // {
        //   type: "YDZ",
        //   code: "1",
        //   desc: "一等座",
        //   count: "100",
        //   price: "50",
        // }
        // 关于SEAT_TYPE[KEY]:当知道某个具体的属性xxx时,可以用obj.xxx,当属性名是个变量时,可以使用obj[xxx]
        const seatTypes = [];
        for (let KEY in SEAT_TYPE) {
          let key = KEY.toLowerCase();
          if (dailyTrainTicket[key] >= 0) {
            seatTypes.push({
              type: KEY,
              code: SEAT_TYPE[KEY]["code"],
              desc: SEAT_TYPE[KEY]["desc"],
              count: dailyTrainTicket[key],
              price: dailyTrainTicket[key + 'Price'],
            })
          }
        }
        console.log("本车次提供的座位:", seatTypes)
        // 购票列表,用于界面展示,并传递到后端接口,用来描述:哪个乘客购买什么座位的票
        // {
        //   passengerId: 123,
        //   passengerType: "1",
        //   passengerName: "张三",
        //   passengerIdCard: "12323132132",
        //   seatTypeCode: "1",
        //   seat: "C1"
        // }
        const tickets = ref([]);
        const PASSENGER_TYPE_ARRAY = window.PASSENGER_TYPE_ARRAY;
        const visible = ref(false);
        const lineModalVisible = ref(false);
        const confirmOrderId = ref();
        const confirmOrderLineCount = ref(-1);
    
        // 勾选或去掉某个乘客时,在购票列表中加上或去掉一张表
        watch(() => passengerChecks.value, (newVal, oldVal)=>{
          console.log("勾选乘客发生变化", newVal, oldVal)
          // 每次有变化时,把购票列表清空,重新构造列表
          tickets.value = [];
          passengerChecks.value.forEach((item) => tickets.value.push({
            passengerId: item.id,
            passengerType: item.type,
            seatTypeCode: seatTypes[0].code,
            passengerName: item.name,
            passengerIdCard: item.idCard
          }))
        }, {immediate: true});
    
        // 0:不支持选座;1:选一等座;2:选二等座
        const chooseSeatType = ref(0);
        // 根据选择的座位类型,计算出对应的列,比如要选的是一等座,就筛选出ACDF,要选的是二等座,就筛选出ABCDF
        const SEAT_COL_ARRAY = computed(() => {
          return window.SEAT_COL_ARRAY.filter(item => item.type === chooseSeatType.value);
        });
        // 选择的座位
        // {
        //   A1: false, C1: true,D1: false, F1: false,
        //   A2: false, C2: false,D2: true, F2: false
        // }
        const chooseSeatObj = ref({});
        watch(() => SEAT_COL_ARRAY.value, () => {
          chooseSeatObj.value = {};
          for (let i = 1; i <= 2; i++) {
            SEAT_COL_ARRAY.value.forEach((item) => {
              chooseSeatObj.value[item.code + i] = false;
            })
          }
          console.log("初始化两排座位,都是未选中:", chooseSeatObj.value);
        }, {immediate: true});
    
        const handleQueryPassenger = () => {
          axios.get("/member/passenger/query-mine").then((response) => {
            let data = response.data;
            if (data.success) {
              passengers.value = data.content;
              passengers.value.forEach((item) => passengerOptions.value.push({
                label: item.name,
                value: item
              }))
            } else {
              notification.error({description: data.message});
            }
          });
        };
    
        const finishCheckPassenger = () => {
          console.log("购票列表:", tickets.value);
    
          if (tickets.value.length > 5) {
            notification.error({description: '最多只能购买5张车票'});
            return;
          }
    
          // 校验余票是否充足,购票列表中的每个座位类型,都去车次座位余票信息中,看余票是否充足
          // 前端校验不一定准,但前端校验可以减轻后端很多压力
          // 注意:这段只是校验,必须copy出seatTypesTemp变量来扣减,用原始的seatTypes去扣减,会影响真实的库存
          let seatTypesTemp = Tool.copy(seatTypes);
          for (let i = 0; i < tickets.value.length; i++) {
            let ticket = tickets.value[i];
            for (let j = 0; j < seatTypesTemp.length; j++) {
              let seatType = seatTypesTemp[j];
              // 同类型座位余票-1,这里扣减的是临时copy出来的库存,不是真正的库存,只是为了校验
              if (ticket.seatTypeCode === seatType.code) {
                seatType.count--;
                if (seatType.count < 0) {
                  notification.error({description: seatType.desc + '余票不足'});
                  return;
                }
              }
            }
          }
          console.log("前端余票校验通过");
    
          // 判断是否支持选座,只有纯一等座和纯二等座支持选座
          // 先筛选出购票列表中的所有座位类型,比如四张表:[1, 1, 2, 2]
          let ticketSeatTypeCodes = [];
          for (let i = 0; i < tickets.value.length; i++) {
            let ticket = tickets.value[i];
            ticketSeatTypeCodes.push(ticket.seatTypeCode);
          }
          // 为购票列表中的所有座位类型去重:[1, 2]
          const ticketSeatTypeCodesSet = Array.from(new Set(ticketSeatTypeCodes));
          console.log("选好的座位类型:", ticketSeatTypeCodesSet);
          if (ticketSeatTypeCodesSet.length !== 1) {
            console.log("选了多种座位,不支持选座");
            chooseSeatType.value = 0;
          } else {
            // ticketSeatTypeCodesSet.length === 1,即只选择了一种座位(不是一个座位,是一种座位)
            if (ticketSeatTypeCodesSet[0] === SEAT_TYPE.YDZ.code) {
              console.log("一等座选座");
              chooseSeatType.value = SEAT_TYPE.YDZ.code;
            } else if (ticketSeatTypeCodesSet[0] === SEAT_TYPE.EDZ.code) {
              console.log("二等座选座");
              chooseSeatType.value = SEAT_TYPE.EDZ.code;
            } else {
              console.log("不是一等座或二等座,不支持选座");
              chooseSeatType.value = 0;
            }
    
            // 余票小于20张时,不允许选座,否则选座成功率不高,影响出票
            if (chooseSeatType.value !== 0) {
              for (let i = 0; i < seatTypes.length; i++) {
                let seatType = seatTypes[i];
                // 找到同类型座位
                if (ticketSeatTypeCodesSet[0] === seatType.code) {
                  // 判断余票,小于20张就不支持选座
                  if (seatType.count < 20) {
                    console.log("余票小于20张就不支持选座")
                    chooseSeatType.value = 0;
                    break;
                  }
                }
              }
            }
          }
    
          // 弹出确认界面
          visible.value = true;
    
        };
    
        const handleOk = () => {
          if (Tool.isEmpty(imageCode.value)) {
            notification.error({description: '验证码不能为空'});
            return;
          }
    
          console.log("选好的座位:", chooseSeatObj.value);
    
          // 设置每张票的座位
          // 先清空购票列表的座位,有可能之前选了并设置座位了,但选座数不对被拦截了,又重新选一遍
          for (let i = 0; i < tickets.value.length; i++) {
            tickets.value[i].seat = null;
          }
          let i = -1;
          // 要么不选座位,要么所选座位应该等于购票数,即i === (tickets.value.length - 1)
          for (let key in chooseSeatObj.value) {
            if (chooseSeatObj.value[key]) {
              i++;
              if (i > tickets.value.length - 1) {
                notification.error({description: '所选座位数大于购票数'});
                return;
              }
              tickets.value[i].seat = key;
            }
          }
          if (i > -1 && i < (tickets.value.length - 1)) {
            notification.error({description: '所选座位数小于购票数'});
            return;
          }
    
          console.log("最终购票:", tickets.value);
    
          axios.post("/business/confirm-order/do", {
            dailyTrainTicketId: dailyTrainTicket.id,
            date: dailyTrainTicket.date,
            trainCode: dailyTrainTicket.trainCode,
            start: dailyTrainTicket.start,
            end: dailyTrainTicket.end,
            tickets: tickets.value,
            imageCodeToken: imageCodeToken.value,
            imageCode: imageCode.value,
          }).then((response) => {
            let data = response.data;
            if (data.success) {
              // notification.success({description: "下单成功!"});
              visible.value = false;
              imageCodeModalVisible.value = false;
              lineModalVisible.value = true;
              confirmOrderId.value = data.content;
              queryLineCount();
            } else {
              notification.error({description: data.message});
            }
          });
        }
    
        /* ------------------- 定时查询订单状态 --------------------- */
        // 确认订单后定时查询
        let queryLineCountInterval;
    
        // 定时查询订单结果/排队数量
        const queryLineCount = () => {
          confirmOrderLineCount.value = -1;
          queryLineCountInterval = setInterval(function () {
            axios.get("/business/confirm-order/query-line-count/" + confirmOrderId.value).then((response) => {
              let data = response.data;
              if (data.success) {
                let result = data.content;
                switch (result) {
                  case -1 :
                    notification.success({description: "购票成功!"});
                    lineModalVisible.value = false;
                    clearInterval(queryLineCountInterval);
                    break;
                  case -2:
                    notification.error({description: "购票失败!"});
                    lineModalVisible.value = false;
                    clearInterval(queryLineCountInterval);
                    break;
                  case -3:
                    notification.error({description: "抱歉,没票了!"});
                    lineModalVisible.value = false;
                    clearInterval(queryLineCountInterval);
                    break;
                  default:
                    confirmOrderLineCount.value = result;
                }
              } else {
                notification.error({description: data.message});
              }
            });
          }, 500);
        };
    
        /* ------------------- 第二层验证码 --------------------- */
        const imageCodeModalVisible = ref();
        const imageCodeToken = ref();
        const imageCodeSrc = ref();
        const imageCode = ref();
        /**
         * 加载图形验证码
         */
        const loadImageCode = () => {
          imageCodeToken.value = Tool.uuid(8);
          imageCodeSrc.value = process.env.VUE_APP_SERVER + '/business/kaptcha/image-code/' + imageCodeToken.value;
        };
    
        const showImageCodeModal = () => {
          loadImageCode();
          imageCodeModalVisible.value = true;
        };
    
        /* ------------------- 第一层验证码 --------------------- */
        const firstImageCodeSourceA = ref();
        const firstImageCodeSourceB = ref();
        const firstImageCodeTarget = ref();
        const firstImageCodeModalVisible = ref();
    
        /**
         * 加载第一层验证码
         */
        const loadFirstImageCode = () => {
          // 获取1~10的数:Math.floor(Math.random()*10 + 1)
          firstImageCodeSourceA.value = Math.floor(Math.random()*10 + 1) + 10;
          firstImageCodeSourceB.value = Math.floor(Math.random()*10 + 1) + 20;
        };
    
        /**
         * 显示第一层验证码弹出框
         */
        const showFirstImageCodeModal = () => {
          loadFirstImageCode();
          firstImageCodeModalVisible.value = true;
        };
    
        /**
         * 校验第一层验证码
         */
        const validFirstImageCode = () => {
          if (parseInt(firstImageCodeTarget.value) === parseInt(firstImageCodeSourceA.value + firstImageCodeSourceB.value)) {
            // 第一层验证通过
            firstImageCodeModalVisible.value = false;
            showImageCodeModal();
          } else {
            notification.error({description: '验证码错误'});
          }
        };
    
        /**
         * 取消排队
         */
        const onCancelOrder = () => {
          axios.get("/business/confirm-order/cancel/" + confirmOrderId.value).then((response) => {
            let data = response.data;
            if (data.success) {
              let result = data.content;
              if (result === 1) {
                notification.success({description: "取消成功!"});
                // 取消成功时,不用再轮询排队结果
                clearInterval(queryLineCountInterval);
                lineModalVisible.value = false;
              } else {
                notification.error({description: "取消失败!"});
              }
            } else {
              notification.error({description: data.message});
            }
          });
        };
    
        onMounted(() => {
          handleQueryPassenger();
        });
    
        return {
          passengers,
          dailyTrainTicket,
          seatTypes,
          passengerOptions,
          passengerChecks,
          tickets,
          PASSENGER_TYPE_ARRAY,
          visible,
          finishCheckPassenger,
          chooseSeatType,
          chooseSeatObj,
          SEAT_COL_ARRAY,
          handleOk,
          imageCodeToken,
          imageCodeSrc,
          imageCode,
          showImageCodeModal,
          imageCodeModalVisible,
          loadImageCode,
          firstImageCodeSourceA,
          firstImageCodeSourceB,
          firstImageCodeTarget,
          firstImageCodeModalVisible,
          showFirstImageCodeModal,
          validFirstImageCode,
          lineModalVisible,
          confirmOrderId,
          confirmOrderLineCount,
          onCancelOrder
        };
      },
    });
    </script>
    
    <style>
    .order-train .order-train-main {
      font-size: 18px;
      font-weight: bold;
    }
    .order-train .order-train-ticket {
      margin-top: 15px;
    }
    .order-train .order-train-ticket .order-train-ticket-main {
      color: red;
      font-size: 18px;
    }
    
    .order-tickets {
      margin: 10px 0;
    }
    .order-tickets .ant-col {
      padding: 5px 10px;
    }
    .order-tickets .order-tickets-header {
      background-color: cornflowerblue;
      border: solid 1px cornflowerblue;
      color: white;
      font-size: 16px;
      padding: 5px 0;
    }
    .order-tickets .order-tickets-row {
      border: solid 1px cornflowerblue;
      border-top: none;
      vertical-align: middle;
      line-height: 30px;
    }
    
    .order-tickets .choose-seat-item {
      margin: 5px 5px;
    }
    </style>
    
  • 效果

在这里插入图片描述

2.余票查询页面增加显示车站信息

完善余票查询的功能体验,可以看到某个车次的所有途径车站和到站出站时间信息

  • DailyTrainStationQueryAllReq.java

    package com.neilxu.train.business.req;
    
    import jakarta.validation.constraints.NotBlank;
    import jakarta.validation.constraints.NotNull;
    import lombok.Data;
    import org.springframework.format.annotation.DateTimeFormat;
    
    import java.util.Date;
    
    @Data
    public class DailyTrainStationQueryAllReq {
    
        /**
         * 日期
         */
        @DateTimeFormat(pattern = "yyyy-MM-dd")
        @NotNull(message = "【日期】不能为空")
        private Date date;
    
        /**
         * 车次编号
         */
        @NotBlank(message = "【车次编号】不能为空")
        private String trainCode;
        
    }
    
  • DailyTrainStationService.java

    /**
     * 按车次日期查询车站列表,用于界面显示一列车经过的车站
     */
    public List<DailyTrainStationQueryResp> queryByTrain(Date date, String trainCode) {
        DailyTrainStationExample dailyTrainStationExample = new DailyTrainStationExample();
        dailyTrainStationExample.setOrderByClause("`index` asc");
        dailyTrainStationExample.createCriteria().andDateEqualTo(date).andTrainCodeEqualTo(trainCode);
        List<DailyTrainStation> list = dailyTrainStationMapper.selectByExample(dailyTrainStationExample);
        return BeanUtil.copyToList(list, DailyTrainStationQueryResp.class);
    }
    
  • DailyTrainStationController.java

    package com.neilxu.train.business.controller;
    
    import com.neilxu.train.business.req.DailyTrainStationQueryAllReq;
    import com.neilxu.train.business.resp.DailyTrainStationQueryResp;
    import com.neilxu.train.business.service.DailyTrainStationService;
    import com.neilxu.train.common.resp.CommonResp;
    import jakarta.validation.Valid;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RestController;
    
    import java.util.List;
    
    @RestController
    @RequestMapping("/daily-train-station")
    public class DailyTrainStationController {
    
        @Autowired
        private DailyTrainStationService dailyTrainStationService;
    
        @GetMapping("/query-by-train-code")
        public CommonResp<List<DailyTrainStationQueryResp>> queryByTrain(@Valid DailyTrainStationQueryAllReq req) {
            List<DailyTrainStationQueryResp> list = dailyTrainStationService.queryByTrain(req.getDate(), req.getTrainCode());
            return new CommonResp<>(list);
        }
    
    }
    
  • ticket.vue

    <template>
      <p>
        <a-space>
          <a-date-picker v-model:value="params.date" valueFormat="YYYY-MM-DD" placeholder="请选择日期"></a-date-picker>
          <station-select-view v-model="params.start" width="200px"></station-select-view>
          <station-select-view v-model="params.end" width="200px"></station-select-view>
          <a-button type="primary" @click="handleQuery()">查找</a-button>
        </a-space>
      </p>
      <a-table :dataSource="dailyTrainTickets"
               :columns="columns"
               :pagination="pagination"
               @change="handleTableChange"
               :loading="loading">
        <template #bodyCell="{ column, record }">
          <template v-if="column.dataIndex === 'operation'">
            <a-space>
              <a-button type="primary" @click="toOrder(record)">预订</a-button>
              <a-button type="primary" @click="showStation(record)">途经车站</a-button>
            </a-space>
          </template>
          <template v-else-if="column.dataIndex === 'station'">
            {{record.start}}<br/>
            {{record.end}}
          </template>
          <template v-else-if="column.dataIndex === 'time'">
            {{record.startTime}}<br/>
            {{record.endTime}}
          </template>
          <template v-else-if="column.dataIndex === 'duration'">
            {{calDuration(record.startTime, record.endTime)}}<br/>
            <div v-if="record.startTime.replaceAll(':', '') >= record.endTime.replaceAll(':', '')">
              次日到达
            </div>
            <div v-else>
              当日到达
            </div>
          </template>
          <template v-else-if="column.dataIndex === 'ydz'">
            <div v-if="record.ydz >= 0">
              {{record.ydz}}<br/>
              {{record.ydzPrice}}¥
            </div>
            <div v-else>
              --
            </div>
          </template>
          <template v-else-if="column.dataIndex === 'edz'">
            <div v-if="record.edz >= 0">
              {{record.edz}}<br/>
              {{record.edzPrice}}¥
            </div>
            <div v-else>
              --
            </div>
          </template>
          <template v-else-if="column.dataIndex === 'rw'">
            <div v-if="record.rw >= 0">
              {{record.rw}}<br/>
              {{record.rwPrice}}¥
            </div>
            <div v-else>
              --
            </div>
          </template>
          <template v-else-if="column.dataIndex === 'yw'">
            <div v-if="record.yw >= 0">
              {{record.yw}}<br/>
              {{record.ywPrice}}¥
            </div>
            <div v-else>
              --
            </div>
          </template>
        </template>
      </a-table>
    
      <!-- 途经车站 -->
      <a-modal style="top: 30px" v-model:visible="visible" :title="null" :footer="null" :closable="false">
        <a-table :data-source="stations" :pagination="false">
          <a-table-column key="index" title="站序" data-index="index" />
          <a-table-column key="name" title="站名" data-index="name" />
          <a-table-column key="inTime" title="进站时间" data-index="inTime">
            <template #default="{ record }">
              {{record.index === 0 ? '-' : record.inTime}}
            </template>
          </a-table-column>
          <a-table-column key="outTime" title="出站时间" data-index="outTime">
            <template #default="{ record }">
              {{record.index === (stations.length - 1) ? '-' : record.outTime}}
            </template>
          </a-table-column>
          <a-table-column key="stopTime" title="停站时长" data-index="stopTime">
            <template #default="{ record }">
              {{record.index === 0 || record.index === (stations.length - 1) ? '-' : record.stopTime}}
            </template>
          </a-table-column>
        </a-table>
      </a-modal>
    </template>
    
    <script>
    import { defineComponent, ref, onMounted } from 'vue';
    import {notification} from "ant-design-vue";
    import axios from "axios";
    import StationSelectView from "@/components/station-select";
    import dayjs from "dayjs";
    import router from "@/router";
    
    export default defineComponent({
      name: "ticket-view",
      components: {StationSelectView},
      setup() {
        const visible = ref(false);
        let dailyTrainTicket = ref({
          id: undefined,
          date: undefined,
          trainCode: undefined,
          start: undefined,
          startPinyin: undefined,
          startTime: undefined,
          startIndex: undefined,
          end: undefined,
          endPinyin: undefined,
          endTime: undefined,
          endIndex: undefined,
          ydz: undefined,
          ydzPrice: undefined,
          edz: undefined,
          edzPrice: undefined,
          rw: undefined,
          rwPrice: undefined,
          yw: undefined,
          ywPrice: undefined,
          createTime: undefined,
          updateTime: undefined,
        });
        const dailyTrainTickets = ref([]);
        // 分页的三个属性名是固定的
        const pagination = ref({
          total: 0,
          current: 1,
          pageSize: 10,
        });
        let loading = ref(false);
        const params = ref({});
        const columns = [
          {
            title: '车次编号',
            dataIndex: 'trainCode',
            key: 'trainCode',
          },
          {
            title: '车站',
            dataIndex: 'station',
          },
          {
            title: '时间',
            dataIndex: 'time',
          },
          {
            title: '历时',
            dataIndex: 'duration',
          },
          {
            title: '一等座',
            dataIndex: 'ydz',
            key: 'ydz',
          },
          {
            title: '二等座',
            dataIndex: 'edz',
            key: 'edz',
          },
          {
            title: '软卧',
            dataIndex: 'rw',
            key: 'rw',
          },
          {
            title: '硬卧',
            dataIndex: 'yw',
            key: 'yw',
          },
          {
            title: '操作',
            dataIndex: 'operation',
          },
        ];
    
    
        const handleQuery = (param) => {
          if (Tool.isEmpty(params.value.date)) {
            notification.error({description: "请输入日期"});
            return;
          }
          if (Tool.isEmpty(params.value.start)) {
            notification.error({description: "请输入出发地"});
            return;
          }
          if (Tool.isEmpty(params.value.end)) {
            notification.error({description: "请输入目的地"});
            return;
          }
          if (!param) {
            param = {
              page: 1,
              size: pagination.value.pageSize
            };
          }
    
          // 保存查询参数
          SessionStorage.set(SESSION_TICKET_PARAMS, params.value);
    
          loading.value = true;
          axios.get("/business/daily-train-ticket/query-list", {
            params: {
              page: param.page,
              size: param.size,
              trainCode: params.value.trainCode,
              date: params.value.date,
              start: params.value.start,
              end: params.value.end
            }
          }).then((response) => {
            loading.value = false;
            let data = response.data;
            if (data.success) {
              dailyTrainTickets.value = data.content.list;
              // 设置分页控件的值
              pagination.value.current = param.page;
              pagination.value.total = data.content.total;
            } else {
              notification.error({description: data.message});
            }
          });
        };
    
        const handleTableChange = (page) => {
          // console.log("看看自带的分页参数都有啥:" + JSON.stringify(page));
          pagination.value.pageSize = page.pageSize;
          handleQuery({
            page: page.current,
            size: page.pageSize
          });
        };
    
        const calDuration = (startTime, endTime) => {
          let diff = dayjs(endTime, 'HH:mm:ss').diff(dayjs(startTime, 'HH:mm:ss'), 'seconds');
          return dayjs('00:00:00', 'HH:mm:ss').second(diff).format('HH:mm:ss');
        };
    
        const toOrder = (record) => {
          dailyTrainTicket.value = Tool.copy(record);
          SessionStorage.set(SESSION_ORDER, dailyTrainTicket.value);
          router.push("/order")
        };
    
        // ---------------------- 途经车站 ----------------------
        const stations = ref([]);
        const showStation = record => {
          visible.value = true;
          axios.get("/business/daily-train-station/query-by-train-code", {
            params: {
              date: record.date,
              trainCode: record.trainCode
            }
          }).then((response) => {
            let data = response.data;
            if (data.success) {
              stations.value = data.content;
            } else {
              notification.error({description: data.message});
            }
          });
        };
    
        onMounted(() => {
          //  "|| {}"是常用技巧,可以避免空指针异常
          params.value = SessionStorage.get(SESSION_TICKET_PARAMS) || {};
          if (Tool.isNotEmpty(params.value)) {
            handleQuery({
              page: 1,
              size: pagination.value.pageSize
            });
          }
        });
    
        return {
          dailyTrainTicket,
          visible,
          dailyTrainTickets,
          pagination,
          columns,
          handleTableChange,
          handleQuery,
          loading,
          params,
          calDuration,
          toOrder,
          showStation,
          stations
        };
      },
    });
    </script>
    
  • 效果

在这里插入图片描述

3.购票页面增加发起多人排队功能

本质就是一次下多条订单,最后返给前端的是最后一条订单的id,给前端的效果就是我是排队在最后面的那个订单

  • ConfirmOrderDoReq.java

    /**
     * 加入排队人数,用于体验排队功能
     */
    private int lineNumber;
    
    @Override
    public String toString() {
        return "ConfirmOrderDoReq{" +
                "memberId=" + memberId +
                ", date=" + date +
                ", trainCode='" + trainCode + '\'' +
                ", start='" + start + '\'' +
                ", end='" + end + '\'' +
                ", dailyTrainTicketId=" + dailyTrainTicketId +
                ", tickets=" + tickets +
                ", imageCode='" + imageCode + '\'' +
                ", imageCodeToken='" + imageCodeToken + '\'' +
                ", logId='" + logId + '\'' +
                ", lineNumber=" + lineNumber +
                '}';
    }
    
  • BeforeConfirmOrderService.java

    @SentinelResource(value = "beforeDoConfirm", blockHandler = "beforeDoConfirmBlock")
    public Long beforeDoConfirm(ConfirmOrderDoReq req) {
        Long id = null;
        // 根据前端传值,加入排队人数
        for (int i = 0; i < req.getLineNumber() + 1; i++) {
            req.setMemberId(LoginMemberContext.getId());
            // 校验令牌余量
            boolean validSkToken = skTokenService.validSkToken(req.getDate(), req.getTrainCode(), LoginMemberContext.getId());
            if (validSkToken) {
                LOG.info("令牌校验通过");
            } else {
                LOG.info("令牌校验不通过");
                throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_SK_TOKEN_FAIL);
            }
    
            Date date = req.getDate();
            String trainCode = req.getTrainCode();
            String start = req.getStart();
            String end = req.getEnd();
            List<ConfirmOrderTicketReq> tickets = req.getTickets();
    
            // 保存确认订单表,状态初始
            DateTime now = DateTime.now();
            ConfirmOrder confirmOrder = new ConfirmOrder();
            confirmOrder.setId(SnowUtil.getSnowflakeNextId());
            confirmOrder.setCreateTime(now);
            confirmOrder.setUpdateTime(now);
            confirmOrder.setMemberId(req.getMemberId());
            confirmOrder.setDate(date);
            confirmOrder.setTrainCode(trainCode);
            confirmOrder.setStart(start);
            confirmOrder.setEnd(end);
            confirmOrder.setDailyTrainTicketId(req.getDailyTrainTicketId());
            confirmOrder.setStatus(ConfirmOrderStatusEnum.INIT.getCode());
            confirmOrder.setTickets(JSON.toJSONString(tickets));
            confirmOrderMapper.insert(confirmOrder);
    
            // 发送MQ排队购票
            ConfirmOrderMQDto confirmOrderMQDto = new ConfirmOrderMQDto();
            confirmOrderMQDto.setDate(req.getDate());
            confirmOrderMQDto.setTrainCode(req.getTrainCode());
            confirmOrderMQDto.setLogId(MDC.get("LOG_ID"));
            String reqJson = JSON.toJSONString(confirmOrderMQDto);
            // LOG.info("排队购票,发送mq开始,消息:{}", reqJson);
            // rocketMQTemplate.convertAndSend(RocketMQTopicEnum.CONFIRM_ORDER.getCode(), reqJson);
            // LOG.info("排队购票,发送mq结束");
            confirmOrderService.doConfirm(confirmOrderMQDto);
            id = confirmOrder.getId();
        }
        return id;
    }
    
  • order.vue

    <template>
      <div class="order-train">
        <span class="order-train-main">{{dailyTrainTicket.date}}</span>&nbsp;
        <span class="order-train-main">{{dailyTrainTicket.trainCode}}</span>次&nbsp;
        <span class="order-train-main">{{dailyTrainTicket.start}}</span>站
        <span class="order-train-main">({{dailyTrainTicket.startTime}})</span>&nbsp;
        <span class="order-train-main">——</span>&nbsp;
        <span class="order-train-main">{{dailyTrainTicket.end}}</span>站
        <span class="order-train-main">({{dailyTrainTicket.endTime}})</span>&nbsp;
    
        <div class="order-train-ticket">
          <span v-for="item in seatTypes" :key="item.type">
            <span>{{item.desc}}</span>:
            <span class="order-train-ticket-main">{{item.price}}¥</span>&nbsp;
            <span class="order-train-ticket-main">{{item.count}}</span>&nbsp;张票&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
          </span>
        </div>
      </div>
      <a-divider></a-divider>
      <b>勾选要购票的乘客:</b>&nbsp;
      <a-checkbox-group v-model:value="passengerChecks" :options="passengerOptions" />
    
      <div class="order-tickets">
        <a-row class="order-tickets-header" v-if="tickets.length > 0">
          <a-col :span="2">乘客</a-col>
          <a-col :span="6">身份证</a-col>
          <a-col :span="4">票种</a-col>
          <a-col :span="4">座位类型</a-col>
        </a-row>
        <a-row class="order-tickets-row" v-for="ticket in tickets" :key="ticket.passengerId">
          <a-col :span="2">{{ticket.passengerName}}</a-col>
          <a-col :span="6">{{ticket.passengerIdCard}}</a-col>
          <a-col :span="4">
            <a-select v-model:value="ticket.passengerType" style="width: 100%">
              <a-select-option v-for="item in PASSENGER_TYPE_ARRAY" :key="item.code" :value="item.code">
                {{item.desc}}
              </a-select-option>
            </a-select>
          </a-col>
          <a-col :span="4">
            <a-select v-model:value="ticket.seatTypeCode" style="width: 100%">
              <a-select-option v-for="item in seatTypes" :key="item.code" :value="item.code">
                {{item.desc}}
              </a-select-option>
            </a-select>
          </a-col>
        </a-row>
      </div>
      <div v-if="tickets.length > 0">
        <a-button type="primary" size="large" @click="finishCheckPassenger">提交订单</a-button>
      </div>
    
      <a-modal v-model:visible="visible" title="请核对以下信息"
               style="top: 50px; width: 800px"
               ok-text="确认" cancel-text="取消"
               @ok="showFirstImageCodeModal">
        <div class="order-tickets">
          <a-row class="order-tickets-header" v-if="tickets.length > 0">
            <a-col :span="3">乘客</a-col>
            <a-col :span="15">身份证</a-col>
            <a-col :span="3">票种</a-col>
            <a-col :span="3">座位类型</a-col>
          </a-row>
          <a-row class="order-tickets-row" v-for="ticket in tickets" :key="ticket.passengerId">
            <a-col :span="3">{{ticket.passengerName}}</a-col>
            <a-col :span="15">{{ticket.passengerIdCard}}</a-col>
            <a-col :span="3">
              <span v-for="item in PASSENGER_TYPE_ARRAY" :key="item.code">
                <span v-if="item.code === ticket.passengerType">
                  {{item.desc}}
                </span>
              </span>
            </a-col>
            <a-col :span="3">
              <span v-for="item in seatTypes" :key="item.code">
                <span v-if="item.code === ticket.seatTypeCode">
                  {{item.desc}}
                </span>
              </span>
            </a-col>
          </a-row>
          <br/>
          <div v-if="chooseSeatType === 0" style="color: red;">
            您购买的车票不支持选座
            <div>12306规则:只有全部是一等座或全部是二等座才支持选座</div>
            <div>12306规则:余票小于一定数量时,不允许选座(本项目以20为例)</div>
          </div>
          <div v-else style="text-align: center">
            <a-switch class="choose-seat-item" v-for="item in SEAT_COL_ARRAY" :key="item.code"
                      v-model:checked="chooseSeatObj[item.code + '1']" :checked-children="item.desc" :un-checked-children="item.desc" />
            <div v-if="tickets.length > 1">
              <a-switch class="choose-seat-item" v-for="item in SEAT_COL_ARRAY" :key="item.code"
                        v-model:checked="chooseSeatObj[item.code + '2']" :checked-children="item.desc" :un-checked-children="item.desc" />
            </div>
            <div style="color: #999999">提示:您可以选择{{tickets.length}}个座位</div>
          </div>
          <br>
          <div style="color: red">
            体验排队购票,加入多人一起排队购票:
            <a-input-number v-model:value="lineNumber" :min="0" :max="20" />
          </div>
          <!--<br/>-->
          <!--最终购票:{{tickets}}-->
          <!--最终选座:{{chooseSeatObj}}-->
        </div>
      </a-modal>
    
      <!-- 第二层验证码 后端 -->
      <a-modal v-model:visible="imageCodeModalVisible" :title="null" :footer="null" :closable="false"
               style="top: 50px; width: 400px">
        <p style="text-align: center; font-weight: bold; font-size: 18px">
          使用服务端验证码削弱瞬时高峰<br/>
          防止机器人刷票
        </p>
        <p>
          <a-input v-model:value="imageCode" placeholder="图片验证码">
            <template #suffix>
              <img v-show="!!imageCodeSrc" :src="imageCodeSrc" alt="验证码" v-on:click="loadImageCode()"/>
            </template>
          </a-input>
        </p>
        <a-button type="danger" block @click="handleOk">输入验证码后开始购票</a-button>
      </a-modal>
    
      <!-- 第一层验证码 纯前端 -->
      <a-modal v-model:visible="firstImageCodeModalVisible" :title="null" :footer="null" :closable="false"
               style="top: 50px; width: 400px">
        <p style="text-align: center; font-weight: bold; font-size: 18px">
          使用纯前端验证码削弱瞬时高峰<br/>
          减小后端验证码接口的压力
        </p>
        <p>
          <a-input v-model:value="firstImageCodeTarget" placeholder="验证码">
            <template #suffix>
              {{firstImageCodeSourceA}} + {{firstImageCodeSourceB}}
            </template>
          </a-input>
        </p>
        <a-button type="danger" block @click="validFirstImageCode">提交验证码</a-button>
      </a-modal>
    
      <a-modal v-model:visible="lineModalVisible" title="排队购票" :footer="null" :maskClosable="false" :closable="false"
               style="top: 50px; width: 400px">
        <div class="book-line">
          <div v-show="confirmOrderLineCount < 0">
            <loading-outlined /> 系统正在处理中...
          </div>
          <div v-show="confirmOrderLineCount >= 0">
            <loading-outlined /> 您前面还有{{confirmOrderLineCount}}位用户在购票,排队中,请稍候
          </div>
        </div>
        <br/>
        <a-button type="danger" @click="onCancelOrder">取消购票</a-button>
      </a-modal>
    </template>
    
    <script>
    
    import {defineComponent, ref, onMounted, watch, computed} from 'vue';
    import axios from "axios";
    import {notification} from "ant-design-vue";
    
    export default defineComponent({
      name: "order-view",
      setup() {
        const passengers = ref([]);
        const passengerOptions = ref([]);
        const passengerChecks = ref([]);
        const dailyTrainTicket = SessionStorage.get(SESSION_ORDER) || {};
        console.log("下单的车次信息", dailyTrainTicket);
    
        const SEAT_TYPE = window.SEAT_TYPE;
        console.log(SEAT_TYPE)
        // 本车次提供的座位类型seatTypes,含票价,余票等信息,例:
        // {
        //   type: "YDZ",
        //   code: "1",
        //   desc: "一等座",
        //   count: "100",
        //   price: "50",
        // }
        // 关于SEAT_TYPE[KEY]:当知道某个具体的属性xxx时,可以用obj.xxx,当属性名是个变量时,可以使用obj[xxx]
        const seatTypes = [];
        for (let KEY in SEAT_TYPE) {
          let key = KEY.toLowerCase();
          if (dailyTrainTicket[key] >= 0) {
            seatTypes.push({
              type: KEY,
              code: SEAT_TYPE[KEY]["code"],
              desc: SEAT_TYPE[KEY]["desc"],
              count: dailyTrainTicket[key],
              price: dailyTrainTicket[key + 'Price'],
            })
          }
        }
        console.log("本车次提供的座位:", seatTypes)
        // 购票列表,用于界面展示,并传递到后端接口,用来描述:哪个乘客购买什么座位的票
        // {
        //   passengerId: 123,
        //   passengerType: "1",
        //   passengerName: "张三",
        //   passengerIdCard: "12323132132",
        //   seatTypeCode: "1",
        //   seat: "C1"
        // }
        const tickets = ref([]);
        const PASSENGER_TYPE_ARRAY = window.PASSENGER_TYPE_ARRAY;
        const visible = ref(false);
        const lineModalVisible = ref(false);
        const confirmOrderId = ref();
        const confirmOrderLineCount = ref(-1);
        const lineNumber = ref(5);
    
        // 勾选或去掉某个乘客时,在购票列表中加上或去掉一张表
        watch(() => passengerChecks.value, (newVal, oldVal)=>{
          console.log("勾选乘客发生变化", newVal, oldVal)
          // 每次有变化时,把购票列表清空,重新构造列表
          tickets.value = [];
          passengerChecks.value.forEach((item) => tickets.value.push({
            passengerId: item.id,
            passengerType: item.type,
            seatTypeCode: seatTypes[0].code,
            passengerName: item.name,
            passengerIdCard: item.idCard
          }))
        }, {immediate: true});
    
        // 0:不支持选座;1:选一等座;2:选二等座
        const chooseSeatType = ref(0);
        // 根据选择的座位类型,计算出对应的列,比如要选的是一等座,就筛选出ACDF,要选的是二等座,就筛选出ABCDF
        const SEAT_COL_ARRAY = computed(() => {
          return window.SEAT_COL_ARRAY.filter(item => item.type === chooseSeatType.value);
        });
        // 选择的座位
        // {
        //   A1: false, C1: true,D1: false, F1: false,
        //   A2: false, C2: false,D2: true, F2: false
        // }
        const chooseSeatObj = ref({});
        watch(() => SEAT_COL_ARRAY.value, () => {
          chooseSeatObj.value = {};
          for (let i = 1; i <= 2; i++) {
            SEAT_COL_ARRAY.value.forEach((item) => {
              chooseSeatObj.value[item.code + i] = false;
            })
          }
          console.log("初始化两排座位,都是未选中:", chooseSeatObj.value);
        }, {immediate: true});
    
        const handleQueryPassenger = () => {
          axios.get("/member/passenger/query-mine").then((response) => {
            let data = response.data;
            if (data.success) {
              passengers.value = data.content;
              passengers.value.forEach((item) => passengerOptions.value.push({
                label: item.name,
                value: item
              }))
            } else {
              notification.error({description: data.message});
            }
          });
        };
    
        const finishCheckPassenger = () => {
          console.log("购票列表:", tickets.value);
    
          if (tickets.value.length > 5) {
            notification.error({description: '最多只能购买5张车票'});
            return;
          }
    
          // 校验余票是否充足,购票列表中的每个座位类型,都去车次座位余票信息中,看余票是否充足
          // 前端校验不一定准,但前端校验可以减轻后端很多压力
          // 注意:这段只是校验,必须copy出seatTypesTemp变量来扣减,用原始的seatTypes去扣减,会影响真实的库存
          let seatTypesTemp = Tool.copy(seatTypes);
          for (let i = 0; i < tickets.value.length; i++) {
            let ticket = tickets.value[i];
            for (let j = 0; j < seatTypesTemp.length; j++) {
              let seatType = seatTypesTemp[j];
              // 同类型座位余票-1,这里扣减的是临时copy出来的库存,不是真正的库存,只是为了校验
              if (ticket.seatTypeCode === seatType.code) {
                seatType.count--;
                if (seatType.count < 0) {
                  notification.error({description: seatType.desc + '余票不足'});
                  return;
                }
              }
            }
          }
          console.log("前端余票校验通过");
    
          // 判断是否支持选座,只有纯一等座和纯二等座支持选座
          // 先筛选出购票列表中的所有座位类型,比如四张表:[1, 1, 2, 2]
          let ticketSeatTypeCodes = [];
          for (let i = 0; i < tickets.value.length; i++) {
            let ticket = tickets.value[i];
            ticketSeatTypeCodes.push(ticket.seatTypeCode);
          }
          // 为购票列表中的所有座位类型去重:[1, 2]
          const ticketSeatTypeCodesSet = Array.from(new Set(ticketSeatTypeCodes));
          console.log("选好的座位类型:", ticketSeatTypeCodesSet);
          if (ticketSeatTypeCodesSet.length !== 1) {
            console.log("选了多种座位,不支持选座");
            chooseSeatType.value = 0;
          } else {
            // ticketSeatTypeCodesSet.length === 1,即只选择了一种座位(不是一个座位,是一种座位)
            if (ticketSeatTypeCodesSet[0] === SEAT_TYPE.YDZ.code) {
              console.log("一等座选座");
              chooseSeatType.value = SEAT_TYPE.YDZ.code;
            } else if (ticketSeatTypeCodesSet[0] === SEAT_TYPE.EDZ.code) {
              console.log("二等座选座");
              chooseSeatType.value = SEAT_TYPE.EDZ.code;
            } else {
              console.log("不是一等座或二等座,不支持选座");
              chooseSeatType.value = 0;
            }
    
            // 余票小于20张时,不允许选座,否则选座成功率不高,影响出票
            if (chooseSeatType.value !== 0) {
              for (let i = 0; i < seatTypes.length; i++) {
                let seatType = seatTypes[i];
                // 找到同类型座位
                if (ticketSeatTypeCodesSet[0] === seatType.code) {
                  // 判断余票,小于20张就不支持选座
                  if (seatType.count < 20) {
                    console.log("余票小于20张就不支持选座")
                    chooseSeatType.value = 0;
                    break;
                  }
                }
              }
            }
          }
    
          // 弹出确认界面
          visible.value = true;
    
        };
    
        const handleOk = () => {
          if (Tool.isEmpty(imageCode.value)) {
            notification.error({description: '验证码不能为空'});
            return;
          }
    
          console.log("选好的座位:", chooseSeatObj.value);
    
          // 设置每张票的座位
          // 先清空购票列表的座位,有可能之前选了并设置座位了,但选座数不对被拦截了,又重新选一遍
          for (let i = 0; i < tickets.value.length; i++) {
            tickets.value[i].seat = null;
          }
          let i = -1;
          // 要么不选座位,要么所选座位应该等于购票数,即i === (tickets.value.length - 1)
          for (let key in chooseSeatObj.value) {
            if (chooseSeatObj.value[key]) {
              i++;
              if (i > tickets.value.length - 1) {
                notification.error({description: '所选座位数大于购票数'});
                return;
              }
              tickets.value[i].seat = key;
            }
          }
          if (i > -1 && i < (tickets.value.length - 1)) {
            notification.error({description: '所选座位数小于购票数'});
            return;
          }
    
          console.log("最终购票:", tickets.value);
    
          axios.post("/business/confirm-order/do", {
            dailyTrainTicketId: dailyTrainTicket.id,
            date: dailyTrainTicket.date,
            trainCode: dailyTrainTicket.trainCode,
            start: dailyTrainTicket.start,
            end: dailyTrainTicket.end,
            tickets: tickets.value,
            imageCodeToken: imageCodeToken.value,
            imageCode: imageCode.value,
            lineNumber: lineNumber.value
          }).then((response) => {
            let data = response.data;
            if (data.success) {
              // notification.success({description: "下单成功!"});
              visible.value = false;
              imageCodeModalVisible.value = false;
              lineModalVisible.value = true;
              confirmOrderId.value = data.content;
              queryLineCount();
            } else {
              notification.error({description: data.message});
            }
          });
        }
    
        /* ------------------- 定时查询订单状态 --------------------- */
        // 确认订单后定时查询
        let queryLineCountInterval;
    
        // 定时查询订单结果/排队数量
        const queryLineCount = () => {
          confirmOrderLineCount.value = -1;
          queryLineCountInterval = setInterval(function () {
            axios.get("/business/confirm-order/query-line-count/" + confirmOrderId.value).then((response) => {
              let data = response.data;
              if (data.success) {
                let result = data.content;
                switch (result) {
                  case -1 :
                    notification.success({description: "购票成功!"});
                    lineModalVisible.value = false;
                    clearInterval(queryLineCountInterval);
                    break;
                  case -2:
                    notification.error({description: "购票失败!"});
                    lineModalVisible.value = false;
                    clearInterval(queryLineCountInterval);
                    break;
                  case -3:
                    notification.error({description: "抱歉,没票了!"});
                    lineModalVisible.value = false;
                    clearInterval(queryLineCountInterval);
                    break;
                  default:
                    confirmOrderLineCount.value = result;
                }
              } else {
                notification.error({description: data.message});
              }
            });
          }, 500);
        };
    
        /* ------------------- 第二层验证码 --------------------- */
        const imageCodeModalVisible = ref();
        const imageCodeToken = ref();
        const imageCodeSrc = ref();
        const imageCode = ref();
        /**
         * 加载图形验证码
         */
        const loadImageCode = () => {
          imageCodeToken.value = Tool.uuid(8);
          imageCodeSrc.value = process.env.VUE_APP_SERVER + '/business/kaptcha/image-code/' + imageCodeToken.value;
        };
    
        const showImageCodeModal = () => {
          loadImageCode();
          imageCodeModalVisible.value = true;
        };
    
        /* ------------------- 第一层验证码 --------------------- */
        const firstImageCodeSourceA = ref();
        const firstImageCodeSourceB = ref();
        const firstImageCodeTarget = ref();
        const firstImageCodeModalVisible = ref();
    
        /**
         * 加载第一层验证码
         */
        const loadFirstImageCode = () => {
          // 获取1~10的数:Math.floor(Math.random()*10 + 1)
          firstImageCodeSourceA.value = Math.floor(Math.random()*10 + 1) + 10;
          firstImageCodeSourceB.value = Math.floor(Math.random()*10 + 1) + 20;
        };
    
        /**
         * 显示第一层验证码弹出框
         */
        const showFirstImageCodeModal = () => {
          loadFirstImageCode();
          firstImageCodeModalVisible.value = true;
        };
    
        /**
         * 校验第一层验证码
         */
        const validFirstImageCode = () => {
          if (parseInt(firstImageCodeTarget.value) === parseInt(firstImageCodeSourceA.value + firstImageCodeSourceB.value)) {
            // 第一层验证通过
            firstImageCodeModalVisible.value = false;
            showImageCodeModal();
          } else {
            notification.error({description: '验证码错误'});
          }
        };
    
        /**
         * 取消排队
         */
        const onCancelOrder = () => {
          axios.get("/business/confirm-order/cancel/" + confirmOrderId.value).then((response) => {
            let data = response.data;
            if (data.success) {
              let result = data.content;
              if (result === 1) {
                notification.success({description: "取消成功!"});
                // 取消成功时,不用再轮询排队结果
                clearInterval(queryLineCountInterval);
                lineModalVisible.value = false;
              } else {
                notification.error({description: "取消失败!"});
              }
            } else {
              notification.error({description: data.message});
            }
          });
        };
    
        onMounted(() => {
          handleQueryPassenger();
        });
    
        return {
          passengers,
          dailyTrainTicket,
          seatTypes,
          passengerOptions,
          passengerChecks,
          tickets,
          PASSENGER_TYPE_ARRAY,
          visible,
          finishCheckPassenger,
          chooseSeatType,
          chooseSeatObj,
          SEAT_COL_ARRAY,
          handleOk,
          imageCodeToken,
          imageCodeSrc,
          imageCode,
          showImageCodeModal,
          imageCodeModalVisible,
          loadImageCode,
          firstImageCodeSourceA,
          firstImageCodeSourceB,
          firstImageCodeTarget,
          firstImageCodeModalVisible,
          showFirstImageCodeModal,
          validFirstImageCode,
          lineModalVisible,
          confirmOrderId,
          confirmOrderLineCount,
          onCancelOrder,
          lineNumber
        };
      },
    });
    </script>
    
    <style>
    .order-train .order-train-main {
      font-size: 18px;
      font-weight: bold;
    }
    .order-train .order-train-ticket {
      margin-top: 15px;
    }
    .order-train .order-train-ticket .order-train-ticket-main {
      color: red;
      font-size: 18px;
    }
    
    .order-tickets {
      margin: 10px 0;
    }
    .order-tickets .ant-col {
      padding: 5px 10px;
    }
    .order-tickets .order-tickets-header {
      background-color: cornflowerblue;
      border: solid 1px cornflowerblue;
      color: white;
      font-size: 16px;
      padding: 5px 0;
    }
    .order-tickets .order-tickets-row {
      border: solid 1px cornflowerblue;
      border-top: none;
      vertical-align: middle;
      line-height: 30px;
    }
    
    .order-tickets .choose-seat-item {
      margin: 5px 5px;
    }
    </style>
    
  • 效果

在这里插入图片描述

4.增加座位销售图

额外的功能,最终展现类似电影院座位销售图的效果

1.增加查询座位销售详情接口

  • com.neilxu.train.business.req.SeatSellReq

    package com.neilxu.train.business.req;
    
    import jakarta.validation.constraints.NotNull;
    import lombok.Data;
    import org.springframework.format.annotation.DateTimeFormat;
    
    import java.util.Date;
    
    @Data
    public class SeatSellReq {
    
        /**
         * 日期
         */
        @DateTimeFormat(pattern = "yyyy-MM-dd")
        @NotNull(message = "【日期】不能为空")
        private Date date;
    
        /**
         * 车次编号
         */
        @NotNull(message = "【车次编号】不能为空")
        private String trainCode;
    
    }
    
  • com.neilxu.train.business.resp.SeatSellResp

    package com.neilxu.train.business.resp;
    
    import lombok.Data;
    
    @Data
    public class SeatSellResp {
    
        /**
         * 箱序
         */
        private Integer carriageIndex;
    
        /**
         * 排号|01, 02
         */
        private String row;
    
        /**
         * 列号|枚举[SeatColEnum]
         */
        private String col;
    
        /**
         * 座位类型|枚举[SeatTypeEnum]
         */
        private String seatType;
    
        /**
         * 售卖情况|将经过的车站用01拼接,0表示可卖,1表示已卖
         */
        private String sell;
    
    }
    
  • com.neilxu.train.business.service.DailyTrainSeatService

    /**
     * 查询某日某车次的所有座位
     */
    public List<SeatSellResp> querySeatSell(SeatSellReq req) {
        Date date = req.getDate();
        String trainCode = req.getTrainCode();
        LOG.info("查询日期【{}】车次【{}】的座位销售信息", DateUtil.formatDate(date), trainCode);
        DailyTrainSeatExample dailyTrainSeatExample = new DailyTrainSeatExample();
        dailyTrainSeatExample.setOrderByClause("`carriage_index` asc, carriage_seat_index asc");
        dailyTrainSeatExample.createCriteria()
                .andDateEqualTo(date)
                .andTrainCodeEqualTo(trainCode);
        return BeanUtil.copyToList(dailyTrainSeatMapper.selectByExample(dailyTrainSeatExample), SeatSellResp.class);
    }
    
  • com.neilxu.train.business.controller.SeatSellController

    package com.neilxu.train.business.controller;
    
    import com.neilxu.train.business.req.SeatSellReq;
    import com.neilxu.train.business.resp.SeatSellResp;
    import com.neilxu.train.business.service.DailyTrainSeatService;
    import com.neilxu.train.common.resp.CommonResp;
    import jakarta.validation.Valid;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RestController;
    
    import java.util.List;
    
    ;
    
    @RestController
    @RequestMapping("/seat-sell")
    public class SeatSellController {
    
        @Autowired
        private DailyTrainSeatService dailyTrainSeatService;
    
        @GetMapping("/query")
        public CommonResp<List<SeatSellResp>> query(@Valid SeatSellReq req) {
            List<SeatSellResp> seatList = dailyTrainSeatService.querySeatSell(req);
            return new CommonResp<>(seatList);
        }
    
    }
    
  • 测试

    http/business-seat.http

    GET http://localhost:8000/business/seat-sell/query?date=2024-04-10&trainCode=D2
    Accept: application/json
    token: {{token}}
    
    ###
    

在这里插入图片描述

2.增加座位销售图路由及页面,实现页面跳转和参数传递

  • web/src/views/main/seat.vue

    <template>
      <div v-if="!param.date">
        请到余票查询里选择一趟列车,
        <router-link to="/ticket">
          跳转到余票查询
        </router-link>
      </div>
      <div v-else>
        <p>
          日期:{{param.date}},车次:{{param.trainCode}},出发站:{{param.start}},到达站:{{param.end}}
        </p>
      </div>
    </template>
    
    <script>
    
    import { defineComponent, ref } from 'vue';
    import {useRoute} from "vue-router";
    
    export default defineComponent({
      name: "welcome-view",
      setup() {
        const route = useRoute();
        const param = ref({});
        param.value = route.query;
    
        return {
          param
        };
      },
    });
    </script>
    
  • 增加路由、侧边栏、顶部菜单栏

    操作同之前

  • web/src/views/main/ticket.vue

    <template>
      <p>
        <a-space>
          <a-date-picker v-model:value="params.date" valueFormat="YYYY-MM-DD" placeholder="请选择日期"></a-date-picker>
          <station-select-view v-model="params.start" width="200px"></station-select-view>
          <station-select-view v-model="params.end" width="200px"></station-select-view>
          <a-button type="primary" @click="handleQuery()">查找</a-button>
        </a-space>
      </p>
      <a-table :dataSource="dailyTrainTickets"
               :columns="columns"
               :pagination="pagination"
               @change="handleTableChange"
               :loading="loading">
        <template #bodyCell="{ column, record }">
          <template v-if="column.dataIndex === 'operation'">
            <a-space>
              <a-button type="primary" @click="toOrder(record)">预订</a-button>
              <router-link :to="{
                path: '/seat',
                query: {
                  date: record.date,
                  trainCode: record.trainCode,
                  start: record.start,
                  startIndex: record.startIndex,
                  end: record.end,
                  endIndex: record.endIndex
                }
              }">
                <a-button type="primary">座位销售图</a-button>
              </router-link>
              <a-button type="primary" @click="showStation(record)">途经车站</a-button>
            </a-space>
          </template>
          <template v-else-if="column.dataIndex === 'station'">
            {{record.start}}<br/>
            {{record.end}}
          </template>
          <template v-else-if="column.dataIndex === 'time'">
            {{record.startTime}}<br/>
            {{record.endTime}}
          </template>
          <template v-else-if="column.dataIndex === 'duration'">
            {{calDuration(record.startTime, record.endTime)}}<br/>
            <div v-if="record.startTime.replaceAll(':', '') >= record.endTime.replaceAll(':', '')">
              次日到达
            </div>
            <div v-else>
              当日到达
            </div>
          </template>
          <template v-else-if="column.dataIndex === 'ydz'">
            <div v-if="record.ydz >= 0">
              {{record.ydz}}<br/>
              {{record.ydzPrice}}¥
            </div>
            <div v-else>
              --
            </div>
          </template>
          <template v-else-if="column.dataIndex === 'edz'">
            <div v-if="record.edz >= 0">
              {{record.edz}}<br/>
              {{record.edzPrice}}¥
            </div>
            <div v-else>
              --
            </div>
          </template>
          <template v-else-if="column.dataIndex === 'rw'">
            <div v-if="record.rw >= 0">
              {{record.rw}}<br/>
              {{record.rwPrice}}¥
            </div>
            <div v-else>
              --
            </div>
          </template>
          <template v-else-if="column.dataIndex === 'yw'">
            <div v-if="record.yw >= 0">
              {{record.yw}}<br/>
              {{record.ywPrice}}¥
            </div>
            <div v-else>
              --
            </div>
          </template>
        </template>
      </a-table>
    
      <!-- 途经车站 -->
      <a-modal style="top: 30px" v-model:visible="visible" :title="null" :footer="null" :closable="false">
        <a-table :data-source="stations" :pagination="false">
          <a-table-column key="index" title="站序" data-index="index" />
          <a-table-column key="name" title="站名" data-index="name" />
          <a-table-column key="inTime" title="进站时间" data-index="inTime">
            <template #default="{ record }">
              {{record.index === 0 ? '-' : record.inTime}}
            </template>
          </a-table-column>
          <a-table-column key="outTime" title="出站时间" data-index="outTime">
            <template #default="{ record }">
              {{record.index === (stations.length - 1) ? '-' : record.outTime}}
            </template>
          </a-table-column>
          <a-table-column key="stopTime" title="停站时长" data-index="stopTime">
            <template #default="{ record }">
              {{record.index === 0 || record.index === (stations.length - 1) ? '-' : record.stopTime}}
            </template>
          </a-table-column>
        </a-table>
      </a-modal>
    </template>
    
    <script>
    import { defineComponent, ref, onMounted } from 'vue';
    import {notification} from "ant-design-vue";
    import axios from "axios";
    import StationSelectView from "@/components/station-select";
    import dayjs from "dayjs";
    import router from "@/router";
    
    export default defineComponent({
      name: "ticket-view",
      components: {StationSelectView},
      setup() {
        const visible = ref(false);
        let dailyTrainTicket = ref({
          id: undefined,
          date: undefined,
          trainCode: undefined,
          start: undefined,
          startPinyin: undefined,
          startTime: undefined,
          startIndex: undefined,
          end: undefined,
          endPinyin: undefined,
          endTime: undefined,
          endIndex: undefined,
          ydz: undefined,
          ydzPrice: undefined,
          edz: undefined,
          edzPrice: undefined,
          rw: undefined,
          rwPrice: undefined,
          yw: undefined,
          ywPrice: undefined,
          createTime: undefined,
          updateTime: undefined,
        });
        const dailyTrainTickets = ref([]);
        // 分页的三个属性名是固定的
        const pagination = ref({
          total: 0,
          current: 1,
          pageSize: 10,
        });
        let loading = ref(false);
        const params = ref({});
        const columns = [
          {
            title: '车次编号',
            dataIndex: 'trainCode',
            key: 'trainCode',
          },
          {
            title: '车站',
            dataIndex: 'station',
          },
          {
            title: '时间',
            dataIndex: 'time',
          },
          {
            title: '历时',
            dataIndex: 'duration',
          },
          {
            title: '一等座',
            dataIndex: 'ydz',
            key: 'ydz',
          },
          {
            title: '二等座',
            dataIndex: 'edz',
            key: 'edz',
          },
          {
            title: '软卧',
            dataIndex: 'rw',
            key: 'rw',
          },
          {
            title: '硬卧',
            dataIndex: 'yw',
            key: 'yw',
          },
          {
            title: '操作',
            dataIndex: 'operation',
          },
        ];
    
    
        const handleQuery = (param) => {
          if (Tool.isEmpty(params.value.date)) {
            notification.error({description: "请输入日期"});
            return;
          }
          if (Tool.isEmpty(params.value.start)) {
            notification.error({description: "请输入出发地"});
            return;
          }
          if (Tool.isEmpty(params.value.end)) {
            notification.error({description: "请输入目的地"});
            return;
          }
          if (!param) {
            param = {
              page: 1,
              size: pagination.value.pageSize
            };
          }
    
          // 保存查询参数
          SessionStorage.set(SESSION_TICKET_PARAMS, params.value);
    
          loading.value = true;
          axios.get("/business/daily-train-ticket/query-list", {
            params: {
              page: param.page,
              size: param.size,
              trainCode: params.value.trainCode,
              date: params.value.date,
              start: params.value.start,
              end: params.value.end
            }
          }).then((response) => {
            loading.value = false;
            let data = response.data;
            if (data.success) {
              dailyTrainTickets.value = data.content.list;
              // 设置分页控件的值
              pagination.value.current = param.page;
              pagination.value.total = data.content.total;
            } else {
              notification.error({description: data.message});
            }
          });
        };
    
        const handleTableChange = (page) => {
          // console.log("看看自带的分页参数都有啥:" + JSON.stringify(page));
          pagination.value.pageSize = page.pageSize;
          handleQuery({
            page: page.current,
            size: page.pageSize
          });
        };
    
        const calDuration = (startTime, endTime) => {
          let diff = dayjs(endTime, 'HH:mm:ss').diff(dayjs(startTime, 'HH:mm:ss'), 'seconds');
          return dayjs('00:00:00', 'HH:mm:ss').second(diff).format('HH:mm:ss');
        };
    
        const toOrder = (record) => {
          dailyTrainTicket.value = Tool.copy(record);
          SessionStorage.set(SESSION_ORDER, dailyTrainTicket.value);
          router.push("/order")
        };
    
        // ---------------------- 途经车站 ----------------------
        const stations = ref([]);
        const showStation = record => {
          visible.value = true;
          axios.get("/business/daily-train-station/query-by-train-code", {
            params: {
              date: record.date,
              trainCode: record.trainCode
            }
          }).then((response) => {
            let data = response.data;
            if (data.success) {
              stations.value = data.content;
            } else {
              notification.error({description: data.message});
            }
          });
        };
    
        onMounted(() => {
          //  "|| {}"是常用技巧,可以避免空指针异常
          params.value = SessionStorage.get(SESSION_TICKET_PARAMS) || {};
          if (Tool.isNotEmpty(params.value)) {
            handleQuery({
              page: 1,
              size: pagination.value.pageSize
            });
          }
        });
    
        return {
          dailyTrainTicket,
          visible,
          dailyTrainTickets,
          pagination,
          columns,
          handleTableChange,
          handleQuery,
          loading,
          params,
          calDuration,
          toOrder,
          showStation,
          stations
        };
      },
    });
    </script>
    

3.座位销售图页面获得销售信息,同一趟车,不管查哪个区间,查到的销售信息是一样的,由界面再去截取区间的销售信息。功能设计经验:对于复杂的操作,能放到前端的都放到前端,减小后端的压力。

  • web/src/views/main/seat.vue

    <template>
      <div v-if="!param.date">
        请到余票查询里选择一趟列车,
        <router-link to="/ticket">
          跳转到余票查询
        </router-link>
      </div>
      <div v-else>
        <p>
          日期:{{param.date}},车次:{{param.trainCode}},出发站:{{param.start}},到达站:{{param.end}}
        </p>
        <p>
          {{list}}
        </p>
      </div>
    </template>
    
    <script>
    
    import { defineComponent, ref, onMounted } from 'vue';
    import axios from "axios";
    import {notification} from "ant-design-vue";
    import {useRoute} from "vue-router";
    
    export default defineComponent({
      name: "welcome-view",
      setup() {
        const route = useRoute();
        const param = ref({});
        param.value = route.query;
        const list = ref();
    
        // 查询一列火车的所有销售信息
        const querySeat = () => {
          axios.get("/business/seat-sell/query", {
            params: {
              date: param.value.date,
              trainCode: param.value.trainCode,
            }
          }).then((response) => {
            let data = response.data;
            if (data.success) {
              list.value = data.content;
            } else {
              notification.error({description: data.message});
            }
          });
        };
    
        onMounted(() => {
          if (param.value.date) {
            querySeat();
          }
        });
    
        return {
          param,
          querySeat,
          list
        };
      },
    });
    </script>
    

4.显示各车厢各座位的销售详情,使用橙色灰色代码座位可卖与卖出

  • train-station.vue

    <a-form-item label="站序">
      <a-input v-model:value="trainStation.index" />
      <span style="color: red">重要:第1站是0,对显示销售图有影响</span>
    </a-form-item>
    
  • seat.vue

    <template>
      <div v-if="!param.date">
        请到余票查询里选择一趟列车,
        <router-link to="/ticket">
          跳转到余票查询
        </router-link>
      </div>
      <div v-else>
        <p style="font-weight: bold;">
          日期:{{param.date}},车次:{{param.trainCode}},出发站:{{param.start}},到达站:{{param.end}}
        </p>
    
        <table>
          <tr>
            <td style="width: 25px; background: #FF9900;"></td>
            <td>:已被购买</td>
            <td style="width: 20px;"></td>
            <td style="width: 25px; background: #999999;"></td>
            <td>:未被购买</td>
          </tr>
        </table>
        <br>
        <div v-for="(seatObj, carriage) in train" :key="carriage"
             style="border: 3px solid #99CCFF;
                     margin-bottom: 30px;
                     padding: 5px;
                     border-radius: 4px">
          <div style="display:block;
                      width:50px;
                      height:10px;
                      position:relative;
                      top:-15px;
                      text-align: center;
                      background: white;">
            {{carriage}}
          </div>
          <table>
            <tr>
              <td v-for="(sell, index) in Object.values(seatObj)[0]" :key="index"
                  style="text-align: center">
                {{index + 1}}
              </td>
            </tr>
            <tr v-for="(sellList, col) in seatObj" :key="col">
              <td v-for="(sell, index) in sellList" :key="index"
                  style="text-align: center;
                          border: 2px solid white;
                          background: grey;
                          padding: 0 4px;
                          color: white;
                          "
                  :style="{background: (sell > 0 ? '#FF9900' : '#999999')}">{{col}}</td>
            </tr>
          </table>
        </div>
      </div>
    </template>
    
    <script>
    
    import {defineComponent, onMounted, ref} from 'vue';
    import axios from "axios";
    import {notification} from "ant-design-vue";
    import {useRoute} from "vue-router";
    
    export default defineComponent({
      name: "seat-view",
      setup() {
        const route = useRoute();
        const param = ref({});
        param.value = route.query;
        const list = ref();
        // 使用对象更便于组装数组,三维数组只能存储最终的01,不能存储“车箱1”,“A”这些数据
        // {
        //   "车箱1": {
        //      "A" : ["000", "001", "001", "001"],
        //      "B" : ["000", "001", "001", "001"],
        //      "C" : ["000", "001", "001", "001"],
        //      "D" : ["000", "001", "001", "001"]
        //    }, "车箱2": {
        //      "A" : ["000", "001", "001", "001"],
        //      "B" : ["000", "001", "001", "001"],
        //      "C" : ["000", "001", "001", "001"],
        //      "D" : ["000", "001", "001", "001"],
        //      "D" : ["000", "001", "001", "001"]
        //    }
        // }
        let train = ref({});
    
        // 查询一列火车的所有车站
        const querySeat = () => {
          axios.get("/business/seat-sell/query", {
            params: {
              date: param.value.date,
              trainCode: param.value.trainCode,
            }
          }).then((response) => {
            let data = response.data;
            if (data.success) {
              list.value = data.content;
              format();
            } else {
              notification.error({description: data.message});
            }
          });
        };
    
        /**
         * 截取出当前区间的销售信息,并判断是否有票
         */
        const format = () => {
          let _train = {};
    
          for (let i = 0; i < list.value.length; i++) {
            let item = list.value[i];
    
            // 计算当前区间是否还有票,约定:站序是从0开始
            let sellDB = item.sell;
    
            // 假设6站:start = 1, end = 3, sellDB = 11111,最终得到:sell = 01110,转int 1100,不可买
            // 假设6站:start = 1, end = 3, sellDB = 11011,最终得到:sell = 01010,转int 1000,不可买
            // 假设6站:start = 1, end = 3, sellDB = 10001,最终得到:sell = 00000,转int 0,可买
            // 验证代码:
            // let sellDB = "123456789";
            // let start = 1;
            // let end = 3;
            // let sell = sellDB.substr(start, end - start)
            // console.log(sell)
            let sell = sellDB.substr(param.value.startIndex, param.value.endIndex - param.value.startIndex);
            // console.log("完整的销卖信息:", sellDB, "区间内的销卖信息", sell);
    
            // 将sell放入火车数据中
            if (!_train["车箱" + item.carriageIndex]) {
              _train["车箱" + item.carriageIndex] = {};
            }
            if (!_train["车箱" + item.carriageIndex][item.col]) {
              _train["车箱" + item.carriageIndex][item.col] = [];
            }
            _train["车箱" + item.carriageIndex][item.col].push(parseInt(sell));
          }
    
          train.value = _train;
        }
    
        onMounted(() => {
          if (param.value.date) {
            querySeat();
          }
        });
    
        return {
          param,
          train
        };
      },
    });
    </script>
    
  • 测试效果

在这里插入图片描述

三、只允许购买两周内的车次

  • ticket.vue

    <template>
      <p>
        <a-space>
          <a-date-picker v-model:value="params.date" valueFormat="YYYY-MM-DD" :disabled-date="disabledDate" placeholder="请选择日期"></a-date-picker>
          <station-select-view v-model="params.start" width="200px"></station-select-view>
          <station-select-view v-model="params.end" width="200px"></station-select-view>
          <a-button type="primary" @click="handleQuery()">查找</a-button>
        </a-space>
      </p>
      <a-table :dataSource="dailyTrainTickets"
               :columns="columns"
               :pagination="pagination"
               @change="handleTableChange"
               :loading="loading">
        <template #bodyCell="{ column, record }">
          <template v-if="column.dataIndex === 'operation'">
            <a-space>
              <a-button type="primary" @click="toOrder(record)" :disabled="isExpire(record)">{{isExpire(record) ? "过期" : "预订"}}</a-button>
              <router-link :to="{
                path: '/seat',
                query: {
                  date: record.date,
                  trainCode: record.trainCode,
                  start: record.start,
                  startIndex: record.startIndex,
                  end: record.end,
                  endIndex: record.endIndex
                }
              }">
                <a-button type="primary">座位销售图</a-button>
              </router-link>
              <a-button type="primary" @click="showStation(record)">途经车站</a-button>
            </a-space>
          </template>
          <template v-else-if="column.dataIndex === 'station'">
            {{record.start}}<br/>
            {{record.end}}
          </template>
          <template v-else-if="column.dataIndex === 'time'">
            {{record.startTime}}<br/>
            {{record.endTime}}
          </template>
          <template v-else-if="column.dataIndex === 'duration'">
            {{calDuration(record.startTime, record.endTime)}}<br/>
            <div v-if="record.startTime.replaceAll(':', '') >= record.endTime.replaceAll(':', '')">
              次日到达
            </div>
            <div v-else>
              当日到达
            </div>
          </template>
          <template v-else-if="column.dataIndex === 'ydz'">
            <div v-if="record.ydz >= 0">
              {{record.ydz}}<br/>
              {{record.ydzPrice}}¥
            </div>
            <div v-else>
              --
            </div>
          </template>
          <template v-else-if="column.dataIndex === 'edz'">
            <div v-if="record.edz >= 0">
              {{record.edz}}<br/>
              {{record.edzPrice}}¥
            </div>
            <div v-else>
              --
            </div>
          </template>
          <template v-else-if="column.dataIndex === 'rw'">
            <div v-if="record.rw >= 0">
              {{record.rw}}<br/>
              {{record.rwPrice}}¥
            </div>
            <div v-else>
              --
            </div>
          </template>
          <template v-else-if="column.dataIndex === 'yw'">
            <div v-if="record.yw >= 0">
              {{record.yw}}<br/>
              {{record.ywPrice}}¥
            </div>
            <div v-else>
              --
            </div>
          </template>
        </template>
      </a-table>
    
      <!-- 途经车站 -->
      <a-modal style="top: 30px" v-model:visible="visible" :title="null" :footer="null" :closable="false">
        <a-table :data-source="stations" :pagination="false">
          <a-table-column key="index" title="站序" data-index="index" />
          <a-table-column key="name" title="站名" data-index="name" />
          <a-table-column key="inTime" title="进站时间" data-index="inTime">
            <template #default="{ record }">
              {{record.index === 0 ? '-' : record.inTime}}
            </template>
          </a-table-column>
          <a-table-column key="outTime" title="出站时间" data-index="outTime">
            <template #default="{ record }">
              {{record.index === (stations.length - 1) ? '-' : record.outTime}}
            </template>
          </a-table-column>
          <a-table-column key="stopTime" title="停站时长" data-index="stopTime">
            <template #default="{ record }">
              {{record.index === 0 || record.index === (stations.length - 1) ? '-' : record.stopTime}}
            </template>
          </a-table-column>
        </a-table>
      </a-modal>
    </template>
    
    <script>
    import { defineComponent, ref, onMounted } from 'vue';
    import {notification} from "ant-design-vue";
    import axios from "axios";
    import StationSelectView from "@/components/station-select";
    import dayjs from "dayjs";
    import router from "@/router";
    
    export default defineComponent({
      name: "ticket-view",
      components: {StationSelectView},
      setup() {
        const visible = ref(false);
        let dailyTrainTicket = ref({
          id: undefined,
          date: undefined,
          trainCode: undefined,
          start: undefined,
          startPinyin: undefined,
          startTime: undefined,
          startIndex: undefined,
          end: undefined,
          endPinyin: undefined,
          endTime: undefined,
          endIndex: undefined,
          ydz: undefined,
          ydzPrice: undefined,
          edz: undefined,
          edzPrice: undefined,
          rw: undefined,
          rwPrice: undefined,
          yw: undefined,
          ywPrice: undefined,
          createTime: undefined,
          updateTime: undefined,
        });
        const dailyTrainTickets = ref([]);
        // 分页的三个属性名是固定的
        const pagination = ref({
          total: 0,
          current: 1,
          pageSize: 10,
        });
        let loading = ref(false);
        const params = ref({});
        const columns = [
          {
            title: '车次编号',
            dataIndex: 'trainCode',
            key: 'trainCode',
          },
          {
            title: '车站',
            dataIndex: 'station',
          },
          {
            title: '时间',
            dataIndex: 'time',
          },
          {
            title: '历时',
            dataIndex: 'duration',
          },
          {
            title: '一等座',
            dataIndex: 'ydz',
            key: 'ydz',
          },
          {
            title: '二等座',
            dataIndex: 'edz',
            key: 'edz',
          },
          {
            title: '软卧',
            dataIndex: 'rw',
            key: 'rw',
          },
          {
            title: '硬卧',
            dataIndex: 'yw',
            key: 'yw',
          },
          {
            title: '操作',
            dataIndex: 'operation',
          },
        ];
    
    
        const handleQuery = (param) => {
          if (Tool.isEmpty(params.value.date)) {
            notification.error({description: "请输入日期"});
            return;
          }
          if (Tool.isEmpty(params.value.start)) {
            notification.error({description: "请输入出发地"});
            return;
          }
          if (Tool.isEmpty(params.value.end)) {
            notification.error({description: "请输入目的地"});
            return;
          }
          if (!param) {
            param = {
              page: 1,
              size: pagination.value.pageSize
            };
          }
    
          // 保存查询参数
          SessionStorage.set(SESSION_TICKET_PARAMS, params.value);
    
          loading.value = true;
          axios.get("/business/daily-train-ticket/query-list", {
            params: {
              page: param.page,
              size: param.size,
              trainCode: params.value.trainCode,
              date: params.value.date,
              start: params.value.start,
              end: params.value.end
            }
          }).then((response) => {
            loading.value = false;
            let data = response.data;
            if (data.success) {
              dailyTrainTickets.value = data.content.list;
              // 设置分页控件的值
              pagination.value.current = param.page;
              pagination.value.total = data.content.total;
            } else {
              notification.error({description: data.message});
            }
          });
        };
    
        const handleTableChange = (page) => {
          // console.log("看看自带的分页参数都有啥:" + JSON.stringify(page));
          pagination.value.pageSize = page.pageSize;
          handleQuery({
            page: page.current,
            size: page.pageSize
          });
        };
    
        const calDuration = (startTime, endTime) => {
          let diff = dayjs(endTime, 'HH:mm:ss').diff(dayjs(startTime, 'HH:mm:ss'), 'seconds');
          return dayjs('00:00:00', 'HH:mm:ss').second(diff).format('HH:mm:ss');
        };
    
        const toOrder = (record) => {
          dailyTrainTicket.value = Tool.copy(record);
          SessionStorage.set(SESSION_ORDER, dailyTrainTicket.value);
          router.push("/order")
        };
    
        // ---------------------- 途经车站 ----------------------
        const stations = ref([]);
        const showStation = record => {
          visible.value = true;
          axios.get("/business/daily-train-station/query-by-train-code", {
            params: {
              date: record.date,
              trainCode: record.trainCode
            }
          }).then((response) => {
            let data = response.data;
            if (data.success) {
              stations.value = data.content;
            } else {
              notification.error({description: data.message});
            }
          });
        };
    
        // 不能选择今天以前及两周以后的日期
        const disabledDate = current => {
          return current && (current <= dayjs().add(-1, 'day') || current > dayjs().add(14, 'day'));
        };
    
        // 判断是否过期
        const isExpire = (record) => {
          // 标准时间:2000/01/01 00:00:00
          let startDateTimeString = record.date.replace(/-/g, "/") + " " + record.startTime;
          let startDateTime = new Date(startDateTimeString);
    
          //当前时间
          let now = new Date();
    
          console.log(startDateTime)
          return now.valueOf() >= startDateTime.valueOf();
        };
    
        onMounted(() => {
          //  "|| {}"是常用技巧,可以避免空指针异常
          params.value = SessionStorage.get(SESSION_TICKET_PARAMS) || {};
          if (Tool.isNotEmpty(params.value)) {
            handleQuery({
              page: 1,
              size: pagination.value.pageSize
            });
          }
        });
    
        return {
          dailyTrainTicket,
          visible,
          dailyTrainTickets,
          pagination,
          columns,
          handleTableChange,
          handleQuery,
          loading,
          params,
          calDuration,
          toOrder,
          showStation,
          stations,
          disabledDate,
          isExpire
        };
      },
    });
    </script>
    
  • 效果

在这里插入图片描述

;