Compare commits
1
Commits
f2fda8202a
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
360ce71f0b |
+35
-15
@@ -13,7 +13,6 @@ import com.budwk.app.zhgh.activity.trainSignUp.models.TrainSignUpCourse;
|
|||||||
import com.budwk.app.zhgh.activity.trainSignUp.models.TrainSignUpActivity;
|
import com.budwk.app.zhgh.activity.trainSignUp.models.TrainSignUpActivity;
|
||||||
import com.budwk.app.zhgh.activity.trainSignUp.models.TrainSignUpUserCourse;
|
import com.budwk.app.zhgh.activity.trainSignUp.models.TrainSignUpUserCourse;
|
||||||
import com.budwk.app.zhgh.activity.trainSignUp.service.TrainSignUpActivityService;
|
import com.budwk.app.zhgh.activity.trainSignUp.service.TrainSignUpActivityService;
|
||||||
import com.budwk.app.zhgh.map.TencentLocationService;
|
|
||||||
import io.swagger.annotations.Api;
|
import io.swagger.annotations.Api;
|
||||||
import io.swagger.annotations.ApiOperation;
|
import io.swagger.annotations.ApiOperation;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
@@ -30,7 +29,6 @@ import org.nutz.lang.util.NutMap;
|
|||||||
import org.nutz.mvc.annotation.At;
|
import org.nutz.mvc.annotation.At;
|
||||||
import org.nutz.mvc.annotation.Ok;
|
import org.nutz.mvc.annotation.Ok;
|
||||||
|
|
||||||
import javax.servlet.http.HttpServletRequest;
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.math.RoundingMode;
|
import java.math.RoundingMode;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
@@ -67,15 +65,10 @@ public class TrainsignUpMineController {
|
|||||||
public void h5Index() {
|
public void h5Index() {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 打开活动签到页,并按系统开关决定是否加载腾讯地图资源。
|
|
||||||
* @param request 用于向页面及公共布局传递本次签到定位开关
|
|
||||||
*/
|
|
||||||
@At("/scan")
|
@At("/scan")
|
||||||
@SaCheckPermission("h5.trainSignUp.mine")
|
@SaCheckPermission("h5.trainSignUp.mine")
|
||||||
@Ok("beetl:/platform/zhghh5/activity/trainSignUp/scan/index.html")
|
@Ok("beetl:/platform/zhghh5/activity/trainSignUp/scan/index.html")
|
||||||
public void scan(HttpServletRequest request) {
|
public void scan() {
|
||||||
request.setAttribute("activitySignTencentMapEnabled", TencentLocationService.isActivitySignEnabled());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@At
|
@At
|
||||||
@@ -83,7 +76,6 @@ public class TrainsignUpMineController {
|
|||||||
@Aop(TransAop.READ_COMMITTED)
|
@Aop(TransAop.READ_COMMITTED)
|
||||||
@SaCheckPermission(value = {"trainSignUp.mine", "h5.trainSignUp.mine"}, mode = SaMode.OR)
|
@SaCheckPermission(value = {"trainSignUp.mine", "h5.trainSignUp.mine"}, mode = SaMode.OR)
|
||||||
@SLog(tag = "品牌活动-活动签到", msg = "主动扫码签到")
|
@SLog(tag = "品牌活动-活动签到", msg = "主动扫码签到")
|
||||||
// courseId为二维码点位ID,lat/lng为腾讯GCJ-02纬度/经度;Result返回签到结果及扩展进度。
|
|
||||||
public Result drivingScan(String courseId, Double lat, Double lng) {
|
public Result drivingScan(String courseId, Double lat, Double lng) {
|
||||||
TrainSignUpCourse extensionCourse = dao.fetch(TrainSignUpCourse.class, courseId);
|
TrainSignUpCourse extensionCourse = dao.fetch(TrainSignUpCourse.class, courseId);
|
||||||
TrainSignUpActivity extensionActivity = extensionCourse == null ? null
|
TrainSignUpActivity extensionActivity = extensionCourse == null ? null
|
||||||
@@ -98,12 +90,41 @@ public class TrainsignUpMineController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// 主动扫码签到是用户自己打开扫一扫,扫二维码签到
|
||||||
activityService.handleLegacyScanCodeSign(courseId, SecurityUtil.getUserId(), lat, lng);
|
int count = dao.count(TrainSignUpUserCourse.class, Cnd.where("courseId", "=", courseId).and("userId", "=", SecurityUtil.getUserId()));
|
||||||
return Result.success("签到成功");
|
if (count == 0) {
|
||||||
} catch (IllegalArgumentException e) {
|
return Result.error(99, "未查询到您的报名记录");
|
||||||
return Result.error(99, e.getMessage());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TrainSignUpCourse course = dao.fetch(TrainSignUpCourse.class, courseId);
|
||||||
|
List<Double> coordinates = course.getTransPosition();
|
||||||
|
int minute = course.getSignUnit() != null ? course.getSignUnit() : 0;
|
||||||
|
int radius = course.getRadius() != null ? course.getRadius() : 100;
|
||||||
|
|
||||||
|
boolean flag = true;
|
||||||
|
// 获取现在的日期,并根据设置的数据
|
||||||
|
List<TrainSignUpUserCourse> timeList = dao.query(TrainSignUpUserCourse.class, Cnd.where("courseId", "=", courseId).and("userId", "=", SecurityUtil.getUserId()));
|
||||||
|
for (TrainSignUpUserCourse userCourse : timeList) {
|
||||||
|
DateTime startTime = DateUtil.offsetMinute(userCourse.getCourseStartTime(), -minute);
|
||||||
|
DateTime endTime = DateUtil.offsetMinute(userCourse.getCourseEndTime(), minute);
|
||||||
|
if(DateUtil.isIn(DateUtil.date(), startTime, endTime)) {
|
||||||
|
userCourse.setAttend(true);
|
||||||
|
userCourse.setAttendTime(DateUtil.date());
|
||||||
|
flag = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(flag) {
|
||||||
|
return Result.error(99, "未到签到时间");
|
||||||
|
}
|
||||||
|
|
||||||
|
double distance = DistanceUtil.getDistance(coordinates.get(0), coordinates.get(1), lat, lng);
|
||||||
|
if(distance > radius) {
|
||||||
|
return Result.error(99, "签到失败,您距离签到点还有%.1f米".formatted(distance - radius));
|
||||||
|
}
|
||||||
|
|
||||||
|
dao.update(timeList);
|
||||||
|
|
||||||
|
return Result.success("签到成功");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -251,7 +272,6 @@ public class TrainsignUpMineController {
|
|||||||
}
|
}
|
||||||
TrainSignUpActivity activity = dao.fetch(TrainSignUpActivity.class, course.getActivityId());
|
TrainSignUpActivity activity = dao.fetch(TrainSignUpActivity.class, course.getActivityId());
|
||||||
NutMap data = Lang.obj2nutmap(course);
|
NutMap data = Lang.obj2nutmap(course);
|
||||||
data.put("tencentMapEnabled", TencentLocationService.isActivitySignEnabled());
|
|
||||||
data.put("signExtensionEnabled", activity != null && activity.getSignPointCount() != null
|
data.put("signExtensionEnabled", activity != null && activity.getSignPointCount() != null
|
||||||
&& activity.getSignPointCount() > 0);
|
&& activity.getSignPointCount() > 0);
|
||||||
return Result.success(data);
|
return Result.success(data);
|
||||||
|
|||||||
+2
-12
@@ -149,22 +149,12 @@ public interface TrainSignUpActivityService extends BaseService<TrainSignUpActiv
|
|||||||
*
|
*
|
||||||
* @param courseId 扫码进入的课程点位ID
|
* @param courseId 扫码进入的课程点位ID
|
||||||
* @param userId 当前登录用户ID
|
* @param userId 当前登录用户ID
|
||||||
* @param latitude 腾讯前端定位组件返回的GCJ-02纬度
|
* @param latitude 浏览器定位得到的WGS84纬度
|
||||||
* @param longitude 腾讯前端定位组件返回的GCJ-02经度
|
* @param longitude 浏览器定位得到的WGS84经度
|
||||||
* @return 最新签到进度及达标后的处理信息
|
* @return 最新签到进度及达标后的处理信息
|
||||||
*/
|
*/
|
||||||
NutMap handleScanCodeSign(String courseId, String userId, Double latitude, Double longitude);
|
NutMap handleScanCodeSign(String courseId, String userId, Double latitude, Double longitude);
|
||||||
|
|
||||||
/**
|
|
||||||
* 普通活动扫码签到,沿用报名及时间限制,使用腾讯GCJ-02定位校验签到范围。
|
|
||||||
* @param courseId 二维码中的课程点位ID
|
|
||||||
* @param userId 当前登录用户ID
|
|
||||||
* @param latitude 本次GCJ-02纬度
|
|
||||||
* @param longitude 本次GCJ-02经度
|
|
||||||
* @return 无返回值,业务校验失败抛出IllegalArgumentException
|
|
||||||
*/
|
|
||||||
void handleLegacyScanCodeSign(String courseId, String userId, Double latitude, Double longitude);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 保存签到达标后填写的动态表单。
|
* 保存签到达标后填写的动态表单。
|
||||||
*
|
*
|
||||||
|
|||||||
+5
-50
@@ -19,7 +19,6 @@ import com.budwk.app.zhgh.activity.trainSignUp.service.TrainSignUpActivityServic
|
|||||||
import com.budwk.app.zhgh.activity.trainSignUp.service.TrainSignUpActivityStatisticsService;
|
import com.budwk.app.zhgh.activity.trainSignUp.service.TrainSignUpActivityStatisticsService;
|
||||||
import com.budwk.app.zhgh.welfare.model.WelfareProject;
|
import com.budwk.app.zhgh.welfare.model.WelfareProject;
|
||||||
import com.budwk.app.zhgh.welfare.service.WelfareListService;
|
import com.budwk.app.zhgh.welfare.service.WelfareListService;
|
||||||
import com.budwk.app.zhgh.map.TencentLocationService;
|
|
||||||
import com.google.common.collect.Sets;
|
import com.google.common.collect.Sets;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.nutz.aop.interceptor.async.Async;
|
import org.nutz.aop.interceptor.async.Async;
|
||||||
@@ -624,7 +623,7 @@ public class TrainSignUpActivityServiceImpl extends BaseServiceImpl<TrainSignUpA
|
|||||||
lockScanCodeUser(userId);
|
lockScanCodeUser(userId);
|
||||||
TrainSignUpCourse course = requireScanCodeCourse(courseId, userId);
|
TrainSignUpCourse course = requireScanCodeCourse(courseId, userId);
|
||||||
TrainSignUpActivity activity = dao().fetch(TrainSignUpActivity.class, course.getActivityId());
|
TrainSignUpActivity activity = dao().fetch(TrainSignUpActivity.class, course.getActivityId());
|
||||||
requireScanCodeLocation(course.getCourseLocationCoordinates(), latitude, longitude, effectiveRadius(course));
|
requireScanCodeLocation(course.getTransPosition(), latitude, longitude, effectiveRadius(course));
|
||||||
|
|
||||||
List<TrainSignUpUserCourse> records = dao().query(TrainSignUpUserCourse.class,
|
List<TrainSignUpUserCourse> records = dao().query(TrainSignUpUserCourse.class,
|
||||||
Cnd.where("activityId", "=", activity.getId()).and("courseId", "=", courseId)
|
Cnd.where("activityId", "=", activity.getId()).and("courseId", "=", courseId)
|
||||||
@@ -780,10 +779,7 @@ public class TrainSignUpActivityServiceImpl extends BaseServiceImpl<TrainSignUpA
|
|||||||
Cnd.where("activityId", "=", activity.getId()).asc("orderNum"));
|
Cnd.where("activityId", "=", activity.getId()).asc("orderNum"));
|
||||||
List<NutMap> signPoints = courses.stream().map(item -> NutMap.NEW().addv("courseId", item.getId())
|
List<NutMap> signPoints = courses.stream().map(item -> NutMap.NEW().addv("courseId", item.getId())
|
||||||
.addv("courseName", item.getCourseName()).addv("courseLocation", item.getCourseLocation())
|
.addv("courseName", item.getCourseName()).addv("courseLocation", item.getCourseLocation())
|
||||||
.addv("signed", attended.stream().anyMatch(record -> Objects.equals(record.getCourseId(), item.getId())))
|
.addv("signed", attended.stream().anyMatch(record -> Objects.equals(record.getCourseId(), item.getId()))))
|
||||||
// 同一课程可能有多个课次,展示该点位最早的有效签到时间,兼容历史空时间。
|
|
||||||
.addv("attendTime", attended.stream().filter(record -> Objects.equals(record.getCourseId(), item.getId()))
|
|
||||||
.map(TrainSignUpUserCourse::getAttendTime).filter(Objects::nonNull).min(Date::compareTo).orElse(null)))
|
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
Map<String, Object> savedValues = new HashMap<>();
|
Map<String, Object> savedValues = new HashMap<>();
|
||||||
List<TrainSignUpUser> signUsers = dao().query(TrainSignUpUser.class,
|
List<TrainSignUpUser> signUsers = dao().query(TrainSignUpUser.class,
|
||||||
@@ -850,19 +846,12 @@ public class TrainSignUpActivityServiceImpl extends BaseServiceImpl<TrainSignUpA
|
|||||||
&& Double.isFinite(coordinates.get(0)) && Double.isFinite(coordinates.get(1));
|
&& Double.isFinite(coordinates.get(0)) && Double.isFinite(coordinates.get(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 当前定位与transPosition均为WGS84坐标,沿用原签到距离计算口径。 */
|
||||||
* coordinates为配置的GCJ-02[纬度,经度],latitude/longitude为本次腾讯定位值。
|
|
||||||
* 不与WGS84 transPosition混算;距离超出配置半径即拒绝签到。
|
|
||||||
*/
|
|
||||||
private void requireScanCodeLocation(List<Double> coordinates, Double latitude, Double longitude, int radius) {
|
private void requireScanCodeLocation(List<Double> coordinates, Double latitude, Double longitude, int radius) {
|
||||||
// 仅跳过定位与距离校验,调用方仍校验报名、课次时间并保留重复签到处理。
|
if (!validCoordinates(coordinates)) {
|
||||||
if (!TencentLocationService.isActivitySignEnabled()) return;
|
|
||||||
if (!validCoordinates(coordinates) || Math.abs(coordinates.get(0)) > 90 || Math.abs(coordinates.get(1)) > 180
|
|
||||||
|| radius <= 0) {
|
|
||||||
throw new IllegalArgumentException("当前点位未配置有效签到坐标");
|
throw new IllegalArgumentException("当前点位未配置有效签到坐标");
|
||||||
}
|
}
|
||||||
if (latitude == null || longitude == null || !Double.isFinite(latitude) || !Double.isFinite(longitude)
|
if (latitude == null || longitude == null || !Double.isFinite(latitude) || !Double.isFinite(longitude)) {
|
||||||
|| Math.abs(latitude) > 90 || Math.abs(longitude) > 180) {
|
|
||||||
throw new IllegalArgumentException("请先获取当前定位后再签到");
|
throw new IllegalArgumentException("请先获取当前定位后再签到");
|
||||||
}
|
}
|
||||||
double distance = com.budwk.app.base.utils.DistanceUtil.getDistance(coordinates.get(0), coordinates.get(1), latitude, longitude);
|
double distance = com.budwk.app.base.utils.DistanceUtil.getDistance(coordinates.get(0), coordinates.get(1), latitude, longitude);
|
||||||
@@ -871,40 +860,6 @@ public class TrainSignUpActivityServiceImpl extends BaseServiceImpl<TrainSignUpA
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 普通扫码入口接收课程、用户及GCJ-02定位;报名、范围和时间全部通过后才更新签到记录。 */
|
|
||||||
@Override
|
|
||||||
@Aop(TransAop.READ_COMMITTED)
|
|
||||||
public void handleLegacyScanCodeSign(String courseId, String userId, Double latitude, Double longitude) {
|
|
||||||
lockScanCodeUser(userId);
|
|
||||||
TrainSignUpCourse course = dao().fetch(TrainSignUpCourse.class, courseId);
|
|
||||||
if (course == null) {
|
|
||||||
throw new IllegalArgumentException("未找到签到课程");
|
|
||||||
}
|
|
||||||
List<TrainSignUpUserCourse> records = dao().query(TrainSignUpUserCourse.class,
|
|
||||||
Cnd.where("courseId", "=", courseId).and("userId", "=", userId));
|
|
||||||
if (records.isEmpty()) {
|
|
||||||
throw new IllegalArgumentException("未查询到您的报名记录");
|
|
||||||
}
|
|
||||||
requireScanCodeLocation(course.getCourseLocationCoordinates(), latitude, longitude, effectiveRadius(course));
|
|
||||||
Date now = new Date();
|
|
||||||
int minute = course.getSignUnit() == null ? 0 : course.getSignUnit();
|
|
||||||
List<TrainSignUpUserCourse> available = records.stream().filter(record ->
|
|
||||||
record.getCourseStartTime() != null && record.getCourseEndTime() != null
|
|
||||||
&& !now.before(DateUtil.offsetMinute(record.getCourseStartTime(), -minute))
|
|
||||||
&& !now.after(DateUtil.offsetMinute(record.getCourseEndTime(), minute))).toList();
|
|
||||||
if (available.isEmpty()) {
|
|
||||||
throw new IllegalArgumentException("未到签到时间");
|
|
||||||
}
|
|
||||||
for (TrainSignUpUserCourse record : available) {
|
|
||||||
// 重复请求保留首次签到时间,不改写其他课次。
|
|
||||||
if (!record.isAttend()) {
|
|
||||||
record.setAttend(true);
|
|
||||||
record.setAttendTime(now);
|
|
||||||
dao().update(record);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean isEmptyDynamicValue(Object value) {
|
private boolean isEmptyDynamicValue(Object value) {
|
||||||
return value == null || value instanceof String && StrUtil.isBlank((String) value)
|
return value == null || value instanceof String && StrUtil.isBlank((String) value)
|
||||||
|| value instanceof Collection && ((Collection<?>) value).isEmpty();
|
|| value instanceof Collection && ((Collection<?>) value).isEmpty();
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package com.budwk.app.zhgh.map;
|
|||||||
|
|
||||||
import cn.dev33.satoken.annotation.SaCheckLogin;
|
import cn.dev33.satoken.annotation.SaCheckLogin;
|
||||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||||
import cn.dev33.satoken.annotation.SaMode;
|
|
||||||
import cn.hutool.core.util.StrUtil;
|
import cn.hutool.core.util.StrUtil;
|
||||||
import cn.hutool.http.HttpUtil;
|
import cn.hutool.http.HttpUtil;
|
||||||
import cn.hutool.json.JSONUtil;
|
import cn.hutool.json.JSONUtil;
|
||||||
@@ -16,13 +15,9 @@ import io.swagger.annotations.ApiOperation;
|
|||||||
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.mvc.annotation.At;
|
import org.nutz.mvc.annotation.At;
|
||||||
import org.nutz.mvc.annotation.GET;
|
|
||||||
import org.nutz.mvc.annotation.Ok;
|
import org.nutz.mvc.annotation.Ok;
|
||||||
|
|
||||||
import javax.servlet.http.HttpServletRequest;
|
|
||||||
import javax.servlet.http.HttpServletResponse;
|
|
||||||
import javax.validation.Valid;
|
import javax.validation.Valid;
|
||||||
import java.io.IOException;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @ClassName TMapController
|
* @ClassName TMapController
|
||||||
@@ -40,9 +35,6 @@ public class TMapController {
|
|||||||
@Inject
|
@Inject
|
||||||
private SysConfigService configService;
|
private SysConfigService configService;
|
||||||
|
|
||||||
@Inject
|
|
||||||
private TencentLocationService tencentLocationService;
|
|
||||||
|
|
||||||
@At
|
@At
|
||||||
@SaCheckLogin
|
@SaCheckLogin
|
||||||
@ApiOperation("关键词输入提示")
|
@ApiOperation("关键词输入提示")
|
||||||
@@ -57,36 +49,4 @@ public class TMapController {
|
|||||||
String response = HttpUtil.get(url);
|
String response = HttpUtil.get(url);
|
||||||
return Result.success(JSONUtil.parse(response));
|
return Result.success(JSONUtil.parse(response));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 代理腾讯定位组件的坐标转换请求,异步排队领取共享调用名额。
|
|
||||||
*
|
|
||||||
* @param request 腾讯组件的单点坐标及 JSONP 回调请求
|
|
||||||
* @param response 腾讯协议的异步响应
|
|
||||||
* @throws IOException 即时错误响应写入失败时抛出
|
|
||||||
*/
|
|
||||||
@At("/ws/coord/v1/translate")
|
|
||||||
@GET
|
|
||||||
@Ok("void")
|
|
||||||
@SaCheckLogin
|
|
||||||
@SaCheckPermission(value = {"trainSignUp.mine", "h5.trainSignUp.mine"}, mode = SaMode.OR)
|
|
||||||
public void translate(HttpServletRequest request, HttpServletResponse response) throws IOException {
|
|
||||||
tencentLocationService.proxy(true, KEY, request, response);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 代理腾讯定位组件的逆地址解析请求,保留腾讯返回状态及请求编号。
|
|
||||||
*
|
|
||||||
* @param request 腾讯组件的 GCJ-02 坐标及 JSONP 回调请求
|
|
||||||
* @param response 腾讯协议的异步响应
|
|
||||||
* @throws IOException 即时错误响应写入失败时抛出
|
|
||||||
*/
|
|
||||||
@At("/ws/geocoder/v1/")
|
|
||||||
@GET
|
|
||||||
@Ok("void")
|
|
||||||
@SaCheckLogin
|
|
||||||
@SaCheckPermission(value = {"trainSignUp.mine", "h5.trainSignUp.mine"}, mode = SaMode.OR)
|
|
||||||
public void geocoder(HttpServletRequest request, HttpServletResponse response) throws IOException {
|
|
||||||
tencentLocationService.proxy(false, KEY, request, response);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
package com.budwk.app.zhgh.map;
|
|
||||||
|
|
||||||
import org.nutz.boot.starter.WebFilterFace;
|
|
||||||
import org.nutz.ioc.loader.annotation.IocBean;
|
|
||||||
|
|
||||||
import javax.servlet.DispatcherType;
|
|
||||||
import javax.servlet.Filter;
|
|
||||||
import javax.servlet.FilterRegistration;
|
|
||||||
import javax.servlet.ServletContext;
|
|
||||||
import java.util.EnumSet;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 为定位代理经过的过滤器开启 Servlet 异步支持,保留原有过滤器顺序及鉴权流程。
|
|
||||||
* NutzBoot 的 WebFilterFace 没有异步配置项,需在过滤器注册完成后补充该能力。
|
|
||||||
*/
|
|
||||||
@IocBean
|
|
||||||
public class TencentLocationAsyncSupport implements WebFilterFace {
|
|
||||||
/** @return 定位异步支持的注册名称 */
|
|
||||||
@Override
|
|
||||||
public String getName() {
|
|
||||||
return "tencentLocationAsyncSupport";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return 定位代理所在的业务路径 */
|
|
||||||
@Override
|
|
||||||
public String getPathSpec() {
|
|
||||||
return "/platform/TMap/ws/*";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return 仅处理原始请求,不重新分派鉴权链 */
|
|
||||||
@Override
|
|
||||||
public EnumSet<DispatcherType> getDispatches() {
|
|
||||||
return EnumSet.of(DispatcherType.REQUEST);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return null,仅补充注册配置,不增加或替换业务过滤器 */
|
|
||||||
@Override
|
|
||||||
public Filter getFilter() {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return 无额外过滤器参数 */
|
|
||||||
@Override
|
|
||||||
public Map<String, String> getInitParameters() {
|
|
||||||
return Map.of();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return 最后执行,确保 Nutz 和登录过滤器均已完成注册 */
|
|
||||||
@Override
|
|
||||||
public int getOrder() {
|
|
||||||
return Integer.MAX_VALUE;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 在容器启动阶段补齐定位代理过滤器链的异步标记;其他入口仍按原有同步流程执行。
|
|
||||||
*
|
|
||||||
* @param context 当前应用 Servlet 上下文
|
|
||||||
*/
|
|
||||||
@Override
|
|
||||||
public void setServletContext(ServletContext context) {
|
|
||||||
String[] paths = {"/platform/TMap/ws/coord/v1/translate", "/platform/TMap/ws/geocoder/v1/"};
|
|
||||||
for (FilterRegistration registration : context.getFilterRegistrations().values()) {
|
|
||||||
boolean matches = registration.getServletNameMappings().contains("*");
|
|
||||||
for (String pattern : registration.getUrlPatternMappings()) {
|
|
||||||
for (String path : paths) {
|
|
||||||
matches |= pattern.equals(path) || (pattern.endsWith("/*")
|
|
||||||
&& path.startsWith(pattern.substring(0, pattern.length() - 1)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (matches && registration instanceof FilterRegistration.Dynamic) {
|
|
||||||
((FilterRegistration.Dynamic) registration).setAsyncSupported(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,377 +0,0 @@
|
|||||||
package com.budwk.app.zhgh.map;
|
|
||||||
|
|
||||||
import cn.hutool.crypto.digest.DigestUtil;
|
|
||||||
import cn.hutool.json.JSONObject;
|
|
||||||
import cn.hutool.json.JSONUtil;
|
|
||||||
import com.budwk.app.web.commons.base.Globals;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.nutz.integration.jedis.RedisService;
|
|
||||||
import org.nutz.ioc.loader.annotation.Inject;
|
|
||||||
import org.nutz.ioc.loader.annotation.IocBean;
|
|
||||||
|
|
||||||
import javax.servlet.AsyncContext;
|
|
||||||
import javax.servlet.AsyncEvent;
|
|
||||||
import javax.servlet.AsyncListener;
|
|
||||||
import javax.servlet.http.HttpServletRequest;
|
|
||||||
import javax.servlet.http.HttpServletResponse;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.net.URI;
|
|
||||||
import java.net.URLEncoder;
|
|
||||||
import java.net.http.HttpClient;
|
|
||||||
import java.net.http.HttpRequest;
|
|
||||||
import java.net.http.HttpResponse;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.time.Duration;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Set;
|
|
||||||
import java.util.UUID;
|
|
||||||
import java.util.concurrent.ArrayBlockingQueue;
|
|
||||||
import java.util.concurrent.CompletableFuture;
|
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
|
||||||
import java.util.concurrent.ExecutorService;
|
|
||||||
import java.util.concurrent.Executors;
|
|
||||||
import java.util.concurrent.ScheduledExecutorService;
|
|
||||||
import java.util.concurrent.Semaphore;
|
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 腾讯定位组件的业务代理:按 Key 和接口共享滑动窗口名额,异步等待并调用腾讯服务。
|
|
||||||
* 定位期间不访问业务数据库,只有页面取得有效位置后才进入现有签到事务。
|
|
||||||
*/
|
|
||||||
@Slf4j
|
|
||||||
@IocBean(create = "init", depose = "close")
|
|
||||||
public class TencentLocationService {
|
|
||||||
private static final int MAX_PENDING = 2000;
|
|
||||||
private static final long REQUEST_TIMEOUT_MS = 20000;
|
|
||||||
// Redis 时间统一多实例计时;完成请求不归还名额,避免将每秒限额误当作在途并发数。
|
|
||||||
// Redis 3.0 禁止 TIME 后在同一 Lua 内写入,因此先读取服务端时间,再原子维护窗口。
|
|
||||||
private static final String ACQUIRE = "local now=tonumber(ARGV[2]); "
|
|
||||||
+ "local last=redis.call('ZREVRANGE',KEYS[1],0,0,'WITHSCORES'); "
|
|
||||||
+ "if #last>0 then now=math.max(now,tonumber(last[2])); end; "
|
|
||||||
+ "redis.call('ZREMRANGEBYSCORE',KEYS[1],'-inf',now-1000); "
|
|
||||||
+ "if redis.call('ZCARD',KEYS[1])<80 then "
|
|
||||||
+ "redis.call('ZADD',KEYS[1],now,ARGV[1]); redis.call('PEXPIRE',KEYS[1],2000); return 0; end; "
|
|
||||||
+ "local first=redis.call('ZRANGE',KEYS[1],0,0,'WITHSCORES'); "
|
|
||||||
+ "return math.max(1,1000-now+tonumber(first[2]));";
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
private RedisService redisService;
|
|
||||||
|
|
||||||
private final Map<String, Lane> lanes = new ConcurrentHashMap<>();
|
|
||||||
private final Set<Job> pending = ConcurrentHashMap.newKeySet();
|
|
||||||
private final Semaphore capacity = new Semaphore(MAX_PENDING);
|
|
||||||
private final Object lifecycle = new Object();
|
|
||||||
private volatile boolean closed;
|
|
||||||
private ScheduledExecutorService dispatcher;
|
|
||||||
private ExecutorService httpExecutor;
|
|
||||||
private HttpClient client;
|
|
||||||
|
|
||||||
/** 初始化共享 HTTP 客户端和队列调度器,不为每个等待请求创建线程。 */
|
|
||||||
public void init() {
|
|
||||||
httpExecutor = Executors.newFixedThreadPool(8, runnable -> {
|
|
||||||
Thread thread = new Thread(runnable, "tencent-location-http");
|
|
||||||
thread.setDaemon(true);
|
|
||||||
return thread;
|
|
||||||
});
|
|
||||||
client = HttpClient.newBuilder().executor(httpExecutor)
|
|
||||||
.connectTimeout(Duration.ofSeconds(3)).build();
|
|
||||||
dispatcher = Executors.newSingleThreadScheduledExecutor(runnable -> {
|
|
||||||
Thread thread = new Thread(runnable, "tencent-location-dispatch");
|
|
||||||
thread.setDaemon(true);
|
|
||||||
return thread;
|
|
||||||
});
|
|
||||||
dispatcher.scheduleWithFixedDelay(this::drain, 0, 10, TimeUnit.MILLISECONDS);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 接收腾讯组件的 JSONP 请求,校验后异步排队;队满或超时返回组件可识别的失败结果。
|
|
||||||
*
|
|
||||||
* @param translate true 为坐标转换,false 为逆地址解析,仅允许这两个固定接口
|
|
||||||
* @param key 服务端配置的腾讯 Key,不接受客户端替换
|
|
||||||
* @param request 已通过登录及签到权限检查的请求
|
|
||||||
* @param response 输出腾讯原始 JSON/JSONP 协议,不包装业务 Result
|
|
||||||
* @throws IOException 无法写入即时错误响应时抛出
|
|
||||||
*/
|
|
||||||
public void proxy(boolean translate, String key, HttpServletRequest request,
|
|
||||||
HttpServletResponse response) throws IOException {
|
|
||||||
String callback = request.getParameter("callback");
|
|
||||||
if (callback != null && !callback.matches("[A-Za-z_$][A-Za-z0-9_$]{0,127}")) {
|
|
||||||
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
|
|
||||||
write(response, null, error(400, "定位回调参数不合法"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// 关闭开关后,旧页面尚未完成的定位请求也不能继续调用腾讯接口。
|
|
||||||
if (!isActivitySignEnabled()) {
|
|
||||||
write(response, callback, error(403, "当前签到无需定位,请刷新页面直接签到"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
String coordinate = request.getParameter(translate ? "locations" : "location");
|
|
||||||
String type = request.getParameter("type");
|
|
||||||
if (!validCoordinate(coordinate) || (translate && (type == null || !type.matches("[1-6]")))) {
|
|
||||||
write(response, callback, error(400, "定位坐标或坐标类型不合法"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
String endpoint = translate ? "/ws/coord/v1/translate" : "/ws/geocoder/v1/";
|
|
||||||
// 只转发 SDK 实际使用的单点坐标;固定主机、服务端 Key 和 JSON 输出,禁止开放代理。
|
|
||||||
String query = "?output=json&key=" + URLEncoder.encode(key, StandardCharsets.UTF_8)
|
|
||||||
+ (translate ? "&type=" + type + "&locations=" : "&location=")
|
|
||||||
+ URLEncoder.encode(coordinate, StandardCharsets.UTF_8);
|
|
||||||
URI uri = URI.create("https://apis.map.qq.com" + endpoint + query);
|
|
||||||
String rateKey = "tencent:location:rate:" + DigestUtil.sha256Hex(key) + ":" + endpoint;
|
|
||||||
synchronized (lifecycle) {
|
|
||||||
if (closed || !request.isAsyncSupported()) {
|
|
||||||
write(response, callback, error(503, "定位代理暂不可用"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!capacity.tryAcquire()) {
|
|
||||||
write(response, callback, error(429, "定位请求排队已满,请稍后重试"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
AsyncContext context;
|
|
||||||
try {
|
|
||||||
context = request.startAsync(request, response);
|
|
||||||
} catch (RuntimeException e) {
|
|
||||||
capacity.release();
|
|
||||||
write(response, callback, error(503, "定位异步请求无法启动"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Lane lane = lanes.computeIfAbsent(rateKey, Lane::new);
|
|
||||||
Job job = new Job(context, lane, endpoint, uri, callback);
|
|
||||||
pending.add(job);
|
|
||||||
try {
|
|
||||||
context.setTimeout(REQUEST_TIMEOUT_MS);
|
|
||||||
context.addListener(new AsyncListener() {
|
|
||||||
/**
|
|
||||||
* 容器提前完成时释放队列名额,禁止继续调用上游。
|
|
||||||
* @param event 容器完成事件
|
|
||||||
*/
|
|
||||||
@Override
|
|
||||||
public void onComplete(AsyncEvent event) {
|
|
||||||
finish(job, error(499, "定位请求已结束"), false);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 超时同时取消未执行任务及在途 HTTP 请求。
|
|
||||||
* @param event 容器超时事件
|
|
||||||
*/
|
|
||||||
@Override
|
|
||||||
public void onTimeout(AsyncEvent event) {
|
|
||||||
finish(job, error(504, "定位代理等待超时,请重新定位"), true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 客户端连接错误后不继续消耗腾讯调用额度。
|
|
||||||
* @param event 客户端连接错误事件
|
|
||||||
*/
|
|
||||||
@Override
|
|
||||||
public void onError(AsyncEvent event) {
|
|
||||||
finish(job, error(499, "定位连接已断开"), false);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 本代理直接完成响应,不执行二次异步分派。
|
|
||||||
* @param event 异步分派事件
|
|
||||||
*/
|
|
||||||
@Override
|
|
||||||
public void onStartAsync(AsyncEvent event) {
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (!lane.queue.offer(job)) {
|
|
||||||
finish(job, error(429, "定位请求排队已满,请稍后重试"), true);
|
|
||||||
}
|
|
||||||
} catch (RuntimeException e) {
|
|
||||||
finish(job, error(503, "定位请求排队失败"), true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 读取品牌活动签到定位开关,页面、签到校验和腾讯代理使用相同判断。
|
|
||||||
*
|
|
||||||
* @return 仅明确配置为 false 时关闭,参数缺失时保留原有定位校验
|
|
||||||
*/
|
|
||||||
public static boolean isActivitySignEnabled() {
|
|
||||||
String value = Globals.MyConfig.getString("AppActivitySignTencentMap");
|
|
||||||
return value == null || !"false".equalsIgnoreCase(value.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean validCoordinate(String value) {
|
|
||||||
if (value == null || value.length() > 80) return false;
|
|
||||||
String[] parts = value.split(",", -1);
|
|
||||||
if (parts.length != 2) return false;
|
|
||||||
try {
|
|
||||||
double lat = Double.parseDouble(parts[0]);
|
|
||||||
double lng = Double.parseDouble(parts[1]);
|
|
||||||
return Double.isFinite(lat) && Double.isFinite(lng) && Math.abs(lat) <= 90 && Math.abs(lng) <= 180;
|
|
||||||
} catch (NumberFormatException e) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void drain() {
|
|
||||||
if (closed) return;
|
|
||||||
for (Lane lane : lanes.values()) {
|
|
||||||
if (System.nanoTime() < lane.nextAttempt) continue;
|
|
||||||
// 每个接口只由队头申请名额,避免数百个任务同时轮询 Redis。
|
|
||||||
for (int i = 0; i < 80 && !closed; i++) {
|
|
||||||
Job job = lane.queue.peek();
|
|
||||||
if (job == null) break;
|
|
||||||
if (job.done) {
|
|
||||||
lane.queue.remove(job);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (elapsedMillis(job.createdAt) >= REQUEST_TIMEOUT_MS) {
|
|
||||||
finish(job, error(504, "定位代理等待超时,请重新定位"), true);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
List<String> time = redisService.time();
|
|
||||||
long now = Long.parseLong(time.get(0)) * 1000 + Long.parseLong(time.get(1)) / 1000;
|
|
||||||
long wait = ((Number) redisService.eval(ACQUIRE, List.of(lane.rateKey),
|
|
||||||
List.of(job.id, Long.toString(now)))).longValue();
|
|
||||||
if (wait > 0) {
|
|
||||||
lane.nextAttempt = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(wait);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
lane.queue.remove(job);
|
|
||||||
send(job);
|
|
||||||
} catch (Exception e) {
|
|
||||||
// Redis 故障不能退化为直连,否则多个实例会共同击穿腾讯每秒额度。
|
|
||||||
log.warn("腾讯定位调度失败 requestId={} endpoint={} errorType={}",
|
|
||||||
job.id, job.endpoint, e.getClass().getSimpleName());
|
|
||||||
finish(job, error(503, "定位调度暂不可用,请稍后重试"), true);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void send(Job job) {
|
|
||||||
synchronized (job) {
|
|
||||||
if (job.done) return;
|
|
||||||
if (!isActivitySignEnabled()) {
|
|
||||||
finish(job, error(403, "当前签到无需定位,请刷新页面直接签到"), true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
long remaining = REQUEST_TIMEOUT_MS - elapsedMillis(job.createdAt);
|
|
||||||
if (closed || remaining <= 0) {
|
|
||||||
finish(job, error(504, "定位代理等待超时,请重新定位"), true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
job.startedAt = System.nanoTime();
|
|
||||||
HttpRequest request = HttpRequest.newBuilder(job.uri)
|
|
||||||
.timeout(Duration.ofMillis(Math.min(5000, remaining))).GET().build();
|
|
||||||
job.future = client.sendAsync(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
|
||||||
job.future.whenComplete((response, failure) -> {
|
|
||||||
if (failure != null) {
|
|
||||||
finish(job, error(502, "腾讯定位接口调用失败或超时"), true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
if (response.statusCode() != 200) {
|
|
||||||
finish(job, error(502, "腾讯定位接口响应异常"), true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
JSONObject body = JSONUtil.parseObj(response.body());
|
|
||||||
if (body.getInt("status") == null) throw new IllegalArgumentException("Missing status");
|
|
||||||
finish(job, body, true);
|
|
||||||
} catch (RuntimeException e) {
|
|
||||||
finish(job, error(502, "腾讯定位接口返回格式异常"), true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void finish(Job job, JSONObject body, boolean reply) {
|
|
||||||
CompletableFuture<?> future;
|
|
||||||
synchronized (job) {
|
|
||||||
if (job.done) return;
|
|
||||||
job.done = true;
|
|
||||||
future = job.future;
|
|
||||||
}
|
|
||||||
if (future != null && !future.isDone()) future.cancel(true);
|
|
||||||
job.lane.queue.remove(job);
|
|
||||||
try {
|
|
||||||
long queueMs = job.startedAt == 0 ? elapsedMillis(job.createdAt)
|
|
||||||
: TimeUnit.NANOSECONDS.toMillis(job.startedAt - job.createdAt);
|
|
||||||
// 不记录 Key、请求 URL 或经纬度;腾讯 request_id 可与浏览器定位结果关联。
|
|
||||||
log.info("腾讯定位代理 requestId={} endpoint={} queueMs={} apiMs={} totalMs={} status={} tencentRequestId={}",
|
|
||||||
job.id, job.endpoint, queueMs, job.startedAt == 0 ? 0 : elapsedMillis(job.startedAt),
|
|
||||||
elapsedMillis(job.createdAt), body.getInt("status"), body.getStr("request_id", ""));
|
|
||||||
if (reply) write((HttpServletResponse) job.context.getResponse(), job.callback, body);
|
|
||||||
} catch (IOException | IllegalStateException e) {
|
|
||||||
log.debug("腾讯定位响应连接已结束 requestId={}", job.id);
|
|
||||||
} finally {
|
|
||||||
pending.remove(job);
|
|
||||||
capacity.release();
|
|
||||||
try {
|
|
||||||
job.context.complete();
|
|
||||||
} catch (IllegalStateException ignored) {
|
|
||||||
// 容器的超时或连接关闭回调可能已完成同一请求。
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static long elapsedMillis(long start) {
|
|
||||||
return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static JSONObject error(int status, String message) {
|
|
||||||
return new JSONObject().set("status", status).set("message", message);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void write(HttpServletResponse response, String callback, JSONObject body) throws IOException {
|
|
||||||
response.setCharacterEncoding("UTF-8");
|
|
||||||
response.setContentType(callback == null ? "application/json;charset=UTF-8" : "application/javascript;charset=UTF-8");
|
|
||||||
response.setHeader("Cache-Control", "no-store");
|
|
||||||
response.setHeader("X-Content-Type-Options", "nosniff");
|
|
||||||
String json = JSONUtil.toJsonStr(body);
|
|
||||||
byte[] bytes = (callback == null ? json : callback + "(" + json + ");").getBytes(StandardCharsets.UTF_8);
|
|
||||||
response.setContentLength(bytes.length);
|
|
||||||
response.getOutputStream().write(bytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 应用关闭时取消排队及在途请求,释放异步响应和线程资源。 */
|
|
||||||
public void close() {
|
|
||||||
Job[] jobs;
|
|
||||||
synchronized (lifecycle) {
|
|
||||||
closed = true;
|
|
||||||
if (dispatcher != null) dispatcher.shutdownNow();
|
|
||||||
jobs = pending.toArray(new Job[0]);
|
|
||||||
}
|
|
||||||
for (Job job : jobs) finish(job, error(503, "定位服务正在关闭"), true);
|
|
||||||
if (httpExecutor != null) httpExecutor.shutdownNow();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static class Lane {
|
|
||||||
private final String rateKey;
|
|
||||||
private final ArrayBlockingQueue<Job> queue = new ArrayBlockingQueue<>(MAX_PENDING);
|
|
||||||
private long nextAttempt;
|
|
||||||
|
|
||||||
private Lane(String rateKey) {
|
|
||||||
this.rateKey = rateKey;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static class Job {
|
|
||||||
private final String id = UUID.randomUUID().toString();
|
|
||||||
private final long createdAt = System.nanoTime();
|
|
||||||
private final AsyncContext context;
|
|
||||||
private final Lane lane;
|
|
||||||
private final String endpoint;
|
|
||||||
private final URI uri;
|
|
||||||
private final String callback;
|
|
||||||
private volatile boolean done;
|
|
||||||
private long startedAt;
|
|
||||||
private CompletableFuture<HttpResponse<String>> future;
|
|
||||||
|
|
||||||
private Job(AsyncContext context, Lane lane, String endpoint, URI uri, String callback) {
|
|
||||||
this.context = context;
|
|
||||||
this.lane = lane;
|
|
||||||
this.endpoint = endpoint;
|
|
||||||
this.uri = uri;
|
|
||||||
this.callback = callback;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
INSERT INTO `sys_config`
|
|
||||||
(`configKey`, `configValue`, `note`, `createdBy`, `createdAt`, `updatedBy`, `updatedAt`, `delFlag`)
|
|
||||||
SELECT
|
|
||||||
'AppActivitySignTencentMap', 'true',
|
|
||||||
'活动签到腾讯位置服务开关:true开启,false关闭',
|
|
||||||
'', UNIX_TIMESTAMP() * 1000, '', UNIX_TIMESTAMP() * 1000, 0
|
|
||||||
WHERE NOT EXISTS (
|
|
||||||
SELECT 1 FROM `sys_config`
|
|
||||||
WHERE `configKey` = 'AppActivitySignTencentMap'
|
|
||||||
);
|
|
||||||
@@ -347,6 +347,19 @@ module.exports = {
|
|||||||
|
|
||||||
// 预览图片
|
// 预览图片
|
||||||
handlePreview(file) {
|
handlePreview(file) {
|
||||||
|
if (this.upload_mode === "image") {
|
||||||
|
// 回显记录的名称可能是无扩展名的下载地址;图片模式直接预览缩略图地址。
|
||||||
|
const url = file.url || (this.upload_result_type === "url" && file.response?.data)
|
||||||
|
if (!url) {
|
||||||
|
this.$message.warning("暂无可预览的图片")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const image = new Image()
|
||||||
|
image.src = url
|
||||||
|
const viewer = new Viewer(image, {zIndex: 99999, title: false, hidden: () => viewer.destroy()})
|
||||||
|
viewer.show()
|
||||||
|
return
|
||||||
|
}
|
||||||
const suffix = file.name.substring(file.name.lastIndexOf(".") + 1).toLowerCase()
|
const suffix = file.name.substring(file.name.lastIndexOf(".") + 1).toLowerCase()
|
||||||
const fileId = file.response.data.substring(file.response.data.lastIndexOf("=") + 1)
|
const fileId = file.response.data.substring(file.response.data.lastIndexOf("=") + 1)
|
||||||
const buildFile = {
|
const buildFile = {
|
||||||
|
|||||||
@@ -77,9 +77,7 @@
|
|||||||
src="https://webapi.amap.com/maps?v=2.0&key=57f0d098ba1b881ecc436c4cfd23bbbf&plugin=AMap.PolyEditor,AMap.Geolocation"
|
src="https://webapi.amap.com/maps?v=2.0&key=57f0d098ba1b881ecc436c4cfd23bbbf&plugin=AMap.PolyEditor,AMap.Geolocation"
|
||||||
></script>-->
|
></script>-->
|
||||||
|
|
||||||
<!--# if (activitySignTencentMapEnabled!true) { #-->
|
|
||||||
<script src="https://map.qq.com/api/gljs?v=2.exp&key=NYNBZ-K62KI-DQJGU-U4NJ5-CSME7-ULFMU"></script>
|
<script src="https://map.qq.com/api/gljs?v=2.exp&key=NYNBZ-K62KI-DQJGU-U4NJ5-CSME7-ULFMU"></script>
|
||||||
<!--# } #-->
|
|
||||||
<script nonce="${cspNonce!}">
|
<script nonce="${cspNonce!}">
|
||||||
Vue.config.devtools = true
|
Vue.config.devtools = true
|
||||||
|
|
||||||
|
|||||||
@@ -1,439 +1,70 @@
|
|||||||
<!--#
|
<!--#
|
||||||
layout("/layouts/platform_h5.html"){
|
layout("/layouts/platform_h5.html"){
|
||||||
#-->
|
#-->
|
||||||
<style>
|
<style scoped>
|
||||||
/* 沿用点位签到组件的纵向布局:地图伸展,信息和操作保留自身高度,小屏可滚动。 */
|
.sign-page { min-height: 100vh; background: #f5f6f8; }
|
||||||
#app.brand-train-sign-page {
|
.sign-map { width: 100%; height: 42vh; background: #e8edf2; }
|
||||||
display: flex;
|
.sign-panel { padding: 18px 16px 28px; background: #fff; }
|
||||||
height: 100vh;
|
.sign-title { margin: 0 0 6px; font-size: 20px; font-weight: 600; text-align: center; }
|
||||||
height: 100dvh;
|
.sign-location { color: #7d8490; font-size: 13px; text-align: center; }
|
||||||
width: 100%;
|
.sign-progress { margin: 20px 0 8px; display: flex; align-items: center; justify-content: space-between; }
|
||||||
min-width: 0;
|
.sign-progress strong { color: #0878d1; font-size: 18px; }
|
||||||
min-height: 0;
|
.sign-message { min-height: 24px; margin: 16px 0; color: #606773; text-align: center; }
|
||||||
flex-direction: column;
|
.sign-message.success { color: #07c160; }
|
||||||
overflow-x: hidden;
|
.sign-message.error { color: #ee0a24; }
|
||||||
overflow-y: auto;
|
.sign-actions { display: grid; gap: 12px; }
|
||||||
background: #f4f7fc;
|
.point-list { max-height: 54vh; overflow-y: auto; }
|
||||||
}
|
.point-row { display: flex; justify-content: space-between; padding: 14px 16px; border-bottom: 1px solid #f0f1f3; }
|
||||||
|
.point-row small { display: block; margin-top: 4px; color: #969799; }
|
||||||
.brand-train-sign-page > .van-nav-bar__placeholder {
|
.point-state { color: #969799; }
|
||||||
flex-shrink: 0;
|
.point-state.signed { color: #07c160; }
|
||||||
}
|
.form-popup { max-height: 62vh; overflow-y: auto; }
|
||||||
|
.popup-footer { padding: 12px 16px calc(12px + env(safe-area-inset-bottom)); }
|
||||||
/* 仅本页沿用参考签到页的亮蓝主题,不改变其他业务页面的全局 Vant 配色。 */
|
.legacy-result { min-height: 52vh; padding-top: 36px; background: #fff; }
|
||||||
.brand-train-sign-page .van-nav-bar__text,
|
|
||||||
.brand-train-sign-page .van-nav-bar .van-icon,
|
|
||||||
.brand-train-sign-page .van-tag--primary {
|
|
||||||
color: #1989fa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-train-sign-page .van-button--info {
|
|
||||||
border-color: #1989fa;
|
|
||||||
background: #1989fa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-train-sign-page .van-button--info.van-button--plain {
|
|
||||||
background: #fff;
|
|
||||||
color: #1989fa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-train-sign-content {
|
|
||||||
display: flex;
|
|
||||||
flex: 1 0 auto;
|
|
||||||
min-width: 0;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-train-sign-map {
|
|
||||||
/* 固定 SDK 容器定位,防止地图控件溢出到信息区和底部按钮。 */
|
|
||||||
position: relative !important;
|
|
||||||
z-index: 0;
|
|
||||||
flex: 1 0 240px;
|
|
||||||
width: 100%;
|
|
||||||
min-height: 240px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-train-sign-details {
|
|
||||||
flex-shrink: 0;
|
|
||||||
min-width: 0;
|
|
||||||
max-width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
padding: 16px;
|
|
||||||
background: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-train-sign-title {
|
|
||||||
color: #263548;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 24px;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-train-sign-title-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-train-sign-signed {
|
|
||||||
color: #07c160;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-train-sign-signed .van-icon {
|
|
||||||
margin-right: 4px;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-train-sign-location-tools {
|
|
||||||
display: flex;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-train-sign-location-tools .van-button {
|
|
||||||
flex-shrink: 0;
|
|
||||||
height: 32px;
|
|
||||||
padding: 0 2px;
|
|
||||||
border: 0;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-train-sign-location-status {
|
|
||||||
color: #78869a;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 18px;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-train-sign-location-status.is-in-range {
|
|
||||||
color: #263548;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-train-sign-location-status.is-in-range::before {
|
|
||||||
display: inline-block;
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
margin-right: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #07c160;
|
|
||||||
content: '';
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-train-sign-text {
|
|
||||||
margin-top: 6px;
|
|
||||||
color: #78869a;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 20px;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-train-sign-text .van-icon {
|
|
||||||
margin-right: 4px;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-train-sign-progress {
|
|
||||||
min-width: 0;
|
|
||||||
max-width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
margin-top: 14px;
|
|
||||||
padding: 12px;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: #f4f7fc;
|
|
||||||
color: #78869a;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-train-sign-progress-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 4px 8px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-train-sign-progress-heading {
|
|
||||||
color: #263548;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-train-sign-record-link {
|
|
||||||
display: inline-flex;
|
|
||||||
min-height: 32px;
|
|
||||||
margin-left: auto;
|
|
||||||
padding: 0;
|
|
||||||
align-items: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
border: 0;
|
|
||||||
background: transparent;
|
|
||||||
color: #1989fa;
|
|
||||||
font-size: 13px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-train-sign-count {
|
|
||||||
margin: 0 3px;
|
|
||||||
color: #1989fa;
|
|
||||||
font-size: 22px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-train-sign-progress-track {
|
|
||||||
/* 使用容器百分比宽度,避免旧组件缓存像素宽度后在屏幕尺寸变化时越出卡片。 */
|
|
||||||
width: 100%;
|
|
||||||
height: 8px;
|
|
||||||
margin-top: 10px;
|
|
||||||
overflow: hidden;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #dce3ee;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-train-sign-progress-fill {
|
|
||||||
display: block;
|
|
||||||
height: 100%;
|
|
||||||
max-width: 100%;
|
|
||||||
border-radius: inherit;
|
|
||||||
background: #1989fa;
|
|
||||||
transition: width 0.25s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-train-sign-progress-message {
|
|
||||||
margin-top: 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-train-sign-map-warning {
|
|
||||||
color: #ee0a24;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-train-sign-footer {
|
|
||||||
/* 状态留在信息区,底栏仅容纳操作;双按钮平分宽度,单按钮居中且不铺满屏幕。 */
|
|
||||||
position: sticky;
|
|
||||||
bottom: 0;
|
|
||||||
z-index: 3;
|
|
||||||
display: flex;
|
|
||||||
flex-shrink: 0;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 12px 24px;
|
|
||||||
padding-bottom: calc(12px + env(safe-area-inset-bottom));
|
|
||||||
border-top: 1px solid #edf0f5;
|
|
||||||
background: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-train-sign-footer .van-button {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
height: 40px;
|
|
||||||
padding: 0 8px;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-train-sign-footer .van-button:only-child {
|
|
||||||
flex: 0 1 180px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-train-sign-points-popup {
|
|
||||||
/* 从右侧滑入并覆盖整个视口;导航和底部提示保留高度,点位卡片区域独立滚动。 */
|
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
height: 100dvh;
|
|
||||||
flex-direction: column;
|
|
||||||
box-sizing: border-box;
|
|
||||||
overflow: hidden;
|
|
||||||
background: #f5f7fa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-train-sign-points-popup > .van-nav-bar {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-train-sign-points-list {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
padding: 16px;
|
|
||||||
overflow-y: auto;
|
|
||||||
overscroll-behavior: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-train-sign-point {
|
|
||||||
margin-bottom: 12px;
|
|
||||||
padding: 16px;
|
|
||||||
border: 1px solid #e9edf4;
|
|
||||||
border-radius: 12px;
|
|
||||||
background: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-train-sign-point:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-train-sign-point .brand-train-sign-title {
|
|
||||||
font-size: 18px;
|
|
||||||
line-height: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-train-sign-point .brand-train-sign-text {
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-train-sign-point-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-train-sign-point-header .brand-train-sign-title-row {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
gap: 4px 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-train-sign-point-status {
|
|
||||||
flex-shrink: 0;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #fff7e9;
|
|
||||||
color: #d98a13;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 20px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-train-sign-point-status.is-signed {
|
|
||||||
background: #e8fbf2;
|
|
||||||
color: #07c160;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-train-sign-point-status .van-icon {
|
|
||||||
margin-right: 4px;
|
|
||||||
font-size: 14px;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-train-sign-point-time {
|
|
||||||
margin-top: 12px;
|
|
||||||
padding-top: 12px;
|
|
||||||
border-top: 1px solid #edf0f5;
|
|
||||||
color: #78869a;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-train-sign-point-time-value {
|
|
||||||
display: block;
|
|
||||||
margin-top: 4px;
|
|
||||||
color: #3b4b60;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 20px;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-train-sign-points-note {
|
|
||||||
flex-shrink: 0;
|
|
||||||
padding: 12px 16px;
|
|
||||||
padding-bottom: calc(12px + env(safe-area-inset-bottom));
|
|
||||||
border-top: 1px solid #edf0f5;
|
|
||||||
background: #fff;
|
|
||||||
color: #78869a;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 18px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-popup { padding: 16px; overflow-y: auto; }
|
|
||||||
.popup-footer { padding: 12px 16px calc(12px + env(safe-area-inset-bottom)); background: #fff; }
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div id="app" class="brand-train-sign-page" v-cloak>
|
<div id="app" class="sign-page">
|
||||||
<van-nav-bar title="活动签到" left-text="返回" left-arrow @click-left="onNavBack" fixed placeholder></van-nav-bar>
|
<van-nav-bar title="活动签到" left-text="返回" left-arrow @click-left="onNavBack" fixed placeholder></van-nav-bar>
|
||||||
<div class="brand-train-sign-content">
|
<div id="mapContainer" class="sign-map"></div>
|
||||||
<div v-if="tencentMapEnabled" id="mapContainer" class="brand-train-sign-map"></div>
|
<template v-if="extensionEnabled">
|
||||||
<section class="brand-train-sign-details">
|
<section class="sign-panel">
|
||||||
<div v-if="tencentMapEnabled" class="brand-train-sign-location-tools">
|
<h1 class="sign-title">{{signData.activityName || '活动签到'}}</h1>
|
||||||
<span class="brand-train-sign-location-status" role="status"
|
<div class="sign-location">{{signData.courseName}}<span v-if="signData.courseLocation"> · {{signData.courseLocation}}</span></div>
|
||||||
:class="{'brand-train-sign-map-warning': locationFailed || distance !== null && !isInRange,
|
<div class="sign-progress"><span>签到进度</span><strong>{{signData.signCount || 0}} / {{signData.signPointCount || 0}}</strong></div>
|
||||||
'is-in-range': !locationFailed && !locating && isInRange}">{{locationMessage}}</span>
|
<van-progress :percentage="progressPercentage" color="#07c160" track-color="#e9edf1" stroke-width="8"></van-progress>
|
||||||
<van-button size="small" plain type="info" icon="aim"
|
<div class="sign-message" :class="messageType">{{statusMessage}}</div>
|
||||||
:loading="locating || mapLoading" :disabled="signLoading || courseLoading"
|
<div class="sign-actions">
|
||||||
@click="refreshPosition">{{mapReady ? '重新定位' : '重新加载地图'}}</van-button>
|
<van-button type="primary" block :loading="signLoading" :disabled="signLoading || signData.currentPointSigned" @click="onSign">
|
||||||
|
{{signData.currentPointSigned ? '当前点位已签到' : '立即签到'}}
|
||||||
|
</van-button>
|
||||||
|
<van-button plain type="info" block @click="openPopup('points')">查看全部签到点位</van-button>
|
||||||
|
<van-button v-if="signData.needDynamicForm" type="info" block @click="openPopup('form')">填写达标信息</van-button>
|
||||||
|
<van-button v-if="signData.needWelfareSelect" type="warning" block @click="goWelfare">选择礼物或福利</van-button>
|
||||||
</div>
|
</div>
|
||||||
<div class="brand-train-sign-title-row">
|
|
||||||
<div class="brand-train-sign-title">{{signData.courseName || row.courseName || '活动签到'}}</div>
|
|
||||||
<span v-if="currentPointSigned" class="brand-train-sign-signed" role="status">
|
|
||||||
<van-icon name="checked"></van-icon>当前点位已签到
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="tencentMapEnabled" class="brand-train-sign-text">签到范围:{{signRadius}} 米</div>
|
|
||||||
<div v-if="tencentMapEnabled && distance !== null" class="brand-train-sign-text">距签到地点约 {{Math.ceil(distance)}} 米</div>
|
|
||||||
<div v-if="extensionEnabled" class="brand-train-sign-progress">
|
|
||||||
<div class="brand-train-sign-progress-header">
|
|
||||||
<span class="brand-train-sign-progress-heading">活动签到进度</span>
|
|
||||||
<van-tag v-if="signData.isReachTarget" plain type="success">活动已达标</van-tag>
|
|
||||||
<button type="button" class="brand-train-sign-record-link" @click="openPopup('points')">
|
|
||||||
点位打卡情况<van-icon name="arrow"></van-icon>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div>已打卡 <strong class="brand-train-sign-count">{{signData.signCount || 0}}</strong> 个 / 达标需 {{signData.signPointCount || 0}} 个</div>
|
|
||||||
<div class="brand-train-sign-progress-track" role="progressbar" aria-label="活动签到进度"
|
|
||||||
aria-valuemin="0" aria-valuemax="100" :aria-valuenow="progressPercentage">
|
|
||||||
<span class="brand-train-sign-progress-fill" :style="{width: progressPercentage + '%'}"></span>
|
|
||||||
</div>
|
|
||||||
<div class="brand-train-sign-progress-message" role="status"
|
|
||||||
:class="{'brand-train-sign-map-warning': messageType === 'error'}">{{progressMessage}}</div>
|
|
||||||
</div>
|
|
||||||
<div v-else class="brand-train-sign-text">{{legacyResult.msg}}</div>
|
|
||||||
</section>
|
</section>
|
||||||
<div v-if="showSignButton || showDynamicFormButton || signData.needWelfareSelect" class="brand-train-sign-footer">
|
</template>
|
||||||
<van-button v-if="showDynamicFormButton" :plain="showSignButton" type="info"
|
<div v-else class="weui-msg legacy-result">
|
||||||
:disabled="signLoading || formLoading" @click="openPopup('form')">填写详细信息</van-button>
|
<div class="weui-msg__icon-area">
|
||||||
<van-button v-if="signData.needWelfareSelect" :plain="showSignButton" type="info"
|
<i v-if="legacyResult.code !== 0" class="weui-icon-warn weui-icon_msg"></i>
|
||||||
:disabled="signLoading || formLoading" @click="goWelfare">去选择福利</van-button>
|
<i v-else class="weui-icon-success weui-icon_msg"></i>
|
||||||
<van-button v-if="showSignButton" type="info" loading-text="签到中..."
|
|
||||||
:loading="signLoading" :disabled="signDisabled" @click="onSign">确认签到</van-button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="weui-msg__text-area"><div class="weui-msg__title">温馨提醒</div><div>{{legacyResult.msg || '正在获取签到信息'}}</div></div>
|
||||||
|
<div class="weui-msg__opr-area"><p class="weui-btn-area">
|
||||||
|
<a v-if="legacyResult.code !== 0" @click="onSign" class="weui-btn weui-btn_default">重新签到</a>
|
||||||
|
<a v-else class="weui-btn weui-btn_primary">您已签到</a>
|
||||||
|
</p></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<van-popup :value="pointPopup" class="brand-train-sign-points-popup" position="right"
|
<van-popup v-model="pointPopup" position="bottom" round :close-on-click-overlay="false" :style="{height: '62vh'}">
|
||||||
@input="visible => { if (!visible) closePopup('points') }" :close-on-click-overlay="false">
|
<van-nav-bar title="签到点位" left-text="关闭" left-arrow @click-left="closePopup('points')"></van-nav-bar>
|
||||||
<van-nav-bar title="点位打卡情况" left-text="返回" left-arrow @click-left="closePopup('points')"></van-nav-bar>
|
<div class="point-list">
|
||||||
<div ref="signPointsList" class="brand-train-sign-points-list">
|
<div class="point-row" v-for="point in signData.signPoints" :key="point.courseId">
|
||||||
<van-empty v-if="!displaySignPoints.length" description="暂无点位信息"></van-empty>
|
<div>{{point.courseName}}<small>{{point.courseLocation || '未填写地点'}}</small></div>
|
||||||
<div v-for="point in displaySignPoints" :key="point.courseId" class="brand-train-sign-point">
|
<span class="point-state" :class="{signed: point.signed}">{{point.signed ? '已签到' : '未签到'}}</span>
|
||||||
<div class="brand-train-sign-point-header">
|
|
||||||
<div class="brand-train-sign-title-row">
|
|
||||||
<div class="brand-train-sign-title">{{point.courseName || '点位名称未填写'}}</div>
|
|
||||||
<van-tag v-if="point.courseId === courseId" plain type="primary">当前点位</van-tag>
|
|
||||||
</div>
|
|
||||||
<span class="brand-train-sign-point-status" :class="{'is-signed': point.signed === true}">
|
|
||||||
<van-icon :name="point.signed === true ? 'checked' : 'clock-o'"></van-icon>{{point.signed === true ? '已打卡' : '未打卡'}}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="brand-train-sign-text"><van-icon name="location-o"></van-icon>地点:{{point.courseLocation || '未填写地点名称'}}</div>
|
|
||||||
<div v-if="point.signed === true" class="brand-train-sign-point-time">
|
|
||||||
签到时间
|
|
||||||
<span class="brand-train-sign-point-time-value">{{formatAttendTime(point.attendTime)}}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="brand-train-sign-points-note">
|
|
||||||
{{signData.isReachTarget ? '活动已达标,仍可继续打卡其他点位' : '完成 ' + (signData.signPointCount || 0) + ' 个不同点位打卡即可达标'}}
|
|
||||||
</div>
|
|
||||||
</van-popup>
|
</van-popup>
|
||||||
<van-popup :value="formPopup" @input="visible => { if (!visible) closePopup('form') }" position="bottom" round :close-on-click-overlay="false" :style="{height: '78vh'}">
|
<van-popup v-model="formPopup" position="bottom" round :close-on-click-overlay="false" :style="{height: '78vh'}">
|
||||||
<van-nav-bar title="填写签到信息" left-text="关闭" left-arrow @click-left="closePopup('form')"></van-nav-bar>
|
<van-nav-bar title="填写签到信息" left-text="关闭" left-arrow @click-left="closePopup('form')"></van-nav-bar>
|
||||||
<div class="form-popup"><train-sign-dynamic-form ref="dynamicForm" :dynamic-data="signData.dynamicColumnsData || []"></train-sign-dynamic-form></div>
|
<div class="form-popup"><train-sign-dynamic-form ref="dynamicForm" :dynamic-data="signData.dynamicColumnsData || []"></train-sign-dynamic-form></div>
|
||||||
<div class="popup-footer"><van-button type="primary" block :loading="formLoading" :disabled="formLoading" @click="saveDynamicForm">保存</van-button></div>
|
<div class="popup-footer"><van-button type="primary" block :loading="formLoading" :disabled="formLoading" @click="saveDynamicForm">保存</van-button></div>
|
||||||
@@ -441,10 +72,6 @@ layout("/layouts/platform_h5.html"){
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script nonce="${cspNonce!}">
|
<script nonce="${cspNonce!}">
|
||||||
// 腾讯实例与异步回调不进入Vue响应式系统,销毁时统一释放。
|
|
||||||
const scanRuntime = {map: null, markers: null, circle: null, locator: null,
|
|
||||||
destroyed: false, requestId: 0, cancelLocation: null, scripts: {},
|
|
||||||
nonce: document.currentScript && document.currentScript.nonce}
|
|
||||||
const vue = new Vue({
|
const vue = new Vue({
|
||||||
el: "#app",
|
el: "#app",
|
||||||
store,
|
store,
|
||||||
@@ -453,375 +80,92 @@ layout("/layouts/platform_h5.html"){
|
|||||||
return {
|
return {
|
||||||
ownerId: "train-sign-scan-" + Date.now(), courseId: GetQueryString("courseId"), row: {}, signData: {},
|
ownerId: "train-sign-scan-" + Date.now(), courseId: GetQueryString("courseId"), row: {}, signData: {},
|
||||||
extensionEnabled: false, legacyResult: {code: 1, msg: "正在获取签到信息"},
|
extensionEnabled: false, legacyResult: {code: 1, msg: "正在获取签到信息"},
|
||||||
tencentMapEnabled: ${activitySignTencentMapEnabled!true},
|
|
||||||
statusMessage: "请获取定位后完成签到", messageType: "", signLoading: false, formLoading: false,
|
statusMessage: "请获取定位后完成签到", messageType: "", signLoading: false, formLoading: false,
|
||||||
pointPopup: false, formPopup: false,
|
pointPopup: false, formPopup: false, markerLayer: null, circle: null, map: null
|
||||||
mapReady: false, mapLoading: false, locating: false, courseLoading: true,
|
|
||||||
locationMessage: "正在加载签到地点", locationFailed: false, currentPosition: null, distance: null
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
currentPointSigned() {
|
|
||||||
return this.extensionEnabled ? this.signData.currentPointSigned === true : this.legacyResult.code === 0
|
|
||||||
},
|
|
||||||
showSignButton() {
|
|
||||||
return !this.courseLoading && !this.currentPointSigned
|
|
||||||
},
|
|
||||||
showDynamicFormButton() {
|
|
||||||
return this.signData.isReachTarget === true && this.signData.needDynamicForm === true
|
|
||||||
},
|
|
||||||
isInRange() {
|
|
||||||
return this.distance !== null && this.distance <= this.signRadius
|
|
||||||
},
|
|
||||||
// 达标与当前点位签到分别提示;错误优先,保留接口返回的处理失败原因。
|
|
||||||
progressMessage() {
|
|
||||||
if (this.messageType === "error") return this.statusMessage
|
|
||||||
if (this.signData.isReachTarget && !this.currentPointSigned) return "已达标,可继续打卡其他点位。"
|
|
||||||
if (this.signData.isReachTarget) return this.statusMessage
|
|
||||||
return this.currentPointSigned ? "请继续完成其他点位签到。"
|
|
||||||
: this.tencentMapEnabled ? "到达点位后,点击确认签到。" : "请点击确认签到。"
|
|
||||||
},
|
|
||||||
// 未打卡项优先,组内保留接口中的点位序号,不能改动原数组。
|
|
||||||
displaySignPoints() {
|
|
||||||
const points = Array.isArray(this.signData.signPoints) ? this.signData.signPoints : []
|
|
||||||
return points.filter(point => point.signed !== true).concat(points.filter(point => point.signed === true))
|
|
||||||
},
|
|
||||||
// 本项目点位为GCJ-02[纬度,经度],不能使用参考工程的[经度,纬度]下标。
|
|
||||||
sitePosition() {
|
|
||||||
const p = this.row.courseLocationCoordinates
|
|
||||||
return Array.isArray(p) && p.length === 2 && p.every(v => v !== null && v !== "" && Number.isFinite(Number(v)))
|
|
||||||
&& Math.abs(Number(p[0])) <= 90 && Math.abs(Number(p[1])) <= 180
|
|
||||||
? {lat: Number(p[0]), lng: Number(p[1])} : null
|
|
||||||
},
|
|
||||||
signRadius() {
|
|
||||||
const r = this.row.radius == null ? 100 : Number(this.row.radius)
|
|
||||||
return Number.isFinite(r) && r > 0 ? r : 0
|
|
||||||
},
|
|
||||||
signDisabled() {
|
|
||||||
return this.courseLoading || this.mapLoading || this.locating || this.signLoading || !this.row.id
|
|
||||||
|| this.tencentMapEnabled && (!this.mapReady || !this.currentPosition || this.distance === null
|
|
||||||
|| this.distance > this.signRadius)
|
|
||||||
|| this.extensionEnabled && this.signData.currentPointSigned === true
|
|
||||||
},
|
|
||||||
progressPercentage() {
|
progressPercentage() {
|
||||||
if (!this.signData.signPointCount) return 0
|
if (!this.signData.signPointCount) return 0
|
||||||
return Math.min(100, Math.round((this.signData.signCount || 0) * 100 / this.signData.signPointCount))
|
return Math.min(100, Math.round((this.signData.signCount || 0) * 100 / this.signData.signPointCount))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
/** attendTime为首次签到时间;返回可读时间,历史数据缺失时显示未记录。 */
|
|
||||||
formatAttendTime(attendTime) {
|
|
||||||
const time = attendTime ? this.$moment(attendTime) : null
|
|
||||||
return time && time.isValid() ? time.format("YYYY-MM-DD HH:mm:ss") : "未记录"
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* 复用布局中的腾讯地图Key;定位组件仅在扫码页按需加载。
|
|
||||||
* 返回SDK就绪Promise,失败时移除脚本,允许重试并处理页面销毁。
|
|
||||||
*/
|
|
||||||
loadTencentSdk() {
|
|
||||||
const mapScript = Array.from(document.scripts).find(item => item.src.indexOf("map.qq.com/api/gljs") >= 0)
|
|
||||||
const key = mapScript ? new URL(mapScript.src).searchParams.get("key") : ""
|
|
||||||
if (!key) return Promise.reject(new Error("未找到腾讯地图Key配置"))
|
|
||||||
const load = (name, src, ready) => {
|
|
||||||
if (ready()) return Promise.resolve()
|
|
||||||
if (scanRuntime.scripts[name]) return scanRuntime.scripts[name].promise
|
|
||||||
const state = {script: document.createElement("script"), promise: null, cancel: null}
|
|
||||||
scanRuntime.scripts[name] = state
|
|
||||||
state.promise = new Promise((resolve, reject) => {
|
|
||||||
let finished = false
|
|
||||||
let timer
|
|
||||||
const finish = error => {
|
|
||||||
if (finished) return
|
|
||||||
finished = true
|
|
||||||
window.clearTimeout(timer)
|
|
||||||
state.script.onload = null
|
|
||||||
state.script.onerror = null
|
|
||||||
state.cancel = null
|
|
||||||
if (error) {
|
|
||||||
state.script.remove()
|
|
||||||
delete scanRuntime.scripts[name]
|
|
||||||
reject(error)
|
|
||||||
} else resolve()
|
|
||||||
}
|
|
||||||
state.cancel = () => finish(new Error("页面已关闭"))
|
|
||||||
state.script.src = src
|
|
||||||
state.script.async = true
|
|
||||||
if (scanRuntime.nonce) state.script.nonce = scanRuntime.nonce
|
|
||||||
state.script.onload = () => finish(ready() ? null : new Error("腾讯定位组件未正确加载"))
|
|
||||||
state.script.onerror = () => finish(new Error("腾讯地图服务加载失败,请检查网络"))
|
|
||||||
timer = window.setTimeout(() => finish(new Error("腾讯地图服务加载超时")), 15000)
|
|
||||||
document.head.appendChild(state.script)
|
|
||||||
})
|
|
||||||
return state.promise
|
|
||||||
}
|
|
||||||
return Promise.all([
|
|
||||||
load("map", mapScript.src, () => window.TMap && TMap.Map && TMap.MultiMarker && TMap.LatLngBounds),
|
|
||||||
load("location", "https://mapapi.qq.com/web/lbs/h5-components/geolocation/geolocation.min.js",
|
|
||||||
() => window.LBS && LBS.WebComponent && LBS.WebComponent.Geolocation)
|
|
||||||
]).then(() => key)
|
|
||||||
},
|
|
||||||
/** 地图标记使用静态文字区分地点与本人,below决定标签显示在坐标下方或上方。 */
|
|
||||||
markerStyle(label, color, below) {
|
|
||||||
const y = below ? 12 : 36
|
|
||||||
const top = below ? 25 : 1
|
|
||||||
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="80" height="50">'
|
|
||||||
+ '<rect x="2" y="' + top + '" width="76" height="22" rx="8" fill="' + color + '"/>'
|
|
||||||
+ '<text x="40" y="' + (top + 15) + '" text-anchor="middle" font-family="sans-serif" font-size="12" fill="white">'
|
|
||||||
+ label + '</text><circle cx="40" cy="' + y + '" r="6" fill="' + color + '" stroke="white" stroke-width="2"/></svg>'
|
|
||||||
return new TMap.MarkerStyle({width: 80, height: 50, anchor: {x: 40, y: y},
|
|
||||||
src: "data:image/svg+xml;charset=UTF-8," + encodeURIComponent(svg)})
|
|
||||||
},
|
|
||||||
/** 只初始化地图及定位预览,不提交签到。SDK失败可由重新加载按钮重试。 */
|
|
||||||
initMap() {
|
initMap() {
|
||||||
if (!this.tencentMapEnabled || scanRuntime.destroyed || this.mapLoading) return Promise.resolve()
|
if (this.map) return
|
||||||
if (this.mapReady) return this.refreshPosition()
|
if (!this.row.courseLocationCoordinates || this.row.courseLocationCoordinates.length < 2) {
|
||||||
if (!this.sitePosition || !this.signRadius) {
|
this.statusMessage = "当前点位未设置地图坐标"; this.messageType = "error"; return
|
||||||
this.$set(this, "locationFailed", true)
|
|
||||||
this.$set(this, "locationMessage", "当前点位未设置有效坐标或签到半径,请联系管理员")
|
|
||||||
return Promise.resolve()
|
|
||||||
}
|
}
|
||||||
this.$set(this, "mapLoading", true)
|
const position = this.row.courseLocationCoordinates
|
||||||
return this.loadTencentSdk().then(key => new Promise(resolve => this.$nextTick(() => resolve(key))))
|
const center = new TMap.LatLng(position[0], position[1])
|
||||||
.then(key => {
|
this.map = new TMap.Map("mapContainer", {zoom: 16, center: center})
|
||||||
if (scanRuntime.destroyed) return
|
this.markerLayer = new TMap.MultiMarker({
|
||||||
const p = this.sitePosition
|
map: this.map,
|
||||||
const center = new TMap.LatLng(p.lat, p.lng)
|
styles: {
|
||||||
scanRuntime.map = new TMap.Map("mapContainer", {zoom: 16, center: center})
|
target: new TMap.MarkerStyle({width: 36, height: 48}),
|
||||||
scanRuntime.markers = new TMap.MultiMarker({
|
current: new TMap.MarkerStyle({src: "https://mapapi.qq.com/web/bundles/lbs-home/prod/assets/markerActive-NhH9NoBV.png", width: 36, height: 48})
|
||||||
map: scanRuntime.map,
|
},
|
||||||
styles: {target: this.markerStyle("签到地点", "#ee0a24", false),
|
geometries: [{id: "target", styleId: "target", position: center}]
|
||||||
targetBelow: this.markerStyle("签到地点", "#ee0a24", true),
|
})
|
||||||
current: this.markerStyle("我的位置", "#1989fa", true),
|
this.circle = new TMap.MultiCircle({map: this.map, geometries: [{center: center, radius: this.row.radius || this.signData.signRadius || 100}]})
|
||||||
currentAbove: this.markerStyle("我的位置", "#1989fa", false)},
|
|
||||||
geometries: [{id: "target", styleId: "target", position: center}]
|
|
||||||
})
|
|
||||||
scanRuntime.circle = new TMap.MultiCircle({map: scanRuntime.map,
|
|
||||||
geometries: [{id: "range", center: center, radius: this.signRadius}]})
|
|
||||||
// 腾讯组件仍负责真实定位,两步 WebService 请求经业务代理统一排队,避免直连突破共享额度。
|
|
||||||
scanRuntime.locator = new LBS.WebComponent.Geolocation({key: key, referer: "zhgh_zjipc-trainSignUp",
|
|
||||||
domain: window.location.origin + "/platform/TMap"})
|
|
||||||
this.$set(this, "mapReady", true)
|
|
||||||
return this.refreshPosition()
|
|
||||||
}).catch(error => {
|
|
||||||
if (scanRuntime.destroyed) return
|
|
||||||
this.destroyMap()
|
|
||||||
this.$set(this, "locationFailed", true)
|
|
||||||
this.$set(this, "locationMessage", error.message || "地图加载失败,请重新加载")
|
|
||||||
}).finally(() => {
|
|
||||||
if (!scanRuntime.destroyed) this.$set(this, "mapLoading", false)
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
/** lat/lng均为GCJ-02,本方法返回到签到地点的米数,与后端采用相同地球半径。 */
|
|
||||||
distanceFromSite(coords) {
|
|
||||||
const site = this.sitePosition
|
|
||||||
const rad = value => value * Math.PI / 180
|
|
||||||
const dlat = rad(coords.lat - site.lat)
|
|
||||||
const dlng = rad(coords.lng - site.lng)
|
|
||||||
const a = Math.sin(dlat / 2) ** 2 + Math.cos(rad(site.lat)) * Math.cos(rad(coords.lat)) * Math.sin(dlng / 2) ** 2
|
|
||||||
return 6371000 * 2 * Math.atan2(Math.sqrt(Math.min(1, a)), Math.sqrt(Math.max(0, 1 - a)))
|
|
||||||
},
|
|
||||||
/** coords为GCJ-02 {lat,lng};更新两处标记并适配视野,不改变任何签到记录。 */
|
|
||||||
fitMap(coords) {
|
|
||||||
const p = this.sitePosition
|
|
||||||
// 北侧点的标签向上,南侧点向下,避免近距离签到时两个名称互相遮挡。
|
|
||||||
const currentAbove = coords.lat > p.lat
|
|
||||||
scanRuntime.markers.remove(["target", "current"])
|
|
||||||
scanRuntime.markers.add([
|
|
||||||
{id: "target", styleId: currentAbove ? "targetBelow" : "target", position: new TMap.LatLng(p.lat, p.lng)},
|
|
||||||
{id: "current", styleId: currentAbove ? "currentAbove" : "current", position: new TMap.LatLng(coords.lat, coords.lng)}
|
|
||||||
])
|
|
||||||
// 两点较远时也保留边距,避免本人位置和签到地点标签被地图边缘裁切。
|
|
||||||
const latPad = Math.max(this.signRadius / 111320, Math.abs(p.lat - coords.lat) * 0.2, 0.0003)
|
|
||||||
const lngPad = Math.max(latPad / Math.max(Math.cos(p.lat * Math.PI / 180), 0.000001),
|
|
||||||
Math.abs(p.lng - coords.lng) * 0.2)
|
|
||||||
scanRuntime.map.fitBounds(new TMap.LatLngBounds(
|
|
||||||
new TMap.LatLng(Math.max(-90, Math.min(p.lat, coords.lat) - latPad), Math.max(-180, Math.min(p.lng, coords.lng) - lngPad)),
|
|
||||||
new TMap.LatLng(Math.min(90, Math.max(p.lat, coords.lat) + latPad), Math.min(180, Math.max(p.lng, coords.lng) + lngPad))))
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* 返回本次GCJ-02 {lat,lng};严格高精度模式不允许IP兜底,失败清除旧位置。
|
|
||||||
* 超时、权限、额度及坐标转换错误给出提示;晚到回调和离页回调不更新页面。
|
|
||||||
*/
|
|
||||||
getUserLocation() {
|
getUserLocation() {
|
||||||
if (!this.mapReady || !scanRuntime.locator) return Promise.reject(new Error("定位组件尚未就绪"))
|
return new Promise((resolve) => {
|
||||||
if (this.locating) return Promise.reject(new Error("正在定位,请稍候"))
|
if (!navigator.geolocation) { this.$toast("浏览器不支持定位"); resolve(null); return }
|
||||||
this.$set(this, "locating", true)
|
navigator.geolocation.getCurrentPosition((position) => {
|
||||||
this.$set(this, "locationFailed", false)
|
const coords = {lat: position.coords.latitude, lng: position.coords.longitude}
|
||||||
this.$set(this, "currentPosition", null)
|
if (this.markerLayer) {
|
||||||
this.$set(this, "distance", null)
|
this.markerLayer.remove(["current"])
|
||||||
this.$set(this, "locationMessage", "正在获取您的当前位置")
|
const converted = this.$coordinateUtil.wgs84ToGcj02(coords.lng, coords.lat)
|
||||||
scanRuntime.markers.remove(["current"])
|
this.markerLayer.add([{id: "current", styleId: "current", position: new TMap.LatLng(converted[0], converted[1])}])
|
||||||
const requestId = ++scanRuntime.requestId
|
|
||||||
const locationStarted = performance.now()
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
let completed = false
|
|
||||||
let timer
|
|
||||||
const finish = (error, result) => {
|
|
||||||
if (completed) return
|
|
||||||
completed = true
|
|
||||||
window.clearTimeout(timer)
|
|
||||||
scanRuntime.cancelLocation = null
|
|
||||||
if (scanRuntime.destroyed || requestId !== scanRuntime.requestId) {
|
|
||||||
reject(new Error("页面已关闭")); return
|
|
||||||
}
|
}
|
||||||
if (error) { reject(error); return }
|
resolve(coords)
|
||||||
try {
|
}, () => { this.$toast("定位失败,请检查定位权限后重试"); resolve(null) }, {enableHighAccuracy: true, timeout: 8000})
|
||||||
if (!result || result.type === "ip" || !Number.isFinite(result.lat) || !Number.isFinite(result.lng)
|
|
||||||
|| Math.abs(result.lat) > 90 || Math.abs(result.lng) > 180) {
|
|
||||||
throw new Error("未获得有效的高精度位置,请重新定位")
|
|
||||||
}
|
|
||||||
const coords = {lat: result.lat, lng: result.lng}
|
|
||||||
const distance = this.distanceFromSite(coords)
|
|
||||||
if (!Number.isFinite(distance)) throw new Error("距离计算失败,请重新定位")
|
|
||||||
this.$set(this, "currentPosition", coords)
|
|
||||||
this.$set(this, "distance", distance)
|
|
||||||
this.$set(this, "locationMessage", distance <= this.signRadius ? "已进入签到范围" : "尚未进入签到范围")
|
|
||||||
this.fitMap(coords)
|
|
||||||
// 定位阶段包含设备取位、坐标转换和逆地址解析,不记录本人经纬度。
|
|
||||||
console.info("签到阶段耗时", {stage: "location", requestId: requestId,
|
|
||||||
elapsedMs: Math.round(performance.now() - locationStarted), success: true,
|
|
||||||
tencentRequestId: result.gcoder_request_id})
|
|
||||||
resolve(coords)
|
|
||||||
} catch (err) { reject(err) }
|
|
||||||
}
|
|
||||||
scanRuntime.cancelLocation = () => finish(new Error("页面已关闭"))
|
|
||||||
if (window.isSecureContext === false) {
|
|
||||||
finish(new Error("当前页面不是安全环境,请使用HTTPS地址定位")); return
|
|
||||||
}
|
|
||||||
// 为共享额度排队预留时间;外层兜底晚于 SDK 截止,保留其具体失败原因。
|
|
||||||
timer = window.setTimeout(() => finish(new Error("定位超时,请检查网络和定位权限后重试")), 30000)
|
|
||||||
try {
|
|
||||||
scanRuntime.locator.getLocation(result => finish(null, result), detail => {
|
|
||||||
const status = Number(detail && detail.status)
|
|
||||||
const message = status === 10101 ? "未获得定位权限,请在手机设置中允许定位"
|
|
||||||
: status === 10002 || status === 10103 ? "定位超时,请重新定位"
|
|
||||||
: "腾讯定位失败,请检查权限、网络及地图服务配置后重试"
|
|
||||||
// 只记录错误诊断,不记录本人经纬度。
|
|
||||||
console.warn("腾讯签到定位失败", {status: status, message: detail && detail.message, logs: detail && detail.logs})
|
|
||||||
finish(new Error(message))
|
|
||||||
}, {timeout: 25000, highAccuracy: true, maximumAge: 0})
|
|
||||||
} catch (error) { finish(new Error("腾讯定位调用失败,请重新加载地图")) }
|
|
||||||
}).catch(error => {
|
|
||||||
console.warn("签到阶段耗时", {stage: "location", requestId: requestId,
|
|
||||||
elapsedMs: Math.round(performance.now() - locationStarted), success: false})
|
|
||||||
if (!scanRuntime.destroyed) {
|
|
||||||
this.$set(this, "locationFailed", true)
|
|
||||||
this.$set(this, "locationMessage", error.message)
|
|
||||||
}
|
|
||||||
throw error
|
|
||||||
}).finally(() => {
|
|
||||||
if (!scanRuntime.destroyed) this.$set(this, "locating", false)
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
/** 重新定位仅更新当前位置和距离;地图尚未就绪时重新加载SDK。 */
|
|
||||||
refreshPosition() {
|
|
||||||
if (!this.tencentMapEnabled || scanRuntime.destroyed || this.locating || this.signLoading) return Promise.resolve()
|
|
||||||
if (!this.mapReady) return this.initMap()
|
|
||||||
return this.getUserLocation().catch(() => {})
|
|
||||||
},
|
|
||||||
/** 释放WebGL地图及相关图层,失败重试或离页均调用,不触碰业务数据。 */
|
|
||||||
destroyMap() {
|
|
||||||
if (scanRuntime.map) scanRuntime.map.destroy()
|
|
||||||
scanRuntime.map = null
|
|
||||||
scanRuntime.markers = null
|
|
||||||
scanRuntime.circle = null
|
|
||||||
scanRuntime.locator = null
|
|
||||||
if (!scanRuntime.destroyed) this.$set(this, "mapReady", false)
|
|
||||||
},
|
|
||||||
loadCourse() {
|
loadCourse() {
|
||||||
if (!this.courseId) {
|
this.$axios.post("/platform/trainSignUp/mine/fetchCourse", {courseId: this.courseId}).then((resp) => {
|
||||||
this.$set(this, "courseLoading", false)
|
if (resp.code !== 0) { this.legacyResult = resp; return }
|
||||||
this.$set(this, "locationMessage", "未获取到签到点位,请重新扫描二维码")
|
this.row = resp.data
|
||||||
return
|
this.extensionEnabled = resp.data.signExtensionEnabled === true
|
||||||
}
|
if (this.extensionEnabled) this.loadSignData()
|
||||||
return this.$axios.post("/platform/trainSignUp/mine/fetchCourse", {courseId: this.courseId})
|
else { this.initMap(); this.onSign() }
|
||||||
.then(resp => {
|
})
|
||||||
if (scanRuntime.destroyed) return
|
|
||||||
if (resp.code !== 0) {
|
|
||||||
this.$set(this, "legacyResult", resp)
|
|
||||||
this.$set(this, "locationMessage", resp.msg)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.$set(this, "row", resp.data)
|
|
||||||
this.$set(this, "tencentMapEnabled", resp.data.tencentMapEnabled !== false)
|
|
||||||
this.$set(this, "extensionEnabled", resp.data.signExtensionEnabled === true)
|
|
||||||
// 普通活动同样只预览,禁止进入页面就自动写入签到。
|
|
||||||
if (this.extensionEnabled) return this.loadSignData()
|
|
||||||
this.$set(this, "legacyResult", {code: 1, msg: this.tencentMapEnabled
|
|
||||||
? "到达点位后,请点击确认签到" : "请点击确认签到"})
|
|
||||||
return this.initMap()
|
|
||||||
}).catch(() => {
|
|
||||||
if (!scanRuntime.destroyed) this.$set(this, "locationMessage", "获取签到信息失败,请重新进入页面")
|
|
||||||
}).finally(() => {
|
|
||||||
if (!scanRuntime.destroyed) this.$set(this, "courseLoading", false)
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
loadSignData() {
|
loadSignData() {
|
||||||
return this.$axios.post("/platform/trainSignUp/mine/getSignData", {courseId: this.courseId}).then((resp) => {
|
this.$axios.post("/platform/trainSignUp/mine/getSignData", {courseId: this.courseId}).then((resp) => {
|
||||||
if (scanRuntime.destroyed) return
|
if (resp.code !== 0) { this.statusMessage = resp.msg; this.messageType = "error"; return }
|
||||||
if (resp.code !== 0) {
|
this.signData = resp.data
|
||||||
this.$set(this, "statusMessage", resp.msg)
|
this.statusMessage = resp.data.handleMsg || "请完成当前点位签到"
|
||||||
this.$set(this, "messageType", "error")
|
this.messageType = resp.data.isReachTarget ? "success" : ""
|
||||||
return
|
this.initMap()
|
||||||
}
|
|
||||||
this.$set(this, "signData", resp.data)
|
|
||||||
this.$set(this, "statusMessage", resp.data.handleMsg || "请完成当前点位签到")
|
|
||||||
this.$set(this, "messageType", resp.data.isReachTarget ? "success" : "")
|
|
||||||
return this.initMap()
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
onSign() {
|
onSign() {
|
||||||
if (this.signDisabled || scanRuntime.destroyed) return
|
if (this.signLoading) return
|
||||||
this.$set(this, "signLoading", true)
|
this.signLoading = true
|
||||||
// 开启定位时重新取位;关闭时只提交课次,由后端按同一系统开关处理签到。
|
this.getUserLocation().then((coords) => {
|
||||||
const location = this.tencentMapEnabled ? this.getUserLocation() : Promise.resolve(null)
|
if (!coords) return null
|
||||||
return location.then((coords) => {
|
return this.$axios.post("/platform/trainSignUp/mine/drivingScan", {courseId: this.courseId, lat: coords.lat, lng: coords.lng})
|
||||||
if (scanRuntime.destroyed) return null
|
|
||||||
if (coords && this.distance > this.signRadius) throw new Error("尚未进入签到范围,请到达地点后重试")
|
|
||||||
const signStarted = performance.now()
|
|
||||||
const requestId = scanRuntime.requestId
|
|
||||||
const payload = {courseId: this.courseId}
|
|
||||||
if (coords) { payload.lat = coords.lat; payload.lng = coords.lng }
|
|
||||||
return this.$axios.post("/platform/trainSignUp/mine/drivingScan", payload)
|
|
||||||
.then(resp => {
|
|
||||||
console.info("签到阶段耗时", {stage: "sign", requestId: requestId,
|
|
||||||
elapsedMs: Math.round(performance.now() - signStarted), code: resp.code})
|
|
||||||
return resp
|
|
||||||
}, error => {
|
|
||||||
console.warn("签到阶段耗时", {stage: "sign", requestId: requestId,
|
|
||||||
elapsedMs: Math.round(performance.now() - signStarted), success: false})
|
|
||||||
throw error
|
|
||||||
})
|
|
||||||
}).then((resp) => {
|
}).then((resp) => {
|
||||||
if (!resp || scanRuntime.destroyed) return
|
if (!resp) return
|
||||||
if (!this.extensionEnabled) { this.$set(this, "legacyResult", resp); return }
|
if (!this.extensionEnabled) { this.legacyResult = resp; return }
|
||||||
if (resp.code !== 0) {
|
if (resp.code !== 0) { this.statusMessage = resp.msg; this.messageType = "error"; return }
|
||||||
this.$set(this, "statusMessage", resp.msg)
|
this.signData = resp.data
|
||||||
this.$set(this, "messageType", "error")
|
this.statusMessage = resp.data.message || resp.data.handleMsg || resp.msg
|
||||||
return
|
this.messageType = "success"
|
||||||
}
|
|
||||||
this.$set(this, "signData", resp.data)
|
|
||||||
this.$set(this, "statusMessage", resp.data.message || resp.data.handleMsg || resp.msg)
|
|
||||||
this.$set(this, "messageType", "success")
|
|
||||||
if (resp.data.needDynamicForm) this.openPopup("form")
|
if (resp.data.needDynamicForm) this.openPopup("form")
|
||||||
}).catch(error => {
|
}).finally(() => { this.signLoading = false })
|
||||||
if (!scanRuntime.destroyed) this.$toast(error.msg || error.message || "签到失败,请重试")
|
|
||||||
}).finally(() => {
|
|
||||||
if (!scanRuntime.destroyed) this.$set(this, "signLoading", false)
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
openPopup(name) {
|
openPopup(name) {
|
||||||
if (name === "points") this.$set(this, "pointPopup", true)
|
if (name === "points") this.pointPopup = true
|
||||||
if (name === "form") this.$set(this, "formPopup", true)
|
if (name === "form") this.formPopup = true
|
||||||
window.mobilePopupHistory.open(this.ownerId, name)
|
window.mobilePopupHistory.open(this.ownerId, name)
|
||||||
if (name === "points") {
|
|
||||||
this.$nextTick(() => { if (this.$refs.signPointsList) this.$refs.signPointsList.scrollTop = 0 })
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
closePopup(name) { window.mobilePopupHistory.close(this.ownerId, name) },
|
closePopup(name) { window.mobilePopupHistory.close(this.ownerId, name) },
|
||||||
closePopupFromHistory(name) {
|
closePopupFromHistory(name) {
|
||||||
if (name === "points") this.$set(this, "pointPopup", false)
|
if (name === "points") this.pointPopup = false
|
||||||
if (name === "form") this.$set(this, "formPopup", false)
|
if (name === "form") this.formPopup = false
|
||||||
},
|
},
|
||||||
onNavBack() {
|
onNavBack() {
|
||||||
if (window.mobilePopupHistory.hasOpen(this.ownerId)) window.history.back()
|
if (window.mobilePopupHistory.hasOpen(this.ownerId)) window.history.back()
|
||||||
@@ -830,13 +174,13 @@ layout("/layouts/platform_h5.html"){
|
|||||||
saveDynamicForm() {
|
saveDynamicForm() {
|
||||||
if (this.formLoading) return
|
if (this.formLoading) return
|
||||||
this.$refs.dynamicForm.validate().then(() => {
|
this.$refs.dynamicForm.validate().then(() => {
|
||||||
this.$set(this, "formLoading", true)
|
this.formLoading = true
|
||||||
return this.$axios.post("/platform/trainSignUp/mine/saveDynamicForm", {
|
this.$axios.post("/platform/trainSignUp/mine/saveDynamicForm", {
|
||||||
courseId: this.courseId, mobileColumnsValue: JSON.stringify(this.signData.dynamicColumnsData || [])
|
courseId: this.courseId, mobileColumnsValue: JSON.stringify(this.signData.dynamicColumnsData || [])
|
||||||
}).then((resp) => {
|
}).then((resp) => {
|
||||||
if (resp.code === 0) { this.$toast.success(resp.msg || "保存成功"); this.closePopup("form"); this.loadSignData() }
|
if (resp.code === 0) { this.$toast.success(resp.msg || "保存成功"); this.closePopup("form"); this.loadSignData() }
|
||||||
else this.$toast.fail(resp.msg)
|
else this.$toast.fail(resp.msg)
|
||||||
}).finally(() => { if (!scanRuntime.destroyed) this.$set(this, "formLoading", false) })
|
}).finally(() => { this.formLoading = false })
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
},
|
},
|
||||||
goWelfare() {
|
goWelfare() {
|
||||||
@@ -848,14 +192,7 @@ layout("/layouts/platform_h5.html"){
|
|||||||
window.mobilePopupHistory.register(this.ownerId, (name) => this.closePopupFromHistory(name))
|
window.mobilePopupHistory.register(this.ownerId, (name) => this.closePopupFromHistory(name))
|
||||||
this.loadCourse()
|
this.loadCourse()
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() { window.mobilePopupHistory.unregister(this.ownerId) }
|
||||||
window.mobilePopupHistory.unregister(this.ownerId)
|
|
||||||
scanRuntime.destroyed = true
|
|
||||||
scanRuntime.requestId += 1
|
|
||||||
if (scanRuntime.cancelLocation) scanRuntime.cancelLocation()
|
|
||||||
Object.values(scanRuntime.scripts).forEach(state => { if (state.cancel) state.cancel() })
|
|
||||||
this.destroyMap()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
<!--#
|
<!--#
|
||||||
|
|||||||
@@ -707,9 +707,8 @@ layout("/layouts/platform_h5.html"){
|
|||||||
:key="option.id"
|
:key="option.id"
|
||||||
class="welfare-option"
|
class="welfare-option"
|
||||||
:class="{ selected: projectInfo.isCheckBox === 'radio' ? selectedRadioId === option.id : option.selectNum > 0 }"
|
:class="{ selected: projectInfo.isCheckBox === 'radio' ? selectedRadioId === option.id : option.selectNum > 0 }"
|
||||||
@click="projectInfo.isCheckBox === 'radio' && !isDeadlinePassed ? selectRadioOption(option.id) : null"
|
|
||||||
>
|
>
|
||||||
<div class="welfare-option-image">
|
<div class="welfare-option-image" @click.stop="previewOptionImage(option.imgUrl)">
|
||||||
<van-image :src="option.imgUrl" fit="cover" width="100%" height="100%" radius="4px"></van-image>
|
<van-image :src="option.imgUrl" fit="cover" width="100%" height="100%" radius="4px"></van-image>
|
||||||
<div class="welfare-tag"
|
<div class="welfare-tag"
|
||||||
v-if="projectInfo.isCheckBox === 'radio' ? selectedRadioId === option.id : option.selectNum > 0">
|
v-if="projectInfo.isCheckBox === 'radio' ? selectedRadioId === option.id : option.selectNum > 0">
|
||||||
@@ -717,7 +716,8 @@ layout("/layouts/platform_h5.html"){
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="welfare-option-content">
|
<div class="welfare-option-content"
|
||||||
|
@click="projectInfo.isCheckBox === 'radio' && !isDeadlinePassed ? selectRadioOption(option.id) : null">
|
||||||
<div class="welfare-option-title">{{ option.optionName }}</div>
|
<div class="welfare-option-title">{{ option.optionName }}</div>
|
||||||
|
|
||||||
<!-- 查看详情按钮 -->
|
<!-- 查看详情按钮 -->
|
||||||
@@ -846,6 +846,10 @@ layout("/layouts/platform_h5.html"){
|
|||||||
</div>
|
</div>
|
||||||
</van-action-sheet>
|
</van-action-sheet>
|
||||||
|
|
||||||
|
<van-image-preview :value="showOptionImagePreview" :images="optionPreviewImages"
|
||||||
|
closeable :show-index="false" :close-on-popstate="false"
|
||||||
|
@input="visible => { if (!visible) closeOptionImagePreview() }"></van-image-preview>
|
||||||
|
|
||||||
<!-- 选项详情弹窗 -->
|
<!-- 选项详情弹窗 -->
|
||||||
<van-popup v-model="showOptionDetailDialog" round closeable close-icon="close" position="bottom"
|
<van-popup v-model="showOptionDetailDialog" round closeable close-icon="close" position="bottom"
|
||||||
:close-on-click-overlay="false"
|
:close-on-click-overlay="false"
|
||||||
@@ -932,6 +936,8 @@ layout("/layouts/platform_h5.html"){
|
|||||||
isSubmitting: false,
|
isSubmitting: false,
|
||||||
hasSubmittedBefore: false, // 是否之前提交过
|
hasSubmittedBefore: false, // 是否之前提交过
|
||||||
showOptionDetailDialog: false, // 选项详情弹窗
|
showOptionDetailDialog: false, // 选项详情弹窗
|
||||||
|
showOptionImagePreview: false,
|
||||||
|
optionPreviewImages: [],
|
||||||
selectedOption: null, // 当前选中的选项
|
selectedOption: null, // 当前选中的选项
|
||||||
popupHistoryOwner: "welfare-select-" + Date.now(),
|
popupHistoryOwner: "welfare-select-" + Date.now(),
|
||||||
detailScale: 1,
|
detailScale: 1,
|
||||||
@@ -1347,6 +1353,21 @@ layout("/layouts/platform_h5.html"){
|
|||||||
},
|
},
|
||||||
|
|
||||||
// 显示选项详情
|
// 显示选项详情
|
||||||
|
previewOptionImage(url) {
|
||||||
|
if (!url || this.showOptionImagePreview) return
|
||||||
|
// 图片预览不修改选项状态,返回键只关闭当前预览层。
|
||||||
|
this.$set(this, "optionPreviewImages", [url])
|
||||||
|
this.$set(this, "showOptionImagePreview", true)
|
||||||
|
window.mobilePopupHistory.open(this.popupHistoryOwner, "optionImage")
|
||||||
|
},
|
||||||
|
|
||||||
|
closeOptionImagePreview() {
|
||||||
|
if (!this.showOptionImagePreview) return
|
||||||
|
// 先隐藏,防止关闭动画期间的重复点击触发多次历史回退。
|
||||||
|
this.$set(this, "showOptionImagePreview", false)
|
||||||
|
window.mobilePopupHistory.close(this.popupHistoryOwner, "optionImage")
|
||||||
|
},
|
||||||
|
|
||||||
showOptionDetail(option) {
|
showOptionDetail(option) {
|
||||||
this.selectedOption = option
|
this.selectedOption = option
|
||||||
this.showOptionDetailDialog = true
|
this.showOptionDetailDialog = true
|
||||||
@@ -1370,6 +1391,9 @@ layout("/layouts/platform_h5.html"){
|
|||||||
},
|
},
|
||||||
|
|
||||||
closePopupFromHistory(popupId) {
|
closePopupFromHistory(popupId) {
|
||||||
|
if (popupId === "optionImage") {
|
||||||
|
this.$set(this, "showOptionImagePreview", false)
|
||||||
|
}
|
||||||
if (popupId === "optionDetail") {
|
if (popupId === "optionDetail") {
|
||||||
this.showOptionDetailDialog = false
|
this.showOptionDetailDialog = false
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user