This commit is contained in:
2026-06-17 09:56:13 +08:00
parent 4df1e1527c
commit ef7ac57bb8
35 changed files with 3778 additions and 167 deletions
@@ -0,0 +1,70 @@
package com.budwk.app.zhgh.dayofficework.coffeeTicket.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.result.Result;
import com.budwk.app.zhgh.dayofficework.coffeeTicket.models.CoffeeTicketActivity;
import com.budwk.app.zhgh.dayofficework.coffeeTicket.service.CoffeeTicketActivityService;
import io.swagger.annotations.ApiOperation;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Cnd;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
import javax.validation.Valid;
@IocBean
@At("/platform/coffeeTicket/activity")
@Ok("json:full")
@ApiOperation("咖啡抢票活动管理")
public class CoffeeTicketActivityController {
@Inject
private CoffeeTicketActivityService coffeeTicketActivityService;
@At("")
@Ok("beetl:/platform/zhgh/dayofficework/coffeeTicket/activity/index.html")
@SaCheckPermission("coffeeTicket.activity")
public void index() {
}
/**
* 分页查询咖啡抢票活动。
*
* @param pageForm 分页参数,包含 pageNumber、pageSize、排序字段等公共分页信息
* @param year 活动开始日期所属年度,传 yyyy 年份;为空时不按年度筛选
* @param title 活动标题关键字;为空时不按标题筛选
* @return Result,data 为 Pagination,list 为活动列表,totalCount 为总数
*/
@At
@SaCheckPermission("coffeeTicket.activity")
public Result pageData(@Valid PageForm pageForm, Integer year, String title) {
Cnd cnd = Cnd.NEW();
cnd.andEX("YEAR(activityStartDate)", "=", year);
if (StrUtil.isNotBlank(title)) {
cnd.and(Cnd.likeEX("title", title));
}
Pagination pagination = coffeeTicketActivityService.listPage(pageForm.getPageNumber(), pageForm.getPageSize(), cnd);
return Result.success(pagination);
}
@At
@SaCheckPermission(value = {"coffeeTicket.activity", "coffeeTicket.new"}, mode = cn.dev33.satoken.annotation.SaMode.OR)
public Result findOne(@Valid String id) {
CoffeeTicketActivity activity = coffeeTicketActivityService.fetch(id);
return Result.success(activity);
}
@At
@SaCheckPermission("coffeeTicket.activity")
@Aop(TransAop.READ_COMMITTED)
public Result delete(@Valid String id) {
coffeeTicketActivityService.deleteActivity(id);
return Result.success();
}
}
@@ -0,0 +1,92 @@
package com.budwk.app.zhgh.dayofficework.coffeeTicket.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.result.Result;
import com.budwk.app.zhgh.dayofficework.coffeeTicket.models.CoffeeTicketActivity;
import com.budwk.app.zhgh.dayofficework.coffeeTicket.service.CoffeeTicketActivityService;
import io.swagger.annotations.ApiOperation;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject;
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 javax.validation.Valid;
@IocBean
@At("/platform/coffeeTicket/new")
@Ok("json:full")
@ApiOperation("咖啡抢票平台活动创建")
public class CoffeeTicketNewController {
@Inject
private CoffeeTicketActivityService coffeeTicketActivityService;
@At("")
@Ok("beetl:/platform/zhgh/dayofficework/coffeeTicket/new/index.html")
@SaCheckPermission("coffeeTicket.new")
public void index() {
}
/**
* 保存咖啡抢票活动。
*
* @param activity 页面提交的活动配置,包含标题、封面、描述、活动开始/结束日期、可预约星期、票数、每日预约时间段、预约方式、活动地点和参与须知
* @return Result,成功时 data 为 CoffeeTicketActivity,可通过 data.id 获取活动ID
*/
@At
@SaCheckPermission("coffeeTicket.new")
@Aop(TransAop.READ_COMMITTED)
public Result save(@Param("data") @Valid CoffeeTicketActivity activity) {
Result validateResult = validateActivity(activity);
if (validateResult != null) {
return validateResult;
}
CoffeeTicketActivity savedActivity = coffeeTicketActivityService.saveActivity(activity);
return Result.success(savedActivity);
}
/**
* 只做入参完整性和范围校验,业务保存交给 service 层处理。
*/
private Result validateActivity(CoffeeTicketActivity activity) {
if (activity == null) {
return Result.error("活动信息不能为空");
}
if (StrUtil.isBlank(activity.getTitle())) {
return Result.error("请输入活动标题");
}
if (StrUtil.isBlank(activity.getCover())) {
return Result.error("请上传活动封面");
}
if (StrUtil.isBlank(activity.getDescription())) {
return Result.error("请输入活动描述");
}
if (activity.getActivityStartDate() == null || activity.getActivityEndDate() == null) {
return Result.error("请选择活动周期");
}
if (activity.getActivityStartDate().after(activity.getActivityEndDate())) {
return Result.error("活动结束日期必须晚于开始日期");
}
if (CollUtil.isEmpty(activity.getWeekDays())) {
return Result.error("请选择可预约星期");
}
if (activity.getTicketCount() == null || activity.getTicketCount() <= 0) {
return Result.error("每次可预约票数必须大于0");
}
if (StrUtil.isBlank(activity.getReserveStartTime()) || StrUtil.isBlank(activity.getReserveEndTime())) {
return Result.error("请选择每日可预约时间段");
}
if (activity.getReserveStartTime().compareTo(activity.getReserveEndTime()) >= 0) {
return Result.error("每日可预约结束时间必须晚于开始时间");
}
if (StrUtil.isBlank(activity.getReserveMode())) {
return Result.error("请选择预约方式");
}
return null;
}
}
@@ -0,0 +1,166 @@
package com.budwk.app.zhgh.dayofficework.coffeeTicket.controller;
import cn.afterturn.easypoi.excel.ExcelExportUtil;
import cn.afterturn.easypoi.excel.entity.ExportParams;
import cn.afterturn.easypoi.excel.entity.enmus.ExcelType;
import cn.afterturn.easypoi.excel.entity.params.ExcelExportEntity;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.hutool.core.date.DateUtil;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.result.Result;
import com.budwk.app.base.utils.CommonDownloadUtil;
import com.budwk.app.zhgh.dayofficework.coffeeTicket.service.CoffeeTicketActivityService;
import com.budwk.app.zhgh.dayofficework.coffeeTicket.service.CoffeeTicketRecordService;
import com.budwk.app.zhgh.dayofficework.coffeeTicket.vo.CoffeeTicketGrabStatisticsVO;
import io.swagger.annotations.ApiOperation;
import org.apache.poi.ss.usermodel.Workbook;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.util.NutMap;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
import org.nutz.mvc.annotation.Param;
import javax.servlet.http.HttpServletResponse;
import javax.validation.Valid;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
@IocBean
@At("/platform/coffeeTicket/statistics")
@Ok("json:full")
@ApiOperation("咖啡抢票统计")
public class CoffeeTicketStatisticsController {
@Inject
private CoffeeTicketActivityService coffeeTicketActivityService;
@Inject
private CoffeeTicketRecordService coffeeTicketRecordService;
@At("")
@Ok("beetl:/platform/zhgh/dayofficework/coffeeTicket/statistics/index.html")
@SaCheckPermission("coffeeTicket.statistics")
public void index() {
}
/**
* 查询统计页活动下拉列表。
*
* @param year 活动开始日期所属年度,传 yyyy 年份;为空时返回全部活动
* @return Result,data 为 List<CoffeeTicketActivity>,前端使用 id 作为活动值、title 作为活动名称
*/
@At
@SaCheckPermission("coffeeTicket.statistics")
public Result activityList(@Param("year") Integer year) {
return Result.success(coffeeTicketActivityService.statisticsActivityList(year));
}
/**
* 分页查询咖啡抢票统计明细。
*
* @param pageForm 分页参数,包含 pageNumber、pageSize、排序字段等公共分页信息
* @param year 活动开始日期所属年度,传 yyyy 年份;为空且 activityId 为空时查询全部记录
* @param activityId 活动ID,传 coffee_ticket_activity 表主键;为空时按年度查询多个活动
* @param unionId 抢票用户所属工会ID,传 vw_user.unionId;为空时不按工会筛选
* @param unitId 抢票用户所属单位ID,传 vw_user.unitId;为空时不按单位筛选
* @param grabDate 抢票日期,传 yyyy-MM-dd;为空时不按实际抢票日期筛选
* @return Result,data 为 Pagination,list 为 CoffeeTicketGrabStatisticsVO 抢票明细列表
*/
@At
@SaCheckPermission("coffeeTicket.statistics")
public Result pageData(@Valid PageForm pageForm, @Param("year") Integer year, @Param("activityId") String activityId,
@Param("unionId") String unionId, @Param("unitId") String unitId, @Param("grabDate") String grabDate) {
Pagination pagination = coffeeTicketRecordService.statisticsPageData(pageForm, year, activityId, unionId, unitId, grabDate);
return Result.success(pagination);
}
/**
* 查询当前统计条件下有抢票记录的日期。
*
* @param year 活动开始日期所属年度,传 yyyy 年份;为空时不按年度筛选
* @param activityId 活动ID,传 coffee_ticket_activity 表主键;为空时按年度查询多个活动
* @param unionId 抢票用户所属工会ID,传 vw_user.unionId;为空时不按工会筛选
* @param unitId 抢票用户所属单位ID,传 vw_user.unitId;为空时不按单位筛选
* @return Result,data 为 yyyy-MM-dd 字符串列表,用于限制抢票日期选择器可选日期
*/
@At
@SaCheckPermission("coffeeTicket.statistics")
public Result grabDateList(@Param("year") Integer year, @Param("activityId") String activityId,
@Param("unionId") String unionId, @Param("unitId") String unitId) {
return Result.success(coffeeTicketRecordService.statisticsGrabDateList(year, activityId, unionId, unitId));
}
/**
* 导出当前筛选条件下的咖啡抢票统计明细。
*
* @param year 活动开始日期所属年度,传 yyyy 年份;为空时不按年度筛选
* @param activityId 活动ID,传 coffee_ticket_activity 表主键;为空时按年度查询多个活动
* @param unionId 抢票用户所属工会ID,传 vw_user.unionId;为空时不按工会筛选
* @param unitId 抢票用户所属单位ID,传 vw_user.unitId;为空时不按单位筛选
* @param grabDate 抢票日期,传 yyyy-MM-dd;为空时不按实际抢票日期筛选
* @param response HttpServletResponse,用于输出 xlsx 文件流
* @return void,浏览器直接下载 Excel 文件
*/
@At
@Ok("void")
@SaCheckPermission("coffeeTicket.statistics")
public void export(@Param("year") Integer year, @Param("activityId") String activityId,
@Param("unionId") String unionId, @Param("unitId") String unitId,
@Param("grabDate") String grabDate, HttpServletResponse response) {
List<CoffeeTicketGrabStatisticsVO> list = coffeeTicketRecordService.statisticsExportList(year, activityId, unionId, unitId, grabDate);
List<NutMap> exportList = list.stream().map(this::buildExportRow).collect(Collectors.toList());
List<ExcelExportEntity> entities = new ArrayList<>();
entities.add(new ExcelExportEntity("姓名", "userName", 20));
entities.add(new ExcelExportEntity("工号", "loginName", 20));
entities.add(new ExcelExportEntity("单位", "unitName", 25));
entities.add(new ExcelExportEntity("分工会", "unionName", 25));
entities.add(new ExcelExportEntity("活动名称", "activityTitle", 30));
entities.add(new ExcelExportEntity("抢票张数", "ticketNum", 15));
entities.add(new ExcelExportEntity("实际抢票时间", "grabTime", 25));
ExportParams exportParams = new ExportParams();
exportParams.setType(ExcelType.XSSF);
Workbook workbook = ExcelExportUtil.exportExcel(exportParams, entities, exportList);
CommonDownloadUtil.download("咖啡抢票统计.xlsx", workbook, response);
}
/**
* 删除统计页面中的一条抢票记录。
*
* @param recordId 抢票记录ID,传 coffee_ticket_record 表主键;删除后该用户可重新参与该活动抢票
* @return Result,code 为 0 表示删除成功;非 0 时 msg 为失败原因
*/
@At
@Aop(TransAop.READ_COMMITTED)
@SaCheckPermission("coffeeTicket.statistics.delete")
public Result delete(@Param("recordId") @Valid String recordId) {
try {
coffeeTicketRecordService.deleteStatisticsRecord(recordId);
return Result.success();
} catch (IllegalStateException e) {
return Result.error(e.getMessage());
}
}
/**
* 组装导出行数据,字段与页面当前统计列表保持一致,不包含操作列。
*
* @param item 一条抢票统计明细
* @return NutMap,key 与 ExcelExportEntity 中的字段名对应
*/
private NutMap buildExportRow(CoffeeTicketGrabStatisticsVO item) {
return NutMap.NEW()
.setv("userName", item.getUserName())
.setv("loginName", item.getLoginName())
.setv("unitName", item.getUnitName())
.setv("unionName", item.getUnionName())
.setv("activityTitle", item.getActivityTitle())
.setv("ticketNum", item.getTicketNum())
.setv("grabTime", item.getGrabTime() == null ? "" : DateUtil.format(item.getGrabTime(), "yyyy-MM-dd HH:mm:ss"));
}
}
@@ -0,0 +1,42 @@
package com.budwk.app.zhgh.dayofficework.coffeeTicket.h5controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.result.Result;
import com.budwk.app.zhgh.dayofficework.coffeeTicket.service.CoffeeTicketActivityService;
import io.swagger.annotations.ApiOperation;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
import javax.validation.Valid;
@IocBean
@At("/platform/h5/coffeeTicket/list")
@Ok("json:full")
@ApiOperation("手机端咖啡抢票活动列表")
public class H5CoffeeTicketActivityController {
@Inject
private CoffeeTicketActivityService coffeeTicketActivityService;
@At("")
@Ok("beetl:/platform/zhghh5/dayofficework/coffeeTicket/list/index.html")
@SaCheckPermission("h5.coffeeTicket.list")
public void index() {
}
/**
* 手机端抢票活动列表。
*
* @param pageForm 分页参数,searchKeyword 可传活动标题或活动地点关键字
* @param status 状态筛选,all全部、active报名中、upcoming即将开始、ended已结束
* @return Result,data 为 Pagination,list 为 CoffeeTicketActivity 活动列表
*/
@At
@SaCheckPermission("h5.coffeeTicket.list")
public Result pageData(@Valid PageForm pageForm, String status) {
return Result.success(coffeeTicketActivityService.h5PageData(pageForm, status));
}
}
@@ -0,0 +1,74 @@
package com.budwk.app.zhgh.dayofficework.coffeeTicket.h5controller;
import cn.hutool.core.bean.BeanUtil;
import cn.dev33.satoken.annotation.SaCheckPermission;
import com.budwk.app.base.result.Result;
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.zhgh.dayofficework.coffeeTicket.models.CoffeeTicketActivity;
import com.budwk.app.zhgh.dayofficework.coffeeTicket.models.CoffeeTicketRecord;
import com.budwk.app.zhgh.dayofficework.coffeeTicket.service.CoffeeTicketActivityService;
import com.budwk.app.zhgh.dayofficework.coffeeTicket.service.CoffeeTicketRecordService;
import com.budwk.app.zhgh.dayofficework.coffeeTicket.vo.CoffeeTicketActivityH5DetailVO;
import io.swagger.annotations.ApiOperation;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject;
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 javax.validation.Valid;
@IocBean
@At("/platform/h5/coffeeTicket/detail")
@Ok("json:full")
@ApiOperation("手机端咖啡抢票活动详情")
public class H5CoffeeTicketDetailController {
@Inject
private CoffeeTicketActivityService coffeeTicketActivityService;
@Inject
private CoffeeTicketRecordService coffeeTicketRecordService;
@At("")
@Ok("beetl:/platform/zhghh5/dayofficework/coffeeTicket/detail/index.html")
@SaCheckPermission("h5.coffeeTicket.list")
public void index() {
}
@At
@SaCheckPermission("h5.coffeeTicket.list")
public Result findOne(@Valid String id) {
CoffeeTicketActivity activity = coffeeTicketActivityService.fetch(id);
if (activity == null) {
return Result.error("活动不存在");
}
CoffeeTicketActivityH5DetailVO detailVO = BeanUtil.copyProperties(activity, CoffeeTicketActivityH5DetailVO.class);
CoffeeTicketRecord userRecord = coffeeTicketRecordService.fetchLatestUserRecord(id, SecurityUtil.getUserId());
detailVO.setHasGrabbed(userRecord != null);
detailVO.setMyTicketNum(userRecord == null ? 0 : coffeeTicketRecordService.countUserTicketNum(id, SecurityUtil.getUserId()));
detailVO.setMyGrabTime(userRecord == null ? null : userRecord.getGrabTime());
return Result.success(detailVO);
}
/**
* 手机端立即抢票并保存记录。
*
* @param id 活动ID,前端从详情页地址参数或活动详情数据中传入
* @param ticketNum 本次抢票张数,当前页面默认传 1,用于判断余票是否足够并写入记录
* @return Result,code 为 0 表示抢票记录保存成功,data 为 CoffeeTicketRecord;非 0 时 msg 为不可抢票原因
*/
@At
@Aop(TransAop.READ_COMMITTED)
@SaCheckPermission("h5.coffeeTicket.list")
public Result grab(@Param("id") @Valid String id, @Param("ticketNum") Integer ticketNum) {
try {
CoffeeTicketRecord record = coffeeTicketActivityService.grabH5Ticket(id, ticketNum);
return Result.success("抢票成功", record);
} catch (IllegalStateException e) {
return Result.error(e.getMessage());
}
}
}
@@ -0,0 +1,62 @@
package com.budwk.app.zhgh.dayofficework.coffeeTicket.h5controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.result.Result;
import com.budwk.app.zhgh.dayofficework.coffeeTicket.service.CoffeeTicketRecordService;
import io.swagger.annotations.ApiOperation;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject;
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 javax.validation.Valid;
@IocBean
@At("/platform/h5/coffeeTicket/mine")
@Ok("json:full")
@ApiOperation("手机端我的咖啡抢票")
public class H5CoffeeTicketMineController {
@Inject
private CoffeeTicketRecordService coffeeTicketRecordService;
@At("")
@Ok("beetl:/platform/zhghh5/dayofficework/coffeeTicket/mine/index.html")
@SaCheckPermission("h5.coffeeTicket.list")
public void index() {
}
/**
* 手机端我的抢票列表。
*
* @param pageForm 分页参数,searchKeyword 可传活动标题关键字
* @return Result,data 为 Pagination,list 为 CoffeeTicketMineListVO,按抢票时间倒序排列
*/
@At
@SaCheckPermission("h5.coffeeTicket.list")
public Result pageData(@Valid PageForm pageForm) {
return Result.success(coffeeTicketRecordService.h5MinePageData(pageForm));
}
/**
* 手机端取消抢票。
*
* @param recordId 抢票记录ID
* @return Result,code 为 0 表示取消成功
*/
@At("/cancel")
@SaCheckPermission("h5.coffeeTicket.list")
@Aop(TransAop.READ_COMMITTED)
public Result cancel(@Param("recordId") @Valid String recordId) {
try {
coffeeTicketRecordService.cancelH5Ticket(recordId);
return Result.success("取消成功", null);
} catch (IllegalStateException e) {
return Result.error(e.getMessage());
}
}
}
@@ -0,0 +1,90 @@
package com.budwk.app.zhgh.dayofficework.coffeeTicket.models;
import com.budwk.app.base.model.BaseModel;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.nutz.dao.entity.annotation.ColDefine;
import org.nutz.dao.entity.annotation.ColType;
import org.nutz.dao.entity.annotation.Column;
import org.nutz.dao.entity.annotation.Comment;
import org.nutz.dao.entity.annotation.Name;
import org.nutz.dao.entity.annotation.Table;
import org.nutz.dao.entity.annotation.TableMeta;
import org.nutz.dao.interceptor.annotation.PrevInsert;
import java.io.Serializable;
import java.util.Date;
import java.util.List;
@EqualsAndHashCode(callSuper = true)
@Data
@Table("coffee_ticket_activity")
@TableMeta("{'mysql-charset':'utf8mb4'}")
@Comment("咖啡抢票活动表")
public class CoffeeTicketActivity extends BaseModel implements Serializable {
@Name
@Comment("ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
@PrevInsert(uu32 = true)
private String id;
@Column
@Comment("活动标题")
@ColDefine(type = ColType.VARCHAR, width = 50)
private String title;
@Column
@Comment("活动封面")
@ColDefine(type = ColType.VARCHAR, width = 255)
private String cover;
@Column
@Comment("活动描述")
@ColDefine(type = ColType.TEXT)
private String description;
@Column
@Comment("活动开始日期")
@ColDefine(type = ColType.DATE)
private Date activityStartDate;
@Column
@Comment("活动结束日期")
@ColDefine(type = ColType.DATE)
private Date activityEndDate;
@Column
@Comment("可预约星期,保存周一至周日对应的1-7")
@ColDefine(type = ColType.MYSQL_JSON)
private List<String> weekDays;
@Column
@Comment("每次可预约票数")
@ColDefine(type = ColType.INT)
private Integer ticketCount;
@Column
@Comment("每日可预约开始时间")
@ColDefine(type = ColType.VARCHAR, width = 5)
private String reserveStartTime;
@Column
@Comment("每日可预约结束时间")
@ColDefine(type = ColType.VARCHAR, width = 5)
private String reserveEndTime;
@Column
@Comment("预约方式:WEEKLY每周固定,SPECIAL特殊日期")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String reserveMode;
@Column
@Comment("活动地点")
@ColDefine(type = ColType.VARCHAR, width = 100)
private String address;
@Column
@Comment("参与须知")
@ColDefine(type = ColType.TEXT)
private String notice;
}
@@ -0,0 +1,74 @@
package com.budwk.app.zhgh.dayofficework.coffeeTicket.models;
import com.budwk.app.base.model.BaseModel;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.nutz.dao.entity.annotation.ColDefine;
import org.nutz.dao.entity.annotation.ColType;
import org.nutz.dao.entity.annotation.Column;
import org.nutz.dao.entity.annotation.Comment;
import org.nutz.dao.entity.annotation.Index;
import org.nutz.dao.entity.annotation.Name;
import org.nutz.dao.entity.annotation.Table;
import org.nutz.dao.entity.annotation.TableIndexes;
import org.nutz.dao.entity.annotation.TableMeta;
import org.nutz.dao.interceptor.annotation.PrevInsert;
import java.io.Serializable;
import java.util.Date;
@EqualsAndHashCode(callSuper = true)
@Data
@Table("coffee_ticket_record")
@TableIndexes({
@Index(name = "UK_COFFEE_TICKET_RECORD_ACTIVITY_USER", fields = {"activityId", "userId"}, unique = true)
})
@TableMeta("{'mysql-charset':'utf8mb4'}")
@Comment("咖啡抢票记录表")
public class CoffeeTicketRecord extends BaseModel implements Serializable {
@Name
@Comment("ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
@PrevInsert(uu32 = true)
private String id;
@Column
@Comment("活动ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String activityId;
@Column
@Comment("活动标题")
@ColDefine(type = ColType.VARCHAR, width = 50)
private String activityTitle;
@Column
@Comment("抢票张数")
@ColDefine(type = ColType.INT)
private Integer ticketNum;
@Column
@Comment("抢票时间")
@ColDefine(type = ColType.DATETIME)
private Date grabTime;
@Column
@Comment("用户ID")
@ColDefine(type = ColType.VARCHAR, width = 32)
private String userId;
@Column
@Comment("姓名")
@ColDefine(type = ColType.VARCHAR, width = 100)
private String userName;
@Column
@Comment("工号")
@ColDefine(type = ColType.VARCHAR, width = 120)
private String loginName;
@Column
@Comment("状态:SUCCESS已抢票")
@ColDefine(type = ColType.VARCHAR, width = 20)
private String status;
}
@@ -0,0 +1,62 @@
package com.budwk.app.zhgh.dayofficework.coffeeTicket.service;
import com.budwk.app.base.service.BaseService;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.param.PageForm;
import com.budwk.app.zhgh.dayofficework.coffeeTicket.models.CoffeeTicketActivity;
import com.budwk.app.zhgh.dayofficework.coffeeTicket.models.CoffeeTicketRecord;
import java.util.List;
public interface CoffeeTicketActivityService extends BaseService<CoffeeTicketActivity> {
/**
* 保存咖啡抢票活动基础配置。
*
* @param activity 页面提交的活动配置,包含标题、封面、活动日期、预约星期、票数、每日预约时间段、地点和须知等内容
* @return CoffeeTicketActivity 保存后的活动实体,新增时会带有生成后的活动ID
*/
CoffeeTicketActivity saveActivity(CoffeeTicketActivity activity);
/**
* 删除咖啡抢票活动及其基础配置。
*
* @param id 活动ID
*/
void deleteActivity(String id);
/**
* 手机端分页查询可展示的咖啡抢票活动。
*
* @param pageForm 分页参数,searchKeyword 可传活动标题或活动地点关键字
* @param status 活动状态,all全部、active报名中、upcoming即将开始、ended已结束
* @return Pagination,list 为 CoffeeTicketActivity 活动实体列表
*/
Pagination h5PageData(PageForm pageForm, String status);
/**
* 查询统计页面年度活动下拉列表。
*
* @param year 活动开始日期所属年度,传 yyyy 年份;为空时返回全部咖啡抢票活动
* @return List<CoffeeTicketActivity>,按活动开始日期倒序排列
*/
List<CoffeeTicketActivity> statisticsActivityList(Integer year);
/**
* 校验手机端抢票请求是否符合活动开放规则。
*
* @param id 活动ID,必须传 coffee_ticket_activity 表中的活动主键
* @param ticketNum 本次抢票张数,当前详情页默认传 1,用于判断是否超过活动配置的每次可预约票数
* @return String,返回 null 表示校验通过;返回非空字符串表示不可抢票的具体原因
*/
String checkH5GrabTicket(String id, Integer ticketNum);
/**
* 手机端立即抢票并保存抢票记录。
*
* @param id 活动ID,必须传 coffee_ticket_activity 表中的活动主键
* @param ticketNum 本次抢票张数,当前详情页默认传 1
* @return CoffeeTicketRecord,返回保存后的抢票记录;校验失败时抛出 IllegalStateException,异常消息为前端提示文案
*/
CoffeeTicketRecord grabH5Ticket(String id, Integer ticketNum);
}
@@ -0,0 +1,116 @@
package com.budwk.app.zhgh.dayofficework.coffeeTicket.service;
import com.budwk.app.base.service.BaseService;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.param.PageForm;
import com.budwk.app.zhgh.dayofficework.coffeeTicket.models.CoffeeTicketActivity;
import com.budwk.app.zhgh.dayofficework.coffeeTicket.models.CoffeeTicketRecord;
import java.util.List;
public interface CoffeeTicketRecordService extends BaseService<CoffeeTicketRecord> {
/**
* 统计指定活动已经占用的票数。
*
* @param activityId 活动ID,必须传 coffee_ticket_activity 表中的活动主键
* @return int,返回 coffee_ticket_record 中该活动 ticketNum 的合计值,无记录时返回 0
*/
int countGrabbedTicketNum(String activityId);
/**
* 判断用户是否已经抢过指定活动的票。
*
* @param activityId 活动ID
* @param userId 当前登录用户ID
* @return boolean,true 表示该用户已有该活动抢票记录
*/
boolean hasUserGrabbed(String activityId, String userId);
/**
* 查询用户在指定活动下最近一次抢票记录。
*
* @param activityId 活动ID
* @param userId 当前登录用户ID
* @return CoffeeTicketRecord,返回最近一次抢票记录;没有记录时返回 null
*/
CoffeeTicketRecord fetchLatestUserRecord(String activityId, String userId);
/**
* 统计用户在指定活动下已抢票数。
*
* @param activityId 活动ID
* @param userId 当前登录用户ID
* @return int,返回该用户在当前活动下 ticketNum 的合计值,无记录时返回 0
*/
int countUserTicketNum(String activityId, String userId);
/**
* 保存手机端抢票记录。
*
* @param activity 已通过规则校验的活动实体,用于回填活动ID和标题
* @param ticketNum 本次抢票张数,当前 H5 详情页默认传 1
* @return CoffeeTicketRecord,返回已经写入数据库的抢票记录实体
*/
CoffeeTicketRecord saveGrabRecord(CoffeeTicketActivity activity, Integer ticketNum);
/**
* 手机端分页查询当前用户的抢票记录。
*
* @param pageForm 分页参数,searchKeyword 可传活动标题关键字
* @return Pagination,list 为 CoffeeTicketMineListVO,按抢票时间倒序排列
*/
Pagination h5MinePageData(PageForm pageForm);
/**
* PC端分页查询咖啡抢票统计明细。
*
* @param pageForm 分页参数,包含 pageNumber、pageSize、排序字段等公共分页信息
* @param year 活动开始日期所属年度,传 yyyy 年份;为空且 activityId 为空时查询全部记录
* @param activityId 活动ID,传 coffee_ticket_activity 表主键;为空时按年度查询多个活动
* @param unionId 抢票用户所属工会ID,传 vw_user.unionId;为空时不按工会筛选
* @param unitId 抢票用户所属单位ID,传 vw_user.unitId;为空时不按单位筛选
* @param grabDate 抢票日期,传 yyyy-MM-dd;为空时不按实际抢票日期筛选
* @return Pagination,list 为 CoffeeTicketGrabStatisticsVO,展示用户、活动、票数和抢票时间
*/
Pagination statisticsPageData(PageForm pageForm, Integer year, String activityId, String unionId, String unitId, String grabDate);
/**
* 查询当前统计条件下的全部抢票明细,用于按页面筛选条件导出。
*
* @param year 活动开始日期所属年度,传 yyyy 年份;为空时不按年度筛选
* @param activityId 活动ID,传 coffee_ticket_activity 表主键;为空时按年度查询多个活动
* @param unionId 抢票用户所属工会ID,传 vw_user.unionId;为空时不按工会筛选
* @param unitId 抢票用户所属单位ID,传 vw_user.unitId;为空时不按单位筛选
* @param grabDate 抢票日期,传 yyyy-MM-dd;为空时不按实际抢票日期筛选
* @return List<CoffeeTicketGrabStatisticsVO>,返回与统计列表字段一致的全部匹配数据
*/
List<com.budwk.app.zhgh.dayofficework.coffeeTicket.vo.CoffeeTicketGrabStatisticsVO> statisticsExportList(Integer year, String activityId, String unionId, String unitId, String grabDate);
/**
* 查询当前统计条件下存在抢票记录的日期。
*
* @param year 活动开始日期所属年度,传 yyyy 年份;为空时不按年度筛选
* @param activityId 活动ID,传 coffee_ticket_activity 表主键;为空时按年度查询多个活动
* @param unionId 抢票用户所属工会ID,传 vw_user.unionId;为空时不按工会筛选
* @param unitId 抢票用户所属单位ID,传 vw_user.unitId;为空时不按单位筛选
* @return List<String>,返回 yyyy-MM-dd 格式的实际抢票日期列表,前端据此控制日期选择器可选范围
*/
List<String> statisticsGrabDateList(Integer year, String activityId, String unionId, String unitId);
/**
* PC端统计页面删除一条抢票记录。
*
* @param recordId 抢票记录ID,必须传 coffee_ticket_record 表中的记录主键
* @return void,成功则删除该条抢票记录
*/
void deleteStatisticsRecord(String recordId);
/**
* 手机端取消一条抢票记录。
*
* @param recordId 要取消的抢票记录ID
* @return void,成功则直接删除该条抢票记录
*/
void cancelH5Ticket(String recordId);
}
@@ -0,0 +1,199 @@
package com.budwk.app.zhgh.dayofficework.coffeeTicket.service.impl;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.StrUtil;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.service.impl.BaseServiceImpl;
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.zhgh.dayofficework.coffeeTicket.models.CoffeeTicketActivity;
import com.budwk.app.zhgh.dayofficework.coffeeTicket.models.CoffeeTicketRecord;
import com.budwk.app.zhgh.dayofficework.coffeeTicket.service.CoffeeTicketActivityService;
import com.budwk.app.zhgh.dayofficework.coffeeTicket.service.CoffeeTicketRecordService;
import com.budwk.app.zhgh.dayofficework.coffeeTicket.vo.CoffeeTicketActivityH5ListVO;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.dao.DaoException;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import java.util.Calendar;
import java.util.Date;
import java.util.List;
@IocBean(args = {"refer:dao"})
public class CoffeeTicketActivityServiceImpl extends BaseServiceImpl<CoffeeTicketActivity> implements CoffeeTicketActivityService {
@Inject
private CoffeeTicketRecordService coffeeTicketRecordService;
public CoffeeTicketActivityServiceImpl(Dao dao) {
super(dao);
}
@Override
@Aop(TransAop.READ_COMMITTED)
public CoffeeTicketActivity saveActivity(CoffeeTicketActivity activity) {
dao().insertOrUpdate(activity);
return activity;
}
@Override
@Aop(TransAop.READ_COMMITTED)
public void deleteActivity(String id) {
dao().delete(CoffeeTicketActivity.class, id);
}
@Override
public Pagination h5PageData(PageForm pageForm, String status) {
Cnd cnd = Cnd.NEW();
if (StrUtil.isNotBlank(pageForm.getSearchKeyword())) {
String keyword = "%" + pageForm.getSearchKeyword() + "%";
cnd.and(Cnd.exps("title", "like", keyword).or("address", "like", keyword));
}
Date todayStart = DateUtil.beginOfDay(new Date());
Date todayEnd = DateUtil.endOfDay(new Date());
if ("active".equals(status)) {
cnd.and("activityStartDate", "<=", todayEnd);
cnd.and("activityEndDate", ">=", todayStart);
} else if ("upcoming".equals(status)) {
cnd.and("activityStartDate", ">", todayEnd);
} else if ("ended".equals(status)) {
cnd.and("activityEndDate", "<", todayStart);
}
cnd.desc("activityStartDate");
Pagination pagination = listPage(pageForm.getPageNumber(), pageForm.getPageSize(), cnd);
List<CoffeeTicketActivity> activityList = pagination.getList(CoffeeTicketActivity.class);
List<CoffeeTicketActivityH5ListVO> voList = activityList.stream().map(this::buildH5ListVO).toList();
pagination.setList(voList);
return pagination;
}
@Override
public List<CoffeeTicketActivity> statisticsActivityList(Integer year) {
Cnd cnd = Cnd.NEW();
cnd.andEX("YEAR(activityStartDate)", "=", year);
cnd.desc("activityStartDate");
return dao().query(CoffeeTicketActivity.class, cnd);
}
@Override
public String checkH5GrabTicket(String id, Integer ticketNum) {
if (StrUtil.isBlank(id)) {
return "活动ID不能为空";
}
int currentTicketNum = ticketNum == null ? 1 : ticketNum;
if (currentTicketNum <= 0) {
return "抢票张数必须大于0";
}
CoffeeTicketActivity activity = fetch(id);
if (activity == null) {
return "活动不存在";
}
Date now = new Date();
if (activity.getActivityStartDate() == null || activity.getActivityEndDate() == null) {
return "活动周期未配置";
}
if (now.before(DateUtil.beginOfDay(activity.getActivityStartDate()))) {
return "活动尚未开始";
}
if (now.after(DateUtil.endOfDay(activity.getActivityEndDate()))) {
return "活动已结束";
}
List<String> weekDays = activity.getWeekDays();
if (weekDays == null || weekDays.isEmpty()) {
return "活动未配置可预约星期";
}
if (!weekDays.contains(getTodayWeekValue(now))) {
return "今天不在可预约星期范围内";
}
if (StrUtil.isBlank(activity.getReserveStartTime()) || StrUtil.isBlank(activity.getReserveEndTime())) {
return "活动未配置每日可预约时间段";
}
String currentTime = DateUtil.format(now, "HH:mm");
if (currentTime.compareTo(activity.getReserveStartTime()) < 0 || currentTime.compareTo(activity.getReserveEndTime()) > 0) {
return "当前不在每日可预约时间段内";
}
if (activity.getTicketCount() == null || activity.getTicketCount() <= 0) {
return "活动票数不足";
}
if (currentTicketNum > activity.getTicketCount()) {
return "本次抢票张数超过每次可预约票数";
}
return null;
}
@Override
@Aop(TransAop.READ_COMMITTED)
public CoffeeTicketRecord grabH5Ticket(String id, Integer ticketNum) {
int currentTicketNum = ticketNum == null ? 1 : ticketNum;
String errorMsg = checkH5GrabTicket(id, currentTicketNum);
if (errorMsg != null) {
throw new IllegalStateException(errorMsg);
}
CoffeeTicketActivity activity = fetch(id);
if (coffeeTicketRecordService.hasUserGrabbed(activity.getId(), SecurityUtil.getUserId())) {
throw new IllegalStateException("您已抢过该活动票");
}
int grabbedTicketNum = coffeeTicketRecordService.countGrabbedTicketNum(activity.getId());
int remainingTicketNum = activity.getTicketCount() - grabbedTicketNum;
if (remainingTicketNum < currentTicketNum) {
throw new IllegalStateException("票数不足");
}
try {
return coffeeTicketRecordService.saveGrabRecord(activity, currentTicketNum);
} catch (DaoException e) {
if (isDuplicateGrabException(e)) {
throw new IllegalStateException("您已抢过该活动票");
}
throw e;
}
}
/**
* 组装手机端列表展示对象,补齐活动总票数、已抢票数和剩余票数字段。
*
* @param activity 当前页活动实体
* @return CoffeeTicketActivityH5ListVO,包含活动基础字段和票数统计字段
*/
private CoffeeTicketActivityH5ListVO buildH5ListVO(CoffeeTicketActivity activity) {
CoffeeTicketActivityH5ListVO vo = BeanUtil.copyProperties(activity, CoffeeTicketActivityH5ListVO.class);
int totalTicketNum = activity.getTicketCount() == null ? 0 : activity.getTicketCount();
int grabbedTicketNum = coffeeTicketRecordService.countGrabbedTicketNum(activity.getId());
vo.setTotalTicketNum(totalTicketNum);
vo.setGrabbedTicketNum(grabbedTicketNum);
vo.setRemainingTicketNum(Math.max(totalTicketNum - grabbedTicketNum, 0));
return vo;
}
/**
* 将 Java 星期值转换为页面保存的 1-7 格式,其中 1 表示周一、7 表示周日。
*
* @param date 需要判断星期的日期
* @return String,返回当前日期对应的星期配置值
*/
private String getTodayWeekValue(Date date) {
Calendar calendar = Calendar.getInstance();
calendar.setTime(date);
int dayOfWeek = calendar.get(Calendar.DAY_OF_WEEK);
if (dayOfWeek == Calendar.SUNDAY) {
return "7";
}
return String.valueOf(dayOfWeek - 1);
}
/**
* 数据库唯一索引用于兜底防止同一用户并发重复抢同一活动。
*
* @param e 抢票记录插入时抛出的数据库异常
* @return boolean,true 表示命中 activityId + userId 唯一约束
*/
private boolean isDuplicateGrabException(DaoException e) {
String message = e.getMessage();
return StrUtil.containsIgnoreCase(message, "Duplicate")
|| StrUtil.containsIgnoreCase(message, "UK_COFFEE_TICKET_RECORD_ACTIVITY_USER");
}
}
@@ -0,0 +1,299 @@
package com.budwk.app.zhgh.dayofficework.coffeeTicket.service.impl;
import cn.hutool.core.util.StrUtil;
import cn.hutool.core.date.DatePattern;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.service.impl.BaseServiceImpl;
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.zhgh.dayofficework.coffeeTicket.models.CoffeeTicketActivity;
import com.budwk.app.zhgh.dayofficework.coffeeTicket.models.CoffeeTicketRecord;
import com.budwk.app.zhgh.dayofficework.coffeeTicket.service.CoffeeTicketRecordService;
import com.budwk.app.zhgh.dayofficework.coffeeTicket.vo.CoffeeTicketGrabStatisticsVO;
import com.budwk.app.zhgh.dayofficework.coffeeTicket.vo.CoffeeTicketMineListVO;
import cn.hutool.core.date.DateUtil;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.util.NutMap;
import java.util.Calendar;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.stream.Collectors;
@IocBean(args = {"refer:dao"})
public class CoffeeTicketRecordServiceImpl extends BaseServiceImpl<CoffeeTicketRecord> implements CoffeeTicketRecordService {
public CoffeeTicketRecordServiceImpl(Dao dao) {
super(dao);
}
@Override
public int countGrabbedTicketNum(String activityId) {
return dao().func(CoffeeTicketRecord.class, "sum", "ticketNum", Cnd.where("activityId", "=", activityId));
}
@Override
public boolean hasUserGrabbed(String activityId, String userId) {
return dao().count(CoffeeTicketRecord.class, Cnd.where("activityId", "=", activityId).and("userId", "=", userId)) > 0;
}
@Override
public CoffeeTicketRecord fetchLatestUserRecord(String activityId, String userId) {
Cnd cnd = Cnd.where("activityId", "=", activityId).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));
}
@Override
@Aop(TransAop.READ_COMMITTED)
public CoffeeTicketRecord saveGrabRecord(CoffeeTicketActivity activity, Integer ticketNum) {
CoffeeTicketRecord record = new CoffeeTicketRecord();
record.setActivityId(activity.getId());
record.setActivityTitle(activity.getTitle());
record.setTicketNum(ticketNum);
record.setGrabTime(new Date());
record.setUserId(SecurityUtil.getUserId());
record.setUserName(SecurityUtil.getUserUsername());
record.setLoginName(SecurityUtil.getUserLoginname());
record.setStatus("SUCCESS");
return dao().insert(record);
}
@Override
public Pagination h5MinePageData(PageForm pageForm) {
Cnd cnd = Cnd.where("userId", "=", SecurityUtil.getUserId());
cnd.desc("grabTime");
List<CoffeeTicketRecord> recordList = dao().query(CoffeeTicketRecord.class, cnd);
List<CoffeeTicketMineListVO> voList = recordList.stream()
.map(this::buildMineListVO)
.filter(item -> matchKeyword(item, pageForm.getSearchKeyword()))
.toList();
int totalCount = voList.size();
int fromIndex = Math.min((pageForm.getPageNumber() - 1) * pageForm.getPageSize(), totalCount);
int toIndex = Math.min(fromIndex + pageForm.getPageSize(), totalCount);
return new Pagination<>(pageForm.getPageNumber(), pageForm.getPageSize(), totalCount, new ArrayList<>(voList.subList(fromIndex, toIndex)));
}
@Override
public Pagination statisticsPageData(PageForm pageForm, Integer year, String activityId, String unionId, String unitId, String grabDate) {
Sql sql = buildStatisticsSql(year, activityId, unionId, unitId, grabDate);
Pagination pagination = listPageMap(pageForm.getPageNumber(), pageForm.getPageSize(), sql);
List<NutMap> recordList = pagination.getList();
List<CoffeeTicketGrabStatisticsVO> voList = recordList.stream().map(this::buildGrabStatisticsVO).collect(Collectors.toList());
pagination.setList(voList);
return pagination;
}
@Override
public List<CoffeeTicketGrabStatisticsVO> statisticsExportList(Integer year, String activityId, String unionId, String unitId, String grabDate) {
Sql sql = buildStatisticsSql(year, activityId, unionId, unitId, grabDate);
return listMap(sql).stream().map(this::buildGrabStatisticsVO).collect(Collectors.toList());
}
@Override
public List<String> statisticsGrabDateList(Integer year, String activityId, String unionId, String unitId) {
Sql sql = Sqls.create("""
SELECT
DATE_FORMAT(r.grabTime, '%Y-%m-%d') AS grabDate
FROM
coffee_ticket_record r
LEFT JOIN vw_user u ON u.id = r.userId
LEFT JOIN coffee_ticket_activity a ON a.id = r.activityId
$condition
GROUP BY DATE_FORMAT(r.grabTime, '%Y-%m-%d')
ORDER BY grabDate DESC
""");
Cnd cnd = Cnd.NEW();
cnd.andEX("r.activityId", "=", activityId);
cnd.andEX("YEAR(a.activityStartDate)", "=", year);
cnd.andEX("u.unionId", "=", unionId);
cnd.andEX("u.unitId", "=", unitId);
sql.setCondition(cnd);
List<NutMap> list = listMap(sql);
return list.stream().map(item -> item.getString("grabDate")).collect(Collectors.toList());
}
@Override
@Aop(TransAop.READ_COMMITTED)
public void deleteStatisticsRecord(String recordId) {
if (StrUtil.isBlank(recordId)) {
throw new IllegalStateException("抢票记录ID不能为空");
}
CoffeeTicketRecord record = fetch(recordId);
if (record == null) {
throw new IllegalStateException("抢票记录不存在");
}
dao().delete(record);
}
@Override
@Aop(TransAop.READ_COMMITTED)
public void cancelH5Ticket(String recordId) {
if (StrUtil.isBlank(recordId)) {
throw new IllegalStateException("抢票记录ID不能为空");
}
CoffeeTicketRecord record = fetch(recordId);
if (record == null) {
throw new IllegalStateException("抢票记录不存在");
}
if (!SecurityUtil.getUserId().equals(record.getUserId())) {
throw new IllegalStateException("无权取消该抢票记录");
}
CoffeeTicketActivity activity = dao().fetch(CoffeeTicketActivity.class, record.getActivityId());
if (activity == null) {
throw new IllegalStateException("活动不存在");
}
if (!isWithinGrabWindow(activity)) {
throw new IllegalStateException("当前不在可取消时间内");
}
dao().delete(record);
}
/**
* 将抢票记录和活动信息组合成手机端“我的抢票”卡片数据。
*
* @param record 当前用户的一条抢票记录
* @return CoffeeTicketMineListVO,包含记录字段和活动字段
*/
private CoffeeTicketMineListVO buildMineListVO(CoffeeTicketRecord record) {
CoffeeTicketActivity activity = dao().fetch(CoffeeTicketActivity.class, record.getActivityId());
CoffeeTicketMineListVO vo = new CoffeeTicketMineListVO();
vo.setRecordId(record.getId());
vo.setActivityId(record.getActivityId());
vo.setTitle(activity == null ? record.getActivityTitle() : activity.getTitle());
vo.setCover(activity == null ? null : activity.getCover());
vo.setDescription(activity == null ? null : activity.getDescription());
vo.setActivityStartDate(activity == null ? null : activity.getActivityStartDate());
vo.setActivityEndDate(activity == null ? null : activity.getActivityEndDate());
vo.setReserveStartTime(activity == null ? null : activity.getReserveStartTime());
vo.setReserveEndTime(activity == null ? null : activity.getReserveEndTime());
vo.setAddress(activity == null ? null : activity.getAddress());
vo.setTicketNum(record.getTicketNum());
vo.setGrabTime(record.getGrabTime());
vo.setCanCancel(activity != null && isWithinGrabWindow(activity));
return vo;
}
/**
* 组装PC端抢票统计明细,保留记录表中的用户、活动、票数和实际抢票时间。
*
* @param record 当前页的一条抢票记录
* @return CoffeeTicketGrabStatisticsVO,返回给统计页面展示的明细数据
*/
private CoffeeTicketGrabStatisticsVO buildGrabStatisticsVO(NutMap record) {
CoffeeTicketGrabStatisticsVO vo = new CoffeeTicketGrabStatisticsVO();
vo.setRecordId(record.getString("recordId"));
vo.setActivityId(record.getString("activityId"));
vo.setActivityTitle(record.getString("activityTitle"));
vo.setUserId(record.getString("userId"));
vo.setUserName(record.getString("userName"));
vo.setLoginName(record.getString("loginName"));
vo.setUnitId(record.getString("unitId"));
vo.setUnitName(record.getString("unitName"));
vo.setUnionId(record.getString("unionId"));
vo.setUnionName(record.getString("unionName"));
vo.setTicketNum(record.getInt("ticketNum"));
vo.setGrabTime(record.getAs("grabTime", Date.class));
return vo;
}
/**
* 构建PC端抢票统计查询SQL,分页列表和导出共用同一套筛选条件,避免页面展示与导出结果不一致。
*
* @param year 活动开始日期所属年度
* @param activityId 活动ID
* @param unionId 抢票用户所属工会ID
* @param unitId 抢票用户所属单位ID
* @param grabDate 实际抢票日期,格式 yyyy-MM-dd
* @return Sql,已带查询条件和按抢票时间倒序排序
*/
private Sql buildStatisticsSql(Integer year, String activityId, String unionId, String unitId, String grabDate) {
Sql sql = Sqls.create("""
SELECT
r.id AS recordId,
r.activityId,
r.activityTitle,
r.userId,
r.userName,
r.loginName,
u.unitId,
u.unitName,
u.unionId,
u.unionName,
r.ticketNum,
r.grabTime
FROM
coffee_ticket_record r
LEFT JOIN vw_user u ON u.id = r.userId
LEFT JOIN coffee_ticket_activity a ON a.id = r.activityId
$condition
""");
Cnd cnd = Cnd.NEW();
cnd.andEX("r.activityId", "=", activityId);
cnd.andEX("YEAR(a.activityStartDate)", "=", year);
cnd.andEX("u.unionId", "=", unionId);
cnd.andEX("u.unitId", "=", unitId);
if (StrUtil.isNotBlank(grabDate)) {
Date date = DateUtil.parse(grabDate, DatePattern.NORM_DATE_PATTERN);
cnd.and("r.grabTime", ">=", DateUtil.beginOfDay(date));
cnd.and("r.grabTime", "<=", DateUtil.endOfDay(date));
}
cnd.desc("r.grabTime");
sql.setCondition(cnd);
return sql;
}
private boolean isWithinGrabWindow(CoffeeTicketActivity activity) {
Date now = new Date();
if (activity.getActivityStartDate() == null || activity.getActivityEndDate() == null) {
return false;
}
if (now.before(DateUtil.beginOfDay(activity.getActivityStartDate()))) {
return false;
}
if (now.after(DateUtil.endOfDay(activity.getActivityEndDate()))) {
return false;
}
List<String> weekDays = activity.getWeekDays();
if (weekDays == null || weekDays.isEmpty()) {
return false;
}
if (!weekDays.contains(getTodayWeekValue(now))) {
return false;
}
if (StrUtil.isBlank(activity.getReserveStartTime()) || StrUtil.isBlank(activity.getReserveEndTime())) {
return false;
}
String currentTime = DateUtil.format(now, "HH:mm");
if (currentTime.compareTo(activity.getReserveStartTime()) < 0 || currentTime.compareTo(activity.getReserveEndTime()) > 0) {
return false;
}
return true;
}
private String getTodayWeekValue(Date date) {
Calendar calendar = Calendar.getInstance();
calendar.setTime(date);
int dayOfWeek = calendar.get(Calendar.DAY_OF_WEEK);
if (dayOfWeek == Calendar.SUNDAY) {
return "7";
}
return String.valueOf(dayOfWeek - 1);
}
private boolean matchKeyword(CoffeeTicketMineListVO item, String keyword) {
return StrUtil.isBlank(keyword) || StrUtil.containsIgnoreCase(item.getTitle(), keyword);
}
}
@@ -0,0 +1,28 @@
package com.budwk.app.zhgh.dayofficework.coffeeTicket.vo;
import com.budwk.app.zhgh.dayofficework.coffeeTicket.models.CoffeeTicketActivity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.io.Serializable;
import java.util.Date;
@EqualsAndHashCode(callSuper = true)
@Data
public class CoffeeTicketActivityH5DetailVO extends CoffeeTicketActivity implements Serializable {
/**
* 当前登录用户是否已经抢过该活动票。
*/
private Boolean hasGrabbed;
/**
* 当前登录用户在该活动下已抢票张数合计。
*/
private Integer myTicketNum;
/**
* 当前登录用户最近一次抢票时间。
*/
private Date myGrabTime;
}
@@ -0,0 +1,27 @@
package com.budwk.app.zhgh.dayofficework.coffeeTicket.vo;
import com.budwk.app.zhgh.dayofficework.coffeeTicket.models.CoffeeTicketActivity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.io.Serializable;
@EqualsAndHashCode(callSuper = true)
@Data
public class CoffeeTicketActivityH5ListVO extends CoffeeTicketActivity implements Serializable {
/**
* 活动总票数,当前业务按活动表 ticketCount 字段计算。
*/
private Integer totalTicketNum;
/**
* 已抢票数,统计抢票记录表中当前活动 ticketNum 合计。
*/
private Integer grabbedTicketNum;
/**
* 剩余票数,按 totalTicketNum - grabbedTicketNum 计算,小于 0 时按 0 返回。
*/
private Integer remainingTicketNum;
}
@@ -0,0 +1,70 @@
package com.budwk.app.zhgh.dayofficework.coffeeTicket.vo;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
@Data
public class CoffeeTicketGrabStatisticsVO implements Serializable {
/**
* 抢票记录ID,用于定位一条用户抢票明细。
*/
private String recordId;
/**
* 活动ID,对应 coffee_ticket_activity 表主键。
*/
private String activityId;
/**
* 活动标题,优先使用当前活动表标题,活动被删除时使用记录表冗余标题。
*/
private String activityTitle;
/**
* 抢票用户ID,对应当前登录用户体系中的用户主键。
*/
private String userId;
/**
* 抢票用户姓名,来源于抢票时保存的用户信息。
*/
private String userName;
/**
* 抢票用户工号,来源于抢票时保存的登录名。
*/
private String loginName;
/**
* 抢票用户所属单位ID,来源于 vw_user 当前用户视图。
*/
private String unitId;
/**
* 抢票用户所属单位名称,来源于 vw_user 当前用户视图。
*/
private String unitName;
/**
* 抢票用户所属工会ID,来源于 vw_user 当前用户视图。
*/
private String unionId;
/**
* 抢票用户所属工会名称,来源于 vw_user 当前用户视图。
*/
private String unionName;
/**
* 本次抢票张数。
*/
private Integer ticketNum;
/**
* 用户实际点击抢票并生成记录的时间。
*/
private Date grabTime;
}
@@ -0,0 +1,24 @@
package com.budwk.app.zhgh.dayofficework.coffeeTicket.vo;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
@Data
public class CoffeeTicketMineListVO implements Serializable {
private String recordId;
private String activityId;
private String title;
private String cover;
private String description;
private Date activityStartDate;
private Date activityEndDate;
private String reserveStartTime;
private String reserveEndTime;
private String address;
private Integer ticketNum;
private Date grabTime;
private Boolean canCancel;
}
@@ -9,8 +9,10 @@ import com.budwk.app.zhgh.dayofficework.qsv.models.QsvBank;
import com.budwk.app.zhgh.dayofficework.qsv.models.QsvSubject;
import com.budwk.app.zhgh.dayofficework.qsv.service.QsvBankService;
import io.swagger.annotations.ApiOperation;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.mvc.annotation.At;
@@ -51,6 +53,7 @@ public class QsvBankController {
@At
@SaCheckPermission("qsv.bank")
@Aop(TransAop.READ_COMMITTED)
@ApiOperation("保存题库")
public Result save(@Param("qsvBank") QsvBank qsvBank) {
dao.insert(qsvBank);
@@ -59,6 +62,7 @@ public class QsvBankController {
@At
@SaCheckPermission("qsv.bank")
@Aop(TransAop.READ_COMMITTED)
@ApiOperation("修改题库")
public Result update(@Param("qsvBank") QsvBank qsvBank) {
dao.updateIgnoreNull(qsvBank);
@@ -75,6 +79,7 @@ public class QsvBankController {
@At
@SaCheckPermission("qsv.bank")
@Aop(TransAop.READ_COMMITTED)
@ApiOperation("删除题库")
public Result delete(@Valid String id) {
dao.delete(QsvBank.class, id);
@@ -83,6 +88,7 @@ public class QsvBankController {
@At
@SaCheckPermission("qsv.bank")
@Aop(TransAop.READ_COMMITTED)
@ApiOperation("保存题库题目")
public Result saveSubjects(@Valid String id, @Param("subjects") QsvSubject[] subjects) {
QsvBank qsvBank = dao.fetch(QsvBank.class, id);
@@ -13,8 +13,8 @@ import org.nutz.mvc.annotation.Ok;
public class QsvNewController {
@At("/new")
@Ok("beetl:/platform/zhgh/dayofficework/qsv/activity/new.html")
@SaCheckPermission("qsv.activity")
@Ok("beetl:/platform/zhgh/dayofficework/qsv/activity/index.html")
@SaCheckPermission("qsv.new")
public void newPage() {
}
@@ -10,12 +10,12 @@ import com.budwk.app.base.param.PageForm;
import com.budwk.app.base.result.Result;
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
import com.budwk.app.zhgh.activity.basic.models.ActivityUserScope;
import com.budwk.app.zhgh.dayofficework.qsv.dto.QsvCheckAnswerResult;
import com.budwk.app.zhgh.dayofficework.qsv.models.QsvActivity;
import com.budwk.app.zhgh.dayofficework.qsv.models.QsvOption;
import com.budwk.app.zhgh.dayofficework.qsv.models.QsvSubject;
import com.budwk.app.zhgh.dayofficework.qsv.models.QsvUserAnswerRecord;
import com.budwk.app.zhgh.dayofficework.qsv.param.QsvAnswerParam;
import com.budwk.app.zhgh.dayofficework.qsv.dto.QsvCheckAnswerResult;
import com.budwk.app.zhgh.dayofficework.qsv.service.QsvQuizService;
import com.budwk.app.zhgh.dayofficework.qsv.service.QsvUserAnswerRecordService;
import io.swagger.annotations.ApiOperation;
@@ -31,14 +31,7 @@ import org.nutz.mvc.annotation.Ok;
import org.nutz.mvc.annotation.Param;
import javax.validation.Valid;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
@@ -4,11 +4,14 @@ import cn.dev33.satoken.annotation.SaCheckPermission;
import com.budwk.app.base.page.Pagination;
import com.budwk.app.base.result.Result;
import com.budwk.app.zhgh.dayofficework.qsv.models.QsvActivity;
import com.budwk.app.zhgh.dayofficework.qsv.models.QsvUserAnswerRecord;
import com.budwk.app.zhgh.dayofficework.qsv.param.QsvQuizRankPageForm;
import com.budwk.app.zhgh.dayofficework.qsv.service.QsvQuizRankService;
import io.swagger.annotations.ApiOperation;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.mvc.annotation.At;
@@ -64,5 +67,17 @@ public class QsvQuizRankController {
qsvQuizRankService.exportXlsx(pageForm, response);
}
@At
@SaCheckPermission("qsv.quiz.rank.delete")
@Aop(TransAop.READ_COMMITTED)
@ApiOperation("删除答题记录")
public Result deleteAnswerRecord(@Valid String id) {
QsvUserAnswerRecord record = dao.fetch(QsvUserAnswerRecord.class, id);
if (record == null) {
return Result.error("答题记录不存在");
}
dao.delete(QsvUserAnswerRecord.class, id);
return Result.success("删除成功");
}
}
@@ -22,8 +22,10 @@ import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType0Font;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.util.NutMap;
@@ -377,6 +379,7 @@ public class QsvSurveyController {
@At
@SaCheckPermission("qsv.survey")
@Aop(TransAop.READ_COMMITTED)
@ApiOperation("删除用户答题记录")
@SLog(type = "qsv.survey", tag = "删除用户答题记录", msg = "删除用户答题记录")
public Result deleteUserAnswer(@Valid String id) {
@@ -3,7 +3,6 @@ package com.budwk.app.zhgh.dayofficework.qsv.h5controller;
import cn.dev33.satoken.annotation.SaCheckLogin;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import com.budwk.app.base.annotation.SLog;
import com.budwk.app.base.result.Result;
@@ -335,6 +334,7 @@ public class H5QsvQuizController {
@At
@ApiOperation("提交答题")
@SLog(type = "qsv.quiz", tag = "提交答题", msg = "问卷调查提交答题")
@Aop(TransAop.READ_COMMITTED)
public Result submitAnswer(@Valid @Param("answer") QsvAnswerParam qsvAnswerParam) {
QsvUserAnswerRecord answerRecord = dao.fetch(QsvUserAnswerRecord.class, qsvAnswerParam.getAnswerRecordId());
JSONObject extJson = answerRecord.getExtJson();
@@ -10,9 +10,9 @@ import com.budwk.app.zhgh.dayofficework.qsv.models.QsvSubject;
import com.budwk.app.zhgh.dayofficework.qsv.models.QsvUserAnswerRecord;
import com.budwk.app.zhgh.dayofficework.qsv.param.QsvAnswerParam;
import com.budwk.app.zhgh.dayofficework.qsv.service.QsvUserAnswerRecordService;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Cnd;
import org.nutz.dao.Dao;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
@@ -22,11 +22,7 @@ import org.nutz.mvc.annotation.Ok;
import org.nutz.mvc.annotation.Param;
import javax.validation.Valid;
import java.util.ArrayList;
import java.util.Date;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
@@ -23,12 +23,7 @@ import org.nutz.mvc.annotation.Ok;
import org.nutz.mvc.annotation.Param;
import javax.validation.Valid;
import java.util.ArrayList;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
@@ -123,7 +123,7 @@ public class QsvActivity extends BaseModel implements Serializable, SysHomeConve
sysHomeActivity.setName(this.getTitle());
sysHomeActivity.setCover(this.getCover());
sysHomeActivity.setContent(this.getDescription());
sysHomeActivity.setUrl("/platform/qsv/activity");
sysHomeActivity.setUrl("/platform/qsv/online");
sysHomeActivity.setH5Url(getH5Url());
sysHomeActivity.setStartDate(this.getStartTime());
sysHomeActivity.setEndDate(this.getEndTime());
@@ -90,6 +90,7 @@ public class QsvQuizRankServiceImpl extends BaseServiceImpl<QsvUserAnswerRecord>
private Sql buildQuerySql(String activityId, String mode, String scoreStatisticsMode, QsvQuizRankPageForm pageForm) {
StringBuilder sqlBuilder = new StringBuilder("""
SELECT
t1.id,
t1.userId,
t1.userName,
t1.loginName,
@@ -0,0 +1,139 @@
<!--#
layout("/layouts/platform.html"){
#-->
<div id="app" v-cloak>
<guava ref="guava">
<el-card shadow="never">
<search @search="doSearch">
<search-item label="年度">
<el-date-picker
placeholder="请选择年度"
style="width: 100%"
type="year"
v-model="pageForm.year"
value-format="yyyy">
</el-date-picker>
</search-item>
<search-item label="活动标题">
<el-input placeholder="请输入活动标题" v-model="pageForm.title" clearable></el-input>
</search-item>
</search>
</el-card>
<el-card shadow="never" class="mt10">
<table-tool label="活动列表">
<el-button size="small" type="primary" @click="openAdd">
<i class="ti-plus"></i>
新增活动
</el-button>
</table-tool>
<el-table :data="tableData" ref="tableRef" @sort-change="pageOrder">
<el-table-column label="序号" width="60" type="index" :index="indexMethod"></el-table-column>
<el-table-column label="活动标题" prop="title" sortable show-overflow-tooltip></el-table-column>
<el-table-column label="活动周期" width="220">
<template scope="{row}">
<span>{{formatDate(row.activityStartDate)}} - {{formatDate(row.activityEndDate)}}</span>
</template>
</el-table-column>
<el-table-column label="可预约星期" width="220">
<template scope="{row}">
<span>{{formatWeekDays(row.weekDays)}}</span>
</template>
</el-table-column>
<el-table-column label="每次票数" prop="ticketCount" width="100" sortable></el-table-column>
<el-table-column label="每日抢票时间段" width="180">
<template scope="{row}">
<span>{{row.reserveStartTime || '--'}} - {{row.reserveEndTime || '--'}}</span>
</template>
</el-table-column>
<el-table-column label="创建时间" prop="createdAt" width="170" sortable>
<template scope="{row}">
<span>{{formatDateTime(row.createdAt)}}</span>
</template>
</el-table-column>
<el-table-column label="操作" fixed="right" width="150">
<template scope="{row}">
<el-button size="mini" type="primary" @click="openEdit(row)">编辑</el-button>
<el-button size="mini" type="danger" @click="onDelete(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!--#include("/layouts/pagination.html"){}#-->
</el-card>
</guava>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
mixins: [initTableMixins],
data() {
return {
weekMap: {
"1": "周一",
"2": "周二",
"3": "周三",
"4": "周四",
"5": "周五",
"6": "周六",
"7": "周日"
}
}
},
methods: {
openAdd() {
location.href = "/platform/coffeeTicket/new"
},
openEdit(row) {
location.href = "/platform/coffeeTicket/new?id=" + row.id
},
formatDate(value) {
return value ? this.$moment(value).format("YYYY-MM-DD") : "--"
},
formatDateTime(value) {
return value ? this.$moment(value).format("YYYY-MM-DD HH:mm:ss") : "--"
},
formatWeekDays(weekDays) {
if (!weekDays || weekDays.length === 0) {
return "--"
}
return weekDays.map(item => this.weekMap[item] || item).join("、")
},
pageData() {
this.$axios.post(loc() + "/pageData", this.pageForm)
.then((res) => {
if (res.code === 0) {
this.tableData = res.data.list
this.pageForm.totalCount = res.data.totalCount
}
})
},
onDelete(id) {
this.$confirm("删除后该活动配置不可恢复,确定删除吗?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning"
}).then(() => {
this.$axios.post("/platform/coffeeTicket/activity/delete", { id: id })
.then((res) => {
if (res.code === 0) {
this.$message.success("删除成功")
this.pageData()
} else {
this.$message.warning(res.msg)
}
})
}).catch(() => {})
}
},
created() {
this.pageData()
}
})
</script>
<!--#
}
#-->
@@ -0,0 +1,383 @@
<!--#
layout("/layouts/platform.html"){
#-->
<style>
.coffee-ticket-page {
min-height: calc(100vh - 120px);
padding-bottom: 18px;
background: #f5f7fb;
}
.coffee-ticket-form {
max-width: 1180px;
margin: 0 auto;
}
.coffee-section {
position: relative;
margin-bottom: 14px;
padding: 24px 30px 20px 54px;
background: #ffffff;
border-radius: 6px;
box-shadow: 0 1px 4px rgba(31, 45, 61, .04);
}
.coffee-section-index {
position: absolute;
top: 24px;
left: 18px;
width: 28px;
height: 28px;
line-height: 28px;
border-radius: 50%;
background: #1f73ff;
color: #ffffff;
font-size: 16px;
font-weight: 600;
text-align: center;
}
.coffee-section-title {
margin: 0 0 24px;
color: #1f2d3d;
font-size: 22px;
line-height: 28px;
font-weight: 600;
}
.coffee-form-item-tip {
margin-top: 8px;
color: #7b8794;
font-size: 14px;
line-height: 20px;
}
.coffee-cover-tip {
margin-top: 8px;
color: #7b8794;
font-size: 14px;
line-height: 20px;
}
.coffee-week-group {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.coffee-week-group .el-checkbox {
margin-right: 0;
}
.coffee-ticket-number {
display: flex;
align-items: center;
gap: 12px;
}
.coffee-time-range {
display: flex;
align-items: center;
gap: 14px;
}
.coffee-time-range .el-date-editor.el-input,
.coffee-time-range .el-date-editor.el-input__inner {
width: 190px;
}
.coffee-inline-note {
margin-left: 24px;
color: #7b8794;
font-size: 14px;
}
.coffee-radio-help {
margin-left: 6px;
color: #8c98a8;
cursor: pointer;
}
.coffee-action-note {
margin-right: 12px;
color: #909399;
font-size: 13px;
}
</style>
<div id="app" v-cloak class="coffee-ticket-page">
<custom-card>
<h2 slot="header">创建咖啡抢票活动</h2>
<template>
<el-form class="coffee-ticket-form" :model="formData" ref="formRef" :rules="formRules" label-width="150px" v-loading="formLoading">
<section class="coffee-section">
<div class="coffee-section-index">1</div>
<h3 class="coffee-section-title">基础信息</h3>
<el-form-item label="活动标题" prop="title">
<el-input
v-model="formData.title"
placeholder="请输入活动标题"
maxlength="50"
show-word-limit
clearable>
</el-input>
</el-form-item>
<el-form-item label="活动封面" prop="cover">
<file-upload
:upload_number="1"
:upload_size="1024 * 1024 * 2"
:value.sync="formData.cover"
accept=".jpg,.jpeg,.png"
upload_mode="image"
upload_result_category="interval"
upload_result_type="url">
</file-upload>
<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>
</section>
<section class="coffee-section">
<div class="coffee-section-index">2</div>
<h3 class="coffee-section-title">活动时间与频次</h3>
<el-form-item label="活动周期" prop="activityDateRange">
<el-date-picker
v-model="formData.activityDateRange"
type="daterange"
start-placeholder="选择开始日期"
end-placeholder="选择结束日期"
range-separator="-"
value-format="yyyy-MM-dd"
style="width: 460px">
</el-date-picker>
<div class="coffee-form-item-tip">设置活动整体的开始和结束时间</div>
</el-form-item>
<el-form-item label="可预约星期" prop="weekDays">
<el-checkbox-group v-model="formData.weekDays" class="coffee-week-group">
<el-checkbox v-for="item in weekOptions" :key="item.value" :label="item.value" border>
{{item.label}}
</el-checkbox>
</el-checkbox-group>
<div class="coffee-form-item-tip">设置每周哪些天可以预约(可多选)</div>
</el-form-item>
</section>
<section class="coffee-section">
<div class="coffee-section-index">3</div>
<h3 class="coffee-section-title">预约规则</h3>
<el-form-item label="每次可预约票数" 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>
</div>
</el-form-item>
<el-form-item label="每日可预约时间段" required>
<div class="coffee-time-range">
<el-form-item prop="reserveStartTime">
<el-time-select
v-model="formData.reserveStartTime"
placeholder="开始时间"
:picker-options="{ start: '00:00', step: '00:30', end: '23:30' }">
</el-time-select>
</el-form-item>
<span>-</span>
<el-form-item prop="reserveEndTime">
<el-time-select
v-model="formData.reserveEndTime"
placeholder="结束时间"
:picker-options="{ start: '00:30', step: '00:30', end: '24:00' }">
</el-time-select>
</el-form-item>
<span class="coffee-inline-note">在该时间段内可预约,可自行调整</span>
</div>
</el-form-item>
</section>
<section class="coffee-section">
<div class="coffee-section-index">4</div>
<h3 class="coffee-section-title">其他设置</h3>
<el-form-item label="活动地点" prop="address">
<el-input
v-model="formData.address"
placeholder="请输入活动地点"
maxlength="100"
show-word-limit
clearable>
</el-input>
</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>
</el-form-item>
</section>
</el-form>
<template slot="footer">
<el-button @click="goBack">取消</el-button>
<el-button type="primary" :loading="formLoading" @click="submitForm">保存</el-button>
</template>
</template>
</custom-card>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
data() {
const validateTimeRange = (rule, value, callback) => {
if (!this.formData.reserveStartTime || !this.formData.reserveEndTime) {
callback()
return
}
if (this.formData.reserveStartTime >= this.formData.reserveEndTime) {
callback(new Error("结束时间必须晚于开始时间"))
return
}
callback()
}
return {
formLoading: false,
formData: {
title: "",
cover: "",
description: "",
activityDateRange: [],
weekDays: ["2", "4"],
ticketCount: 40,
reserveStartTime: "",
reserveEndTime: "",
reserveMode: "WEEKLY",
address: "",
notice: ""
},
weekOptions: [
{ value: "1", label: "周一" },
{ value: "2", label: "周二" },
{ value: "3", label: "周三" },
{ value: "4", label: "周四" },
{ value: "5", label: "周五" },
{ value: "6", label: "周六" },
{ value: "7", label: "周日" }
],
formRules: {
title: [{ required: true, message: "请输入活动标题", trigger: ["blur", "change"] }],
cover: [{ required: true, message: "请上传活动封面", trigger: ["blur", "change"] }],
description: [{ required: true, message: "请输入活动描述", 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" }],
reserveStartTime: [
{ required: true, message: "请选择开始时间", trigger: "change" },
{ validator: validateTimeRange, trigger: "change" }
],
reserveEndTime: [
{ required: true, message: "请选择结束时间", trigger: "change" },
{ validator: validateTimeRange, trigger: "change" }
],
reserveMode: [{ required: true, message: "请选择预约方式", trigger: "change" }]
}
}
},
methods: {
getQueryString(name) {
const reg = new RegExp("(^|&)" + name + "=([^&]*)(&|$)")
const result = window.location.search.substr(1).match(reg)
return result ? decodeURIComponent(result[2]) : ""
},
// 保存接口参数:活动基础信息、可预约星期、票数、每日预约时间段、地点和须知;成功后返回活动实体。
buildSubmitData() {
return {
id: this.formData.id,
title: this.formData.title,
cover: this.formData.cover,
description: this.formData.description,
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,
ticketCount: this.formData.ticketCount,
reserveStartTime: this.formData.reserveStartTime,
reserveEndTime: this.formData.reserveEndTime,
reserveMode: this.formData.reserveMode,
address: this.formData.address,
notice: this.formData.notice
}
},
submitForm() {
this.$refs.formRef.validate((valid) => {
if (!valid) {
return
}
const submitData = this.buildSubmitData()
this.formLoading = true
this.$axios.post("/platform/coffeeTicket/new/save", { data: JSON.stringify(submitData) })
.then((res) => {
if (res.code === 0) {
this.$message.success(res.msg || "保存成功")
if (res.data && res.data.id) {
this.$set(this.formData, "id", res.data.id)
}
} else {
this.$message.warning(res.msg)
}
})
.finally(() => {
this.formLoading = false
})
})
},
goBack() {
window.history.back()
},
loadActivity() {
const id = this.getQueryString("id")
if (!id) {
return
}
this.formLoading = true
this.$axios.post("/platform/coffeeTicket/activity/findOne", { id: id })
.then((res) => {
if (res.code === 0 && res.data) {
this.formData = Object.assign({}, this.formData, res.data, {
activityDateRange: [
res.data.activityStartDate ? this.$moment(res.data.activityStartDate).format("YYYY-MM-DD") : "",
res.data.activityEndDate ? this.$moment(res.data.activityEndDate).format("YYYY-MM-DD") : ""
].filter(v => v),
weekDays: res.data.weekDays || []
})
}
})
.finally(() => {
this.formLoading = false
})
}
},
mounted() {
this.loadActivity()
}
})
</script>
<!--#
}
#-->
@@ -0,0 +1,212 @@
<!--#
layout("/layouts/platform.html"){
#-->
<div id="app" v-cloak>
<guava ref="guava">
<el-card shadow="never">
<search @search="doSearch">
<search-item label="年度">
<el-date-picker
placeholder="请选择年度"
style="width: 100%"
type="year"
v-model="pageForm.year"
value-format="yyyy"
@change="yearChange">
</el-date-picker>
</search-item>
<search-item label="活动名称">
<el-select placeholder="请选择活动" style="width: 100%" v-model="pageForm.activityId" clearable @change="activityChange">
<el-option label="全部活动" value=""></el-option>
<el-option :label="item.title" :value="item.id" v-for="item in activityList" :key="item.id"></el-option>
</el-select>
</search-item>
<search-item label="抢票日期">
<el-date-picker
clearable
placeholder="请选择抢票日期"
style="width: 100%"
type="date"
:picker-options="grabDatePickerOptions"
v-model="pageForm.grabDate"
value-format="yyyy-MM-dd"
@change="doSearch">
</el-date-picker>
</search-item>
<search-item label="所属工会">
<el-select clearable filterable placeholder="请选择所属工会" style="width: 100%" v-model="pageForm.unionId" @change="unionChange">
<el-option :label="item.name" :value="item.id" v-for="item in unionList" :key="item.id"></el-option>
</el-select>
</search-item>
<search-item label="所属单位">
<el-select clearable filterable placeholder="请选择所属单位" style="width: 100%" v-model="pageForm.unitId" @change="unitChange">
<el-option :label="item.name" :value="item.id" v-for="item in unitList" :key="item.id"></el-option>
</el-select>
</search-item>
</search>
</el-card>
<el-card shadow="never" class="mt10">
<table-tool label="抢票统计">
<el-button size="small" type="primary" @click="exportExcel">导出</el-button>
</table-tool>
<el-table :data="tableData" ref="tableRef" @sort-change="pageOrder">
<el-table-column label="序号" width="70" type="index" :index="indexMethod"></el-table-column>
<el-table-column label="姓名" prop="userName" width="120" sortable show-overflow-tooltip></el-table-column>
<el-table-column label="工号" prop="loginName" width="140" sortable show-overflow-tooltip></el-table-column>
<el-table-column label="单位" prop="unitName" min-width="160" show-overflow-tooltip></el-table-column>
<el-table-column label="分工会" prop="unionName" min-width="160" show-overflow-tooltip></el-table-column>
<el-table-column label="活动名称" prop="activityTitle" min-width="180" show-overflow-tooltip></el-table-column>
<el-table-column label="抢票张数" prop="ticketNum" width="110" sortable align="center" header-align="center"></el-table-column>
<el-table-column label="实际抢票时间" prop="grabTime" width="180" sortable>
<template scope="{row}">
<span>{{formatDateTime(row.grabTime)}}</span>
</template>
</el-table-column>
<el-table-column label="操作" fixed="right" width="90" v-if="$auth.hasPermission('coffeeTicket.statistics.delete')">
<template scope="{row}">
<el-button size="mini" type="danger" @click="onDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!--#include("/layouts/pagination.html"){}#-->
</el-card>
</guava>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
mixins: [initTableMixins],
data() {
return {
activityList: [],
unionList: [],
unitList: [],
grabDateList: [],
pageForm: {
pageNumber: 1,
pageSize: 10,
totalCount: 0,
year: new Date().getFullYear().toString(),
activityId: "",
grabDate: "",
unionId: "",
unitId: ""
},
grabDatePickerOptions: {
disabledDate: (date) => {
const value = this.$moment(date).format("YYYY-MM-DD")
return this.grabDateList.indexOf(value) === -1
}
}
}
},
methods: {
formatDateTime(value) {
return value ? this.$moment(value).format("YYYY-MM-DD HH:mm:ss") : "--"
},
yearChange() {
this.getActivityList(true)
},
activityChange() {
this.getGrabDateList(true)
},
unionChange() {
this.pageForm.unitId = ""
this.getUnitList()
this.getGrabDateList(false)
},
unitChange() {
this.getGrabDateList(false)
},
getActivityList(searchAfterLoad) {
this.$axios.post(loc() + "/activityList", { year: this.pageForm.year })
.then((res) => {
if (res.code === 0) {
this.activityList = res.data || []
this.pageForm.activityId = this.activityList.length > 0 ? this.activityList[0].id : ""
if (searchAfterLoad) {
this.getGrabDateList(true)
}
} else {
this.$message.warning(res.msg)
}
})
},
getUnionList() {
this.$businessTool.listUnion()
.then((res) => {
this.unionList = res || []
})
},
getUnitList() {
this.$businessTool.listUnit(this.pageForm.unionId)
.then((res) => {
this.unitList = res || []
})
},
getGrabDateList(clearInvalidDate) {
this.$axios.post(loc() + "/grabDateList", {
year: this.pageForm.year,
activityId: this.pageForm.activityId,
unionId: this.pageForm.unionId,
unitId: this.pageForm.unitId
})
.then((res) => {
if (res.code === 0) {
this.grabDateList = res.data || []
if (clearInvalidDate && this.pageForm.grabDate && this.grabDateList.indexOf(this.pageForm.grabDate) === -1) {
this.pageForm.grabDate = ""
}
this.doSearch()
} else {
this.$message.warning(res.msg)
}
})
},
pageData() {
this.$axios.post(loc() + "/pageData", this.pageForm)
.then((res) => {
if (res.code === 0) {
this.tableData = res.data.list
this.pageForm.totalCount = res.data.totalCount
} else {
this.$message.warning(res.msg)
}
})
},
exportExcel() {
this.$downLoad(loc() + "/export", this.pageForm)
},
onDelete(row) {
this.$confirm("删除后该用户可重新参与该活动抢票,确定删除吗?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning"
}).then(() => {
this.$axios.post(loc() + "/delete", { recordId: row.recordId })
.then((res) => {
if (res.code === 0) {
this.$message.success("删除成功")
this.pageData()
} else {
this.$message.warning(res.msg)
}
})
}).catch(() => {})
}
},
created() {
this.getActivityList(true)
this.getUnionList()
this.getUnitList()
}
})
</script>
<!--#
}
#-->
@@ -71,76 +71,14 @@ layout("/layouts/platform.html"){
</style>
<div id="app" v-cloak>
<div class="qsv-new-page">
<div class="qsv-new-header">
<h2 class="qsv-new-title">{{pageTitle}}</h2>
<el-button icon="el-icon-back" @click="goBack">返回</el-button>
</div>
<el-card shadow="never" class="qsv-new-card">
<h3 class="qsv-new-sub-title">基础信息</h3>
<el-form :model="formData" ref="formRef" :rules="formRules" label-width="150px">
<el-form-item label="类型" prop="category">
<el-radio-group v-model="formData.category" size="small" @change="onCategoryChange">
<el-radio v-for="item in categoryOptions" :label="item.code"
:key="item.code"
border>
{{item.label}}
</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="标题" prop="title">
<el-input v-model="formData.title" placeholder="请输入标题" maxlength="50"
show-word-limit></el-input>
</el-form-item>
<el-form-item label="说明" prop="description">
<text-editor v-model="formData.description" :height="150"></text-editor>
</el-form-item>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="开始时间" prop="startTime">
<el-date-picker v-model="formData.startTime" type="datetime" placeholder="选择日期时间"
style="width: 100%"
value-format="yyyy-MM-dd HH:mm:ss"></el-date-picker>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="结束时间" prop="endTime">
<el-date-picker v-model="formData.endTime" type="datetime" placeholder="选择日期时间"
style="width: 100%"
value-format="yyyy-MM-dd HH:mm:ss"></el-date-picker>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="参加人员范围" prop="groupId">
<div style="display: flex;column-gap: 10px">
<el-select class="qsv-scope-select"
clearable filterable
placeholder="参加人员范围"
style="width: 640px; max-width: calc(100% - 82px);"
v-model="formData.groupId">
<el-option :key="item.groupId"
:label="item.groupName"
:value="item.groupId"
v-for="item in activityGroupOptions"></el-option>
</el-select>
<el-button
@click="$refs.drawerUserScope.userScopeDialog = true"
type="primary">设置
</el-button>
</div>
</el-form-item>
<h3 class="qsv-new-sub-title" v-if="formData.category==='QUIZ'">题目信息</h3>
<template v-if="formData.category==='QUIZ'">
<el-form-item label="答题模式" prop="mode">
<el-radio-group v-model="formData.mode" size="small" @change="onModeChange">
<el-radio v-for="item in modeOptions" :label="item.code"
<template>
<custom-card>
<h2 slot="header" >{{pageTitle}}</h2>
<template>
<el-form :model="formData" ref="formRef" :rules="formRules" label-width="150px">
<el-form-item label="类型" prop="category">
<el-radio-group v-model="formData.category" size="small" @change="onCategoryChange">
<el-radio v-for="item in categoryOptions" :label="item.code"
:key="item.code"
border>
{{item.label}}
@@ -148,85 +86,142 @@ layout("/layouts/platform.html"){
</el-radio-group>
</el-form-item>
<el-form-item label="常规题目范围" prop="displayMode"
v-if="isRegularMode()">
<el-radio-group v-model="formData.displayMode" size="small" @change="normalizeQuizConfig">
<el-radio label="ALL" border>显示全部题目</el-radio>
<el-radio label="RANDOM" border>随机抽取题目</el-radio>
</el-radio-group>
<el-form-item label="标题" prop="title">
<el-input v-model="formData.title" placeholder="请输入标题" maxlength="50"
show-word-limit></el-input>
</el-form-item>
<el-form-item label="题目展示顺序打乱" prop="shuffleSubject">
<el-switch v-model="formData.shuffleSubject" inactive-text="否" active-text="是"></el-switch>
<el-form-item label="说明" prop="description">
<text-editor v-model="formData.description" :height="150"></text-editor>
</el-form-item>
<el-form-item label="每次随机题数" prop="randomCount"
v-if="isRegularMode() && isRandomDisplayMode()">
<el-input-number v-model="formData.randomCount" :min="1" :max="100"></el-input-number>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="开始时间" prop="startTime">
<el-date-picker v-model="formData.startTime" type="datetime" placeholder="选择日期时间"
style="width: 100%"
value-format="yyyy-MM-dd HH:mm:ss"></el-date-picker>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="结束时间" prop="endTime">
<el-date-picker v-model="formData.endTime" type="datetime" placeholder="选择日期时间"
style="width: 100%"
value-format="yyyy-MM-dd HH:mm:ss"></el-date-picker>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="参加人员范围" prop="groupId">
<div style="display: flex;column-gap: 10px">
<el-select class="qsv-scope-select"
clearable filterable
placeholder="参加人员范围"
style="width: 640px; max-width: calc(100% - 82px);"
v-model="formData.groupId">
<el-option :key="item.groupId"
:label="item.groupName"
:value="item.groupId"
v-for="item in activityGroupOptions"></el-option>
</el-select>
<el-button
@click="$refs.drawerUserScope.userScopeDialog = true"
type="primary">设置
</el-button>
</div>
</el-form-item>
<el-form-item label="最多随机答题次数" prop="totalRandom"
v-if="isRegularMode() && isRandomDisplayMode()">
<el-input-number v-model="formData.totalRandom" :min="1" :max="100"></el-input-number>
</el-form-item>
<h3 class="qsv-new-sub-title" v-if="formData.category==='QUIZ'">题目信息</h3>
<el-form-item label="允许重复答题" prop="repeatable"
v-if="canRepeatAnswer()">
<el-switch v-model="formData.repeatable" inactive-text="否" active-text="是"
@change="normalizeQuizConfig"></el-switch>
</el-form-item>
<template v-if="formData.category==='QUIZ'">
<el-form-item label="答题模式" prop="mode">
<el-radio-group v-model="formData.mode" size="small" @change="onModeChange">
<el-radio v-for="item in modeOptions" :label="item.code"
:key="item.code"
border>
{{item.label}}
</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="最多答题次数" prop="maxAttempts"
v-if="canRepeatAnswer() && formData.repeatable">
<el-input-number v-model="formData.maxAttempts" :min="1" :max="100"></el-input-number>
</el-form-item>
<el-form-item label="常规题目范围" prop="displayMode"
v-if="isRegularMode()">
<el-radio-group v-model="formData.displayMode" size="small" @change="normalizeQuizConfig">
<el-radio label="ALL" border>显示全部题目</el-radio>
<el-radio label="RANDOM" border>随机抽取题目</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="时间限制(min)" prop="timeLimit">
<el-input-number v-model="formData.timeLimit" :min="0" :max="100"
:precision="0"
placeholder="0表示不限时"></el-input-number>
</el-form-item>
<el-form-item label="题目展示顺序打乱" prop="shuffleSubject">
<el-switch v-model="formData.shuffleSubject" inactive-text="否" active-text="是"></el-switch>
</el-form-item>
<el-form-item label="重复答题计分方式" prop="scoreMode"
v-if="canRepeatAnswer() && formData.repeatable">
<el-radio-group v-model="formData.scoreMode" size="small">
<el-radio v-for="item in dict.type.ACTIVITY_QSV_SCORE_MODE" :label="item.code"
:key="item.code" border>
{{item.label}}
</el-radio>
</el-radio-group>
<el-form-item label="每次随机题数" prop="randomCount"
v-if="isRegularMode() && isRandomDisplayMode()">
<el-input-number v-model="formData.randomCount" :min="1" :max="100"></el-input-number>
</el-form-item>
<el-form-item label="最多随机答题次数" prop="totalRandom"
v-if="isRegularMode() && isRandomDisplayMode()">
<el-input-number v-model="formData.totalRandom" :min="1" :max="100"></el-input-number>
</el-form-item>
<el-form-item label="允许重复答题" prop="repeatable"
v-if="canRepeatAnswer()">
<el-switch v-model="formData.repeatable" inactive-text="否" active-text="是"
@change="normalizeQuizConfig"></el-switch>
</el-form-item>
<el-form-item label="最多答题次数" prop="maxAttempts"
v-if="canRepeatAnswer() && formData.repeatable">
<el-input-number v-model="formData.maxAttempts" :min="1" :max="100"></el-input-number>
</el-form-item>
<el-form-item label="时间限制(min)" prop="timeLimit">
<el-input-number v-model="formData.timeLimit" :min="0" :max="100"
:precision="0"
placeholder="0表示不限时"></el-input-number>
</el-form-item>
<el-form-item label="重复答题计分方式" prop="scoreMode"
v-if="canRepeatAnswer() && formData.repeatable">
<el-radio-group v-model="formData.scoreMode" size="small">
<el-radio v-for="item in dict.type.ACTIVITY_QSV_SCORE_MODE" :label="item.code"
:key="item.code" border>
{{item.label}}
</el-radio>
</el-radio-group>
</el-form-item>
</template>
<el-form-item label="封面" prop="cover">
<file-upload
:upload_number="1"
:upload_size="1024 * 1024"
:value.sync="formData.cover"
accept=".jpg,.jpeg,.png"
upload_mode="image"
upload_result_category="interval"
upload_result_type="url"
></file-upload>
<div class="qsv-cover-tip">
只能上传<span>1</span>个文件;只能上传<span>.jpg,.jpeg,.png</span>文件;单个文件大小不能超过<span>1.00</span>M;
</div>
</el-form-item>
</el-form>
<template slot="footer">
<el-button @click="goBack">取消</el-button>
<el-button :loading="saveLoading" @click="saveDraft">保存</el-button>
<el-button type="primary" :loading="saveLoading" @click="submitActivity">提交</el-button>
</template>
<el-form-item label="封面" prop="cover">
<file-upload
:upload_number="1"
:upload_size="1024 * 1024"
:value.sync="formData.cover"
accept=".jpg,.jpeg,.png"
upload_mode="image"
upload_result_category="interval"
upload_result_type="url"
></file-upload>
<div class="qsv-cover-tip">
只能上传<span>1</span>个文件;只能上传<span>.jpg,.jpeg,.png</span>文件;单个文件大小不能超过<span>1.00</span>M;
</div>
</el-form-item>
</el-form>
</el-card>
<div class="qsv-new-footer">
<el-button @click="goBack">取消</el-button>
<el-button :loading="saveLoading" @click="saveDraft">保存</el-button>
<el-button type="primary" :loading="saveLoading" @click="submitActivity">提交</el-button>
</div>
<drawer-user-scope
@group_change="getActivityGroup"
ref="drawerUserScope"
:group_id.sync="formData.groupId"
></drawer-user-scope>
</div>
</template>
</custom-card>
</template>
<drawer-user-scope
@group_change="getActivityGroup"
ref="drawerUserScope"
:group_id.sync="formData.groupId"
></drawer-user-scope>
</div>
<script nonce="${cspNonce!}">
@@ -84,6 +84,39 @@ layout("/layouts/platform.html"){
margin-left: 4px;
}
.qsv-online-score-tag {
display: inline-block;
margin-left: 10px;
padding: 0 8px;
height: 22px;
line-height: 22px;
border-radius: 3px;
color: #409eff;
background: #ecf5ff;
border: 1px solid #d9ecff;
font-size: 12px;
font-weight: normal;
}
.qsv-online-result-score {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 18px 20px;
color: #409eff;
background: #f0f7ff;
border: 1px solid #d9ecff;
border-radius: 4px;
margin-bottom: 12px;
font-size: 15px;
}
.qsv-online-result-score strong {
font-size: 28px;
line-height: 1;
}
.qsv-online-options .el-radio,
.qsv-online-options .el-checkbox {
margin: 0 32px 14px 0;
@@ -100,6 +133,36 @@ layout("/layouts/platform.html"){
max-width: 760px;
}
.qsv-online-option-correct {
color: #67c23a;
font-weight: 600;
}
.qsv-online-answer-result {
margin-top: 14px;
padding: 12px 14px;
border-radius: 4px;
font-size: 14px;
line-height: 22px;
}
.qsv-online-answer-result.correct {
color: #67c23a;
background: #f0f9eb;
border: 1px solid #e1f3d8;
}
.qsv-online-answer-result.incorrect {
color: #f56c6c;
background: #fef0f0;
border: 1px solid #fde2e2;
}
.qsv-online-correct-answer {
margin-top: 6px;
color: #606266;
}
.qsv-online-vote {
max-width: 800px;
margin: 0 auto;
@@ -171,6 +234,26 @@ layout("/layouts/platform.html"){
margin: 0 3px;
}
.qsv-online-quiz-countdown {
position: sticky;
top: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 22px;
color: #e65100;
background: #fff8e6;
border-bottom: 1px solid #ffe0a3;
font-size: 14px;
}
.qsv-online-quiz-countdown strong {
font-size: 18px;
color: #d84315;
}
.qsv-online-vote-search {
display: flex;
border-top: 8px solid #ffd8c5;
@@ -418,6 +501,12 @@ layout("/layouts/platform.html"){
</el-table-column>
<el-table-column label="开始时间" prop="startTime" sortable width="180"></el-table-column>
<el-table-column label="结束时间" prop="endTime" sortable width="180"></el-table-column>
<el-table-column label="得分" width="90" align="center">
<template scope="{row}">
<span v-if="row.category === 'QUIZ' && row.totalScore !== undefined && row.totalScore !== null">{{row.totalScore}}分</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="答题状态" width="110" align="center">
<template scope="{row}">
<el-tag :type="row.statusType || (row.isAnswered ? 'success' : 'warning')">
@@ -444,7 +533,7 @@ layout("/layouts/platform.html"){
size="1145px"
custom-class="qsv-online-drawer"
append-to-body
@closed="stopVoteCountdown">
@closed="handleAnswerDrawerClosed">
<div class="qsv-online-preview-body" v-loading="answerDialog.loading">
<div v-if="isVoteActivity" class="qsv-online-vote">
<div class="qsv-online-vote-header">
@@ -518,12 +607,22 @@ layout("/layouts/platform.html"){
</div>
<el-empty v-else description="暂无投票题目"></el-empty>
</div>
<div class="qsv-online-quiz-countdown" v-if="isQuizCountdownVisible">
<span>本次答题限时 {{answerDialog.activity.timeLimit}} 分钟</span>
<span>剩余时间:<strong>{{quizRemainingText}}</strong></span>
</div>
<div class="qsv-online-result-score" v-if="isQuizResultScoreVisible">
<span>本次答题得分</span>
<strong>{{answerTotalScore}}</strong>
<span>分</span>
</div>
<el-empty v-if="!isVoteActivity && visibleSubjects.length === 0 && !answerDialog.loading" description="暂无题目"></el-empty>
<div v-if="!isVoteActivity" v-for="(subject, index) in visibleSubjects" :key="subject.id" class="qsv-online-subject">
<div class="qsv-online-subject-title">
<span v-if="answerDialog.showIndex" class="qsv-online-subject-index">{{index + 1}}.</span>
<span>{{subject.title}}</span>
<span class="qsv-online-required">*</span>
<span class="qsv-online-score-tag" v-if="isQuizActivity && subject.score">{{subject.score}}分</span>
</div>
<div v-if="subject.type === 'radio'" class="qsv-online-options">
@@ -532,7 +631,9 @@ layout("/layouts/platform.html"){
:value="getRadioValue(subject)"
@input="setRadioValue(subject, $event)">
<template v-for="option in subject.options">
<el-radio :key="option.id" :label="option.id">{{option.text}}</el-radio>
<el-radio :key="option.id" :label="option.id">
<span :class="getOptionTextClass(option)">{{option.text}}</span>
</el-radio>
<el-input
v-if="isOptionFillVisible(subject, option)"
:key="option.id + '-fill'"
@@ -551,7 +652,9 @@ layout("/layouts/platform.html"){
:disabled="answerDialog.readonly"
@change="onCheckboxChange(subject)">
<template v-for="option in subject.options">
<el-checkbox :key="option.id" :label="option.id">{{option.text}}</el-checkbox>
<el-checkbox :key="option.id" :label="option.id">
<span :class="getOptionTextClass(option)">{{option.text}}</span>
</el-checkbox>
<el-input
v-if="isOptionFillVisible(subject, option)"
:key="option.id + '-fill'"
@@ -570,6 +673,17 @@ layout("/layouts/platform.html"){
:readonly="answerDialog.readonly"
v-model="subject.userFillContent"
:placeholder="'请输入' + subject.title"></el-input>
<div
v-if="isQuizAnswerResultVisible(subject)"
:class="['qsv-online-answer-result', isSubjectCorrect(subject) ? 'correct' : 'incorrect']">
<div>
<i :class="isSubjectCorrect(subject) ? 'el-icon-success' : 'el-icon-error'"></i>
{{isSubjectCorrect(subject) ? '回答正确' : '回答错误'}}
</div>
<div class="qsv-online-correct-answer" v-if="!isSubjectCorrect(subject) && getCorrectAnswerText(subject)">
正确答案:{{getCorrectAnswerText(subject)}}
</div>
</div>
</div>
</div>
<div class="qsv-online-footer">
@@ -657,6 +771,9 @@ layout("/layouts/platform.html"){
seconds: 0
},
voteTimer: null,
answerTime: 0,
quizTimer: null,
quizTimerStarted: false,
voteRankVisible: false,
voteDetailVisible: false,
voteDetailOption: null
@@ -672,6 +789,9 @@ layout("/layouts/platform.html"){
isVoteActivity() {
return this.answerDialog.activity && this.answerDialog.activity.category === "VOTE"
},
isQuizActivity() {
return this.answerDialog.activity && this.answerDialog.activity.category === "QUIZ"
},
currentVoteSubject() {
return this.visibleSubjects.length > 0 ? this.visibleSubjects[0] : null
},
@@ -693,6 +813,42 @@ layout("/layouts/platform.html"){
}
return this.$moment().unix() > this.$moment(this.answerDialog.activity.endTime).unix()
},
isQuizCountdownVisible() {
return this.isQuizActivity
&& !this.answerDialog.readonly
&& Number(this.answerDialog.activity.timeLimit || 0) > 0
},
quizRemainingSeconds() {
if (!this.isQuizCountdownVisible) {
return 0
}
return Math.max(0, Number(this.answerDialog.activity.timeLimit || 0) * 60 - Number(this.answerDialog.answerTime || 0))
},
quizRemainingText() {
const totalSeconds = this.quizRemainingSeconds
const minutes = Math.floor(totalSeconds / 60)
const seconds = totalSeconds % 60
return this.padTime(minutes) + ":" + this.padTime(seconds)
},
isQuizResultScoreVisible() {
return this.isQuizActivity && this.answerDialog.readonly && this.answerDialog.answerRecord && this.answerDialog.answerRecord.isFinish
},
answerTotalScore() {
if (!this.answerDialog.answerRecord) {
return 0
}
if (this.answerDialog.answerRecord.totalScore !== undefined && this.answerDialog.answerRecord.totalScore !== null) {
return this.answerDialog.answerRecord.totalScore
}
const extJson = this.answerDialog.answerRecord.extJson || {}
let totalScore = 0
for (const key in extJson) {
if (extJson.hasOwnProperty(key)) {
totalScore += extJson[key].score || 0
}
}
return totalScore
},
submitSubjects() {
if (this.isVoteActivity) {
return this.currentVoteSubject ? [this.currentVoteSubject] : []
@@ -794,6 +950,7 @@ layout("/layouts/platform.html"){
this.answerDialog.voteRankVisible = false
this.answerDialog.voteDetailVisible = false
this.answerDialog.voteDetailOption = null
this.resetQuizCountdown()
this.stopVoteCountdown()
$.post("/platform/qsv/online/subjects", {
activityId: row.id,
@@ -827,6 +984,7 @@ layout("/layouts/platform.html"){
this.answerDialog.answerRecord = res.data || {}
this.answerDialog.readonly = !!this.answerDialog.answerRecord.isFinish
this.initAnswer()
this.confirmStartQuizCountdown()
}
})
},
@@ -868,6 +1026,35 @@ layout("/layouts/platform.html"){
isOptionSelected(subject, optionId) {
return subject.userSelectOptionIds && subject.userSelectOptionIds.map(String).includes(String(optionId))
},
getSubjectAnswer(subject) {
const extJson = this.answerDialog.answerRecord && this.answerDialog.answerRecord.extJson
? this.answerDialog.answerRecord.extJson
: {}
return subject && subject.id ? extJson[subject.id] : null
},
isQuizAnswerResultVisible(subject) {
return this.isQuizResultScoreVisible && !!this.getSubjectAnswer(subject)
},
isSubjectCorrect(subject) {
const answer = this.getSubjectAnswer(subject)
return !!(answer && answer.isCorrect)
},
getCorrectAnswerText(subject) {
if (!subject || !subject.options) {
return ""
}
return subject.options.filter((option) => {
return option.isCorrect
}).map((option) => {
return option.text
}).join("、")
},
getOptionTextClass(option) {
if (this.isQuizResultScoreVisible && option && option.isCorrect) {
return "qsv-online-option-correct"
}
return ""
},
toggleVoteOption(option) {
const subject = this.currentVoteSubject
if (!subject || this.answerDialog.readonly || this.isVoteEnd) {
@@ -927,6 +1114,60 @@ layout("/layouts/platform.html"){
this.answerDialog.voteCountdown.minutes = Math.floor((diff % 3600) / 60)
this.answerDialog.voteCountdown.seconds = diff % 60
},
padTime(value) {
const text = String(value)
return text.length > 1 ? text : "0" + text
},
resetQuizCountdown() {
this.stopQuizCountdown()
this.answerDialog.answerTime = 0
this.answerDialog.quizTimerStarted = false
},
stopQuizCountdown() {
if (this.answerDialog.quizTimer) {
clearInterval(this.answerDialog.quizTimer)
this.answerDialog.quizTimer = null
}
},
confirmStartQuizCountdown() {
if (!this.isQuizCountdownVisible || this.answerDialog.quizTimerStarted) {
return
}
this.$alert("本次答题时间" + this.answerDialog.activity.timeLimit + "分钟,点击确认开始答题", "提示", {
confirmButtonText: "确定",
callback: (action) => {
if (action === "confirm" && this.answerDialog.visible) {
this.startQuizCountdown()
}
}
})
},
startQuizCountdown() {
if (!this.isQuizCountdownVisible || this.answerDialog.quizTimerStarted) {
return
}
this.answerDialog.quizTimerStarted = true
// PC 端限时答题与手机端一致:从用户确认开始后累计答题用时,到时停止并提示刷新。
this.answerDialog.quizTimer = setInterval(() => {
this.answerDialog.answerTime++
if (this.quizRemainingSeconds <= 0) {
this.handleQuizCountdownFinished()
}
}, 1000)
},
handleQuizCountdownFinished() {
this.stopQuizCountdown()
this.$alert("答题时间到,请刷新页面重新答题", "提示", {
confirmButtonText: "确定",
callback: () => {
window.location.reload()
}
})
},
handleAnswerDrawerClosed() {
this.stopVoteCountdown()
this.resetQuizCountdown()
},
getVoteOptionStats(optionId) {
return this.answerDialog.voteStats.optionStats && this.answerDialog.voteStats.optionStats[optionId]
? this.answerDialog.voteStats.optionStats[optionId]
@@ -1135,12 +1376,18 @@ layout("/layouts/platform.html"){
userFillContent: subject.type === "text" ? subject.userFillContent : null,
optionFillContents: ["checkbox", "radio"].includes(subject.type) ? subject.optionFillContents : {}
}
})
}),
answerTime: this.isQuizActivity ? this.answerDialog.answerTime : null
})
}).then((res) => {
if (res.code === 0) {
this.stopQuizCountdown()
this.$message.success(res.msg)
this.answerDialog.visible = false
if (this.isQuizActivity) {
this.loadAnswerRecord()
} else {
this.answerDialog.visible = false
}
this.pageData()
} else {
this.$message.error(res.msg)
@@ -1156,6 +1403,7 @@ layout("/layouts/platform.html"){
},
beforeDestroy() {
this.stopVoteCountdown()
this.stopQuizCountdown()
}
})
</script>
@@ -69,6 +69,11 @@ layout("/layouts/platform.html"){
<el-table-column label="联系方式" prop="mobile" sortable></el-table-column>
<el-table-column label="答题日期" prop="attemptDate" sortable></el-table-column>
<el-table-column label="得分" prop="totalScore" sortable></el-table-column>
<el-table-column label="操作" fixed="right" width="100px" v-if="$auth.hasPermission('qsv.quiz.rank.delete')">
<template slot-scope="{row}">
<el-button @click="deleteAnswerRecord(row.id)" size="mini" type="danger">删除</el-button>
</template>
</el-table-column>
</el-table>
<!--#include("/layouts/pagination.html"){}#-->
</el-card>
@@ -106,6 +111,20 @@ layout("/layouts/platform.html"){
exportXlsx() {
this.$downLoad("/platform/qsv/quizRank/exportXlsx", this.pageForm)
},
deleteAnswerRecord(id) {
this.$confirm("您确定要删除该答题记录吗?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning"
}).then(() => {
this.$axios.post("/platform/qsv/quizRank/deleteAnswerRecord", {id}).then((res) => {
if (res.code === 0) {
this.$message.success(res.msg)
this.pageData()
}
})
})
},
async flushUnits() {
this.$set(this.pageForm, "unitId", null)
this.unitList = []
@@ -0,0 +1,374 @@
<!--#
layout("/layouts/platform_h5.html"){
#-->
<style scoped>
body {
background: #f5f7fb;
}
.detail-page {
min-height: 100vh;
padding-bottom: 74px;
background: #f5f7fb;
}
.detail-cover {
position: relative;
height: 170px;
overflow: hidden;
background: linear-gradient(135deg, #dfeeff, #f8fbff);
}
.detail-cover img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.detail-cover-empty {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #9aa8ba;
font-size: 13px;
}
.detail-card {
margin: -10px 12px 12px;
position: relative;
z-index: 1;
border-radius: 10px;
background: #ffffff;
box-shadow: 0 6px 18px rgba(25, 54, 94, .08);
overflow: hidden;
}
.detail-main {
padding: 16px 14px 8px;
}
.detail-title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
margin-bottom: 8px;
}
.detail-title {
color: #1f2d3d;
font-size: 20px;
line-height: 28px;
font-weight: 700;
}
.detail-status {
flex-shrink: 0;
margin-top: 2px;
padding: 3px 8px;
border-radius: 12px;
background: #e7f8ee;
color: #21b45b;
font-size: 12px;
line-height: 16px;
}
.detail-status.upcoming {
background: #fff5e5;
color: #f29b22;
}
.detail-status.ended {
background: #eef0f5;
color: #8d98a8;
}
.detail-desc {
color: #4b5565;
font-size: 13px;
line-height: 20px;
}
.detail-info {
margin-top: 12px;
border-top: 1px solid #eef1f6;
}
.detail-info-row {
display: grid;
grid-template-columns: 22px 96px minmax(0, 1fr);
align-items: center;
min-height: 44px;
border-bottom: 1px solid #eef1f6;
color: #334155;
font-size: 13px;
}
.detail-info-row i {
color: #71829b;
font-size: 16px;
}
.detail-info-label {
color: #4b5565;
}
.detail-info-value {
text-align: right;
color: #243044;
font-weight: normal;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.notice-card {
margin: 0 12px 12px;
padding: 14px;
border-radius: 10px;
background: #ffffff;
box-shadow: 0 6px 18px rgba(25, 54, 94, .08);
box-sizing: border-box;
overflow: hidden;
}
.notice-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
color: #1f2d3d;
font-size: 15px;
font-weight: 700;
}
.notice-title i {
color: #1f73ff;
font-size: 18px;
}
.notice-content {
max-width: 100%;
color: #5f6f86;
font-size: 12px;
line-height: 22px;
white-space: pre-line;
word-break: break-all;
overflow-wrap: anywhere;
}
.detail-footer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 10;
padding: 10px 14px calc(10px + env(safe-area-inset-bottom));
border-top: 1px solid #eef1f6;
background: #ffffff;
box-sizing: border-box;
}
.detail-action {
height: 42px;
border-radius: 6px;
font-weight: 600;
}
</style>
<div id="app" v-cloak class="detail-page">
<van-nav-bar title="活动详情" left-arrow placeholder fixed @click-left="historyBack">
</van-nav-bar>
<div class="detail-cover">
<img v-if="formData.cover" :src="formData.cover">
<div v-else class="detail-cover-empty">暂无封面</div>
</div>
<div class="detail-card">
<div class="detail-main">
<div class="detail-title-row">
<div class="detail-title">{{formData.title || '--'}}</div>
<div class="detail-status" :class="statusInfo.className">{{statusInfo.text}}</div>
</div>
<div class="detail-desc">{{formData.description || '暂无活动描述'}}</div>
<div class="detail-info">
<div class="detail-info-row">
<i class="van-icon van-icon-calendar-o"></i>
<div class="detail-info-label">活动周期</div>
<div class="detail-info-value">{{formatDate(formData.activityStartDate)}} ~ {{formatDate(formData.activityEndDate)}}</div>
</div>
<div class="detail-info-row">
<i class="van-icon van-icon-todo-list-o"></i>
<div class="detail-info-label">可抢票星期</div>
<div class="detail-info-value">{{formatWeekDays(formData.weekDays)}}</div>
</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>
<div class="detail-info-row">
<i class="van-icon van-icon-clock-o"></i>
<div class="detail-info-label">抢票时间段</div>
<div class="detail-info-value">{{formData.reserveStartTime || '--'}} - {{formData.reserveEndTime || '--'}}</div>
</div>
<div class="detail-info-row">
<i class="van-icon van-icon-location-o"></i>
<div class="detail-info-label">活动地点</div>
<div class="detail-info-value">{{formData.address || '--'}}</div>
</div>
<div class="detail-info-row" v-if="formData.hasGrabbed">
<i class="van-icon van-icon-passed"></i>
<div class="detail-info-label">我的抢票</div>
<div class="detail-info-value">已抢 {{formData.myTicketNum || 0}} 张</div>
</div>
</div>
</div>
</div>
<div class="notice-card">
<div class="notice-title">
<i class="van-icon van-icon-bookmark-o"></i>
<span>参与须知</span>
</div>
<div class="notice-content">{{noticeText}}</div>
</div>
<div class="detail-footer">
<van-button class="detail-action" type="info" block :disabled="formData.hasGrabbed || ticketLoading" :loading="ticketLoading" loading-text="校验中..." @click="onTicket">{{formData.hasGrabbed ? '已抢票' : '立即抢票'}}</van-button>
</div>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
data() {
return {
formLoading: false,
ticketLoading: false,
formData: {},
weekMap: {
"1": "周一",
"2": "周二",
"3": "周三",
"4": "周四",
"5": "周五",
"6": "周六",
"7": "周日"
},
defaultNotice: "1. 请提前到达活动地点,活动开始后15分钟停止入场。\n2. 每人每次最多可预约40张,请合理安排名额。\n3. 预约成功后如需取消,请提前2小时操作。\n4. 如有疑问,请联系客服或关注活动公告。"
}
},
computed: {
statusInfo() {
const now = this.$moment()
if (this.formData.activityEndDate && now.isAfter(this.$moment(this.formData.activityEndDate).endOf("day"))) {
return { text: "已结束", className: "ended" }
}
if (this.formData.activityStartDate && now.isBefore(this.$moment(this.formData.activityStartDate).startOf("day"))) {
return { text: "即将开始", className: "upcoming" }
}
return { text: "报名中", className: "" }
},
noticeText() {
return this.formData.notice || this.defaultNotice
}
},
methods: {
getQueryString(name) {
const reg = new RegExp("(^|&)" + name + "=([^&]*)(&|$)")
const result = window.location.search.substr(1).match(reg)
return result ? decodeURIComponent(result[2]) : ""
},
formatDate(value) {
return value ? this.$moment(value).format("YYYY-MM-DD") : "--"
},
formatDateTime(value) {
return value ? this.$moment(value).format("YYYY-MM-DD HH:mm") : "--"
},
formatWeekDays(weekDays) {
if (!weekDays || weekDays.length === 0) {
return "--"
}
return weekDays.map(item => this.weekMap[item] || item).join("、")
},
loadData() {
const id = this.getQueryString("id")
if (!id) {
this.$toast.fail("活动ID不能为空")
return
}
this.formLoading = true
this.$axios.post("/platform/h5/coffeeTicket/detail/findOne", { id: id })
.then((res) => {
if (res.code === 0) {
this.$set(this, "formData", res.data || {})
} else {
this.$toast.fail(res.msg)
}
})
.finally(() => {
this.formLoading = false
})
},
onTicket() {
if (this.ticketLoading) {
return
}
if (!this.formData.id) {
this.$toast.fail("活动ID不能为空")
return
}
if (this.formData.hasGrabbed) {
this.$toast("您已抢过该活动票")
return
}
this.$dialog.confirm({
title: "确认抢票",
message: "确定要抢该活动票吗?",
confirmButtonText: "确认抢票",
cancelButtonText: "再看看"
})
.then(() => {
this.doGrabTicket()
})
.catch(() => {
})
},
doGrabTicket() {
if (this.ticketLoading) {
return
}
this.ticketLoading = true
this.$axios.post("/platform/h5/coffeeTicket/detail/grab", {
id: this.formData.id,
ticketNum: 1
})
.then((res) => {
if (res.code === 0) {
const record = res.data || {}
this.$set(this.formData, "hasGrabbed", true)
this.$set(this.formData, "myTicketNum", record.ticketNum || 1)
this.$set(this.formData, "myGrabTime", record.grabTime || "")
this.$toast.success(res.msg || "抢票成功")
} else {
this.$toast.fail(res.msg)
}
})
.finally(() => {
this.ticketLoading = false
})
}
},
mounted() {
this.loadData()
}
})
</script>
<!--#
}
#-->
@@ -0,0 +1,390 @@
<!--#
layout("/layouts/platform_h5.html"){
#-->
<style scoped>
body {
background: #f5f7fb;
}
.ticket-page {
min-height: 100vh;
padding-bottom: 62px;
background: #f5f7fb;
}
.ticket-sticky {
background: #f5f7fb;
box-shadow: 0 4px 12px rgba(30, 70, 130, .05);
}
.ticket-search {
padding: 8px 14px 4px;
background: #f5f7fb;
}
.ticket-tabs {
padding: 0 12px 8px;
background: #f5f7fb;
}
.ticket-tabs /deep/ .van-tabs__nav {
background: transparent;
}
.ticket-tabs /deep/ .van-tab {
flex: none;
margin-right: 8px;
padding: 0 14px;
border-radius: 15px;
background: #eef2f8;
color: #7a8799;
font-size: 12px;
line-height: 30px;
}
.ticket-tabs /deep/ .van-tab--active {
background: #1f73ff;
color: #ffffff;
font-weight: 600;
}
.ticket-tabs /deep/ .van-tabs__line {
display: none;
}
.ticket-list {
padding: 14px 14px 0;
}
.ticket-card {
position: relative;
margin-bottom: 16px;
padding: 20px;
overflow: hidden;
border-radius: 14px;
background: #ffffff;
box-shadow: 0 10px 30px rgba(0, 0, 0, .08);
box-sizing: border-box;
}
.ticket-cover {
position: relative;
height: 150px;
overflow: hidden;
border-radius: 12px;
background: linear-gradient(135deg, #dfeeff, #f8fbff);
}
.ticket-cover img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.ticket-cover-empty {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #9aa8ba;
font-size: 13px;
}
.ticket-status {
position: absolute;
top: 8px;
right: 8px;
z-index: 1;
padding: 3px 8px;
border-radius: 12px;
border: 1px solid #82d9a1;
background: #eefbf3;
color: #19a957;
font-size: 12px;
line-height: 16px;
font-weight: 600;
}
.ticket-status.upcoming {
background: #fff5e5;
color: #f29b22;
}
.ticket-status.ended {
background: #eef0f5;
color: #8d98a8;
}
.ticket-content {
padding: 14px 0 0;
}
.ticket-title {
margin-bottom: 12px;
color: #1f2d3d;
font-size: 17px;
line-height: 24px;
font-weight: 700;
}
.ticket-desc {
margin-bottom: 10px;
color: #7a8799;
font-size: 12px;
line-height: 18px;
}
.ticket-info {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
margin-bottom: 16px;
font-size: 12px;
line-height: 18px;
}
.ticket-info-row {
display: grid;
grid-template-columns: 20px 78px minmax(0, 1fr);
gap: 4px;
align-items: center;
min-width: 0;
}
.ticket-info-row i {
color: #8ca0bc;
font-size: 15px;
flex-shrink: 0;
}
.ticket-info-label {
color: #8b98aa;
}
.ticket-info-value {
color: #243044;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ticket-week-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
overflow: visible;
white-space: normal;
}
.ticket-week-tag {
display: inline-flex;
align-items: center;
height: 20px;
padding: 0 7px;
border: 1px solid #8ec8ff;
border-radius: 4px;
background: #eef8ff;
color: #1684e8;
font-size: 11px;
line-height: 18px;
box-sizing: border-box;
}
.ticket-quota {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
color: #7a8799;
font-size: 12px;
}
.ticket-quota strong {
color: #1f73ff;
font-size: 15px;
}
.ticket-action {
height: 42px;
border-radius: 8px;
border: 0;
background: #1890ff;
font-weight: 600;
box-shadow: 0 8px 18px rgba(24, 144, 255, .26);
}
</style>
<div id="app" v-cloak class="ticket-page">
<van-nav-bar title="抢票活动" left-text="返回" left-arrow placeholder fixed @click-left="historyBack"></van-nav-bar>
<van-sticky offset-top="46px" class="ticket-sticky">
<div class="ticket-search">
<van-search
v-model="pageForm.searchKeyword"
placeholder="搜索活动名称或地点"
shape="round"
@search="doSearch">
</van-search>
</div>
<div class="ticket-tabs">
<van-tabs v-model="status" @change="doSearch">
<van-tab title="全部" name="all"></van-tab>
<van-tab title="报名中" name="active"></van-tab>
<van-tab title="即将开始" name="upcoming"></van-tab>
<van-tab title="已结束" name="ended"></van-tab>
</van-tabs>
</div>
</van-sticky>
<van-list
v-model="loading"
:finished="finished"
finished-text="没有更多了"
@load="pageData">
<div class="ticket-list">
<div class="ticket-card" v-for="(item,index) in tableData" :key="item.id">
<div class="ticket-cover">
<img v-if="item.cover" :src="item.cover">
<div v-else class="ticket-cover-empty">暂无封面</div>
<div class="ticket-status" :class="getStatusInfo(item).className">{{getStatusInfo(item).text}}</div>
</div>
<div class="ticket-content">
<div class="ticket-title">{{item.title}}</div>
<div class="ticket-info">
<div class="ticket-info-row">
<i class="van-icon van-icon-calendar-o"></i>
<span class="ticket-info-label">活动时间</span>
<span class="ticket-info-value">{{formatDate(item.activityStartDate)}} 至 {{formatDate(item.activityEndDate)}}</span>
</div>
<div class="ticket-info-row">
<i class="van-icon van-icon-clock-o"></i>
<span class="ticket-info-label">可抢票</span>
<span class="ticket-info-value ticket-week-tags" v-if="item.weekDays && item.weekDays.length > 0">
<span class="ticket-week-tag" v-for="week in item.weekDays" :key="week">{{weekMap[week] || week}}</span>
</span>
<span class="ticket-info-value" v-else>--</span>
</div>
<div class="ticket-info-row">
<i class="van-icon van-icon-underway-o"></i>
<span class="ticket-info-label">抢票时间</span>
<span class="ticket-info-value">{{item.reserveStartTime || '--'}} - {{item.reserveEndTime || '--'}}</span>
</div>
<div class="ticket-info-row">
<i class="van-icon van-icon-location-o"></i>
<span class="ticket-info-label">活动地点</span>
<span class="ticket-info-value">{{item.address || '--'}}</span>
</div>
</div>
<div class="ticket-quota">
<span>活动总票数 <strong>{{item.totalTicketNum || 0}}</strong> 张</span>
<span>剩余票数 <strong>{{item.remainingTicketNum || 0}}</strong> 张</span>
</div>
<van-button class="ticket-action" type="info" block @click="onTicket(item)">立即抢票</van-button>
</div>
</div>
</div>
</van-list>
<van-empty v-if="!loading && tableData.length === 0" description="暂无抢票活动"></van-empty>
<van-tabbar v-model="activeTab" @change="onTabChange" safe-area-inset-bottom>
<van-tabbar-item name="list" icon="wap-home-o">抢票活动</van-tabbar-item>
<van-tabbar-item name="mine" icon="coupon-o">我的抢票</van-tabbar-item>
</van-tabbar>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
data() {
return {
activeTab: "list",
status: "all",
loading: false,
finished: false,
tableData: [],
pageForm: {
pageNumber: 1,
pageSize: 10,
totalCount: 0,
searchKeyword: ""
},
weekMap: {
"1": "周一",
"2": "周二",
"3": "周三",
"4": "周四",
"5": "周五",
"6": "周六",
"7": "周日"
}
}
},
methods: {
formatDate(value) {
return value ? this.$moment(value).format("YYYY-MM-DD") : "--"
},
formatWeekDays(weekDays) {
if (!weekDays || weekDays.length === 0) {
return "--"
}
return weekDays.map(item => this.weekMap[item] || item).join(" / ")
},
getStatusInfo(row) {
const now = this.$moment()
if (row.activityEndDate && now.isAfter(this.$moment(row.activityEndDate).endOf("day"))) {
return { text: "已结束", className: "ended" }
}
if (row.activityStartDate && now.isBefore(this.$moment(row.activityStartDate).startOf("day"))) {
return { text: "即将开始", className: "upcoming" }
}
return { text: "报名中", className: "" }
},
pageData() {
const params = Object.assign({}, this.pageForm, {
status: this.status
})
this.$axios.post("/platform/h5/coffeeTicket/list/pageData", params)
.then((res) => {
if (res.code === 0) {
const list = res.data && res.data.list ? res.data.list : []
this.tableData = this.tableData.concat(list)
this.pageForm.totalCount = res.data.totalCount || 0
if (this.tableData.length >= this.pageForm.totalCount) {
this.finished = true
} else {
this.pageForm.pageNumber++
}
} else {
this.$toast.fail(res.msg)
this.finished = true
}
})
.finally(() => {
this.loading = false
})
},
doSearch() {
this.pageForm.pageNumber = 1
this.pageForm.totalCount = 0
this.tableData = []
this.finished = false
this.loading = true
this.pageData()
},
onTicket(row) {
this.$pjaxReplace('/platform/h5/coffeeTicket/detail?id='+ row.id)
},
onTabChange(name) {
if (name === "mine") {
this.$pjaxReplace("/platform/h5/coffeeTicket/mine")
}
}
}
})
</script>
<!--#
}
#-->
@@ -0,0 +1,347 @@
<!--#
layout("/layouts/platform_h5.html"){
#-->
<style scoped>
body {
background: #f5f7fb;
}
.mine-page {
min-height: 100vh;
padding-bottom: 62px;
background: #f5f7fb;
}
.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;
}
.mine-card {
display: grid;
grid-template-columns: 94px minmax(0, 1fr);
gap: 10px;
position: relative;
margin-bottom: 12px;
padding: 10px;
border-radius: 10px;
background: #ffffff;
box-shadow: 0 6px 18px rgba(25, 54, 94, .08);
overflow: hidden;
}
.mine-cover {
width: 94px;
height: 94px;
border-radius: 8px;
overflow: hidden;
background: linear-gradient(135deg, #dfeeff, #f8fbff);
}
.mine-cover img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.mine-cover-empty {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #9aa8ba;
font-size: 12px;
}
.mine-content {
min-width: 0;
padding-right: 2px;
}
.mine-title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
margin-bottom: 4px;
}
.mine-title {
min-width: 0;
color: #1f2d3d;
font-size: 14px;
line-height: 20px;
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mine-desc {
margin-bottom: 8px;
color: #7a8799;
font-size: 11px;
line-height: 16px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mine-ticket-count {
display: flex;
align-items: center;
gap: 5px;
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px dashed #e7edf5;
color: #5f6f86;
font-size: 12px;
}
.mine-ticket-count i,
.mine-info-row i {
color: #1f73ff;
font-size: 13px;
}
.mine-ticket-count strong {
color: #1f73ff;
font-size: 14px;
}
.mine-info {
display: grid;
gap: 4px;
color: #64748b;
font-size: 11px;
line-height: 16px;
}
.mine-info-row {
display: flex;
align-items: center;
min-width: 0;
}
.mine-info-row i {
width: 16px;
color: #8ca0bc;
flex-shrink: 0;
}
.mine-info-row span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mine-card-footer {
grid-column: 2;
display: flex;
justify-content: flex-end;
gap: 6px;
margin-top: -2px;
}
.mine-action {
min-width: 76px;
height: 28px;
padding: 0 12px;
border-radius: 5px;
font-size: 12px;
box-shadow: 0 5px 12px rgba(24, 144, 255, .18);
}
.mine-action-danger {
color: #ffffff;
border-color: #e3402a;
background: #e3402a;
box-shadow: 0 5px 12px rgba(227, 64, 42, .24);
}
</style>
<div id="app" v-cloak class="mine-page">
<van-nav-bar title="我的抢票" left-arrow placeholder fixed @click-left="historyBack"></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"
finished-text="没有更多了"
@load="pageData">
<div class="mine-list">
<div class="mine-card" v-for="item in tableData" :key="item.recordId">
<div class="mine-cover">
<img v-if="item.cover" :src="item.cover">
<div v-else class="mine-cover-empty">暂无封面</div>
</div>
<div class="mine-content">
<div class="mine-title-row">
<div class="mine-title">{{item.title || '--'}}</div>
</div>
<div class="mine-ticket-count">
<i class="van-icon van-icon-coupon-o"></i>
<span>已抢 <strong>{{item.ticketNum || 0}}</strong> 张</span>
</div>
<div class="mine-info">
<div class="mine-info-row">
<i class="van-icon van-icon-calendar-o"></i>
<span>活动时间 {{formatDate(item.activityStartDate)}} 至 {{formatDate(item.activityEndDate)}}</span>
</div>
<div class="mine-info-row">
<i class="van-icon van-icon-clock-o"></i>
<span>抢票时间 {{formatDateTime(item.grabTime)}}</span>
</div>
<div class="mine-info-row">
<i class="van-icon van-icon-location-o"></i>
<span>活动地点 {{item.address || '--'}}</span>
</div>
</div>
</div>
<div class="mine-card-footer">
<van-button v-if="item.canCancel" class="mine-action mine-action-danger" hairline type="danger" :loading="cancelLoading && cancelingRecordId === item.recordId" @click="onCancel(item)">取消抢票</van-button>
<van-button class="mine-action" hairline type="info" @click="onDetail(item)">查看详情</van-button>
</div>
</div>
</div>
</van-list>
<van-empty v-if="!loading && tableData.length === 0" description="暂无抢票记录"></van-empty>
<van-tabbar v-model="activeTab" @change="onTabChange" safe-area-inset-bottom>
<van-tabbar-item name="list" icon="wap-home-o">抢票活动</van-tabbar-item>
<van-tabbar-item name="mine" icon="coupon-o">我的抢票</van-tabbar-item>
</van-tabbar>
</div>
<script nonce="${cspNonce!}">
new Vue({
el: "#app",
data() {
return {
activeTab: "mine",
loading: false,
finished: false,
cancelLoading: false,
cancelingRecordId: "",
tableData: [],
pageForm: {
pageNumber: 1,
pageSize: 10,
totalCount: 0,
searchKeyword: ""
}
}
},
methods: {
formatDate(value) {
return value ? this.$moment(value).format("YYYY-MM-DD") : "--"
},
formatDateTime(value) {
return value ? this.$moment(value).format("YYYY-MM-DD HH:mm") : "--"
},
pageData() {
this.$axios.post("/platform/h5/coffeeTicket/mine/pageData", this.pageForm)
.then((res) => {
if (res.code === 0) {
const list = res.data && res.data.list ? res.data.list : []
this.tableData = this.tableData.concat(list)
this.pageForm.totalCount = res.data.totalCount || 0
if (this.tableData.length >= this.pageForm.totalCount) {
this.finished = true
} else {
this.pageForm.pageNumber++
}
} else {
this.$toast.fail(res.msg)
this.finished = true
}
})
.finally(() => {
this.loading = false
})
},
doSearch() {
this.pageForm.pageNumber = 1
this.pageForm.totalCount = 0
this.tableData = []
this.finished = false
this.loading = true
this.pageData()
},
onDetail(item) {
this.$pjaxReplace("/platform/h5/coffeeTicket/detail?id=" + item.activityId)
},
onCancel(item) {
if (!item.recordId) {
this.$toast.fail("抢票记录ID缺失")
return
}
if (!item.canCancel) {
this.$toast.fail("当前不在可取消时间内")
return
}
this.$dialog.confirm({
title: "取消确认",
message: "确定要取消本次抢票吗?",
confirmButtonText: "确认取消",
cancelButtonText: "再想想"
})
.then(() => {
this.cancelLoading = true
this.cancelingRecordId = item.recordId
this.$axios.post("/platform/h5/coffeeTicket/mine/cancel", { recordId: item.recordId })
.then((res) => {
if (res.code === 0) {
this.$toast.success("取消成功")
this.tableData = this.tableData.filter((row) => row.recordId !== item.recordId)
this.pageForm.totalCount = Math.max((this.pageForm.totalCount || 0) - 1, 0)
} else {
this.$toast.fail(res.msg)
}
})
.finally(() => {
this.cancelLoading = false
this.cancelingRecordId = ""
})
})
.catch(() => {
})
},
onTabChange(name) {
if (name === "list") {
this.$pjaxReplace("/platform/h5/coffeeTicket/list")
}
}
}
})
</script>
<!--#
}
#-->