品牌活动增加签到可以领礼品
This commit is contained in:
+27
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+110
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
-19
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+26
@@ -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("课程讲师")
|
||||||
|
|||||||
+38
-2
@@ -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
|
||||||
|
|||||||
+11
@@ -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);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+504
-1
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
+124
@@ -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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+634
@@ -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()
|
||||||
|
|||||||
+45
-5
@@ -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>
|
||||||
|
|||||||
+10
-1
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user