品牌活动增加签到可以领礼品

This commit is contained in:
2026-10-15 15:22:34 +08:00
parent f1cbd97b86
commit a92d3c99c5
29 changed files with 3211 additions and 216 deletions
@@ -5,12 +5,15 @@ import io.v.nutz.annontation.ViReturn;
import io.v.nutz.brandtrainactivity.models.BrandTrainSignUpActivity; import io.v.nutz.brandtrainactivity.models.BrandTrainSignUpActivity;
import io.v.nutz.brandtrainactivity.models.BrandTrainSignUpUser; import io.v.nutz.brandtrainactivity.models.BrandTrainSignUpUser;
import io.v.nutz.brandtrainactivity.service.BrandTrainSignUpActivityService; import io.v.nutz.brandtrainactivity.service.BrandTrainSignUpActivityService;
import io.v.nutz.welfare.service.WelfareProjectService;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authz.annotation.RequiresPermissions; import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Cnd; import org.nutz.dao.Cnd;
import org.nutz.dao.Dao; import org.nutz.dao.Dao;
import org.nutz.dao.Sqls; import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql; import org.nutz.dao.sql.Sql;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.json.Json; import org.nutz.json.Json;
@@ -38,16 +41,26 @@ public class BrandTrainSignUpActivityAddController {
@Inject @Inject
private BrandTrainSignUpActivityService brandTrainSignUpActivityService; private BrandTrainSignUpActivityService brandTrainSignUpActivityService;
@Inject
private WelfareProjectService welfareProjectService;
@At("/add") @At("/add")
@RequiresPermissions("brandTrainSingUp.manage.activity.add") @RequiresPermissions("brandTrainSingUp.manage.activity.add")
@Ok("beetl:/platform/brandTrainSingUp/manage/activity_add.html") @Ok("beetl:/platform/brandTrainSingUp/manage/activity_add.html")
public void add() { public void add() {
} }
/**
* 保存品牌活动及课程配置,新增与编辑共用同一份活动JSON。
*
* @param data 品牌活动实体JSON,包含课程列表、范围限制及可选的签到扩展配置;id为空时新增
* @return 无业务数据,由ViReturn包装为包含code、msg、data的操作结果
*/
@At @At
@ViReturn @ViReturn
@Ok("json:full") @Ok("json:full")
@POST @POST
@Aop(TransAop.READ_COMMITTED)
@RequiresPermissions("brandTrainSingUp.manage.activity.add") @RequiresPermissions("brandTrainSingUp.manage.activity.add")
public Object doHandle(@Param(value = "data", required = false) String data) { public Object doHandle(@Param(value = "data", required = false) String data) {
BrandTrainSignUpActivity activity = Json.fromJson(BrandTrainSignUpActivity.class, data); BrandTrainSignUpActivity activity = Json.fromJson(BrandTrainSignUpActivity.class, data);
@@ -99,4 +112,18 @@ public class BrandTrainSignUpActivityAddController {
List<BrandTrainSignUpActivity> query = dao.query(BrandTrainSignUpActivity.class, Cnd.NEW().desc("activityStartTime")); List<BrandTrainSignUpActivity> query = dao.query(BrandTrainSignUpActivity.class, Cnd.NEW().desc("activityStartTime"));
return query; return query;
} }
/**
* 获取品牌活动签到达标后可引导选择的福利项目,本接口不接收参数。
*
* @return 福利项目列表,由ViReturn包装为code、msg、data;data每项的id、name供表单选择
*/
@At
@POST
@ViReturn
@Ok("json:full")
@RequiresPermissions("brandTrainSingUp.manage.activity")
public Object getAvailableWelfareProjectList() {
return welfareProjectService.getAvailableSignWelfareProjects();
}
} }
@@ -0,0 +1,110 @@
package io.v.nutz.brandtrainactivity.controller.mobile;
import cn.hutool.core.util.StrUtil;
import io.v.nutz.base.result.Result;
import io.v.nutz.brandtrainactivity.service.BrandTrainSignUpActivityService;
import io.v.nutz.web.commons.utils.ShiroUtil;
import org.apache.shiro.authz.annotation.RequiresAuthentication;
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.POST;
import org.nutz.mvc.annotation.Param;
import org.nutz.trans.Trans;
/** 已开启签到扩展的品牌活动点位扫码入口,登录与页面渲染沿用当前手机端方式。 */
@IocBean
@At("/platform/mobile/brandTrain/scanCode")
@Ok("json:full")
public class BrandTrainScanCodeController {
@Inject
private BrandTrainSignUpActivityService brandTrainSignUpActivityService;
@At("")
@RequiresAuthentication
@Ok("beetl:/mobile/brandTrain/scanCode.html")
public void scanCode() {
}
/**
* @param courseId 二维码携带的课程ID,用户ID从当前登录态获取
* @return Result的code/msg/data;code=0时data包含签到地点坐标、当前点位签到状态、活动进度及达标事项
*/
@At
@POST
@RequiresAuthentication
public Result getSignData(@Param("courseId") String courseId) {
if (StrUtil.isBlank(courseId)) {
return Result.error(99, "缺少签到点位信息");
}
try {
NutMap data = brandTrainSignUpActivityService.getScanCodeSignData(courseId, ShiroUtil.getPlatformUid());
return Result.success().addData(data);
} catch (IllegalArgumentException e) {
return Result.error(99, e.getMessage());
}
}
/**
* 用户在签到地图中手动确认签到,浏览页面及获取定位本身均不写入签到记录。
*
* @param courseId 二维码携带的品牌课程ID,当前用户从服务端登录态取得
* @param longitude 当前腾讯定位的经度,须与地图显示点位同为GCJ-02坐标
* @param latitude 当前定位的纬度,与经度共同用于服务端配置半径的范围校验
* @return Result的code/msg/data;0签到成功,99业务提示,已签到时data仍保留当前状态和达标事项
*/
@At
@POST
@RequiresAuthentication
@Aop(TransAop.READ_COMMITTED)
public Result doSign(@Param("courseId") String courseId, @Param("longitude") Double longitude,
@Param("latitude") Double latitude) {
if (StrUtil.isBlank(courseId)) {
return Result.error(99, "缺少签到点位信息");
}
if (longitude == null || latitude == null) {
return Result.error(99, "请先获取当前定位后再签到");
}
try {
NutMap result = brandTrainSignUpActivityService.handleScanCodeSign(courseId, ShiroUtil.getPlatformUid(), longitude, latitude);
return Result.NEW().addCode(result.getInt("code")).addMsg(result.getString("msg")).addData(result.get("data"));
} catch (IllegalArgumentException e) {
// 当前Nutz嵌套事务未提供rollbackOnly;转换业务响应前回滚当前连接,保留外层AOP的事务计数。
if (Trans.get() != null) {
brandTrainSignUpActivityService.dao().run(connection -> connection.rollback());
}
return Result.error(99, e.getMessage());
}
}
/**
* @param courseId 当前扫码课程ID,用于定位活动及动态字段配置
* @param mobileColumnsValue JSON数组,每项包含columnCode及columnValue;附件值保留数组结构
* @return Result,code=0表示保存成功,code=99表示权限、达标或字段校验未通过
*/
@At
@POST
@RequiresAuthentication
@Aop(TransAop.READ_COMMITTED)
public Result saveDynamicForm(@Param("courseId") String courseId,
@Param("mobileColumnsValue") String mobileColumnsValue) {
if (StrUtil.isBlank(courseId) || StrUtil.isBlank(mobileColumnsValue)) {
return Result.error(99, "请填写当前签到点位的信息");
}
try {
brandTrainSignUpActivityService.saveScanCodeDynamicForm(courseId, ShiroUtil.getPlatformUid(), mobileColumnsValue);
return Result.success("保存成功");
} catch (IllegalArgumentException e) {
// 显式回滚已加入外层事务的写入,避免捕获service异常后被controller外层提交。
if (Trans.get() != null) {
brandTrainSignUpActivityService.dao().run(connection -> connection.rollback());
}
return Result.error(99, e.getMessage());
}
}
}
@@ -11,10 +11,12 @@ import io.v.nutz.data.service.MatchConditionStructureService;
import io.v.nutz.modules.models.PageForm; import io.v.nutz.modules.models.PageForm;
import io.v.nutz.web.commons.utils.ShiroUtil; import io.v.nutz.web.commons.utils.ShiroUtil;
import org.apache.shiro.authz.annotation.RequiresAuthentication; import org.apache.shiro.authz.annotation.RequiresAuthentication;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Chain; import org.nutz.dao.Chain;
import org.nutz.dao.Cnd; import org.nutz.dao.Cnd;
import org.nutz.dao.Dao; import org.nutz.dao.Dao;
import org.nutz.integration.jedis.RedisService; import org.nutz.integration.jedis.RedisService;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.util.NutMap; import org.nutz.lang.util.NutMap;
@@ -166,27 +168,24 @@ public class MBrandTrainSignUpActivityController {
return null; return null;
} }
/**
* GPS签到,半径与坐标以服务端课程配置为准。
* @param id 当前登录用户的课次签到记录ID
* @param courseId 当前课程ID,service核对其与签到记录的对应关系
* @param points 当前GCJ-02定位坐标,JSON数组[经度,纬度]
* @return ViReturn包装的code/msg/data;成功无业务data,参数或距离错误返回业务提示
*/
@At @At
@Ok("json:full") @Ok("json:full")
@POST @POST
@ViReturn @ViReturn
@RequiresAuthentication @RequiresAuthentication
@Aop(TransAop.READ_COMMITTED)
public Object doQd(@Param("id") String id, @Param("courseId") String courseId, @Param("point") Double[] points) { public Object doQd(@Param("id") String id, @Param("courseId") String courseId, @Param("point") Double[] points) {
BrandTrainSignUpCourse course = dao.fetch(BrandTrainSignUpCourse.class, courseId); if (StrUtil.isBlank(id) || StrUtil.isBlank(courseId) || points == null || points.length != 2) {
List<Double> coursePoints = course.getCourseLocationCoordinates(); return Result.error("请获取当前定位后再签到");
// if (Lang.isNotEmpty(coursePoints)) { }
// //需要签到 brandTrainSignUpActivityService.doQd(id, courseId, ShiroUtil.getPlatformUid(), points[0], points[1], 3);
// if (ArrayUtil.isEmpty(points) || ArrayUtil.hasNull(points)) {
// return Result.error().addMsg("请获取当前的坐标信息");
// }
// Double[] coursePointArray = coursePoints.toArray(new Double[]{});
// float distance = AMapUtils.calculateLineDistance(new LatLng(points[0], points[1]), new LatLng(coursePointArray[0], coursePointArray[1]));
//
// if (distance > 500) {
// return Result.error().addMsg("请到签到点位附近签到");
// }
// }
brandTrainSignUpActivityService.doQd(id);
return null; return null;
} }
@@ -285,19 +284,34 @@ public class MBrandTrainSignUpActivityController {
} }
} }
/**
* 旧课程二维码签到,与GPS签到复用service的用户归属和配置半径校验。
* @param id 当前登录用户的课次签到记录ID
* @param codeCourseId 二维码解析出的课程ID
* @param clickCourseId 页面点击的课程ID,必须与二维码课程一致
* @param longitude 当前GCJ-02定位经度
* @param latitude 当前GCJ-02定位纬度
* @return ViReturn包装的code/msg/data;成功无业务data,校验失败返回业务提示
*/
@At @At
@Ok("json:full") @Ok("json:full")
@POST
@ViReturn @ViReturn
@RequiresAuthentication @RequiresAuthentication
public Object codeSign(String id, String codeCourseId, String clickCourseId) { @Aop(TransAop.READ_COMMITTED)
if(StrUtil.isBlank(codeCourseId) || StrUtil.isBlank(clickCourseId)) { public Object codeSign(@Param("id") String id, @Param("codeCourseId") String codeCourseId,
@Param("clickCourseId") String clickCourseId, @Param("longitude") Double longitude,
@Param("latitude") Double latitude) {
if(StrUtil.isBlank(id) || StrUtil.isBlank(codeCourseId) || StrUtil.isBlank(clickCourseId)) {
return Result.error("签到失败,没有获取到扫描信息"); return Result.error("签到失败,没有获取到扫描信息");
} }
if(!codeCourseId.equals(clickCourseId)) { if(!codeCourseId.equals(clickCourseId)) {
return Result.error("签到失败,二维码与您当前签到信息不符"); return Result.error("签到失败,二维码与您当前签到信息不符");
} }
dao.update(BrandTrainSignUpUserCourse.class, Chain.make("isAttend", true) if (longitude == null || latitude == null) {
.add("attendTime", new Date()), Cnd.where("id", "=", id)); return Result.error("请获取当前定位后再签到");
}
brandTrainSignUpActivityService.doQd(id, clickCourseId, ShiroUtil.getPlatformUid(), longitude, latitude, 1);
return null; return null;
} }
} }
@@ -1,5 +1,8 @@
package io.v.nutz.brandtrainactivity.controller.statistics; package io.v.nutz.brandtrainactivity.controller.statistics;
import io.v.nutz.base.result.Result;
import io.v.nutz.base.utils.CommonDownloadUtil;
import cn.afterturn.easypoi.excel.entity.ExportParams; import cn.afterturn.easypoi.excel.entity.ExportParams;
import cn.afterturn.easypoi.excel.entity.enmus.ExcelType; import cn.afterturn.easypoi.excel.entity.enmus.ExcelType;
@@ -347,4 +350,27 @@ public class BrandTrainSignUpActivityStatisticsController {
zipOutputStream.flush(); zipOutputStream.flush();
zipOutputStream.close(); zipOutputStream.close();
} }
/**
* 下载活动签到后填写的信息,不接收人员ID或动态字段定义。
*
* @param activityId 活动主键,导出该活动下按人员合并的填写记录
* @param response 成功返回xlsx文件流;参数错误返回包含code、msg的JSON对象
* @throws IOException 无法写入HTTP响应时抛出
*/
@At
@Ok("void")
@RequiresPermissions("brandTrainSingUp.statistics.activity")
public void exportFillForm(@Param("activityId") String activityId,
HttpServletResponse response) throws IOException {
try {
Workbook workbook = brandTrainSignUpActivityStatisticsService.exportFillFormWorkbook(activityId);
CommonDownloadUtil.download("填写信息.xlsx", workbook, response);
} catch (IllegalArgumentException e) {
response.reset();
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json");
response.getWriter().write(Result.error(99, e.getMessage()).toJsonString());
}
}
} }
@@ -118,4 +118,19 @@ public class BrandTrainSignUpActivity extends BaseModel implements Serializable
@Comment("活动类型") @Comment("活动类型")
@ColDefine(type = ColType.VARCHAR, width = 20) @ColDefine(type = ColType.VARCHAR, width = 20)
private String trainType; private String trainType;
@Column
@ColDefine(type = ColType.INT)
@Comment("签到达标点位数,空或0表示未开启签到扩展")
private Integer signPointCount;
@Column
@ColDefine(type = ColType.INT)
@Comment("签到达标处理方式:1填写信息,2选择福利,3无")
private Integer signHandleType;
@Column
@ColDefine(type = ColType.VARCHAR, width = 32)
@Comment("签到达标后关联的福利项目ID")
private String welfareProjectId;
} }
@@ -63,6 +63,11 @@ public class BrandTrainSignUpCourse extends BaseModel implements Serializable {
@Comment("课程地点坐标") @Comment("课程地点坐标")
private List<Double> courseLocationCoordinates; private List<Double> courseLocationCoordinates;
@Column
@ColDefine(type = ColType.INT)
@Comment("签到点位半径,单位米;未配置时使用250米")
private Integer signRadius;
@Column @Column
@ColDefine(type = ColType.VARCHAR, width = 20) @ColDefine(type = ColType.VARCHAR, width = 20)
@Comment("课程讲师") @Comment("课程讲师")
@@ -113,11 +113,17 @@ public interface BrandTrainSignUpActivityService extends ViService<BrandTrainSig
boolean isSignCourseByUser(String courseId, String userId); boolean isSignCourseByUser(String courseId, String userId);
/** /**
* 手机端签到 * 原手机端扫码/GPS签到,共用服务端点位范围校验。
* *
* @param id 每个培训班每节课每个用户的记录ID * @param id 每个培训班每节课每个用户的记录ID
* @param courseId 当前签到课程ID,必须与签到记录所属课程一致
* @param userId 当前登录用户ID,由服务端登录态取得
* @param longitude 当前GCJ-02经度,范围[-180,180]
* @param latitude 当前GCJ-02纬度,范围[-90,90]
* @param signType 当前入口的签到方式,1扫码、3 GPS,必须与课程配置一致
* @return 无返回值;校验失败抛出业务异常,成功更新签到状态和时间
*/ */
void doQd(String id); void doQd(String id, String courseId, String userId, Double longitude, Double latitude, int signType);
/** /**
* 某个用户的签到信息 * 某个用户的签到信息
@@ -127,6 +133,36 @@ public interface BrandTrainSignUpActivityService extends ViService<BrandTrainSig
*/ */
List<NutMap> qdInfoByUserId(String userId, String activityId); List<NutMap> qdInfoByUserId(String userId, String activityId);
/**
* 只读预览扫码点位、当前点位签到状态和活动进度,不补报名、不签到、不新增福利名单。
*
* @param courseId 品牌课程ID,仅支持已开启签到扩展的扫码签到课程
* @param userId 当前登录用户ID,由服务端登录态取得
* @return data对象;courseLocationCoordinates为[经度,纬度],currentPointSigned为当前课程是否已签,signRadius为米数
*/
NutMap getScanCodeSignData(String courseId, String userId);
/**
* 当前用户在地图页手动签到,校验定位后累计不重复的课程点位,并处理原有达标事项。
*
* @param courseId 品牌课程ID,不接受普通培训课程或非扫码签到课程
* @param userId 当前登录用户ID,由服务端登录态取得
* @param longitude 当前腾讯地图GCJ-02定位经度,范围为[-180,180],不能为NaN或无穷值
* @param latitude 当前腾讯地图GCJ-02定位纬度,范围为[-90,90],与课程坐标相距不得超过配置的点位半径
* @return code/msg/data结构;data含签到进度、动态表单、福利跳转及达标处理状态
*/
NutMap handleScanCodeSign(String courseId, String userId, Double longitude, Double latitude);
/**
* 保存品牌活动达标后的动态字段,同步至该用户在本活动已签到课程的报名记录。
*
* @param courseId 当前扫码品牌课程ID,用于确定活动及统一的课程类型
* @param userId 当前登录用户ID,由服务端登录态取得
* @param mobileColumnsValue JSON数组,每项包含columnCode和columnValue,附件保留数组结构
* @throws IllegalArgumentException 未达标、无参与权限或字段校验不通过时抛出业务提示
*/
void saveScanCodeDynamicForm(String courseId, String userId, String mobileColumnsValue);
/** /**
* 查询活动的报名条件 * 查询活动的报名条件
* @param activityId * @param activityId
@@ -1,5 +1,7 @@
package io.v.nutz.brandtrainactivity.service; package io.v.nutz.brandtrainactivity.service;
import org.apache.poi.ss.usermodel.Workbook;
import cn.wizzer.framework.page.Pagination; import cn.wizzer.framework.page.Pagination;
import io.v.nutz.modules.models.PageForm; import io.v.nutz.modules.models.PageForm;
import io.v.nutz.service.ViService; import io.v.nutz.service.ViService;
@@ -55,4 +57,13 @@ public interface BrandTrainSignUpActivityStatisticsService extends ViService<Bra
* @return * @return
*/ */
List<NutMap> baoMingUserList(String activityId); List<NutMap> baoMingUserList(String activityId);
/**
* 按活动、人员汇总签到后填写的信息,同一人员仅导出一行。
*
* @param activityId 活动主键;活动内课程须使用同一种动态字段配置
* @return XSSF 工作簿,包含人员信息、已签到课程及课程类型配置的动态字段
*/
Workbook exportFillFormWorkbook(String activityId);
} }
@@ -2,6 +2,7 @@ package io.v.nutz.brandtrainactivity.service.impl;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import cn.wizzer.framework.page.Pagination; import cn.wizzer.framework.page.Pagination;
import io.v.nutz.activity.models.ActivityUserScope;
import io.v.nutz.base.utils.DateUtil; import io.v.nutz.base.utils.DateUtil;
import io.v.nutz.base.utils.Vi; import io.v.nutz.base.utils.Vi;
import io.v.nutz.brandtrainactivity.models.*; import io.v.nutz.brandtrainactivity.models.*;
@@ -15,6 +16,9 @@ import io.v.nutz.modules.models.PageForm;
import io.v.nutz.service.impl.ViServiceImpl; import io.v.nutz.service.impl.ViServiceImpl;
import io.v.nutz.sys.models.Sys_user; import io.v.nutz.sys.models.Sys_user;
import io.v.nutz.web.commons.utils.ShiroUtil; import io.v.nutz.web.commons.utils.ShiroUtil;
import io.v.nutz.welfare.model.WelfareList;
import io.v.nutz.welfare.model.WelfareProject;
import io.v.nutz.welfare.service.WelfareListService;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.nutz.aop.interceptor.ioc.TransAop; import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Chain; import org.nutz.dao.Chain;
@@ -29,13 +33,19 @@ import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.Lang; import org.nutz.lang.Lang;
import org.nutz.lang.Mirror; import org.nutz.lang.Mirror;
import org.nutz.json.Json;
import org.nutz.lang.util.NutMap; import org.nutz.lang.util.NutMap;
import java.lang.reflect.Field; import java.lang.reflect.Field;
import java.math.BigDecimal;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Calendar; import java.util.Calendar;
import java.util.Date; import java.util.Date;
import java.util.List; import java.util.List;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Collections;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -43,6 +53,9 @@ import java.util.stream.Collectors;
@IocBean(args = {"refer:dao"}) @IocBean(args = {"refer:dao"})
public class BrandTrainSignUpActivityServiceImpl extends ViServiceImpl<BrandTrainSignUpActivity> implements BrandTrainSignUpActivityService { public class BrandTrainSignUpActivityServiceImpl extends ViServiceImpl<BrandTrainSignUpActivity> implements BrandTrainSignUpActivityService {
// 历史课程未配置半径时沿用250米;扫码与GPS均以当前课程保存的半径为准。
private static final int DEFAULT_SIGN_RADIUS = 250;
public BrandTrainSignUpActivityServiceImpl(Dao dao) { public BrandTrainSignUpActivityServiceImpl(Dao dao) {
super(dao); super(dao);
} }
@@ -50,9 +63,13 @@ public class BrandTrainSignUpActivityServiceImpl extends ViServiceImpl<BrandTrai
@Inject @Inject
private MatchConditionStructureService matchConditionStructureService; private MatchConditionStructureService matchConditionStructureService;
@Inject
private WelfareListService welfareListService;
@Override @Override
@Aop(TransAop.READ_COMMITTED) @Aop(TransAop.READ_COMMITTED)
public void add(BrandTrainSignUpActivity activity, BrandTrainSignUpCourse course) { public void add(BrandTrainSignUpActivity activity, BrandTrainSignUpCourse course) {
validateSignExtension(activity);
if (checkCnd(activity.getConditionStructure())) { if (checkCnd(activity.getConditionStructure())) {
activity.setConditionStructureId(matchConditionStructureService.save(activity.getConditionStructure()).getId()); activity.setConditionStructureId(matchConditionStructureService.save(activity.getConditionStructure()).getId());
} else { } else {
@@ -108,6 +125,7 @@ public class BrandTrainSignUpActivityServiceImpl extends ViServiceImpl<BrandTrai
@Override @Override
@Aop(TransAop.READ_COMMITTED) @Aop(TransAop.READ_COMMITTED)
public void edit(BrandTrainSignUpActivity activity) { public void edit(BrandTrainSignUpActivity activity) {
validateSignExtension(activity);
if (checkCnd(activity.getConditionStructure())) { if (checkCnd(activity.getConditionStructure())) {
activity.setConditionStructureId(matchConditionStructureService.save(activity.getConditionStructure()).getId()); activity.setConditionStructureId(matchConditionStructureService.save(activity.getConditionStructure()).getId());
} else { } else {
@@ -519,7 +537,24 @@ public class BrandTrainSignUpActivityServiceImpl extends ViServiceImpl<BrandTrai
} }
@Override @Override
public void doQd(String id) { @Aop(TransAop.READ_COMMITTED)
public void doQd(String id, String courseId, String userId, Double longitude, Double latitude, int signType) {
// 从签到记录和登录态确认课程及用户,禁止借用其他课程的大半径或替他人签到。
BrandTrainSignUpUserCourse record = StrUtil.isBlank(id) ? null : dao().fetch(BrandTrainSignUpUserCourse.class, id);
if (record == null || StrUtil.isBlank(userId) || !Objects.equals(userId, ShiroUtil.getPrincipalProperty("id"))
|| !Objects.equals(record.getUserId(), userId) || !Objects.equals(record.getCourseId(), courseId)) {
throw new IllegalArgumentException("当前签到记录无效或不属于当前用户");
}
BrandTrainSignUpCourse course = dao().fetch(BrandTrainSignUpCourse.class, record.getCourseId());
if (course == null || !course.isMobileSign() || (signType != 1 && signType != 3)
|| !Integer.valueOf(signType).equals(course.getSignType())) {
throw new IllegalArgumentException("当前课程未开启此签到方式");
}
requireScanCodeLocation(course.getCourseLocationCoordinates(), longitude, latitude, getSignRadius(course));
// 重复提交不覆盖首次签到时间;范围校验通过后才允许写入签到结果。
if (record.isAttend()) {
return;
}
NutMap updateMap = NutMap.NEW(); NutMap updateMap = NutMap.NEW();
updateMap.put("isAttend", true); updateMap.put("isAttend", true);
updateMap.put("attendTime", new Date()); updateMap.put("attendTime", new Date());
@@ -532,6 +567,7 @@ public class BrandTrainSignUpActivityServiceImpl extends ViServiceImpl<BrandTrai
SELECT SELECT
c.*, c.*,
t.courseLocationCoordinates, t.courseLocationCoordinates,
t.signRadius,
t.isMobileSign, t.isMobileSign,
t.signType, t.signType,
t.isReceiveGift, t.isReceiveGift,
@@ -649,6 +685,473 @@ public class BrandTrainSignUpActivityServiceImpl extends ViServiceImpl<BrandTrai
return resultMap; return resultMap;
} }
/**
* 仅显式填写正数点位数的活动开启签到扩展,历史未配置活动仍沿用原报名和签到。
* 校验在写入活动及课程之前执行,避免只靠前端控制而保存无效的达标配置。
*/
private void validateSignExtension(BrandTrainSignUpActivity activity) {
List<BrandTrainSignUpCourse> courses = activity.getCourseList();
// 扫码课程均须保存有效地点;校验不能放在扩展配置之后,避免未开启扩展时绕过坐标要求。
if (courses != null) {
for (BrandTrainSignUpCourse course : courses) {
// 旧客户端和历史课程缺少半径时补默认值;显式非法值不得静默放行。
if (course.getSignRadius() == null) {
course.setSignRadius(DEFAULT_SIGN_RADIUS);
} else if (course.getSignRadius() <= 0) {
throw new IllegalArgumentException("课程“" + course.getCourseName() + "”的点位半径必须为正整数(米)");
}
if (course.isMobileSign() && Integer.valueOf(1).equals(course.getSignType())
&& !validScanCodeCoordinates(course.getCourseLocationCoordinates())) {
throw new IllegalArgumentException("请为课程“" + course.getCourseName() + "”设置有效的签到地点坐标");
}
}
}
Integer pointCount = activity.getSignPointCount();
Integer handleType = activity.getSignHandleType();
if (pointCount == null || pointCount == 0) {
if ((handleType != null && handleType != 3) || StrUtil.isNotBlank(activity.getWelfareProjectId())) {
throw new IllegalArgumentException("请先配置签到达标点位数");
}
activity.setSignHandleType(3);
activity.setWelfareProjectId(null);
return;
}
if (pointCount < 0 || courses == null || courses.isEmpty() || pointCount > courses.size()) {
throw new IllegalArgumentException("签到点位数必须为不超过课程数量的正整数");
}
if (courses.stream().anyMatch(item -> !item.isMobileSign())) {
throw new IllegalArgumentException("全部课程开启手机签到后才能配置签到扩展");
}
if (handleType == null || handleType < 1 || handleType > 3) {
throw new IllegalArgumentException("请选择正确的签到达标处理方式");
}
if (handleType == 1 && (courses.stream().anyMatch(item -> StrUtil.isBlank(item.getCourseType()))
|| courses.stream().map(BrandTrainSignUpCourse::getCourseType).distinct().count() != 1)) {
throw new IllegalArgumentException("填写信息方式要求全部课程使用同一种课程类型");
}
if (handleType == 2) {
WelfareProject project = StrUtil.isBlank(activity.getWelfareProjectId()) ? null
: dao().fetch(WelfareProject.class, activity.getWelfareProjectId());
if (project == null || project.getChoiceTimeEnd() == null || project.getChoiceTimeEnd().before(new Date())) {
throw new IllegalArgumentException("请选择选择时间未结束的福利项目");
}
} else {
activity.setWelfareProjectId(null);
}
}
/** 进入扫码页只读取已有进度,历史尚未设置坐标的课程仍可预览,由页面提示补齐配置。 */
@Override
public NutMap getScanCodeSignData(String courseId, String userId) {
if (StrUtil.isBlank(userId) || !Objects.equals(userId, ShiroUtil.getPrincipalProperty("id"))) {
throw new IllegalArgumentException("未获取到当前登录用户");
}
BrandTrainSignUpCourse course = requireScanCodeCourse(courseId, userId);
BrandTrainSignUpActivity activity = dao().fetch(BrandTrainSignUpActivity.class, course.getActivityId());
return buildScanCodeData(activity, course, userId, false);
}
@Override
@Aop(TransAop.READ_COMMITTED)
public NutMap handleScanCodeSign(String courseId, String userId, Double longitude, Double latitude) {
lockScanCodeUser(userId);
BrandTrainSignUpCourse course = requireScanCodeCourse(courseId, userId);
// 地点配置和用户定位均在补报名、课次写入与福利处理之前校验,校验失败不产生业务数据。
requireScanCodeLocation(course.getCourseLocationCoordinates(), longitude, latitude, getSignRadius(course));
BrandTrainSignUpActivity activity = dao().fetch(BrandTrainSignUpActivity.class, course.getActivityId());
ensureScanCodeSignUpUser(activity, course, userId);
List<BrandTrainSignUpUserCourse> records = dao().query(BrandTrainSignUpUserCourse.class,
Cnd.where("activityId", "=", activity.getId()).and("courseId", "=", courseId)
.and("userId", "=", userId).asc("courseStartTime").asc("id"));
// 达标按不同课程点位累计,同一课程的多节课或重复扫描不重复增加点位数。
if (records.stream().anyMatch(BrandTrainSignUpUserCourse::isAttend)) {
return scanCodeResponse(99, "此点位,您已经签到", buildScanCodeData(activity, course, userId, true));
}
List<BrandTrainSignUpActivityCourse> lessons = dao().query(BrandTrainSignUpActivityCourse.class,
Cnd.where("activityId", "=", activity.getId()).and("courseId", "=", courseId)
.asc("courseStartTime").asc("id"));
Date now = new Date();
// 单课次点位沿用源功能的活动时间范围;多课次必须匹配当前课次,不能任取一条签到。
List<BrandTrainSignUpActivityCourse> currentLessons = lessons.stream()
.filter(item -> item.getCourseStartTime() != null && item.getCourseEndTime() != null
&& !now.before(item.getCourseStartTime()) && !now.after(item.getCourseEndTime()))
.collect(Collectors.toList());
if (lessons.isEmpty() || lessons.size() > 1 && currentLessons.size() != 1) {
throw new IllegalArgumentException("当前点位没有唯一可签到的课次时间配置");
}
BrandTrainSignUpActivityCourse lesson = lessons.size() == 1 ? lessons.get(0) : currentLessons.get(0);
BrandTrainSignUpUserCourse userCourse = records.stream()
.filter(item -> Objects.equals(item.getActivityCourseId(), lesson.getId())).findFirst().orElse(null);
if (userCourse == null) {
userCourse = new BrandTrainSignUpUserCourse();
userCourse.setActivityId(activity.getId());
userCourse.setCourseId(courseId);
userCourse.setUserId(userId);
userCourse.setActivityCourseId(lesson.getId());
userCourse.setCourseStartTime(lesson.getCourseStartTime());
userCourse.setCourseEndTime(lesson.getCourseEndTime());
dao().insert(userCourse);
}
userCourse.setAttend(true);
userCourse.setAttendTime(now);
dao().update(userCourse);
if (Integer.valueOf(1).equals(activity.getSignHandleType())) {
syncAttendMobileColumnsValue(activity.getId(), userId, getLatestScanCodeForm(activity.getId(), userId));
}
return scanCodeResponse(0, "签到成功", buildScanCodeData(activity, course, userId, true));
}
/** 坐标统一为[经度,纬度]两个有限数,排除缺失、越界及异常数字,避免距离比较被NaN绕过。 */
private boolean validScanCodeCoordinates(List<Double> coordinates) {
return coordinates != null && coordinates.size() == 2
&& validScanCodePosition(coordinates.get(0), coordinates.get(1));
}
/** 腾讯地图GCJ-02坐标按经度[-180,180]、纬度[-90,90]校验,零值本身属于有效坐标。 */
private boolean validScanCodePosition(Double longitude, Double latitude) {
return longitude != null && latitude != null && Double.isFinite(longitude) && Double.isFinite(latitude)
&& longitude >= -180 && longitude <= 180 && latitude >= -90 && latitude <= 90;
}
/** course为数据库课程;返回生效半径(米),仅缺失配置使用历史默认值,非法配置禁止签到。 */
private int getSignRadius(BrandTrainSignUpCourse course) {
Integer radius = course.getSignRadius();
if (radius == null) {
return DEFAULT_SIGN_RADIUS;
}
if (radius <= 0) {
throw new IllegalArgumentException("当前点位半径配置无效,请联系管理员");
}
return radius;
}
/** 服务端按同坐标系经纬度计算球面距离;signRadius为数据库半径(米),边界以内允许签到。 */
private void requireScanCodeLocation(List<Double> coordinates, Double longitude, Double latitude, int signRadius) {
if (!validScanCodeCoordinates(coordinates)) {
throw new IllegalArgumentException("当前点位未配置有效的签到地点坐标,请联系管理员");
}
if (!validScanCodePosition(longitude, latitude)) {
throw new IllegalArgumentException("当前定位坐标无效,请重新获取定位后再签到");
}
double currentLatitude = Math.toRadians(latitude);
double targetLatitude = Math.toRadians(coordinates.get(1));
double latitudeDifference = targetLatitude - currentLatitude;
double longitudeDifference = Math.toRadians(coordinates.get(0) - longitude);
double haversine = Math.pow(Math.sin(latitudeDifference / 2), 2)
+ Math.cos(currentLatitude) * Math.cos(targetLatitude) * Math.pow(Math.sin(longitudeDifference / 2), 2);
// 浮点计算误差可能使结果略超[0,1],夹紧后再求距离,保证远距离点也得到有效校验结果。
double distance = 6371000D * 2 * Math.asin(Math.sqrt(Math.max(0, Math.min(1, haversine))));
if (distance > signRadius) {
throw new IllegalArgumentException("请在签到点位" + signRadius + "米内签到");
}
}
@Override
@Aop(TransAop.READ_COMMITTED)
public void saveScanCodeDynamicForm(String courseId, String userId, String mobileColumnsValue) {
lockScanCodeUser(userId);
BrandTrainSignUpCourse course = requireScanCodeCourse(courseId, userId);
BrandTrainSignUpActivity activity = dao().fetch(BrandTrainSignUpActivity.class, course.getActivityId());
if (!Integer.valueOf(1).equals(activity.getSignHandleType())) {
throw new IllegalArgumentException("当前活动无需填写动态表单");
}
if (!buildScanCodeData(activity, course, userId, false).getBoolean("isReachTarget", false)) {
throw new IllegalArgumentException("当前活动尚未达标,暂不能填写动态表单");
}
List<NutMap> formData;
try {
formData = Json.fromJsonAsList(NutMap.class, mobileColumnsValue);
} catch (Exception e) {
throw new IllegalArgumentException("填写信息格式不正确");
}
List<BrandTrainMobileSignColumn> columns = getScanCodeColumns(course);
if (formData == null || columns.isEmpty()) {
throw new IllegalArgumentException("请填写当前活动配置的信息");
}
Map<String, NutMap> inputMap = new LinkedHashMap<>();
for (NutMap item : formData) {
if (item == null || StrUtil.isBlank(item.getString("columnCode"))
|| inputMap.put(item.getString("columnCode"), item) != null) {
throw new IllegalArgumentException("填写信息包含无效或重复的字段");
}
}
List<String> columnCodes = columns.stream().map(BrandTrainMobileSignColumn::getColumnCode).collect(Collectors.toList());
if (!columnCodes.containsAll(inputMap.keySet())) {
throw new IllegalArgumentException("填写信息包含未配置的字段");
}
List<NutMap> normalizedData = new ArrayList<>();
for (BrandTrainMobileSignColumn column : columns) {
Object value = inputMap.containsKey(column.getColumnCode()) ? inputMap.get(column.getColumnCode()).get("columnValue") : null;
value = validateScanCodeColumnValue(column, value);
normalizedData.add(NutMap.NEW().addv("columnCode", column.getColumnCode())
.addv("columnName", column.getColumnName()).addv("columnFormType", column.getColumnFormType())
.addv("columnValue", value));
}
syncAttendMobileColumnsValue(activity.getId(), userId, normalizedData);
}
/**
* 新入口在事务内锁定登录用户行,锁持有到事务提交,避免同用户并发扫码先查后插产生重复记录。
* 不改变普通报名和普通签到路径,也不增加表、索引或数据库脚本。
*/
private void lockScanCodeUser(String userId) {
if (StrUtil.isBlank(userId) || !Objects.equals(userId, ShiroUtil.getPrincipalProperty("id"))) {
throw new IllegalArgumentException("未获取到当前登录用户");
}
NutMap user = fetch(Sqls.create("SELECT id FROM sys_user WHERE id = @userId FOR UPDATE").setParam("userId", userId));
if (user == null || user.isEmpty()) {
throw new IllegalArgumentException("未找到当前用户信息");
}
}
/** 校验新点位入口的启用状态、活动时间及当前项目原有人员范围、条件和课程开放规则。 */
private BrandTrainSignUpCourse requireScanCodeCourse(String courseId, String userId) {
BrandTrainSignUpCourse course = StrUtil.isBlank(courseId) ? null : dao().fetch(BrandTrainSignUpCourse.class, courseId);
if (course == null || !course.isMobileSign() || !Integer.valueOf(1).equals(course.getSignType())) {
throw new IllegalArgumentException("当前课程未开启扫码签到");
}
BrandTrainSignUpActivity activity = dao().fetch(BrandTrainSignUpActivity.class, course.getActivityId());
if (activity == null || activity.getSignPointCount() == null || activity.getSignPointCount() <= 0) {
throw new IllegalArgumentException("当前活动未开启签到扩展,请从原签到入口操作");
}
Date now = new Date();
if (activity.isDisabled() || activity.getActivityStartTime() == null || activity.getActivityEndTime() == null
|| now.before(activity.getActivityStartTime()) || now.after(activity.getActivityEndTime())) {
throw new IllegalArgumentException("当前活动未开放或不在活动时间范围内");
}
// 已有报名沿用原页面仅正常状态1/3可签到的限制,候补、废弃等状态不能经新入口达标或填表。
// 没有报名记录时继续允许源点位扫码自动补报名,不将该规则扩展到原有报名状态。
List<BrandTrainSignUpUser> signUpUsers = dao().query(BrandTrainSignUpUser.class,
Cnd.where("activityId", "=", activity.getId()).and("courseId", "=", courseId).and("userId", "=", userId));
if (signUpUsers.stream().anyMatch(item -> !Integer.valueOf(1).equals(item.getState())
&& !Integer.valueOf(3).equals(item.getState()))) {
throw new IllegalArgumentException("当前报名状态不能签到或填写信息");
}
if (activity.getActivityGroupId() != null && activity.getActivityGroupId() > 0
&& dao().count(ActivityUserScope.class, Cnd.where("groupId", "=", activity.getActivityGroupId())
.and("userId", "=", userId)) == 0) {
throw new IllegalArgumentException("您没有权限参与此次活动");
}
if ((StrUtil.isNotBlank(activity.getConditionStructureId())
&& !validConditionStructure(activity.getConditionStructureId()).getBoolean("flag", false))
|| (StrUtil.isNotBlank(course.getConditionStructureId())
&& !validConditionStructure(course.getConditionStructureId()).getBoolean("flag", false))) {
throw new IllegalArgumentException("您不满足当前活动或课程的参与条件");
}
if (StrUtil.isNotBlank(course.getInterTime()) && Boolean.FALSE.equals(course.getOpenOtherUnion())
&& !Objects.equals(Vi.getUnionId(), course.getHostUnionId())
&& now.before(cn.hutool.core.date.DateUtil.parse(course.getInterTime()))) {
throw new IllegalArgumentException("当前课程暂未开放给其他工会");
}
List<BrandTrainSignUpCourse> courses = dao().query(BrandTrainSignUpCourse.class, Cnd.where("activityId", "=", activity.getId()));
if (courses.stream().anyMatch(item -> !item.isMobileSign()) || activity.getSignPointCount() > courses.size()) {
throw new IllegalArgumentException("当前活动签到扩展配置无效,请联系管理员");
}
if (Integer.valueOf(1).equals(activity.getSignHandleType())
&& (courses.stream().anyMatch(item -> StrUtil.isBlank(item.getCourseType()))
|| courses.stream().map(BrandTrainSignUpCourse::getCourseType).distinct().count() != 1)) {
throw new IllegalArgumentException("填写信息方式要求全部课程使用同一种课程类型");
}
return course;
}
/**
* 未报名的扫码用户须满足品牌原有总名额、分工会名额和类型/活动报名数量限制,候补不能直接签到。
* 新入口锁定当前课程行后复用品牌报名方法,沿用品牌同步生成全部课次记录的原有行为。
* @param activity 当前已校验开放状态的品牌活动
* @param course 当前扫码品牌课程,不计入尚未填写的家属人数
* @param userId 当前登录用户ID
*/
@Aop(TransAop.READ_COMMITTED)
private void ensureScanCodeSignUpUser(BrandTrainSignUpActivity activity, BrandTrainSignUpCourse course, String userId) {
if (dao().count(BrandTrainSignUpUser.class, Cnd.where("activityId", "=", activity.getId())
.and("courseId", "=", course.getId()).and("userId", "=", userId)) > 0) {
return;
}
// 同课程不同用户的新扫码补报名串行校验名额,避免两个新入口同时占用最后一个正常名额。
fetch(Sqls.create("SELECT id FROM brand_train_sign_up_course WHERE id = @courseId FOR UPDATE")
.setParam("courseId", course.getId()));
if (isSignFull(course.getId(), 0)) {
throw new IllegalArgumentException("当前报名人数已满");
}
if (isSignFullByUnionId(course.getId(), 0)) {
throw new IllegalArgumentException("该活动您所在的分工会名额已报满");
}
if (!isSignCourse(course.getId(), activity.getId())) {
throw new IllegalArgumentException("您已达到当前品牌活动或课程类型的报名数量上限");
}
if (Integer.valueOf(2).equals(course.getReserveMode())
&& dao().count(BrandTrainSignUpUser.class, Cnd.where("courseId", "=", course.getId()).and("state", "!=", 2))
>= course.getCoursePeopleNumber()) {
throw new IllegalArgumentException("当前仅剩候补名额,请先完成正常报名后再签到");
}
BrandTrainSignUpUser signUpUser = new BrandTrainSignUpUser();
signUpUser.setActivityId(activity.getId());
signUpUser.setCourseId(course.getId());
try {
doSignUp(signUpUser);
} catch (Exception e) {
throw new IllegalStateException("补齐品牌活动报名记录失败", e);
}
}
/**
* 组装地图、当前点位状态、活动进度和表单回显;当前点位已签与整个活动达标分别计算。
* 页面预览和表单保存均使用allowHandle=false,只能回显已有福利名单,不新增任何业务记录。
*/
private NutMap buildScanCodeData(BrandTrainSignUpActivity activity, BrandTrainSignUpCourse course, String userId, boolean allowHandle) {
List<BrandTrainSignUpUserCourse> attendRecords = dao().query(BrandTrainSignUpUserCourse.class,
Cnd.where("activityId", "=", activity.getId()).and("userId", "=", userId).and("isAttend", "=", true));
long signCount = attendRecords.stream().map(BrandTrainSignUpUserCourse::getCourseId).filter(StrUtil::isNotBlank).distinct().count();
boolean reached = signCount >= activity.getSignPointCount();
Integer handleType = activity.getSignHandleType() == null ? 3 : activity.getSignHandleType();
Map<String, Object> values = new LinkedHashMap<>();
getLatestScanCodeForm(activity.getId(), userId).forEach(item -> values.put(item.getString("columnCode"), item.get("columnValue")));
List<NutMap> dynamicColumns = new ArrayList<>();
for (BrandTrainMobileSignColumn column : getScanCodeColumns(course)) {
NutMap columnMap = Lang.obj2nutmap(column);
columnMap.put("columnValue", values.containsKey(column.getColumnCode()) ? values.get(column.getColumnCode())
: "FILE".equals(column.getColumnFormType()) ? new ArrayList<>() : "");
dynamicColumns.add(columnMap);
}
NutMap data = NutMap.NEW().addv("activityName", activity.getActivityName()).addv("courseName", course.getCourseName())
.addv("courseLocation", course.getCourseLocation()).addv("courseLocationCoordinates", course.getCourseLocationCoordinates())
.addv("currentPointSigned", attendRecords.stream().anyMatch(item -> Objects.equals(item.getCourseId(), course.getId())))
.addv("signRadius", getSignRadius(course))
.addv("signCount", signCount).addv("signPointCount", activity.getSignPointCount())
.addv("signHandleType", handleType).addv("welfareProjectId", activity.getWelfareProjectId())
.addv("isReachTarget", reached).addv("dynamicColumnsData", dynamicColumns)
.addv("needDynamicForm", reached && handleType == 1 && !dynamicColumns.isEmpty())
.addv("needWelfareSelect", false).addv("welfareJumpUrl", "")
.addv("handleStatus", "NONE").addv("handleMsg", reached ? "当前活动已达标" : "请继续完成其他点位签到");
if (reached && handleType == 1 && !dynamicColumns.isEmpty()) {
// 达标处理覆盖默认状态和提示;addv 会将同名字段累加为数组,导致手机端直接显示数组内容。
data.setv("handleStatus", "PENDING_FORM").setv("handleMsg", "请点击下方按钮填写详细信息。");
} else if (reached && handleType == 2) {
if (allowHandle) {
completeScanCodeWelfare(activity.getWelfareProjectId(), userId, data);
} else if (dao().count(WelfareList.class, Cnd.where("projectId", "=", activity.getWelfareProjectId())
.and("userId", "=", userId)) > 0) {
// 已达标用户刷新后保留已有福利入口,只读查询不能为尚未入名单的用户补建福利记录。
buildScanCodeWelfareData(activity.getWelfareProjectId(), data);
}
}
return data;
}
/** 已达标用户复用本项目福利名单服务,并由现有福利选择页处理时间、选项和签收。 */
@Aop(TransAop.READ_COMMITTED)
private void completeScanCodeWelfare(String projectId, String userId, NutMap data) {
buildScanCodeWelfareData(projectId, data);
if (!data.getBoolean("needWelfareSelect", false)) {
return;
}
if (dao().count(WelfareList.class, Cnd.where("projectId", "=", projectId).and("userId", "=", userId)) == 0) {
welfareListService.joinWelfareList(projectId, Collections.singletonList(userId));
}
}
/** 福利入口组装仅校验项目并设置返回字段,供手动签到和已有名单的只读预览共同使用。 */
private void buildScanCodeWelfareData(String projectId, NutMap data) {
WelfareProject project = StrUtil.isBlank(projectId) ? null : dao().fetch(WelfareProject.class, projectId);
if (project == null || project.getChoiceTimeEnd() == null || project.getChoiceTimeEnd().before(new Date())) {
// 福利校验失败时覆盖默认结果,确保状态和提示仍为单个字符串。
data.setv("handleStatus", "FAILED").setv("handleMsg", "关联福利项目不存在或选择时间已结束,请联系管理员");
return;
}
// 覆盖默认福利结果,保持选择标记为布尔值、跳转地址为字符串,避免数组转换后生成错误地址。
data.setv("handleStatus", "PENDING_WELFARE").setv("handleMsg", "当前活动已达标,请前往选择福利")
.setv("needWelfareSelect", true).setv("welfareJumpUrl", "/mobile/welfare/list/receive?projectId=" + projectId);
}
/** 动态字段沿用当前课程类型配置,排序与既有报名表单保持一致。 */
private List<BrandTrainMobileSignColumn> getScanCodeColumns(BrandTrainSignUpCourse course) {
if (StrUtil.isBlank(course.getCourseType())) {
return new ArrayList<>();
}
return dao().query(BrandTrainMobileSignColumn.class, Cnd.where("typeId", "=", course.getCourseType()).asc("columnIndex"));
}
/** 新点位继续使用活动内最近一份已保存表单,按报名时间和ID排序保证回显结果稳定。 */
private List<NutMap> getLatestScanCodeForm(String activityId, String userId) {
List<BrandTrainSignUpUser> users = dao().query(BrandTrainSignUpUser.class,
Cnd.where("activityId", "=", activityId).and("userId", "=", userId).desc("signUpTime").desc("id"));
return users.stream().map(BrandTrainSignUpUser::getMobileColumnsValue).filter(item -> item != null && !item.isEmpty())
.findFirst().orElseGet(ArrayList::new);
}
/** 在上层扫码或表单保存事务内同步已签到课程,未签到的普通报名记录保持原值。 */
@Aop(TransAop.READ_COMMITTED)
private void syncAttendMobileColumnsValue(String activityId, String userId, List<NutMap> formData) {
if (formData == null || formData.isEmpty()) {
return;
}
List<BrandTrainSignUpUserCourse> attends = dao().query(BrandTrainSignUpUserCourse.class,
Cnd.where("activityId", "=", activityId).and("userId", "=", userId).and("isAttend", "=", true));
List<String> courseIds = attends.stream().map(BrandTrainSignUpUserCourse::getCourseId).distinct().collect(Collectors.toList());
if (courseIds.isEmpty()) {
return;
}
List<BrandTrainSignUpUser> users = dao().query(BrandTrainSignUpUser.class,
Cnd.where("activityId", "=", activityId).and("userId", "=", userId).and("courseId", "in", courseIds));
users.forEach(item -> item.setMobileColumnsValue(Json.fromJsonAsList(NutMap.class, Json.toJson(formData))));
dao().update(users);
}
/** 字段定义以服务端配置为准,校验必填、整数/小数、日期、选项和附件数组,保留附件原有结构。 */
private Object validateScanCodeColumnValue(BrandTrainMobileSignColumn column, Object value) {
boolean empty = value == null || value instanceof String && StrUtil.isBlank((String) value)
|| value instanceof List && ((List<?>) value).isEmpty();
if (empty) {
if (Boolean.TRUE.equals(column.getIsRequired())) {
throw new IllegalArgumentException("请填写" + column.getColumnName());
}
return "FILE".equals(column.getColumnFormType()) ? new ArrayList<>() : "";
}
String text = String.valueOf(value);
String type = column.getColumnType();
try {
if (!"JSON".equals(type) && !"MYSQL_JSON".equals(type) && (value instanceof List || value instanceof Map)) {
throw new IllegalArgumentException();
}
if ("INT".equals(type) || "INT2".equals(type) || "INT4".equals(type) || "INT8".equals(type)) {
new BigDecimal(text).toBigIntegerExact();
} else if ("FLOAT".equals(type) || "DOUBLE".equals(type) || "DECIMAL".equals(type)) {
new BigDecimal(text);
} else if ("DATE".equals(type)) {
java.time.LocalDate.parse(text);
} else if ("DATETIME".equals(type) || "TIMESTAMP".equals(type)) {
java.time.LocalDateTime.parse(text, java.time.format.DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss")
.withResolverStyle(java.time.format.ResolverStyle.STRICT));
} else if ("BOOLEAN".equals(type) && !"true".equalsIgnoreCase(text) && !"false".equalsIgnoreCase(text)) {
throw new IllegalArgumentException();
} else if ("JSON".equals(type) || "MYSQL_JSON".equals(type)) {
value = value instanceof String ? Json.fromJson(text) : value;
if (!(value instanceof List) && !(value instanceof Map)) {
throw new IllegalArgumentException();
}
}
} catch (Exception e) {
throw new IllegalArgumentException(column.getColumnName() + "格式不正确");
}
if ("SELECT".equals(column.getColumnFormType()) && !"DATE".equals(type) && !"DATETIME".equals(type)
&& (column.getSelectValues() == null || !column.getSelectValues().contains(text))) {
throw new IllegalArgumentException("请选择有效的" + column.getColumnName());
}
if ("FILE".equals(column.getColumnFormType()) && (!(value instanceof List)
|| column.getFileNumber() != null && ((List<?>) value).size() > column.getFileNumber()
|| Boolean.TRUE.equals(column.getIsRequired()) && ((List<?>) value).isEmpty())) {
throw new IllegalArgumentException(column.getColumnName() + "附件数量不正确");
}
return value;
}
/** 扫码接口保持code/msg/data结构,重复签到也返回进度供手机端继续办理达标事项。 */
private NutMap scanCodeResponse(int code, String msg, NutMap data) {
return NutMap.NEW().addv("code", code).addv("msg", msg).addv("data", data);
}
private Boolean checkCnd(MatchConditionStructure cnd) { private Boolean checkCnd(MatchConditionStructure cnd) {
return cnd != null && cnd.getConditions() != null && cnd.getMethod() != null && cnd.getConditions().stream().allMatch(v -> v.getField() != null && v.getOperational() != null && v.getValue() != null); return cnd != null && cnd.getConditions() != null && cnd.getMethod() != null && cnd.getConditions().stream().allMatch(v -> v.getField() != null && v.getOperational() != null && v.getValue() != null);
} }
@@ -1,5 +1,20 @@
package io.v.nutz.brandtrainactivity.service.impl; package io.v.nutz.brandtrainactivity.service.impl;
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.hutool.core.util.StrUtil;
import io.v.nutz.brandtrainactivity.models.BrandTrainMobileSignColumn;
import io.v.nutz.brandtrainactivity.models.BrandTrainSignUpActivity;
import io.v.nutz.brandtrainactivity.models.BrandTrainSignUpCourse;
import io.v.nutz.brandtrainactivity.models.BrandTrainSignUpType;
import org.apache.poi.ss.usermodel.Workbook;
import org.nutz.dao.Cnd;
import org.nutz.json.Json;
import java.util.ArrayList;
import java.util.HashMap;
import cn.wizzer.framework.page.Pagination; import cn.wizzer.framework.page.Pagination;
import io.v.nutz.base.utils.Vi; import io.v.nutz.base.utils.Vi;
import io.v.nutz.dao.CndPlus; import io.v.nutz.dao.CndPlus;
@@ -175,4 +190,113 @@ public class BrandTrainSignUpActivityStatisticsServiceImpl extends ViServiceImpl
sql.setParam("activityId", activityId); sql.setParam("activityId", activityId);
return listMap(sql); return listMap(sql);
} }
/**
* 按活动汇总人员填写的信息;同一人员报名多个点位时只输出一行。
*
* @param activityId 需要导出的品牌活动ID,不可为空
* @return XSSF工作簿;固定列为人员信息和已签到课程,后续列为该活动课程类型的动态字段
*/
@Override
public Workbook exportFillFormWorkbook(String activityId) {
if (StrUtil.isBlank(activityId)) {
throw new IllegalArgumentException("请选择需要导出的活动");
}
BrandTrainSignUpActivity activity = dao().fetch(BrandTrainSignUpActivity.class, activityId);
if (activity == null) {
throw new IllegalArgumentException("未找到当前活动");
}
List<BrandTrainSignUpCourse> courseList = dao().query(BrandTrainSignUpCourse.class,
Cnd.where("activityId", "=", activityId).asc("orderNum"));
List<String> courseTypes = courseList.stream().map(BrandTrainSignUpCourse::getCourseType)
.filter(StrUtil::isNotBlank).distinct().collect(Collectors.toList());
// 多种课程类型可能复用字段编码但含义不同,不能按同一份动态表头合并导出。
if (courseTypes.size() > 1) {
throw new IllegalArgumentException("当前活动存在多个课程类型,不能导出填写信息");
}
List<BrandTrainMobileSignColumn> columns = new ArrayList<>();
if (!courseTypes.isEmpty()) {
BrandTrainSignUpType type = dao().fetch(BrandTrainSignUpType.class, courseTypes.get(0));
if (type != null) {
dao().fetchLinks(type, "trainMobileSignColumnList", Cnd.NEW().asc("columnIndex"));
if (type.getTrainMobileSignColumnList() != null) {
columns = type.getTrainMobileSignColumnList();
}
}
}
Sql userSql = Sqls.create("""
SELECT ts.userId, u.username, u.loginname, u.unitname, u.unionname, u.sex,
ts.mobileColumnsValue, ts.signUpTime
FROM brand_train_sign_up_user ts
LEFT JOIN `user` u ON u.id = ts.userId
WHERE ts.activityId = @activityId
ORDER BY ts.signUpTime DESC, ts.id
""").setParam("activityId", activityId);
Map<String, List<NutMap>> users = listMap(userSql).stream()
.filter(item -> StrUtil.isNotBlank(item.getString("userId")))
.collect(Collectors.groupingBy(item -> item.getString("userId"),
LinkedHashMap::new, Collectors.toList()));
Sql signSql = Sqls.create("""
SELECT uc.userId,
GROUP_CONCAT(DISTINCT c.courseName ORDER BY c.orderNum SEPARATOR '、') AS signCourseNames
FROM brand_train_sign_up_user_course uc
LEFT JOIN brand_train_sign_up_course c ON c.id = uc.courseId
WHERE uc.activityId = @activityId AND uc.isAttend = 1
GROUP BY uc.userId
""").setParam("activityId", activityId);
Map<String, String> signCourseNames = listMap(signSql).stream()
.collect(Collectors.toMap(item -> item.getString("userId"),
item -> item.getString("signCourseNames", ""), (first, second) -> first));
List<NutMap> rows = new ArrayList<>();
for (Map.Entry<String, List<NutMap>> entry : users.entrySet()) {
NutMap user = entry.getValue().get(0);
NutMap row = NutMap.NEW().addv("username", user.getString("username"))
.addv("loginname", user.getString("loginname"))
.addv("unitname", user.getString("unitname"))
.addv("unionname", user.getString("unionname"))
.addv("sex", user.getString("sex"))
.addv("signCourseNames", signCourseNames.getOrDefault(entry.getKey(), ""));
Map<String, Object> formValues = new HashMap<>();
// 签到表单会同步到各已签到点位;历史记录未同步时使用最近一份非空表单。
for (NutMap record : entry.getValue()) {
String json = record.getString("mobileColumnsValue");
if (StrUtil.isBlank(json)) {
continue;
}
List<NutMap> values = Json.fromJsonAsList(NutMap.class, json);
if (values == null || values.isEmpty()) {
continue;
}
for (NutMap value : values) {
formValues.put(value.getString("columnCode"), value.get("columnValue"));
}
break;
}
for (BrandTrainMobileSignColumn column : columns) {
Object value = formValues.get(column.getColumnCode());
// 动态字段单独命名,防止配置的字段编码覆盖姓名等固定导出列。
row.addv("form_" + column.getColumnCode(), value == null ? "" : String.valueOf(value));
}
rows.add(row);
}
List<ExcelExportEntity> entities = new ArrayList<>();
entities.add(new ExcelExportEntity("姓名", "username", 20));
entities.add(new ExcelExportEntity("工号", "loginname", 20));
entities.add(new ExcelExportEntity("单位", "unitname", 30));
entities.add(new ExcelExportEntity("分工会", "unionname", 30));
entities.add(new ExcelExportEntity("性别", "sex", 10));
entities.add(new ExcelExportEntity("已签到课程", "signCourseNames", 40));
for (BrandTrainMobileSignColumn column : columns) {
entities.add(new ExcelExportEntity(column.getColumnName(), "form_" + column.getColumnCode(), 25));
}
ExportParams exportParams = new ExportParams();
exportParams.setType(ExcelType.XSSF);
exportParams.setTitle(activity.getActivityName() + "填写信息");
exportParams.setSheetName("填写信息");
return ExcelExportUtil.exportExcel(exportParams, entities, rows);
}
} }
@@ -4,6 +4,7 @@ package io.v.nutz.sys.services;
import io.v.nutz.base.service.BaseService; import io.v.nutz.base.service.BaseService;
import io.v.nutz.sys.models.Sys_menu; import io.v.nutz.sys.models.Sys_menu;
import io.v.nutz.sys.models.Sys_user; import io.v.nutz.sys.models.Sys_user;
import org.nutz.lang.util.NutMap;
import java.util.List; import java.util.List;
@@ -11,6 +12,15 @@ import java.util.List;
* Created by wizzer on 2016/12/22. * Created by wizzer on 2016/12/22.
*/ */
public interface SysUserService extends BaseService<Sys_user> { public interface SysUserService extends BaseService<Sys_user> {
/**
* 无参读取当前Shiro Subject,为H5布局返回可公开的登录用户视图,不缓存当前用户。
*
* @return 非null的NutMap:id/loginname/username/avatar/unitid/unionid为身份字符串,
* roles为仅含code的对象数组,permissions为权限字符串数组;匿名、未知Principal或禁用用户
* 返回空身份字符串及两个空数组,不返回密码、盐、完整角色或用户实体。
*/
NutMap getH5LogonUser();
/** /**
* 查询用户的角色 * 查询用户的角色
* *
@@ -7,6 +7,9 @@ import io.v.nutz.sys.models.Sys_role;
import io.v.nutz.sys.models.Sys_user; import io.v.nutz.sys.models.Sys_user;
import io.v.nutz.sys.services.SysMenuService; import io.v.nutz.sys.services.SysMenuService;
import io.v.nutz.sys.services.SysUserService; import io.v.nutz.sys.services.SysUserService;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.UnavailableSecurityManagerException;
import org.apache.shiro.subject.Subject;
import org.nutz.aop.interceptor.ioc.TransAop; import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Cnd; import org.nutz.dao.Cnd;
import org.nutz.dao.Dao; import org.nutz.dao.Dao;
@@ -16,6 +19,7 @@ import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.Strings; import org.nutz.lang.Strings;
import org.nutz.lang.util.NutMap;
import org.nutz.plugins.wkcache.annotation.CacheDefaults; import org.nutz.plugins.wkcache.annotation.CacheDefaults;
import org.nutz.plugins.wkcache.annotation.CacheRemove; import org.nutz.plugins.wkcache.annotation.CacheRemove;
import org.nutz.plugins.wkcache.annotation.CacheRemoveAll; import org.nutz.plugins.wkcache.annotation.CacheRemoveAll;
@@ -23,6 +27,7 @@ import org.nutz.plugins.wkcache.annotation.CacheResult;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -39,6 +44,60 @@ public class SysUserServiceImpl extends BaseServiceImpl<Sys_user> implements Sys
@Inject @Inject
private SysMenuService sysMenuService; private SysMenuService sysMenuService;
/**
* 无参从当前Shiro登录态读取用户,仅在H5模板实际调用时组装安全视图。
* 返回身份白名单、roles:[{code}]和permissions:[字符串];空登录态、未知Principal、
* 无有效用户ID或禁用用户均返回空身份和空数组,避免模板json(null)生成无效脚本。
* 角色及菜单权限复用现有查询的禁用过滤规则,用户原始字符串由模板注入点负责脚本转义。
*
* @return 每次调用独立创建的NutMap,不包含密码、盐或完整角色/用户关联对象
*/
@Override
public NutMap getH5LogonUser() {
List<NutMap> roles = new ArrayList<>();
List<String> permissions = new ArrayList<>();
NutMap result = NutMap.NEW().addv("id", "").addv("loginname", "").addv("username", "")
.addv("avatar", "").addv("unitid", "").addv("unionid", "")
.addv("roles", roles).addv("permissions", permissions);
Subject subject;
try {
subject = SecurityUtils.getSubject();
} catch (UnavailableSecurityManagerException e) {
return result;
}
Object principal = subject == null ? null : subject.getPrincipal();
if (!(principal instanceof Sys_user)) {
return result;
}
Sys_user user = (Sys_user) principal;
if (user.isDisabled() || Strings.isBlank(user.getId())) {
return result;
}
result.setv("id", user.getId()).setv("loginname", Strings.sNull(user.getLoginname()))
.setv("username", Strings.sNull(user.getUsername())).setv("avatar", Strings.sNull(user.getAvatar()))
.setv("unitid", Strings.sNull(user.getUnitid())).setv("unionid", Strings.sNull(user.getUnionid()));
List<String> roleCodes = getRoleCodeList(user);
if (roleCodes != null) {
LinkedHashSet<String> uniqueRoles = new LinkedHashSet<>(roleCodes);
for (String roleCode : uniqueRoles) {
if (!Strings.isBlank(roleCode)) {
roles.add(NutMap.NEW().addv("code", roleCode));
}
}
}
List<Sys_menu> menus = getMenusAndButtons(user.getId());
if (menus != null) {
LinkedHashSet<String> uniquePermissions = new LinkedHashSet<>();
for (Sys_menu menu : menus) {
if (menu != null && !menu.isDisabled() && !Strings.isBlank(menu.getPermission())) {
uniquePermissions.add(menu.getPermission());
}
}
permissions.addAll(uniquePermissions);
}
return result;
}
/** /**
* 查询用户角色code列表 * 查询用户角色code列表
* *
@@ -0,0 +1,51 @@
package io.v.nutz.web.commons.controller;
import cn.hutool.core.util.StrUtil;
import io.v.nutz.annontation.ViReturn;
import io.v.nutz.base.result.Result;
import io.v.nutz.web.commons.ext.map.TMapService;
import org.apache.shiro.authz.annotation.RequiresAuthentication;
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.POST;
import org.nutz.mvc.annotation.Param;
/**
* 腾讯地图公共接口,供已登录用户在各业务模块中搜索地点。
*/
@IocBean
@At("/platform/TMap")
@Ok("json:full")
public class TMapController {
@Inject
private TMapService tMapService;
/**
* 查询腾讯地图地址候选,仅接收关键词;地图Key及搜索区域由公共服务读取配置。
*
* @param keyword 地址关键词,去除首尾空白后须为1至100个字符
* @return Result的code/msg/data;成功时data含腾讯status、message和地点data数组,
* 地点包含title、address、location.lat(纬度)和location.lng(经度)
*/
@At
@POST
@ViReturn
@RequiresAuthentication
public Result suggestion(@Param(value = "keyword", required = false) String keyword) {
if (StrUtil.isBlank(keyword)) {
return Result.error("请输入地址关键词");
}
String searchKeyword = keyword.trim();
if (searchKeyword.length() > 100) {
return Result.error("地址关键词不能超过100个字符");
}
try {
return Result.success(tMapService.suggestion(searchKeyword));
} catch (IllegalArgumentException e) {
return Result.error(e.getMessage());
}
}
}
@@ -0,0 +1,78 @@
package io.v.nutz.web.commons.ext.map;
import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HttpResponse;
import cn.hutool.http.HttpUtil;
import io.v.nutz.web.commons.base.Globals;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.json.Json;
import org.nutz.lang.util.NutMap;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 腾讯地图公共地点搜索服务,负责请求参数编码、服务调用和响应校验。
*/
@IocBean
public class TMapService {
// 地点搜索仅访问腾讯固定服务;系统TencentMapKey可覆盖参考工程的默认Key。
private static final String TENCENT_MAP_SUGGESTION_URL = "https://apis.map.qq.com/ws/place/v1/suggestion";
private static final String DEFAULT_TENCENT_MAP_KEY = "L5WBZ-3DDKH-KW2DC-WCDWL-Y3SUV-WNBDI";
/**
* 读取腾讯地图地址候选,仅访问固定的地点搜索服务,不修改本地数据。
*
* @param keyword 地址关键词,去除首尾空白后须为1至100个字符,搜索区域沿用系统CityName配置
* @return 腾讯响应对象;status=0表示成功,data为地点数组,location.lat为纬度、location.lng为经度
* @throws IllegalArgumentException 参数、网络或腾讯地图响应校验失败时提供可读业务提示
*/
public NutMap suggestion(String keyword) {
if (StrUtil.isBlank(keyword) || keyword.trim().length() > 100) {
throw new IllegalArgumentException("地址关键词须为1至100个字符");
}
String mapKey = Globals.MyConfig.getString("TencentMapKey");
if (StrUtil.isBlank(mapKey)) {
mapKey = DEFAULT_TENCENT_MAP_KEY;
}
Map<String, Object> params = new LinkedHashMap<>();
params.put("key", mapKey.trim());
params.put("keyword", keyword.trim());
params.put("region", StrUtil.trimToEmpty(Globals.MyConfig.getString("CityName")));
// 参数交由HTTP库编码,避免中文、&等字符改变查询内容;禁用重定向,保持固定服务地址。
String responseBody;
try (HttpResponse response = HttpUtil.createGet(TENCENT_MAP_SUGGESTION_URL)
.charset(StandardCharsets.UTF_8).form(params).timeout(5000).setFollowRedirects(false).execute()) {
if (response.getStatus() != 200) {
throw new IllegalStateException("腾讯地图地址搜索请求未成功");
}
responseBody = response.body();
} catch (Exception e) {
// HTTP库异常可能携带完整URL,禁止向前端或日志透传,以免暴露地图Key。
throw new IllegalArgumentException("腾讯地图地址搜索失败,请稍后重试");
}
if (StrUtil.isBlank(responseBody)) {
throw new IllegalArgumentException("腾讯地图地址搜索响应为空,请稍后重试");
}
NutMap result;
try {
result = Json.fromJson(NutMap.class, responseBody);
} catch (Exception e) {
throw new IllegalArgumentException("腾讯地图地址搜索响应异常,请稍后重试");
}
// 缺少状态不能按默认0当作成功;地址候选必须为数组,供前端自动完成组件直接使用。
if (result == null || !(result.get("status") instanceof Number)) {
throw new IllegalArgumentException("腾讯地图地址搜索响应异常,请稍后重试");
}
if (result.getInt("status") != 0) {
throw new IllegalArgumentException("腾讯地图地址搜索失败,请检查地图服务配置");
}
if (!(result.get("data") instanceof List)) {
throw new IllegalArgumentException("腾讯地图地址搜索响应异常,请稍后重试");
}
return result;
}
}
@@ -2,6 +2,7 @@ package io.v.nutz.web.commons.proc;
import io.v.nutz.base.utils.DateUtil; import io.v.nutz.base.utils.DateUtil;
import io.v.nutz.base.utils.StringUtil; import io.v.nutz.base.utils.StringUtil;
import io.v.nutz.sys.services.SysUserService;
import io.v.nutz.util.ShiroUtil; import io.v.nutz.util.ShiroUtil;
import io.v.nutz.web.commons.base.Globals; import io.v.nutz.web.commons.base.Globals;
import org.nutz.lang.Strings; import org.nutz.lang.Strings;
@@ -18,6 +19,7 @@ public class GlobalsSettingProcessor extends AbstractProcessor {
private static ShiroUtil ShiroUtil; private static ShiroUtil ShiroUtil;
private static DateUtil dateUtil; private static DateUtil dateUtil;
private static StringUtil stringUtil; private static StringUtil stringUtil;
private SysUserService h5UserService;
public void init(NutConfig config, ActionInfo ai) throws Throwable { public void init(NutConfig config, ActionInfo ai) throws Throwable {
if (ShiroUtil == null) { if (ShiroUtil == null) {
@@ -29,6 +31,8 @@ public class GlobalsSettingProcessor extends AbstractProcessor {
if (stringUtil == null) { if (stringUtil == null) {
stringUtil = new StringUtil(); stringUtil = new StringUtil();
} }
// 仅取得当前应用的服务代理,不在处理器初始化时读取登录态或查询用户权限。
h5UserService = config.getIoc().getByType(SysUserService.class);
} }
@SuppressWarnings("rawtypes") @SuppressWarnings("rawtypes")
@@ -41,6 +45,8 @@ public class GlobalsSettingProcessor extends AbstractProcessor {
ac.getRequest().setAttribute("AppFileDomain", Globals.AppFileDomain); ac.getRequest().setAttribute("AppFileDomain", Globals.AppFileDomain);
ac.getRequest().setAttribute("config", Globals.MyConfig); ac.getRequest().setAttribute("config", Globals.MyConfig);
ac.getRequest().setAttribute("shiro", ShiroUtil); ac.getRequest().setAttribute("shiro", ShiroUtil);
// H5 布局渲染时再调用服务生成安全用户视图,其余请求只绑定对象,不额外查询用户。
ac.getRequest().setAttribute("h5UserService", h5UserService);
ac.getRequest().setAttribute("date", dateUtil); ac.getRequest().setAttribute("date", dateUtil);
ac.getRequest().setAttribute("string", stringUtil); ac.getRequest().setAttribute("string", stringUtil);
// 如果url中有语言属性则设置 // 如果url中有语言属性则设置
@@ -25,4 +25,11 @@ public interface WelfareProjectService extends ViService<WelfareProject> {
List<WelfareProject> getSchoolWelfareList(Integer year); List<WelfareProject> getSchoolWelfareList(Integer year);
/**
* 获取签到扩展可关联的福利项目,包含选择尚未开始和正在进行的项目。
*
* @return 选择结束时间未过期的项目列表;每项的 id、name 用于活动配置的福利选择框
*/
List<WelfareProject> getAvailableSignWelfareProjects();
} }
@@ -18,6 +18,7 @@ import org.nutz.lang.Strings;
import org.nutz.lang.util.NutMap; import org.nutz.lang.util.NutMap;
import org.nutz.plugins.wkcache.annotation.CacheDefaults; import org.nutz.plugins.wkcache.annotation.CacheDefaults;
import java.util.Date;
import java.util.List; import java.util.List;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -76,4 +77,15 @@ public class WelfareProjectServiceImpl extends ViServiceImpl<WelfareProject> imp
return projectList; return projectList;
} }
/**
* 当前项目没有福利禁用字段,按已有的选择结束时间筛选可关联项目。
*
* @return 选择时间未结束的福利项目,按开始时间及创建时间排列
*/
@Override
public List<WelfareProject> getAvailableSignWelfareProjects() {
return query(Cnd.where("choiceTimeEnd", ">=", new Date())
.asc("choiceTimeStart").desc("createdAt"));
}
} }
@@ -0,0 +1,634 @@
<style>
.field .van-field__error-message {
display: none;
}
</style>
<template>
<div>
<van-form @submit="onSubmit" ref="form">
<template v-for="column in dynamicData">
<!-- 文本、数值、时间 -->
<template v-if="['VARCHAR', 'TEXT', 'INT', 'DATE', 'DATETIME'].includes(column.columnType)">
<template v-if="isAddressField(column)">
<van-field
class="field"
:value="getAddressAreaText(column)"
clickable
:label="column.columnName"
:name="column.columnCode + 'Area'"
:placeholder="'请选择' + column.columnName"
readonly
:rules="buildAddressAreaRules(column)"
:required="column.isRequired"
@click="openAddressPicker(column)"
></van-field>
<van-popup
v-model="pickerStates[column.columnCode + 'Picker']"
position="bottom"
get-container="body"
>
<van-area
:area-list="addressAreaList"
:value="getAddressAreaCode(column)"
:title="'选择' + column.columnName"
:area-columns-placeholder="['请选择', '请选择', '请选择']"
@cancel="closeAddressPicker(column)"
@confirm="
(values) => {
addressPickerConfirm(values, column)
}
"
></van-area>
</van-popup>
<van-field
class="field"
v-model="addressStates[column.columnCode].detail"
label="详细地址"
:name="column.columnCode"
placeholder="请填写详细地址"
:rules="buildAddressDetailRules(column)"
:required="column.isRequired"
@input="addressDetailInput(column)"
></van-field>
</template>
<template v-else>
<van-field
class="field"
v-model="column.columnValue"
:clickable="['SELECT'].includes(column.columnFormType)"
:label="column.columnName"
:name="column.columnCode"
:type="['INT'].includes(column.columnType) ? 'digit' : ''"
:placeholder="(['SELECT'].includes(column.columnFormType) ? '请选择' : '请填写') + column.columnName"
:readonly="['SELECT'].includes(column.columnFormType)"
:rules="[{ required: column.isRequired, message: (['SELECT'].includes(column.columnFormType) ? '请选择' : '请填写') + column.columnName }]"
:required="column.isRequired"
@click="selectFieldClick(column)"
></van-field>
<van-popup
v-if="['SELECT'].includes(column.columnFormType)"
v-model="pickerStates[column.columnCode + 'Picker']"
position="bottom"
get-container="body"
>
<template v-if="['DATETIME', 'DATE'].includes(column.columnType)">
<van-datetime-picker
v-model="column.currentDate"
:min-date="column.minDate"
:title="column.columnName"
:type="column.columnType.toLocaleLowerCase()"
show-toolbar
@cancel="dateTimePickerCancel(column)"
@confirm="
(time) => {
dateTimePickerConfirm(time, column)
}
"
/>
</template>
<template v-else>
<van-picker
:columns="column.selectValues"
:title="column.columnName"
show-toolbar
@cancel="ordinaryPickerCancel(column)"
@confirm="
(value) => {
ordinaryPickerConfirm(value, column)
}
"
/>
</template>
</van-popup>
</template>
</template>
<!-- 单选框 -->
<template v-else-if="['BOOLEAN'].includes(column.columnType)"></template>
<!-- 文件 -->
<template v-else-if="['JSON'].includes(column.columnType)">
<van-field
:required="column.isRequired"
:label="column.columnName"
:name="column.columnCode"
:rules="[{ validator: () => !column.isRequired || getSavedFileValues(column).length > 0, message: '请上传' + column.columnName }]"
>
<template #input>
<van-uploader
v-model="fileLists[column.columnCode]"
multiple
:max-count="getFileMaxCount(column)"
:accept="getFileAccept(column)"
:before-read="(files) => fileBeforeRead(files, column)"
:after-read="(files) => fileAfterRead(files, column)"
:preview-full-image="false"
@click-preview="previewFile"
@delete="() => syncFileColumnValue(column)"
></van-uploader>
</template>
</van-field>
</template>
</template>
</van-form>
</div>
</template>
<!-- 外层通过组件的 ref.form.validate() 校验,避免再次嵌套 van-form。 -->
<script>
module.exports = {
name: "BrandTrainDynamicForm",
components: {},
props: {
dynamicData: []
},
model: {
prop: "dynamicData",
event: "updateDynamicData"
},
watch: {
dynamicData: {
handler(newValue) {
this.initAddressStates(newValue)
this.initFileStates(newValue)
this.$emit("updateDynamicData", newValue)
},
deep: true,
immediate: true
}
},
data() {
return {
//所有的picker状态
pickerStates: {},
fileLists: {},
fileValueSnapshots: {},
addressStates: {},
addressAreaList: window.brandTrainSignUpAreaList || { province_list: {}, city_list: {}, county_list: {} }
}
},
computed: {
// 父页面提交前读取该状态,上传中的本地文件不能作为已保存附件提交。
hasUploading() {
return Object.keys(this.fileLists).some((key) => this.fileLists[key].some((item) => item.status === 'uploading'))
}
},
methods: {
async validForm() {
try {
this.$refs.form.validate().then(() => {
return true
}).catch(() => {
return false
})
} catch (e) {
return false
}
},
async onSubmit() {
},
/**
* 初始化地址字段的联动状态。
* 动态表单只保存 columnValue 字符串,这里需要额外拆成省市区和详细地址,方便前端进行地区联动选择。
*
* @param columns 当前动态字段列表
*/
initAddressStates(columns) {
if (!columns || columns.length === 0) {
return
}
columns.forEach((column) => {
if (!this.isAddressField(column)) {
return
}
const existsState = this.addressStates[column.columnCode]
if (existsState && existsState.initializedValue === column.columnValue) {
return
}
const addressState = this.parseAddressValue(column.columnValue)
addressState.initializedValue = column.columnValue || ""
this.$set(this.addressStates, column.columnCode, addressState)
})
},
/**
* 判断当前动态字段是否属于收货地址类字段。
* 目前后台没有独立的地址控件类型,因此这里按字段名称和字段编码做兼容识别。
*
* @param column 动态字段配置
* @return true-按地址控件渲染;false-沿用原有输入方式
*/
isAddressField(column) {
const columnName = (column && column.columnName) ? column.columnName : ""
const columnCode = (column && column.columnCode) ? column.columnCode.toLowerCase() : ""
return ["收货地址", "邮寄地址", "礼品邮递地址", "邮递地址"].some((item) => columnName.indexOf(item) > -1)
|| ["receiveaddress", "mailaddress", "shippingaddress"].some((item) => columnCode.indexOf(item) > -1)
},
/**
* 将已保存的地址字符串拆分为省市区和详细地址。
* 如果旧数据无法可靠拆分,就保留原始值作为兼容显示,避免历史文本地址丢失。
*
* @param addressValue 当前字段已保存的地址字符串
* @return 地址状态对象
*/
parseAddressValue(addressValue) {
const emptyState = {
province: "",
city: "",
county: "",
detail: "",
displayArea: "",
areaCode: "",
legacyValue: addressValue || "",
initializedValue: addressValue || ""
}
if (!addressValue) {
return emptyState
}
const provinceMatch = this.findAreaOption(addressValue, this.addressAreaList.province_list)
if (!provinceMatch) {
return emptyState
}
let remainValue = addressValue.substring(provinceMatch.name.length)
const cityMatch = this.findAreaOption(remainValue, this.addressAreaList.city_list, provinceMatch.code.substring(0, 2))
if (!cityMatch) {
return emptyState
}
remainValue = remainValue.substring(cityMatch.name.length)
const countyMatch = this.findAreaOption(remainValue, this.addressAreaList.county_list, cityMatch.code.substring(0, 4))
if (!countyMatch) {
return {
province: provinceMatch.name,
city: cityMatch.name,
county: "",
detail: remainValue,
displayArea: provinceMatch.name + cityMatch.name,
areaCode: cityMatch.code,
legacyValue: "",
initializedValue: addressValue || ""
}
}
remainValue = remainValue.substring(countyMatch.name.length)
return {
province: provinceMatch.name,
city: cityMatch.name,
county: countyMatch.name,
detail: remainValue,
displayArea: provinceMatch.name + cityMatch.name + countyMatch.name,
areaCode: countyMatch.code,
legacyValue: "",
initializedValue: addressValue || ""
}
},
/**
* 根据地址字符串前缀匹配地区代码和值。
*
* @param addressValue 地址字符串
* @param areaMap 地区编码映射
* @param codePrefix 编码前缀,用于限制省/市/区层级
* @return 匹配到的地区对象
*/
findAreaOption(addressValue, areaMap, codePrefix) {
if (!addressValue || !areaMap) {
return null
}
let match = null
Object.keys(areaMap).forEach((code) => {
if (codePrefix && code.indexOf(codePrefix) !== 0) {
return
}
const name = areaMap[code]
if (addressValue.indexOf(name) !== 0) {
return
}
if (!match || name.length > match.name.length) {
match = {
code: code,
name: name
}
}
})
return match
},
/**
* 获取地址字段当前展示的省市区文本。
*
* @param column 动态字段配置
* @return 省市区展示文本
*/
getAddressAreaText(column) {
const state = this.addressStates[column.columnCode]
if (!state) {
return ""
}
return state.displayArea || state.legacyValue || ""
},
/**
* 获取地区选择器默认定位的地区编码。
*
* @param column 动态字段配置
* @return 地区编码
*/
getAddressAreaCode(column) {
const state = this.addressStates[column.columnCode]
return state ? state.areaCode : ""
},
/**
* 地址地区选择字段校验。
* 已有历史纯文本地址时允许直接通过,避免旧数据因无法拆分省市区而被强制重填。
*
* @param column 动态字段配置
* @return van-field 规则数组
*/
buildAddressAreaRules(column) {
if (!column.isRequired) {
return []
}
return [{
validator: () => {
const state = this.addressStates[column.columnCode]
if (!state) {
return false
}
return !!(state.displayArea || state.legacyValue)
},
message: "请选择" + column.columnName
}]
},
/**
* 地址详细地址字段校验。
* 新填写的数据要求必须包含详细地址,历史纯文本地址继续兼容通过。
*
* @param column 动态字段配置
* @return van-field 规则数组
*/
buildAddressDetailRules(column) {
if (!column.isRequired) {
return []
}
return [{
validator: () => {
const state = this.addressStates[column.columnCode]
if (!state) {
return false
}
return !!(state.detail || state.legacyValue)
},
message: "请填写详细地址"
}]
},
/**
* 打开地址地区选择器。
*
* @param column 动态字段配置
*/
openAddressPicker(column) {
this.$set(this.pickerStates, column.columnCode + "Picker", true)
},
/**
* 关闭地址地区选择器。
*
* @param column 动态字段配置
*/
closeAddressPicker(column) {
this.$set(this.pickerStates, column.columnCode + "Picker", false)
},
/**
* 地址地区确认后同步 columnValue。
*
* @param values van-area 返回的省市区数组
* @param column 动态字段配置
*/
addressPickerConfirm(values, column) {
const validValues = (values || []).filter((item) => item && item.name)
const province = validValues[0] ? validValues[0].name : ""
const city = validValues[1] ? validValues[1].name : ""
const county = validValues[2] ? validValues[2].name : ""
const areaCode = validValues[2] ? validValues[2].code : (validValues[1] ? validValues[1].code : "")
const currentState = this.addressStates[column.columnCode] || {}
this.$set(this.addressStates, column.columnCode, {
province: province,
city: city,
county: county,
detail: currentState.detail || "",
displayArea: province + city + county,
areaCode: areaCode,
legacyValue: "",
initializedValue: currentState.initializedValue || ""
})
this.syncAddressColumnValue(column)
this.closeAddressPicker(column)
},
/**
* 详细地址输入后同步完整地址字符串。
*
* @param column 动态字段配置
*/
addressDetailInput(column) {
const currentState = this.addressStates[column.columnCode] || {}
this.$set(this.addressStates, column.columnCode, {
province: currentState.province || "",
city: currentState.city || "",
county: currentState.county || "",
detail: currentState.detail || "",
displayArea: currentState.displayArea || "",
areaCode: currentState.areaCode || "",
legacyValue: "",
initializedValue: currentState.initializedValue || ""
})
this.syncAddressColumnValue(column)
},
/**
* 将省市区和详细地址重新拼装回 columnValue。
* 后端仍然只接收一个 columnValue 字符串,因此这里保持原有存储结构不变。
*
* @param column 动态字段配置
*/
syncAddressColumnValue(column) {
const state = this.addressStates[column.columnCode]
if (!state) {
return
}
const addressValue = (state.displayArea || "") + (state.detail || "")
this.$set(column, "columnValue", addressValue)
},
selectFieldClick(column) {
this.$set(this.pickerStates, column.columnCode + "Picker", true)
if (!["DATE", "DATETIME"].includes(column.columnType)) {
//说明是普通的下拉框
// column.selectValues = ['1', '2', '3']
}else{
this.$set(column, "minDate", new Date(1900, 0, 1))
this.$set(column, "currentDate", new Date(column.columnValue))
}
},
/**
* 时间picker取消后,清空当前字段值。
*
* @param column 动态字段配置
*/
dateTimePickerCancel(column) {
this.$set(this.pickerStates, column.columnCode + "Picker", false)
this.$set(column, "columnValue", "")
},
/**
* 时间picker确认
* @param time
* @param column
*/
dateTimePickerConfirm(time, column) {
if (column.columnType === "DATE") {
this.$set(column, "columnValue", this.$moment(time).format("YYYY-MM-DD"))
} else if (column.columnType === "DATETIME") {
this.$set(column, "columnValue", this.$moment(time).format("YYYY-MM-DD HH:mm:ss"))
} else {
//预留别的类型
this.$set(column, "columnValue", this.$moment(time).format("YYYY-MM-DD HH:mm:ss"))
}
this.$set(this.pickerStates, column.columnCode + "Picker", false)
},
/**
* 普通picker取消后,清空当前字段值。
*
* @param column 动态字段配置
*/
ordinaryPickerCancel(column) {
this.$set(this.pickerStates, column.columnCode + "Picker", false)
this.$set(column, "columnValue", "")
},
/**
* 普通picker确认
* @param value
* @param column
*/
ordinaryPickerConfirm(value, column) {
this.$set(column, "columnValue", value)
this.$set(this.pickerStates, column.columnCode + "Picker", false)
},
/** 读取已保存附件数组,兼容历史 JSON 字符串;本地 File 和上传状态只保存在 fileLists。 */
getSavedFileValues(column) {
let values = column.columnValue
if (typeof values === 'string') {
try {
values = values ? JSON.parse(values) : []
} catch (err) {
return []
}
}
return Array.isArray(values) ? values.filter((item) => item && (item.id || item.url || item.filepath)) : []
},
/** 回填当前 Sys_file 或源系统 name/url 附件,快照未变化时保留正在上传、失败的本地预览项。 */
initFileStates(columns) {
(columns || []).forEach((column) => {
if (column.columnType !== 'JSON') {
return
}
const snapshot = JSON.stringify(column.columnValue || [])
if (this.fileValueSnapshots[column.columnCode] === snapshot) {
return
}
this.$set(this.fileValueSnapshots, column.columnCode, snapshot)
this.$set(this.fileLists, column.columnCode, this.getSavedFileValues(column).map((item) => this.buildFilePreview(item)))
})
},
/** 附件展示使用本站文件预览地址;旧 name/url 记录保留原 URL,不改变已保存的业务结构。 */
buildFilePreview(data) {
const name = data.filename || data.name || '附件'
return {
data: data,
name: name,
url: data.id ? '/file_server/fileStreamPreview?id=' + encodeURIComponent(data.id) : (data.url || data.filepath || ''),
isImage: /\.(jpg|jpeg|png|gif|bmp|webp|svg)$/i.test(name),
status: ''
}
},
getFileMaxCount(column) {
return Number(column.fileNumber) > 0 ? Number(column.fileNumber) : 1
},
getFileAccept(column) {
const types = Array.isArray(column.fileType) ? column.fileType : []
return types.length > 0 ? types.map((type) => type.indexOf('/') > -1 ? type : '.' + type.replace(/^\./, '')).join(',') : '*/*'
},
/** 单选或多选均按当前字段 fileType 校验;未配置类型时沿用允许任意类型的规则。 */
fileBeforeRead(files, column) {
const types = Array.isArray(column.fileType) ? column.fileType.map((type) => type.toLowerCase().replace(/^\./, '')) : []
const valid = (Array.isArray(files) ? files : [files]).every((file) => {
const extension = file.name.indexOf('.') > -1 ? file.name.split('.').pop().toLowerCase() : ''
return types.length === 0 || types.some((type) => type === extension || type === file.type
|| type.slice(-2) === '/*' && file.type.indexOf(type.slice(0, -1)) === 0)
})
if (!valid) {
this.$toast('请上传' + types.join('/') + '格式的附件')
}
return valid
},
/**
* Vant 回调参数可能是单文件或数组;每个文件分别上传,只有服务端返回的 Sys_file 进入 columnValue。
* /file_server/uploadFile 接收 FormData.file,返回 Result.data={id,filename,filepath,...};失败项可删除后重选。
*/
fileAfterRead(files, column) {
return Promise.all((Array.isArray(files) ? files : [files]).map((item) => {
this.$set(item, 'status', 'uploading')
this.$set(item, 'message', '上传中...')
const data = new FormData()
data.append('file', item.file, item.file.name)
return this.$axios.post('/file_server/uploadFile', data, {
headers: {'Content-Type': 'multipart/form-data'}
}).then((res) => {
if (res.code !== 0 || !res.data || !res.data.id) {
throw new Error(res.msg || '上传失败')
}
// 上传期间已删除的预览项不再加入业务附件,也不删除服务端文件。
if ((this.fileLists[column.columnCode] || []).indexOf(item) === -1) {
return
}
const preview = this.buildFilePreview(res.data)
Object.keys(preview).forEach((key) => this.$set(item, key, preview[key]))
this.$delete(item, 'file')
this.$delete(item, 'content')
this.syncFileColumnValue(column)
}).catch((err) => {
this.$set(item, 'status', 'failed')
this.$set(item, 'message', '上传失败,请删除后重试')
this.$toast(err.message || '上传失败,请稍后重试')
}).finally(() => {
if (item.status === 'uploading') {
this.$set(item, 'status', 'failed')
}
})
}))
},
/** 删除仅移除当前业务引用;失败、上传中的本地项永远不进入待保存的附件值。 */
syncFileColumnValue(column) {
const values = (this.fileLists[column.columnCode] || [])
.filter((item) => item.data && item.status !== 'uploading' && item.status !== 'failed')
.map((item) => item.data)
this.$set(this.fileValueSnapshots, column.columnCode, JSON.stringify(values))
this.$set(column, 'columnValue', values)
},
previewFile(item) {
if (!item.data || !item.url) {
return
}
if (item.isImage) {
vant.ImagePreview([item.url])
} else {
window.open(item.url, '_blank')
}
}
},
created() {
// console.log(this.dynamicData)
// this.dynamicData.forEach(v => {
// if (v.columnFormType === 'SELECT') {
// v.columnPickerName = v.columnCode+'Picker'
// }
// })
// console.log(this.dynamicData)
}
}
</script>
<style scoped></style>
@@ -70,6 +70,8 @@
<!-- <script type="text/javascript" src="https://cdn.jsdelivr.net/npm/wangeditor@4.6.15/dist/wangEditor.min.js"></script>--> <!-- <script type="text/javascript" src="https://cdn.jsdelivr.net/npm/wangeditor@4.6.15/dist/wangEditor.min.js"></script>-->
<script type="text/javascript" <script type="text/javascript"
src="https://webapi.amap.com/maps?v=1.4.15&key=b15158fab2e62a38638ffe27b45cfc6d&plugin=AMap.PolyEditor,AMap.PlaceSearch"></script> src="https://webapi.amap.com/maps?v=1.4.15&key=b15158fab2e62a38638ffe27b45cfc6d&plugin=AMap.PolyEditor,AMap.PlaceSearch"></script>
<!-- 参考工程在公共布局中静态引入腾讯地图,供活动选点及 PJAX 进入后直接使用。 -->
<script src="https://map.qq.com/api/gljs?v=2.exp&key=L5WBZ-3DDKH-KW2DC-WCDWL-Y3SUV-WNBDI"></script>
<!--viewer--> <!--viewer-->
<script src="${base!}/assets/platform/plugins/viewer/viewer.min.js"></script> <script src="${base!}/assets/platform/plugins/viewer/viewer.min.js"></script>
@@ -226,7 +226,17 @@
<!--vuex相关--> <!--vuex相关-->
<script> <script>
const logonUser = ${json(@auth.getLogonUser())} <!--#
var h5LogonUserJson = json(@h5UserService.getH5LogonUser());
h5LogonUserJson = strutil.replace(h5LogonUserJson, "<", "\\u003c");
h5LogonUserJson = strutil.replace(h5LogonUserJson, ">", "\\u003e");
h5LogonUserJson = strutil.replace(h5LogonUserJson, "&", "\\u0026");
h5LogonUserJson = strutil.replace(h5LogonUserJson, @java.lang.Character.toString(8232), "\\u2028");
h5LogonUserJson = strutil.replace(h5LogonUserJson, @java.lang.Character.toString(8233), "\\u2029");
#-->
// 仅输出服务筛选的用户字段、角色编码和权限标识,不将完整登录实体写入页面。
// JSON 中的 HTML 标记和 Unicode 行分隔符已转义,避免用户字段提前结束当前脚本。
const logonUser = ${h5LogonUserJson}
const store = new Vuex.Store({ const store = new Vuex.Store({
plugins: [createPersistedState({ plugins: [createPersistedState({
key: "zhgh-h5-vuex", key: "zhgh-h5-vuex",
@@ -0,0 +1,820 @@
<!--#
layout("/mobile/platform.html"){
#-->
<style>
/* 沿用点位签到组件的纵向布局:地图伸展,信息和操作保留自身高度,小屏可滚动。 */
#app.brand-train-sign-page {
display: flex;
height: 100vh;
height: 100dvh;
min-height: 0;
flex-direction: column;
overflow-y: auto;
background: #f4f7fc;
}
.brand-train-sign-page > .van-nav-bar__placeholder {
flex-shrink: 0;
}
.brand-train-sign-content {
display: flex;
flex: 1 0 auto;
flex-direction: column;
}
.brand-train-sign-map {
/* 固定 SDK 容器定位,防止地图控件溢出到信息区和底部按钮。 */
position: relative !important;
flex: 1 0 240px;
width: 100%;
min-height: 240px;
overflow: hidden;
}
.brand-train-sign-details {
flex-shrink: 0;
padding: 16px;
background: #fff;
}
.brand-train-sign-title {
color: #263548;
font-size: 16px;
font-weight: 600;
line-height: 24px;
overflow-wrap: anywhere;
}
.brand-train-sign-location-tools {
display: flex;
margin-bottom: 12px;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.brand-train-sign-location-tools .van-button {
flex-shrink: 0;
}
.brand-train-sign-location-status {
color: #78869a;
font-size: 12px;
line-height: 18px;
overflow-wrap: anywhere;
}
.brand-train-sign-text {
margin-top: 6px;
color: #78869a;
font-size: 13px;
line-height: 20px;
overflow-wrap: anywhere;
}
.brand-train-sign-text .van-icon {
margin-right: 4px;
vertical-align: middle;
}
.brand-train-sign-progress {
display: flex;
margin-top: 10px;
align-items: center;
flex-wrap: wrap;
gap: 8px;
color: #78869a;
font-size: 13px;
line-height: 20px;
}
.brand-train-sign-map-warning {
color: #ee0a24;
}
.brand-train-sign-footer {
display: flex;
flex-shrink: 0;
flex-direction: column;
gap: 8px;
padding: 12px 16px;
padding-bottom: calc(12px + env(safe-area-inset-bottom));
background: #fff;
}
.brand-train-sign-result {
padding: 24px 20px;
text-align: center;
background: #fff;
}
.brand-train-sign-result .brand-train-sign-title {
margin-top: 12px;
}
.brand-train-sign-form-popup {
width: 100%;
height: 100%;
background-color: #f6f7f9;
}
.brand-train-sign-form-content {
padding: 20px;
}
.brand-train-sign-form-card {
margin-bottom: 20px;
padding: 12px;
border-radius: 10px;
background-color: white;
}
</style>
<div id="app" class="brand-train-sign-page" v-cloak>
<van-nav-bar title="活动签到" left-text="返回" left-arrow fixed placeholder
@click-left="historyBack"></van-nav-bar>
<div v-if="tableLoading" class="brand-train-sign-result">
<van-loading size="24px" vertical>正在加载签到信息</van-loading>
</div>
<div v-else class="brand-train-sign-content">
<template v-if="hasSignProgress">
<div v-show="!mapError" id="brandTrainSignMap" class="brand-train-sign-map"></div>
<div class="brand-train-sign-details">
<div class="brand-train-sign-location-tools">
<span class="brand-train-sign-location-status" role="status"
:class="{'brand-train-sign-map-warning': mapError || locationError || distance != null && !isInRange}">
<template v-if="mapError">{{mapError}}</template>
<template v-else-if="locating">正在获取您的当前位置</template>
<template v-else-if="locationError">{{locationError}}</template>
<template v-else-if="distance != null">距签到地点约{{distanceText}}米 · {{isInRange ? '已进入签到范围' : '尚未进入签到范围'}}</template>
<template v-else>正在准备地图和定位服务</template>
</span>
<van-button v-if="mapError && siteCoordinates.length === 2" size="small" plain type="info"
:loading="mapLoading" @click="initSignMap">重新加载地图</van-button>
<van-button v-else-if="!mapError" size="small" plain type="info" icon="aim"
:loading="locating" :disabled="!mapReady || signLoading"
@click="refreshPosition">重新定位</van-button>
</div>
<div class="brand-train-sign-title">{{signData.courseName}}</div>
<div class="brand-train-sign-text">
<van-icon name="location-o"></van-icon>{{signData.courseLocation || '未填写地点名称'}}
</div>
<div class="brand-train-sign-text">签到范围:{{signRadius}} 米</div>
<div v-if="locationAccuracy != null" class="brand-train-sign-text">定位精度约 {{Math.ceil(locationAccuracy)}} 米</div>
<!-- 活动达标与当前点位签到分别展示,避免已达标时误认为所有点位都已签到。 -->
<div class="brand-train-sign-progress">
<span>已签到 {{signData.signCount}} 个点位 / 达标需 {{signData.signPointCount}} 个</span>
<van-tag v-if="isReachTarget" plain type="success">活动已达标</van-tag>
<van-tag v-else-if="currentPointSigned" plain type="success">当前点位已签到</van-tag>
</div>
<div v-if="resultMessage && resultMessage !== '当前活动已达标' && (isReachTarget || currentPointSigned || result.code !== 0)"
class="brand-train-sign-text" role="status">{{resultMessage}}</div>
</div>
</template>
<div v-else class="brand-train-sign-result">
<van-icon :name="isReachTarget || currentPointSigned ? 'checked' : 'warning'"
:color="isReachTarget || currentPointSigned ? '#07c160' : '#ee0a24'" size="40"></van-icon>
<div class="brand-train-sign-title">{{isReachTarget ? '当前活动已达标' : currentPointSigned ? '当前点位已签到' : '签到提示'}}</div>
<div class="brand-train-sign-text">{{resultMessage}}</div>
</div>
<div v-if="hasSignProgress || signData.needDynamicForm || signData.needWelfareSelect" class="brand-train-sign-footer">
<van-button v-if="hasSignProgress" block round type="info" loading-text="签到中..."
:loading="signLoading" :disabled="signDisabled" @click="submitSign">
{{currentPointSigned ? '当前点位已签到' : '确认签到'}}
</van-button>
<van-button v-if="signData.needDynamicForm" block round type="info"
@click="openDynamicForm">填写详细信息</van-button>
<van-button v-if="signData.needWelfareSelect" block round type="info"
@click="goWelfareSelect">去选择福利</van-button>
</div>
</div>
<van-popup v-model="dynamicFormPopup" class="brand-train-sign-form-popup" position="right">
<van-nav-bar title="填写详细信息" left-text="返回" left-arrow fixed placeholder
@click-left="closeDynamicForm"></van-nav-bar>
<div class="brand-train-sign-form-content">
<div>
<div class="brand-train-sign-form-card">
<brand-train-dynamic-form v-model="dynamicColumnsData" ref="brandTrainDynamicForm"></brand-train-dynamic-form>
</div>
<van-button block type="primary" native-type="button" :loading="formLoading"
:disabled="formLoading" @click="submitDynamicForm">提交</van-button>
</div>
</div>
</van-popup>
</div>
<script src="${base!}/assets/platform/plugins/axios/axios.js"></script>
<script>
<!--#include("/mobile/welfare/include/areaList.js"){}#-->
window.brandTrainSignUpAreaList = areaList
// 当前手机布局已提供 Vue、Vant 和 moment;本页单独补齐请求实例,供签到接口及动态表单附件上传使用。
const brandTrainAxios = axios.create({
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8',
'x-requested-with': 'XMLHttpRequest'
}
})
// 接口返回 Result 的 code/msg/data;0 为成功,99 为业务提示,均交由页面展示原始结果。
brandTrainAxios.interceptors.response.use((response) => {
const result = response.data
if (result && result.code !== 0 && result.code !== 99) {
vant.Toast(result.msg)
return Promise.reject(result)
}
return result
})
Vue.prototype.$axios = brandTrainAxios
Vue.prototype.$moment = moment
// 腾讯地图与定位组件仅在本页加载;定位和坐标转换统一由腾讯组件处理,实例不进入 Vue 响应式数据。
const brandTrainTencentKey = 'L5WBZ-3DDKH-KW2DC-WCDWL-Y3SUV-WNBDI'
const brandTrainTencentLoads = {}
let brandTrainMap = null
let brandTrainGeolocation = null
let brandTrainSiteMarker = null
let brandTrainCurrentMarker = null
let brandTrainSignCircle = null
let brandTrainLocationTimer = null
let brandTrainLocationRequestId = 0
let brandTrainMapDestroyed = false
new Vue({
el: '#app',
components: {
'brand-train-dynamic-form': httpVueLoader('/components/module/brandTrainSignUp/BrandTrainDynamicForm.vue')
},
data() {
return {
courseId: '',
result: {},
tableLoading: false,
formLoading: false,
signLoading: false,
locating: false,
mapReady: false,
mapLoading: false,
mapError: '',
locationError: '',
currentPosition: [],
locationAccuracy: null,
distance: null,
dynamicFormPopup: false,
dynamicColumnsData: []
}
},
computed: {
signData() {
return this.result.data || {}
},
hasSignProgress() {
return this.signData.signCount != null && this.signData.signPointCount != null
},
isReachTarget() {
return this.signData.isReachTarget === true
},
currentPointSigned() {
return this.signData.currentPointSigned === true
},
// 坐标按经度、纬度排列;缺失或超出有效范围时不创建地图,也不允许提交签到。
siteCoordinates() {
const coordinates = this.signData.courseLocationCoordinates
if (!Array.isArray(coordinates) || coordinates.length !== 2
|| coordinates.some((item) => item == null || item === '' || !Number.isFinite(Number(item)))) {
return []
}
const longitude = Number(coordinates[0])
const latitude = Number(coordinates[1])
return longitude >= -180 && longitude <= 180 && latitude >= -90 && latitude <= 90 ? [longitude, latitude] : []
},
signRadius() {
const radius = Number(this.signData.signRadius)
return Number.isFinite(radius) && radius > 0 ? radius : 250
},
distanceText() {
return this.distance == null ? '' : Math.ceil(this.distance)
},
isInRange() {
return this.distance != null && this.distance <= this.signRadius
},
signDisabled() {
return this.currentPointSigned || this.signLoading || this.locating || !this.mapReady
|| this.siteCoordinates.length !== 2 || this.currentPosition.length !== 2 || !this.isInRange
},
resultMessage() {
if (this.isReachTarget) {
return this.signData.handleMsg || '当前活动已达标'
}
if (this.hasSignProgress && this.result.code === 0) {
return this.currentPointSigned ? '请继续完成其他点位签到。' : '请在签到范围内点击下方按钮完成签到。'
}
return this.result.msg || ''
}
},
methods: {
// 沿用浏览器返回;从二维码直接进入且没有上一页时,回到当前系统手机首页。
historyBack() {
if (window.history.length > 1) {
window.history.back()
return
}
window.location.replace('/mobile/index')
},
/**
* courseId 为二维码中的课程点位 ID;本接口只预览,不签到、不补报名,也不写入福利名单。
* 返回 Result.data,包含进度、地点坐标 [经度, 纬度]、currentPointSigned、signRadius 及达标后的操作配置。
*/
getSignData() {
if (!this.courseId) {
this.$set(this, 'result', {code: 99, msg: '未获取到签到点位,请重新扫描二维码'})
return
}
this.$set(this, 'tableLoading', true)
this.$axios.post('/platform/mobile/brandTrain/scanCode/getSignData', {courseId: this.courseId})
.then((res) => {
this.$set(this, 'result', res)
})
.catch(() => {
this.$set(this, 'result', {code: 99, msg: '获取签到信息失败,请稍后重新扫描'})
})
.finally(() => {
this.$set(this, 'tableLoading', false)
this.$nextTick(() => this.initSignMap())
})
},
/**
* 无入参;返回 Promise,按需加载腾讯 GL 地图与前端定位组件,两者均就绪后再创建实例。
* 同一脚本复用加载结果,失败后移除脚本和缓存以便重试;页面销毁后取消尚未完成的加载。
*/
loadTencentSdk() {
const loadScript = (name, url, ready) => {
if (ready()) {
return Promise.resolve()
}
if (brandTrainTencentLoads[name]) {
return brandTrainTencentLoads[name].promise
}
const state = {script: document.createElement('script'), promise: null, cancel: null}
brandTrainTencentLoads[name] = state
state.promise = new Promise((resolve, reject) => {
let completed = false
let timer = null
const finish = (error) => {
if (completed) {
return
}
completed = true
window.clearTimeout(timer)
state.script.removeEventListener('load', onLoad)
state.script.removeEventListener('error', onError)
state.cancel = null
if (error) {
if (state.script.parentNode) {
state.script.parentNode.removeChild(state.script)
}
reject(error)
} else {
resolve()
}
}
const onLoad = () => {
finish(ready() ? null : new Error('腾讯地图或定位组件未正确加载,请重新加载地图。'))
}
const onError = () => finish(new Error('腾讯地图服务加载失败,请检查网络后重新加载。'))
state.cancel = () => finish(new Error('页面已关闭'))
state.script.setAttribute('src', url)
state.script.setAttribute('charset', 'utf-8')
state.script.setAttribute('async', '')
state.script.addEventListener('load', onLoad)
state.script.addEventListener('error', onError)
timer = window.setTimeout(() => finish(new Error('腾讯地图服务加载超时,请重新加载地图。')), 15000)
document.head.appendChild(state.script)
}).catch((error) => {
if (brandTrainTencentLoads[name] === state) {
delete brandTrainTencentLoads[name]
}
throw error
})
return state.promise
}
return Promise.all([
loadScript('map', 'https://map.qq.com/api/gljs?v=2.exp&key=' + encodeURIComponent(brandTrainTencentKey), () => {
return window.TMap && TMap.Map && TMap.LatLng && TMap.MultiMarker
&& TMap.MarkerStyle && TMap.MultiCircle && TMap.LatLngBounds
}),
loadScript('location', 'https://mapapi.qq.com/web/lbs/h5-components/geolocation/geolocation.min.js', () => {
return window.LBS && window.LBS.WebComponent && window.LBS.WebComponent.Geolocation
})
])
},
/** 只在容器和 SDK 均准备完成后创建腾讯地图;首次展示只定位预览,不发起签到。 */
initSignMap() {
if (brandTrainMapDestroyed || !this.hasSignProgress || brandTrainMap || this.mapLoading) {
return
}
if (this.siteCoordinates.length !== 2) {
this.$set(this, 'mapError', '当前课程未配置有效的签到地点坐标,请联系管理员。')
return
}
this.$set(this, 'mapLoading', true)
this.$set(this, 'mapError', '')
return this.loadTencentSdk()
.then(() => new Promise((resolve) => this.$nextTick(resolve)))
.then(() => {
if (brandTrainMapDestroyed) {
return
}
// 业务字段保持 GCJ-02 [经度, 纬度];腾讯 LatLng 构造参数采用纬度、经度。
const center = new TMap.LatLng(this.siteCoordinates[1], this.siteCoordinates[0])
brandTrainMap = new TMap.Map('brandTrainSignMap', {center: center, zoom: 16})
brandTrainSiteMarker = new TMap.MultiMarker({
map: brandTrainMap,
styles: {site: this.createSignMarkerStyle('签到地点', '#ee0a24', false)},
geometries: [{id: 'sign-site', styleId: 'site', position: center}]
})
brandTrainCurrentMarker = new TMap.MultiMarker({
map: brandTrainMap,
zIndex: 2,
styles: {current: this.createSignMarkerStyle('我的位置', '#1989fa', true)},
geometries: []
})
brandTrainSignCircle = new TMap.MultiCircle({
map: brandTrainMap,
geometries: [{id: 'sign-radius', center: center, radius: this.signRadius}]
})
// key 沿用本页地图配置;referer 是应用名称,腾讯组件内部完成定位途径选择和坐标转换。
brandTrainGeolocation = new window.LBS.WebComponent.Geolocation({
key: brandTrainTencentKey,
referer: 'UNION_ZJXU-brandTrain'
})
this.$set(this, 'mapReady', true)
this.fitSignMap()
return this.refreshPosition()
})
.catch((error) => {
if (!brandTrainMapDestroyed) {
this.destroySignMap()
this.$set(this, 'mapError', error.message || '腾讯地图初始化失败,请重新加载地图。')
}
})
.finally(() => {
if (!brandTrainMapDestroyed) {
this.$set(this, 'mapLoading', false)
}
})
},
/**
* label 为静态点位名称,color 为标记颜色,below 控制标签位于定位点下方;返回腾讯 MarkerStyle。
* 两点重合时分别在点位上下显示名称,避免“签到地点”与“我的位置”互相遮挡。
*/
createSignMarkerStyle(label, color, below) {
const circleY = below ? 12 : 36
const labelY = below ? 25 : 1
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="80" height="50" viewBox="0 0 80 50">'
+ '<rect x="2" y="' + labelY + '" width="76" height="22" rx="11" fill="' + color + '"/>'
+ '<text x="40" y="' + (labelY + 15) + '" text-anchor="middle" font-family="sans-serif" font-size="12" fill="white">' + label + '</text>'
+ '<circle cx="40" cy="' + circleY + '" r="11" fill="' + color + '" fill-opacity="0.2"/>'
+ '<circle cx="40" cy="' + circleY + '" r="6" fill="' + color + '" stroke="white" stroke-width="2"/></svg>'
return new TMap.MarkerStyle({
width: 80,
height: 50,
anchor: {x: 40, y: circleY},
src: 'data:image/svg+xml;charset=UTF-8,' + encodeURIComponent(svg)
})
},
/** 无入参;地图范围同时包含签到圈和本人位置,位置重合时仍保留可辨识的范围。 */
fitSignMap() {
if (!brandTrainMap || brandTrainMapDestroyed) {
return
}
const site = this.siteCoordinates
const current = this.currentPosition.length === 2 ? this.currentPosition : site
const latPadding = Math.max(this.signRadius / 111320, 0.0003)
const lngPadding = latPadding / Math.max(Math.cos(site[1] * Math.PI / 180), 0.000001)
const southwest = new TMap.LatLng(
Math.max(-90, Math.min(site[1], current[1]) - latPadding),
Math.max(-180, Math.min(site[0], current[0]) - lngPadding))
const northeast = new TMap.LatLng(
Math.min(90, Math.max(site[1], current[1]) + latPadding),
Math.min(180, Math.max(site[0], current[0]) + lngPadding))
brandTrainMap.fitBounds(new TMap.LatLngBounds(southwest, northeast))
},
/**
* coordinates 为本次 GCJ-02 [经度, 纬度];返回距配置签到点的米数。
* 与服务端采用相同的 6371000 米地球半径和 Haversine 公式,保留原始精度判断配置半径的边界。
*/
getSignDistance(coordinates) {
const site = this.siteCoordinates
const latitude1 = site[1] * Math.PI / 180
const latitude2 = coordinates[1] * Math.PI / 180
const deltaLatitude = latitude2 - latitude1
const deltaLongitude = (coordinates[0] - site[0]) * Math.PI / 180
const haversine = Math.pow(Math.sin(deltaLatitude / 2), 2)
+ Math.cos(latitude1) * Math.cos(latitude2) * Math.pow(Math.sin(deltaLongitude / 2), 2)
return 6371000 * 2 * Math.asin(Math.sqrt(Math.max(0, Math.min(1, haversine))))
},
/**
* detail 为腾讯定位失败对象,status 是定位状态码,message 是说明,logs 是各定位途径的失败记录。
* 返回面向用户的 Error;diagnostic 保留 status/message/logs 便于排查,不复制定位坐标。
* 总失败时仅归纳一致的已知原因;未加载微信等其他 SDK 不直接判定为未授权或腾讯服务故障。
*/
createLocationError(detail) {
const source = detail && typeof detail === 'object' ? detail : {}
const status = Number(source.status) || 0
const logs = Array.isArray(source.logs) ? source.logs.filter((item) => item && typeof item === 'object') : []
const categories = {
10002: 'timeout',
10101: 'permission',
10102: 'unavailable',
10103: 'timeout',
10104: 'unsupported',
10201: 'unavailable',
10302: 'permission',
10303: 'sensor',
10304: 'unavailable',
10305: 'permission',
10401: 'unavailable',
10402: 'timeout',
10601: 'geocoder',
10602: 'conversion'
}
const messages = {
permission: '当前页面未获定位授权,请允许当前位置访问,并检查系统定位权限。',
unavailable: '暂时无法获取当前位置,请检查设备定位服务和网络后重试。',
timeout: '获取当前位置超时,请到信号较好的位置重新定位。',
unsupported: '当前环境不支持此定位途径,请更换浏览器或在微信中重试。',
sensor: '设备定位传感器未开启,请开启设备定位后重试。',
geocoder: '腾讯逆地址解析服务异常(10601),请联系管理员检查该 Key 的服务权限、额度及访问限制。',
conversion: '腾讯坐标转换服务异常(10602),请联系管理员检查该 Key 的服务权限、额度及访问限制。'
}
let category = categories[status]
if (status === 10001 || status === 0) {
const causes = logs.map((item) => categories[Number(item.status)]).filter((item) => !!item)
if (causes.length && causes.every((item) => item === causes[0])) {
category = causes[0]
}
}
const error = new Error(messages[category] || '腾讯定位未成功,请重新定位;若持续失败,请联系管理员查看定位诊断。')
error.diagnostic = {
status: status,
message: typeof source.message === 'string' ? source.message : '',
logs: logs.map((item) => ({
status: Number(item.status) || 0,
type: typeof item.type === 'string' ? item.type : '',
message: typeof item.message === 'string' ? item.message : ''
}))
}
return error
},
/**
* 无入参;返回 Promise<[经度, 纬度]>,直接使用腾讯组件转换后的 GCJ-02 坐标,不再二次转换。
* 每次清除旧位置,定位失败或页面销毁后禁止沿用旧点签到;请求编号和超时隔离迟到的定位回调。
*/
locatePosition() {
if (!this.mapReady || !brandTrainGeolocation || this.siteCoordinates.length !== 2) {
return Promise.reject(new Error('定位服务尚未准备好,请稍后重试。'))
}
this.$set(this, 'locating', true)
this.$set(this, 'locationError', '')
this.$set(this, 'currentPosition', [])
this.$set(this, 'locationAccuracy', null)
this.$set(this, 'distance', null)
if (brandTrainCurrentMarker) {
brandTrainCurrentMarker.setGeometries([])
}
brandTrainLocationRequestId += 1
const requestId = brandTrainLocationRequestId
return new Promise((resolve, reject) => {
let completed = false
const finish = (error, res) => {
if (completed) {
return
}
completed = true
window.clearTimeout(brandTrainLocationTimer)
if (brandTrainMapDestroyed || requestId !== brandTrainLocationRequestId) {
reject(new Error('页面已关闭'))
return
}
if (error) {
reject(error)
return
}
try {
// 即使组件意外返回 IP 定位,也不能用于小半径点位签到;经纬度必须为合法数值。
if (!res || res.type === 'ip' || !Number.isFinite(res.lng) || !Number.isFinite(res.lat)
|| Math.abs(res.lng) > 180 || Math.abs(res.lat) > 90) {
throw new Error('未获取到有效的高精度位置,请重新定位。')
}
const coordinates = [res.lng, res.lat]
const distance = this.getSignDistance(coordinates)
if (!Number.isFinite(distance)) {
throw new Error('当前位置距离计算失败,请重新定位。')
}
this.$set(this, 'currentPosition', coordinates)
this.$set(this, 'distance', distance)
// 精度仅供用户判断设备定位质量,不抵扣距离,也不放宽管理员配置的签到半径。
this.$set(this, 'locationAccuracy', Number.isFinite(res.accuracy) && res.accuracy >= 0
? res.accuracy : null)
brandTrainCurrentMarker.setGeometries([{
id: 'current-user',
styleId: 'current',
position: new TMap.LatLng(coordinates[1], coordinates[0])
}])
this.fitSignMap()
resolve(coordinates)
} catch (err) {
reject(err)
}
}
// 依据浏览器实际安全上下文判断,localhost 可被视为安全来源,不能仅凭 http 协议拦截。
if (window.isSecureContext === false) {
finish(new Error('当前页面不在安全环境中,请通过 HTTPS 地址打开后定位。'))
return
}
brandTrainLocationTimer = window.setTimeout(() => {
finish(this.createLocationError({status: 10002, message: '页面等待腾讯定位结果超过12秒'}))
}, 12000)
try {
// 严格高精度模式禁止 IP 兜底;maximumAge 为 0,确认签到时必须获取本次位置。
brandTrainGeolocation.getLocation(
(res) => finish(null, res),
(error) => finish(this.createLocationError(error)),
{timeout: 10000, highAccuracy: true, maximumAge: 0})
} catch (err) {
finish(this.createLocationError({message: err && err.message || '定位服务调用异常'}))
}
}).catch((err) => {
if (!brandTrainMapDestroyed) {
this.$set(this, 'locationError', err.message || '定位失败,请重新定位。')
this.$set(this, 'currentPosition', [])
this.$set(this, 'locationAccuracy', null)
this.$set(this, 'distance', null)
if (brandTrainCurrentMarker) {
brandTrainCurrentMarker.setGeometries([])
}
// 诊断仅记录失败信息和环境能力,不记录用户坐标,也不向后端发送日志。
console.warn('签到定位失败', {
diagnostic: err.diagnostic || {message: err.message},
secureContext: window.isSecureContext
})
}
throw err
}).finally(() => {
if (!brandTrainMapDestroyed) {
this.$set(this, 'locating', false)
}
})
},
// 释放腾讯图层及 WebGL 地图;不改变签到结果和动态表单数据。
destroySignMap() {
if (brandTrainMap) {
brandTrainMap.destroy()
}
brandTrainMap = null
brandTrainGeolocation = null
brandTrainSiteMarker = null
brandTrainCurrentMarker = null
brandTrainSignCircle = null
if (!brandTrainMapDestroyed) {
this.$set(this, 'mapReady', false)
}
},
// 重新定位只刷新地图和距离,始终不会调用签到接口。
refreshPosition() {
if (this.locating || this.signLoading) {
return
}
return this.locatePosition().catch((err) => {
if (!brandTrainMapDestroyed) {
this.$toast(err.message || '定位失败,请重新定位。')
}
})
},
/**
* 点击后重新定位并提交 courseId、longitude(经度)、latitude(纬度);不能复用打开页面时的旧位置。
* 返回 Result,code=0 表示签到成功、99 为业务提示,data 沿用预览结构;服务端再次校验配置半径。
*/
submitSign() {
if (this.signDisabled) {
return
}
this.$set(this, 'signLoading', true)
return this.locatePosition().then((coordinates) => {
if (!this.isInRange) {
throw new Error('您距签到地点约' + this.distanceText + '米,请在' + this.signRadius + '米范围内签到。')
}
return this.$axios.post('/platform/mobile/brandTrain/scanCode/doSign', {
courseId: this.courseId,
longitude: coordinates[0],
latitude: coordinates[1]
}).then((res) => {
// 业务失败且未返回预览数据时保留地图和现有结果,用户可重新定位后重试。
if (res.data) {
this.$set(this, 'result', res)
}
this.$toast(res.msg || (res.code === 0 ? '签到成功' : '签到失败,请稍后重试'))
})
}).catch((err) => {
if (!brandTrainMapDestroyed) {
this.$toast(err.msg || err.message || '签到失败,请稍后重试')
}
}).finally(() => {
if (!brandTrainMapDestroyed) {
this.$set(this, 'signLoading', false)
}
})
},
// 使用字段副本编辑,取消填写时保留后端返回的原始字段和已保存内容。
openDynamicForm() {
if (!Array.isArray(this.signData.dynamicColumnsData) || this.signData.dynamicColumnsData.length === 0) {
this.$toast('当前课程未配置动态表单')
return
}
this.$set(this, 'dynamicColumnsData', JSON.parse(JSON.stringify(this.signData.dynamicColumnsData)))
this.$set(this, 'dynamicFormPopup', true)
},
closeDynamicForm() {
if (!this.formLoading) {
this.$set(this, 'dynamicFormPopup', false)
}
},
/**
* 提交当前 courseId 及 mobileColumnsValue 字段值 JSON 数组;字段沿用现有动态表单的名称、编码和控件类型。
* 接口返回 Result,code 为 0 时保存成功;失败时保留填写内容,允许继续修改或重试。
*/
submitDynamicForm() {
if (this.formLoading || !this.$refs.brandTrainDynamicForm) {
return
}
if (this.$refs.brandTrainDynamicForm.hasUploading) {
this.$toast('附件正在上传,请稍候再提交')
return
}
this.$set(this, 'formLoading', true)
this.$refs.brandTrainDynamicForm.$refs.form.validate()
.then(() => {
const mobileColumnsValue = this.dynamicColumnsData.map((item) => {
return {
columnName: item.columnName,
columnValue: item.columnValue,
columnCode: item.columnCode,
columnFormType: item.columnFormType
}
})
return this.$axios.post('/platform/mobile/brandTrain/scanCode/saveDynamicForm', {
courseId: this.courseId,
mobileColumnsValue: JSON.stringify(mobileColumnsValue)
}).then((res) => {
if (res.code === 0) {
// 保存最近一次填写内容,当前页再次打开表单时沿用该值,重新扫码时由后端回显已保存数据。
this.$set(this.signData, 'dynamicColumnsData', JSON.parse(JSON.stringify(this.dynamicColumnsData)))
this.$set(this.signData, 'needDynamicForm', false)
this.$set(this.signData, 'handleMsg', '当前活动已达标,详细信息已保存')
this.$set(this, 'dynamicFormPopup', false)
this.$toast('保存成功')
} else {
this.$toast(res.msg || '保存失败,请稍后重试')
}
}).catch(() => {
this.$toast('保存失败,请稍后重试')
})
}, () => {
this.$toast('请完善必填信息')
})
.finally(() => {
this.$set(this, 'formLoading', false)
})
},
// 福利页沿用旧手机端完整布局,通过整页跳转加载其脚本和布局依赖。
goWelfareSelect() {
if (!this.signData.welfareJumpUrl) {
this.$toast('当前活动未配置福利跳转地址')
return
}
window.location.href = this.signData.welfareJumpUrl
}
},
created() {
this.$set(this, 'courseId', GetQueryString('courseId') || '')
this.getSignData()
},
beforeDestroy() {
brandTrainMapDestroyed = true
brandTrainLocationRequestId += 1
window.clearTimeout(brandTrainLocationTimer)
Object.keys(brandTrainTencentLoads).forEach((name) => {
const state = brandTrainTencentLoads[name]
if (state.cancel) {
state.cancel()
}
})
this.destroySignMap()
}
})
</script>
<!--#
}
#-->
@@ -285,6 +285,7 @@ layout("/mobile/platform.html"){
未签到 未签到
</van-button> </van-button>
<van-button type="primary" color="#1867b0" round size="mini" <van-button type="primary" color="#1867b0" round size="mini"
:loading="formLoading" :disabled="formLoading"
v-if="(item.isAttend === false && findTime(item) === true && item.state === 1) v-if="(item.isAttend === false && findTime(item) === true && item.state === 1)
|| (moment().unix() > moment(item.courseEndTime).unix() && item.state === 3 && item.isAttend === false)" || (moment().unix() > moment(item.courseEndTime).unix() && item.state === 3 && item.isAttend === false)"
style="position: absolute; right: 0; top: 25%; width: 60px; height: 25px" style="position: absolute; right: 0; top: 25%; width: 60px; height: 25px"
@@ -358,6 +359,7 @@ layout("/mobile/platform.html"){
assortList: [], assortList: [],
allColumnsData: [], allColumnsData: [],
dynamicColumnsData: [], dynamicColumnsData: [],
formLoading: false,
trainType: '培训班', trainType: '培训班',
trainTypeList: [], trainTypeList: [],
actionShow: false, actionShow: false,
@@ -448,27 +450,51 @@ layout("/mobile/platform.html"){
return day.isAttend ? 2 : 1 return day.isAttend ? 2 : 1
} }
}, },
async signIn(o) { signIn(o) {
if (this.formLoading) {
return
}
if (o.signType === 1) { if (o.signType === 1) {
//调相机扫一扫 if (this.formLoading) {
wxScannerQrCode.scanner().then(async res => { return
if (res) { }
const course = JSON.parse(res) // 新点位二维码为本站签到页地址;旧课程二维码仍为 {courseId} JSON,并沿用原签到接口。
const resp = await $.post('/platform/mobile/brandTrainSignUpActivity/codeSign', { wxScannerQrCode.scanner().then((res) => {
codeCourseId: course.courseId, if (!res) {
clickCourseId: o.courseId, return
id: o.id,
})
if(resp.code === 0) {
vant.Dialog.alert({message: '签到成功'})
await this.pageData()
o.isAttend = true
} else {
vant.Dialog.alert({message: resp.msg})
}
} }
}).catch(err => { const qrContent = String(res).trim()
vant.Dialog.alert({message: err}) let course
try {
if (qrContent.charAt(0) === '{') {
course = JSON.parse(qrContent)
} else {
const signUrl = new URL(qrContent, window.location.origin)
const courseId = signUrl.searchParams.get('courseId')
// 只接受本站品牌活动签到页,且课程必须与当前点击的签到记录一致,避免任意地址跳转。
if (signUrl.origin !== window.location.origin || signUrl.pathname !== '/platform/mobile/brandTrain/scanCode') {
vant.Dialog.alert({message: '请扫描当前品牌活动课程的签到二维码'})
return
}
if (!courseId || courseId !== o.courseId) {
vant.Dialog.alert({message: '签到失败,二维码与您当前签到信息不符'})
return
}
window.location.href = signUrl.origin + signUrl.pathname + '?courseId=' + encodeURIComponent(courseId)
return
}
} catch (err) {
vant.Dialog.alert({message: '二维码格式不正确,请重新扫描'})
return
}
if (!course || !course.courseId || course.courseId !== o.courseId) {
vant.Dialog.alert({message: '签到失败,二维码与您当前签到信息不符'})
return
}
// 旧二维码仍使用原接口,但必须提交本次定位,服务端按课程配置半径校验。
this.submitLocatedSign(o, course.courseId)
}).catch((err) => {
vant.Dialog.alert({message: typeof err === 'string' ? err : '签到失败,请稍后重试'})
}) })
} }
if (o.signType === 2) { if (o.signType === 2) {
@@ -489,35 +515,86 @@ layout("/mobile/platform.html"){
vant.Toast('请在规定时间签到') vant.Toast('请在规定时间签到')
return return
} }
const that = this // 范围判断交给服务端,避免浏览器地图距离取整后误放行边界外的点。
geolocation.getCurrentPosition(async function (status, result) { return this.submitLocatedSign(o)
if (status === 'complete') {
locations = [result.position.getLng(), result.position.getLat()]
//用户当前位置
const userPoint = new AMap.LngLat(result.position.getLng(), result.position.getLat())
//课程位置
const coursePoint = new AMap.LngLat(that.course.courseLocationCoordinates[0], that.course.courseLocationCoordinates[1])
const distance = Math.round(userPoint.distance(coursePoint))
if (distance > 250) {
vant.Toast('请在250米内签到')
return
}
const array = [result.position.getLng(), result.position.getLat()]
const resp = await $.post('/platform/mobile/brandTrainSignUpActivity/doQd', {
id: o.id,
courseId: that.course.id,
point: JSON.stringify(array),
})
if (resp.code === 0) {
await that.pageData()
}
vant.Toast(resp.msg)
that.popupShow = false
} else {
}
});
} }
}, },
/**
* 无参数;返回jQuery Promise,成功值为本次GCJ-02定位的[经度,纬度]。
* 每次签到重新定位,扫码入口无需先打开GPS地图;超时或定位失败不发送签到请求。
*/
getSignPosition() {
const deferred = $.Deferred()
const timer = window.setTimeout(() => deferred.reject('定位超时,请重试'), 12000)
deferred.always(() => window.clearTimeout(timer))
try {
if (!window.AMap || !AMap.plugin) {
deferred.reject('定位服务未加载,请刷新页面重试')
return deferred.promise()
}
AMap.plugin('AMap.Geolocation', () => {
if (deferred.state() !== 'pending') return
try {
const locator = new AMap.Geolocation({enableHighAccuracy: true, timeout: 10000, convert: true})
locator.getCurrentPosition((status, result) => {
if (status !== 'complete' || !result || !result.position) {
deferred.reject('定位失败,请开启定位权限后重试')
return
}
const coordinates = [result.position.getLng(), result.position.getLat()]
if (!coordinates.every(value => Number.isFinite(value))
|| coordinates[0] < -180 || coordinates[0] > 180
|| coordinates[1] < -90 || coordinates[1] > 90) {
deferred.reject('定位坐标无效,请重新定位')
return
}
deferred.resolve(coordinates)
})
} catch (err) {
deferred.reject('定位服务不可用,请刷新页面重试')
}
})
} catch (err) {
deferred.reject('定位服务不可用,请刷新页面重试')
}
return deferred.promise()
},
/**
* o为当前用户的课次签到记录;codeCourseId为旧二维码课程ID,缺省时走GPS入口。
* 返回jQuery Promise;响应code=0成功,msg为提示,data为空,半径始终由服务端读取。
*/
submitLocatedSign(o, codeCourseId) {
if (this.formLoading) return
this.$set(this, 'formLoading', true)
return this.getSignPosition().then(coordinates => {
if (codeCourseId) {
return $.post('/platform/mobile/brandTrainSignUpActivity/codeSign', {
id: o.id, codeCourseId: codeCourseId, clickCourseId: o.courseId,
longitude: coordinates[0], latitude: coordinates[1]
})
}
return $.post('/platform/mobile/brandTrainSignUpActivity/doQd', {
id: o.id, courseId: o.courseId, point: JSON.stringify(coordinates)
})
}).then(resp => {
if (resp.code === 0) {
this.$set(o, 'isAttend', true)
this.pageData()
}
vant.Toast(resp.msg || (resp.code === 0 ? '签到成功' : '签到失败'))
if (!codeCourseId) {
this.$set(this, 'popupShow', false)
}
}, err => {
vant.Toast(typeof err === 'string' ? err : '签到失败,请稍后重试')
}).always(() => {
this.$set(this, 'formLoading', false)
})
},
// course为课程对象;旧数据无半径时沿用250米,与服务端默认值保持一致。
getSignRadius(course) {
return course.signRadius == null ? 250 : Number(course.signRadius)
},
//获取当前坐标点 //获取当前坐标点
getPoint() { getPoint() {
/*return new Promise((resolve, reject) => { /*return new Promise((resolve, reject) => {
@@ -705,7 +782,7 @@ layout("/mobile/platform.html"){
var circle = new AMap.Circle({ var circle = new AMap.Circle({
center: new AMap.LngLat(o.courseLocationCoordinates[0], o.courseLocationCoordinates[1]), // 圆心位置 center: new AMap.LngLat(o.courseLocationCoordinates[0], o.courseLocationCoordinates[1]), // 圆心位置
radius: 250, // 圆半径 radius: this.getSignRadius(o), // 当前课程配置的签到半径,单位为米
borderWeight: 1, borderWeight: 1,
strokeOpacity: 1, strokeOpacity: 1,
fillOpacity: 0.4, fillOpacity: 0.4,
@@ -30,6 +30,7 @@ layout("/mobile/platform.html"){
<van-sticky> <van-sticky>
<van-nav-bar title="扫描二维码" <van-nav-bar title="扫描二维码"
left-arrow left-arrow
@click-left="history.back()"
></van-nav-bar> ></van-nav-bar>
</van-sticky> </van-sticky>
@@ -205,6 +205,8 @@ layout("/layouts/platform.html"){
courseDialog: false, courseDialog: false,
trainType: '', trainType: '',
trainTypeList: [], trainTypeList: [],
welfareProjectList: [],
welfareProjectLoading: false,
campusList: [], campusList: [],
tableColumns: [ tableColumns: [
{label: '活动名称', prop: 'activityName'}, {label: '活动名称', prop: 'activityName'},
@@ -238,7 +240,8 @@ layout("/layouts/platform.html"){
this.$refs.openQRCode.openCode("/platform/mobile/brandTrainSignUpActivity/trainList") this.$refs.openQRCode.openCode("/platform/mobile/brandTrainSignUpActivity/trainList")
}, },
makeCode(row) { makeCode(row) {
this.clickRow = row // 保留活动级配置,生成课程二维码时据此区分普通签到与签到扩展。
this.$set(this, 'clickRow', row)
const type = this.trainTypeList.find(o => o.code === row.trainType) const type = this.trainTypeList.find(o => o.code === row.trainType)
this.trainType = type ? type.name : '培训班' this.trainType = type ? type.name : '培训班'
this.courseDialog = true this.courseDialog = true
@@ -247,13 +250,43 @@ layout("/layouts/platform.html"){
const o = { const o = {
courseId: row.id courseId: row.id
} }
// 生成二维码的 base64 编码 // 未启用扩展的品牌活动保留原 JSON 二维码;扩展活动进入手机端达标处理页面。
const content = jrQrcode.getQrBase64(JSON.stringify(o)) const qrText = this.isSignExtensionEnabled(this.clickRow)
? window.location.origin + '/platform/mobile/brandTrain/scanCode?courseId=' + encodeURIComponent(row.id)
: JSON.stringify(o)
const content = jrQrcode.getQrBase64(qrText)
let image = new Image() let image = new Image()
image.src = content image.src = content
let viewer = new Viewer(image) let viewer = new Viewer(image)
viewer.show() viewer.show()
}, },
// 只有全部课程签到且配置了正整数点位数的活动,才启用手机端扩展流程。
isSignExtensionEnabled(activity) {
return activity && Number.isInteger(activity.signPointCount) && activity.signPointCount > 0
&& Array.isArray(activity.courseList) && activity.courseList.length > 0
&& activity.courseList.every(item => item.isMobileSign === true)
},
// type 为活动签到处理方式编码,历史未配置时按无处理显示。
getActivitySignHandleTypeName(type) {
const typeNames = {1: '系统自动弹出填写信息', 2: '系统自动选择礼物或福利', 3: '无'}
return typeNames[type] || '无'
},
// welfareProjectId 为已关联项目 ID,项目失效不在候选列表时仍展示原 ID。
getWelfareProjectName(welfareProjectId) {
const project = this.welfareProjectList.find(item => item.id === welfareProjectId)
return project ? project.name : (welfareProjectId || '-')
},
// 无请求参数;响应 data 为含 id、name 的福利项目数组,供查看页解析项目名称。
getAvailableWelfareProjectList() {
this.welfareProjectLoading = true
return $.post('/platform/brandTrainSingUp/manage/activity/getAvailableWelfareProjectList', {})
.then(resp => {
this.$set(this, 'welfareProjectList', resp.code === 0 ? resp.data : [])
})
.always(() => {
this.welfareProjectLoading = false
})
},
openAdd() { openAdd() {
sublime.jumpPagePjax('/platform/brandTrainSingUp/manage/activity/add') sublime.jumpPagePjax('/platform/brandTrainSingUp/manage/activity/add')
}, },
@@ -346,6 +379,7 @@ layout("/layouts/platform.html"){
}, },
}, },
async created() { async created() {
this.getAvailableWelfareProjectList()
this.pageData() this.pageData()
this.trainTypeList = await getDictOptions("trainSignUpType") this.trainTypeList = await getDictOptions("trainSignUpType")
this.campusList = await getCampus() this.campusList = await getCampus()
@@ -33,9 +33,9 @@ layout("/layouts/platform.html"){
<script> <script>
let pointX = '${config.AppMapCenterPointX!}' let pointX = '${config.AppMapCenterPointX!}'
let pointY = '${config.AppMapCenterPointY!}' let pointY = '${config.AppMapCenterPointY!}'
let map = null // SDK 图对象保留在脚本作用域,避免 Vue 递归监听其图层与内部状态。
let marker = null let brandTrainSignMapInstance = null
let geocoder = null let brandTrainSignMarkerLayer = null
let E = window.wangEditor let E = window.wangEditor
const vue = new Vue({ const vue = new Vue({
el: '#app', el: '#app',
@@ -49,14 +49,20 @@ layout("/layouts/platform.html"){
use: false, use: false,
historicalActList: [], historicalActList: [],
historicalAct: '', historicalAct: '',
welfareProjectList: [],
welfareProjectLoading: false,
formLoading: false,
unionList: [], unionList: [],
trainType: '培训班', trainType: '培训班',
trainTypeList: [], trainTypeList: [],
fromEdit: '', fromEdit: '',
formData: { formData: {
notice: false, notice: false,
signPointCount: null,
signHandleType: 3,
welfareProjectId: '',
courseList: [ courseList: [
{orderNum: 1, courseName: '', courseTimeList: [], isMobileSign: false, isReceiveGift: false, reserveMode: 1}, {orderNum: 1, courseName: '', courseTimeList: [], isMobileSign: false, isReceiveGift: false, reserveMode: 1, signRadius: 250},
], ],
conditionStructure: { conditionStructure: {
method: "AND", method: "AND",
@@ -83,6 +89,13 @@ layout("/layouts/platform.html"){
setUpUnionLimitDialog: false, setUpUnionLimitDialog: false,
viewMapDialog: false, viewMapDialog: false,
mapDialog: false, mapDialog: false,
mapOpened: false,
mapLoading: false,
mapError: '',
signMapReady: false,
mapRequestVersion: 0,
mapSearchVersion: 0,
mapSearchCallback: null,
courseTypeList: [], courseTypeList: [],
campusList: [], campusList: [],
courseTimeOneKeySet: { courseTimeOneKeySet: {
@@ -107,7 +120,6 @@ layout("/layouts/platform.html"){
moreInfoDrawer: false, moreInfoDrawer: false,
moreInfoRow: {}, moreInfoRow: {},
moreInfoIndex: 0, moreInfoIndex: 0,
placeSearchComponent: null,
addressValue: '', addressValue: '',
courseBzDialog: false, courseBzDialog: false,
@@ -115,6 +127,11 @@ layout("/layouts/platform.html"){
} }
}, },
computed: { computed: {
// 活动内全部课程均启用手机签到后,才允许设置活动级达标处理。
allCourseSignEnabled() {
return Array.isArray(this.formData.courseList) && this.formData.courseList.length > 0
&& this.formData.courseList.every(item => item.isMobileSign === true)
},
unionUserNumCalcTips() { unionUserNumCalcTips() {
return this.unionUserNumCalc.map(v => { return this.unionUserNumCalc.map(v => {
return (v.startNum ? v.startNum : '?') + '-' + (v.endNum ? v.endNum : '?') + '人的分工会,限报' + (v.resultNum ? v.resultNum : '?') + '人' return (v.startNum ? v.startNum : '?') + '-' + (v.endNum ? v.endNum : '?') + '人的分工会,限报' + (v.resultNum ? v.resultNum : '?') + '人'
@@ -122,6 +139,45 @@ layout("/layouts/platform.html"){
}, },
}, },
methods: { methods: {
// 旧活动缺少扩展字段时补齐默认值,兼容编辑与沿用历史活动。
initSignExtensionDefaults() {
// 编辑和沿用历史活动时,只为缺失半径补默认值,保留已有自定义范围。
this.formData.courseList.forEach(course => {
if (course.signRadius === undefined || course.signRadius === null) {
this.$set(course, 'signRadius', 250)
}
})
if (this.formData.signPointCount === undefined || this.formData.signPointCount === null
|| this.formData.signPointCount === 0) {
this.$set(this.formData, 'signPointCount', null)
}
if (this.formData.signHandleType === undefined || this.formData.signHandleType === null) {
this.$set(this.formData, 'signHandleType', 3)
}
if (this.formData.welfareProjectId === undefined || this.formData.welfareProjectId === null) {
this.$set(this.formData, 'welfareProjectId', '')
}
},
// val 为处理方式;只有方式 2 使用福利项目,切换后清除不再生效的项目。
handleActivitySignHandleTypeChange(val) {
if (val !== 2) {
this.$set(this.formData, 'welfareProjectId', '')
}
},
/**
* 无请求参数;返回 Promise,响应 data 为可选福利项目数组,每项包含 id 和 name。
* 加载期间禁用项目选择,避免将尚未加载的项目列表当作无可用项目。
*/
getAvailableWelfareProjectList() {
this.welfareProjectLoading = true
return $.post('/platform/brandTrainSingUp/manage/activity/getAvailableWelfareProjectList', {})
.then(resp => {
this.$set(this, 'welfareProjectList', resp.code === 0 ? resp.data : [])
})
.always(() => {
this.welfareProjectLoading = false
})
},
openCourseBz(row, index) { openCourseBz(row, index) {
this.moreInfoRow = row this.moreInfoRow = row
this.courseBzDialog = true this.courseBzDialog = true
@@ -177,7 +233,7 @@ layout("/layouts/platform.html"){
o.id = '' o.id = ''
this.formData.courseList.push(o) this.formData.courseList.push(o)
} else { } else {
this.formData.courseList.push({orderNum: index, courseTimeList: [], isMobileSign: false, isReceiveGift: false, reserveMode: 1}) this.formData.courseList.push({orderNum: index, courseTimeList: [], isMobileSign: false, isReceiveGift: false, reserveMode: 1, signRadius: 250})
} }
}, },
//删除培训班 //删除培训班
@@ -311,55 +367,139 @@ layout("/layouts/platform.html"){
this.setUpUnionLimitDialog = false this.setUpUnionLimitDialog = false
} }
}, },
//设置签到点位 /**
* coordinates 为 [经度, 纬度] 数值数组;返回布尔值,表示两项坐标有限且位于合法经纬度范围。
* 扫码签到保存、提交与地图回显共用校验,避免缺失点位或将默认地图中心作为签到坐标。
*/
isValidSignCoordinates(coordinates) {
return Array.isArray(coordinates) && coordinates.length === 2
&& coordinates.every(value => typeof value === 'number' && Number.isFinite(value))
&& coordinates[0] >= -180 && coordinates[0] <= 180
&& coordinates[1] >= -90 && coordinates[1] <= 90
},
// index 为当前课程行;维持选点即写入的原规则,关闭弹框不会回滚已选坐标。
openMap(index) { openMap(index) {
this.mapIndex = index this.destroySignMap()
this.mapDialog = true this.$set(this, 'mapIndex', index)
this.$set(this, 'addressValue', '')
this.$set(this, 'mapError', '')
this.$set(this, 'mapDialog', true)
},
// 等弹框显示动画结束再初始化,避免在隐藏容器中创建地图或重复打开后保留旧实例。
onMapOpened() {
if (!this.mapDialog) return
this.$set(this, 'mapOpened', true)
this.$set(this, 'mapRequestVersion', this.mapRequestVersion + 1)
const version = this.mapRequestVersion
this.$set(this, 'mapLoading', true)
this.$set(this, 'mapError', '')
this.$nextTick(() => { this.$nextTick(() => {
map = new AMap.Map("signMap", { if (!this.mapDialog || !this.mapOpened || version !== this.mapRequestVersion) return
resizeEnable: true, try {
center: [pointX, pointY], // SDK 由公共布局按参考工程静态加载;未就绪时提示刷新,不再自行创建脚本。
zoom: 16 if (!window.TMap || !TMap.Map || !TMap.LatLng || !TMap.MultiMarker) {
}) throw new Error('腾讯地图脚本未加载成功,请检查网络后刷新页面')
this.placeSearchComponent = new AMap.PlaceSearch({ }
// city 指定搜索所在城市,支持传入格式有:城市名、citycode和adcode const coordinates = this.formData.courseList[this.mapIndex].courseLocationCoordinates
city: '' const selectedPoint = this.isValidSignCoordinates(coordinates) ? coordinates : null
}) const defaultPoint = [Number(pointX), Number(pointY)]
const coordinateArray = this.formData.courseList[this.mapIndex].courseLocationCoordinates const centerPoint = selectedPoint || (String(pointX).trim() && String(pointY).trim()
this.clearMarker() && this.isValidSignCoordinates(defaultPoint) ? defaultPoint : null)
this.createMarker(coordinateArray) if (!centerPoint) throw new Error('地图默认中心未配置或无效,请检查 AppMapCenterPointX(经度)、AppMapCenterPointY(纬度)')
map.on('click', (e) => { const mapInstance = new TMap.Map('brandTrainSignMap', {
this.clearMarker() zoom: 16,
const position = [e.lnglat.getLng(), e.lnglat.getLat()] center: new TMap.LatLng(centerPoint[1], centerPoint[0])
this.createMarker(position)
this.formData.courseList[this.mapIndex].courseLocationCoordinates = position
})
})
},
handleSelect(item) {
if(item.location) {
//定位到中心点
map.setCenter(item.location);
//TODO 获取数据,对数据进行操作如:添加marker等
this.clearMarker()
this.createMarker(item.location)
this.formData.courseList[this.mapIndex].courseLocationCoordinates = [item.location.lng, item.location.lat]
}
},
querySearchAsync(queryString, cb) {
if(!queryString) {
return
}
this.placeSearchComponent.search(queryString, function (status, result) {
// 查询成功时,result即对应匹配的POI信息
if(status !== 'error') {
result.poiList.pois.forEach(item => {
item.value = item.name + '【详细地址:' + item.address + '】'
}) })
cb(result.poiList.pois) brandTrainSignMapInstance = mapInstance
brandTrainSignMarkerLayer = new TMap.MultiMarker({map: mapInstance, geometries: []})
this.$set(this, 'signMapReady', true)
// 默认中心仅供浏览,只有已配置点位才回显标记,不把默认中心写回课程。
if (selectedPoint) this.createMarker(selectedPoint)
mapInstance.on('click', (event) => {
if (version !== this.mapRequestVersion || !this.mapDialog || !this.mapOpened) return
this.selectSignMapPosition([event.latLng.getLng(), event.latLng.getLat()])
})
} catch (error) {
if (version !== this.mapRequestVersion || !this.mapDialog) return
this.$set(this, 'mapError', error && error.message ? error.message : '地图初始化失败,请重新打开地图重试')
this.destroySignMap()
} finally {
if (version === this.mapRequestVersion) this.$set(this, 'mapLoading', false)
} }
}) })
}, },
closeMapDialog() {
this.$set(this, 'mapDialog', false)
this.onMapClosed()
},
onMapClosed() {
this.destroySignMap()
},
// 关闭、初始化失败或 PJAX 离开时使旧响应失效,并完成待处理的自动补全回调后释放地图。
destroySignMap() {
this.$set(this, 'mapOpened', false)
this.$set(this, 'mapLoading', false)
this.$set(this, 'mapRequestVersion', this.mapRequestVersion + 1)
this.$set(this, 'mapSearchVersion', this.mapSearchVersion + 1)
if (this.mapSearchCallback) this.mapSearchCallback([])
const mapInstance = brandTrainSignMapInstance
brandTrainSignMapInstance = null
brandTrainSignMarkerLayer = null
this.$set(this, 'signMapReady', false)
if (mapInstance) mapInstance.destroy()
},
// position 始终为 [经度, 纬度];仅调用腾讯 LatLng 构造方法时交换顺序。
selectSignMapPosition(position) {
if (!this.mapDialog || !this.mapOpened || !brandTrainSignMapInstance || !this.isValidSignCoordinates(position)) return
this.createMarker(position)
this.$set(this.formData.courseList[this.mapIndex], 'courseLocationCoordinates', position)
},
// item.location 提供搜索结果的 lng/lat;仅搜索选点移动地图中心,普通点击保持当前视野。
handleSelect(item) {
if (!item || !item.location || !this.mapDialog || !this.mapOpened || !brandTrainSignMapInstance) return
const position = [item.location.lng, item.location.lat]
if (!this.isValidSignCoordinates(position)) return
brandTrainSignMapInstance.setCenter(new window.TMap.LatLng(position[1], position[0]))
this.selectSignMapPosition(position)
},
/**
* queryString 为地点关键词,POST 参数 keyword;响应 code/msg/data 中 data 为腾讯 status/message/data。
* cb 接收含 value/title/address/location 的候选项数组;空输入、请求失败及弹框关闭均以空数组结束回调。
*/
querySearchAsync(queryString, cb) {
if (this.mapSearchCallback) this.mapSearchCallback([])
this.$set(this, 'mapSearchVersion', this.mapSearchVersion + 1)
const version = this.mapSearchVersion
const keyword = typeof queryString === 'string' ? queryString.trim() : ''
let finished = false
const finish = (items) => {
if (finished) return
finished = true
cb(items)
if (this.mapSearchCallback === finish) this.$set(this, 'mapSearchCallback', null)
}
this.$set(this, 'mapSearchCallback', finish)
if (!keyword || !this.mapDialog || !this.mapOpened || !brandTrainSignMapInstance) {
finish([])
return
}
let suggestions = []
return $.post('/platform/TMap/suggestion', {keyword})
.then((res) => {
if (version !== this.mapSearchVersion || !this.mapDialog || !this.mapOpened) return
if (res && res.code === 0 && res.data && res.data.status === 0 && Array.isArray(res.data.data)) {
suggestions = res.data.data.filter(item => item && item.location
&& this.isValidSignCoordinates([item.location.lng, item.location.lat]))
.map(item => Object.assign({}, item, {value: item.title + '【详细地址:' + (item.address || '') + '】'}))
} else {
this.$message.warning(res && res.data && res.data.message ? res.data.message : (res && res.msg) || '地点搜索失败')
}
}).fail(() => {
if (version === this.mapSearchVersion && this.mapDialog && this.mapOpened) this.$message.warning('地点搜索失败,请稍后重试')
}).always(() => {
finish(version === this.mapSearchVersion && this.mapDialog && this.mapOpened ? suggestions : [])
})
},
//删除一行培训时间记录 //删除一行培训时间记录
removeSetUpTableCourseRow(row, index) { removeSetUpTableCourseRow(row, index) {
this.formData.courseList[this.courseIndex].courseTimeList.splice(index, 1) this.formData.courseList[this.courseIndex].courseTimeList.splice(index, 1)
@@ -372,30 +512,35 @@ layout("/layouts/platform.html"){
return courseDateArray.includes(moment(v).format('YYYY-MM-DD')) return courseDateArray.includes(moment(v).format('YYYY-MM-DD'))
}) })
}, },
//清楚签到点位 // 单个图层只更新当前签到点,不移动地图视野;业务坐标仍为 [经度, 纬度]。
clearMarker() {
if (marker) {
map.remove(marker)
}
},
//创建签到点位
createMarker(position) { createMarker(position) {
marker = new AMap.Marker({ if (!brandTrainSignMarkerLayer || !brandTrainSignMapInstance || !this.isValidSignCoordinates(position)) return
position: position, const center = new window.TMap.LatLng(position[1], position[0])
offset: new AMap.Pixel(-13, -30) brandTrainSignMarkerLayer.setGeometries([{id: 'sign-point', position: center}])
})
map.add(marker)
this.getAddress(position)
map.setFitView(null, false, [150, 60, 100, 60]);
},
getAddress() {
}, },
courseOrderNumChange() { courseOrderNumChange() {
this.formData.courseList = this.formData.courseList.sort((a, b) => a.orderNum - b.orderNum) this.formData.courseList = this.formData.courseList.sort((a, b) => a.orderNum - b.orderNum)
}, },
//提交 //提交
async doHandle(flag) { async doHandle(flag) {
if (this.formLoading) {
return
}
// 保存与提交都校验半径;上限与后端Integer字段保持一致,禁止清空后绕过必填校验。
const invalidRadiusIndex = this.formData.courseList.findIndex(item => item.isMobileSign === true
&& (item.signType === 1 || item.signType === 3)
&& (!Number.isInteger(item.signRadius) || item.signRadius <= 0 || item.signRadius > 2147483647))
if (invalidRadiusIndex !== -1) {
this.$message.warning('第' + (invalidRadiusIndex + 1) + '行' + this.trainType + '请填写有效的点位半径(正整数,单位:米)')
return
}
// 已开启扫码签到的课程在保存与提交时均须配置真实点位;GPS仍沿用原提交校验。
const invalidScanCourseIndex = this.formData.courseList.findIndex(item => item.isMobileSign === true
&& item.signType === 1 && !this.isValidSignCoordinates(item.courseLocationCoordinates))
if (invalidScanCourseIndex !== -1) {
this.$message.warning('第' + (invalidScanCourseIndex + 1) + '行' + this.trainType + '请设置有效的扫码签到地点坐标')
return
}
if(flag === false) { if(flag === false) {
let valid = false let valid = false
this.$refs.form.validateField('activityName', (errMsg) => { this.$refs.form.validateField('activityName', (errMsg) => {
@@ -448,7 +593,42 @@ layout("/layouts/platform.html"){
} }
} }
} }
// 点位数按不同课程累计;填写信息复用课程类型字段,因此仅支持一种课程类型。
if (this.allCourseSignEnabled) {
const hasSignPointCount = this.formData.signPointCount !== undefined
&& this.formData.signPointCount !== null && this.formData.signPointCount !== ''
// 历史活动未配置点位且处理方式为无时,继续保留原签到方式。
if ((hasSignPointCount || this.formData.signHandleType !== 3)
&& (!Number.isInteger(this.formData.signPointCount) || this.formData.signPointCount <= 0
|| this.formData.signPointCount > this.formData.courseList.length)) {
this.$message.warning('签到点位数量必须为正整数,且不能超过当前课程数量')
return
}
if (![1, 2, 3].includes(this.formData.signHandleType)) {
this.$message.warning('请选择签到处理方式')
return
}
if (this.formData.signHandleType === 1) {
const courseTypes = new Set(this.formData.courseList.map(item => item.courseType).filter(item => item))
if (courseTypes.size !== 1 || this.formData.courseList.some(item => !item.courseType)) {
this.$message.warning('填写信息仅支持同一种课程类型,请检查课程类型')
return
}
}
if (this.formData.signHandleType === 2 && !this.formData.welfareProjectId) {
this.$message.warning('请选择福利项目')
return
}
}
const cloneData = clone(this.formData) const cloneData = clone(this.formData)
// 任一课程关闭签到时不保存活动级扩展,避免隐藏配置继续触发手机端达标处理。
if (!this.allCourseSignEnabled) {
cloneData.signPointCount = null
cloneData.signHandleType = 3
cloneData.welfareProjectId = ''
} else if (cloneData.signHandleType !== 2) {
cloneData.welfareProjectId = ''
}
//cloneData.conditionStructure.conditions = cloneData.conditionStructure.conditions.filter(v => v.field && v.fieldObj && v.operational && v.value) //cloneData.conditionStructure.conditions = cloneData.conditionStructure.conditions.filter(v => v.field && v.fieldObj && v.operational && v.value)
@@ -484,53 +664,70 @@ layout("/layouts/platform.html"){
}) })
cloneData.typeLimits = limitNumArray cloneData.typeLimits = limitNumArray
const resp = await $.post('/platform/brandTrainSingUp/manage/activity/doHandle', {data: JSON.stringify(cloneData)}) // data 为完整活动 JSON;响应 code 为 0 表示保存成功,msg 用于页面提示。
if (resp.code === 0) { this.formLoading = true
this.$notify({ $.post('/platform/brandTrainSingUp/manage/activity/doHandle', {data: JSON.stringify(cloneData)})
title: '成功', .then(resp => {
message: resp.msg, if (resp.code === 0) {
type: 'success', this.$notify({
duration: 500, title: '成功',
onClose: () => { message: resp.msg,
sublime.jumpPagePjax('/platform/brandTrainSingUp/manage/activity') type: 'success',
duration: 500,
onClose: () => {
sublime.jumpPagePjax('/platform/brandTrainSingUp/manage/activity')
}
});
} else {
this.$message.warning(resp.msg)
} }
}); })
} .always(() => {
this.formLoading = false
})
}, },
async initData() { initData() {
const id = GetQueryString("id") const id = GetQueryString("id")
if (id) { if (id) {
const resp = await $.get('/platform/brandTrainSingUp/manage/activity/findOne', {id}) // id 为待编辑活动 ID;响应 data 为含课程列表与签到扩展字段的活动对象。
if (resp.code === 0) { this.formLoading = true
resp.data.cover = [resp.data.cover] return $.get('/platform/brandTrainSingUp/manage/activity/findOne', {id})
if (resp.data.wechat) { .then(resp => {
resp.data.wechat = [resp.data.wechat] if (resp.code === 0) {
} resp.data.cover = [resp.data.cover]
this.formData = resp.data if (resp.data.wechat) {
const type = this.trainTypeList.find(o => o.code === this.formData.trainType) resp.data.wechat = [resp.data.wechat]
this.trainType = type ? type.name : '培训班'
this.$nextTick(() => {
$("#introduce").html("")
var editor = new E("#introduce")
editor.config.onchange = (html) => {
this.formData.introduce = html.replace(/<p\><\/p>/g, '')
}
editor.create()
editor.txt.html(resp.data.introduce)
})
this.formData.courseList.forEach(item => {
const type = this.courseTypeList.find(o => o.id === item.courseType)
if(type !== null && type !== undefined) {
if(type.isBringFamily === true) {
this.$set(item, 'isBringFamily', true)
item.reserveMode = 1
} else {
this.$set(item, 'isBringFamily', false)
} }
this.$set(this, 'formData', resp.data)
const type = this.trainTypeList.find(o => o.code === this.formData.trainType)
this.trainType = type ? type.name : '培训班'
this.$nextTick(() => {
$("#introduce").html("")
var editor = new E("#introduce")
editor.config.onchange = (html) => {
this.$set(this.formData, 'introduce', html.replace(/<p\><\/p>/g, ''))
}
editor.create()
editor.txt.html(resp.data.introduce)
})
this.formData.courseList.forEach(item => {
const type = this.courseTypeList.find(o => o.id === item.courseType)
if(type !== null && type !== undefined) {
if(type.isBringFamily === true) {
this.$set(item, 'isBringFamily', true)
item.reserveMode = 1
} else {
this.$set(item, 'isBringFamily', false)
}
}
})
this.initSignExtensionDefaults()
} }
}) })
} .always(() => {
this.formLoading = false
})
} else { } else {
this.$nextTick(() => { this.$nextTick(() => {
$("#introduce").html("") $("#introduce").html("")
@@ -704,40 +901,57 @@ layout("/layouts/platform.html"){
const resp = await $.post('/platform/brandTrainSingUp/manage/activity/getHistoricalActList', {}) const resp = await $.post('/platform/brandTrainSingUp/manage/activity/getHistoricalActList', {})
return resp.data return resp.data
}, },
async historicalActChange(val) { historicalActChange(val) {
const resp = await $.get('/platform/brandTrainSingUp/manage/activity/findOne', {id: val}) // val 为沿用的活动 ID;取回完整活动后清空 ID,保留原上传、介绍和课程回显规则。
if (resp.code === 0) { this.formLoading = true
resp.data.cover = [resp.data.cover] return $.get('/platform/brandTrainSingUp/manage/activity/findOne', {id: val})
if (resp.data.wechat) { .then(resp => {
resp.data.wechat = [resp.data.wechat] if (resp.code === 0) {
} resp.data.cover = [resp.data.cover]
this.formData = resp.data if (resp.data.wechat) {
const type = this.trainTypeList.find(o => o.code === this.formData.trainType) resp.data.wechat = [resp.data.wechat]
this.trainType = type ? type.name : '培训班' }
this.$nextTick(() => { this.$set(this, 'formData', resp.data)
$("#introduce").html("") const type = this.trainTypeList.find(o => o.code === this.formData.trainType)
var editor = new E("#introduce") this.trainType = type ? type.name : '培训班'
editor.config.onchange = (html) => { this.$nextTick(() => {
this.formData.introduce = html.replace(/<p\><\/p>/g, '') $("#introduce").html("")
} var editor = new E("#introduce")
editor.create() editor.config.onchange = (html) => {
editor.txt.html(resp.data.introduce) this.$set(this.formData, 'introduce', html.replace(/<p\><\/p>/g, ''))
}) }
editor.create()
editor.txt.html(resp.data.introduce)
})
this.formData.courseList.forEach(item => { this.formData.courseList.forEach(item => {
const type = this.courseTypeList.find(o => o.id === item.courseType) const type = this.courseTypeList.find(o => o.id === item.courseType)
if (type.isBringFamily === true) { if (type.isBringFamily === true) {
this.$set(item, 'isBringFamily', true) this.$set(item, 'isBringFamily', true)
item.reserveMode = 1 item.reserveMode = 1
} else { } else {
this.$set(item, 'isBringFamily', false) this.$set(item, 'isBringFamily', false)
}
})
this.$set(this.formData, 'id', '')
this.initSignExtensionDefaults()
} }
}) })
this.formData.id = '' .always(() => {
} this.formLoading = false
})
}, },
}, },
mounted() {
// PJAX 替换页面前释放地图;具名事件在再次进入时只保留当前页面的一次清理回调。
$(document).off('pjax:beforeReplace.brandTrainSignMap').one('pjax:beforeReplace.brandTrainSignMap', () => this.onMapClosed())
},
beforeDestroy() {
$(document).off('pjax:beforeReplace.brandTrainSignMap')
this.onMapClosed()
},
async created() { async created() {
this.getAvailableWelfareProjectList()
this.unionList = await getUnions() this.unionList = await getUnions()
await this.initCnd() await this.initCnd()
this.courseTypeList = await this.getAllType() this.courseTypeList = await this.getAllType()
@@ -279,11 +279,33 @@
</div> </div>
</div> </div>
</el-form-item> </el-form-item>
<vi-title v-if="allCourseSignEnabled" class="mt10" title="签到扩展配置"></vi-title>
<el-form-item v-if="allCourseSignEnabled" label="签到点位数量" prop="signPointCount">
<!-- Element 会将 null 夹为最小值;空配置传 undefined,避免编辑历史活动时自动启用扩展。 -->
<el-input-number :value="formData.signPointCount === null ? undefined : formData.signPointCount"
@input="value => this.$set(formData, 'signPointCount', value)"
:precision="0" :step="1" :min="1" :max="formData.courseList.length"
placeholder="请输入签到点位数量" style="width: 220px"></el-input-number>
<span style="color: #c64120; font-size: 12px;">达标点位数不能超过课程数量;不配置点位且处理方式为无时,保留原签到方式</span>
</el-form-item>
<el-form-item v-if="allCourseSignEnabled" label="处理方式" prop="signHandleType">
<el-radio-group v-model="formData.signHandleType" size="small" @change="handleActivitySignHandleTypeChange">
<el-radio-button :label="1">系统自动弹出填写信息</el-radio-button>
<el-radio-button :label="2">系统自动选择礼物或福利</el-radio-button>
<el-radio-button :label="3">无</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item v-if="allCourseSignEnabled && formData.signHandleType === 2" label="福利项目" prop="welfareProjectId">
<el-select v-model="formData.welfareProjectId" placeholder="请选择福利项目" style="width: 100%"
:loading="welfareProjectLoading" :disabled="welfareProjectLoading">
<el-option :label="item.name" :value="item.id" :key="item.id" v-for="item in welfareProjectList"></el-option>
</el-select>
</el-form-item>
</el-form> </el-form>
<el-row class="mt10" justify="end" type="flex"> <el-row class="mt10" justify="end" type="flex">
<el-button v-if="GetQueryString('id') !== ''" @click="doBack">返回</el-button> <el-button v-if="GetQueryString('id') !== ''" @click="doBack">返回</el-button>
<el-button @click="doHandle(false)" type="primary">保存</el-button> <el-button @click="doHandle(false)" type="primary" :loading="formLoading">保存</el-button>
<el-button @click="doHandle(true)" type="primary">提交</el-button> <el-button @click="doHandle(true)" type="primary" :loading="formLoading">提交</el-button>
</el-row> </el-row>
<condition :fields="fields" ref="joinCnd" :show_filter_result="true" v-model="conditionStructure"></condition> <condition :fields="fields" ref="joinCnd" :show_filter_result="true" v-model="conditionStructure"></condition>
@@ -379,8 +401,9 @@
</el-row> </el-row>
</el-dialog> </el-dialog>
<el-dialog :close-on-click-modal="false" :visible.sync="mapDialog" title="位置信息"> <el-dialog :close-on-click-modal="false" :visible.sync="mapDialog" title="位置信息"
<!--#include("/platform/trainSingUp/manage/include_activity_map.html"){}#--> @opened="onMapOpened" @close="onMapClosed">
<!--#include("/platform/brandTrainSingUp/manage/include_activity_map.html"){}#-->
</el-dialog> </el-dialog>
<el-dialog :close-on-click-modal="false" :visible.sync="signUpDialog" title="设置报名限制"> <el-dialog :close-on-click-modal="false" :visible.sync="signUpDialog" title="设置报名限制">
@@ -553,7 +576,8 @@
</el-row> </el-row>
<el-row v-if="formData.courseList[moreInfoIndex].isMobileSign === true <el-row v-if="formData.courseList[moreInfoIndex].isMobileSign === true
&& formData.courseList[moreInfoIndex].signType == 3" :gutter="50" type="flex"> && (formData.courseList[moreInfoIndex].signType == 1
|| formData.courseList[moreInfoIndex].signType == 3)" :gutter="50" type="flex">
<el-col :span="4"> <el-col :span="4">
<span>地点坐标</span> <span>地点坐标</span>
</el-col> </el-col>
@@ -562,6 +586,22 @@
</el-col> </el-col>
</el-row> </el-row>
<!-- 半径属于当前课程点位,扫码和GPS签到共用;被扫签到不使用定位范围。 -->
<el-row v-if="formData.courseList[moreInfoIndex].isMobileSign === true
&& (formData.courseList[moreInfoIndex].signType == 1
|| formData.courseList[moreInfoIndex].signType == 3)" :gutter="50" type="flex">
<el-col :span="4">
<span>点位半径</span>
</el-col>
<el-col :span="20">
<el-input-number :value="formData.courseList[moreInfoIndex].signRadius"
@input="value => this.$set(formData.courseList[moreInfoIndex], 'signRadius', value)"
:min="1" :max="2147483647" :precision="0" :step="1" size="small"></el-input-number>
<span style="margin-left: 8px">米</span>
<div class="text-muted">以签到点为中心,距离不超过此半径允许签到,默认250米。</div>
</el-col>
</el-row>
<el-row :gutter="50" type="flex"> <el-row :gutter="50" type="flex">
<el-col :span="4"> <el-col :span="4">
<span>是否领取礼品</span> <span>是否领取礼品</span>
@@ -2,11 +2,15 @@
<el-autocomplete <el-autocomplete
v-model="addressValue" style="width: 100%" v-model="addressValue" style="width: 100%"
:fetch-suggestions="querySearchAsync" :fetch-suggestions="querySearchAsync"
:trigger-on-focus="false"
:disabled="mapLoading || !signMapReady"
placeholder="请输入关键词查询地址" placeholder="请输入关键词查询地址"
@select="handleSelect"></el-autocomplete> @select="handleSelect"></el-autocomplete>
</div> </div>
<div id="signMap" style="width: 100%;height: 500px"></div> <el-alert v-if="mapError" :title="mapError" type="error" :closable="false" show-icon style="margin-bottom: 10px"></el-alert>
<!-- 固定地图定位容器,避免 loading 结束移除临时定位样式后,地图控件越出边界。 -->
<div v-if="mapDialog" id="brandTrainSignMap" v-loading="mapLoading" element-loading-text="腾讯地图加载中..."
style="position: relative !important; overflow: hidden; width: 100%; height: 500px; padding: 0; margin: 0"></div>
<el-row class="mt10" justify="end" type="flex"> <el-row class="mt10" justify="end" type="flex">
<el-button @click="mapDialog = false" type="danger">关闭</el-button> <el-button @click="closeMapDialog" type="danger">关闭</el-button>
<!-- <el-button @click="mapConfirm" type="primary">确定</el-button>-->
</el-row> </el-row>
@@ -22,6 +22,15 @@
<el-descriptions-item label="报名结束时间"> <el-descriptions-item label="报名结束时间">
{{viewData.activitySignUpEndTime}} {{viewData.activitySignUpEndTime}}
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="签到点位数量">
{{isSignExtensionEnabled(viewData) ? viewData.signPointCount : '-'}}
</el-descriptions-item>
<el-descriptions-item label="处理方式">
{{isSignExtensionEnabled(viewData) ? getActivitySignHandleTypeName(viewData.signHandleType) : '-'}}
</el-descriptions-item>
<el-descriptions-item label="福利项目">
{{isSignExtensionEnabled(viewData) && viewData.signHandleType === 2 ? getWelfareProjectName(viewData.welfareProjectId) : '-'}}
</el-descriptions-item>
</el-descriptions> </el-descriptions>
</el-tab-pane> </el-tab-pane>
<el-tab-pane :label="trainType + '信息'"> <el-tab-pane :label="trainType + '信息'">
@@ -62,4 +71,4 @@
<el-dialog :visible.sync="viewMapDialog" title="地点" width="60%"> <el-dialog :visible.sync="viewMapDialog" title="地点" width="60%">
<div id="viewMap" style="width: 100%;height: 500px"></div> <div id="viewMap" style="width: 100%;height: 500px"></div>
</el-dialog> </el-dialog>
@@ -54,6 +54,9 @@ layout("/layouts/platform.html"){
<el-button type="primary" size="small" @click="exportSignUser" :disabled="!pageForm.activityId"> <el-button type="primary" size="small" @click="exportSignUser" :disabled="!pageForm.activityId">
按校区导出报名人员 按校区导出报名人员
</el-button> </el-button>
<el-button type="primary" size="small" @click="exportFillForm" :disabled="!pageForm.activityId" :loading="exportFillFormLoading">
导出填写信息
</el-button>
</template> </template>
</table-tool> </table-tool>
<el-table :data="tableData" :size="tableSize"> <el-table :data="tableData" :size="tableSize">
@@ -143,6 +146,7 @@ layout("/layouts/platform.html"){
{label: '报名状态', prop: 'state'}, {label: '报名状态', prop: 'state'},
], ],
userSignInfo: {}, userSignInfo: {},
exportFillFormLoading: false,
trainType: '培训班', trainType: '培训班',
trainTypeList: [], trainTypeList: [],
clickRow: {}, clickRow: {},
@@ -169,6 +173,58 @@ layout("/layouts/platform.html"){
exportSignUserByZip() { exportSignUserByZip() {
window.open(loc() + '/exportSignUserByZip?activityId=' + this.pageForm.activityId) window.open(loc() + '/exportSignUserByZip?activityId=' + this.pageForm.activityId)
}, },
/**
* activityId 为当前品牌活动 ID;成功响应为按用户汇总的 Excel 文件流。
* 业务校验失败返回 JSON Blob,通过箭头回调读取 msg,避免将错误信息下载为 Excel。
*/
exportFillForm() {
if (!this.pageForm.activityId || this.exportFillFormLoading) {
return
}
this.exportFillFormLoading = true
axios.post(loc() + '/exportFillForm', $.param({activityId: this.pageForm.activityId}), {
responseType: 'blob',
headers: {'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'}
})
.then(response => {
if (response.data.type && response.data.type.indexOf('application/json') > -1) {
const reader = new FileReader()
reader.onload = () => {
try {
const result = JSON.parse(reader.result)
this.$message.warning(result.msg || '导出填写信息失败')
} catch (error) {
this.$message.error('读取导出结果失败')
}
}
reader.onerror = () => {
this.$message.error('读取导出结果失败')
}
reader.readAsText(response.data, 'UTF-8')
return
}
const downloadUrl = window.URL.createObjectURL(response.data)
const link = document.createElement('a')
link.href = downloadUrl
link.download = '填写信息.xlsx'
document.body.appendChild(link)
try {
link.click()
} finally {
document.body.removeChild(link)
// 下载触发后释放临时地址,避免多次导出占用浏览器内存。
setTimeout(() => {
window.URL.revokeObjectURL(downloadUrl)
}, 1000)
}
})
.catch(() => {
this.$message.error('导出填写信息失败,请稍后重试')
})
.finally(() => {
this.exportFillFormLoading = false
})
},
async yearChange() { async yearChange() {
this.pageForm.activityId = null this.pageForm.activityId = null
await this.getActivityList() await this.getActivityList()