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

This commit is contained in:
2026-10-30 16:34:12 +08:00
parent 6903d39c68
commit e01856cc2b
10 changed files with 699 additions and 95 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>
@@ -54,8 +54,8 @@ public class BrandTrainScanCodeController {
* 用户在签到地图中手动确认签到,浏览页面及获取定位本身均不写入签到记录。 * 用户在签到地图中手动确认签到,浏览页面及获取定位本身均不写入签到记录。
* *
* @param courseId 二维码携带的品牌课程ID,当前用户从服务端登录态取得 * @param courseId 二维码携带的品牌课程ID,当前用户从服务端登录态取得
* @param longitude 当前腾讯定位的经度,须与地图显示点位同为GCJ-02坐标 * @param longitude 保留兼容的GCJ-02经度,腾讯地图余额耗尽、临时停用定位校验期间可不传
* @param latitude 当前定位的纬度,与经度共同用于服务端配置半径的范围校验 * @param latitude 保留兼容的GCJ-02纬度,临时停用定位和范围校验期间可不传
* @return Result的code/msg/data;0签到成功,99业务提示,已签到时data仍保留当前状态和达标事项 * @return Result的code/msg/data;0签到成功,99业务提示,已签到时data仍保留当前状态和达标事项
*/ */
@At @At
@@ -67,9 +67,10 @@ public class BrandTrainScanCodeController {
if (StrUtil.isBlank(courseId)) { if (StrUtil.isBlank(courseId)) {
return Result.error(99, "缺少签到点位信息"); return Result.error(99, "缺少签到点位信息");
} }
if (longitude == null || latitude == null) { // 腾讯地图余额耗尽,暂不要求定位;恢复时需同步恢复前端定位及service点位范围校验。
return Result.error(99, "请先获取当前定位后再签到"); // if (longitude == null || latitude == null) {
} // return Result.error(99, "请先获取当前定位后再签到");
// }
try { try {
NutMap result = brandTrainSignUpActivityService.handleScanCodeSign(courseId, ShiroUtil.getPlatformUid(), longitude, latitude); NutMap result = brandTrainSignUpActivityService.handleScanCodeSign(courseId, ShiroUtil.getPlatformUid(), longitude, latitude);
return Result.NEW().addCode(result.getInt("code")).addMsg(result.getString("msg")).addData(result.get("data")); return Result.NEW().addCode(result.getInt("code")).addMsg(result.getString("msg")).addData(result.get("data"));
@@ -143,12 +143,13 @@ public interface BrandTrainSignUpActivityService extends ViService<BrandTrainSig
NutMap getScanCodeSignData(String courseId, String userId); NutMap getScanCodeSignData(String courseId, String userId);
/** /**
* 当前用户在地图页手动签到,校验定位后累计不重复的课程点位,并处理原有达标事项。 * 当前用户在地图页手动签到,累计不重复的课程点位,并处理原有达标事项。
* 腾讯地图余额耗尽期间临时停用定位和范围校验,恢复时需同步恢复前端、controller和service中的校验。
* *
* @param courseId 品牌课程ID,不接受普通培训课程或非扫码签到课程 * @param courseId 品牌课程ID,不接受普通培训课程或非扫码签到课程
* @param userId 当前登录用户ID,由服务端登录态取得 * @param userId 当前登录用户ID,由服务端登录态取得
* @param longitude 当前腾讯地图GCJ-02定位经度,范围为[-180,180],不能为NaN或无穷值 * @param longitude 保留兼容的GCJ-02经度,临时停用定位校验期间可不传
* @param latitude 当前腾讯地图GCJ-02定位纬度,范围为[-90,90],与课程坐标相距不得超过配置的点位半径 * @param latitude 保留兼容的GCJ-02纬度,临时停用定位和范围校验期间可不传
* @return code/msg/data结构;data含签到进度、动态表单、福利跳转及达标处理状态 * @return code/msg/data结构;data含签到进度、动态表单、福利跳转及达标处理状态
*/ */
NutMap handleScanCodeSign(String courseId, String userId, Double longitude, Double latitude); NutMap handleScanCodeSign(String courseId, String userId, Double longitude, Double latitude);
@@ -45,6 +45,7 @@ import java.util.List;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.Map; import java.util.Map;
import java.util.Collections; import java.util.Collections;
import java.util.Comparator;
import java.util.Objects; 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;
@@ -756,8 +757,8 @@ public class BrandTrainSignUpActivityServiceImpl extends ViServiceImpl<BrandTrai
public NutMap handleScanCodeSign(String courseId, String userId, Double longitude, Double latitude) { public NutMap handleScanCodeSign(String courseId, String userId, Double longitude, Double latitude) {
lockScanCodeUser(userId); lockScanCodeUser(userId);
BrandTrainSignUpCourse course = requireScanCodeCourse(courseId, userId); BrandTrainSignUpCourse course = requireScanCodeCourse(courseId, userId);
// 地点配置和用户定位均在补报名、课次写入与福利处理之前校验,校验失败不产生业务数据。 // 腾讯地图余额耗尽期间,仅当前扫码入口临时停用定位和范围校验;恢复时需同步恢复前端定位及controller经纬度校验。
requireScanCodeLocation(course.getCourseLocationCoordinates(), longitude, latitude, getSignRadius(course)); // requireScanCodeLocation(course.getCourseLocationCoordinates(), longitude, latitude, getSignRadius(course));
BrandTrainSignUpActivity activity = dao().fetch(BrandTrainSignUpActivity.class, course.getActivityId()); BrandTrainSignUpActivity activity = dao().fetch(BrandTrainSignUpActivity.class, course.getActivityId());
ensureScanCodeSignUpUser(activity, course, userId); ensureScanCodeSignUpUser(activity, course, userId);
List<BrandTrainSignUpUserCourse> records = dao().query(BrandTrainSignUpUserCourse.class, List<BrandTrainSignUpUserCourse> records = dao().query(BrandTrainSignUpUserCourse.class,
@@ -1015,11 +1016,14 @@ public class BrandTrainSignUpActivityServiceImpl extends ViServiceImpl<BrandTrai
: "FILE".equals(column.getColumnFormType()) ? new ArrayList<>() : ""); : "FILE".equals(column.getColumnFormType()) ? new ArrayList<>() : "");
dynamicColumns.add(columnMap); dynamicColumns.add(columnMap);
} }
List<NutMap> signedPoints = buildScanCodeSignedPoints(activity.getId(), attendRecords);
NutMap data = NutMap.NEW().addv("activityName", activity.getActivityName()).addv("courseName", course.getCourseName()) NutMap data = NutMap.NEW().addv("activityName", activity.getActivityName()).addv("courseName", course.getCourseName())
.addv("courseLocation", course.getCourseLocation()).addv("courseLocationCoordinates", course.getCourseLocationCoordinates()) .addv("courseLocation", course.getCourseLocation()).addv("courseLocationCoordinates", course.getCourseLocationCoordinates())
.addv("currentPointSigned", attendRecords.stream().anyMatch(item -> Objects.equals(item.getCourseId(), course.getId()))) .addv("currentPointSigned", attendRecords.stream().anyMatch(item -> Objects.equals(item.getCourseId(), course.getId())))
.addv("signRadius", getSignRadius(course)) .addv("signRadius", getSignRadius(course))
.addv("signCount", signCount).addv("signPointCount", activity.getSignPointCount()) .addv("signCount", signCount).addv("signPointCount", activity.getSignPointCount())
.addv("signedPoints", signedPoints)
.addv("signPoints", buildScanCodePoints(activity.getId(), signedPoints))
.addv("signHandleType", handleType).addv("welfareProjectId", activity.getWelfareProjectId()) .addv("signHandleType", handleType).addv("welfareProjectId", activity.getWelfareProjectId())
.addv("isReachTarget", reached).addv("dynamicColumnsData", dynamicColumns) .addv("isReachTarget", reached).addv("dynamicColumnsData", dynamicColumns)
.addv("needDynamicForm", reached && handleType == 1 && !dynamicColumns.isEmpty()) .addv("needDynamicForm", reached && handleType == 1 && !dynamicColumns.isEmpty())
@@ -1040,6 +1044,67 @@ public class BrandTrainSignUpActivityServiceImpl extends ViServiceImpl<BrandTrai
return 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) @Aop(TransAop.READ_COMMITTED)
private void completeScanCodeWelfare(String projectId, String userId, NutMap data) { private void completeScanCodeWelfare(String projectId, String userId, NutMap data) {
@@ -21,7 +21,7 @@ public class TMapService {
// 地点搜索仅访问腾讯固定服务;系统TencentMapKey可覆盖参考工程的默认Key。 // 地点搜索仅访问腾讯固定服务;系统TencentMapKey可覆盖参考工程的默认Key。
private static final String TENCENT_MAP_SUGGESTION_URL = "https://apis.map.qq.com/ws/place/v1/suggestion"; private static final String TENCENT_MAP_SUGGESTION_URL = "https://apis.map.qq.com/ws/place/v1/suggestion";
private static final String DEFAULT_TENCENT_MAP_KEY = "L5WBZ-3DDKH-KW2DC-WCDWL-Y3SUV-WNBDI"; private static final String DEFAULT_TENCENT_MAP_KEY = "NYNBZ-K62KI-DQJGU-U4NJ5-CSME7-ULFMU";
/** /**
* 读取腾讯地图地址候选,仅访问固定的地点搜索服务,不修改本地数据。 * 读取腾讯地图地址候选,仅访问固定的地点搜索服务,不修改本地数据。
@@ -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
}
}
})()
@@ -148,6 +148,24 @@ module.exports = {
event: "updateDynamicData" event: "updateDynamicData"
}, },
watch: { 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: { dynamicData: {
handler(newValue) { handler(newValue) {
this.initAddressStates(newValue) this.initAddressStates(newValue)
@@ -162,6 +180,8 @@ module.exports = {
return { return {
//所有的picker状态 //所有的picker状态
pickerStates: {}, pickerStates: {},
popupHistory: null,
imagePreview: null,
fileLists: {}, fileLists: {},
fileValueSnapshots: {}, fileValueSnapshots: {},
addressStates: {}, addressStates: {},
@@ -454,6 +474,10 @@ module.exports = {
this.$set(column, "columnValue", addressValue) this.$set(column, "columnValue", addressValue)
}, },
selectFieldClick(column) { selectFieldClick(column) {
// 普通输入框也绑定了点击事件,只有实际存在选择弹框的字段才允许打开历史层。
if (column.columnFormType !== 'SELECT') {
return
}
this.$set(this.pickerStates, column.columnCode + "Picker", true) this.$set(this.pickerStates, column.columnCode + "Picker", true)
if (!["DATE", "DATETIME"].includes(column.columnType)) { if (!["DATE", "DATETIME"].includes(column.columnType)) {
//说明是普通的下拉框 //说明是普通的下拉框
@@ -613,13 +637,37 @@ module.exports = {
return return
} }
if (item.isImage) { if (item.isImage) {
// 图片预览位于表单之上,也作为独立历史层,返回时先关预览再关表单。
if (!this.popupHistory) {
vant.ImagePreview([item.url]) 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 { } else {
window.open(item.url, '_blank') window.open(item.url, '_blank')
} }
} }
}, },
created() { 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) // console.log(this.dynamicData)
// this.dynamicData.forEach(v => { // this.dynamicData.forEach(v => {
// if (v.columnFormType === 'SELECT') { // if (v.columnFormType === 'SELECT') {
@@ -627,6 +675,14 @@ module.exports = {
// } // }
// }) // })
// console.log(this.dynamicData) // console.log(this.dynamicData)
},
beforeDestroy() {
if (this.imagePreview) {
this.imagePreview.close()
}
if (this.popupHistory) {
this.popupHistory.unregister()
}
} }
} }
</script> </script>
@@ -71,7 +71,7 @@
<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 进入后直接使用。 --> <!-- 参考工程在公共布局中静态引入腾讯地图,供活动选点及 PJAX 进入后直接使用。 -->
<script src="https://map.qq.com/api/gljs?v=2.exp&key=L5WBZ-3DDKH-KW2DC-WCDWL-Y3SUV-WNBDI"></script> <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>
@@ -7,8 +7,11 @@ layout("/mobile/platform.html"){
display: flex; display: flex;
height: 100vh; height: 100vh;
height: 100dvh; height: 100dvh;
width: 100%;
min-width: 0;
min-height: 0; min-height: 0;
flex-direction: column; flex-direction: column;
overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
background: #f4f7fc; background: #f4f7fc;
} }
@@ -20,12 +23,14 @@ layout("/mobile/platform.html"){
.brand-train-sign-content { .brand-train-sign-content {
display: flex; display: flex;
flex: 1 0 auto; flex: 1 0 auto;
min-width: 0;
flex-direction: column; flex-direction: column;
} }
.brand-train-sign-map { .brand-train-sign-map {
/* 固定 SDK 容器定位,防止地图控件溢出到信息区和底部按钮。 */ /* 固定 SDK 容器定位,防止地图控件溢出到信息区和底部按钮。 */
position: relative !important; position: relative !important;
z-index: 0;
flex: 1 0 240px; flex: 1 0 240px;
width: 100%; width: 100%;
min-height: 240px; min-height: 240px;
@@ -34,6 +39,9 @@ layout("/mobile/platform.html"){
.brand-train-sign-details { .brand-train-sign-details {
flex-shrink: 0; flex-shrink: 0;
min-width: 0;
max-width: 100%;
box-sizing: border-box;
padding: 16px; padding: 16px;
background: #fff; background: #fff;
} }
@@ -46,6 +54,24 @@ layout("/mobile/platform.html"){
overflow-wrap: anywhere; 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 { .brand-train-sign-location-tools {
display: flex; display: flex;
margin-bottom: 12px; margin-bottom: 12px;
@@ -56,6 +82,10 @@ layout("/mobile/platform.html"){
.brand-train-sign-location-tools .van-button { .brand-train-sign-location-tools .van-button {
flex-shrink: 0; flex-shrink: 0;
height: 32px;
padding: 0 2px;
border: 0;
background: transparent;
} }
.brand-train-sign-location-status { .brand-train-sign-location-status {
@@ -65,6 +95,20 @@ layout("/mobile/platform.html"){
overflow-wrap: anywhere; 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 { .brand-train-sign-text {
margin-top: 6px; margin-top: 6px;
color: #78869a; color: #78869a;
@@ -79,28 +123,218 @@ layout("/mobile/platform.html"){
} }
.brand-train-sign-progress { .brand-train-sign-progress {
display: flex; min-width: 0;
margin-top: 10px; max-width: 100%;
align-items: center; box-sizing: border-box;
flex-wrap: wrap; margin-top: 14px;
gap: 8px; padding: 12px;
border-radius: 10px;
background: #f4f7fc;
color: #78869a; color: #78869a;
font-size: 13px; font-size: 13px;
line-height: 20px; 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 { .brand-train-sign-map-warning {
color: #ee0a24; color: #ee0a24;
} }
.brand-train-sign-footer { .brand-train-sign-footer {
/* 状态留在信息区,底栏仅容纳操作;双按钮平分宽度,单按钮居中且不铺满屏幕。 */
position: sticky;
bottom: 0;
z-index: 3;
display: flex; display: flex;
flex-shrink: 0; 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; flex-direction: column;
gap: 8px; 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: 12px 16px;
padding-bottom: calc(12px + env(safe-area-inset-bottom)); padding-bottom: calc(12px + env(safe-area-inset-bottom));
border-top: 1px solid #edf0f5;
background: #fff; background: #fff;
color: #78869a;
font-size: 12px;
line-height: 18px;
text-align: center;
} }
.brand-train-sign-result { .brand-train-sign-result {
@@ -143,12 +377,13 @@ layout("/mobile/platform.html"){
<div class="brand-train-sign-details"> <div class="brand-train-sign-details">
<div class="brand-train-sign-location-tools"> <div class="brand-train-sign-location-tools">
<span class="brand-train-sign-location-status" role="status" <span class="brand-train-sign-location-status" role="status"
:class="{'brand-train-sign-map-warning': mapError || locationError || distance != null && !isInRange}"> :class="{'brand-train-sign-map-warning': mapError || locationError || distance != null && !isInRange,
'is-in-range': !mapError && !locationError && !locating && isInRange}">
<template v-if="mapError">{{mapError}}</template> <template v-if="mapError">{{mapError}}</template>
<template v-else-if="locating">正在获取您的当前位置</template> <template v-else-if="locating">正在获取您的当前位置</template>
<template v-else-if="locationError">{{locationError}}</template> <template v-else-if="locationError">{{locationError}}</template>
<template v-else-if="distance != null">距签到地点约{{distanceText}}米 · {{isInRange ? '已进入签到范围' : '尚未进入签到范围'}}</template> <template v-else-if="distance != null">{{isInRange ? '已进入签到范围' : '尚未进入签到范围'}}</template>
<template v-else>正在准备地图和定位服务</template> <template v-else>可直接点击下方按钮签到</template>
</span> </span>
<van-button v-if="mapError && siteCoordinates.length === 2" size="small" plain type="info" <van-button v-if="mapError && siteCoordinates.length === 2" size="small" plain type="info"
:loading="mapLoading" @click="initSignMap">重新加载地图</van-button> :loading="mapLoading" @click="initSignMap">重新加载地图</van-button>
@@ -156,20 +391,31 @@ layout("/mobile/platform.html"){
:loading="locating" :disabled="!mapReady || signLoading" :loading="locating" :disabled="!mapReady || signLoading"
@click="refreshPosition">重新定位</van-button> @click="refreshPosition">重新定位</van-button>
</div> </div>
<div class="brand-train-sign-title-row">
<div class="brand-train-sign-title">{{signData.courseName}}</div> <div class="brand-train-sign-title">{{signData.courseName}}</div>
<div class="brand-train-sign-text"> <span v-if="currentPointSigned" class="brand-train-sign-signed" role="status">
<van-icon name="location-o"></van-icon>{{signData.courseLocation || '未填写地点名称'}} <van-icon name="checked"></van-icon>当前点位已签到
</span>
</div> </div>
<div class="brand-train-sign-text">签到范围:{{signRadius}} 米</div> <div class="brand-train-sign-text">签到范围:{{signRadius}} 米</div>
<div v-if="locationAccuracy != null" class="brand-train-sign-text">定位精度约 {{Math.ceil(locationAccuracy)}} 米</div> <div v-if="distance != null" class="brand-train-sign-text">距签到地点约 {{distanceText}} 米</div>
<!-- 活动达标与当前点位签到分别展示,避免已达标时误认为所有点位都已签到。 --> <!-- 活动达标与当前点位签到分别展示,避免已达标时误认为所有点位都已签到。 -->
<div class="brand-train-sign-progress"> <div class="brand-train-sign-progress">
<span>已签到 {{signData.signCount}} 个点位 / 达标需 {{signData.signPointCount}} 个</span> <div class="brand-train-sign-progress-header">
<span class="brand-train-sign-progress-heading">活动签到进度</span>
<van-tag v-if="isReachTarget" plain type="success">活动已达标</van-tag> <van-tag v-if="isReachTarget" plain type="success">活动已达标</van-tag>
<van-tag v-else-if="currentPointSigned" plain type="success">当前点位已签到</van-tag> <button type="button" class="brand-train-sign-record-link" @click="openSignedPoints">
点位打卡情况<van-icon name="arrow"></van-icon>
</button>
</div>
<div>已打卡 <strong class="brand-train-sign-count">{{signData.signCount}}</strong> 个 / 达标需 {{signData.signPointCount}} 个</div>
<div class="brand-train-sign-progress-track" role="progressbar" aria-label="活动签到进度"
aria-valuemin="0" aria-valuemax="100" :aria-valuenow="signProgressPercent"
:aria-valuetext="'已打卡 ' + signData.signCount + ' 个点位,达标需 ' + signData.signPointCount + ' 个'">
<span class="brand-train-sign-progress-fill" :style="{width: signProgressPercent + '%'}"></span>
</div>
<div class="brand-train-sign-progress-message" role="status">{{signProgressMessage}}</div>
</div> </div>
<div v-if="resultMessage && resultMessage !== '当前活动已达标' && (isReachTarget || currentPointSigned || result.code !== 0)"
class="brand-train-sign-text" role="status">{{resultMessage}}</div>
</div> </div>
</template> </template>
<div v-else class="brand-train-sign-result"> <div v-else class="brand-train-sign-result">
@@ -178,19 +424,45 @@ layout("/mobile/platform.html"){
<div class="brand-train-sign-title">{{isReachTarget ? '当前活动已达标' : currentPointSigned ? '当前点位已签到' : '签到提示'}}</div> <div class="brand-train-sign-title">{{isReachTarget ? '当前活动已达标' : currentPointSigned ? '当前点位已签到' : '签到提示'}}</div>
<div class="brand-train-sign-text">{{resultMessage}}</div> <div class="brand-train-sign-text">{{resultMessage}}</div>
</div> </div>
<div v-if="hasSignProgress || signData.needDynamicForm || signData.needWelfareSelect" class="brand-train-sign-footer"> <div v-if="showSignButton || showDynamicFormButton || signData.needWelfareSelect" class="brand-train-sign-footer">
<van-button v-if="hasSignProgress" block round type="info" loading-text="签到中..." <van-button v-if="showDynamicFormButton" :plain="showSignButton" type="info"
:loading="signLoading" :disabled="signDisabled" @click="submitSign"> :disabled="signLoading || formLoading" @click="openDynamicForm">填写详细信息</van-button>
{{currentPointSigned ? '当前点位已签到' : '确认签到'}} <van-button v-if="signData.needWelfareSelect" :plain="showSignButton" type="info"
</van-button> :disabled="signLoading || formLoading" @click="goWelfareSelect">去选择福利</van-button>
<van-button v-if="signData.needDynamicForm" block round type="info" <van-button v-if="showSignButton" type="info" loading-text="签到中..."
@click="openDynamicForm">填写详细信息</van-button> :loading="signLoading" :disabled="signDisabled" @click="submitSign">确认签到</van-button>
<van-button v-if="signData.needWelfareSelect" block round type="info"
@click="goWelfareSelect">去选择福利</van-button>
</div> </div>
</div> </div>
<van-popup v-model="dynamicFormPopup" class="brand-train-sign-form-popup" position="right"> <van-popup :value="signedPointsPopup" class="brand-train-sign-points-popup" position="right"
@input="(visible) => syncSignPopup('signedPointsPopup', visible)">
<van-nav-bar title="点位打卡情况" left-text="返回" left-arrow @click-left="closeSignedPoints"></van-nav-bar>
<div ref="signPointsList" class="brand-train-sign-points-list">
<van-empty v-if="!displaySignPoints.length" description="暂无点位信息"></van-empty>
<div v-for="point in displaySignPoints" :key="point.courseId" class="brand-train-sign-point">
<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 class="brand-train-sign-points-note">
{{isReachTarget ? '活动已达标,仍可继续打卡其他点位' : '完成 ' + signData.signPointCount + ' 个不同点位打卡即可达标'}}
</div>
</van-popup>
<van-popup :value="dynamicFormPopup" class="brand-train-sign-form-popup" position="right"
:close-on-click-overlay="!formLoading" @input="(visible) => syncSignPopup('dynamicFormPopup', visible)">
<van-nav-bar title="填写详细信息" left-text="返回" left-arrow fixed placeholder <van-nav-bar title="填写详细信息" left-text="返回" left-arrow fixed placeholder
@click-left="closeDynamicForm"></van-nav-bar> @click-left="closeDynamicForm"></van-nav-bar>
<div class="brand-train-sign-form-content"> <div class="brand-train-sign-form-content">
@@ -206,6 +478,7 @@ layout("/mobile/platform.html"){
</div> </div>
<script src="${base!}/assets/platform/plugins/axios/axios.js"></script> <script src="${base!}/assets/platform/plugins/axios/axios.js"></script>
<script src="${base!}/assets/mobile/js/popupHistory.js"></script>
<script> <script>
<!--#include("/mobile/welfare/include/areaList.js"){}#--> <!--#include("/mobile/welfare/include/areaList.js"){}#-->
window.brandTrainSignUpAreaList = areaList window.brandTrainSignUpAreaList = areaList
@@ -230,7 +503,7 @@ layout("/mobile/platform.html"){
Vue.prototype.$moment = moment Vue.prototype.$moment = moment
// 腾讯地图与定位组件仅在本页加载;定位和坐标转换统一由腾讯组件处理,实例不进入 Vue 响应式数据。 // 腾讯地图与定位组件仅在本页加载;定位和坐标转换统一由腾讯组件处理,实例不进入 Vue 响应式数据。
const brandTrainTencentKey = 'L5WBZ-3DDKH-KW2DC-WCDWL-Y3SUV-WNBDI' const brandTrainTencentKey = 'NYNBZ-K62KI-DQJGU-U4NJ5-CSME7-ULFMU'
const brandTrainTencentLoads = {} const brandTrainTencentLoads = {}
let brandTrainMap = null let brandTrainMap = null
let brandTrainGeolocation = null let brandTrainGeolocation = null
@@ -240,6 +513,7 @@ layout("/mobile/platform.html"){
let brandTrainLocationTimer = null let brandTrainLocationTimer = null
let brandTrainLocationRequestId = 0 let brandTrainLocationRequestId = 0
let brandTrainMapDestroyed = false let brandTrainMapDestroyed = false
let brandTrainPopupHistory = null
new Vue({ new Vue({
el: '#app', el: '#app',
@@ -259,9 +533,9 @@ layout("/mobile/platform.html"){
mapError: '', mapError: '',
locationError: '', locationError: '',
currentPosition: [], currentPosition: [],
locationAccuracy: null,
distance: null, distance: null,
dynamicFormPopup: false, dynamicFormPopup: false,
signedPointsPopup: false,
dynamicColumnsData: [] dynamicColumnsData: []
} }
}, },
@@ -278,7 +552,46 @@ layout("/mobile/platform.html"){
currentPointSigned() { currentPointSigned() {
return this.signData.currentPointSigned === true return this.signData.currentPointSigned === true
}, },
// 坐标按经度、纬度排列;缺失或超出有效范围时不创建地图,也不允许提交签到。 // 达标不影响新点位签到;已签点位仅展示状态,不再占用底部按钮位置。
showSignButton() {
return this.hasSignProgress && !this.currentPointSigned
},
showDynamicFormButton() {
return this.isReachTarget && this.signData.needDynamicForm === true
},
signedPoints() {
return Array.isArray(this.signData.signedPoints) ? this.signData.signedPoints : []
},
// 全部点位由活动课程配置提供,已报名但未签到的课程仍属于未打卡。
allSignPoints() {
return Array.isArray(this.signData.signPoints) ? this.signData.signPoints : []
},
displaySignPoints() {
const signed = this.allSignPoints.filter((point) => point.signed === true)
const unsigned = this.allSignPoints.filter((point) => point.signed !== true)
// 始终展示全部点位,仅将未打卡项排在前面;组内保持接口顺序,不修改原数组。
return unsigned.concat(signed)
},
// 仅进度条封顶100%,实际点位计数保留超过达标数量的值。
signProgressPercent() {
const target = Number(this.signData.signPointCount)
const count = Number(this.signData.signCount)
return target > 0 && Number.isFinite(count) ? Math.min(100, Math.max(0, count / target * 100)) : 0
},
signProgressMessage() {
// 福利失败等服务端提示优先展示,避免美化布局后掩盖业务错误。
if (this.signData.handleStatus === 'FAILED' || this.result.code !== 0 && this.result.code !== 99) {
return this.resultMessage
}
if (this.isReachTarget && !this.currentPointSigned) {
return '已达标,可继续打卡其他点位。'
}
if (this.isReachTarget) {
return this.resultMessage === '当前活动已达标' ? '当前活动已达标,可继续打卡其他点位。' : this.resultMessage
}
return this.currentPointSigned ? '请继续完成其他点位签到。' : '到达点位后,点击确认签到。'
},
// 坐标按经度、纬度排列;缺失或超出有效范围时不创建地图,临时取消定位限制期间不影响签到。
siteCoordinates() { siteCoordinates() {
const coordinates = this.signData.courseLocationCoordinates const coordinates = this.signData.courseLocationCoordinates
if (!Array.isArray(coordinates) || coordinates.length !== 2 if (!Array.isArray(coordinates) || coordinates.length !== 2
@@ -300,15 +613,17 @@ layout("/mobile/platform.html"){
return this.distance != null && this.distance <= this.signRadius return this.distance != null && this.distance <= this.signRadius
}, },
signDisabled() { signDisabled() {
return this.currentPointSigned || this.signLoading || this.locating || !this.mapReady // 腾讯定位服务余额耗尽,临时取消地图、定位和范围限制;保留已签到及提交中的防重复判断。
|| this.siteCoordinates.length !== 2 || this.currentPosition.length !== 2 || !this.isInRange return this.currentPointSigned || this.signLoading || this.formLoading
// || this.locating || !this.mapReady
// || this.siteCoordinates.length !== 2 || this.currentPosition.length !== 2 || !this.isInRange
}, },
resultMessage() { resultMessage() {
if (this.isReachTarget) { if (this.isReachTarget) {
return this.signData.handleMsg || '当前活动已达标' return this.signData.handleMsg || '当前活动已达标'
} }
if (this.hasSignProgress && this.result.code === 0) { if (this.hasSignProgress && this.result.code === 0) {
return this.currentPointSigned ? '请继续完成其他点位签到。' : '请在签到范围内点击下方按钮完成签到。' return this.currentPointSigned ? '请继续完成其他点位签到。' : '请点击下方按钮完成签到。'
} }
return this.result.msg || '' return this.result.msg || ''
} }
@@ -316,15 +631,45 @@ layout("/mobile/platform.html"){
methods: { methods: {
// 沿用浏览器返回;从二维码直接进入且没有上一页时,回到当前系统手机首页。 // 沿用浏览器返回;从二维码直接进入且没有上一页时,回到当前系统手机首页。
historyBack() { historyBack() {
if (window.mobilePopupHistory.back()) {
return
}
if (window.history.length > 1) { if (window.history.length > 1) {
window.history.back() window.history.back()
return return
} }
window.location.replace('/mobile/index') window.location.replace('/mobile/index')
}, },
/** key 为当前页注册的弹框字段名,visible 为 Vant 关闭事件;历史回退后统一通过 $set 同步。 */
syncSignPopup(key, visible) {
if (!visible) {
brandTrainPopupHistory.close(key)
}
},
openSignedPoints() {
if (!brandTrainPopupHistory.open('signedPointsPopup')) {
return
}
// 每次打开从首个点位展示,滚动位置不写入浏览器历史栈。
this.$nextTick(() => {
if (this.$refs.signPointsList) {
this.$refs.signPointsList.scrollTop = 0
}
})
},
closeSignedPoints() {
brandTrainPopupHistory.close('signedPointsPopup')
},
/** attendTime 为服务端的首次签到时间;返回本地展示字符串,历史缺失或非法时间显示“未记录”。 */
formatAttendTime(attendTime) {
const time = attendTime ? moment(attendTime) : null
return time && time.isValid() ? time.format('YYYY-MM-DD HH:mm:ss') : '未记录'
},
/** /**
* courseId 为二维码中的课程点位 ID;本接口只预览,不签到、不补报名,也不写入福利名单。 * courseId 为二维码中的课程点位 ID;本接口只预览,不签到、不补报名,也不写入福利名单。
* 返回 Result.data,包含进度、地点坐标 [经度, 纬度]、currentPointSigned、signRadius 及达标后的操作配置。 * 返回 Result.data,包含进度、地点坐标 [经度, 纬度]、currentPointSigned、signRadius 及达标后的操作配置。
* signedPoints 为当前用户已打卡点位数组,每项包含 courseId、courseName、courseLocation、attendTime。
* signPoints 为全部点位数组,在上述字段基础上增加 signed 布尔值;未打卡点位 attendTime 为空。
*/ */
getSignData() { getSignData() {
if (!this.courseId) { if (!this.courseId) {
@@ -547,7 +892,7 @@ layout("/mobile/platform.html"){
unsupported: '当前环境不支持此定位途径,请更换浏览器或在微信中重试。', unsupported: '当前环境不支持此定位途径,请更换浏览器或在微信中重试。',
sensor: '设备定位传感器未开启,请开启设备定位后重试。', sensor: '设备定位传感器未开启,请开启设备定位后重试。',
geocoder: '腾讯逆地址解析服务异常(10601),请联系管理员检查该 Key 的服务权限、额度及访问限制。', geocoder: '腾讯逆地址解析服务异常(10601),请联系管理员检查该 Key 的服务权限、额度及访问限制。',
conversion: '腾讯坐标转换服务异常(10602),请联系管理员检查该 Key 的服务权限、额度及访问限制。' // conversion: '腾讯坐标转换服务异常(10602),请联系管理员检查该 Key 的服务权限、额度及访问限制。'
} }
let category = categories[status] let category = categories[status]
if (status === 10001 || status === 0) { if (status === 10001 || status === 0) {
@@ -557,6 +902,8 @@ layout("/mobile/platform.html"){
} }
} }
const error = new Error(messages[category] || '腾讯定位未成功,请重新定位;若持续失败,请联系管理员查看定位诊断。') const error = new Error(messages[category] || '腾讯定位未成功,请重新定位;若持续失败,请联系管理员查看定位诊断。')
// 临时取消定位限制期间,坐标转换失败仅保留诊断,不在页面或 Toast 中提示。
error.silent = category === 'conversion'
error.diagnostic = { error.diagnostic = {
status: status, status: status,
message: typeof source.message === 'string' ? source.message : '', message: typeof source.message === 'string' ? source.message : '',
@@ -579,7 +926,6 @@ layout("/mobile/platform.html"){
this.$set(this, 'locating', true) this.$set(this, 'locating', true)
this.$set(this, 'locationError', '') this.$set(this, 'locationError', '')
this.$set(this, 'currentPosition', []) this.$set(this, 'currentPosition', [])
this.$set(this, 'locationAccuracy', null)
this.$set(this, 'distance', null) this.$set(this, 'distance', null)
if (brandTrainCurrentMarker) { if (brandTrainCurrentMarker) {
brandTrainCurrentMarker.setGeometries([]) brandTrainCurrentMarker.setGeometries([])
@@ -615,9 +961,6 @@ layout("/mobile/platform.html"){
} }
this.$set(this, 'currentPosition', coordinates) this.$set(this, 'currentPosition', coordinates)
this.$set(this, 'distance', distance) this.$set(this, 'distance', distance)
// 精度仅供用户判断设备定位质量,不抵扣距离,也不放宽管理员配置的签到半径。
this.$set(this, 'locationAccuracy', Number.isFinite(res.accuracy) && res.accuracy >= 0
? res.accuracy : null)
brandTrainCurrentMarker.setGeometries([{ brandTrainCurrentMarker.setGeometries([{
id: 'current-user', id: 'current-user',
styleId: 'current', styleId: 'current',
@@ -648,9 +991,8 @@ layout("/mobile/platform.html"){
} }
}).catch((err) => { }).catch((err) => {
if (!brandTrainMapDestroyed) { if (!brandTrainMapDestroyed) {
this.$set(this, 'locationError', err.message || '定位失败,请重新定位。') this.$set(this, 'locationError', err.silent ? '' : err.message || '定位失败,请重新定位。')
this.$set(this, 'currentPosition', []) this.$set(this, 'currentPosition', [])
this.$set(this, 'locationAccuracy', null)
this.$set(this, 'distance', null) this.$set(this, 'distance', null)
if (brandTrainCurrentMarker) { if (brandTrainCurrentMarker) {
brandTrainCurrentMarker.setGeometries([]) brandTrainCurrentMarker.setGeometries([])
@@ -688,36 +1030,38 @@ layout("/mobile/platform.html"){
return return
} }
return this.locatePosition().catch((err) => { return this.locatePosition().catch((err) => {
if (!brandTrainMapDestroyed) { if (!brandTrainMapDestroyed && !err.silent) {
this.$toast(err.message || '定位失败,请重新定位。') this.$toast(err.message || '定位失败,请重新定位。')
} }
}) })
}, },
/** /**
* 点击后重新定位并提交 courseId、longitude(经度)、latitude(纬度);不能复用打开页面时的旧位置。 * 点击后提交 courseId(当前二维码的课程点位 ID);临时取消定位限制期间不传 longitude、latitude。
* 返回 Result,code=0 表示签到成功、99 为业务提示,data 沿用预览结构;服务端再次校验配置半径。 * 返回 Result,code=0 表示签到成功、99 为业务提示,data 沿用预览结构,包含签到状态与活动进度。
*/ */
submitSign() { submitSign() {
if (this.signDisabled) { if (this.signDisabled) {
return return
} }
this.$set(this, 'signLoading', true) this.$set(this, 'signLoading', true)
return this.locatePosition().then((coordinates) => { // 腾讯定位服务余额耗尽,临时跳过提交前重新定位和距离判断;恢复时需同步恢复后端校验。
if (!this.isInRange) { // return this.locatePosition().then((coordinates) => {
throw new Error('您距签到地点约' + this.distanceText + '米,请在' + this.signRadius + '米范围内签到。') // if (!this.isInRange) {
} // throw new Error('您距签到地点约' + this.distanceText + '米,请在' + this.signRadius + '米范围内签到。')
// }
return this.$axios.post('/platform/mobile/brandTrain/scanCode/doSign', { return this.$axios.post('/platform/mobile/brandTrain/scanCode/doSign', {
courseId: this.courseId, courseId: this.courseId,
longitude: coordinates[0], // longitude: coordinates[0],
latitude: coordinates[1] // latitude: coordinates[1]
}).then((res) => { }).then((res) => {
// 业务失败且未返回预览数据时保留地图和现有结果,用户可重新定位后重试。 // 业务失败且未返回预览数据时保留地图和现有结果,用户可再次提交重试。
if (res.data) { if (res.data) {
this.$set(this, 'result', res) this.$set(this, 'result', res)
} }
this.$toast(res.msg || (res.code === 0 ? '签到成功' : '签到失败,请稍后重试')) this.$toast(res.msg || (res.code === 0 ? '签到成功' : '签到失败,请稍后重试'))
}) })
}).catch((err) => { // }) // 恢复定位时,与上方 locatePosition 回调及经纬度参数一起解除注释。
.catch((err) => {
if (!brandTrainMapDestroyed) { if (!brandTrainMapDestroyed) {
this.$toast(err.msg || err.message || '签到失败,请稍后重试') this.$toast(err.msg || err.message || '签到失败,请稍后重试')
} }
@@ -729,17 +1073,19 @@ layout("/mobile/platform.html"){
}, },
// 使用字段副本编辑,取消填写时保留后端返回的原始字段和已保存内容。 // 使用字段副本编辑,取消填写时保留后端返回的原始字段和已保存内容。
openDynamicForm() { openDynamicForm() {
if (!this.showDynamicFormButton || this.signLoading || this.formLoading) {
return
}
if (!Array.isArray(this.signData.dynamicColumnsData) || this.signData.dynamicColumnsData.length === 0) { if (!Array.isArray(this.signData.dynamicColumnsData) || this.signData.dynamicColumnsData.length === 0) {
this.$toast('当前课程未配置动态表单') this.$toast('当前课程未配置动态表单')
return return
} }
this.$set(this, 'dynamicColumnsData', JSON.parse(JSON.stringify(this.signData.dynamicColumnsData))) this.$set(this, 'dynamicColumnsData', JSON.parse(JSON.stringify(this.signData.dynamicColumnsData)))
this.$set(this, 'dynamicFormPopup', true) brandTrainPopupHistory.open('dynamicFormPopup')
}, },
closeDynamicForm() { closeDynamicForm() {
if (!this.formLoading) { // NavBar 与系统返回一致:只关闭最上层;保存未完成时由 formLoading 禁止重新发起操作。
this.$set(this, 'dynamicFormPopup', false) window.mobilePopupHistory.back()
}
}, },
/** /**
* 提交当前 courseId 及 mobileColumnsValue 字段值 JSON 数组;字段沿用现有动态表单的名称、编码和控件类型。 * 提交当前 courseId 及 mobileColumnsValue 字段值 JSON 数组;字段沿用现有动态表单的名称、编码和控件类型。
@@ -773,8 +1119,8 @@ layout("/mobile/platform.html"){
this.$set(this.signData, 'dynamicColumnsData', JSON.parse(JSON.stringify(this.dynamicColumnsData))) this.$set(this.signData, 'dynamicColumnsData', JSON.parse(JSON.stringify(this.dynamicColumnsData)))
this.$set(this.signData, 'needDynamicForm', false) this.$set(this.signData, 'needDynamicForm', false)
this.$set(this.signData, 'handleMsg', '当前活动已达标,详细信息已保存') this.$set(this.signData, 'handleMsg', '当前活动已达标,详细信息已保存')
this.$set(this, 'dynamicFormPopup', false) // 等待弹框及其子选择器的历史全部退出后再结束提交,返回时不残留已关闭表单。
this.$toast('保存成功') return brandTrainPopupHistory.clear().then(() => this.$toast('保存成功'))
} else { } else {
this.$toast(res.msg || '保存失败,请稍后重试') this.$toast(res.msg || '保存失败,请稍后重试')
} }
@@ -794,14 +1140,21 @@ layout("/mobile/platform.html"){
this.$toast('当前活动未配置福利跳转地址') this.$toast('当前活动未配置福利跳转地址')
return return
} }
return brandTrainPopupHistory.clear().then(() => {
window.location.href = this.signData.welfareJumpUrl window.location.href = this.signData.welfareJumpUrl
})
} }
}, },
created() { created() {
// 页面和表单子组件共用全局历史栈,页面销毁时注销本页回调。
brandTrainPopupHistory = window.mobilePopupHistory.register('brand-train-sign-' + this._uid, (key, visible) => {
this.$set(this, key, visible)
})
this.$set(this, 'courseId', GetQueryString('courseId') || '') this.$set(this, 'courseId', GetQueryString('courseId') || '')
this.getSignData() this.getSignData()
}, },
beforeDestroy() { beforeDestroy() {
brandTrainPopupHistory.unregister()
brandTrainMapDestroyed = true brandTrainMapDestroyed = true
brandTrainLocationRequestId += 1 brandTrainLocationRequestId += 1
window.clearTimeout(brandTrainLocationTimer) window.clearTimeout(brandTrainLocationTimer)
@@ -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,6 +213,8 @@ layout("/layouts/platform.html"){
return { return {
url: '', url: '',
courseDialog: false, courseDialog: false,
courseCodeDialog: false,
courseCodeImage: '',
trainType: '', trainType: '',
trainTypeList: [], trainTypeList: [],
welfareProjectList: [], welfareProjectList: [],
@@ -254,11 +266,13 @@ layout("/layouts/platform.html"){
const qrText = this.isSignExtensionEnabled(this.clickRow) const qrText = this.isSignExtensionEnabled(this.clickRow)
? window.location.origin + '/platform/mobile/brandTrain/scanCode?courseId=' + encodeURIComponent(row.id) ? window.location.origin + '/platform/mobile/brandTrain/scanCode?courseId=' + encodeURIComponent(row.id)
: JSON.stringify(o) : JSON.stringify(o)
const content = jrQrcode.getQrBase64(qrText) // 每次先更新当前课程的二维码图片,再打开弹框,避免切换课程时显示上一张二维码。
let image = new Image() this.$set(this, 'courseCodeImage', jrQrcode.getQrBase64(qrText))
image.src = content this.$set(this, 'courseCodeDialog', true)
let viewer = new Viewer(image) },
viewer.show() // 关闭二维码时保留课程列表,便于继续生成其他课程的签到二维码。
closeCourseCode() {
this.$set(this, 'courseCodeDialog', false)
}, },
// 只有全部课程签到且配置了正整数点位数的活动,才启用手机端扩展流程。 // 只有全部课程签到且配置了正整数点位数的活动,才启用手机端扩展流程。
isSignExtensionEnabled(activity) { isSignExtensionEnabled(activity) {