本文参考自
Springboot3+微服务实战12306高性能售票系统 - 慕课网 (imooc.com)
本文是仿12306项目实战第(三)章——项目优化,本篇将讲解该项目最后的优化部分以及一些压测知识点
本章目录
- 一、压力测试-高并发优化前后的性能对比
- 1.压力测试相关概念讲解
- 2.JMeter压测
- 3.将mq去除,改用成springboot自带的异步
- 二、项目功能优化
- 1.购票页面增加取消排队的功能
- 2.**余票查询页面增加显示车站信息**
- 3.购票页面增加发起多人排队功能
- 4.增加座位销售图
- 1.增加查询座位销售详情接口
- 2.增加座位销售图路由及页面,实现页面跳转和参数传递
- 3.座位销售图页面获得销售信息,同一趟车,不管查哪个区间,查到的销售信息是一样的,由界面再去截取区间的销售信息。功能设计经验:对于复杂的操作,能放到前端的都放到前端,减小后端的压力。
- 4.显示各车厢各座位的销售详情,使用橙色灰色代码座位可卖与卖出
- 三、只允许购买两周内的车次
一、压力测试-高并发优化前后的性能对比
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> <span class="order-train-main">{{dailyTrainTicket.trainCode}}</span>次 <span class="order-train-main">{{dailyTrainTicket.start}}</span>站 <span class="order-train-main">({{dailyTrainTicket.startTime}})</span> <span class="order-train-main">——</span> <span class="order-train-main">{{dailyTrainTicket.end}}</span>站 <span class="order-train-main">({{dailyTrainTicket.endTime}})</span> <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> <span class="order-train-ticket-main">{{item.count}}</span> 张票 </span> </div> </div> <a-divider></a-divider> <b>勾选要购票的乘客:</b> <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> <span class="order-train-main">{{dailyTrainTicket.trainCode}}</span>次 <span class="order-train-main">{{dailyTrainTicket.start}}</span>站 <span class="order-train-main">({{dailyTrainTicket.startTime}})</span> <span class="order-train-main">——</span> <span class="order-train-main">{{dailyTrainTicket.end}}</span>站 <span class="order-train-main">({{dailyTrainTicket.endTime}})</span> <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> <span class="order-train-ticket-main">{{item.count}}</span> 张票 </span> </div> </div> <a-divider></a-divider> <b>勾选要购票的乘客:</b> <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>
-
效果