This commit is contained in:
2026-08-18 10:22:01 +08:00
parent 9208bf06e6
commit a9a5502d17
15 changed files with 436 additions and 151 deletions
@@ -3,6 +3,7 @@ package io.v.nutz.zhgh.coffeeTicket.controller;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import io.v.nutz.base.result.Result;
import io.v.nutz.base.utils.Html2Text;
import io.v.nutz.zhgh.coffeeTicket.models.CoffeeTicketActivity;
import io.v.nutz.zhgh.coffeeTicket.service.CoffeeTicketActivityService;
import org.nutz.aop.interceptor.ioc.TransAop;
@@ -36,7 +37,7 @@ public class CoffeeTicketNewController {
/**
* 保存咖啡抢票活动。
*
* @param activity 页面提交的活动配置,包含标题、封面、描述、活动开始/结束日期、可预约星期、活动总票数、每人每次可抢票数、每日预约时间段、可报名人员范围、预约方式、活动地点和参与须知
* @param activity 页面提交的活动配置,包含标题、封面、描述、活动开始/结束日期、可预约星期、票池模式、票池数量、每人每次可抢票数、每日预约时间段、可报名人员范围、预约方式、活动地点和参与须知
* @return Result,成功时 data 为 CoffeeTicketActivity,可通过 data.id 获取活动ID
*/
@At
@@ -68,7 +69,8 @@ public class CoffeeTicketNewController {
if (StrUtil.isBlank(activity.getCover())) {
return Result.error("请上传活动封面");
}
if (StrUtil.isBlank(activity.getDescription())) {
// 富文本空编辑器会提交 p、br 等标签,必须按去除 HTML 后的实际文本判断必填内容。
if (StrUtil.isBlank(Html2Text.toPlainText(activity.getDescription()))) {
return Result.error("请输入活动描述");
}
if (activity.getActivityStartDate() == null || activity.getActivityEndDate() == null) {
@@ -80,14 +82,21 @@ public class CoffeeTicketNewController {
if (CollUtil.isEmpty(activity.getWeekDays())) {
return Result.error("请选择可预约星期");
}
if (StrUtil.isNotBlank(activity.getTicketMode())
&& !CoffeeTicketActivity.TICKET_MODE_TOTAL.equals(activity.getTicketMode())
&& !CoffeeTicketActivity.TICKET_MODE_DAILY.equals(activity.getTicketMode())) {
return Result.error("请选择有效的票池模式");
}
if (activity.getTicketCount() == null || activity.getTicketCount() <= 0) {
return Result.error("活动总票数必须大于0");
return Result.error(CoffeeTicketActivity.TICKET_MODE_DAILY.equals(activity.getTicketMode())
? "每日票数必须大于0" : "活动总票数必须大于0");
}
if (activity.getPerUserTicketNum() == null || activity.getPerUserTicketNum() <= 0) {
return Result.error("每人每次可抢票数必须大于0");
}
if (activity.getPerUserTicketNum() > activity.getTicketCount()) {
return Result.error("每人每次可抢票数不能大于活动总票数");
return Result.error(CoffeeTicketActivity.TICKET_MODE_DAILY.equals(activity.getTicketMode())
? "每人每天可抢票数不能大于每日票数" : "每人每次可抢票数不能大于活动总票数");
}
if (StrUtil.isBlank(activity.getReserveStartTime()) || StrUtil.isBlank(activity.getReserveEndTime())) {
return Result.error("请选择每日可预约时间段");
@@ -1,12 +1,9 @@
package io.v.nutz.zhgh.coffeeTicket.h5controller;
import cn.hutool.core.bean.BeanUtil;
import io.v.nutz.base.result.Result;
import io.v.nutz.web.commons.utils.ShiroUtil;
import io.v.nutz.zhgh.coffeeTicket.models.CoffeeTicketActivity;
import io.v.nutz.zhgh.coffeeTicket.models.CoffeeTicketRecord;
import io.v.nutz.zhgh.coffeeTicket.service.CoffeeTicketActivityService;
import io.v.nutz.zhgh.coffeeTicket.service.CoffeeTicketRecordService;
import io.v.nutz.zhgh.coffeeTicket.vo.CoffeeTicketActivityH5DetailVO;
import org.apache.shiro.authz.annotation.RequiresAuthentication;
import org.nutz.aop.interceptor.ioc.TransAop;
@@ -16,7 +13,6 @@ import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
import org.nutz.mvc.annotation.Param;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import javax.validation.Valid;
@@ -28,9 +24,6 @@ public class H5CoffeeTicketDetailController {
@Inject
private CoffeeTicketActivityService coffeeTicketActivityService;
@Inject
private CoffeeTicketRecordService coffeeTicketRecordService;
/**
* 打开移动端咖啡抢票活动详情页面。
*/
@@ -49,23 +42,12 @@ public class H5CoffeeTicketDetailController {
@At
@RequiresAuthentication
public Result findOne(@Valid String id) {
CoffeeTicketActivity activity = coffeeTicketActivityService.fetch(id);
if (activity == null) {
return Result.error("活动不存在");
try {
CoffeeTicketActivityH5DetailVO detailVO = coffeeTicketActivityService.h5DetailData(id, ShiroUtil.getUserId());
return Result.success(detailVO);
} catch (IllegalStateException e) {
return Result.error(e.getMessage());
}
if (!Boolean.TRUE.equals(activity.getEnabled())) {
return Result.error("活动未开启");
}
String scopeMsg = coffeeTicketActivityService.checkH5ActivityUserScope(activity, ShiroUtil.getUserId());
if (scopeMsg != null) {
return Result.error(scopeMsg);
}
CoffeeTicketActivityH5DetailVO detailVO = BeanUtil.copyProperties(activity, CoffeeTicketActivityH5DetailVO.class);
CoffeeTicketRecord userRecord = coffeeTicketRecordService.fetchLatestUserRecord(id, ShiroUtil.getUserId());
detailVO.setHasGrabbed(userRecord != null);
detailVO.setMyTicketNum(userRecord == null ? 0 : coffeeTicketRecordService.countUserTicketNum(id, ShiroUtil.getUserId()));
detailVO.setMyGrabTime(userRecord == null ? null : userRecord.getGrabTime());
return Result.success(detailVO);
}
/**
@@ -25,6 +25,9 @@ import java.util.List;
@TableMeta("{'mysql-charset':'utf8mb4'}")
@Comment("咖啡抢票活动表")
public class CoffeeTicketActivity extends BaseModel implements Serializable, SysHomeConvert {
public static final String TICKET_MODE_TOTAL = "TOTAL";
public static final String TICKET_MODE_DAILY = "DAILY";
@Name
@Comment("ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
@@ -62,7 +65,12 @@ public class CoffeeTicketActivity extends BaseModel implements Serializable, Sys
private List<String> weekDays;
@Column
@Comment("活动总票数")
@Comment("票池模式:TOTAL整场总票池,DAILY每日票池")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String ticketMode;
@Column
@Comment("票池数量:TOTAL为活动总票数,DAILY为每日票数")
@ColDefine(type = ColType.INT)
private Integer ticketCount;
@@ -21,11 +21,13 @@ import java.util.Date;
@Data
@Table("coffee_ticket_record")
@TableIndexes({
@Index(name = "UK_COFFEE_TICKET_RECORD_ACTIVITY_USER", fields = {"activityId", "userId"}, unique = true)
@Index(name = "UK_COFFEE_TICKET_RECORD_ACTIVITY_USER_QUOTA", fields = {"activityId", "userId", "quotaKey"}, unique = true)
})
@TableMeta("{'mysql-charset':'utf8mb4'}")
@Comment("咖啡抢票记录表")
public class CoffeeTicketRecord extends BaseModel implements Serializable {
public static final String QUOTA_KEY_TOTAL = "TOTAL";
@Name
@Comment("ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
@@ -52,6 +54,11 @@ public class CoffeeTicketRecord extends BaseModel implements Serializable {
@ColDefine(type = ColType.DATETIME)
private Date grabTime;
@Column
@Comment("票池标识:整场票池为TOTAL,每日票池为yyyy-MM-dd")
@ColDefine(type = ColType.VARCHAR, width = 10)
private String quotaKey;
@Column
@Comment("用户ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
@@ -5,6 +5,7 @@ import io.v.nutz.base.page.Pagination;
import io.v.nutz.base.query.PageForm;
import io.v.nutz.zhgh.coffeeTicket.models.CoffeeTicketActivity;
import io.v.nutz.zhgh.coffeeTicket.models.CoffeeTicketRecord;
import io.v.nutz.zhgh.coffeeTicket.vo.CoffeeTicketActivityH5DetailVO;
import java.util.List;
@@ -13,7 +14,7 @@ public interface CoffeeTicketActivityService extends BaseService<CoffeeTicketAct
/**
* 保存咖啡抢票活动基础配置。
*
* @param activity 页面提交的活动配置,包含标题、封面、活动日期、预约星期、活动总票数、每人每次可抢票数、每日预约时间段、地点和须知等内容
* @param activity 页面提交的活动配置,包含标题、封面、活动日期、预约星期、票池模式、票池数量、每人每次可抢票数、每日预约时间段、地点和须知等内容
* @return CoffeeTicketActivity 保存后的活动实体,新增时会带有生成后的活动ID
*/
CoffeeTicketActivity saveActivity(CoffeeTicketActivity activity);
@@ -42,6 +43,15 @@ public interface CoffeeTicketActivityService extends BaseService<CoffeeTicketAct
*/
Pagination h5PageData(PageForm pageForm, String status);
/**
* 查询手机端活动详情,并按票池模式组装当前票池和当前用户抢票状态。
*
* @param id 活动ID,必须传 coffee_ticket_activity 表中的活动主键
* @param userId 当前登录用户ID,用于校验人员范围并统计当前用户在当前票池的抢票记录
* @return CoffeeTicketActivityH5DetailVO,包含活动详情、当前票池票数及当前用户抢票状态
*/
CoffeeTicketActivityH5DetailVO h5DetailData(String id, String userId);
/**
* 查询统计页面年度活动下拉列表。
*
@@ -54,7 +64,7 @@ public interface CoffeeTicketActivityService extends BaseService<CoffeeTicketAct
* 校验手机端抢票请求是否符合活动开放规则。
*
* @param id 活动ID,必须传 coffee_ticket_activity 表中的活动主键
* @param ticketNum 本次抢票张数,手机端传步进器所选数量,用于校验不超过活动配置的每人每次上限且活动余票充足
* @param ticketNum 本次抢票张数,手机端传步进器所选数量,用于校验不超过活动配置的每人每次上限
* @return String,返回 null 表示校验通过;返回非空字符串表示不可抢票的具体原因
*/
String checkH5GrabTicket(String id, Integer ticketNum);
@@ -12,48 +12,61 @@ import java.util.List;
public interface CoffeeTicketRecordService extends BaseService<CoffeeTicketRecord> {
/**
* 统计指定活动已经占用的票数。
* 统计指定活动当前票池已经占用的票数。
*
* @param activityId 活动ID,必须传 coffee_ticket_activity 表中的活动主键
* @return int,返回 coffee_ticket_record 中该活动 ticketNum 的合计值,无记录时返回 0
* @param quotaKey 票池标识,整场总票池传 TOTAL,每日票池传 yyyy-MM-dd
* @return int,返回当前票池中 ticketNum 的合计值,无记录时返回 0TOTAL 会兼容 quotaKey 为空的历史记录
*/
int countGrabbedTicketNum(String activityId);
int countGrabbedTicketNum(String activityId, String quotaKey);
/**
* 判断用户是否已经抢过指定活动的票。
* 判断用户是否已经抢过指定活动当前票池的票。
*
* @param activityId 活动ID
* @param userId 当前登录用户ID
* @return booleantrue 表示该用户已有该活动抢票记录
* @param quotaKey 票池标识,整场总票池传 TOTAL,每日票池传 yyyy-MM-dd
* @return booleantrue 表示该用户已有当前票池抢票记录
*/
boolean hasUserGrabbed(String activityId, String userId);
boolean hasUserGrabbed(String activityId, String userId, String quotaKey);
/**
* 查询用户在指定活动下最近一次抢票记录。
* 查询用户在指定活动当前票池下最近一次抢票记录。
*
* @param activityId 活动ID
* @param userId 当前登录用户ID
* @param quotaKey 票池标识,整场总票池传 TOTAL,每日票池传 yyyy-MM-dd
* @return CoffeeTicketRecord,返回最近一次抢票记录;没有记录时返回 null
*/
CoffeeTicketRecord fetchLatestUserRecord(String activityId, String userId);
CoffeeTicketRecord fetchLatestUserRecord(String activityId, String userId, String quotaKey);
/**
* 统计用户在指定活动下已抢票数。
* 统计用户在指定活动当前票池下已抢票数。
*
* @param activityId 活动ID
* @param userId 当前登录用户ID
* @return int,返回该用户在当前活动下 ticketNum 的合计值,无记录时返回 0
* @param quotaKey 票池标识,整场总票池传 TOTAL,每日票池传 yyyy-MM-dd
* @return int,返回该用户在当前票池下 ticketNum 的合计值,无记录时返回 0
*/
int countUserTicketNum(String activityId, String userId);
int countUserTicketNum(String activityId, String userId, String quotaKey);
/**
* 判断活动是否已经产生抢票记录,用于阻止活动在产生记录后切换票池模式。
*
* @param activityId 活动ID,必须传 coffee_ticket_activity 表中的活动主键
* @return booleantrue 表示活动至少存在一条抢票记录
*/
boolean hasActivityRecord(String activityId);
/**
* 保存手机端抢票记录。
*
* @param activity 已通过规则校验的活动实体,用于回填活动ID和标题
* @param ticketNum 本次抢票张数,当前 H5 详情页默认传 1
* @param quotaKey 票池标识,整场总票池传 TOTAL,每日票池传 yyyy-MM-dd
* @return CoffeeTicketRecord,返回已经写入数据库的抢票记录实体
*/
CoffeeTicketRecord saveGrabRecord(CoffeeTicketActivity activity, Integer ticketNum);
CoffeeTicketRecord saveGrabRecord(CoffeeTicketActivity activity, Integer ticketNum, String quotaKey);
/**
* 手机端分页查询当前用户的抢票记录。
@@ -13,6 +13,7 @@ import io.v.nutz.zhgh.coffeeTicket.models.CoffeeTicketActivity;
import io.v.nutz.zhgh.coffeeTicket.models.CoffeeTicketRecord;
import io.v.nutz.zhgh.coffeeTicket.service.CoffeeTicketActivityService;
import io.v.nutz.zhgh.coffeeTicket.service.CoffeeTicketRecordService;
import io.v.nutz.zhgh.coffeeTicket.vo.CoffeeTicketActivityH5DetailVO;
import io.v.nutz.zhgh.coffeeTicket.vo.CoffeeTicketActivityH5ListVO;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Cnd;
@@ -42,10 +43,20 @@ public class CoffeeTicketActivityServiceImpl extends BaseServiceImpl<CoffeeTicke
@Override
@Aop(TransAop.READ_COMMITTED)
public CoffeeTicketActivity saveActivity(CoffeeTicketActivity activity) {
CoffeeTicketActivity existingActivity = StrUtil.isBlank(activity.getId()) ? null : fetch(activity.getId());
String ticketMode = StrUtil.isBlank(activity.getTicketMode()) && existingActivity != null
? normalizeTicketMode(existingActivity)
: normalizeTicketMode(activity);
if (existingActivity != null
&& !normalizeTicketMode(existingActivity).equals(ticketMode)
&& coffeeTicketRecordService.hasActivityRecord(existingActivity.getId())) {
throw new IllegalStateException("活动已产生抢票记录,不能切换票池模式");
}
if (activity.getActivityGroupId() == null
|| dao().count(ActivityUserScope.class, Cnd.where("groupId", "=", activity.getActivityGroupId())) == 0) {
throw new IllegalStateException("请选择有效的可报名人员范围");
}
activity.setTicketMode(ticketMode);
if (activity.getEnabled() == null) {
activity.setEnabled(false);
}
@@ -108,6 +119,31 @@ public class CoffeeTicketActivityServiceImpl extends BaseServiceImpl<CoffeeTicke
return pagination;
}
@Override
public CoffeeTicketActivityH5DetailVO h5DetailData(String id, String userId) {
CoffeeTicketActivity activity = fetch(id);
if (activity == null) {
throw new IllegalStateException("活动不存在");
}
if (!Boolean.TRUE.equals(activity.getEnabled())) {
throw new IllegalStateException("活动未开启");
}
String scopeMsg = checkH5ActivityUserScope(activity, userId);
if (scopeMsg != null) {
throw new IllegalStateException(scopeMsg);
}
Date now = new Date();
String quotaKey = resolveQuotaKey(activity, now);
CoffeeTicketActivityH5DetailVO detailVO = BeanUtil.copyProperties(activity, CoffeeTicketActivityH5DetailVO.class);
detailVO.setTicketMode(normalizeTicketMode(activity));
fillDetailQuota(detailVO, activity, quotaKey, now);
CoffeeTicketRecord userRecord = coffeeTicketRecordService.fetchLatestUserRecord(id, userId, quotaKey);
detailVO.setHasGrabbed(userRecord != null);
detailVO.setMyTicketNum(userRecord == null ? 0 : coffeeTicketRecordService.countUserTicketNum(id, userId, quotaKey));
detailVO.setMyGrabTime(userRecord == null ? null : userRecord.getGrabTime());
return detailVO;
}
@Override
public List<CoffeeTicketActivity> statisticsActivityList(Integer year) {
Cnd cnd = Cnd.NEW();
@@ -126,6 +162,18 @@ public class CoffeeTicketActivityServiceImpl extends BaseServiceImpl<CoffeeTicke
return "活动不存在";
}
int currentTicketNum = ticketNum == null ? getPerUserTicketNum(activity) : ticketNum;
return checkH5GrabTicket(activity, currentTicketNum, new Date());
}
/**
* 校验活动在指定时刻是否允许当前用户按给定数量抢票,供公开预校验和加锁后的正式抢票共用。
*
* @param activity 当前活动实体
* @param currentTicketNum 本次抢票张数,必须大于0且不超过单次上限
* @param now 本次规则判断使用的统一时间,避免跨午夜时星期和配额日期不一致
* @return Stringnull 表示通过,非空表示不可抢原因
*/
private String checkH5GrabTicket(CoffeeTicketActivity activity, int currentTicketNum, Date now) {
if (currentTicketNum <= 0) {
return "抢票张数必须大于0";
}
@@ -136,7 +184,6 @@ public class CoffeeTicketActivityServiceImpl extends BaseServiceImpl<CoffeeTicke
if (scopeMsg != null) {
return scopeMsg;
}
Date now = new Date();
if (activity.getActivityStartDate() == null || activity.getActivityEndDate() == null) {
return "活动周期未配置";
}
@@ -193,24 +240,26 @@ public class CoffeeTicketActivityServiceImpl extends BaseServiceImpl<CoffeeTicke
}
try {
lockActivity(id);
String errorMsg = checkH5GrabTicket(id, ticketNum);
CoffeeTicketActivity activity = fetch(id);
int currentTicketNum = ticketNum == null ? getPerUserTicketNum(activity) : ticketNum;
Date now = new Date();
String errorMsg = checkH5GrabTicket(activity, currentTicketNum, now);
if (errorMsg != null) {
throw new IllegalStateException(errorMsg);
}
CoffeeTicketActivity activity = fetch(id);
int currentTicketNum = ticketNum == null ? getPerUserTicketNum(activity) : ticketNum;
if (coffeeTicketRecordService.hasUserGrabbed(activity.getId(), ShiroUtil.getUserId())) {
throw new IllegalStateException("您已抢过该活动票");
String quotaKey = resolveQuotaKey(activity, now);
if (coffeeTicketRecordService.hasUserGrabbed(activity.getId(), ShiroUtil.getUserId(), quotaKey)) {
throw new IllegalStateException(isDailyMode(activity) ? "您今天已抢过该活动票" : "您已抢过该活动票");
}
int grabbedTicketNum = coffeeTicketRecordService.countGrabbedTicketNum(activity.getId());
int grabbedTicketNum = coffeeTicketRecordService.countGrabbedTicketNum(activity.getId(), quotaKey);
int remainingTicketNum = activity.getTicketCount() - grabbedTicketNum;
if (remainingTicketNum < currentTicketNum) {
throw new IllegalStateException("票数不足");
}
return coffeeTicketRecordService.saveGrabRecord(activity, currentTicketNum);
return coffeeTicketRecordService.saveGrabRecord(activity, currentTicketNum, quotaKey);
} catch (DaoException e) {
if (isDuplicateGrabException(e)) {
throw new IllegalStateException("您已抢过活动票");
throw new IllegalStateException("您已抢过当前票池的活动票");
}
if (isGrabLockException(e)) {
throw new IllegalStateException("当前抢票人数较多,请稍后重试");
@@ -234,21 +283,98 @@ public class CoffeeTicketActivityServiceImpl extends BaseServiceImpl<CoffeeTicke
}
/**
* 组装手机端列表展示对象,补齐活动总票数、已抢票数和剩余票数字段
* 组装手机端列表展示对象,按票池模式补齐当前票池数量、已抢票数和剩余票数。
*
* @param activity 当前页活动实体
* @return CoffeeTicketActivityH5ListVO,包含活动基础字段和票数统计字段
*/
private CoffeeTicketActivityH5ListVO buildH5ListVO(CoffeeTicketActivity activity) {
CoffeeTicketActivityH5ListVO vo = BeanUtil.copyProperties(activity, CoffeeTicketActivityH5ListVO.class);
Date now = new Date();
String quotaKey = resolveQuotaKey(activity, now);
int totalTicketNum = activity.getTicketCount() == null ? 0 : activity.getTicketCount();
int grabbedTicketNum = coffeeTicketRecordService.countGrabbedTicketNum(activity.getId());
int grabbedTicketNum = coffeeTicketRecordService.countGrabbedTicketNum(activity.getId(), quotaKey);
vo.setTicketMode(normalizeTicketMode(activity));
vo.setTotalTicketNum(totalTicketNum);
vo.setGrabbedTicketNum(grabbedTicketNum);
vo.setRemainingTicketNum(Math.max(totalTicketNum - grabbedTicketNum, 0));
vo.setQuotaAvailableToday(isQuotaAvailableToday(activity, now));
return vo;
}
/**
* 为手机端详情补齐当前票池统计。每日模式按当天配额统计,整场模式统计活动全部记录。
*
* @param detailVO 手机端详情返回对象
* @param activity 当前活动实体
* @param quotaKey 当前票池标识
* @param now 本次详情查询时间
*/
private void fillDetailQuota(CoffeeTicketActivityH5DetailVO detailVO, CoffeeTicketActivity activity,
String quotaKey, Date now) {
int totalTicketNum = activity.getTicketCount() == null ? 0 : activity.getTicketCount();
int grabbedTicketNum = coffeeTicketRecordService.countGrabbedTicketNum(activity.getId(), quotaKey);
detailVO.setTotalTicketNum(totalTicketNum);
detailVO.setGrabbedTicketNum(grabbedTicketNum);
detailVO.setRemainingTicketNum(Math.max(totalTicketNum - grabbedTicketNum, 0));
detailVO.setQuotaAvailableToday(isQuotaAvailableToday(activity, now));
}
/**
* 统一历史活动票池模式。未配置或非法值均按整场总票池处理,避免老数据行为改变。
*
* @param activity 当前活动实体
* @return StringTOTAL 或 DAILY
*/
private String normalizeTicketMode(CoffeeTicketActivity activity) {
if (activity != null && CoffeeTicketActivity.TICKET_MODE_DAILY.equals(activity.getTicketMode())) {
return CoffeeTicketActivity.TICKET_MODE_DAILY;
}
return CoffeeTicketActivity.TICKET_MODE_TOTAL;
}
/**
* 根据活动票池模式生成本次统计和唯一约束使用的配额标识。
*
* @param activity 当前活动实体
* @param now 本次业务处理时间
* @return String,整场模式返回 TOTAL,每日模式返回 yyyy-MM-dd
*/
private String resolveQuotaKey(CoffeeTicketActivity activity, Date now) {
if (isDailyMode(activity)) {
return DateUtil.format(now, "yyyy-MM-dd");
}
return CoffeeTicketRecord.QUOTA_KEY_TOTAL;
}
/**
* 判断今天是否位于活动周期且命中可抢票星期,仅表示日历范围有效,不包含每日抢票时段判断。
*
* @param activity 当前活动实体
* @param now 当前时间
* @return booleantrue 表示今天存在该活动票池
*/
private boolean isQuotaAvailableToday(CoffeeTicketActivity activity, Date now) {
if (activity == null || activity.getActivityStartDate() == null || activity.getActivityEndDate() == null) {
return false;
}
if (now.before(DateUtil.beginOfDay(activity.getActivityStartDate()))
|| now.after(DateUtil.endOfDay(activity.getActivityEndDate()))) {
return false;
}
return activity.getWeekDays() != null && activity.getWeekDays().contains(getTodayWeekValue(now));
}
/**
* 判断活动是否使用每日独立票池。
*
* @param activity 当前活动实体
* @return booleantrue 表示每日票池
*/
private boolean isDailyMode(CoffeeTicketActivity activity) {
return CoffeeTicketActivity.TICKET_MODE_DAILY.equals(normalizeTicketMode(activity));
}
/**
* 读取活动的单人单次抢票数量;历史活动尚未配置该字段时按 1 张兼容。
*
@@ -316,15 +442,15 @@ public class CoffeeTicketActivityServiceImpl extends BaseServiceImpl<CoffeeTicke
}
/**
* 数据库唯一索引用于兜底防止同一用户并发重复抢同一活动。
* 数据库唯一索引用于兜底防止同一用户并发重复抢同一活动的同一票池
*
* @param e 抢票记录插入时抛出的数据库异常
* @return booleantrue 表示命中 activityId + userId 唯一约束
* @return booleantrue 表示命中 activityId + userId + quotaKey 唯一约束
*/
private boolean isDuplicateGrabException(DaoException e) {
String message = e.getMessage();
return StrUtil.containsIgnoreCase(message, "Duplicate")
|| StrUtil.containsIgnoreCase(message, "UK_COFFEE_TICKET_RECORD_ACTIVITY_USER");
|| StrUtil.containsIgnoreCase(message, "UK_COFFEE_TICKET_RECORD_ACTIVITY_USER_QUOTA");
}
/**
@@ -35,35 +35,42 @@ public class CoffeeTicketRecordServiceImpl extends BaseServiceImpl<CoffeeTicketR
}
@Override
public int countGrabbedTicketNum(String activityId) {
return dao().func(CoffeeTicketRecord.class, "sum", "ticketNum", Cnd.where("activityId", "=", activityId));
public int countGrabbedTicketNum(String activityId, String quotaKey) {
return dao().func(CoffeeTicketRecord.class, "sum", "ticketNum", buildQuotaCnd(activityId, quotaKey));
}
@Override
public boolean hasUserGrabbed(String activityId, String userId) {
return dao().count(CoffeeTicketRecord.class, Cnd.where("activityId", "=", activityId).and("userId", "=", userId)) > 0;
public boolean hasUserGrabbed(String activityId, String userId, String quotaKey) {
return dao().count(CoffeeTicketRecord.class, buildQuotaCnd(activityId, quotaKey).and("userId", "=", userId)) > 0;
}
@Override
public CoffeeTicketRecord fetchLatestUserRecord(String activityId, String userId) {
Cnd cnd = Cnd.where("activityId", "=", activityId).and("userId", "=", userId);
public CoffeeTicketRecord fetchLatestUserRecord(String activityId, String userId, String quotaKey) {
Cnd cnd = buildQuotaCnd(activityId, quotaKey).and("userId", "=", userId);
cnd.desc("grabTime");
return dao().fetch(CoffeeTicketRecord.class, cnd);
}
@Override
public int countUserTicketNum(String activityId, String userId) {
return dao().func(CoffeeTicketRecord.class, "sum", "ticketNum", Cnd.where("activityId", "=", activityId).and("userId", "=", userId));
public int countUserTicketNum(String activityId, String userId, String quotaKey) {
return dao().func(CoffeeTicketRecord.class, "sum", "ticketNum",
buildQuotaCnd(activityId, quotaKey).and("userId", "=", userId));
}
@Override
public boolean hasActivityRecord(String activityId) {
return dao().count(CoffeeTicketRecord.class, Cnd.where("activityId", "=", activityId)) > 0;
}
@Override
@Aop(TransAop.READ_COMMITTED)
public CoffeeTicketRecord saveGrabRecord(CoffeeTicketActivity activity, Integer ticketNum) {
public CoffeeTicketRecord saveGrabRecord(CoffeeTicketActivity activity, Integer ticketNum, String quotaKey) {
CoffeeTicketRecord record = new CoffeeTicketRecord();
record.setActivityId(activity.getId());
record.setActivityTitle(activity.getTitle());
record.setTicketNum(ticketNum);
record.setGrabTime(new Date());
record.setQuotaKey(quotaKey);
record.setUserId(ShiroUtil.getUserId());
record.setUserName(ShiroUtil.getPlatformUsername());
record.setLoginName(ShiroUtil.getPlatformLoginname());
@@ -155,6 +162,10 @@ public class CoffeeTicketRecordServiceImpl extends BaseServiceImpl<CoffeeTicketR
if (!isWithinGrabWindow(activity)) {
throw new IllegalStateException("当前不在可取消时间内");
}
// 每日票池的记录只允许在所属配额日内取消,避免第二天取消前一天记录后错误恢复当日余票。
if (isDailyMode(activity) && !DateUtil.today().equals(record.getQuotaKey())) {
throw new IllegalStateException("每日票池只能取消当天抢票记录");
}
dao().delete(record);
}
@@ -179,10 +190,38 @@ public class CoffeeTicketRecordServiceImpl extends BaseServiceImpl<CoffeeTicketR
vo.setAddress(activity == null ? null : activity.getAddress());
vo.setTicketNum(record.getTicketNum());
vo.setGrabTime(record.getGrabTime());
vo.setCanCancel(activity != null && isWithinGrabWindow(activity));
vo.setCanCancel(activity != null && isWithinGrabWindow(activity)
&& (!isDailyMode(activity) || DateUtil.today().equals(record.getQuotaKey())));
return vo;
}
/**
* 构建票池查询条件。整场模式同时匹配 TOTAL 和空值,以兼容新增 quotaKey 字段前的历史抢票记录。
*
* @param activityId 活动ID
* @param quotaKey 票池标识,TOTAL 表示整场票池,yyyy-MM-dd 表示某日票池
* @return Cnd,可继续追加用户等查询条件
*/
private Cnd buildQuotaCnd(String activityId, String quotaKey) {
Cnd cnd = Cnd.where("activityId", "=", activityId);
if (CoffeeTicketRecord.QUOTA_KEY_TOTAL.equals(quotaKey)) {
cnd.and(Cnd.exps("quotaKey", "=", CoffeeTicketRecord.QUOTA_KEY_TOTAL).or("quotaKey", "is", null));
return cnd;
}
cnd.and("quotaKey", "=", quotaKey);
return cnd;
}
/**
* 判断活动是否使用每日独立票池;历史活动未配置票池模式时按整场总票池兼容。
*
* @param activity 抢票活动实体
* @return booleantrue 表示每日票池
*/
private boolean isDailyMode(CoffeeTicketActivity activity) {
return activity != null && CoffeeTicketActivity.TICKET_MODE_DAILY.equals(activity.getTicketMode());
}
/**
* 组装PC端抢票统计明细,保留记录表中的用户、活动、票数和实际抢票时间。
*
@@ -12,12 +12,32 @@ import java.util.Date;
public class CoffeeTicketActivityH5DetailVO extends CoffeeTicketActivity implements Serializable {
/**
* 当前登录用户是否已经抢过该活动票
* 当前票池配置数量。整场模式为活动总票数,每日模式为每日票数
*/
private Integer totalTicketNum;
/**
* 当前票池已抢票数。整场模式统计全部记录,每日模式只统计当天记录。
*/
private Integer grabbedTicketNum;
/**
* 当前票池剩余票数。
*/
private Integer remainingTicketNum;
/**
* 今天是否处于活动周期且属于活动配置的可抢票星期。
*/
private Boolean quotaAvailableToday;
/**
* 当前登录用户是否已经抢过当前票池的活动票。
*/
private Boolean hasGrabbed;
/**
* 当前登录用户在该活动下已抢票张数合计。
* 当前登录用户在当前票池下已抢票张数合计。
*/
private Integer myTicketNum;
@@ -11,12 +11,12 @@ import java.io.Serializable;
public class CoffeeTicketActivityH5ListVO extends CoffeeTicketActivity implements Serializable {
/**
* 活动总票数,当前业务按活动表 ticketCount 字段计算
* 当前票池配置数量。整场模式为活动总票数,每日模式为每日票数
*/
private Integer totalTicketNum;
/**
* 已抢票数,统计抢票记录表中当前活动 ticketNum 合计
* 当前票池已抢票数。整场模式统计全部记录,每日模式只统计当天记录
*/
private Integer grabbedTicketNum;
@@ -24,4 +24,9 @@ public class CoffeeTicketActivityH5ListVO extends CoffeeTicketActivity implement
* 剩余票数,按 totalTicketNum - grabbedTicketNum 计算,小于 0 时按 0 返回。
*/
private Integer remainingTicketNum;
/**
* 今天是否处于活动周期且属于活动配置的可抢票星期。
*/
private Boolean quotaAvailableToday;
}
@@ -214,13 +214,23 @@ layout("/mobile/platform.html"){
white-space: pre-line;
word-break: break-all;
overflow-wrap: anywhere;
overflow-x: auto;
}
.notice-content-collapsed {
display: -webkit-box;
max-height: 220px;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 10;
}
.notice-content img,
.notice-content video {
max-width: 100%;
height: auto;
}
.notice-content table {
max-width: 100%;
border-collapse: collapse;
}
.notice-expand-button {
@@ -320,8 +330,14 @@ layout("/mobile/platform.html"){
</div>
<div class="detail-info-row">
<i class="van-icon van-icon-coupon-o"></i>
<div class="detail-info-label">活动总票数</div>
<div class="detail-info-value">{{formData.ticketCount || 0}} 张</div>
<div class="detail-info-label">{{isDailyMode ? '每日票数' : '活动总票数'}}</div>
<div class="detail-info-value">{{formData.totalTicketNum || 0}} 张</div>
</div>
<div class="detail-info-row">
<i class="van-icon van-icon-balance-list-o"></i>
<div class="detail-info-label">{{isDailyMode ? '今日剩余' : '剩余票数'}}</div>
<div class="detail-info-value" v-if="isDailyMode && !formData.quotaAvailableToday">--</div>
<div class="detail-info-value" v-else>{{formData.remainingTicketNum || 0}} 张</div>
</div>
<!-- <div class="detail-info-row">
<i class="van-icon van-icon-user-o"></i>
@@ -352,8 +368,9 @@ layout("/mobile/platform.html"){
<i class="van-icon van-icon-bookmark-o"></i>
<span>活动描述</span>
</div>
<div class="notice-content" :class="{'notice-content-collapsed': !descriptionExpanded}">
{{formData.description || '暂无活动描述'}}
<div class="notice-content"
:class="{'notice-content-collapsed': !descriptionExpanded}"
v-html="formData.description || '暂无活动描述'">
</div>
<button v-if="formData.description" type="button" class="notice-expand-button" @click="toggleDescription">
{{descriptionExpanded ? '收起' : '展开'}}
@@ -365,7 +382,7 @@ layout("/mobile/platform.html"){
<i class="van-icon van-icon-bookmark-o"></i>
<span>参与须知</span>
</div>
<div class="notice-content">{{noticeText}}</div>
<div class="notice-content" v-html="noticeText"></div>
</div>
</div>
@@ -387,6 +404,7 @@ layout("/mobile/platform.html"){
:disabled="ticketActionInfo.disabled || ticketLoading">
</van-stepper>
</div>
<!-- 未到抢票时间时将倒计时直接显示在按钮内,避免额外占用底部空间。 -->
<van-button
v-if="ticketActionInfo.visible"
class="detail-action"
@@ -396,7 +414,9 @@ layout("/mobile/platform.html"){
:loading="ticketLoading"
loading-text="抢票中..."
@click="onTicket">
{{formData.hasGrabbed ? '已抢票' : ticketActionInfo.text}}
{{ticketActionInfo.showCountdown && grabCountdownInfo.visible
? '距抢票开始 ' + grabCountdownInfo.text
: (formData.hasGrabbed ? '已抢票' : ticketActionInfo.text)}}
</van-button>
</div>
</div>
@@ -411,6 +431,7 @@ layout("/mobile/platform.html"){
grabTicketNum: 1,
descriptionExpanded: false,
nowTimestamp: Date.now(),
quotaDate: "",
ticketTimer: null,
formData: {},
weekMap: {
@@ -439,8 +460,36 @@ layout("/mobile/platform.html"){
noticeText() {
return this.formData.notice || this.defaultNotice
},
isDailyMode() {
return this.formData.ticketMode === "DAILY"
},
maxGrabTicketNum() {
return Number(this.formData.perUserTicketNum) > 0 ? Number(this.formData.perUserTicketNum) : 1
const perUserTicketNum = Number(this.formData.perUserTicketNum) > 0 ? Number(this.formData.perUserTicketNum) : 1
const remainingTicketNum = Number(this.formData.remainingTicketNum)
if (isNaN(remainingTicketNum) || remainingTicketNum <= 0) {
return 1
}
return Math.max(Math.min(perUserTicketNum, remainingTicketNum), 1)
},
grabCountdownInfo() {
const startTime = this.getCurrentGrabStartTime()
const now = this.$moment(this.nowTimestamp)
if (!startTime || !startTime.isValid() || !startTime.isAfter(now)) {
return {visible: false, text: ""}
}
const totalSeconds = Math.max(startTime.diff(now, "seconds"), 0)
const days = Math.floor(totalSeconds / 86400)
const hours = Math.floor(totalSeconds % 86400 / 3600)
const minutes = Math.floor(totalSeconds % 3600 / 60)
const seconds = totalSeconds % 60
const formatNumber = (value) => {
return value < 10 ? "0" + value : String(value)
}
const dayText = days > 0 ? days + "天 " : ""
return {
visible: true,
text: dayText + formatNumber(hours) + "时" + formatNumber(minutes) + "分" + formatNumber(seconds) + "秒"
}
},
ticketActionInfo() {
const now = this.$moment(this.nowTimestamp)
@@ -451,8 +500,11 @@ layout("/mobile/platform.html"){
if (!this.formData.activityStartDate || !this.formData.activityEndDate) {
return { visible: false, disabled: true, text: "" }
}
if (!this.formData.reserveStartTime || !this.formData.reserveEndTime) {
return { visible: true, disabled: true, text: "未配置抢票时间" }
}
if (now.isBefore(this.$moment(this.formData.activityStartDate).startOf("day"))) {
return { visible: false, disabled: true, text: "" }
return { visible: true, disabled: true, text: "即将开始", showCountdown: true }
}
if (now.isAfter(this.$moment(this.formData.activityEndDate).endOf("day"))) {
return { visible: true, disabled: true, text: "活动已结束" }
@@ -460,20 +512,13 @@ layout("/mobile/platform.html"){
if (!this.formData.weekDays || this.formData.weekDays.indexOf(this.getTodayWeekValue()) === -1) {
return { visible: true, disabled: true, text: "今日不可抢票" }
}
if (!this.formData.reserveStartTime || !this.formData.reserveEndTime) {
return { visible: true, disabled: true, text: "未配置抢票时间" }
if (Number(this.formData.remainingTicketNum) <= 0) {
return { visible: true, disabled: true, text: this.isDailyMode ? "今日票已抢完" : "活动票已抢完" }
}
const startTime = this.$moment(today + " " + this.formData.reserveStartTime, "YYYY-MM-DD HH:mm")
const endTime = this.$moment(today + " " + this.formData.reserveEndTime, "YYYY-MM-DD HH:mm")
const showTime = startTime.clone().subtract(10, "minutes")
if (now.isBefore(showTime)) {
return { visible: false, disabled: true, text: "" }
}
if (now.isBefore(startTime)) {
const seconds = Math.max(startTime.diff(now, "seconds"), 0)
const minutes = Math.floor(seconds / 60)
const leftSeconds = seconds % 60
return { visible: true, disabled: true, text: "距抢票开始 " + minutes + "分" + leftSeconds + "秒" }
return { visible: true, disabled: true, text: "即将开始", showCountdown: true }
}
if (now.isAfter(endTime)) {
return { visible: true, disabled: true, text: "今日抢票已结束" }
@@ -508,6 +553,19 @@ layout("/mobile/platform.html"){
const day = this.$moment(this.nowTimestamp).day()
return day === 0 ? "7" : String(day)
},
// 倒计时目标:活动未开始时取活动开始日期,活动进行中时取当天的抢票开始时间。
getCurrentGrabStartTime() {
if (!this.formData.activityStartDate || !this.formData.reserveStartTime) {
return null
}
const now = this.$moment(this.nowTimestamp)
const activityStartDate = this.$moment(this.formData.activityStartDate).startOf("day")
const targetDate = now.isBefore(activityStartDate) ? activityStartDate : now
return this.$moment(
targetDate.format("YYYY-MM-DD") + " " + this.formData.reserveStartTime,
"YYYY-MM-DD HH:mm"
)
},
loadData() {
const id = this.getQueryString("id")
if (!id) {
@@ -585,6 +643,8 @@ layout("/mobile/platform.html"){
this.$set(this.formData, "hasGrabbed", true)
this.$set(this.formData, "myTicketNum", record.ticketNum || this.grabTicketNum)
this.$set(this.formData, "myGrabTime", record.grabTime || "")
this.$set(this.formData, "grabbedTicketNum", Number(this.formData.grabbedTicketNum || 0) + Number(record.ticketNum || this.grabTicketNum))
this.$set(this.formData, "remainingTicketNum", Math.max(Number(this.formData.remainingTicketNum || 0) - Number(record.ticketNum || this.grabTicketNum), 0))
this.$toast.success(res.msg || "抢票成功")
} else {
this.$toast.fail(res.msg)
@@ -600,14 +660,21 @@ layout("/mobile/platform.html"){
},
mounted() {
this.loadData()
this.ticketTimer = setInterval(() => {
this.nowTimestamp = Date.now()
}, 1000)
this.$set(this, "quotaDate", this.$moment().format("YYYY-MM-DD"))
this.$set(this, "ticketTimer", setInterval(() => {
this.$set(this, "nowTimestamp", Date.now())
const currentDate = this.$moment(this.nowTimestamp).format("YYYY-MM-DD")
// 页面跨过午夜时重新读取当日配额和抢票状态,保证每日票池不会沿用前一天数据。
if (currentDate !== this.quotaDate) {
this.$set(this, "quotaDate", currentDate)
this.loadData()
}
}, 1000))
},
beforeDestroy() {
if (this.ticketTimer) {
clearInterval(this.ticketTimer)
this.ticketTimer = null
this.$set(this, "ticketTimer", null)
}
}
})
@@ -281,8 +281,9 @@ layout("/mobile/platform.html"){
</div>
</div>
<div class="ticket-quota">
<span>活动总票数 <strong>{{item.totalTicketNum || 0}}</strong></span>
<span>剩余票数 <strong>{{item.remainingTicketNum || 0}}</strong></span>
<span>{{isDailyMode(item) ? '每日票数' : '活动总票数'}} <strong>{{item.totalTicketNum || 0}}</strong></span>
<span v-if="isDailyMode(item) && !item.quotaAvailableToday">今日剩余 <strong>--</strong></span>
<span v-else>{{isDailyMode(item) ? '今日剩余' : '剩余票数'}} <strong>{{item.remainingTicketNum || 0}}</strong></span>
</div>
<van-button class="ticket-action" type="info" block @click="onTicket(item)">立即抢票</van-button>
</div>
@@ -326,6 +327,9 @@ layout("/mobile/platform.html"){
}
},
methods: {
isDailyMode(row) {
return row && row.ticketMode === "DAILY"
},
formatDate(value) {
return value ? this.$moment(value).format("YYYY-MM-DD") : "--"
},
@@ -16,26 +16,6 @@ layout("/mobile/platform.html"){
-webkit-overflow-scrolling: touch;
}
.mine-filter-wrap {
background: #ffffff;
box-shadow: 0 4px 12px rgba(30, 70, 130, .05);
}
.mine-filter {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 10px 14px 8px;
color: #8b98aa;
font-size: 12px;
background: #f5f7fb;
}
.mine-total strong {
color: #1f73ff;
font-weight: 600;
}
.mine-list {
padding: 10px 12px 0;
}
@@ -187,12 +167,6 @@ layout("/mobile/platform.html"){
<div id="app" v-cloak class="mine-page">
<van-nav-bar title="我的抢票" left-arrow left-text="返回" placeholder fixed @click-left="goHome"></van-nav-bar>
<van-sticky offset-top="46px" class="mine-filter-wrap">
<div class="mine-filter">
<div class="mine-total"><strong>{{pageForm.totalCount || 0}}</strong> 张票</div>
</div>
</van-sticky>
<van-list
v-model="loading"
:finished="finished"
@@ -61,7 +61,17 @@ layout("/layouts/platform.html"){
<span>{{formatWeekDays(row.weekDays)}}</span>
</template>
</el-table-column>
<el-table-column label="活动总票数" prop="ticketCount" width="110" sortable></el-table-column>
<el-table-column label="票池模式" width="110">
<template scope="{row}">
<el-tag v-if="row.ticketMode === 'DAILY'" type="warning" size="small">每日票池</el-tag>
<el-tag v-else type="primary" size="small">整场总票池</el-tag>
</template>
</el-table-column>
<el-table-column label="票池数量" prop="ticketCount" width="120" sortable>
<template scope="{row}">
<span>{{row.ticketMode === 'DAILY' ? '每日 ' : '总计 '}}{{row.ticketCount || 0}} 张</span>
</template>
</el-table-column>
<el-table-column label="每人每次可抢" prop="perUserTicketNum" width="130" sortable>
<template scope="{row}">
<span>{{row.perUserTicketNum || 1}} 张</span>
@@ -170,15 +170,8 @@ layout("/layouts/platform.html"){
<div class="coffee-cover-tip">建议尺寸:750*422px,支持 jpg、png 格式,大小不超过 2MB</div>
</el-form-item>
<el-form-item label="活动描述" prop="description">
<el-input
v-model="formData.description"
placeholder="请输入活动描述,介绍活动内容、亮点等"
type="textarea"
:rows="4"
maxlength="200"
show-word-limit>
</el-input>
<el-form-item class="is-required" label="活动描述" prop="description">
<text-editor v-model="formData.description" :height="220"></text-editor>
</el-form-item>
</section>
@@ -211,21 +204,34 @@ layout("/layouts/platform.html"){
<section class="coffee-section">
<div class="coffee-section-index">3</div>
<h3 class="coffee-section-title">抢票规则</h3>
<el-form-item label="活动总票数" prop="ticketCount">
<el-form-item label="票池模式" prop="ticketMode">
<el-radio-group v-model="formData.ticketMode">
<el-radio label="TOTAL">整场总票池</el-radio>
<el-radio label="DAILY">每日票池</el-radio>
</el-radio-group>
<div class="coffee-form-item-tip" v-if="formData.ticketMode === 'DAILY'">
每个符合活动周期和星期设置的日期都有独立票数,用户每天可抢一次
</div>
<div class="coffee-form-item-tip" v-else>
活动周期内所有可抢日期共用一批票,用户整场活动只能抢一次
</div>
</el-form-item>
<el-form-item :label="formData.ticketMode === 'DAILY' ? '每日票数' : '活动总票数'" prop="ticketCount">
<div class="coffee-ticket-number">
<el-input-number v-model="formData.ticketCount" :min="1" :max="999"
:precision="0"></el-input-number>
<span></span>
<span class="coffee-inline-note">活动可供抢购的总票数</span>
<span class="coffee-inline-note">{{formData.ticketMode === 'DAILY' ? '每个可抢票日期单独提供的票数' : '活动周期内共用的总票数'}}</span>
</div>
</el-form-item>
<el-form-item label="每人每次可抢票数" prop="perUserTicketNum">
<el-form-item :label="formData.ticketMode === 'DAILY' ? '每人每天可抢票数' : '每人每次可抢票数'" prop="perUserTicketNum">
<div class="coffee-ticket-number">
<el-input-number v-model="formData.perUserTicketNum" :min="1" :max="999"
:precision="0"></el-input-number>
<span></span>
<span class="coffee-inline-note">默认 1 张,用户每次抢票将按此数量领取</span>
<span class="coffee-inline-note">{{formData.ticketMode === 'DAILY' ? '用户每天只能抢一次,每次最多领取该数量' : '用户整场活动只能抢一次,每次最多领取该数量'}}</span>
</div>
</el-form-item>
@@ -261,7 +267,7 @@ layout("/layouts/platform.html"){
<el-time-select
v-model="formData.reserveStartTime"
placeholder="开始时间"
:picker-options="{ start: '07:00', step: '00:10', end: '23:30' }">
:picker-options="{ start: '00:00', step: '00:10', end: '23:59' }">
</el-time-select>
</el-form-item>
<span>-</span>
@@ -269,7 +275,7 @@ layout("/layouts/platform.html"){
<el-time-select
v-model="formData.reserveEndTime"
placeholder="结束时间"
:picker-options="{ start: '00:30', step: '00:30', end: '24:00' }">
:picker-options="{ start: '00:00', step: '00:10', end: '23:59' }">
</el-time-select>
</el-form-item>
<span class="coffee-inline-note">在该时间段内可预约,可自行调整</span>
@@ -291,14 +297,7 @@ layout("/layouts/platform.html"){
</el-form-item>
<el-form-item label="参与须知" prop="notice">
<el-input
v-model="formData.notice"
placeholder="请输入参与须知,如:请提前 10 分钟到场,遵守现场秩序等"
type="textarea"
:rows="4"
maxlength="200"
show-word-limit>
</el-input>
<text-editor v-model="formData.notice" :height="220"></text-editor>
</el-form-item>
</section>
</el-form>
@@ -320,6 +319,14 @@ layout("/layouts/platform.html"){
new Vue({
el: "#app",
data() {
// 富文本空内容可能仍包含 p、br 等标签,校验时必须转换为纯文本后判断。
const validateDescription = (rule, value, callback) => {
if (!value || !dd3s.html2txt(value).trim()) {
callback(new Error("请输入活动描述"))
return
}
callback()
}
const validateTimeRange = (rule, value, callback) => {
if (!this.formData.reserveStartTime || !this.formData.reserveEndTime) {
callback()
@@ -340,6 +347,7 @@ layout("/layouts/platform.html"){
description: "",
activityDateRange: [],
weekDays: ["2", "4"],
ticketMode: "TOTAL",
ticketCount: 40,
perUserTicketNum: 1,
reserveStartTime: "",
@@ -362,10 +370,11 @@ layout("/layouts/platform.html"){
formRules: {
title: [{required: true, message: "请输入活动标题", trigger: ["blur", "change"]}],
cover: [{required: true, message: "请上传活动封面", trigger: ["blur", "change"]}],
description: [{required: true, message: "请输入活动描述", trigger: ["blur", "change"]}],
description: [{required: true, validator: validateDescription, trigger: ["blur", "change"]}],
activityDateRange: [{required: true, type: "array", message: "请选择活动周期", trigger: "change"}],
weekDays: [{required: true, type: "array", message: "请选择可预约星期", trigger: "change"}],
ticketCount: [{required: true, message: "请输入活动总票数", trigger: "change"}],
ticketMode: [{required: true, message: "请选择票池模式", trigger: "change"}],
ticketCount: [{required: true, message: "请输入票数", trigger: "change"}],
perUserTicketNum: [{required: true, message: "请输入每人每次可抢票数", trigger: "change"}],
reserveStartTime: [
{required: true, message: "请选择开始时间", trigger: "change"},
@@ -389,7 +398,7 @@ layout("/layouts/platform.html"){
const result = window.location.search.substr(1).match(reg)
return result ? decodeURIComponent(result[2]) : ""
},
// 保存接口参数:活动基础信息、可预约星期、票数、每日预约时间段、地点和须知;成功后返回活动实体。
// 保存接口参数:活动基础信息、可预约星期、票池模式、票数、每日预约时间段、地点和须知;成功后返回活动实体。
buildSubmitData() {
return {
id: this.formData.id,
@@ -399,6 +408,7 @@ layout("/layouts/platform.html"){
activityStartDate: this.formData.activityDateRange && this.formData.activityDateRange.length > 0 ? this.formData.activityDateRange[0] : "",
activityEndDate: this.formData.activityDateRange && this.formData.activityDateRange.length > 1 ? this.formData.activityDateRange[1] : "",
weekDays: this.formData.weekDays,
ticketMode: this.formData.ticketMode,
ticketCount: this.formData.ticketCount,
perUserTicketNum: this.formData.perUserTicketNum,
reserveStartTime: this.formData.reserveStartTime,
@@ -484,6 +494,7 @@ layout("/layouts/platform.html"){
res.data.activityEndDate ? moment(res.data.activityEndDate).format("YYYY-MM-DD") : ""
].filter(v => v),
weekDays: res.data.weekDays || [],
ticketMode: res.data.ticketMode === "DAILY" ? "DAILY" : "TOTAL",
perUserTicketNum: res.data.perUserTicketNum || 1
}))
}