Compare commits

...
3 Commits
31 changed files with 3824 additions and 225 deletions
+7 -4
View File
@@ -22,7 +22,7 @@
<pinyin4j.veision>2.5.1</pinyin4j.veision> <pinyin4j.veision>2.5.1</pinyin4j.veision>
<poi.version>4.1.2</poi.version> <poi.version>4.1.2</poi.version>
<tencentcloud-sdk-java.version>4.0.11</tencentcloud-sdk-java.version> <tencentcloud-sdk-java.version>4.0.11</tencentcloud-sdk-java.version>
<lombok.version>1.18.12</lombok.version> <lombok.version>1.18.32</lombok.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<v.version>1.0.1</v.version> <v.version>1.0.1</v.version>
</properties> </properties>
@@ -463,9 +463,12 @@
<artifactId>maven-compiler-plugin</artifactId> <artifactId>maven-compiler-plugin</artifactId>
<version>3.7.0</version> <version>3.7.0</version>
<configuration> <configuration>
<source>15</source> <source>17</source>
<target>15</target> <target>17</target>
<compilerArgs>--enable-preview</compilerArgs> <compilerArgs>
<arg>--add-opens</arg>
<arg>java.base/java.lang=ALL-UNNAMED</arg>
</compilerArgs>
<useIncrementalCompilation>false</useIncrementalCompilation> <useIncrementalCompilation>false</useIncrementalCompilation>
</configuration> </configuration>
</plugin> </plugin>
@@ -5,12 +5,15 @@ import io.v.nutz.annontation.ViReturn;
import io.v.nutz.brandtrainactivity.models.BrandTrainSignUpActivity; import io.v.nutz.brandtrainactivity.models.BrandTrainSignUpActivity;
import io.v.nutz.brandtrainactivity.models.BrandTrainSignUpUser; import io.v.nutz.brandtrainactivity.models.BrandTrainSignUpUser;
import io.v.nutz.brandtrainactivity.service.BrandTrainSignUpActivityService; import io.v.nutz.brandtrainactivity.service.BrandTrainSignUpActivityService;
import io.v.nutz.welfare.service.WelfareProjectService;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authz.annotation.RequiresPermissions; import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Cnd; import org.nutz.dao.Cnd;
import org.nutz.dao.Dao; import org.nutz.dao.Dao;
import org.nutz.dao.Sqls; import org.nutz.dao.Sqls;
import org.nutz.dao.sql.Sql; import org.nutz.dao.sql.Sql;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.json.Json; import org.nutz.json.Json;
@@ -38,16 +41,26 @@ public class BrandTrainSignUpActivityAddController {
@Inject @Inject
private BrandTrainSignUpActivityService brandTrainSignUpActivityService; private BrandTrainSignUpActivityService brandTrainSignUpActivityService;
@Inject
private WelfareProjectService welfareProjectService;
@At("/add") @At("/add")
@RequiresPermissions("brandTrainSingUp.manage.activity.add") @RequiresPermissions("brandTrainSingUp.manage.activity.add")
@Ok("beetl:/platform/brandTrainSingUp/manage/activity_add.html") @Ok("beetl:/platform/brandTrainSingUp/manage/activity_add.html")
public void add() { public void add() {
} }
/**
* 保存品牌活动及课程配置,新增与编辑共用同一份活动JSON。
*
* @param data 品牌活动实体JSON,包含课程列表、范围限制及可选的签到扩展配置;id为空时新增
* @return 无业务数据,由ViReturn包装为包含code、msg、data的操作结果
*/
@At @At
@ViReturn @ViReturn
@Ok("json:full") @Ok("json:full")
@POST @POST
@Aop(TransAop.READ_COMMITTED)
@RequiresPermissions("brandTrainSingUp.manage.activity.add") @RequiresPermissions("brandTrainSingUp.manage.activity.add")
public Object doHandle(@Param(value = "data", required = false) String data) { public Object doHandle(@Param(value = "data", required = false) String data) {
BrandTrainSignUpActivity activity = Json.fromJson(BrandTrainSignUpActivity.class, data); BrandTrainSignUpActivity activity = Json.fromJson(BrandTrainSignUpActivity.class, data);
@@ -99,4 +112,18 @@ public class BrandTrainSignUpActivityAddController {
List<BrandTrainSignUpActivity> query = dao.query(BrandTrainSignUpActivity.class, Cnd.NEW().desc("activityStartTime")); List<BrandTrainSignUpActivity> query = dao.query(BrandTrainSignUpActivity.class, Cnd.NEW().desc("activityStartTime"));
return query; return query;
} }
/**
* 获取品牌活动签到达标后可引导选择的福利项目,本接口不接收参数。
*
* @return 福利项目列表,由ViReturn包装为code、msg、data;data每项的id、name供表单选择
*/
@At
@POST
@ViReturn
@Ok("json:full")
@RequiresPermissions("brandTrainSingUp.manage.activity")
public Object getAvailableWelfareProjectList() {
return welfareProjectService.getAvailableSignWelfareProjects();
}
} }
@@ -0,0 +1,111 @@
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 保留兼容的GCJ-02纬度,临时停用定位和范围校验期间可不传
* @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, "缺少签到点位信息");
}
// 腾讯地图余额耗尽,暂不要求定位;恢复时需同步恢复前端定位及service点位范围校验。
// if (longitude == null || latitude == null) {
// return Result.error(99, "请先获取当前定位后再签到");
// }
try {
NutMap result = brandTrainSignUpActivityService.handleScanCodeSign(courseId, ShiroUtil.getPlatformUid(), longitude, latitude);
return Result.NEW().addCode(result.getInt("code")).addMsg(result.getString("msg")).addData(result.get("data"));
} catch (IllegalArgumentException e) {
// 当前Nutz嵌套事务未提供rollbackOnly;转换业务响应前回滚当前连接,保留外层AOP的事务计数。
if (Trans.get() != null) {
brandTrainSignUpActivityService.dao().run(connection -> connection.rollback());
}
return Result.error(99, e.getMessage());
}
}
/**
* @param courseId 当前扫码课程ID,用于定位活动及动态字段配置
* @param mobileColumnsValue JSON数组,每项包含columnCode及columnValue;附件值保留数组结构
* @return Result,code=0表示保存成功,code=99表示权限、达标或字段校验未通过
*/
@At
@POST
@RequiresAuthentication
@Aop(TransAop.READ_COMMITTED)
public Result saveDynamicForm(@Param("courseId") String courseId,
@Param("mobileColumnsValue") String mobileColumnsValue) {
if (StrUtil.isBlank(courseId) || StrUtil.isBlank(mobileColumnsValue)) {
return Result.error(99, "请填写当前签到点位的信息");
}
try {
brandTrainSignUpActivityService.saveScanCodeDynamicForm(courseId, ShiroUtil.getPlatformUid(), mobileColumnsValue);
return Result.success("保存成功");
} catch (IllegalArgumentException e) {
// 显式回滚已加入外层事务的写入,避免捕获service异常后被controller外层提交。
if (Trans.get() != null) {
brandTrainSignUpActivityService.dao().run(connection -> connection.rollback());
}
return Result.error(99, e.getMessage());
}
}
}
@@ -11,10 +11,12 @@ import io.v.nutz.data.service.MatchConditionStructureService;
import io.v.nutz.modules.models.PageForm; import io.v.nutz.modules.models.PageForm;
import io.v.nutz.web.commons.utils.ShiroUtil; import io.v.nutz.web.commons.utils.ShiroUtil;
import org.apache.shiro.authz.annotation.RequiresAuthentication; import org.apache.shiro.authz.annotation.RequiresAuthentication;
import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Chain; import org.nutz.dao.Chain;
import org.nutz.dao.Cnd; import org.nutz.dao.Cnd;
import org.nutz.dao.Dao; import org.nutz.dao.Dao;
import org.nutz.integration.jedis.RedisService; import org.nutz.integration.jedis.RedisService;
import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.util.NutMap; import org.nutz.lang.util.NutMap;
@@ -166,27 +168,24 @@ public class MBrandTrainSignUpActivityController {
return null; return null;
} }
/**
* GPS签到,半径与坐标以服务端课程配置为准。
* @param id 当前登录用户的课次签到记录ID
* @param courseId 当前课程ID,service核对其与签到记录的对应关系
* @param points 当前GCJ-02定位坐标,JSON数组[经度,纬度]
* @return ViReturn包装的code/msg/data;成功无业务data,参数或距离错误返回业务提示
*/
@At @At
@Ok("json:full") @Ok("json:full")
@POST @POST
@ViReturn @ViReturn
@RequiresAuthentication @RequiresAuthentication
@Aop(TransAop.READ_COMMITTED)
public Object doQd(@Param("id") String id, @Param("courseId") String courseId, @Param("point") Double[] points) { public Object doQd(@Param("id") String id, @Param("courseId") String courseId, @Param("point") Double[] points) {
BrandTrainSignUpCourse course = dao.fetch(BrandTrainSignUpCourse.class, courseId); if (StrUtil.isBlank(id) || StrUtil.isBlank(courseId) || points == null || points.length != 2) {
List<Double> coursePoints = course.getCourseLocationCoordinates(); return Result.error("请获取当前定位后再签到");
// if (Lang.isNotEmpty(coursePoints)) { }
// //需要签到 brandTrainSignUpActivityService.doQd(id, courseId, ShiroUtil.getPlatformUid(), points[0], points[1], 3);
// if (ArrayUtil.isEmpty(points) || ArrayUtil.hasNull(points)) {
// return Result.error().addMsg("请获取当前的坐标信息");
// }
// Double[] coursePointArray = coursePoints.toArray(new Double[]{});
// float distance = AMapUtils.calculateLineDistance(new LatLng(points[0], points[1]), new LatLng(coursePointArray[0], coursePointArray[1]));
//
// if (distance > 500) {
// return Result.error().addMsg("请到签到点位附近签到");
// }
// }
brandTrainSignUpActivityService.doQd(id);
return null; return null;
} }
@@ -285,19 +284,34 @@ public class MBrandTrainSignUpActivityController {
} }
} }
/**
* 旧课程二维码签到,与GPS签到复用service的用户归属和配置半径校验。
* @param id 当前登录用户的课次签到记录ID
* @param codeCourseId 二维码解析出的课程ID
* @param clickCourseId 页面点击的课程ID,必须与二维码课程一致
* @param longitude 当前GCJ-02定位经度
* @param latitude 当前GCJ-02定位纬度
* @return ViReturn包装的code/msg/data;成功无业务data,校验失败返回业务提示
*/
@At @At
@Ok("json:full") @Ok("json:full")
@POST
@ViReturn @ViReturn
@RequiresAuthentication @RequiresAuthentication
public Object codeSign(String id, String codeCourseId, String clickCourseId) { @Aop(TransAop.READ_COMMITTED)
if(StrUtil.isBlank(codeCourseId) || StrUtil.isBlank(clickCourseId)) { public Object codeSign(@Param("id") String id, @Param("codeCourseId") String codeCourseId,
@Param("clickCourseId") String clickCourseId, @Param("longitude") Double longitude,
@Param("latitude") Double latitude) {
if(StrUtil.isBlank(id) || StrUtil.isBlank(codeCourseId) || StrUtil.isBlank(clickCourseId)) {
return Result.error("签到失败,没有获取到扫描信息"); return Result.error("签到失败,没有获取到扫描信息");
} }
if(!codeCourseId.equals(clickCourseId)) { if(!codeCourseId.equals(clickCourseId)) {
return Result.error("签到失败,二维码与您当前签到信息不符"); return Result.error("签到失败,二维码与您当前签到信息不符");
} }
dao.update(BrandTrainSignUpUserCourse.class, Chain.make("isAttend", true) if (longitude == null || latitude == null) {
.add("attendTime", new Date()), Cnd.where("id", "=", id)); return Result.error("请获取当前定位后再签到");
}
brandTrainSignUpActivityService.doQd(id, clickCourseId, ShiroUtil.getPlatformUid(), longitude, latitude, 1);
return null; return null;
} }
} }
@@ -1,5 +1,8 @@
package io.v.nutz.brandtrainactivity.controller.statistics; package io.v.nutz.brandtrainactivity.controller.statistics;
import io.v.nutz.base.result.Result;
import io.v.nutz.base.utils.CommonDownloadUtil;
import cn.afterturn.easypoi.excel.entity.ExportParams; import cn.afterturn.easypoi.excel.entity.ExportParams;
import cn.afterturn.easypoi.excel.entity.enmus.ExcelType; import cn.afterturn.easypoi.excel.entity.enmus.ExcelType;
@@ -347,4 +350,27 @@ public class BrandTrainSignUpActivityStatisticsController {
zipOutputStream.flush(); zipOutputStream.flush();
zipOutputStream.close(); zipOutputStream.close();
} }
/**
* 下载活动签到后填写的信息,不接收人员ID或动态字段定义。
*
* @param activityId 活动主键,导出该活动下按人员合并的填写记录
* @param response 成功返回xlsx文件流;参数错误返回包含code、msg的JSON对象
* @throws IOException 无法写入HTTP响应时抛出
*/
@At
@Ok("void")
@RequiresPermissions("brandTrainSingUp.statistics.activity")
public void exportFillForm(@Param("activityId") String activityId,
HttpServletResponse response) throws IOException {
try {
Workbook workbook = brandTrainSignUpActivityStatisticsService.exportFillFormWorkbook(activityId);
CommonDownloadUtil.download("填写信息.xlsx", workbook, response);
} catch (IllegalArgumentException e) {
response.reset();
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json");
response.getWriter().write(Result.error(99, e.getMessage()).toJsonString());
}
}
} }
@@ -118,4 +118,19 @@ public class BrandTrainSignUpActivity extends BaseModel implements Serializable
@Comment("活动类型") @Comment("活动类型")
@ColDefine(type = ColType.VARCHAR, width = 20) @ColDefine(type = ColType.VARCHAR, width = 20)
private String trainType; private String trainType;
@Column
@ColDefine(type = ColType.INT)
@Comment("签到达标点位数,空或0表示未开启签到扩展")
private Integer signPointCount;
@Column
@ColDefine(type = ColType.INT)
@Comment("签到达标处理方式:1填写信息,2选择福利,3无")
private Integer signHandleType;
@Column
@ColDefine(type = ColType.VARCHAR, width = 32)
@Comment("签到达标后关联的福利项目ID")
private String welfareProjectId;
} }
@@ -63,6 +63,11 @@ public class BrandTrainSignUpCourse extends BaseModel implements Serializable {
@Comment("课程地点坐标") @Comment("课程地点坐标")
private List<Double> courseLocationCoordinates; private List<Double> courseLocationCoordinates;
@Column
@ColDefine(type = ColType.INT)
@Comment("签到点位半径,单位米;未配置时使用250米")
private Integer signRadius;
@Column @Column
@ColDefine(type = ColType.VARCHAR, width = 20) @ColDefine(type = ColType.VARCHAR, width = 20)
@Comment("课程讲师") @Comment("课程讲师")
@@ -113,11 +113,17 @@ public interface BrandTrainSignUpActivityService extends ViService<BrandTrainSig
boolean isSignCourseByUser(String courseId, String userId); boolean isSignCourseByUser(String courseId, String userId);
/** /**
* 手机端签到 * 原手机端扫码/GPS签到,共用服务端点位范围校验。
* *
* @param id 每个培训班每节课每个用户的记录ID * @param id 每个培训班每节课每个用户的记录ID
* @param courseId 当前签到课程ID,必须与签到记录所属课程一致
* @param userId 当前登录用户ID,由服务端登录态取得
* @param longitude 当前GCJ-02经度,范围[-180,180]
* @param latitude 当前GCJ-02纬度,范围[-90,90]
* @param signType 当前入口的签到方式,1扫码、3 GPS,必须与课程配置一致
* @return 无返回值;校验失败抛出业务异常,成功更新签到状态和时间
*/ */
void doQd(String id); void doQd(String id, String courseId, String userId, Double longitude, Double latitude, int signType);
/** /**
* 某个用户的签到信息 * 某个用户的签到信息
@@ -127,6 +133,37 @@ 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);
/**
* 当前用户在地图页手动签到,累计不重复的课程点位,并处理原有达标事项。
* 腾讯地图余额耗尽期间临时停用定位和范围校验,恢复时需同步恢复前端、controller和service中的校验。
*
* @param courseId 品牌课程ID,不接受普通培训课程或非扫码签到课程
* @param userId 当前登录用户ID,由服务端登录态取得
* @param longitude 保留兼容的GCJ-02经度,临时停用定位校验期间可不传
* @param latitude 保留兼容的GCJ-02纬度,临时停用定位和范围校验期间可不传
* @return code/msg/data结构;data含签到进度、动态表单、福利跳转及达标处理状态
*/
NutMap handleScanCodeSign(String courseId, String userId, Double longitude, Double latitude);
/**
* 保存品牌活动达标后的动态字段,同步至该用户在本活动已签到课程的报名记录。
*
* @param courseId 当前扫码品牌课程ID,用于确定活动及统一的课程类型
* @param userId 当前登录用户ID,由服务端登录态取得
* @param mobileColumnsValue JSON数组,每项包含columnCode和columnValue,附件保留数组结构
* @throws IllegalArgumentException 未达标、无参与权限或字段校验不通过时抛出业务提示
*/
void saveScanCodeDynamicForm(String courseId, String userId, String mobileColumnsValue);
/** /**
* 查询活动的报名条件 * 查询活动的报名条件
* @param activityId * @param activityId
@@ -1,5 +1,7 @@
package io.v.nutz.brandtrainactivity.service; package io.v.nutz.brandtrainactivity.service;
import org.apache.poi.ss.usermodel.Workbook;
import cn.wizzer.framework.page.Pagination; import cn.wizzer.framework.page.Pagination;
import io.v.nutz.modules.models.PageForm; import io.v.nutz.modules.models.PageForm;
import io.v.nutz.service.ViService; import io.v.nutz.service.ViService;
@@ -55,4 +57,13 @@ public interface BrandTrainSignUpActivityStatisticsService extends ViService<Bra
* @return * @return
*/ */
List<NutMap> baoMingUserList(String activityId); List<NutMap> baoMingUserList(String activityId);
/**
* 按活动、人员汇总签到后填写的信息,同一人员仅导出一行。
*
* @param activityId 活动主键;活动内课程须使用同一种动态字段配置
* @return XSSF 工作簿,包含人员信息、已签到课程及课程类型配置的动态字段
*/
Workbook exportFillFormWorkbook(String activityId);
} }
@@ -2,6 +2,7 @@ package io.v.nutz.brandtrainactivity.service.impl;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import cn.wizzer.framework.page.Pagination; import cn.wizzer.framework.page.Pagination;
import io.v.nutz.activity.models.ActivityUserScope;
import io.v.nutz.base.utils.DateUtil; import io.v.nutz.base.utils.DateUtil;
import io.v.nutz.base.utils.Vi; import io.v.nutz.base.utils.Vi;
import io.v.nutz.brandtrainactivity.models.*; import io.v.nutz.brandtrainactivity.models.*;
@@ -15,6 +16,9 @@ import io.v.nutz.modules.models.PageForm;
import io.v.nutz.service.impl.ViServiceImpl; import io.v.nutz.service.impl.ViServiceImpl;
import io.v.nutz.sys.models.Sys_user; import io.v.nutz.sys.models.Sys_user;
import io.v.nutz.web.commons.utils.ShiroUtil; import io.v.nutz.web.commons.utils.ShiroUtil;
import io.v.nutz.welfare.model.WelfareList;
import io.v.nutz.welfare.model.WelfareProject;
import io.v.nutz.welfare.service.WelfareListService;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.nutz.aop.interceptor.ioc.TransAop; import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Chain; import org.nutz.dao.Chain;
@@ -29,13 +33,20 @@ 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.Comparator;
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 +54,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 +64,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 +126,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 +538,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 +568,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 +686,537 @@ 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);
// 腾讯地图余额耗尽期间,仅当前扫码入口临时停用定位和范围校验;恢复时需同步恢复前端定位及controller经纬度校验。
// 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);
}
List<NutMap> signedPoints = buildScanCodeSignedPoints(activity.getId(), attendRecords);
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("signedPoints", signedPoints)
.addv("signPoints", buildScanCodePoints(activity.getId(), signedPoints))
.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;
}
/**
* activityId 为已校验的当前活动,attendRecords 仅含当前登录用户在本活动的已签到课次。
* 返回点位列表,每项为 courseId、courseName、courseLocation、attendTime(首次签到时间);按时间倒序。
* 同一课程的多个课次只展示一次,与 signCount 计数口径一致;历史课程被删除时保留记录并提示名称。
*/
private List<NutMap> buildScanCodeSignedPoints(String activityId, List<BrandTrainSignUpUserCourse> attendRecords) {
Map<String, BrandTrainSignUpUserCourse> firstAttends = new LinkedHashMap<>();
attendRecords.stream().filter(item -> StrUtil.isNotBlank(item.getCourseId()))
.sorted(Comparator.comparing(BrandTrainSignUpUserCourse::getAttendTime,
Comparator.nullsLast(Comparator.naturalOrder()))
.thenComparing(BrandTrainSignUpUserCourse::getId, Comparator.nullsLast(Comparator.naturalOrder())))
.forEach(item -> firstAttends.putIfAbsent(item.getCourseId(), item));
if (firstAttends.isEmpty()) {
return new ArrayList<>();
}
// 批量读取点位名称和地点,避免每条记录单独查询;不返回人员、附件或其他报名数据。
Map<String, BrandTrainSignUpCourse> courses = dao().query(BrandTrainSignUpCourse.class,
Cnd.where("activityId", "=", activityId).and("id", "in", new ArrayList<>(firstAttends.keySet())))
.stream().collect(Collectors.toMap(BrandTrainSignUpCourse::getId, item -> item));
return firstAttends.values().stream()
.sorted(Comparator.comparing(BrandTrainSignUpUserCourse::getAttendTime,
Comparator.nullsLast(Comparator.reverseOrder())))
.map(item -> {
BrandTrainSignUpCourse signedCourse = courses.get(item.getCourseId());
return NutMap.NEW().addv("courseId", item.getCourseId())
.addv("courseName", signedCourse == null ? "点位已删除" : signedCourse.getCourseName())
.addv("courseLocation", signedCourse == null ? "" : signedCourse.getCourseLocation())
.addv("attendTime", item.getAttendTime() == null ? null : DateUtil.formatDateTime(item.getAttendTime()));
}).collect(Collectors.toList());
}
/**
* activityId 为当前已校验的活动ID;signedPoints 为当前登录用户按课程去重后的已打卡列表。
* 返回活动全部点位,每项包含 courseId、courseName、courseLocation、attendTime、signed(是否已打卡)。
* 未报名或仅报名的点位均为未打卡,attendTime 为空;课程按配置序号和ID排序,列表不触发任何签到写入。
*/
private List<NutMap> buildScanCodePoints(String activityId, List<NutMap> signedPoints) {
Map<String, NutMap> signedByCourse = new LinkedHashMap<>();
signedPoints.forEach(point -> signedByCourse.put(point.getString("courseId"), point));
Map<String, NutMap> points = new LinkedHashMap<>();
List<BrandTrainSignUpCourse> courses = dao().query(BrandTrainSignUpCourse.class,
Cnd.where("activityId", "=", activityId).asc("orderNum").asc("id"));
for (BrandTrainSignUpCourse pointCourse : courses) {
NutMap signedPoint = signedByCourse.get(pointCourse.getId());
points.put(pointCourse.getId(), NutMap.NEW().addv("courseId", pointCourse.getId())
.addv("courseName", pointCourse.getCourseName()).addv("courseLocation", pointCourse.getCourseLocation())
.addv("signed", signedPoint != null)
.addv("attendTime", signedPoint == null ? null : signedPoint.get("attendTime")));
}
// 已删除课程的历史签到仍计入原达标数;保留为已打卡项,避免弹框数量与主页面进度不一致。
for (NutMap signedPoint : signedPoints) {
if (!points.containsKey(signedPoint.getString("courseId"))) {
NutMap historicalPoint = NutMap.NEW();
historicalPoint.putAll(signedPoint);
historicalPoint.setv("signed", true);
points.put(signedPoint.getString("courseId"), historicalPoint);
}
}
return new ArrayList<>(points.values());
}
/** 已达标用户复用本项目福利名单服务,并由现有福利选择页处理时间、选项和签收。 */
@Aop(TransAop.READ_COMMITTED)
private void completeScanCodeWelfare(String projectId, String userId, NutMap data) {
buildScanCodeWelfareData(projectId, data);
if (!data.getBoolean("needWelfareSelect", false)) {
return;
}
if (dao().count(WelfareList.class, Cnd.where("projectId", "=", projectId).and("userId", "=", userId)) == 0) {
welfareListService.joinWelfareList(projectId, Collections.singletonList(userId));
}
}
/** 福利入口组装仅校验项目并设置返回字段,供手动签到和已有名单的只读预览共同使用。 */
private void buildScanCodeWelfareData(String projectId, NutMap data) {
WelfareProject project = StrUtil.isBlank(projectId) ? null : dao().fetch(WelfareProject.class, projectId);
if (project == null || project.getChoiceTimeEnd() == null || project.getChoiceTimeEnd().before(new Date())) {
// 福利校验失败时覆盖默认结果,确保状态和提示仍为单个字符串。
data.setv("handleStatus", "FAILED").setv("handleMsg", "关联福利项目不存在或选择时间已结束,请联系管理员");
return;
}
// 覆盖默认福利结果,保持选择标记为布尔值、跳转地址为字符串,避免数组转换后生成错误地址。
data.setv("handleStatus", "PENDING_WELFARE").setv("handleMsg", "当前活动已达标,请前往选择福利")
.setv("needWelfareSelect", true).setv("welfareJumpUrl", "/mobile/welfare/list/receive?projectId=" + projectId);
}
/** 动态字段沿用当前课程类型配置,排序与既有报名表单保持一致。 */
private List<BrandTrainMobileSignColumn> getScanCodeColumns(BrandTrainSignUpCourse course) {
if (StrUtil.isBlank(course.getCourseType())) {
return new ArrayList<>();
}
return dao().query(BrandTrainMobileSignColumn.class, Cnd.where("typeId", "=", course.getCourseType()).asc("columnIndex"));
}
/** 新点位继续使用活动内最近一份已保存表单,按报名时间和ID排序保证回显结果稳定。 */
private List<NutMap> getLatestScanCodeForm(String activityId, String userId) {
List<BrandTrainSignUpUser> users = dao().query(BrandTrainSignUpUser.class,
Cnd.where("activityId", "=", activityId).and("userId", "=", userId).desc("signUpTime").desc("id"));
return users.stream().map(BrandTrainSignUpUser::getMobileColumnsValue).filter(item -> item != null && !item.isEmpty())
.findFirst().orElseGet(ArrayList::new);
}
/** 在上层扫码或表单保存事务内同步已签到课程,未签到的普通报名记录保持原值。 */
@Aop(TransAop.READ_COMMITTED)
private void syncAttendMobileColumnsValue(String activityId, String userId, List<NutMap> formData) {
if (formData == null || formData.isEmpty()) {
return;
}
List<BrandTrainSignUpUserCourse> attends = dao().query(BrandTrainSignUpUserCourse.class,
Cnd.where("activityId", "=", activityId).and("userId", "=", userId).and("isAttend", "=", true));
List<String> courseIds = attends.stream().map(BrandTrainSignUpUserCourse::getCourseId).distinct().collect(Collectors.toList());
if (courseIds.isEmpty()) {
return;
}
List<BrandTrainSignUpUser> users = dao().query(BrandTrainSignUpUser.class,
Cnd.where("activityId", "=", activityId).and("userId", "=", userId).and("courseId", "in", courseIds));
users.forEach(item -> item.setMobileColumnsValue(Json.fromJsonAsList(NutMap.class, Json.toJson(formData))));
dao().update(users);
}
/** 字段定义以服务端配置为准,校验必填、整数/小数、日期、选项和附件数组,保留附件原有结构。 */
private Object validateScanCodeColumnValue(BrandTrainMobileSignColumn column, Object value) {
boolean empty = value == null || value instanceof String && StrUtil.isBlank((String) value)
|| value instanceof List && ((List<?>) value).isEmpty();
if (empty) {
if (Boolean.TRUE.equals(column.getIsRequired())) {
throw new IllegalArgumentException("请填写" + column.getColumnName());
}
return "FILE".equals(column.getColumnFormType()) ? new ArrayList<>() : "";
}
String text = String.valueOf(value);
String type = column.getColumnType();
try {
if (!"JSON".equals(type) && !"MYSQL_JSON".equals(type) && (value instanceof List || value instanceof Map)) {
throw new IllegalArgumentException();
}
if ("INT".equals(type) || "INT2".equals(type) || "INT4".equals(type) || "INT8".equals(type)) {
new BigDecimal(text).toBigIntegerExact();
} else if ("FLOAT".equals(type) || "DOUBLE".equals(type) || "DECIMAL".equals(type)) {
new BigDecimal(text);
} else if ("DATE".equals(type)) {
java.time.LocalDate.parse(text);
} else if ("DATETIME".equals(type) || "TIMESTAMP".equals(type)) {
java.time.LocalDateTime.parse(text, java.time.format.DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss")
.withResolverStyle(java.time.format.ResolverStyle.STRICT));
} else if ("BOOLEAN".equals(type) && !"true".equalsIgnoreCase(text) && !"false".equalsIgnoreCase(text)) {
throw new IllegalArgumentException();
} else if ("JSON".equals(type) || "MYSQL_JSON".equals(type)) {
value = value instanceof String ? Json.fromJson(text) : value;
if (!(value instanceof List) && !(value instanceof Map)) {
throw new IllegalArgumentException();
}
}
} catch (Exception e) {
throw new IllegalArgumentException(column.getColumnName() + "格式不正确");
}
if ("SELECT".equals(column.getColumnFormType()) && !"DATE".equals(type) && !"DATETIME".equals(type)
&& (column.getSelectValues() == null || !column.getSelectValues().contains(text))) {
throw new IllegalArgumentException("请选择有效的" + column.getColumnName());
}
if ("FILE".equals(column.getColumnFormType()) && (!(value instanceof List)
|| column.getFileNumber() != null && ((List<?>) value).size() > column.getFileNumber()
|| Boolean.TRUE.equals(column.getIsRequired()) && ((List<?>) value).isEmpty())) {
throw new IllegalArgumentException(column.getColumnName() + "附件数量不正确");
}
return value;
}
/** 扫码接口保持code/msg/data结构,重复签到也返回进度供手机端继续办理达标事项。 */
private NutMap scanCodeResponse(int code, String msg, NutMap data) {
return NutMap.NEW().addv("code", code).addv("msg", msg).addv("data", data);
}
private Boolean checkCnd(MatchConditionStructure cnd) { private Boolean checkCnd(MatchConditionStructure cnd) {
return cnd != null && cnd.getConditions() != null && cnd.getMethod() != null && cnd.getConditions().stream().allMatch(v -> v.getField() != null && v.getOperational() != null && v.getValue() != null); return cnd != null && cnd.getConditions() != null && cnd.getMethod() != null && cnd.getConditions().stream().allMatch(v -> v.getField() != null && v.getOperational() != null && v.getValue() != null);
} }
@@ -1,5 +1,20 @@
package io.v.nutz.brandtrainactivity.service.impl; package io.v.nutz.brandtrainactivity.service.impl;
import cn.afterturn.easypoi.excel.ExcelExportUtil;
import cn.afterturn.easypoi.excel.entity.ExportParams;
import cn.afterturn.easypoi.excel.entity.enmus.ExcelType;
import cn.afterturn.easypoi.excel.entity.params.ExcelExportEntity;
import cn.hutool.core.util.StrUtil;
import io.v.nutz.brandtrainactivity.models.BrandTrainMobileSignColumn;
import io.v.nutz.brandtrainactivity.models.BrandTrainSignUpActivity;
import io.v.nutz.brandtrainactivity.models.BrandTrainSignUpCourse;
import io.v.nutz.brandtrainactivity.models.BrandTrainSignUpType;
import org.apache.poi.ss.usermodel.Workbook;
import org.nutz.dao.Cnd;
import org.nutz.json.Json;
import java.util.ArrayList;
import java.util.HashMap;
import cn.wizzer.framework.page.Pagination; import cn.wizzer.framework.page.Pagination;
import io.v.nutz.base.utils.Vi; import io.v.nutz.base.utils.Vi;
import io.v.nutz.dao.CndPlus; import io.v.nutz.dao.CndPlus;
@@ -175,4 +190,113 @@ public class BrandTrainSignUpActivityStatisticsServiceImpl extends ViServiceImpl
sql.setParam("activityId", activityId); sql.setParam("activityId", activityId);
return listMap(sql); return listMap(sql);
} }
/**
* 按活动汇总人员填写的信息;同一人员报名多个点位时只输出一行。
*
* @param activityId 需要导出的品牌活动ID,不可为空
* @return XSSF工作簿;固定列为人员信息和已签到课程,后续列为该活动课程类型的动态字段
*/
@Override
public Workbook exportFillFormWorkbook(String activityId) {
if (StrUtil.isBlank(activityId)) {
throw new IllegalArgumentException("请选择需要导出的活动");
}
BrandTrainSignUpActivity activity = dao().fetch(BrandTrainSignUpActivity.class, activityId);
if (activity == null) {
throw new IllegalArgumentException("未找到当前活动");
}
List<BrandTrainSignUpCourse> courseList = dao().query(BrandTrainSignUpCourse.class,
Cnd.where("activityId", "=", activityId).asc("orderNum"));
List<String> courseTypes = courseList.stream().map(BrandTrainSignUpCourse::getCourseType)
.filter(StrUtil::isNotBlank).distinct().collect(Collectors.toList());
// 多种课程类型可能复用字段编码但含义不同,不能按同一份动态表头合并导出。
if (courseTypes.size() > 1) {
throw new IllegalArgumentException("当前活动存在多个课程类型,不能导出填写信息");
}
List<BrandTrainMobileSignColumn> columns = new ArrayList<>();
if (!courseTypes.isEmpty()) {
BrandTrainSignUpType type = dao().fetch(BrandTrainSignUpType.class, courseTypes.get(0));
if (type != null) {
dao().fetchLinks(type, "trainMobileSignColumnList", Cnd.NEW().asc("columnIndex"));
if (type.getTrainMobileSignColumnList() != null) {
columns = type.getTrainMobileSignColumnList();
}
}
}
Sql userSql = Sqls.create("""
SELECT ts.userId, u.username, u.loginname, u.unitname, u.unionname, u.sex,
ts.mobileColumnsValue, ts.signUpTime
FROM brand_train_sign_up_user ts
LEFT JOIN `user` u ON u.id = ts.userId
WHERE ts.activityId = @activityId
ORDER BY ts.signUpTime DESC, ts.id
""").setParam("activityId", activityId);
Map<String, List<NutMap>> users = listMap(userSql).stream()
.filter(item -> StrUtil.isNotBlank(item.getString("userId")))
.collect(Collectors.groupingBy(item -> item.getString("userId"),
LinkedHashMap::new, Collectors.toList()));
Sql signSql = Sqls.create("""
SELECT uc.userId,
GROUP_CONCAT(DISTINCT c.courseName ORDER BY c.orderNum SEPARATOR '、') AS signCourseNames
FROM brand_train_sign_up_user_course uc
LEFT JOIN brand_train_sign_up_course c ON c.id = uc.courseId
WHERE uc.activityId = @activityId AND uc.isAttend = 1
GROUP BY uc.userId
""").setParam("activityId", activityId);
Map<String, String> signCourseNames = listMap(signSql).stream()
.collect(Collectors.toMap(item -> item.getString("userId"),
item -> item.getString("signCourseNames", ""), (first, second) -> first));
List<NutMap> rows = new ArrayList<>();
for (Map.Entry<String, List<NutMap>> entry : users.entrySet()) {
NutMap user = entry.getValue().get(0);
NutMap row = NutMap.NEW().addv("username", user.getString("username"))
.addv("loginname", user.getString("loginname"))
.addv("unitname", user.getString("unitname"))
.addv("unionname", user.getString("unionname"))
.addv("sex", user.getString("sex"))
.addv("signCourseNames", signCourseNames.getOrDefault(entry.getKey(), ""));
Map<String, Object> formValues = new HashMap<>();
// 签到表单会同步到各已签到点位;历史记录未同步时使用最近一份非空表单。
for (NutMap record : entry.getValue()) {
String json = record.getString("mobileColumnsValue");
if (StrUtil.isBlank(json)) {
continue;
}
List<NutMap> values = Json.fromJsonAsList(NutMap.class, json);
if (values == null || values.isEmpty()) {
continue;
}
for (NutMap value : values) {
formValues.put(value.getString("columnCode"), value.get("columnValue"));
}
break;
}
for (BrandTrainMobileSignColumn column : columns) {
Object value = formValues.get(column.getColumnCode());
// 动态字段单独命名,防止配置的字段编码覆盖姓名等固定导出列。
row.addv("form_" + column.getColumnCode(), value == null ? "" : String.valueOf(value));
}
rows.add(row);
}
List<ExcelExportEntity> entities = new ArrayList<>();
entities.add(new ExcelExportEntity("姓名", "username", 20));
entities.add(new ExcelExportEntity("工号", "loginname", 20));
entities.add(new ExcelExportEntity("单位", "unitname", 30));
entities.add(new ExcelExportEntity("分工会", "unionname", 30));
entities.add(new ExcelExportEntity("性别", "sex", 10));
entities.add(new ExcelExportEntity("已签到课程", "signCourseNames", 40));
for (BrandTrainMobileSignColumn column : columns) {
entities.add(new ExcelExportEntity(column.getColumnName(), "form_" + column.getColumnCode(), 25));
}
ExportParams exportParams = new ExportParams();
exportParams.setType(ExcelType.XSSF);
exportParams.setTitle(activity.getActivityName() + "填写信息");
exportParams.setSheetName("填写信息");
return ExcelExportUtil.exportExcel(exportParams, entities, rows);
}
} }
@@ -4,6 +4,7 @@ package io.v.nutz.sys.services;
import io.v.nutz.base.service.BaseService; import io.v.nutz.base.service.BaseService;
import io.v.nutz.sys.models.Sys_menu; import io.v.nutz.sys.models.Sys_menu;
import io.v.nutz.sys.models.Sys_user; import io.v.nutz.sys.models.Sys_user;
import org.nutz.lang.util.NutMap;
import java.util.List; import java.util.List;
@@ -11,6 +12,15 @@ import java.util.List;
* Created by wizzer on 2016/12/22. * Created by wizzer on 2016/12/22.
*/ */
public interface SysUserService extends BaseService<Sys_user> { public interface SysUserService extends BaseService<Sys_user> {
/**
* 无参读取当前Shiro Subject,为H5布局返回可公开的登录用户视图,不缓存当前用户。
*
* @return 非null的NutMap:id/loginname/username/avatar/unitid/unionid为身份字符串,
* roles为仅含code的对象数组,permissions为权限字符串数组;匿名、未知Principal或禁用用户
* 返回空身份字符串及两个空数组,不返回密码、盐、完整角色或用户实体。
*/
NutMap getH5LogonUser();
/** /**
* 查询用户的角色 * 查询用户的角色
* *
@@ -7,6 +7,9 @@ import io.v.nutz.sys.models.Sys_role;
import io.v.nutz.sys.models.Sys_user; import io.v.nutz.sys.models.Sys_user;
import io.v.nutz.sys.services.SysMenuService; import io.v.nutz.sys.services.SysMenuService;
import io.v.nutz.sys.services.SysUserService; import io.v.nutz.sys.services.SysUserService;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.UnavailableSecurityManagerException;
import org.apache.shiro.subject.Subject;
import org.nutz.aop.interceptor.ioc.TransAop; import org.nutz.aop.interceptor.ioc.TransAop;
import org.nutz.dao.Cnd; import org.nutz.dao.Cnd;
import org.nutz.dao.Dao; import org.nutz.dao.Dao;
@@ -16,6 +19,7 @@ import org.nutz.ioc.aop.Aop;
import org.nutz.ioc.loader.annotation.Inject; import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean; import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.lang.Strings; import org.nutz.lang.Strings;
import org.nutz.lang.util.NutMap;
import org.nutz.plugins.wkcache.annotation.CacheDefaults; import org.nutz.plugins.wkcache.annotation.CacheDefaults;
import org.nutz.plugins.wkcache.annotation.CacheRemove; import org.nutz.plugins.wkcache.annotation.CacheRemove;
import org.nutz.plugins.wkcache.annotation.CacheRemoveAll; import org.nutz.plugins.wkcache.annotation.CacheRemoveAll;
@@ -23,6 +27,7 @@ import org.nutz.plugins.wkcache.annotation.CacheResult;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -39,6 +44,60 @@ public class SysUserServiceImpl extends BaseServiceImpl<Sys_user> implements Sys
@Inject @Inject
private SysMenuService sysMenuService; private SysMenuService sysMenuService;
/**
* 无参从当前Shiro登录态读取用户,仅在H5模板实际调用时组装安全视图。
* 返回身份白名单、roles:[{code}]和permissions:[字符串];空登录态、未知Principal、
* 无有效用户ID或禁用用户均返回空身份和空数组,避免模板json(null)生成无效脚本。
* 角色及菜单权限复用现有查询的禁用过滤规则,用户原始字符串由模板注入点负责脚本转义。
*
* @return 每次调用独立创建的NutMap,不包含密码、盐或完整角色/用户关联对象
*/
@Override
public NutMap getH5LogonUser() {
List<NutMap> roles = new ArrayList<>();
List<String> permissions = new ArrayList<>();
NutMap result = NutMap.NEW().addv("id", "").addv("loginname", "").addv("username", "")
.addv("avatar", "").addv("unitid", "").addv("unionid", "")
.addv("roles", roles).addv("permissions", permissions);
Subject subject;
try {
subject = SecurityUtils.getSubject();
} catch (UnavailableSecurityManagerException e) {
return result;
}
Object principal = subject == null ? null : subject.getPrincipal();
if (!(principal instanceof Sys_user)) {
return result;
}
Sys_user user = (Sys_user) principal;
if (user.isDisabled() || Strings.isBlank(user.getId())) {
return result;
}
result.setv("id", user.getId()).setv("loginname", Strings.sNull(user.getLoginname()))
.setv("username", Strings.sNull(user.getUsername())).setv("avatar", Strings.sNull(user.getAvatar()))
.setv("unitid", Strings.sNull(user.getUnitid())).setv("unionid", Strings.sNull(user.getUnionid()));
List<String> roleCodes = getRoleCodeList(user);
if (roleCodes != null) {
LinkedHashSet<String> uniqueRoles = new LinkedHashSet<>(roleCodes);
for (String roleCode : uniqueRoles) {
if (!Strings.isBlank(roleCode)) {
roles.add(NutMap.NEW().addv("code", roleCode));
}
}
}
List<Sys_menu> menus = getMenusAndButtons(user.getId());
if (menus != null) {
LinkedHashSet<String> uniquePermissions = new LinkedHashSet<>();
for (Sys_menu menu : menus) {
if (menu != null && !menu.isDisabled() && !Strings.isBlank(menu.getPermission())) {
uniquePermissions.add(menu.getPermission());
}
}
permissions.addAll(uniquePermissions);
}
return result;
}
/** /**
* 查询用户角色code列表 * 查询用户角色code列表
* *
@@ -0,0 +1,51 @@
package io.v.nutz.web.commons.controller;
import cn.hutool.core.util.StrUtil;
import io.v.nutz.annontation.ViReturn;
import io.v.nutz.base.result.Result;
import io.v.nutz.web.commons.ext.map.TMapService;
import org.apache.shiro.authz.annotation.RequiresAuthentication;
import org.nutz.ioc.loader.annotation.Inject;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.mvc.annotation.At;
import org.nutz.mvc.annotation.Ok;
import org.nutz.mvc.annotation.POST;
import org.nutz.mvc.annotation.Param;
/**
* 腾讯地图公共接口,供已登录用户在各业务模块中搜索地点。
*/
@IocBean
@At("/platform/TMap")
@Ok("json:full")
public class TMapController {
@Inject
private TMapService tMapService;
/**
* 查询腾讯地图地址候选,仅接收关键词;地图Key及搜索区域由公共服务读取配置。
*
* @param keyword 地址关键词,去除首尾空白后须为1至100个字符
* @return Result的code/msg/data;成功时data含腾讯status、message和地点data数组,
* 地点包含title、address、location.lat(纬度)和location.lng(经度)
*/
@At
@POST
@ViReturn
@RequiresAuthentication
public Result suggestion(@Param(value = "keyword", required = false) String keyword) {
if (StrUtil.isBlank(keyword)) {
return Result.error("请输入地址关键词");
}
String searchKeyword = keyword.trim();
if (searchKeyword.length() > 100) {
return Result.error("地址关键词不能超过100个字符");
}
try {
return Result.success(tMapService.suggestion(searchKeyword));
} catch (IllegalArgumentException e) {
return Result.error(e.getMessage());
}
}
}
@@ -0,0 +1,78 @@
package io.v.nutz.web.commons.ext.map;
import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HttpResponse;
import cn.hutool.http.HttpUtil;
import io.v.nutz.web.commons.base.Globals;
import org.nutz.ioc.loader.annotation.IocBean;
import org.nutz.json.Json;
import org.nutz.lang.util.NutMap;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 腾讯地图公共地点搜索服务,负责请求参数编码、服务调用和响应校验。
*/
@IocBean
public class TMapService {
// 地点搜索仅访问腾讯固定服务;系统TencentMapKey可覆盖参考工程的默认Key。
private static final String TENCENT_MAP_SUGGESTION_URL = "https://apis.map.qq.com/ws/place/v1/suggestion";
private static final String DEFAULT_TENCENT_MAP_KEY = "NYNBZ-K62KI-DQJGU-U4NJ5-CSME7-ULFMU";
/**
* 读取腾讯地图地址候选,仅访问固定的地点搜索服务,不修改本地数据。
*
* @param keyword 地址关键词,去除首尾空白后须为1至100个字符,搜索区域沿用系统CityName配置
* @return 腾讯响应对象;status=0表示成功,data为地点数组,location.lat为纬度、location.lng为经度
* @throws IllegalArgumentException 参数、网络或腾讯地图响应校验失败时提供可读业务提示
*/
public NutMap suggestion(String keyword) {
if (StrUtil.isBlank(keyword) || keyword.trim().length() > 100) {
throw new IllegalArgumentException("地址关键词须为1至100个字符");
}
String mapKey = Globals.MyConfig.getString("TencentMapKey");
if (StrUtil.isBlank(mapKey)) {
mapKey = DEFAULT_TENCENT_MAP_KEY;
}
Map<String, Object> params = new LinkedHashMap<>();
params.put("key", mapKey.trim());
params.put("keyword", keyword.trim());
params.put("region", StrUtil.trimToEmpty(Globals.MyConfig.getString("CityName")));
// 参数交由HTTP库编码,避免中文、&等字符改变查询内容;禁用重定向,保持固定服务地址。
String responseBody;
try (HttpResponse response = HttpUtil.createGet(TENCENT_MAP_SUGGESTION_URL)
.charset(StandardCharsets.UTF_8).form(params).timeout(5000).setFollowRedirects(false).execute()) {
if (response.getStatus() != 200) {
throw new IllegalStateException("腾讯地图地址搜索请求未成功");
}
responseBody = response.body();
} catch (Exception e) {
// HTTP库异常可能携带完整URL,禁止向前端或日志透传,以免暴露地图Key。
throw new IllegalArgumentException("腾讯地图地址搜索失败,请稍后重试");
}
if (StrUtil.isBlank(responseBody)) {
throw new IllegalArgumentException("腾讯地图地址搜索响应为空,请稍后重试");
}
NutMap result;
try {
result = Json.fromJson(NutMap.class, responseBody);
} catch (Exception e) {
throw new IllegalArgumentException("腾讯地图地址搜索响应异常,请稍后重试");
}
// 缺少状态不能按默认0当作成功;地址候选必须为数组,供前端自动完成组件直接使用。
if (result == null || !(result.get("status") instanceof Number)) {
throw new IllegalArgumentException("腾讯地图地址搜索响应异常,请稍后重试");
}
if (result.getInt("status") != 0) {
throw new IllegalArgumentException("腾讯地图地址搜索失败,请检查地图服务配置");
}
if (!(result.get("data") instanceof List)) {
throw new IllegalArgumentException("腾讯地图地址搜索响应异常,请稍后重试");
}
return result;
}
}
@@ -2,6 +2,7 @@ package io.v.nutz.web.commons.proc;
import io.v.nutz.base.utils.DateUtil; import io.v.nutz.base.utils.DateUtil;
import io.v.nutz.base.utils.StringUtil; import io.v.nutz.base.utils.StringUtil;
import io.v.nutz.sys.services.SysUserService;
import io.v.nutz.util.ShiroUtil; import io.v.nutz.util.ShiroUtil;
import io.v.nutz.web.commons.base.Globals; import io.v.nutz.web.commons.base.Globals;
import org.nutz.lang.Strings; import org.nutz.lang.Strings;
@@ -18,6 +19,7 @@ public class GlobalsSettingProcessor extends AbstractProcessor {
private static ShiroUtil ShiroUtil; private static ShiroUtil ShiroUtil;
private static DateUtil dateUtil; private static DateUtil dateUtil;
private static StringUtil stringUtil; private static StringUtil stringUtil;
private SysUserService h5UserService;
public void init(NutConfig config, ActionInfo ai) throws Throwable { public void init(NutConfig config, ActionInfo ai) throws Throwable {
if (ShiroUtil == null) { if (ShiroUtil == null) {
@@ -29,6 +31,8 @@ public class GlobalsSettingProcessor extends AbstractProcessor {
if (stringUtil == null) { if (stringUtil == null) {
stringUtil = new StringUtil(); stringUtil = new StringUtil();
} }
// 仅取得当前应用的服务代理,不在处理器初始化时读取登录态或查询用户权限。
h5UserService = config.getIoc().getByType(SysUserService.class);
} }
@SuppressWarnings("rawtypes") @SuppressWarnings("rawtypes")
@@ -41,6 +45,8 @@ public class GlobalsSettingProcessor extends AbstractProcessor {
ac.getRequest().setAttribute("AppFileDomain", Globals.AppFileDomain); ac.getRequest().setAttribute("AppFileDomain", Globals.AppFileDomain);
ac.getRequest().setAttribute("config", Globals.MyConfig); ac.getRequest().setAttribute("config", Globals.MyConfig);
ac.getRequest().setAttribute("shiro", ShiroUtil); ac.getRequest().setAttribute("shiro", ShiroUtil);
// H5 布局渲染时再调用服务生成安全用户视图,其余请求只绑定对象,不额外查询用户。
ac.getRequest().setAttribute("h5UserService", h5UserService);
ac.getRequest().setAttribute("date", dateUtil); ac.getRequest().setAttribute("date", dateUtil);
ac.getRequest().setAttribute("string", stringUtil); ac.getRequest().setAttribute("string", stringUtil);
// 如果url中有语言属性则设置 // 如果url中有语言属性则设置
@@ -25,4 +25,11 @@ public interface WelfareProjectService extends ViService<WelfareProject> {
List<WelfareProject> getSchoolWelfareList(Integer year); List<WelfareProject> getSchoolWelfareList(Integer year);
/**
* 获取签到扩展可关联的福利项目,包含选择尚未开始和正在进行的项目。
*
* @return 选择结束时间未过期的项目列表;每项的 id、name 用于活动配置的福利选择框
*/
List<WelfareProject> getAvailableSignWelfareProjects();
} }
@@ -18,6 +18,7 @@ import org.nutz.lang.Strings;
import org.nutz.lang.util.NutMap; import org.nutz.lang.util.NutMap;
import org.nutz.plugins.wkcache.annotation.CacheDefaults; import org.nutz.plugins.wkcache.annotation.CacheDefaults;
import java.util.Date;
import java.util.List; import java.util.List;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -76,4 +77,15 @@ public class WelfareProjectServiceImpl extends ViServiceImpl<WelfareProject> imp
return projectList; return projectList;
} }
/**
* 当前项目没有福利禁用字段,按已有的选择结束时间筛选可关联项目。
*
* @return 选择时间未结束的福利项目,按开始时间及创建时间排列
*/
@Override
public List<WelfareProject> getAvailableSignWelfareProjects() {
return query(Cnd.where("choiceTimeEnd", ">=", new Date())
.asc("choiceTimeStart").desc("createdAt"));
}
} }
@@ -0,0 +1,111 @@
/* 手机弹框共用一份历史栈;页面和子组件只注册状态回调,不各自监听 popstate。 */
;(() => {
if (window.mobilePopupHistory) {
return
}
const stateKey = '__mobilePopupHistory'
const session = Date.now() + '-' + Math.random().toString(36).slice(2)
const entries = []
const owners = new Map()
let pending = null
// 保留页面原有 history.state;标记只用于本次页面内的弹框层级,不携带业务数据。
const markState = (depth) => Object.assign({}, window.history.state || {}, {
[stateKey]: {session: session, depth: depth}
})
const notify = (entry, visible) => {
const callback = owners.get(entry.owner)
if (callback) {
callback(entry.key, visible)
}
}
/** 回退 count 层历史并等待 popstate;关闭按钮、提交成功及跳转均复用此方法。 */
const retreat = (count) => {
if (pending) {
return pending.promise.then(() => retreat(Math.min(count, entries.length)))
}
if (count <= 0) {
return Promise.resolve()
}
let complete = null
const promise = new Promise((resolve) => {
complete = resolve
})
pending = {promise: promise, complete: complete}
window.history.go(-count)
return promise
}
// 唯一的浏览器返回监听:从最上层开始同步关闭,前进到旧记录也不重新打开已关闭弹框。
window.addEventListener('popstate', (event) => {
const marker = event.state && event.state[stateKey]
const depth = marker && marker.session === session ? Math.max(0, marker.depth) : 0
while (entries.length > depth) {
notify(entries.pop(), false)
}
if (depth > entries.length) {
window.history.replaceState(markState(entries.length), '', window.location.href)
}
if (pending) {
const completed = pending
pending = null
completed.complete()
}
})
window.mobilePopupHistory = {
/**
* owner 为页面/组件唯一标识;sync(key, visible) 通过 $set 同步对应弹框状态。
* 返回 open/close/has/clear/unregister;关闭和清理返回 Promise,完成后才能跳转。
*/
register(owner, sync) {
owners.set(owner, sync)
return {
// 先写历史再打开弹框;正在回退时拒绝打开,避免异步 popstate 误关新弹框。
open(key) {
if (pending || entries.some((item) => item.owner === owner && item.key === key)) {
return false
}
const entry = {owner: owner, key: key}
window.history.pushState(markState(entries.length + 1), '', window.location.href)
entries.push(entry)
notify(entry, true)
return true
},
has(key) {
return entries.some((item) => item.owner === owner && item.key === key)
},
// 关闭父弹框时同时关闭其上方的子弹框,始终遵循后进先出。
close(key) {
if (pending) {
return pending.promise.then(() => this.close(key))
}
const index = entries.findIndex((item) => item.owner === owner && item.key === key)
return retreat(index < 0 ? 0 : entries.length - index)
},
clear() {
if (pending) {
return pending.promise.then(() => this.clear())
}
const index = entries.findIndex((item) => item.owner === owner)
return retreat(index < 0 ? 0 : entries.length - index)
},
// 销毁即注销回调,避免迟到的返回事件访问已销毁的 Vue 实例。
unregister() {
owners.delete(owner)
return this.clear()
}
}
},
/** 无参数;有弹框时仅关闭最上层,返回 true;否则返回 false,由页面正常返回。 */
back() {
if (pending) {
return true
}
if (!entries.length) {
return false
}
retreat(1)
return true
}
}
})()
@@ -0,0 +1,690 @@
<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: {
// 地址、日期及普通选项共用选择器状态;取消、遮罩关闭也必须退出对应历史层。
pickerStates: {
handler(states) {
if (!this.popupHistory) {
return
}
Object.keys(states).forEach((key) => {
if (states[key] && !this.popupHistory.has(key)) {
if (!this.popupHistory.open(key)) {
this.$set(this.pickerStates, key, false)
}
} else if (!states[key] && this.popupHistory.has(key)) {
this.popupHistory.close(key)
}
})
},
deep: true
},
dynamicData: {
handler(newValue) {
this.initAddressStates(newValue)
this.initFileStates(newValue)
this.$emit("updateDynamicData", newValue)
},
deep: true,
immediate: true
}
},
data() {
return {
//所有的picker状态
pickerStates: {},
popupHistory: null,
imagePreview: null,
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) {
// 普通输入框也绑定了点击事件,只有实际存在选择弹框的字段才允许打开历史层。
if (column.columnFormType !== 'SELECT') {
return
}
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) {
// 图片预览位于表单之上,也作为独立历史层,返回时先关预览再关表单。
if (!this.popupHistory) {
vant.ImagePreview([item.url])
return
}
if (!this.popupHistory.open('imagePreview')) {
return
}
this.$set(this, 'imagePreview', vant.ImagePreview({
images: [item.url],
closeOnPopstate: false,
onClose: () => this.popupHistory.close('imagePreview')
}))
} else {
window.open(item.url, '_blank')
}
}
},
created() {
if (window.mobilePopupHistory) {
this.$set(this, 'popupHistory', window.mobilePopupHistory.register('brand-train-form-' + this._uid, (key, visible) => {
if (key === 'imagePreview') {
if (!visible && this.imagePreview) {
this.imagePreview.close()
this.$set(this, 'imagePreview', null)
}
return
}
this.$set(this.pickerStates, key, visible)
}))
}
// console.log(this.dynamicData)
// this.dynamicData.forEach(v => {
// if (v.columnFormType === 'SELECT') {
// v.columnPickerName = v.columnCode+'Picker'
// }
// })
// console.log(this.dynamicData)
},
beforeDestroy() {
if (this.imagePreview) {
this.imagePreview.close()
}
if (this.popupHistory) {
this.popupHistory.unregister()
}
}
}
</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=NYNBZ-K62KI-DQJGU-U4NJ5-CSME7-ULFMU"></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",
File diff suppressed because it is too large Load Diff
@@ -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>
@@ -186,6 +186,16 @@ layout("/layouts/platform.html"){
</el-table> </el-table>
</el-dialog> </el-dialog>
<!-- 二维码弹框独立于课程列表,关闭后可继续选择其他课程。 -->
<el-dialog title="签到二维码" :visible.sync="courseCodeDialog" :close-on-click-modal="false" width="40%" append-to-body>
<div style="width: 100%;">
<img :src="courseCodeImage" alt="签到二维码" style="display: block; width: 70%; margin: auto;">
</div>
<span slot="footer" class="dialog-footer">
<el-button @click="closeCourseCode" type="primary">关 闭</el-button>
</span>
</el-dialog>
<send-msg ref="sendMsg" :url="url"></send-msg> <send-msg ref="sendMsg" :url="url"></send-msg>
<open-qr-code ref="openQRCode" :url="url"></open-qr-code> <open-qr-code ref="openQRCode" :url="url"></open-qr-code>
@@ -203,8 +213,12 @@ layout("/layouts/platform.html"){
return { return {
url: '', url: '',
courseDialog: false, courseDialog: false,
courseCodeDialog: false,
courseCodeImage: '',
trainType: '', trainType: '',
trainTypeList: [], trainTypeList: [],
welfareProjectList: [],
welfareProjectLoading: false,
campusList: [], campusList: [],
tableColumns: [ tableColumns: [
{label: '活动名称', prop: 'activityName'}, {label: '活动名称', prop: 'activityName'},
@@ -238,7 +252,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,12 +262,44 @@ 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)
let image = new Image() ? window.location.origin + '/platform/mobile/brandTrain/scanCode?courseId=' + encodeURIComponent(row.id)
image.src = content : JSON.stringify(o)
let viewer = new Viewer(image) // 每次先更新当前课程的二维码图片,再打开弹框,避免切换课程时显示上一张二维码。
viewer.show() this.$set(this, 'courseCodeImage', jrQrcode.getQrBase64(qrText))
this.$set(this, 'courseCodeDialog', true)
},
// 关闭二维码时保留课程列表,便于继续生成其他课程的签到二维码。
closeCourseCode() {
this.$set(this, 'courseCodeDialog', false)
},
// 只有全部课程签到且配置了正整数点位数的活动,才启用手机端扩展流程。
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 +393,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()
@@ -174,7 +174,7 @@
<el-table-column :label="trainType + '人数'" prop="coursePeopleNumber" width="110px"> <el-table-column :label="trainType + '人数'" prop="coursePeopleNumber" width="110px">
<template scope="{row}"> <template scope="{row}">
<el-input-number :controls="false" <el-input-number :controls="false"
:max="1000" :max="10000"
:min="1" :min="1"
:precision="0" :precision="0"
step-strictly step-strictly
@@ -279,11 +279,33 @@
</div> </div>
</div> </div>
</el-form-item> </el-form-item>
<vi-title v-if="allCourseSignEnabled" class="mt10" title="签到扩展配置"></vi-title>
<el-form-item v-if="allCourseSignEnabled" label="签到点位数量" prop="signPointCount">
<!-- Element 会将 null 夹为最小值;空配置传 undefined,避免编辑历史活动时自动启用扩展。 -->
<el-input-number :value="formData.signPointCount === null ? undefined : formData.signPointCount"
@input="value => this.$set(formData, 'signPointCount', value)"
:precision="0" :step="1" :min="1" :max="formData.courseList.length"
placeholder="请输入签到点位数量" style="width: 220px"></el-input-number>
<span style="color: #c64120; font-size: 12px;">达标点位数不能超过课程数量;不配置点位且处理方式为无时,保留原签到方式</span>
</el-form-item>
<el-form-item v-if="allCourseSignEnabled" label="处理方式" prop="signHandleType">
<el-radio-group v-model="formData.signHandleType" size="small" @change="handleActivitySignHandleTypeChange">
<el-radio-button :label="1">系统自动弹出填写信息</el-radio-button>
<el-radio-button :label="2">系统自动选择礼物或福利</el-radio-button>
<el-radio-button :label="3">无</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item v-if="allCourseSignEnabled && formData.signHandleType === 2" label="福利项目" prop="welfareProjectId">
<el-select v-model="formData.welfareProjectId" placeholder="请选择福利项目" style="width: 100%"
:loading="welfareProjectLoading" :disabled="welfareProjectLoading">
<el-option :label="item.name" :value="item.id" :key="item.id" v-for="item in welfareProjectList"></el-option>
</el-select>
</el-form-item>
</el-form> </el-form>
<el-row class="mt10" justify="end" type="flex"> <el-row class="mt10" justify="end" type="flex">
<el-button v-if="GetQueryString('id') !== ''" @click="doBack">返回</el-button> <el-button v-if="GetQueryString('id') !== ''" @click="doBack">返回</el-button>
<el-button @click="doHandle(false)" type="primary">保存</el-button> <el-button @click="doHandle(false)" type="primary" :loading="formLoading">保存</el-button>
<el-button @click="doHandle(true)" type="primary">提交</el-button> <el-button @click="doHandle(true)" type="primary" :loading="formLoading">提交</el-button>
</el-row> </el-row>
<condition :fields="fields" ref="joinCnd" :show_filter_result="true" v-model="conditionStructure"></condition> <condition :fields="fields" ref="joinCnd" :show_filter_result="true" v-model="conditionStructure"></condition>
@@ -379,8 +401,9 @@
</el-row> </el-row>
</el-dialog> </el-dialog>
<el-dialog :close-on-click-modal="false" :visible.sync="mapDialog" title="位置信息"> <el-dialog :close-on-click-modal="false" :visible.sync="mapDialog" title="位置信息"
<!--#include("/platform/trainSingUp/manage/include_activity_map.html"){}#--> @opened="onMapOpened" @close="onMapClosed">
<!--#include("/platform/brandTrainSingUp/manage/include_activity_map.html"){}#-->
</el-dialog> </el-dialog>
<el-dialog :close-on-click-modal="false" :visible.sync="signUpDialog" title="设置报名限制"> <el-dialog :close-on-click-modal="false" :visible.sync="signUpDialog" title="设置报名限制">
@@ -553,7 +576,8 @@
</el-row> </el-row>
<el-row v-if="formData.courseList[moreInfoIndex].isMobileSign === true <el-row v-if="formData.courseList[moreInfoIndex].isMobileSign === true
&& formData.courseList[moreInfoIndex].signType == 3" :gutter="50" type="flex"> && (formData.courseList[moreInfoIndex].signType == 1
|| formData.courseList[moreInfoIndex].signType == 3)" :gutter="50" type="flex">
<el-col :span="4"> <el-col :span="4">
<span>地点坐标</span> <span>地点坐标</span>
</el-col> </el-col>
@@ -562,6 +586,22 @@
</el-col> </el-col>
</el-row> </el-row>
<!-- 半径属于当前课程点位,扫码和GPS签到共用;被扫签到不使用定位范围。 -->
<el-row v-if="formData.courseList[moreInfoIndex].isMobileSign === true
&& (formData.courseList[moreInfoIndex].signType == 1
|| formData.courseList[moreInfoIndex].signType == 3)" :gutter="50" type="flex">
<el-col :span="4">
<span>点位半径</span>
</el-col>
<el-col :span="20">
<el-input-number :value="formData.courseList[moreInfoIndex].signRadius"
@input="value => this.$set(formData.courseList[moreInfoIndex], 'signRadius', value)"
:min="1" :max="2147483647" :precision="0" :step="1" size="small"></el-input-number>
<span style="margin-left: 8px">米</span>
<div class="text-muted">以签到点为中心,距离不超过此半径允许签到,默认250米。</div>
</el-col>
</el-row>
<el-row :gutter="50" type="flex"> <el-row :gutter="50" type="flex">
<el-col :span="4"> <el-col :span="4">
<span>是否领取礼品</span> <span>是否领取礼品</span>
@@ -2,11 +2,15 @@
<el-autocomplete <el-autocomplete
v-model="addressValue" style="width: 100%" v-model="addressValue" style="width: 100%"
:fetch-suggestions="querySearchAsync" :fetch-suggestions="querySearchAsync"
:trigger-on-focus="false"
:disabled="mapLoading || !signMapReady"
placeholder="请输入关键词查询地址" placeholder="请输入关键词查询地址"
@select="handleSelect"></el-autocomplete> @select="handleSelect"></el-autocomplete>
</div> </div>
<div id="signMap" style="width: 100%;height: 500px"></div> <el-alert v-if="mapError" :title="mapError" type="error" :closable="false" show-icon style="margin-bottom: 10px"></el-alert>
<!-- 固定地图定位容器,避免 loading 结束移除临时定位样式后,地图控件越出边界。 -->
<div v-if="mapDialog" id="brandTrainSignMap" v-loading="mapLoading" element-loading-text="腾讯地图加载中..."
style="position: relative !important; overflow: hidden; width: 100%; height: 500px; padding: 0; margin: 0"></div>
<el-row class="mt10" justify="end" type="flex"> <el-row class="mt10" justify="end" type="flex">
<el-button @click="mapDialog = false" type="danger">关闭</el-button> <el-button @click="closeMapDialog" type="danger">关闭</el-button>
<!-- <el-button @click="mapConfirm" type="primary">确定</el-button>-->
</el-row> </el-row>
@@ -22,6 +22,15 @@
<el-descriptions-item label="报名结束时间"> <el-descriptions-item label="报名结束时间">
{{viewData.activitySignUpEndTime}} {{viewData.activitySignUpEndTime}}
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="签到点位数量">
{{isSignExtensionEnabled(viewData) ? viewData.signPointCount : '-'}}
</el-descriptions-item>
<el-descriptions-item label="处理方式">
{{isSignExtensionEnabled(viewData) ? getActivitySignHandleTypeName(viewData.signHandleType) : '-'}}
</el-descriptions-item>
<el-descriptions-item label="福利项目">
{{isSignExtensionEnabled(viewData) && viewData.signHandleType === 2 ? getWelfareProjectName(viewData.welfareProjectId) : '-'}}
</el-descriptions-item>
</el-descriptions> </el-descriptions>
</el-tab-pane> </el-tab-pane>
<el-tab-pane :label="trainType + '信息'"> <el-tab-pane :label="trainType + '信息'">
@@ -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()